homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-06-07istiograceful-terminationingressgatewayenvoymanifests

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, 종료 FSM은 HC FSM을 참고.

명명 매핑(2026-04-26): READY/…/FAILED → OPEN/DRAINING/CLOSING/CLOSED/FAULT. 신규 POST /reopen(DRAINING/CLOSING→OPEN abort, CLOSED는 409 unrecoverable).

관련 파일 · 참조

이 문서가 해부하는 정본 매니페스트(manifests/00-namespace.yaml31-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를 우리 손으로 제어한다.

이 구조를 이해하려면 종료 시점에 누가 누구에게 트래픽을 보내는지부터 그려야 한다.

flowchart LR
    Client[external client] --> HAP[HAProxy<br/>TLS offload :443]
    HAP -->|httpchk :30180| NPh[NodePort 30180<br/>hc :18180]
    HAP -->|traffic :30080| NPt[NodePort 30080<br/>envoy :8080]
    NPt --> ENV[istio-proxy<br/>Envoy router]
    NPh --> HC[hc sidecar<br/>FSM + probe]
    ENV -->|VS route| BE[backend ClusterIP<br/>: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 컨테이너: 파드 종료 이벤트(t=0) 시점에 preStop이 먼저 실행되고, 그 훅이 끝나야 kubelet이 비로소 이 컨테이너에 SIGTERM을 보낸다 — K8s 공식 문서: “PreStop hooks are not executed asynchronously from the signal to stop the Container; the hook must complete its execution before the TERM signal can be sent.” (SIGTERM이 preStop을 트리거하는 게 아니라, 훅 완료가 SIGTERM 전송의 선행조건이다) → current는 drain→close-lb→sleep 30이라 ~30s.

두 컨테이너 모두 t=0(파드 종료 이벤트 시점)부터 동시에 종료 절차에 들어간다 — istio-proxy는 즉시 SIGTERM을 받아 drain을 시작하고, hc는 즉시 preStop 실행을 시작한다. 그래서 임계 경로는 직렬 합(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.yaml21-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 끄기

  name: service-a
    istio-injection: disabled

istio-injection: disabled는 네임스페이스 레벨에서 sidecar auto-inject를 끈다. IGW pod의 sidecar.istio.io/inject: "false" annotation과 이중 설정 — 실제 우선순위는 pod-level annotation이 namespace-level label보다 높다(annotation이 최종 결정권을 가진다). 이 매니페스트처럼 둘 다 주입을 끄는 방향으로 일치시켰다면 관측 결과는 같지만, “ns label이 있으니 annotation은 불필요"라는 서술은 우선순위가 반대다 — annotation은 군더더기가 아니라 최종 결정자이고, ns label은 네임스페이스 전체 기본값을 세팅하는 보조 역할에 가깝다. 왜 끄나: IGW는 envoy를 우리가 수동으로 컨테이너에 넣었으므로, webhook이 또 sidecar를 주입하면 envoy가 둘이 돼 충돌한다. experiment: graceful-termination label은 기능 없음 — kubectl get ns -l experiment=... 일괄 조회용.

10-backend.yaml — 평범한 HTTP backend (축 A의 기준선)

  replicas: 2
  selector:
    matchLabels:
      app: backend

replicas=2 + preferredDuringScheduling anti-affinity. preferred라 deadlock 없음 — worker 한 대에 2개 올라가도 OK(IGW의 required와 대비).

      terminationGracePeriodSeconds: 305

305s 산정: 가장 긴 요청이 /sleep?seconds=300. kubelet이 tGPS 후 SIGKILL을 보내므로 300s + 여유 5s = 305s. 이것이 축 A의 기준선이고, VS timeout도 305s로 일치시킨다.

          env:
            - name: HOSTNAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name

Downward API로 Pod 이름을 환경변수에 주입 → backend 응답 body에 {"pod":"backend-xxxxx"}로 어떤 replica가 응답했는지 추적.

          readinessProbe:
            periodSeconds: 5
            failureThreshold: 3

5s × 3회 = 최대 15s 후 endpoint 제거. backend readiness는 이 실험에 직접 개입하지 않지만 backend pod 비정상 시 IGW가 503을 반환하는 경로를 닫는다.

Service (ClusterIP):

  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(projected SA JWT, audience=istio-ca)으로 istiod에 신원을 증명한다. (과거에는 JWT_POLICY: third-party-jwt env로 이 경로를 legacy first-party-jwt와 양자택일했으나, Istio 1.22에서 first-party-jwt 옵션 자체가 완전히 제거되며 이 env는 표준 사이드카/게이트웨이 주입 템플릿에서 빠졌다 — 1.30 기준으로는 istio-token 볼륨만으로 충분하다.)

  name: service-a-igw
  namespace: service-a

Deployment strategymaxUnavailable=0 + maxSurge=1은 zero-downtime rollout 표준이지만, required anti-affinity + replicas=노드수 조합에서 deadlock — surge pod이 들어갈 빈 노드가 없다. 홈랩에서는 master1 untaint로 좌석을 늘려 해소.

  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1

terminationGracePeriodSeconds — current 모드의 tGPS는 210s. 산정 근거(병렬 타이밍, max(150,30)+60)는 §2 축 A에서 도출했다. 핵심 불변식은 tGPS(210) > terminationDrainDuration(150).

      terminationGracePeriodSeconds: 210

anti-affinityrequired(hard): 같은 hostname에 동일 app 2개 금지. rollout deadlock의 원인이지만 HA상 두 replica가 한 노드에 몰리는 것보다 낫다.

        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: service-a-igw
              topologyKey: kubernetes.io/hostname

volumes — SDS UDS 세 volume 없이 readOnlyRootFilesystem: true로 proxy를 구동하면 UDS를 만들 곳이 없어 SDS 초기화가 실패한다.

        - 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)

실측 검증 노트(2026-07-05): 이 volume 3종을 제거하고 실제로 재현해보면 “시작 즉시 실패"가 아니다 — istio-proxy는 1/2 Running 상태로 약 10분간 유지되며 failed to set up UDS: ... bind: no such file or directory 에러를 반복 로깅하다, startupProbe가 약 600회 연속 실패한 시점에야 kubelet이 0초 백오프로 조용히 컨테이너를 재시작시킨다. kubectl get pod의 STATUS 컬럼은 이 과정 내내 Running으로 보이며 CrashLoopBackOff는 한 번도 나타나지 않았다(T29 실측). “런타임 503이 아니라 기동 실패"라는 결론 자체는 유효하지만, 아래(§핵심 정리, What you might be missing)의 “CrashLoop” 표현은 부정확하며 정확히는 느린 주기의 조용한 재시작이다.

istio-proxy 컨테이너proxy router로 Envoy를 IngressGateway 모드 기동(router는 istiod로부터 Gateway/VS xDS를 받아 외부→내부 라우팅; sidecar는 sidecar 인자).

          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 (1.30 기준 outdated) 1.9~1.21에서 legacy first-party-jwt와의 양자택일용 env였으나, 1.22에서 first-party-jwt가 완전히 제거되며 표준 주입 템플릿에서 빠졌다. 넣어도 무해하지만 필수는 아니다 — K8s SA JWT(audience=istio-ca) 신원 증명은 istio-token 볼륨만으로 충분
ISTIO_META_MESH_ID cluster.local 메시 식별자(istiod와 일치 필요)
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 15021
            periodSeconds: 2
            failureThreshold: 30

15021은 Envoy status 포트. failureThreshold: 30 = 2s × 30 = 최대 60s, Envoy가 istiod 초기 xDS를 받기까지 넉넉히 준다.

          securityContext:
            runAsUser: 1337
            runAsGroup: 1337
            readOnlyRootFilesystem: true

UID 1337은 Istio 표준(iptables 룰에서 1337 트래픽은 인터셉트 제외). readOnlyRootFilesystem: true 때문에 위 SDS UDS emptyDir이 필수가 된다.

hc 컨테이너 — current 모드 (축 B의 병폐):

          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가 무너진다.

          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 폴링:

          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을 참조 — active 폴링이 왜 신규 유입 없이 단조감소하는지의 전제(/drain_listeners로 신규 연결을 먼저 막음)가 거기에 있다.

improved 종료 타임라인 (두 컨테이너 병렬, 임계 경로 = Envoy drain 150s):

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: preStop 훅 실행 (t=0, 훅 완료 후에야 SIGTERM)
    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 보강)

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를 만든다

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를 닫는다

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. 예시 — 적용과 검증

# 전체 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 마운트 확인 (없으면 SDS 초기화 실패 — 단, 즉시 CrashLoop이 아니라
# ~10분 뒤 startupProbe 타임아웃으로 조용히 재시작됨, 위 §4 실측 노트 참고)
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://<worker-without-pod>:30180/health_check.html
#    기대: curl: (7) Failed to connect ... Connection refused  (또는 (28) timeout)
# 3) 대조군: pod 있는 노드는 200
curl -m2 http://<worker-with-pod>: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 둘 다 있는 이유는?

실제 우선순위는 반대다 — pod-level sidecar.istio.io/inject annotation이 namespace-level istio-injection label보다 우선한다(annotation이 최종 결정권자). 이 문서처럼 둘 다 주입을 끄는 방향으로 맞춰뒀다면 결과에는 차이가 없지만, “ns label이 최우선이라 annotation은 기능 중복"이라는 서술은 정정이 필요하다 — annotation이야말로 최종 방어선이고, ns label은 팀 전체 기본값을 세팅해 놓는 1차 방어선에 가깝다.


핵심 정리

  • 모든 숫자는 두 축에서 나온다. 축 A(타이밍 불변식): backend tGPS 305 = VS timeout 305 = 최장 요청 300+5; IGW tGPS 210 > terminationDrainDuration 150. 축 B(순서 제어): LB 먼저, endpoint 나중.
  • tGPS 산식은 직렬 합이 아니라 max(Envoy drain, preStop) + 여유다. 두 컨테이너 모두 t=0(파드 종료 이벤트)부터 동시에 종료 절차에 들어가므로(istio-proxy는 SIGTERM 즉시, hc는 preStop 즉시 — hc는 훅이 끝나야 비로소 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는 직렬 합이 아니다. 두 컨테이너는 t=0(파드 종료 이벤트)부터 동시에 종료 절차에 들어가므로 임계 경로는 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개 누락 → 기동 실패(단, CrashLoop은 아니다). readOnlyRootFilesystem: trueworkload-socket/credential-socket/workload-certs volume이 없으면 proxy가 UDS를 만들 곳이 없어 SDS가 실패한다 — 하지만 실측(T29 실측)으로는 즉시 죽는 CrashLoopBackOff가 아니라, istio-proxy가 1/2 Running 상태로 UDS bind 에러를 반복 로깅하며 약 10분(startupProbe 약 600회 연속 실패)까지 버티다 kubelet이 0초 백오프로 조용히 재시작시키는 형태였다. “런타임 503이 아니라 기동 실패"라는 결론은 유지되지만, kubectl get pod STATUS는 재시작 전후 내내 Running으로 보이고 CrashLoopBackOff는 관측되지 않았다 — 정확히는 느린 주기의 조용한 재시작이다.
  • readiness와 LB health는 다른 path여야 한다. improved가 /health(K8s)와 /health_check.html(LB)를 분리한 이유 — 둘이 같으면 drain 중 503 flip이 LB 차단과 endpoint 제거를 동시에 일으켜 순서 제어가 불가능하다(축 B 붕괴).

검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)

검증 방법: 공식 문서 대조(Kubernetes/Istio/Envoy 레퍼런스 및 GitHub 소스) + homelab 클러스터 실측(T29·T79·T80·T81).

주장 판정 근거
C1. IGW tGPS(210s)는 직렬 합이 아니라 max(Envoy drain 150s, preStop ~30s)+60s 병렬 산정 ✅ 실측 확인 kubernetes.io/…/container-lifecycle-hooks · T79 실측
C2. hc 컨테이너는 “SIGTERM 즉시” preStop을 실행한다(인과관계 서술) ❌ 오류 — 본문 교정 kubernetes.io/…/container-lifecycle-hooks
C3. PROXY_CONFIG env로 terminationDrainDuration 주입 시 pilot-agent가 그 시간만큼 drain ✅ 문헌 확인 istio.io/…/proxy-config
C4. istio-proxy는 UID/GID 1337로 실행 — iptables 인터셉션 제외용 Istio 표준 ✅ 문헌 확인 istio.io/…/ist0144
C5. Envoy 상태 포트 15021 + /healthz/ready가 readinessProbe 표준 경로 ✅ 문헌 확인 istio.io/…/application-requirements
C6. CA_ADDR: istiod...:15012 — xDS와 SDS를 한 포트로 서빙 ✅ 문헌 확인 github.com/…/injection-template.yaml
C7. SDS UDS emptyDir 3종 없이 readOnlyRootFilesystem이면 프록시 기동 실패 ✅ 문헌 확인 github.com/…/istio-agent.md
C8. JWT_POLICY: third-party-jwt가 IGW 표준 env 중 하나로 필요 ⚠️ 구버전 서술 — 갱신 istio.io/…/1.22.x/change-notes
C9. args: proxy router(게이트웨이) vs proxy sidecar(사이드카)가 Envoy 모드를 가름 ✅ 문헌 확인 istio.io/…/pilot-agent
C10. Gateway selector가 pod label과 안 맞으면 listener가 열리지 않음 ✅ 실측 확인 istio.io/…/gateway · T80 실측
C11. externalTrafficPolicy: Local 없으면 SNAT으로 다른 노드 pod까지 health check 전달 ✅ 실측 확인 kubernetes.io/…/source-ip · T81 실측
C12. VirtualService timeout: 305s는 backend tGPS(305s)와 의도적 일치 ✅ 문헌 확인 gateway.envoyproxy.io/…/http-timeouts
C13. ns istio-injection: disabled label이 최우선이라 pod annotation은 기능 중복 ❌ 오류 — 본문 교정 istio.io/…/sidecar-injection
C14. graceful-drain.sh/drain_listeners로 신규 유입 차단 후 active=0 폴링 ✅ 문헌 확인 envoyproxy.io/…/draining
C15. SDS UDS volume 누락 시 “런타임 503이 아니라 CrashLoop” 발생 🔬 실측 반증 — 본문 교정 github.com/…/istio-agent.md · T29 실측

Files