--- type: src tags: [istio, graceful-termination, k8s, ingressgateway, manifests, homelab] created: 2026-06-07 --- # Manifests Walkthrough — IGW 커스텀 Deployment + Service + Gateway/VS > [!abstract] > 홈랩 graceful termination 실험의 `manifests/` 7개 파일을 "왜 이 값인가"까지 해부한 정본 워크스루다. **하나의 그림으로 잡을 것**: 이 매니페스트의 모든 숫자와 모든 path·selector·`externalTrafficPolicy`는 단 **두 축**에서 파생된다 — (A) *타이밍 불변식*: 가장 긴 in-flight 요청(`/sleep?seconds=300`)이 어느 종료 단계에서도 먼저 끊기지 않도록 모든 데드라인을 정렬한다, (B) *순서 제어*: 트래픽을 LB 먼저·endpoint 나중 순서로 빼낸다. 5가지 파일 차이·NodePort 라우팅·Gateway selector 필연성은 전부 이 두 축의 따름정리다. 멘탈모델은 [IGW 커스텀 deployment](gt__src-w3-igw-deployment.html), 종료 FSM은 [HC FSM](gt__src-w2-hc-fsm.html)을 참고. > **명명 매핑(2026-04-26)**: READY/.../FAILED → OPEN/DRAINING/CLOSING/CLOSED/FAULT. 신규 `POST /reopen`(DRAINING/CLOSING→OPEN abort, CLOSED는 409 unrecoverable). > [!warning] 관련 파일 · 참조 > 이 문서가 해부하는 정본 매니페스트(`manifests/00-namespace.yaml` … `31-virtualservice.yaml` 7개)는 현재 레포 스냅샷 어디에도 실파일로 존재하지 않는다. 아래 본문의 인라인 발췌가 유일한 기록이며, 완전한 파일이 아니라 fragment 인용임에 유의(§7의 `kubectl apply -f manifests/*.yaml`·§3 diff는 원본 파일 복원 전까지 그대로 재현 불가). --- ## 1. 배경 — 왜 IGW를 "수동으로" 만드는가 Istio를 쓰면 보통 IngressGateway는 `IstioOperator`나 Helm이 자동 생성해준다. 그런데 이 실험은 **graceful termination**을 다룬다 — pod이 죽을 때 in-flight 요청(최대 5분짜리 `/sleep?seconds=300`)을 한 건도 끊지 않고 흘려보내는 것이 목표다. 자동 생성된 IGW는 종료 시퀀스가 블랙박스라 손댈 수 없다. 그래서 IGW를 **평범한 Deployment로 직접 작성**해 envoy 컨테이너 옆에 health-check(`hc`) 사이드카를 붙이고, `preStop` hook과 probe path를 우리 손으로 제어한다. 이 구조를 이해하려면 종료 시점에 **누가 누구에게 트래픽을 보내는지**부터 그려야 한다. ```mermaid flowchart LR Client[external client] --> HAP[HAProxy
TLS offload :443] HAP -->|httpchk :30180| NPh[NodePort 30180
hc :18180] HAP -->|traffic :30080| NPt[NodePort 30080
envoy :8080] NPt --> ENV[istio-proxy
Envoy router] NPh --> HC[hc sidecar
FSM + probe] ENV -->|VS route| BE[backend ClusterIP
:8080] HC -.controls.-> ENV ``` **선행 개념**: HAProxy가 앞단에서 TLS를 벗긴 뒤 NodePort 30080(traffic)·30180(health)으로 plaintext를 넘긴다. HAProxy는 30180의 `httpchk` 응답이 503이 되면 backend를 DOWN으로 마킹해 새 트래픽 차단. 즉 **hc 컨테이너의 probe 응답이 HAProxy의 트래픽 라우팅을 좌우**한다 — 이것이 "순서 제어"가 가능한 이유다. 종료 FSM(OPEN→DRAINING→CLOSING→CLOSED)의 상태 전이가 각 probe path의 200/503을 결정한다. --- ## 2. 핵심 아키텍처 — 두 축이 모든 숫자를 낳는다 ### 축 A — 타이밍 불변식: "최장 요청이 먼저 끊기면 안 된다" 종료 경로에는 데드라인이 4개 있다. 어느 하나라도 최장 요청(300s)보다 짧으면 그 지점에서 요청이 잘린다. 그래서 전부 300s를 기준선으로 정렬한다. ``` /sleep?seconds=300 ← 최장 in-flight 요청 (기준선 300s) | +-- backend tGPS = 305s (300 + 여유 5s; kubelet SIGKILL 유예) +-- VS timeout = 305s (backend tGPS와 동일; Envoy가 먼저 504 내면 안 됨) +-- IGW Envoy drain = 150s (terminationDrainDuration) +-- IGW tGPS = 210s (max(drain 150, preStop ~30) + 여유 60) ``` 여기서 가장 비직관적인 부분이 IGW의 **tGPS=210s 산정**이다. tGPS는 직렬 합이 아니다. tGPS는 SIGTERM부터 SIGKILL까지의 단일 데드라인이고, 그 구간 안에서 **두 컨테이너가 병렬로** 종료한다: - **istio-proxy(Envoy)**: SIGTERM 즉시 `terminationDrainDuration: 150s` 동안 active connection drain → 최대 150s. - **hc 컨테이너**: SIGTERM 즉시 preStop 실행 → current는 `drain→close-lb→sleep 30`이라 ~30s. 두 컨테이너가 동시에 SIGTERM을 받으므로 임계 경로는 직렬 합(150+30)이 아니라 `max(150, 30) = 150s`다. ``` tGPS(210) = max(Envoy drain 150s, preStop ~30s) + 여유 60s = 150 + 60 ``` 핵심 불변식은 **tGPS > terminationDrainDuration**(210 > 150). 깨지면 Envoy가 drain을 끝내기 전에 kubelet이 SIGKILL을 보내 in-flight가 잘린다. (improved 모드의 preStop은 `DRAIN_TIMEOUT(120)+LB_BUFFER(10)`=최대 130s지만 이 역시 drain 150s와 병렬이라 임계 경로는 여전히 150s — 두 모드 모두 210s 안에 든다.) ### 축 B — 순서 제어: "LB 먼저, endpoint 나중" 요청을 안 끊으려면 트래픽을 **빼는 순서**가 결정적이다. 새 트래픽 유입을 막은(LB DOWN) **다음에** endpoint를 빼야, 아직 처리 중인 in-flight가 RST 없이 완주한다. 순서가 뒤집히거나 동시에 일어나면(current의 병폐) in-flight가 끊긴다. 이 순서를 강제하는 메커니즘이 **probe path 분리**다. 같은 path 하나(`/health_check.html`)를 K8s readiness·LB health·liveness가 공유하면, 그 path를 503으로 뒤집는 순간 세 가지가 **동시에** 터진다. path를 셋으로 쪼개면 각각을 다른 시점에 503으로 만들 수 있다 — 이것이 "순서 제어 축"의 물리적 구현이다. | path | 누가 보나 | 503이 되면 | |---|---|---| | `/health_check.html` | HAProxy `httpchk` (LB) | backend DOWN → 새 트래픽 차단 (**먼저**) | | `/health` | K8s readinessProbe | endpoint 제거 (**나중**) | | `/live` | K8s livenessProbe | (종료 중엔 절대 503 되면 안 됨 → 재시작 유발) | → **두 축이 만나는 지점**: hc 컨테이너의 FSM이 `CLOSING`으로 가면 `/health_check.html`을 503으로 — LB가 먼저 빠진다(축 B). 그 사이 Envoy는 150s drain으로 in-flight를 흘려보낸다(축 A). active=0이 확인되면 그제서야 `/health`를 503으로 — endpoint가 빠진다(축 B). 모든 것이 tGPS 210s 안에 끝난다(축 A). --- ## 3. current vs improved — 같은 파일, 5곳의 델타 `20-igw-current.yaml`과 `21-igw-improved.yaml`은 241~242 라인짜리 거의 동일한 파일이다. 차이는 딱 5곳이고, 5곳 전부가 **축 B(순서 제어)** 하나로 환원된다. current는 LB 차단·endpoint 제거·재시작을 같은 path(`/health_check.html`)에 묶어 동시에 터뜨리고, improved는 path를 셋으로 쪼개고 active=0을 폴링해 *LB→endpoint* 순서를 강제한다. | # | 항목 | current (20) | improved (21) | 근거 | |---|---|---|---|---| | 1 | Deployment `metadata.labels.mode` | `current` | `improved` | kubectl 변형 식별 | | 2 | Pod template `labels.mode` | `current` | `improved` | 실행 중 pod의 mode 추적 | | 3 | hc `readinessProbe.path` | `/health_check.html` | `/health` | K8s readiness ≠ LB health. 분리해야 drain 중 endpoint 제거 시점 제어 가능 | | 4 | hc `livenessProbe.path` | `/health_check.html` | `/live` | drain 중 `/health_check.html`이 503이 되면 liveness도 같은 path면 재시작 트리거 | | 5 | hc `preStop.command` + `env`(improved only) | `drain + close-lb + sleep 30`(no env) | `/opt/hc/graceful-drain.sh` + `DRAIN_TIMEOUT=120,LB_BUFFER=10,POLL_INTERVAL=2` | current는 즉시 503 flip→HAProxy 4s후 DOWN→in-flight RST. improved는 active=0 확인 후 flip | 변종을 "공통 뼈대 + 델타 5곳"으로 좁히면 본질이 드러난다 — IGW를 graceful하게 만드는 일은 곧 **이 5줄을 고치는 일**이다. --- ## 4. 구성 따라하기 — 파일별 라인 해설 이제 두 축을 머리에 넣고 7개 파일을 빌드 순서대로 읽는다. 매 줄을 "어느 축에 봉사하는가"로 읽으면 길을 잃지 않는다. ### `00-namespace.yaml` — sidecar inject 끄기 ```yaml name: service-a istio-injection: disabled ``` `istio-injection: disabled`는 네임스페이스 레벨에서 sidecar auto-inject를 끈다. IGW pod의 `sidecar.istio.io/inject: "false"` annotation과 이중 설정 — ns label이 있으면 annotation은 불필요하지만 명시적 의도를 드러내는 문서화 역할이다. **왜 끄나**: IGW는 envoy를 *우리가 수동으로* 컨테이너에 넣었으므로, webhook이 또 sidecar를 주입하면 envoy가 둘이 돼 충돌한다. `experiment: graceful-termination` label은 기능 없음 — `kubectl get ns -l experiment=...` 일괄 조회용. ### `10-backend.yaml` — 평범한 HTTP backend (축 A의 기준선) ```yaml replicas: 2 selector: matchLabels: app: backend ``` replicas=2 + `preferredDuringScheduling` anti-affinity. `preferred`라 deadlock 없음 — worker 한 대에 2개 올라가도 OK(IGW의 `required`와 대비). ```yaml terminationGracePeriodSeconds: 305 ``` **305s 산정**: 가장 긴 요청이 `/sleep?seconds=300`. kubelet이 tGPS 후 SIGKILL을 보내므로 300s + 여유 5s = 305s. 이것이 **축 A의 기준선**이고, VS timeout도 305s로 일치시킨다. ```yaml env: - name: HOSTNAME valueFrom: fieldRef: fieldPath: metadata.name ``` Downward API로 Pod 이름을 환경변수에 주입 → backend 응답 body에 `{"pod":"backend-xxxxx"}`로 어떤 replica가 응답했는지 추적. ```yaml readinessProbe: periodSeconds: 5 failureThreshold: 3 ``` 5s × 3회 = 최대 15s 후 endpoint 제거. backend readiness는 이 실험에 직접 개입하지 않지만 backend pod 비정상 시 IGW가 503을 반환하는 경로를 닫는다. **Service (ClusterIP)**: ```yaml type: ClusterIP - name: http port: 8080 targetPort: 8080 ``` ClusterIP — 클러스터 내부 전용. IGW(Envoy)가 VS의 `destination.host: backend.service-a.svc.cluster.local:8080`으로 라우팅한다. ### `20-igw-current.yaml` — broken IGW (라인별) **ServiceAccount** — istio-proxy가 `istio-token` projected volume으로 istiod에 신원을 증명할 때 이 SA의 JWT를 사용한다(`JWT_POLICY: third-party-jwt`와 연동). ```yaml name: service-a-igw namespace: service-a ``` **Deployment strategy** — `maxUnavailable=0` + `maxSurge=1`은 zero-downtime rollout 표준이지만, **`required` anti-affinity + replicas=노드수 조합에서 deadlock** — surge pod이 들어갈 빈 노드가 없다. 홈랩에서는 master1 untaint로 좌석을 늘려 해소. ```yaml replicas: 2 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 ``` **terminationGracePeriodSeconds** — current 모드의 tGPS는 210s. 산정 근거(병렬 타이밍, `max(150,30)+60`)는 §2 축 A에서 도출했다. 핵심 불변식은 **tGPS(210) > terminationDrainDuration(150)**. ```yaml terminationGracePeriodSeconds: 210 ``` **anti-affinity** — `required`(hard): 같은 hostname에 동일 app 2개 금지. rollout deadlock의 원인이지만 HA상 두 replica가 한 노드에 몰리는 것보다 낫다. ```yaml podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: service-a-igw topologyKey: kubernetes.io/hostname ``` **volumes** — SDS UDS 세 volume 없이 `readOnlyRootFilesystem: true`로 proxy를 구동하면 시작 즉시 실패한다(proxy가 UDS를 만들 곳이 없음). ```yaml - name: istiod-ca-cert # istio-ca-root-cert configMap - name: istio-token # projected SA token, audience=istio-ca - name: istio-data # emptyDir - name: istio-envoy # emptyDir: /etc/istio/proxy - name: config-volume # configMap: istio (optional=true) - name: podinfo # downwardAPI - name: workload-socket # emptyDir: SDS UDS (workload-spiffe-uds) - name: credential-socket # emptyDir: SDS UDS (credential-uds) - name: workload-certs # emptyDir: SDS UDS (workload-spiffe-credentials) ``` **istio-proxy 컨테이너** — `proxy router`로 Envoy를 IngressGateway 모드 기동(router는 istiod로부터 Gateway/VS xDS를 받아 외부→내부 라우팅; sidecar는 `sidecar` 인자). ```yaml args: - proxy - router # ingressgateway 모드 (sidecar는 "sidecar") - --domain - $(POD_NAMESPACE).svc.cluster.local ``` | env | 값 | 의미 | |---|---|---| | `PROXY_CONFIG` | `{"terminationDrainDuration":"150s"}` | Envoy drain 대기 시간. `proxy.istio.io/config` annotation 없이 env로 직접 주입(수동 IGW라 webhook이 annotation→env 변환을 안 해줌). 둘 다 있으면 일반적으로 annotation 우선이나 여기선 env 단일 경로 | | `PILOT_CERT_PROVIDER` | `istiod` | 인증서를 istiod가 xDS push로 배포 | | `CA_ADDR` | `istiod.istio-system.svc:15012` | xDS + SDS 연결 주소 | | `JWT_POLICY` | `third-party-jwt` | K8s SA JWT(audience=istio-ca)로 신원 증명 | | `ISTIO_META_MESH_ID` | `cluster.local` | 메시 식별자(istiod와 일치 필요) | ```yaml readinessProbe: httpGet: path: /healthz/ready port: 15021 periodSeconds: 2 failureThreshold: 30 ``` 15021은 Envoy status 포트. `failureThreshold: 30` = 2s × 30 = 최대 60s, Envoy가 istiod 초기 xDS를 받기까지 넉넉히 준다. ```yaml securityContext: runAsUser: 1337 runAsGroup: 1337 readOnlyRootFilesystem: true ``` UID 1337은 Istio 표준(iptables 룰에서 1337 트래픽은 인터셉트 제외). `readOnlyRootFilesystem: true` 때문에 위 SDS UDS emptyDir이 필수가 된다. **hc 컨테이너 — current 모드 (축 B의 병폐)**: ```yaml readinessProbe: httpGet: path: /health_check.html # HAProxy와 K8s가 같은 path 공유 (문제) livenessProbe: httpGet: path: /health_check.html # drain 시 503 → liveness 실패 → 재시작 ``` current 핵심 문제: K8s readiness·HAProxy health·liveness가 모두 `/health_check.html` 하나를 공유. preStop이 503으로 뒤집으면 (1) HAProxy backend DOWN→shutdown-sessions(RST), (2) K8s readiness 503→endpoint 제거, (3) liveness 503→컨테이너 재시작(failureThreshold=3이라 30s 후)이 **동시에** 일어난다 — 축 B가 무너진다. ```yaml lifecycle: preStop: exec: command: - /bin/sh - -c - "curl -sf -m 5 -XPOST http://127.0.0.1:18180/drain || true; curl -sf -m 5 -XPOST http://127.0.0.1:18180/close-lb || true; sleep 30" ``` FSM을 즉시 `DRAINING → CLOSING`로 전이 → `/health_check.html` 503. sleep 30은 HAProxy DOWN 감지(~4s)를 기다리는 의도지만, 이미 in-flight는 RST된 후다. ### `21-igw-improved.yaml` — graceful IGW (축 B 복원) 위 current에서 §3의 5줄만 바뀐다. 핵심은 path 분리 + active=0 폴링: ```yaml env: - name: DRAIN_TIMEOUT value: "120" - name: LB_BUFFER value: "10" - name: POLL_INTERVAL value: "2" readinessProbe: httpGet: path: /health # K8s 전용 livenessProbe: httpGet: path: /live # liveness 전용 lifecycle: preStop: exec: command: - /bin/sh - -c - /opt/hc/graceful-drain.sh ``` `graceful-drain.sh` 순서: (1) Envoy drain 시작, (2) `downstream_rq_active+upstream_rq_active==0` 폴링, (3) active=0→FSM CLOSING→`/health_check.html` 503, (4) LB_BUFFER 10s, (5) FSM CLOSED→`/health` 503→K8s endpoint 제거. 이것이 §2 축 B의 *LB먼저→endpoint나중* 순서의 코드 구현이다. 위 5단계는 FSM→path 매핑 요약이다. `/drain`(DRAINING window 개방)·`/drain_listeners`(Envoy admin) 호출까지 포함한 스크립트 7단계 라인별 분석은 [apps walkthrough §3](gt__src-apps-walkthrough.html)을 참조 — active 폴링이 왜 신규 유입 없이 단조감소하는지의 전제(`/drain_listeners`로 신규 연결을 먼저 막음)가 거기에 있다. **improved 종료 타임라인** (두 컨테이너 병렬, 임계 경로 = Envoy drain 150s): ```mermaid sequenceDiagram autonumber participant K as kubelet participant E as istio-proxy (Envoy) participant H as hc (graceful-drain.sh) participant L as HAProxy (LB) K->>E: SIGTERM K->>H: SIGTERM (preStop) Note over E: terminationDrainDuration 150s drain 시작 Note over H: Envoy drain trigger + active 폴링 H->>H: downstream+upstream rq_active == 0 대기 H->>L: FSM CLOSING -> /health_check.html 503 L-->>L: backend DOWN mark (httpchk) Note over H: LB_BUFFER 10s H->>K: FSM CLOSED -> /health 503 (readiness) K->>K: endpoint 제거 Note over E,K: max(drain 150s, preStop) < tGPS 210s K-->>E: SIGKILL @ 210s (도달 전 정상 종료 목표) ``` 핵심: `/health_check.html`(LB용)이 먼저 503이 되어 HAProxy가 새 트래픽을 끊고, **그 다음** `/health`(K8s readiness)가 503이 되어 endpoint가 빠진다. 순서가 바뀌면(current처럼 동시 flip) in-flight가 RST된다. ### `22-igw-service.yaml` — NodePort 3개 + Local (축 B 보강) ```yaml spec: type: NodePort externalTrafficPolicy: Local selector: app: service-a-igw ports: - name: http port: 8080 nodePort: 30080 # HAProxy traffic backend - name: hc port: 18180 nodePort: 30180 # HAProxy httpchk target - name: status port: 15021 nodePort: 32021 # Envoy 상태 디버그용 ``` `externalTrafficPolicy: Local`이 없으면 30180으로 들어온 health check가 다른 노드의 hc로 SNAT forwarding될 수 있다 → worker1에 pod이 없어도 worker1:30180이 200을 반환해 HAProxy가 worker1:30080으로 트래픽을 보내지만 backend는 응답 불가. `Local`이면 해당 노드에 ready endpoint가 없을 때 연결을 거부해 HAProxy가 올바르게 DOWN 마킹한다 — health check의 노드-국소성이 곧 라우팅 정확성이다. ### `30-gateway.yaml` — selector가 listener를 만든다 ```yaml spec: selector: istio: service-a-igw # IGW pod label과 일치해야 함 servers: - port: number: 8080 name: http protocol: HTTP # HTTPS 아님 hosts: - "*" ``` `selector: istio: service-a-igw`는 이 Gateway 설정을 어느 Envoy에 적용할지 결정 — pod template의 `labels.istio: service-a-igw`와 매칭. **이 label이 없으면 istiod가 어느 proxy에 배포할지 모른다** → Envoy가 8080 listener를 열지 않음. `protocol: HTTP`인 이유: HAProxy가 :443에서 TLS offload 후 worker:30080으로 plaintext를 전달하므로 Envoy까지 오는 트래픽은 이미 복호화됨. ### `31-virtualservice.yaml` — timeout이 축 A를 닫는다 ```yaml spec: hosts: - "*" gateways: - service-a-gateway http: - match: - uri: prefix: "/" route: - destination: host: backend.service-a.svc.cluster.local port: number: 8080 timeout: 305s ``` `timeout: 305s`는 backend tGPS 305와 의도적 일치 — `/sleep?seconds=300` 요청이 Envoy timeout으로 먼저 끊기지 않도록 보장(축 A의 마지막 데드라인). `hosts: ["*"]`는 실험 목적이라 도메인 미특정(프로덕션은 `example.local` 명시). --- ## 5. 예시 — 적용과 검증 ```bash # 전체 apply (current 모드 기준) kubectl --context homelab apply \ -f manifests/00-namespace.yaml -f manifests/10-backend.yaml \ -f manifests/20-igw-current.yaml -f manifests/22-igw-service.yaml \ -f manifests/30-gateway.yaml -f manifests/31-virtualservice.yaml kubectl --context homelab -n service-a get all,gateway,virtualservice kubectl --context homelab -n service-a rollout status deploy/service-a-igw --timeout=180s # current → improved 시 hc 컨테이너 diff (델타 5곳이 여기로 드러남) diff \ <(yq '.spec.template.spec.containers[] | select(.name == "hc")' manifests/20-igw-current.yaml) \ <(yq '.spec.template.spec.containers[] | select(.name == "hc")' manifests/21-igw-improved.yaml) # Envoy가 Gateway listener(8080)를 받았는지 — selector 매칭 검증 istioctl --context homelab -n service-a proxy-config listener \ $(kubectl --context homelab -n service-a get pod -l app=service-a-igw -o name | head -1) # SDS UDS volume 마운트 확인 (없으면 proxy CrashLoop) kubectl --context homelab -n service-a exec \ $(kubectl --context homelab -n service-a get pod -l app=service-a-igw -o name | head -1) \ -c istio-proxy -- ls /var/run/secrets/workload-spiffe-uds/ # externalTrafficPolicy: Local 실증 — pod 없는 노드의 health 포트는 거부돼야 한다 # 1) 어느 노드에 IGW pod이 있는지 확인 kubectl --context homelab -n service-a get pod -l app=service-a-igw -o wide # 2) pod 없는 worker의 30180(hc NodePort)으로 직접 curl → Local이면 connection refused/timeout curl -m2 http://:30180/health_check.html # 기대: curl: (7) Failed to connect ... Connection refused (또는 (28) timeout) # 3) 대조군: pod 있는 노드는 200 curl -m2 http://:30180/health_check.html # 기대: HTTP 200 # (Cluster로 바꾸면 pod 없는 노드도 SNAT forwarding으로 200이 새어 나와 HAProxy가 오판한다) ``` **회상 quiz**
Q1. VS timeout=305s와 backend tGPS=305s를 일치시키는 이유는? VS timeout이 더 짧으면 Envoy가 upstream 응답 전 504 반환. tGPS가 더 짧으면 backend 응답 완료 전 SIGKILL로 연결 끊김. 일치해야 "backend 처리 시간 = Envoy 대기 시간 = kubelet kill 유예"가 정렬된다(축 A).
Q2. Gateway selector를 pod label에 추가하지 않으면? istiod가 Gateway 배포 대상 proxy를 못 찾음 → Envoy가 8080 listener를 안 엶 → `curl`이 connection refused. `kubectl logs -n istio-system deploy/istiod | grep "no instances"` 또는 `istioctl proxy-config listener`에서 8080 부재로 확인.
Q3. ns의 `istio-injection: disabled`와 pod의 inject annotation 둘 다 있는 이유는? ns label이 최우선이라 annotation은 기능 중복. 그러나 annotation은 명시적 문서화 + 팀원이 ns label을 모를 때 second line of defense 역할.
--- ## 핵심 정리 - **모든 숫자는 두 축에서 나온다.** 축 A(타이밍 불변식): backend tGPS 305 = VS timeout 305 = 최장 요청 300+5; IGW tGPS 210 > terminationDrainDuration 150. 축 B(순서 제어): LB 먼저, endpoint 나중. - **tGPS 산식은 직렬 합이 아니라 `max(Envoy drain, preStop) + 여유`다.** 컨테이너가 SIGTERM을 동시에 받아 병렬 종료하므로 임계 경로는 `max(150, 30)=150`, 거기에 여유 60s = 210. 불변식은 **tGPS > terminationDrainDuration**. - **current↔improved는 241줄 중 5곳만 다르며**, 본질은 hc probe path 분리(`/health_check.html`/`/health`/`/live`)와 preStop(즉시 flip vs active=0 폴링)뿐이다 — 둘 다 축 B에 봉사. - **`required` anti-affinity + `maxUnavailable=0` + replicas=노드수 = rollout deadlock** — master1 untaint로 좌석을 늘려 해소. - **`externalTrafficPolicy: Local`과 Gateway selector↔pod label 매칭**이 각각 health check 정확성·listener 생성의 필수 조건이다. ## What you might be missing - **tGPS는 직렬 합이 아니다.** 컨테이너들은 SIGTERM을 동시에 받아 병렬 종료하므로 임계 경로는 `max(drain, preStop)`. tGPS를 `drain + preStop`으로 계산해 과대 설정하면 무해하지만, 반대로 tGPS < terminationDrainDuration이면 drain 완료 전 SIGKILL로 in-flight가 잘린다. - **`externalTrafficPolicy: Cluster`의 함정.** Cluster면 pod 없는 노드도 SNAT로 health check를 forwarding해 200을 반환 → HAProxy가 backend 없는 노드를 UP으로 오판하고 트래픽을 보내 502. NodePort health 라우팅에선 `Local`이 사실상 필수다. - **Gateway selector 누락 = listener 미생성.** `selector: istio: service-a-igw`가 pod label과 안 맞으면 istiod가 배포 대상 proxy를 못 찾아 Envoy가 8080 listener를 아예 열지 않는다. `curl`은 connection refused가 되며 에러 로그가 아니라 listener 부재로만 드러난다. - **SDS UDS emptyDir 3개 누락 → 기동 실패.** `readOnlyRootFilesystem: true`라 `workload-socket`/`credential-socket`/`workload-certs` volume이 없으면 proxy가 UDS를 만들 곳이 없어 시작 즉시 죽는다(런타임 503이 아니라 CrashLoop). - **readiness와 LB health는 다른 path여야 한다.** improved가 `/health`(K8s)와 `/health_check.html`(LB)를 분리한 이유 — 둘이 같으면 drain 중 503 flip이 LB 차단과 endpoint 제거를 동시에 일으켜 순서 제어가 불가능하다(축 B 붕괴).