모든 워커노드가 각자 GW존 Squid로 향하던 outbound 경로를 app존의 전용 egress 노드로 모아, 방화벽 ACL 대상을 "클러스터 전체"에서 "egress 노드 몇 대"로 줄이는 설계입니다. 이 문서는 배경지식이 없는 독자도 따라올 수 있도록 forward proxy의 동작 원리부터 시작합니다.
바꾸는 것은 경로이지, 프록시가 아닙니다. GW존의 Squid는 그대로 유지되고
(API·도메인별 egress 허용 정책도 계속 Squid 운영팀 관할), 앱도 지금처럼
HTTP_PROXY/HTTPS_PROXY로 Squid를 향해 요청을 보냅니다. 달라지는 것은 그 요청이
지나가는 길입니다. 각 워커노드에서 곧장 GW존으로 가던 트래픽이, 앞으로는 sidecar에 가로채여
전용 egress 노드의 egress gateway를 경유한 뒤에만 Squid에 도달합니다. 전환이 끝나면
워커노드 → GW존 방화벽 ACL을 제거하는 것이 이 프로젝트의 완료 조건입니다.
| 항목 | 결정 | 근거 |
|---|---|---|
| 체이닝 방식 | A안 — 앱이 CONNECT를 그대로 만들고, gateway는 squid:3128로 가는 TCP 경유지 | Istio 공식 지원 패턴(EnvoyFilter 불필요) · 앱 무변경 · 무장애 원칙 부합 |
| gateway 홉 보안 | ISTIO_MUTUAL, day-one 적용 | 워크로드 단위 통제(AuthorizationPolicy)의 유일한 전제. 롤백 안전판이 살아있는 전환 초기가 검증 비용이 가장 낮은 시점 |
| 워크로드 통제 정책 | AuthorizationPolicy는 맨 마지막에, 관찰된 실사용 신원으로 작성 | dry-run 부재 → "관찰 → 작성 → 적용"이 사실상의 dry-run |
| 역할 분리 | 목적지 정책(어느 도메인 허용) = Squid팀 ACL · 사용 주체 정책(어느 워크로드가 경로 사용) = 플랫폼팀 AuthorizationPolicy | 조직 구조와 기술 경계가 1:1로 일치 |
| 롤백 원칙 | 기존 노드 ACL이 살아있는 동안 VirtualService 삭제 = 즉시 원경로 복귀 | 따라서 ACL 제거는 반드시 마지막 단계 |
구현만 필요하면 Part 04부터 읽어도 됩니다. 다만 Part 01–02의 배경(특히 "CONNECT가 무엇을 실어 나르고 프록시가 무엇을 볼 수 있는가")은 Part 05의 설계 판단과 Part 07의 함정들이 왜 그런지를 설명하는 토대이므로, 이 구조를 처음 보는 독자는 순서대로 읽기를 권합니다.
프록시(proxy)는 "대신 연결해 주는 중계 서버"입니다. 그중 forward proxy는 내부에서 인터넷으로 나가는 방향을 담당하며, Squid는 이 역할의 대표적인 소프트웨어입니다.
쓰는 이유는 밖으로 나가는 문을 하나로 모으기 위해서입니다. 파드 수천 개가 제각각 인터넷에 나가면 누가 어디로 나갔는지 알 수도, 막을 수도 없습니다. 그래서 방화벽으로 "프록시 말고는 아무도 밖으로 못 나감"(default-deny)을 만들고, 유일하게 열린 문인 Squid에서 허용·차단·기록을 한 곳에 모읍니다.
통제의 힘은 방화벽에서 나오고, Squid는 그 유일한 문일 뿐입니다. 이 사실은 문서 끝까지 반복해서 등장합니다 — egress gateway를 도입해도 마찬가지로, 진짜 강제력은 "워커노드 ACL 제거"라는 방화벽 변경에서 나옵니다.
이 변수는 운영체제 기능이 아닙니다. 커널과 소켓 API에는 프록시라는 개념 자체가 없습니다.
Deployment의 env:에서 kubelet과 runc를 거쳐 프로세스 메모리에 놓인 평범한 문자열이고,
이를 읽고 행동을 바꾸는 유일한 주체는 앱이 사용하는 HTTP 클라이언트 라이브러리
(curl/libcurl, Python requests, Go net/http 등)입니다. 요청을 만들기 직전에 읽는 "관례"입니다.
변수가 설정되면 앱의 네트워크 동작이 정확히 세 가지 바뀝니다:
| 바뀌는 것 | 설정 전 | 설정 후 |
|---|---|---|
| TCP 연결 목적지 | 목적지 서버(예: api.example.com:443) | 프록시(squid.gw-zone.example:3128) |
| DNS 조회 대상 | 목적지 이름을 앱이 해석 | 프록시 이름만 해석. 목적지 이름은 해석하지 않음 |
| 소켓의 첫 바이트 | GET /path HTTP/1.1 (origin-form) | CONNECT host:443 또는 전체 URL이 담긴 absolute-form |
"관례"라는 말은 안 지키는 프로그램이 많다는 뜻이기도 합니다. 운영에서 가장 자주 터지는 지점입니다:
| 클라이언트 | env 읽음? | 비고 |
|---|---|---|
| curl / libcurl | O | 단 http_proxy는 소문자만 인식(httpoxy 취약점 여파). HTTPS_PROXY/NO_PROXY는 대소문자 모두 |
| Go net/http 기본 Transport | O | Transport당 최초 1회만 읽어 캐시 — 런타임 중 env 변경 미반영. loopback 목적지는 항상 직결 |
| Python requests / urllib | O | 대소문자 모두 |
| Java 표준 클라이언트 | X | -Dhttps.proxyHost / -Dhttps.proxyPort / -Dhttp.nonProxyHosts 시스템 프로퍼티 필요 |
| Node.js core http · 내장 fetch | X | axios 등 일부 라이브러리는 자체 구현으로 읽음 |
"같은 파드에서 curl은 되는데 앱은 connection timeout" — 십중팔구 위 표의 미지원 런타임입니다.
앱이 env를 무시하고 목적지로 직접 나가려다 방화벽(default-deny)에 막혀 조용히 timeout 됩니다.
또한 HTTPS_PROXY는 "대상 URL이 https일 때 쓸 프록시"라는 선택 기준이지
프록시와의 연결이 TLS라는 뜻이 아닙니다 — 값이 http://squid...:3128인 것이 정상입니다.
자연스러운 다음 질문: "그래서 Squid 주소는 시스템 어디에 등록하나?" 답은 등록소가
존재하지 않는다입니다. DNS의 /etc/resolv.conf 같은 중앙 설정 파일이 없습니다.
환경변수는 프로세스가 execve()로 태어나는 순간 부모로부터 복사되는 문자열
목록(environ)이고, 이후 시스템 어디에도 "이 프로세스의 프록시는 X"라는 기록이 남지 않습니다.
따라서 "어디에 적는가"의 정답은 그 프로세스의 environ이 조립되는 지점이며,
프로세스가 태어나는 경로마다 지점이 다릅니다:
| 평면 | environ 조립 지점 | 비고 |
|---|---|---|
| 파드 안의 앱 (주 관심) | Deployment의 env: 또는 envFrom:(ConfigMap) → kubelet → runc → execve | 표준 경로. 이미지의 ENV도 합쳐지지만 배포 스펙이 우선 |
| 노드 데몬 (containerd/kubelet) | systemd drop-in의 Environment= | 이미지 pull 전용 — 앱 트래픽과 별개 평면 (아래 함정) |
| 노드 호스트 셸 | /etc/environment, profile | 파드에 전달되지 않음 |
가장 원초적인 형태부터 봅니다. 셸에서의 등록은 두 가지이고, 등록 전후의 차이는 curl이 어디로 연결을 시도하는지로 즉시 관찰됩니다:
# 방법 1 — 셸 세션에 등록: 이후의 모든 자식 프로세스가 environ 을 상속
export HTTPS_PROXY=http://squid.test:3128
curl -sk https://api.demo.test/
# 방법 2 — 그 명령 하나에만 부여 (해당 프로세스의 environ 에만 추가)
HTTPS_PROXY=http://squid.test:3128 curl -sk https://api.demo.test/
# 효과 실측 (동반 문서의 재현 랩: squid.test=프록시, api.demo.test=origin)
등록 전: * Trying 192.0.2.2:443 ← 목적지로 직접 시도
등록 후: * Trying 127.0.0.1:3128 ← 프록시로. 목적지 이름은 CONNECT 문자열이 됨
실무 표준은 네임스페이스에 프록시 ConfigMap 하나를 두고 각 Deployment가 참조하는 형태입니다:
apiVersion: v1
kind: ConfigMap
metadata:
name: proxy-env
data:
HTTPS_PROXY: "http://squid.gw-zone.example:3128"
HTTP_PROXY: "http://squid.gw-zone.example:3128"
NO_PROXY: "localhost,127.0.0.1,.svc,.cluster.local,<serviceCIDR>,<apiserver IP>"
---
# Deployment 쪽
spec:
template:
spec:
containers:
- name: app
envFrom:
- configMapRef:
name: proxy-env
전사 일괄 설정은 K8s에 내장돼 있지 않습니다. 그 용도였던 PodPreset은 제거됐고,
모든 파드에 강제로 넣으려면 mutating admission webhook을 직접 운영해야 합니다 — 등록소의 부재가
곧 "env는 opt-in 관례"라는 한계의 뿌리입니다. 또한 변경 = 재기동입니다: 기동 시
복사되므로 ConfigMap을 바꿔도 rollout 전에는 반영되지 않고, Go는 첫 요청 때 읽은 값을 Transport에
캐시합니다. JVM은 이 env를 읽지 않으므로 env로 전달하려면
JAVA_TOOL_OPTIONS="-Dhttps.proxyHost=… -Dhttps.proxyPort=3128"라는 다리를 씁니다.
노드의 containerd에 설정하는 HTTP_PROXY(systemd drop-in)는 이미지 pull 전용입니다. 앱 트래픽과 평면이 달라서, "노드에 프록시를 설정했는데 앱이 안 나가요"는 이 둘을 섞은 전형적인 혼동입니다.
이름 때문에 흔히 생기는 오해를 여기서 정리합니다. HTTP_PROXY와
HTTPS_PROXY는 "프록시에게 어떻게 말할지"를 정하는 변수가 아닙니다. 대상 URL의
스킴에 따라 어느 프록시 주소를 쓸지 고르는 주소록일 뿐이며(보통 둘 다 같은 Squid 주소),
프록시 소켓에 무엇을 보낼지는 변수명이 아니라 대상 URL의 스킴이 결정합니다.
| 대상 URL | 읽는 변수 | 프록시 소켓에 보내는 것 | 프록시의 역할 |
|---|---|---|---|
http:// | HTTP_PROXY | GET http://host/path HTTP/1.1 — absolute-form 일반 요청. CONNECT를 쓰지 않음 | 완전한 HTTP 중개자 — 파싱·캐시·헤더 조작 후 origin-form으로 재작성해 전달 |
https:// | HTTPS_PROXY | CONNECT host:443 HTTP/1.1 — authority-form | 200 회신 후 무해석 바이트 릴레이(터널) |
CONNECT 자체는 GET·POST와 같은 급의 표준 HTTP/1.1 메서드입니다.
평문 요청 라인과 헤더를 보내면 프록시는 상태 라인이 있는 정상 HTTP 응답을 돌려줍니다 —
허용이면 200 Connection established, 정책 거부면 403, 프록시 인증이 필요하면 407.
특수한 점은 두 가지뿐입니다:
host:port만 쓰는
authority-form입니다. HTTP 요청 라인의 대상 표기는 아래 세 형태가 전부이며,
이 문서의 흐름과 정확히 대응합니다.| request-target | 예시 | 쓰이는 곳 |
|---|---|---|
| origin-form | GET /path HTTP/1.1 | 프록시 없이 서버에 직접 요청할 때 |
| absolute-form | GET http://host/path HTTP/1.1 | 평문 http:// 요청을 프록시에 위탁할 때 |
| authority-form | CONNECT host:443 HTTP/1.1 | 프록시에 터널 개설을 요청할 때 |
변수 값의 스킴은 별개의 축입니다 — http://squid...는 프록시와의 연결이
평문이라는 뜻이고(1.2의 주의 재확인), 프록시 구간 자체를 TLS로 감싸는 https:// 스킴
프록시도 존재합니다. 그 경우에도 TLS 안에서 오가는 대화는 위와 동일한
CONNECT / absolute-form입니다. 실측 바이트 캡처는 2.2절에 있습니다.
NO_PROXY의 정확한 지위부터 잡아야 합니다. 라이브러리는 요청을 만들 때마다
아래 결정을 돌고, NO_PROXY는 그 2단계에서만 읽히는 예외 목록입니다:
그림에서 두 가지가 바로 따라 나옵니다. 첫째, 프록시 변수가 없으면 NO_PROXY는 아예 관여할 기회가 없습니다 — "프록시를 타지 않게 하는" 주 스위치는 NO_PROXY가 아니라 프록시 변수의 부재(또는 제거) 자체이고, NO_PROXY는 프록시 세계 안에서 일부 목적지만 빼내는 조각칼입니다. 둘째, 결정의 결과 "직접 연결"은 성공이 아니라 시도입니다 — 도달 여부는 방화벽이 정합니다.
env -i로 환경을 비운 뒤 조합별로 curl의 결정(Trying 대상)만 관찰한 결과입니다:
① 변수 없음 * Trying 192.0.2.2:443 ← 직접 시도
② HTTPS_PROXY만 * Trying 127.0.0.1:3128 ← 프록시로
③ HTTPS_PROXY + NO_PROXY(매치) * Trying 192.0.2.2:443 ← 예외로 직접
④ NO_PROXY만 (프록시 변수 없음) * Trying 192.0.2.2:443 ← ①과 완전히 동일
| 프록시 변수 | NO_PROXY 매치 | 라이브러리의 결정 | default-deny 환경에서의 실제 결과 |
|---|---|---|---|
| 없음 | (무의미) | 직접 시도 | 내부 목적지 ○ / 외부 → 방화벽 drop, timeout |
| 있음 | 미매치 | 프록시 경유 | Squid ACL 통과 시 성공 |
| 있음 | 매치 | 직접 시도 | 내부(.svc 등) ○ — NO_PROXY의 본래 용도 |
| 없음 | 매치 | 직접 시도 — 1행과 동일 | NO_PROXY는 아무 일도 하지 않음 |
①과 ④가 완전히 동일하다는 것이 핵심 실증입니다. 운영 증상으로 번역하면 — "NO_PROXY에 넣었더니 외부 API가 timeout" = 직접 시도가 방화벽에 막힌 정상 동작이고, "프록시 변수를 지웠는데 일부 파드는 여전히 됨" = 목적지가 내부이거나 노드 ACL이 아직 남아 있다는 뜻입니다.
매칭 문법 자체도 함정입니다. 비교는 요청 URL의 호스트 문자열 기준이고
비교 전에 DNS를 하지 않으므로, NO_PROXY=10.0.0.0/8은
https://internal-api.corp.example처럼 이름으로 쓴 URL에는 절대 매치되지 않습니다
(그 이름이 10.x로 해석되더라도). CIDR 매칭은 URL 호스트가 IP 리터럴일 때만 동작하며(Go, requests),
문법 표준이 없어 런타임마다 다릅니다.
K8s 실전 세트는 대략 다음과 같습니다:
NO_PROXY=localhost,127.0.0.1,.svc,.cluster.local,<serviceCIDR>,<podCIDR>,<apiserver IP>
이 목록을 빠뜨리면 클러스터 내부 호출(서비스 간 통신, kube-apiserver 호출)이 Squid로
헤어핀됩니다. Squid는 *.svc.cluster.local을 해석할 DNS가 없으므로 503으로
죽는 것이 이 구조의 고전적 장애 1위입니다.
이 파트가 문서 전체의 기술적 토대입니다. "프록시를 지나간다"는 말이 바이트 수준에서 무엇을 뜻하는지 알아야, 뒤에서 나올 "gateway는 최종 목적지를 모른다"거나 "SNI만 평문"이라는 설계 근거가 이해됩니다.
앱이 https://api.example.com/api/ping을 직접 호출하면 네 가지 일을 전부 앱이 합니다:
① 이름 해석(DNS) ② TCP 연결 ③ TLS 핸드셰이크(인증서 검증 포함) ④ 요청 전송.
같은 URL인데 앱의 행동이 완전히 바뀝니다. 흐름은 두 국면으로 나뉩니다. 앞부분은 프록시가 전부 읽을 수 있는 평문 국면, 뒷부분은 프록시가 의미를 모르는 바이트만 나르는 암호화 국면입니다.
3번 단계에서 앱이 프록시 소켓에 쓰는 실제 바이트, 그리고 200 응답 직후의 바이트는 이렇습니다 (실측 캡처 기준):
== 대상이 https:// 일 때 — 앱 → 프록시 첫 바이트 ==
CONNECT api.example.com:443 HTTP/1.1
Host: api.example.com:443
User-Agent: curl/8.5.0
Proxy-Connection: Keep-Alive ← URL의 경로·쿼리(/v1/pay?user=123)는 어디에도 없음
== 대상이 평문 http:// 일 때 — 완전히 다른 모드 ==
GET http://api.example.com/v1/pay?user=123 HTTP/1.1 ← 전체 URL이 그대로 노출(absolute-form)
Host: api.example.com
== "200 Connection established" 직후 앱이 보내는 바이트 ==
16 03 01 02 00 ... ← 0x16 0x03 0x01 = TLS 핸드셰이크 레코드의 시작
평문으로 읽히는 문자열은 api.example.com(SNI), http/1.1(ALPN) 뿐 — 나머지는 암호 협상 데이터
즉 CONNECT는 "이 host:port로 가는 통로를 뚫어달라"는 요청이지 HTTP 요청의 전달이 아닙니다. 통로가 열리면 TLS 세션은 앱과 origin 사이에 end-to-end로 성립하고, 인증서 검증도 앱이 origin의 인증서를 상대로 직접 수행합니다. 터널이 순수 TCP이므로 내부 프로토콜은 자유입니다 — 프록시 구간이 HTTP/1.1이어도 터널 안에서는 HTTP/2, gRPC, websocket이 그대로 흐릅니다.
| HTTPS (CONNECT 터널) | 평문 HTTP (absolute-form) |
|---|---|
| 봄: 클라이언트 IP · CONNECT의 host:port(클라이언트가 주장한 문자열) · 바이트 수와 시간 · (설정 시) Proxy-Authorization 못 봄: 메서드 · 경로 · 쿼리 · 헤더 · 바디 — 전부 TLS 안 |
전부 봄: 전체 URL · 헤더 · 바디. 캐시하고, 내용을 바꾸고, Via / X-Forwarded-For 헤더를 붙일 수 있음 |
따라서 HTTPS에 대한 허용/차단 판단의 근거는 출발지 IP + CONNECT의 host:port (+ 인증)이
전부입니다. 검증 강도를 높이는 사다리가 존재합니다:
| 모드 | 동작 | 대가 |
|---|---|---|
| splice (기본) | CONNECT 문자열을 믿고 릴레이 | 없음 — 현재 운영 모드 |
| peek | TLS ClientHello까지만 훔쳐봐 SNI를 읽고 CONNECT host와 대조. 복호화 없음 | 설정 복잡도 |
| bump | MITM — 사설 CA로 인증서를 재발급해 양쪽과 각각 TLS를 맺고 내용 전체를 봄 | 인증서 pinning·mTLS 파괴, 사설 CA 배포 필요 |
클라이언트 쪽에서 bump의 존재를 탐지하는 방법은 발급자(issuer) 체인 확인입니다:
curl -v https://example.com 2>&1 | grep -E 'subject:|issuer:'
* subject: CN=example.com
* issuer: O=Corp Internal CA ... ← 공인 CA가 아닌 발급자면 경로상 어딘가가 TLS를 가로채는 중
| 프록시 없음 | HTTPS_PROXY 설정 | |
|---|---|---|
| 앱이 해석하는 이름 | 목적지(api.example.com) | 프록시 이름만 (squid.gw-zone.example) |
| 목적지 이름의 이동 | 앱이 직접 IP로 변환 | CONNECT 라인에 문자열로 실려 감 |
| 목적지를 IP로 바꾸는 주체 | 앱 (파드 resolver → CoreDNS) | Squid (Squid 호스트의 DNS 기준) |
증거는 Squid access.log에 남습니다 — HIER_DIRECT/<IP> 필드가 Squid가 스스로
해석해서 연결한 IP의 기록입니다. 이 구조 덕분에 파드는 외부 도메인을 해석할 능력이 전혀
없어도 외부 API를 호출할 수 있습니다(air-gapped 환경에서 성립하는 이유).
본 설계는 Squid를 유지하므로 이 역할 배치는 전환 후에도 변하지 않습니다.
Squid는 요청 라인을 파싱한 뒤 http_access 규칙을 위에서 아래로 평가해
첫 매치에서 결정하고(deny → 403 HTML + X-Squid-Error 헤더),
허용이면 자체 resolver로 목적지를 해석해(실패 → 503) TCP를 연결한 뒤, CONNECT는
200 회신 후 무해석 릴레이로, 평문 HTTP는 캐시·헤더 조작을 거쳐 전달합니다.
로그는 연결이 닫힐 때 한 줄입니다:
1784177289.182 409 10.32.4.17 TCP_TUNNEL/200 4593 CONNECT example.com:443 - HIER_DIRECT/104.20.23.154 -
└ epoch(ms) └지속ms └클라이언트 └결과/상태 └bytes └메서드+대상(경로 없음) └ Squid가 해석해 연결한 IP
1784177289.192 0 10.32.4.17 TCP_DENIED/403 3374 CONNECT www.badsite.test:443 - HIER_NONE/- text/html
HTTPS는 CONNECT host:port 한 줄이 전부이고(경로 없음), 그 한 줄도 터널이
닫힐 때 찍힙니다 — 2시간짜리 gRPC 스트림이면 2시간 뒤에 로그 1줄. "로그에 안 보임"이
"아직 연결이 살아있음"인 경우가 많습니다. 평문 HTTP도 기본값 strip_query_terms on
때문에 쿼리스트링은 로그에서 제거됩니다 — "평문이면 다 남는다"도 절반만 맞습니다.
현재는 모든 워커노드가 각자 GW존 방화벽 ACL을 통해 Squid로 나갑니다. 목표는 그 경로를 app존의 전용 egress 노드로 모으고, 전환 완료 후 워커노드별 ACL을 제거하는 것입니다.
최종 트래픽 경로는 다음과 같습니다:
app (HTTP_PROXY 유지) → sidecar가 :3128 TCP 인터셉트
→ [ISTIO_MUTUAL] → egress gateway (:7777, egress 노드 고정)
→ plain TCP → squid.gw-zone.example:3128 → internet
| A안 · 앱이 CONNECT 유지 (채택) | B안 · gateway가 CONNECT 생성 | |
|---|---|---|
| 앱 설정 | HTTP_PROXY 그대로 — 무변경 | env 제거, 앱은 원 주소로 직접 호출 |
| Istio 지원 | 공식 패턴 — 표준 객체만으로 성립 | 네이티브 미지원 — EnvoyFilter로 tunneling 주입, 업그레이드마다 회귀 리스크 |
| gateway의 목적지 가시성 | 없음 — CONNECT 문자열은 통과하는 바이트 | 있음 — gateway에서 호스트 기준 정책 가능 |
| 기존 문제 | NO_PROXY·JVM env 이슈 승계 (현상 유지) | env 이슈 소멸, 대신 ServiceEntry 등록 체계 필요 |
A안에서 egress gateway는 최종 목적지를 전혀 모릅니다. "gateway에서 도메인별 라우팅/차단"을 기대하는 이해관계자가 있다면 지금 조정해야 합니다 — 그것은 B안 또는 Squid ACL의 영역입니다.
필요한 객체는 네 종류가 전부입니다. 각 객체가 어느 홉에 작용하는지부터 보면 나머지는 배선 문제입니다.
kubectl label node egress-01 egress-02 node-role.kubernetes.io/egress=
kubectl taint node egress-01 egress-02 dedicated=egress:NoSchedule
taint는 선택이 아닙니다. 방화벽 ACL이 egress 노드 IP 기준인데 일반 파드가 이 노드에 스케줄되면, 그 파드들 전부가 Squid 접근 권한을 얻는 권한 확대가 생깁니다.
# IstioOperator (helm gateway chart도 동일 개념)
spec:
components:
egressGateways:
- name: istio-egressgateway
enabled: true
namespace: istio-egress
k8s:
replicaCount: 2 # egress 노드 2대에 각 1개
nodeSelector:
node-role.kubernetes.io/egress: ""
tolerations:
- key: dedicated
value: egress
effect: NoSchedule
service:
ports:
- name: tls-squid
port: 7777
targetPort: 7777
k8s.service.ports는 기본 포트 목록을 덮어쓸 수 있으니 80/443이 필요하면 함께
명시하세요. replica가 두 노드에 갈라지도록 topologySpreadConstraints(또는 podAntiAffinity)를
함께 겁니다 — 노드 1대 구성은 그 노드 유지보수가 곧 전사 외부 통신 중단입니다.
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: squid-gw
namespace: istio-egress
spec:
hosts: ["squid.gw-zone.example"]
addresses: # GSLB VIP 집합 — 원천은 GSLB 설정 (방화벽 ACL과 동일 목록)
- "<SQUID_VIP_1>/32"
- "<SQUID_VIP_2>/32"
location: MESH_EXTERNAL
resolution: DNS
ports:
- number: 3128
name: tcp-squid
protocol: TCP
exportTo: ["."] # "."은 불변 멤버 — 이유와 확대 규칙은 4.7
이 두 필드는 모순처럼 보이지만("IP를 박는데 왜 DNS?") 각각 다른 단계를 지시합니다. sidecar(Envoy 프로세스 하나) 안에는 테이블이 두 개 있고, DNS의 답이 들어가는 곳은 그중 하나뿐입니다:
| 단계 | 지시 필드 | 성격 | IP의 출처 |
|---|---|---|---|
| ① 매칭 (잡기) | addresses | 정적 — xDS로 미리 푸시 | 사람이 적은 리터럴 |
| ② 발신 (보내기) | resolution: DNS | 동적 — STRICT_DNS 주기 해석 | 연결 시점의 DNS 답 |
즉 이 구조에는 같은 이름을 해석하는 주체가 셋 있습니다 — 앱(요청 직전 getaddrinfo),
Envoy 클러스터(발신 직전), 그리고 addresses는 해석하지 않는 인간의 선언.
Squid VIP가 바뀌면 따라가는 쪽은 ②(이미 DNS)이고, ①은 사람이 바꿔야 합니다.
두 자리가 DNS에 던지는 질문의 종류가 다르기 때문입니다. 발신(②)에 필요한 답은
"지금 살아있는 주소 하나"(존재 질의 — DNS가 잘하는 것)지만,
매칭(①)에 필요한 답은 "앱이 받았을 수 있는 모든 답의 집합"(전칭 질의)인데
DNS 프로토콜에는 전칭을 묻는 쿼리가 없습니다 — 매 질의는 표본 하나일 뿐입니다.
특히 단일 로테이션 A 레코드(우리 GSLB)에서는 Envoy의 스냅샷이 어느 순간에도 1개라,
그걸 키로 쓰면 풀이 N개일 때 앱 dial의 (N−1)/N이 리스너를 비켜갑니다. 또한 istiod가
설정 시점에 해석해 키를 만들면 앱이 요청 시점에 받은 답과 어긋나는 race가 생깁니다
(다른 시각·다른 리졸버·로테이션). 그래서 Istio의 선택지는 둘뿐입니다 —
사람이 집합을 선언하거나(addresses), 모른다고 전부를 주장하거나(0.0.0.0).
| SE 포트 프로토콜 | 매칭 시점 와이어의 이름 | 판별 계층 | 0.0.0.0 안전? |
|---|---|---|---|
TCP — 우리 3128 | 없음 (첫 바이트가 평문 CONNECT) | 목적지 IP뿐 → addresses 필수 | ✕ |
TLS — 443 직결, 홉1의 7777 | ○ — ClientHello의 SNI (평문) | IP + serverNames 체인 | ○ (도메인별 filter chain 공존) |
HTTP — 평문 80 | ○ — Host 헤더 | IP + Host | ○ |
미묘한 진실 하나 — 3128 트래픽에도 이름은 있습니다(CONNECT host:443 라인).
하지만 그걸 읽으려면 protocol: HTTP를 선언해 Envoy를 프록시 대화의 해석 주체로
만들어야 하고, 그것이 정확히 B안으로 넘어가는 스위치입니다. A안의 TCP 선언은
"이름이 있어도 읽지 않기로 한" 의도적 선택이며, 그 대가가 addresses입니다.
(반대로 3128을 TLS로 잘못 선언하면 — 첫 바이트 0x43이 ClientHello가
아니라 어느 체인에도 미매치 → 연결 리셋됩니다.)
| 상태 | 등록 형태 | 결과 |
|---|---|---|
| A | 같은 포트의 SE들이 전부 addresses 보유 (키 상이) | 완벽 공존 — (IP,port) 사전 찾기라 섞임 없음 |
| B | addresses 없는 SE 1개 | 0.0.0.0_3128 와일드카드 — 자기 트래픽은 동작하지만 무관한 모든 3128 트래픽을 흡입(하이재킹). 구체 키 리스너는 와일드카드보다 항상 우선(most-specific-wins)이라 A쪽 SE는 무사 |
| C | addresses 없는 SE 2개 이상 | 같은 키를 복수가 주장하는 정의 충돌 — istiod가 하나만 반영(비결정적), 진 쪽 host로 dial한 트래픽이 이긴 쪽 목적지로 물리적 오배송 |
B 상태에서 어느 팀이 내부 도구를 internal-tool.corp:3128로 열면, 그 트래픽은
조용히 이 SE의 경로(gateway→Squid)로 회송되어 앱은 Squid의 400/403 HTML을 받습니다 —
어느 로그에도 "왜"가 남지 않습니다. 라우팅 목적지(VS가 결정)는 addresses 유무와 무관하게
동일하므로 기능은 멀쩡히 동작하는데, 이것이 오히려 생략을 눈치채지 못하게 만듭니다.
addresses는 동작의 필드가 아니라 잡는 범위를 의도한 좌표로 못 박는 격리의 필드입니다.
우리 Squid 도메인은 GSLB 경유라 질의마다 다른 IP를 답합니다. 단 GSLB는 무한 IP 생성기가 아니라 유한한 VIP 풀에서 골라 답하는 장치이므로, 첫 행동은 설정이 아니라 확인입니다 — GSLB 팀에 풀 멤버 목록을 받으세요(원천은 GSLB 설정, dig는 검증용). 그 성격에 따라:
| 조건 | 선택 | 비고 |
|---|---|---|
| 풀이 유한·확인 가능 (대부분) | VIP 집합 열거 (기본안) | addresses는 리스트/CIDR 허용. 목록의 라이프사이클은 방화벽 ACL과 동일 — 신규 부담 아님. VIP 변경은 [옛,새] 겹침 배포로 무중단 |
| 풀이 크거나 예측 불가 | Istio DNS proxying (자동 할당) | sidecar가 앱의 DNS 질의에 자동 VIP(240.240.x.x)로 답해 "앱이 받은 답 = 리스너 키"를 구조적으로 보장 — addresses 불요. 대가: DNS 경로 부품화(기동 순서·관측·버전·우회 앱 확인) |
| 3128 포트 유일성을 정책으로 보장 가능 | 생략 수용 (B 상태를 의도로) | "이 클러스터의 3128행은 전부 Squid행" 선언. 단일 SE 유지(istioctl analyze CI 게이트) + 파일럿 격리 정밀도 상실 수용 |
# 드리프트 감지 (CI/크론) — 실제 DNS 답이 addresses 집합의 부분집합인지
LIVE=$(for i in $(seq 20); do dig +short squid.gw-zone.example; done | sort -u)
for ip in $LIVE; do
grep -q "$ip" serviceentry.yaml || { echo "DRIFT: $ip not in addresses"; exit 1; }
done
이 절의 명제들(SNI 체인 / TLS 오선언 리셋 / 와일드카드 하이재킹 / 정의 충돌 / addresses
정밀화)을 파일럿에서 직접 재현하는 절차는 별도 문서
se-listener-matching-verification.md(S1~S5)에 있습니다.
istioctl proxy-config listeners <파드> --port 3128
# addresses 있음: VIP별 리스너 (198.51.100.10:3128 …) / 없음: 0.0.0.0:3128
istioctl proxy-config endpoints <파드> --cluster "outbound|3128||squid.gw-zone.example"
# 실 VIP가 보이고, 반복 실행 시 GSLB 로테이션 따라 바뀜 — 그래도 위 listeners는 불변
# = "DNS는 발신에만 유입된다"의 물증
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: egressgw-squid
namespace: istio-egress
spec:
host: istio-egressgateway.istio-egress.svc.cluster.local
subsets:
- name: squid
trafficPolicy:
portLevelSettings:
- port:
number: 7777
tls:
mode: ISTIO_MUTUAL
sni: squid.gw-zone.example
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
name: squid-egress
namespace: istio-egress
spec:
selector:
istio: egressgateway
servers:
- port:
number: 7777
name: tls-squid
protocol: TLS
hosts: ["squid.gw-zone.example"]
tls:
mode: ISTIO_MUTUAL # sidecar가 감싼 mTLS를 여기서 종료
Gateway의 tls.mode와 DestinationRule의 portLevelSettings.tls는 한 쌍입니다.
한쪽만 있으면 핸드셰이크가 깨져 앱은 connection reset을 봅니다. 또한 DR의 sni 값은
Gateway 서버의 hosts에 매칭되어야 필터체인이 잡힙니다. 그리고 7777 리스너에서
비-mTLS 연결을 거절하는 실제 주체는 이 tls.mode: ISTIO_MUTUAL 자체입니다 —
PeerAuthentication은 sidecar용 메커니즘이라 gateway 리스너의 강제 수단으로 의존하지 마세요.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: squid-via-egressgw
namespace: istio-egress
spec:
hosts: ["squid.gw-zone.example"]
gateways: ["mesh", "squid-egress"]
exportTo: ["."] # ServiceEntry와 동일하게 단계적 확대
tcp:
- match: # 홉 1 — 모든 sidecar에서
- gateways: ["mesh"]
port: 3128
route:
- destination:
host: istio-egressgateway.istio-egress.svc.cluster.local
subset: squid
port:
number: 7777
- match: # 홉 2 — gateway에서 진짜 Squid로
- gateways: ["squid-egress"]
port: 7777
route:
- destination:
host: squid.gw-zone.example
port:
number: 3128
gateways: ["mesh"]가 "클러스터의 모든 sidecar에 이 라우팅을 심어라"는 뜻이고,
두 번째 블록이 gateway 쪽 배선입니다. 롤백 관점에서 이 객체가 스위치입니다 —
기존 노드 ACL이 살아있는 동안 이 VirtualService를 삭제하면 sidecar는 ServiceEntry를 통해
Squid로 직행하는 원래 경로로 즉시 복귀합니다.
4.3~4.5를 겹쳐 읽으면 자연스러운 질문이 생깁니다 — "Gateway는 TLS로 받는데, 프록시 대화는 전부 평문 HTTP/1.1 아닌가?" 답은 층이 다르다입니다. Gateway의 TLS는 홉 ② (sidecar→gateway) 구간의 포장이고, 앱의 HTTP/1.1 대화는 그 포장 안의 내용물로 통과합니다. 홉별로 와이어에 실리는 것을 보면:
| 지시 | 객체·필드 | 의미 |
|---|---|---|
| 봉투 씌우기 (홉② 발신) | DR portLevelSettings[7777].tls: ISTIO_MUTUAL | 7777로 갈 때만 mTLS. sni는 그 TLS에 실을 판별자(Gateway hosts와 일치) |
| 봉투 벗기기 (홉② 종단) | Gateway tls.mode: ISTIO_MUTUAL | mesh 인증서 없이는 입장 불가 — AuthorizationPolicy가 심사할 신원의 통로 |
| 내용물 취급 | Gateway protocol: TLS | 종단 후 불투명 바이트 릴레이(HTTP 파싱 없음). HTTP로 선언하는 순간 Envoy가 해석 주체가 됨 = B안 스위치 |
| 홉③ | TLS 설정 없음 | 의도된 평문 — Squid의 계약이 평문 프록시 대화이므로 |
포트를 3128/7777로 가른 이유 — 숫자 7777 자체는 임의값이고, 설계 결정은
"두 홉의 포트를 다르게"입니다. ① 두 리스너는 계약이 다릅니다(3128=평문 프록시 대화,
7777=mTLS 화물) — 첫 바이트부터 다른 세계(0x43 vs 0x16)라, 번호를
갈라두면 오배선이 침묵 오작동이 아니라 즉시 식별 가능한 실패(reset/400)로 나타납니다.
② 두 홉의 매칭 공간이 격리되어 설정 실수의 합성 사고가 원천 차단됩니다.
③ 방화벽·Calico·AuthorizationPolicy·대시보드의 규칙이 포트만으로 홉을 지목할 수 있습니다.
터널 국면의 홉②는 app↔origin TLS 레코드를 mTLS로 한 번 더 감싸는 이중 암호화라 gateway CPU에 재암호화 비용이 실립니다. egress 노드 용량 산정에는 동시 터널 수 축과 함께 처리량(bytes/s) 축을 반영하세요.
오류는 대칭으로 나타납니다 — DR 설정 누락이면 sidecar가 평문 발신 → gateway가
0x43("C")을 ClientHello로 기대하다 handshake 실패 → 앱은 reset.
반대로 Gateway를 평문으로 잘못 열면 sidecar의 ClientHello가 Squid까지 흘러가
400. 부록 트러블슈팅 표의 첫 두 행이 이 메커니즘입니다.
SE·VS·DR에는 각각 exportTo가 있고, 값은 세 종류입니다 —
"."(객체가 사는 네임스페이스 자신), 구체 네임스페이스 이름 나열,
"*"(전체, 기본값). 나열이 파일럿 단계의 도구입니다:
[".", "payments", "settlement"]처럼 시작해 확대는 리스트에 추가하는 PR,
최종 단계에 "*".
SE의 소비자는 둘입니다: 앱 sidecar(3128 리스너·클러스터)와 egressgateway
자신(홉 ③의 outbound|3128||squid… 클러스터). mesh 홉에서 "어디로
보낼지"는 패킷이 아니라 설정 — tcp_proxy에 박힌 클러스터 참조 — 에서 오는데
(원래 목적지 IP는 sidecar의 매칭에서 소비되고 사라집니다), 그 참조의 실체인
STRICT_DNS 클러스터 객체를 gateway에 존재하게 하는 것이 SE의 exportTo: "."입니다.
빠지면 mTLS 핸드셰이크는 성공하는데 직후 연결이 닫히는 특징적 시그니처가 남습니다.
세 객체의 exportTo가 어긋나면 "부분 가시성"이라는 미묘한 장애가 됩니다. 어느 하나만 안 보이는 네임스페이스에서의 증상:
| 안 보이는 객체 | 그 네임스페이스의 동작 | 증상 |
|---|---|---|
| SE | 3128 리스너 자체가 없음 | REGISTRY_ONLY면 차단 / ALLOW_ANY면 passthrough 직행 (노드 ACL 생존 시 성공 — 전환기 안전 롤백의 원리) |
| VS만 | 리스너는 있으나 방향 전환 없음 → Squid 직행 | gateway 우회하는 조용한 회귀 — Squid access.log 소스 IP로만 탐지 |
| DR만 | VS가 7777로 보내는데 평문 발신 | gateway handshake 실패 → reset |
따라서 운영 규칙은 하나입니다 — SE·VS·DR 세 객체의 exportTo를 항상 같은 리스트로 (kustomize overlay에서 세 객체에 동일 patch를 묶어 강제). 그리고 확대의 의미가 ACL 제거를 기점으로 바뀝니다: 제거 전엔 리스트에서 빼면 원래 경로로 안전 복귀, 제거 후엔 빼는 순간 그 네임스페이스의 외부 통신이 중단됩니다.
exportTo는 가시성 통제이지 보안 통제가 아닙니다 — 미포함 네임스페이스의
파드도 Squid VIP로 직행을 시도할 수는 있고, 막는 것은 Calico·방화벽·AuthorizationPolicy의
일입니다. 클러스터가 커지면 소비자 쪽에서 수입 범위를 줄이는 Sidecar 리소스와
mesh 기본값을 닫는 meshConfig.defaultServiceExportTo도 검토 대상입니다.
# sidecar에 3128 라우팅이 심어졌는가
istioctl proxy-config listener <앱파드> -n <ns> --port 3128
# 앱 → gateway 홉의 tlsMode가 ISTIO_MUTUAL인가
istioctl proxy-config cluster <앱파드> -n <ns> | grep egressgateway
# gateway의 7777 필터체인과 server_names(SNI) 확인
istioctl proxy-config listener deploy/istio-egressgateway -n istio-egress --port 7777
# 파일럿 파드에서 실트래픽
curl -v -x http://squid.gw-zone.example:3128 https://<허용도메인>/
# 컷오버의 결정적 신호 — Squid access.log의 클라이언트 IP가
# 워커노드 → egress 노드 IP로 바뀌는지 확인
이 파트가 홉 1을 굳이 ISTIO_MUTUAL로 만든 이유입니다. 오늘의 구조에는 "누가 프록시를 쓸 수 있는가"에 대한 통제가 없습니다(노드 단위 ACL = 노드 위 모든 파드가 사용 가능). 전환과 함께 이 통제를 새로 확보합니다.
Istio에서 워크로드 신원은 istiod가 발급하는 X.509 인증서의 URI SAN에
SPIFFE ID로 각인됩니다. AuthorizationPolicy를 집행하는 것은 연결을 받는 쪽 Envoy의
RBAC 필터이고, 이 필터의 source.namespaces 매칭은 IP 조회가 아니라
클라이언트 인증서 문자열의 파싱입니다 — /ns/ 다음 세그먼트를 꺼내
정책과 비교합니다. 인증서가 없는 plain TCP 연결에는 추출할 원료 자체가 없고, 그때 Envoy가
아는 "누가"는 패킷의 소스 IP(= ipBlocks 필드)가 전부입니다.
IP→네임스페이스 조회로 구현하지 않은 이유도 분명합니다. Envoy는 연결마다 쿠버네티스 API를 조회하지 않는 데이터플레인이고, 파드 IP는 반납·재할당되는 위치 정보라 갱신 지연 동안 엉뚱한 워크로드가 통과하는 race가 생깁니다. IP는 위치이지 신원이 아니므로, Istio는 신원을 암호학적으로 검증 가능한 유일한 운반체인 인증서에만 묶었습니다.
| 가능 (L4) | 불가능 — 그리고 이유 |
|---|---|
source.principals (SPIFFE ID, 서비스어카운트 단위) ·
source.namespaces · ipBlocks · to.operation.ports |
최종 목적지 도메인 · 경로 · 메서드 — gateway는 CONNECT 문자열을 통과하는 바이트로만 보므로 목적지를 모릅니다. 목적지 정책은 설계상 Squid팀 ACL의 영역입니다. |
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: squid-clients
namespace: istio-egress
spec:
selector:
matchLabels:
istio: egressgateway
action: ALLOW
rules:
- from:
- source:
namespaces: ["payments", "fds"] # 또는 principals로 SA 단위
to:
- operation:
ports: ["7777"]
ALLOW 정책이 워크로드에 붙는 순간, 매치되지 않는 트래픽은 전부 거부됩니다. 이 gateway를 다른 egress 용도와 공유한다면 그 포트의 허용 규칙도 같은 정책에 함께 넣어야 합니다. 본 설계처럼 전용 gateway면 해당 없음.
mTLS를 켜는 것과 정책을 거는 것은 분리할 수 있고, 분리해야 합니다. 전환 기간의 mTLS는 통제가 아니라 신원 수집 인프라입니다.
# istio meshConfig에 provider 추가
extensionProviders:
- name: egress-san-log
envoyFileAccessLog:
path: /dev/stdout
logFormat:
text: "[%START_TIME%] peer=%DOWNSTREAM_PEER_URI_SAN% dst=%UPSTREAM_HOST% bytes=%BYTES_SENT%/%BYTES_RECEIVED% dur=%DURATION%\n"
---
apiVersion: telemetry.istio.io/v1
kind: Telemetry
metadata:
name: egressgw-san-logging
namespace: istio-egress
spec:
selector:
matchLabels:
istio: egressgateway
accessLogging:
- providers:
- name: egress-san-log
AuthorizationPolicy에는 성숙한 dry-run 모드가 없습니다(AUDIT 액션은 실험 단계). "관찰 → 작성 → 적용" 순서가 사실상의 dry-run이며, 이를 건너뛰면 인벤토리에 없던 배치잡·크론잡이 첫 실행 때 조용히 거절당하는 것이 전형적인 사고 패턴입니다.
기존 노드 ACL이 살아있는 동안에는 VirtualService 삭제 = 즉시 원경로 복귀입니다. 이 안전판이 존재하는 구간에서 mTLS 홉까지 통째로 검증하고, ACL 제거는 반드시 마지막에 합니다.
| 단계 | 작업 | 성공 신호 |
|---|---|---|
| 1 | 노드 label/taint, gateway 배포, Service에 7777 추가 | 파드가 egress 노드에만 스케줄, 7777 리스닝 |
| 2 | 객체 4종 적용 — exportTo는 파일럿 네임스페이스로 한정 | 파일럿 파드의 proxy-config listener --port 3128에 egressgateway 라우팅 |
| 3 | 파일럿 앱 실트래픽 (mTLS 홉 포함 검증) | Squid access.log의 클라이언트 IP가 워커노드 → egress 노드로 변경 — 앱 무변경 구조에서 유일하고 확실한 컷오버 신호 |
| 4 | exportTo 네임스페이스 점진 확대 → "*" | 에러율 무변화, gateway 커넥션 수 증가 곡선 |
| 5 | sidecar 미주입 인벤토리 정리 | 미주입 파드 0 (또는 예외 승인) — ACL 제거의 게이트 |
| 6 | 관찰 기간 — Squid 로그의 워커노드 IP 소스 | 일정 기간 0 유지 |
| 7 | 워커노드 → GW존 ACL 제거 (완료 조건) | 우회 시도가 timeout으로 관측 — 운영팀 공지 필수 |
| 8 | Calico로 클러스터 내부 이중 잠금 (Part 07) | 비-gateway 파드에서 3128 직행 실패 |
| 9 | SAN 로그 관찰 → AuthorizationPolicy 작성·적용 | gateway 로그 거부 0 → 이후 점진 축소 |
이 프로젝트의 보안 이득은 gateway를 세우는 날이 아니라 ACL을 제거하는 날 실현됩니다. sidecar 미주입 파드·hostNetwork 파드·노드 프로세스는 mesh 밖이므로, 방화벽 제거와 함께 클러스터 안쪽에서도 잠급니다. 전제 확인 한 줄 — 파드 트래픽이 노드 IP로 SNAT되는지:
calicoctl get ippool -o yaml | grep natOutgoing # → true (kubespray 기본값)
apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
name: squid-only-via-egressgw
spec:
selector: istio != 'egressgateway'
order: 100
types: [Egress]
egress:
- action: Deny
protocol: TCP
destination:
nets: ["<SQUID_VIP_1>/32", "<SQUID_VIP_2>/32"] # SE addresses와 동일한 GSLB VIP 집합
ports: [3128]
명시적 Deny 한 줄이라 나머지 트래픽은 기존 프로파일대로 흐릅니다. 전 클러스터 대상이므로 스테이징에서 order 충돌을 먼저 확인하세요.
장수명 커넥션(스트리밍·websocket·gRPC)의 목을 조를 수 있는 지점이 이제 세 층입니다 —
sidecar idle timeout → gateway TCP idle timeout(Envoy 기본 1시간) → Squid의
read_timeout/client_lifetime. 셋 중 최솟값이 실효값입니다.
해당 유형의 앱이 있다면 DestinationRule의 connectionPool.tcp.idleTimeout을
Squid 설정과 명시적으로 정렬하세요. 정렬하지 않으면 "가끔 끊기는데 재현 안 됨" 티켓이 생깁니다.
집약 후 Squid가 보는 소스는 전부 egress 노드 IP로 뭉개집니다 — "어느 팀/파드가 어느 도메인에 갔는가"를 Squid 로그 단독으로 소급할 수 없게 됩니다. 보완은 로그 이원화입니다: gateway의 SAN 로그(Part 5.3)가 "어느 워크로드가 언제 Squid로 향했나"를, Squid access.log가 "그 시각 어느 도메인으로 나갔나"를 담당하고 시간·커넥션 기준으로 상관 분석합니다. 감사 문서의 세그먼트 경계와 추적 방법이 바뀌는 지점이므로 전환 전에 감사 요건과 대조하세요.
실패 지점이 3단이 되면서 앱이 보는 증상으로 구간을 특정할 수 있습니다:
| 앱이 보는 증상 | 실패 구간 | 확인 위치 |
|---|---|---|
| 즉시 connection reset | 홉1 — mTLS 짝/SNI 불일치, 또는 AuthorizationPolicy 거부 | gateway 로그의 TLS 에러 / RBAC denied |
| reset (gateway는 정상 수신) | 홉2 — gateway → Squid 연결 실패 (ACL·스케줄 어긋남) | gateway 파드의 노드 확인, natOutgoing |
| 403 + HTML 응답 | Squid 도메인 ACL 차단 — 정상 동작 | X-Squid-Error 헤더 → Squid팀 문의 |
| 503 (Squid 발신) | Squid → origin의 DNS/연결 실패 | Squid access.log |
| connection timeout | 방화벽 drop — 우회 시도 또는 미주입 파드 (ACL 제거 후) | sidecar 주입 여부, 방화벽 로그 |
| 증상 | 유력 원인 | 확인 |
|---|---|---|
| 앱 reset, gateway 로그에 TLS handshake error | DR portLevelSettings 누락/포트 오타 → sidecar가 평문 송신 | istioctl proxy-config cluster <파드> | grep egressgateway의 tlsMode |
| 앱 reset, 필터체인 미매칭 | DR sni ↔ Gateway hosts 불일치 | proxy-config listener deploy/istio-egressgateway --port 7777의 server_names |
| mTLS 핸드셰이크 성공 직후 연결 종료 | SE exportTo에 "." 누락 → gateway에 Squid 클러스터 부재 (4.7) | proxy-config clusters deploy/istio-egressgateway | grep squid가 빈 출력인지 |
| 특정 파드만 timeout (ACL 제거 후) | sidecar 미주입 → 방화벽 drop | 파드 컨테이너 목록에 istio-proxy 존재 여부 |
| 전체 timeout, gateway → Squid 연결 실패 | gateway 파드가 egress 노드 밖으로 재스케줄 | kubectl get pod -o wide로 노드 확인, taint/nodeSelector |
| 약 1시간 주기의 간헐 스트림 끊김 | Envoy TCP idle timeout 기본값 | DR idleTimeout과 Squid read_timeout 정렬 |
| 403 + HTML (JSON 파서 에러로 표면화) | Squid 도메인 ACL — 정상 차단 | 응답의 X-Squid-Error, Squid팀 ACL 확인 |
| 503 | Squid의 origin DNS/연결 실패 | Squid access.log의 HIER_ 필드 |
| 정책 적용 후 특정 잡만 거절 | AuthorizationPolicy 인벤토리 누락 (크론잡 등) | gateway 로그의 denied + SAN, 규칙에 principal 추가 |
spiffe://cluster.local/ns/<ns>/sa/<sa> 형식의 워크로드 신원. 인증서 URI SAN에 각인.gateways: ["mesh"]는 모든 sidecar에 규칙을 심는다는 뜻.