Platform Engineering · Network Design Doc

Outbound 집약 설계서
Istio Egress Gateway로 Squid 경로 일원화

모든 워커노드가 각자 GW존 Squid로 향하던 outbound 경로를 app존의 전용 egress 노드로 모아, 방화벽 ACL 대상을 "클러스터 전체"에서 "egress 노드 몇 대"로 줄이는 설계입니다. 이 문서는 배경지식이 없는 독자도 따라올 수 있도록 forward proxy의 동작 원리부터 시작합니다.

결정 A안 — 앱 CONNECT 유지 홉 보안 ISTIO_MUTUAL (day-one) 원칙 무장애 최우선 환경 Calico + Istio(Envoy)
대상 독자 플랫폼/DevOps · SRE · 보안감사 대응자 전제 GW존 방화벽 준비 완료 · 용량 산정 완료
문서 전체에서 쓰는 색 규약 — amber · 프록시가 읽을 수 있는 구간 teal · 프록시에게 불투명한(암호화) 구간
Part 00

요약과 결정사항

바꾸는 것은 경로이지, 프록시가 아닙니다. 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 제거는 반드시 마지막 단계
READING GUIDE

구현만 필요하면 Part 04부터 읽어도 됩니다. 다만 Part 01–02의 배경(특히 "CONNECT가 무엇을 실어 나르고 프록시가 무엇을 볼 수 있는가")은 Part 05의 설계 판단과 Part 07의 함정들이 그런지를 설명하는 토대이므로, 이 구조를 처음 보는 독자는 순서대로 읽기를 권합니다.

Part 01

배경 — forward proxy와 HTTP_PROXY

1.1 왜 Squid를 쓰는가

프록시(proxy)는 "대신 연결해 주는 중계 서버"입니다. 그중 forward proxy는 내부에서 인터넷으로 나가는 방향을 담당하며, Squid는 이 역할의 대표적인 소프트웨어입니다.

쓰는 이유는 밖으로 나가는 문을 하나로 모으기 위해서입니다. 파드 수천 개가 제각각 인터넷에 나가면 누가 어디로 나갔는지 알 수도, 막을 수도 없습니다. 그래서 방화벽으로 "프록시 말고는 아무도 밖으로 못 나감"(default-deny)을 만들고, 유일하게 열린 문인 Squid에서 허용·차단·기록을 한 곳에 모읍니다.

핵심 전제

통제의 힘은 방화벽에서 나오고, Squid는 그 유일한 문일 뿐입니다. 이 사실은 문서 끝까지 반복해서 등장합니다 — egress gateway를 도입해도 마찬가지로, 진짜 강제력은 "워커노드 ACL 제거"라는 방화벽 변경에서 나옵니다.

1.2 HTTP_PROXY 환경변수의 정체

이 변수는 운영체제 기능이 아닙니다. 커널과 소켓 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 / libcurlOhttp_proxy는 소문자만 인식(httpoxy 취약점 여파). HTTPS_PROXY/NO_PROXY는 대소문자 모두
Go net/http 기본 TransportOTransport당 최초 1회만 읽어 캐시 — 런타임 중 env 변경 미반영. loopback 목적지는 항상 직결
Python requests / urllibO대소문자 모두
Java 표준 클라이언트X-Dhttps.proxyHost / -Dhttps.proxyPort / -Dhttp.nonProxyHosts 시스템 프로퍼티 필요
Node.js core http · 내장 fetchXaxios 등 일부 라이브러리는 자체 구현으로 읽음
함정

"같은 파드에서 curl은 되는데 앱은 connection timeout" — 십중팔구 위 표의 미지원 런타임입니다. 앱이 env를 무시하고 목적지로 직접 나가려다 방화벽(default-deny)에 막혀 조용히 timeout 됩니다. 또한 HTTPS_PROXY는 "대상 URL이 https일 때 쓸 프록시"라는 선택 기준이지 프록시와의 연결이 TLS라는 뜻이 아닙니다 — 값이 http://squid...:3128인 것이 정상입니다.

1.3 어디에 적는가 — 등록소는 없다

자연스러운 다음 질문: "그래서 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 전용입니다. 앱 트래픽과 평면이 달라서, "노드에 프록시를 설정했는데 앱이 안 나가요"는 이 둘을 섞은 전형적인 혼동입니다.

1.4 두 변수, 두 요청 형태 — 스킴이 갈림길이다

이름 때문에 흔히 생기는 오해를 여기서 정리합니다. HTTP_PROXYHTTPS_PROXY는 "프록시에게 어떻게 말할지"를 정하는 변수가 아닙니다. 대상 URL의 스킴에 따라 어느 프록시 주소를 쓸지 고르는 주소록일 뿐이며(보통 둘 다 같은 Squid 주소), 프록시 소켓에 무엇을 보낼지는 변수명이 아니라 대상 URL의 스킴이 결정합니다.

App 대상 URL 스킴 판별 http:// https:// GET http://host/path HTTP/1.1 absolute-form · 요청 전체가 평문 Squid가 HTTP로 개입 파싱 · 캐시 · 재작성 전달 CONNECT host:443 HTTP/1.1 authority-form · 이 교환만 평문 200 원시 바이트 터널 더는 HTTP가 아님 변수는 프록시 주소 선택일 뿐 — 무엇을 보낼지는 대상 스킴이 결정한다
그림 1 · 스킴의 갈림길 — 같은 프록시라도 http://와 https://는 완전히 다른 대화를 한다
대상 URL읽는 변수프록시 소켓에 보내는 것프록시의 역할
http://HTTP_PROXYGET http://host/path HTTP/1.1 — absolute-form 일반 요청. CONNECT를 쓰지 않음완전한 HTTP 중개자 — 파싱·캐시·헤더 조작 후 origin-form으로 재작성해 전달
https://HTTPS_PROXYCONNECT host:443 HTTP/1.1 — authority-form200 회신 후 무해석 바이트 릴레이(터널)

CONNECT 자체는 GET·POST와 같은 급의 표준 HTTP/1.1 메서드입니다. 평문 요청 라인과 헤더를 보내면 프록시는 상태 라인이 있는 정상 HTTP 응답을 돌려줍니다 — 허용이면 200 Connection established, 정책 거부면 403, 프록시 인증이 필요하면 407. 특수한 점은 두 가지뿐입니다:

request-target예시쓰이는 곳
origin-formGET /path HTTP/1.1프록시 없이 서버에 직접 요청할 때
absolute-formGET http://host/path HTTP/1.1평문 http:// 요청을 프록시에 위탁할 때
authority-formCONNECT host:443 HTTP/1.1프록시에 터널 개설을 요청할 때
참고

변수 의 스킴은 별개의 축입니다 — http://squid...는 프록시와의 연결이 평문이라는 뜻이고(1.2의 주의 재확인), 프록시 구간 자체를 TLS로 감싸는 https:// 스킴 프록시도 존재합니다. 그 경우에도 TLS 안에서 오가는 대화는 위와 동일한 CONNECT / absolute-form입니다. 실측 바이트 캡처는 2.2절에 있습니다.

1.5 NO_PROXY — 독립 스위치가 아니라 예외 목록

NO_PROXY의 정확한 지위부터 잡아야 합니다. 라이브러리는 요청을 만들 때마다 아래 결정을 돌고, NO_PROXY는 그 2단계에서만 읽히는 예외 목록입니다:

GET https://api.example.com/… 1 · 대상 스킴의 프록시 변수가 있는가? https → HTTPS_PROXY · http → http_proxy 없음 있음 2 · 호스트가 NO_PROXY와 매치되는가? 문자열 비교 — DNS 선행 없음 매치 미매치 직접 연결 (시도) 도달 여부는 방화벽이 결정 프록시 경유 CONNECT / absolute-form NO_PROXY는 독립 스위치가 아니라 2단계 안의 예외 목록이다
그림 2 · 요청마다 도는 프록시 결정 — 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/8https://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위입니다.

Part 02

요청 흐름 해부 — CONNECT의 실제

이 파트가 문서 전체의 기술적 토대입니다. "프록시를 지나간다"는 말이 바이트 수준에서 무엇을 뜻하는지 알아야, 뒤에서 나올 "gateway는 최종 목적지를 모른다"거나 "SNI만 평문"이라는 설계 근거가 이해됩니다.

2.1 기준선 — 프록시가 없을 때

앱이 https://api.example.com/api/ping을 직접 호출하면 네 가지 일을 전부 앱이 합니다: ① 이름 해석(DNS) ② TCP 연결 ③ TLS 핸드셰이크(인증서 검증 포함) ④ 요청 전송.

App (pod) DNS api.example.com:443 1 · resolve api.example.com A 93.184.216.34 2 · TCP connect :443 TLS end to end — 앱이 origin 인증서를 검증 3 · ClientHello (SNI는 평문) cert CN=api.example.com 4 · GET /api/ping (암호화) 200 (암호화)
그림 3 · 직접 호출 — 이름 해석·연결·TLS·요청 전부를 앱이 수행

2.2 HTTPS_PROXY가 설정됐을 때 — CONNECT 터널

같은 URL인데 앱의 행동이 완전히 바뀝니다. 흐름은 두 국면으로 나뉩니다. 앞부분은 프록시가 전부 읽을 수 있는 평문 국면, 뒷부분은 프록시가 의미를 모르는 바이트만 나르는 암호화 국면입니다.

App (pod) Squid gw-zone:3128 DNS Origin :443 PHASE 1 · 평문 — Squid가 읽음 1 · 프록시 이름만 해석 2 · TCP :3128 3 · CONNECT host:443 (평문) ACL 검사 — first match 4 · origin 이름 해석 5 · TCP connect 6 · 200 Connection established PHASE 2 · 암호화 — Squid는 릴레이 7 · TLS 핸드셰이크 — SNI만 평문 노출 8 · 요청/응답 (Squid에게 불투명)
그림 4 · CONNECT 터널 — 평문 국면(amber)에서만 Squid가 내용을 읽고, 이후(teal)는 바이트 복사

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이 그대로 흐릅니다.

2.3 프록시는 무엇을 보고, 무엇을 근거로 판단하는가

HTTPS (CONNECT 터널)평문 HTTP (absolute-form)
봄: 클라이언트 IP · CONNECT의 host:port(클라이언트가 주장한 문자열) · 바이트 수와 시간 · (설정 시) Proxy-Authorization
못 봄: 메서드 · 경로 · 쿼리 · 헤더 · 바디 — 전부 TLS 안
전부 봄: 전체 URL · 헤더 · 바디. 캐시하고, 내용을 바꾸고, Via / X-Forwarded-For 헤더를 붙일 수 있음

따라서 HTTPS에 대한 허용/차단 판단의 근거는 출발지 IP + CONNECT의 host:port (+ 인증)이 전부입니다. 검증 강도를 높이는 사다리가 존재합니다:

모드동작대가
splice (기본)CONNECT 문자열을 믿고 릴레이없음 — 현재 운영 모드
peekTLS ClientHello까지만 훔쳐봐 SNI를 읽고 CONNECT host와 대조. 복호화 없음설정 복잡도
bumpMITM — 사설 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를 가로채는 중

2.4 이름 해석(DNS)은 누가 하는가

프록시 없음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를 유지하므로 이 역할 배치는 전환 후에도 변하지 않습니다.

2.5 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 때문에 쿼리스트링은 로그에서 제거됩니다 — "평문이면 다 남는다"도 절반만 맞습니다.

Part 03

목표 아키텍처

현재는 모든 워커노드가 각자 GW존 방화벽 ACL을 통해 Squid로 나갑니다. 목표는 그 경로를 app존의 전용 egress 노드로 모으고, 전환 완료 후 워커노드별 ACL을 제거하는 것입니다.

App zone GW zone Worker node pods + sidecar Worker node pods + sidecar Egress node egress gateway pod Squid :3128 도메인 ACL · origin DNS Internet 붉은 점선 — 현재의 노드별 직행 경로. 전환 완료 후 방화벽 ACL과 함께 제거
그림 5 · 목표 토폴로지 — Squid는 유지, 경로만 egress 노드로 집약

최종 트래픽 경로는 다음과 같습니다:

app (HTTP_PROXY 유지) → sidecar가 :3128 TCP 인터셉트
  → [ISTIO_MUTUAL] → egress gateway (:7777, egress 노드 고정)
  → plain TCP → squid.gw-zone.example:3128 → internet

3.1 설계 원칙

3.2 왜 A안인가 — 체이닝 방식 비교

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의 영역입니다.

Part 04

Istio 구성 상세

필요한 객체는 네 종류가 전부입니다. 각 객체가 어느 홉에 작용하는지부터 보면 나머지는 배선 문제입니다.

App pod + sidecar HTTP_PROXY 유지 egress gateway :7777 · egress 노드 고정 Squid :3128 · GW존 mTLS plain TCP DestinationRule · 홉1 mTLS + SNI ServiceEntry · addresses 명시 Gateway · TLS 종료 — AuthorizationPolicy · L4 신원 VirtualService · 홉1(mesh)과 홉2(gateway)를 하나로 배선
그림 6 · 객체 ↔ 홉 대응 — 각 리소스가 정확히 한 지점씩 책임진다

4.1 노드 준비와 gateway 배치

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대 구성은 그 노드 유지보수가 곧 전사 외부 통신 중단입니다.

4.2 ServiceEntry — Squid를 mesh에 등록

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

addresses와 resolution — 같은 이름을 쓰는 서로 다른 두 단계

이 두 필드는 모순처럼 보이지만("IP를 박는데 왜 DNS?") 각각 다른 단계를 지시합니다. sidecar(Envoy 프로세스 하나) 안에는 테이블이 두 개 있고, DNS의 답이 들어가는 곳은 그중 하나뿐입니다:

앱 트래픽 — 앱이 dial한 IP:port sidecar — Envoy 프로세스 하나 ① 리스너 사전 — 매칭 키: 원래 목적지 IP:port 키의 출처: addresses (사람) DNS 답 유입 ✗ ② 클러스터 — 발신 squid.gw-… = STRICT_DNS 연결 직전 주기 재해석 (5s) DNS 답 유입 ○ DNS 서버 (GSLB 응답) 주기 질의 DNS의 답은 ②로만 들어간다 — ①의 키는 addresses가 공급한다
그림 7 · 한 프로세스, 두 테이블 — Envoy는 이름을 해석하지만 그 답이 매칭 키가 되는 일은 없다
단계지시 필드성격IP의 출처
① 매칭 (잡기)addresses정적 — xDS로 미리 푸시사람이 적은 리터럴
② 발신 (보내기)resolution: DNS동적 — STRICT_DNS 주기 해석연결 시점의 DNS 답

즉 이 구조에는 같은 이름을 해석하는 주체가 셋 있습니다 — 앱(요청 직전 getaddrinfo), Envoy 클러스터(발신 직전), 그리고 addresses는 해석하지 않는 인간의 선언. Squid VIP가 바뀌면 따라가는 쪽은 ②(이미 DNS)이고, ①은 사람이 바꿔야 합니다.

왜 매칭 키는 DNS로 만들 수 없는가

두 자리가 DNS에 던지는 질문의 종류가 다르기 때문입니다. 발신(②)에 필요한 답은 "지금 살아있는 주소 하나"(존재 질의 — DNS가 잘하는 것)지만, 매칭(①)에 필요한 답은 "앱이 받았을 수 있는 모든 답의 집합"(전칭 질의)인데 DNS 프로토콜에는 전칭을 묻는 쿼리가 없습니다 — 매 질의는 표본 하나일 뿐입니다. 특히 단일 로테이션 A 레코드(우리 GSLB)에서는 Envoy의 스냅샷이 어느 순간에도 1개라, 그걸 키로 쓰면 풀이 N개일 때 앱 dial의 (N−1)/N이 리스너를 비켜갑니다. 또한 istiod가 설정 시점에 해석해 키를 만들면 앱이 요청 시점에 받은 답과 어긋나는 race가 생깁니다 (다른 시각·다른 리졸버·로테이션). 그래서 Istio의 선택지는 둘뿐입니다 — 사람이 집합을 선언하거나(addresses), 모른다고 전부를 주장하거나(0.0.0.0).

판별자 사다리 — 왜 이 포트만 addresses가 필요한가

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가 아니라 어느 체인에도 미매치 → 연결 리셋됩니다.)

addresses 부재의 세 가지 상태

상태등록 형태결과
A같은 포트의 SE들이 전부 addresses 보유 (키 상이)완벽 공존 — (IP,port) 사전 찾기라 섞임 없음
Baddresses 없는 SE 1개0.0.0.0_3128 와일드카드 — 자기 트래픽은 동작하지만 무관한 모든 3128 트래픽을 흡입(하이재킹). 구체 키 리스너는 와일드카드보다 항상 우선(most-specific-wins)이라 A쪽 SE는 무사
Caddresses 없는 SE 2개 이상같은 키를 복수가 주장하는 정의 충돌 — istiod가 하나만 반영(비결정적), 진 쪽 host로 dial한 트래픽이 이긴 쪽 목적지로 물리적 오배송
함정 — 침묵 오배송

B 상태에서 어느 팀이 내부 도구를 internal-tool.corp:3128로 열면, 그 트래픽은 조용히 이 SE의 경로(gateway→Squid)로 회송되어 앱은 Squid의 400/403 HTML을 받습니다 — 어느 로그에도 "왜"가 남지 않습니다. 라우팅 목적지(VS가 결정)는 addresses 유무와 무관하게 동일하므로 기능은 멀쩡히 동작하는데, 이것이 오히려 생략을 눈치채지 못하게 만듭니다. addresses는 동작의 필드가 아니라 잡는 범위를 의도한 좌표로 못 박는 격리의 필드입니다.

GSLB 가변 A레코드 대응 — 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는 발신에만 유입된다"의 물증

4.3 DestinationRule — 홉 1을 mTLS로

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

4.4 Gateway — egress gateway의 수신 리스너

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 리스너의 강제 수단으로 의존하지 마세요.

4.5 VirtualService — 두 홉을 하나로 배선

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.6 층위 읽기 — Gateway의 TLS와 HTTP/1.1 프록시 대화

4.3~4.5를 겹쳐 읽으면 자연스러운 질문이 생깁니다 — "Gateway는 TLS로 받는데, 프록시 대화는 전부 평문 HTTP/1.1 아닌가?" 답은 층이 다르다입니다. Gateway의 TLS는 홉 ② (sidecar→gateway) 구간의 포장이고, 앱의 HTTP/1.1 대화는 그 포장 안의 내용물로 통과합니다. 홉별로 와이어에 실리는 것을 보면:

① app → sidecar (:3128 캡처) CONNECT api.example.com:443 HTTP/1.1 평문 — Part 02에서 해부한 그대로 ② sidecar → gateway (:7777) ISTIO_MUTUAL mTLS 봉투 — sidecar가 씌우고(DR) gateway가 벗김(Gateway) CONNECT … — 내용물 무변경, HTTP/1.1 그대로 터널 국면: app↔origin TLS 레코드까지 이중 암호화 ③ gateway → Squid (:3128) CONNECT … — 다시 평문 mTLS는 gateway에서 종단 — Squid는 원본 바이트를 받는다 ④ Squid → origin (:443) app ↔ origin TLS 레코드만 CONNECT는 ③에서 Squid가 소비함 Gateway의 TLS는 ② 구간의 포장일 뿐 — 앱도 Squid도 대화가 변하지 않는다
그림 8 · 홉별 와이어 — 프록시 대화를 HTTP로 읽는 주체는 전 구간에서 Squid뿐이다
지시객체·필드의미
봉투 씌우기 (홉② 발신)DR portLevelSettings[7777].tls: ISTIO_MUTUAL7777로 갈 때만 mTLS. sni는 그 TLS에 실을 판별자(Gateway hosts와 일치)
봉투 벗기기 (홉② 종단)Gateway tls.mode: ISTIO_MUTUALmesh 인증서 없이는 입장 불가 — 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. 부록 트러블슈팅 표의 첫 두 행이 이 메커니즘입니다.

4.7 exportTo — 파일럿 격리와 세 객체 정합

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가 어긋나면 "부분 가시성"이라는 미묘한 장애가 됩니다. 어느 하나만 안 보이는 네임스페이스에서의 증상:

안 보이는 객체그 네임스페이스의 동작증상
SE3128 리스너 자체가 없음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도 검토 대상입니다.

4.8 검증 명령

# 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로 바뀌는지 확인
Part 05

워크로드 단위 통제 — AuthorizationPolicy

이 파트가 홉 1을 굳이 ISTIO_MUTUAL로 만든 이유입니다. 오늘의 구조에는 "누가 프록시를 쓸 수 있는가"에 대한 통제가 없습니다(노드 단위 ACL = 노드 위 모든 파드가 사용 가능). 전환과 함께 이 통제를 새로 확보합니다.

5.1 왜 mTLS가 전제인가 — 신원은 인증서에만 실린다

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는 신원을 암호학적으로 검증 가능한 유일한 운반체인 인증서에만 묶었습니다.

istiod (CA) 워크로드 인증서 발급 · 24h 클라이언트 인증서 URI SAN에 SPIFFE ID 각인 gateway RBAC 필터 SAN 파싱 → 정책 매칭 mTLS 핸드셰이크 spiffe://cluster.local/ns/payments/sa/api-worker source.namespaces 매칭 = 이 문자열의 /ns/ 세그먼트 추출 — IP 조회가 아님
그림 9 · 신원의 운반 경로 — 인증서가 없으면 namespaces/principals는 성립하지 않는다

5.2 정책으로 가능한 것과 불가능한 것

가능 (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면 해당 없음.

5.3 무장애 정책 롤아웃 — 관찰 → 작성 → 적용

mTLS를 켜는 것과 정책을 거는 것은 분리할 수 있고, 분리해야 합니다. 전환 기간의 mTLS는 통제가 아니라 신원 수집 인프라입니다.

  1. 전환 완료까지 정책 없이 mTLS만 운영.
  2. gateway access log에 피어 신원을 찍어 실사용 인벤토리를 수집:
# 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
  1. 관찰된 SPIFFE ID 목록으로 ALLOW 규칙을 작성해 적용하고, gateway 로그에서 거부(denied)가 0인 것을 확인한 뒤 필요하면 좁혀갑니다.
함정 — dry-run 부재

AuthorizationPolicy에는 성숙한 dry-run 모드가 없습니다(AUDIT 액션은 실험 단계). "관찰 → 작성 → 적용" 순서가 사실상의 dry-run이며, 이를 건너뛰면 인벤토리에 없던 배치잡·크론잡이 첫 실행 때 조용히 거절당하는 것이 전형적인 사고 패턴입니다.

Part 06

무장애 전환 절차

롤백 원칙

기존 노드 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 노드로 변경 — 앱 무변경 구조에서 유일하고 확실한 컷오버 신호
4exportTo 네임스페이스 점진 확대 → "*"에러율 무변화, gateway 커넥션 수 증가 곡선
5sidecar 미주입 인벤토리 정리미주입 파드 0 (또는 예외 승인) — ACL 제거의 게이트
6관찰 기간 — Squid 로그의 워커노드 IP 소스일정 기간 0 유지
7워커노드 → GW존 ACL 제거 (완료 조건)우회 시도가 timeout으로 관측 — 운영팀 공지 필수
8Calico로 클러스터 내부 이중 잠금 (Part 07)비-gateway 파드에서 3128 직행 실패
9SAN 로그 관찰 → AuthorizationPolicy 작성·적용gateway 로그 거부 0 → 이후 점진 축소
Part 07

운영 유의사항과 함정

7.1 강제력의 완성 — Calico 이중 잠금

이 프로젝트의 보안 이득은 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 충돌을 먼저 확인하세요.

7.2 타임아웃 3층

장수명 커넥션(스트리밍·websocket·gRPC)의 목을 조를 수 있는 지점이 이제 세 층입니다 — sidecar idle timeout → gateway TCP idle timeout(Envoy 기본 1시간) → Squid의 read_timeout/client_lifetime. 셋 중 최솟값이 실효값입니다. 해당 유형의 앱이 있다면 DestinationRule의 connectionPool.tcp.idleTimeout을 Squid 설정과 명시적으로 정렬하세요. 정렬하지 않으면 "가끔 끊기는데 재현 안 됨" 티켓이 생깁니다.

7.3 감사 추적의 단절 (PCI 관점)

집약 후 Squid가 보는 소스는 전부 egress 노드 IP로 뭉개집니다 — "어느 팀/파드가 어느 도메인에 갔는가"를 Squid 로그 단독으로 소급할 수 없게 됩니다. 보완은 로그 이원화입니다: gateway의 SAN 로그(Part 5.3)가 "어느 워크로드가 언제 Squid로 향했나"를, Squid access.log가 "그 시각 어느 도메인으로 나갔나"를 담당하고 시간·커넥션 기준으로 상관 분석합니다. 감사 문서의 세그먼트 경계와 추적 방법이 바뀌는 지점이므로 전환 전에 감사 요건과 대조하세요.

7.4 에러 시그니처의 변화

실패 지점이 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 주입 여부, 방화벽 로그

7.5 나머지 체크리스트

Part 08

부록 — 트러블슈팅 · 용어집

8.1 트러블슈팅 표

증상유력 원인확인
앱 reset, gateway 로그에 TLS handshake errorDR 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 확인
503Squid의 origin DNS/연결 실패Squid access.log의 HIER_ 필드
정책 적용 후 특정 잡만 거절AuthorizationPolicy 인벤토리 누락 (크론잡 등)gateway 로그의 denied + SAN, 규칙에 principal 추가

8.2 용어집

forward proxy
내부 → 인터넷 방향을 중계하는 프록시. 나가는 문을 하나로 모으는 장치.
CONNECT
"이 host:port로 가는 TCP 통로를 뚫어달라"는 평문 HTTP 메서드. 경로·쿼리는 실리지 않는다.
absolute-form
평문 http:// 대상일 때 프록시에 보내는 요청 형태. 요청 라인에 전체 URL이 담긴다.
SNI
TLS ClientHello에 평문으로 실리는 목적지 호스트명. 복호화 없이 읽을 수 있는 유일한 이름 신호.
splice / peek / bump
Squid의 TLS 개입 3단계 — 릴레이만 / SNI만 확인 / 완전 복호화(MITM).
HIER_DIRECT
Squid access.log 필드. Squid가 스스로 해석해 연결한 origin IP의 기록.
SPIFFE ID
spiffe://cluster.local/ns/<ns>/sa/<sa> 형식의 워크로드 신원. 인증서 URI SAN에 각인.
ISTIO_MUTUAL
Istio가 발급·관리하는 인증서로 양방향 TLS를 맺는 모드. 신원 기반 정책의 전제.
ServiceEntry
mesh 외부 목적지를 Istio 레지스트리에 등록하는 객체. addresses로 매칭 IP를 고정할 수 있다.
Gateway (Istio)
gateway 파드 Envoy의 리스너 정의. tls.mode가 해당 포트의 수신 보안을 결정한다.
VirtualService
라우팅 규칙. gateways: ["mesh"]는 모든 sidecar에 규칙을 심는다는 뜻.
AuthorizationPolicy
수신 측 Envoy RBAC 필터의 정책. TCP에서는 L4 필드(principal·namespace·port)까지만.
natOutgoing (Calico)
파드가 클러스터 밖으로 나갈 때 노드 IP로 SNAT하는 IPPool 옵션. 노드 IP 기반 ACL의 전제.
exportTo
Istio 객체의 가시 범위. 파일럿 → 전체 확대의 점진 전환 스위치로 사용.