프록시 장애를 재현할 때 가장 곤란한 질문은 이것이다.

정말 프록시를 탄 게 맞아?

환경변수에 HTTPS_PROXY를 넣었다. kubectl도 실패했다. 얼핏 보면 답은 끝난 것 같다.

하지만 실패했다는 사실만으로는 부족하다.

DNS가 틀렸을 수도 있다. TLS 인증서가 맞지 않을 수도 있다. Kubernetes 토큰이 만료됐을 수도 있다. 무엇보다 테스트 대상이 localhost라면, 클라이언트가 친절하게 프록시를 건너뛰었을 수도 있다.

이번에는 이 모호함을 없애기 위해 KAC의 kubectl 경로를 작은 실험실에 넣었다.

이 글에서 말하는 Proxy 실험은 OS 전역 System Proxy나 PAC가 아니라, kubectl 프로세스가 읽는 Proxy 환경변수를 대상으로 한다.

실험 칸은 딱 세 개였다.

1번 칸: 프록시 없음
2번 칸: 프록시 있음, NO_PROXY 없음
3번 칸: 프록시 있음, 대상만 NO_PROXY에 추가

결과는 아주 선명했다.

성공 → HTTP 407 실패 → 다시 성공

그런데 이 단순한 세 칸을 제대로 만들려면 localhost 자동 bypass라는 숨은 변수부터 통제해야 했다.

먼저 KAC의 두 흐름을 나눠야 했다

KAC는 Kubernetes 접근 권한과 역할을 통제하고 기록하는 영역이다. exec-plugin kubeconfig로 KAC를 사용할 때는 두 흐름을 구분해야 한다.

A. exec-plugin kubeconfig가 임시 자격 증명을 받는 흐름
   kubectl → exec plugin/로컬 에이전트 → KAC 토큰 발급

B. 발급된 자격 증명으로 Kubernetes API를 호출하는 흐름
   kubectl → KAC Proxy:6443 → Kubernetes API Server

A가 성공했다고 B도 성공하는 것은 아니다.

토큰을 잘 받았어도 6443 통신이 회사의 Web Proxy에 빨려 들어가면 실패할 수 있다. 반대로 6443 네트워크가 열려 있어도 토큰이 만료됐다면 인증에서 실패한다.

그래서 실험을 시작하기 전에 먼저 Direct baseline을 통과시켰다.

KUBECONFIG=<test-kubeconfig> \
kubectl get nodes -o wide --request-timeout=5s

이 명령이 성공해야 그다음 Proxy 실패를 믿을 수 있다.

Direct부터 실패한다면 아직 Proxy 실험을 시작할 때가 아니다. 토큰, 역할, kubeconfig, TLS, 대상 서버 상태부터 확인해야 한다.

좋은 장애 재현은 실패를 만드는 기술이 아니라, 다른 실패를 먼저 치우는 기술에 가깝다.

첫 번째 함정: localhost는 프록시를 몰래 건너뛴다

로컬에서 KAC Proxy를 테스트하려고 127.0.0.1:6443을 사용하면 편하다.

그런데 너무 편해서 문제가 된다.

Go를 비롯한 일부 HTTP 런타임은 localhost와 loopback IP를 특별 취급한다. HTTPS_PROXY가 설정되어 있어도 “이건 내 컴퓨터 안의 주소잖아”라며 자동으로 직접 연결할 수 있다.

그러면 이런 이상한 실험이 된다.

테스터의 생각:
  kubectl → 테스트 Proxy → KAC Proxy

실제 경로:
  kubectl ─────────────→ KAC Proxy

환경변수에는 분명 Proxy가 있다. 명령도 성공하거나 실패한다. 하지만 우리가 검사하려던 중간 경로는 아예 타지 않았다.

이 문제를 피하려고 loopback IP에 이름표를 붙였다.

kac-proxy.localtest.me → 127.0.0.1

localtest.me 계열 이름은 loopback으로 해석되므로 로컬 서버에 도달할 수 있다. 동시에 URL에는 literal loopback IP가 아니라 hostname이 보인다. 이름 확인에는 시스템 DNS가 사용될 수 있지만, 해석된 뒤의 HTTPS/6443 연결은 loopback으로 향한다.

이번 로컬 실험에서는 이 alias가 기존 인증서 SAN에 없었기 때문에 시나리오 전용 kubeconfig에서만 TLS 서버 신원 검증을 비활성화했다. 따라서 이 성공 결과는 Proxy routing을 검증한 것이며, 해당 hostname의 운영 TLS 구성이 정상임을 증명하지는 않는다. 운영 kubeconfig나 고객 설정에서 TLS 검증을 끄는 것은 해결책이 아니다.

두 번째 장치: 일부러 407을 반환하는 아주 작은 Proxy

이제 “프록시를 탔다”는 증거가 필요하다.

그래서 정상 전달을 하는 거대한 Proxy 대신, 아주 작고 고집 센 테스트 Proxy를 만들었다.

이 Proxy가 하는 일은 세 가지뿐이다.

1. 임시 포트에서 기다린다.
2. CONNECT의 method와 authority만 기록한다.
3. HTTP 407 Proxy Authentication Required를 반환하고 연결을 닫는다.

HTTPS를 일반 HTTP Proxy로 보낼 때 클라이언트는 먼저 이런 요청을 보낸다.

CONNECT kac-proxy.localtest.me:6443 HTTP/1.1

뜻은 간단하다.

프록시야,
kac-proxy.localtest.me의 6443 포트까지
암호화 터널을 하나 열어줘.

응답 형태를 단순화하면 다음과 같다.

HTTP/1.1 407 Proxy Authentication Required
Proxy-Authenticate: Basic realm="<test-realm>"
Content-Length: 0
Connection: close

실제 계정이나 비밀번호는 쓰지 않는다. 목적은 인증 성공이 아니라, 요청이 정말 Proxy까지 왔는지 확인하는 것이다.

그리고 기록도 최소화했다.

{
  "method": "CONNECT",
  "authority": "kac-proxy.localtest.me:6443"
}

전체 Header, Proxy-Authorization, Kubernetes Authorization, Cookie, token은 저장하지 않는다.

장애 증거는 많이 모으는 것보다 필요한 것만 안전하게 모으는 편이 낫다.

세 번째 장치: 내 맥의 Proxy 설정은 건드리지 않는다

실험 때문에 운영 중인 브라우저나 다른 터미널까지 영향을 받으면 안 된다.

그래서 macOS의 System Proxy나 셸 전역 환경변수는 변경하지 않았다. 각 kubectl subprocess를 실행할 때만 Proxy 환경을 새로 만들었다.

통제한 변수는 모두 여덟 개다.

HTTP_PROXY   http_proxy
HTTPS_PROXY  https_proxy
ALL_PROXY    all_proxy
NO_PROXY     no_proxy

왜 대문자와 소문자를 모두 다룰까?

도구마다 읽는 방식이 다르기 때문이다. 어떤 클라이언트는 대문자를 보고, 어떤 라이브러리는 소문자를 우선한다. 둘 중 하나만 바꾸면 부모 셸에서 물려받은 값이 슬쩍 살아남을 수 있다.

각 테스트는 먼저 여덟 변수를 모두 제거한 다음, 그 칸에 필요한 값만 넣었다.

Direct:
  Proxy 관련 변수 없음

Forced Proxy:
  HTTPS_PROXY=http://127.0.0.1:<ephemeral-port>
  https_proxy=http://127.0.0.1:<ephemeral-port>
  NO_PROXY=
  no_proxy=

NO_PROXY bypass:
  HTTPS_PROXY=http://127.0.0.1:<same-port>
  https_proxy=http://127.0.0.1:<same-port>
  NO_PROXY=kac-proxy.localtest.me
  no_proxy=kac-proxy.localtest.me

핵심은 2번과 3번 칸이 같은 Proxy를 쓴다는 점이다.

달라진 것은 대상 hostname이 NO_PROXY에 들어갔는지뿐이다.

세 칸의 결과가 완성됐다

실제 실행 명령은 세 칸 모두 같았다.

kubectl get nodes -o wide -v=8 --request-timeout=5s

-v=8은 kubectl의 상세 통신 로그를 보여준다. 이 실험에서는 요청 URL, 응답 상태, 오류가 어느 구간에서 발생했는지 확인하는 데 사용했다. kubectl 버전에 따라 인증 Header가 마스킹되더라도, 상세 로그에는 요청 URL·응답 Header·응답 본문·리소스 식별자가 포함될 수 있다. 저장이나 공유 전에는 token뿐 아니라 환경 식별 정보와 응답 데이터까지 검토해야 한다.

결과는 다음과 같았다.

조건 kubectl 테스트 Proxy의 CONNECT 판정
Proxy 없음 성공 0건 Direct baseline 정상
Proxy 있음, NO_PROXY 없음 실패 대상 6443 1건 HTTP 407, Proxy 경유 확인
같은 Proxy, 대상 in NO_PROXY 성공 0건 정확한 hostname bypass 확인

이 표가 좋은 이유는 성공과 실패를 함께 설명하기 때문이다.

2번 칸만 보면 “Proxy가 문제 같음” 정도다.

하지만 세 칸을 한꺼번에 보면 더 강한 문장이 된다.

같은 kubectl, 같은 kubeconfig, 같은 KAC endpoint에서
Proxy를 강제하면 CONNECT 407로 실패하고,
동일한 Proxy를 유지한 채 대상 hostname만 NO_PROXY에 추가하면
Proxy 연결 없이 다시 성공했다.

이건 단순한 상관관계보다 훨씬 좋은 증거다.

HTTP 407만 재현한 것은 아니다

같은 세 칸 실험에서 fixture의 동작만 바꿔 다음 조건도 확인했다.

테스트 Proxy 동작 기대 분류
HTTP 407 반환 Proxy 인증 필요
HTTP 403 반환 Proxy CONNECT 거부
연결 즉시 종료 연결 조기 종료
응답하지 않음 Proxy timeout

timeout과 403은 목적지 서버에서도 발생할 수 있다. 그래서 오류 문자열 하나만으로 Proxy 문제라고 단정하지 않고 두 증거를 함께 봤다.

클라이언트가 본 오류
+
테스트 Proxy가 실제로 관찰한 CONNECT

CONNECT가 관찰됐고 그 연결에 의도한 fault를 주입했을 때만 구체적인 Proxy failure class를 붙였다.

운영 해결책은 Proxy 전체 해제가 아니다

실험이 끝나면 흔히 이런 결론으로 달려가기 쉽다.

unset HTTPS_PROXY

물론 장애는 사라질 수 있다.

하지만 조직에서 Proxy를 쓰는 이유가 있다. 외부 통신 통제, 감사, 보안 정책, 네트워크 경계가 그 이유일 수 있다. 전체 Proxy를 꺼버리는 것은 진단에는 유용해도 운영 해법으로는 너무 크다.

다음 방식은 해당 클라이언트가 HTTPS_PROXYNO_PROXY를 읽는 경우에 적용된다. OS System Proxy, PAC, 서비스 설정, Java 전용 Proxy 문법은 별도로 확인해야 한다.

그 조건에서는 기존 값을 유지하면서 꼭 필요한 hostname만 bypass 목록에 추가하는 편이 낫다.

export HTTPS_PROXY="<existing-proxy-url>"
export NO_PROXY="${NO_PROXY:+${NO_PROXY},}<querypie-web-fqdn>,<kac-proxy-fqdn>"
export no_proxy="$NO_PROXY"

이번 kubectl/Go 경로에서는 scheme·path·port를 뺀 정확한 hostname이 동작했다. 다른 런타임에서는 suffix, port, CIDR 지원과 대·소문자 변수 우선순위를 별도 A/B 테스트해야 한다.

좋은 예:
  kac-proxy.example.com

피해야 할 예:
  https://kac-proxy.example.com:6443/api

기존 NO_PROXY 값을 통째로 덮어쓰지 않는 것도 중요하다. 이미 등록된 사내 API, metadata endpoint, localhost 예외가 사라지면 Proxy 문제 하나를 고치다가 다른 장애를 만들 수 있다.

NO_PROXY=*처럼 모든 대상을 우회하는 설정은 실험 결과를 무의미하게 만들고 운영 통제도 약하게 한다.

최소한의 정확한 FQDN만 추가하는 편이 안전하다.

이 실험이 KAC 전용으로 끝나지 않은 이유

환경 격리와 CONNECT fixture는 일반 HTTPS client에도 재사용할 수 있다. 별도의 통제된 단위 테스트에서는 Python HTTPS client가 api.example.test:443으로 보내는 CONNECT도 같은 fixture로 관찰했다.

다만 HTTPS_PROXY를 사용하지 않는 MySQL·PostgreSQL·SSH 같은 native protocol에는 별도 adapter가 필요하다. 반복되는 실험 장치만 공유하고, 제품과 프로토콜의 의미는 각 adapter에 남겨야 한다.

가장 크게 배운 것

가장 중요한 발견은 literal loopback 주소가 Proxy 실험의 대조군을 무너뜨릴 수 있다는 점이었다. 가장 결정적인 증거도 긴 로그가 아니라 다음 한 줄이었다.

CONNECT kac-proxy.localtest.me:6443

프록시 장애를 재현할 때는 환경변수를 설정했다는 사실보다, 실제 경로가 어떻게 흘렀는지를 증명해야 한다.

그래서 다음번에는 이렇게 시작하면 좋다.

1. Direct baseline이 먼저 성공하는가?
2. 테스트 대상이 localhost 자동 bypass에 걸리지는 않는가?
3. Proxy가 대상 CONNECT를 실제로 봤는가?
4. 같은 Proxy에서 NO_PROXY 하나만 바꿨을 때 결과가 뒤집히는가?
5. 실험 후 listener와 임시 설정이 모두 정리됐는가?

세 칸짜리 실험실이면 충분하다.

Direct → Forced Proxy → NO_PROXY bypass

복잡한 장애일수록 변수를 한 번에 하나만 바꿔야 한다.

그래야 “왠지 Proxy 같다”가 “이 요청은 Proxy를 탔고, 이 hostname을 bypass했을 때만 정상화됐다”로 바뀐다.

디버깅이 추측에서 실험으로 넘어가는 순간이다.