🏠 목록 Manifests Walkthrough — IGW 커스텀 Deployment + Service + Gateway/VS 📄 MD 원본 🌓 테마
istiograceful-terminationk8singressgatewaymanifestshomelab

Manifests Walkthrough — IGW 커스텀 Deployment + Service + Gateway/VS

NOTE

홈랩 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를 우리 손으로 제어한다.

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

external client HAProxy TLS offload :443 NodePort 30080 envoy :8080 NodePort 30180 hc :18180 istio-proxy Envoy router hc sidecar FSM + probe backend ClusterIP :8080 traffic :30080 httpchk :30180 VS route controls
그림 1. 트래픽/헬스 두 경로. HAProxy가 TLS를 벗겨 traffic은 30080(Envoy), health는 30180(hc)으로 분기한다. hc의 probe 응답(200/503)이 HAProxy의 라우팅을 좌우하므로 hc가 사실상 Envoy 트래픽을 controls — 이것이 종료 순서 제어의 토대다.

선행 개념: 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까지의 단일 데드라인이고, 그 구간 안에서 두 컨테이너가 병렬로 종료한다:

두 컨테이너가 동시에 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.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과 이중 설정 — ns label이 있으면 annotation은 불필요하지만 명시적 의도를 드러내는 문서화 역할이다. 왜 끄나: 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으로 istiod에 신원을 증명할 때 이 SA의 JWT를 사용한다(JWT_POLICY: third-party-jwt와 연동).

  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를 구동하면 시작 즉시 실패한다(proxy가 UDS를 만들 곳이 없음).

        - 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 인자).

          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와 일치 필요)
          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):

kubeletistio-proxyhc (drain.sh)HAProxySIGTERMSIGTERM (preStop)drainDuration 150s drain 시작Envoy drain trigger + active 폴링down+up rq_active==0 대기CLOSING → /health_check 503backend DOWN mark (httpchk)LB_BUFFER 10sCLOSED → /health 503 (readiness)endpoint 제거max(drain150s, preStop) < tGPS 210sSIGKILL @210s (도달 전 종료 목표)
그림 1. terminationDrainDuration 150s·LB_BUFFER 10s가 모두 gracePeriod 210s 안에 끝나도록 설계. SIGKILL 도달 전 정상 종료가 목표.

핵심: /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 마운트 확인 (없으면 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://<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 둘 다 있는 이유는? ns label이 최우선이라 annotation은 기능 중복. 그러나 annotation은 명시적 문서화 + 팀원이 ns label을 모를 때 second line of defense 역할.

핵심 정리

What you might be missing