--- type: src tags: [istio, graceful-termination, k8s, ingressgateway, anti-affinity, sds] created: 2026-06-07 --- # W3. IGW 커스텀 Deployment 설계 — K8s 매니페스트 구조 > [!abstract] > 홈랩 graceful termination 시리즈 W3. IGW를 Helm 표준 대신 **커스텀 Deployment**로 구성해 hc 사이드카·volume·probe·preStop·Service NodePort 설계를 manifest 레벨에서 직접 제어한다. > **결론(붙잡을 한 그림)**: 이 Pod는 **두 개의 독립된 종료 평면(plane)에 신호를 나눠 보내는 health surface**다. hc 컨테이너가 LB(HAProxy)에는 `/health_check.html`로, K8s kubelet에는 `/health`·`/live` probe로 *따로* 종료를 알리고, istio-proxy는 그동안 in-flight를 drain한다. 이 평면 분리 하나가 manifest 전체(포트·probe·preStop·affinity)를 관통하고, `current` vs `improved`의 in-flight 보존 여부도 이 분리를 얼마나 정교하게 다루느냐의 5가지 차이로 갈린다. --- ## 1. 배경: 왜 IGW 종료가 어려운가 — 두 평면 문제 IngressGateway(IGW)는 클러스터의 **북쪽 가장자리**다. 외부 LB(HAProxy)가 그 앞에 서고, 안쪽에서는 K8s가 endpoint를 관리한다. IGW pod 하나를 내릴 때(롤링 업데이트·노드 drain) "무중단"이려면 **두 주체가 서로 다른 시점에 이 pod를 빼야** 한다. - **LB 평면(HAProxy)**: 이 backend로 *새 요청을 보내도 되는지*를 health check 폴링으로 판단. 빼야 하는 순간 = "더 받지 마". 가장 먼저 일어나야 한다. - **K8s 평면(kubelet/Service)**: readiness probe로 Service endpoint 포함 여부를 결정. 너무 일찍 빠지면 LB가 아직 보내는 요청이 갈 곳을 잃는다. 이 둘이 같은 신호를 공유하면 종료가 깨진다. LB가 backend를 빼기도 전에 K8s가 endpoint를 먼저 제거하면, LB는 살아있다고 믿고 보낸 요청이 빈 endpoint로 떨어진다(in-flight 유실). 반대로 LB가 늦게 빼면 drain 중인 Envoy로 새 요청이 계속 들어온다. **무중단의 본질은 "LB가 빼는 시점"과 "K8s가 빼는 시점"을 의도적으로 어긋나게(stagger) 만드는 것**이고, 그러려면 두 평면에 *물리적으로 다른 신호 경로*를 줘야 한다. 표준 Helm `ingressgateway`로는 이 신호 경로를 실험적으로 빠르게 바꾸기 어렵다. probe path·preStop·env 조합을 실험마다 갈아끼우려면 `values.gateways.istio-ingressgateway.podAnnotations`나 `additionalContainers` overlay 계층을 거쳐야 하는데, 이 간접 계층이 학습을 흐린다. **커스텀 Deployment는 모든 설계 결정을 YAML 한 파일에 노출**해 실험 의도가 코드에 그대로 드러나고, current↔improved 변경점 5개가 diff로 즉시 보인다. > [!note] 사내(Helm) 적용은 별도 > 프로덕션처럼 Istio가 Helm으로 운영 중이면 표준 IGW Deployment에 hc를 더하는 현실적 방법은 `values.gateways.istio-ingressgateway.additionalContainers[0]`에 hc 스펙을, `extraVolumes`/`extraVolumeMounts`로 shared socket 마운트를 추가하는 것이다. **upgrade 시 `additionalContainers`가 의도치 않게 초기화될 수 있으므로 values를 GitOps로 관리하고 upgrade 후 pod 재기동을 확인**해야 한다. 상세 사내 적용 절차는 [W6. 프로덕션 적용](gt__src-w6-production-apply.html) 참조. --- ## 2. 아키텍처: 평면 분리가 manifest를 관통하는 방식 **멘탈모델 앵커**: hc 컨테이너의 *세 endpoint*는 곧 *세 소비자*이고, 소비자가 다르다는 것이 "두 평면을 분리한다"의 구현이다. 한 컨테이너가 같은 상태(FSM)를 세 갈래로 응답하되, **누가 폴링하느냐에 따라 다른 종료 시점을 트리거**한다. | endpoint (hc, :18180) | 소비자 (평면) | 답하는 질문 | 종료 시 역할 | |---|---|---|---| | `/health_check.html` | HAProxy (LB 평면) | "이 backend로 새 요청 보내도 돼?" | 가장 먼저 503 → LB가 backend 제거 | | `/health` | K8s kubelet (readiness) | "Service endpoint에 넣어도 돼?" | 나중에 실패 → endpoint 제거 | | `/live` | K8s kubelet (liveness) | "이 컨테이너 재시작해야 돼?" | 종료 중엔 건드리지 않음 | 이 표가 핵심이다. **LB 평면과 K8s 평면이 같은 hc 컨테이너의 다른 path를 폴링**하므로, FSM이 `/health_check.html`만 먼저 503으로 떨구고 `/health`는 잠시 더 200으로 유지하면, LB가 먼저 빠지고 K8s endpoint는 나중에 빠지는 **시점 어긋남**이 자연스럽게 만들어진다. istio-proxy는 이 신호들과 무관하게 자기 listener를 drain한다 — Envoy의 drain은 hc FSM이 아니라 Envoy 자신의 종료 타이머가 몰고 간다. FSM(`OPEN/DRAINING/CLOSING/CLOSED/FAULT`·신규 `POST /reopen`) 상세는 [W2. hc FSM](gt__src-w2-hc-fsm.html) 참조. ```mermaid flowchart TB subgraph Pod["Pod: service-a-igw-xxxxx (gracePeriod 210s, SA service-a-igw)"] proxy["istio-proxy (Envoy router)
:8080 traffic
:15021 status
:15090 prom
runAsUser 1337, readOnlyRootFS
readinessProbe :15021/healthz/ready"] hc["hc (FSM, :18180)
/health_check.html (HAProxy LB health)
/health (K8s readiness)
/live (K8s liveness)
preStop current: drain+close-lb+sleep30
preStop improved: graceful-drain.sh"] end HAProxy["HAProxy (LB)"] -->|poll /health_check.html| hc kubelet["K8s kubelet"] -->|readiness /health, liveness /live| hc ``` istio-proxy 쪽 포트는 **평면이 아니라 Envoy 자체 표면**임에 유의: `:8080`은 실제 트래픽, `:15021`은 status(Envoy의 readiness `/healthz/ready`), `:15090`은 prometheus. `runAsUser 1337`·`readOnlyRootFilesystem`은 보안 표준이고, 이 read-only 제약이 §5의 SDS volume 필요성을 낳는다. ### 2-1. Volumes — Pod가 의존하는 파일 표면 ``` Volumes: istiod-ca-cert (configMap: istio-ca-root-cert) istio-token (projected serviceAccountToken) istio-data (emptyDir -> /var/lib/istio/data) istio-envoy (emptyDir -> /etc/istio/proxy) config-volume (configMap: istio, optional) podinfo (downwardAPI: labels + annotations) workload-socket / credential-socket / workload-certs (emptyDir, SDS UDS — 아래 §5) ``` --- ## 3. 평면 분리의 포트 구현 — Service NodePort 평면을 신호 *경로*로 갈라놓으려면 포트도 갈라야 한다. 여기에 두 계층이 겹쳐 있으니 분리해서 본다: **(b) Service가 어떤 NodePort를 어떤 targetPort로 여는가**와, **(a) HAProxy가 그 NodePort들을 어떻게 backend로 매핑하는가**. ``` (a) HAProxy backend 매핑 (203.0.113.211) :443 (L7 offload) --> NodePort 30080 --> istio-proxy:8080 [traffic] check port --> NodePort 30180 --> hc:18180 [health] :8443 passthrough --> NodePort 31443 --> (future TLS, 현재 미사용) (b) Service NodePort 스펙 (4개 포트) 30080 http targetPort 8080 (Envoy traffic in) 30180 hc targetPort 18180 (hc FSM health out) 31443 https targetPort 8443 (future TLS, HAProxy passthrough 대상·현재 미사용) 32021 status targetPort 15021 (Envoy admin/status, debug 전용·HAProxy 매핑 없음) externalTrafficPolicy: Local ``` **왜 트래픽 포트(30080)와 헬스 포트(30180)를 쪼개나**: 트래픽 포트는 *Envoy의 생존*을 반영하고, 헬스 포트는 *"LB가 이 backend에 새 요청을 보내도 되는지"*를 반영한다 — 이 둘은 같지 않다. 같은 포트로 묶으면 §1의 두 평면이 다시 한 신호로 합쳐져서, Envoy가 살아있어도 drain 중 LB가 요청을 계속 보내거나, 반대로 Envoy가 안 죽었는데 헬스가 먼저 503이 되어 LB가 backend를 빼는 상황이 생긴다. **포트 분리 = 두 평면을 L4 레벨에서 다시 한 번 못 박는 것.** `externalTrafficPolicy: Local`은 이 분리가 *올바른 pod*를 가리키게 하는 마지막 못이다. 기본값 `Cluster`에서는 NodePort가 SNAT로 다른 노드 pod에 트래픽을 재포워딩할 수 있어, "worker1의 30180 health check"가 사실은 worker2 pod의 hc 상태를 돌려줄 수 있다(잘못된 pod의 health). `Local`은 SNAT hop을 차단해 health/traffic이 *같은 노드의 같은 pod*를 보게 만든다. 라우팅 정확성 메커니즘 정본은 [W3 manifests walkthrough](gt__src-manifests-walkthrough.html) §6 — 이 doc은 §7 Q3 quiz에 요지를 남긴다. --- ## 4. 평면 분리가 강제하는 토폴로지 — anti-affinity deadlock `externalTrafficPolicy: Local`은 공짜가 아니다(§3). 수신 노드에 ready endpoint가 0이면 그 노드 트래픽은 **블랙홀**이므로, *모든 LB-매핑 노드에 IGW pod이 최소 1개* 떠 있어야 한다. 그래서 "노드당 1 pod"을 보장하려고 `replicas = 노드수` + required anti-affinity를 건다 — 그런데 이 조합이 롤링 업데이트에서 deadlock을 만든다. ``` 노드: worker1, worker2 (2대), replicas: 2 anti-affinity: requiredDuringScheduling (같은 hostname에 2개 금지) maxUnavailable: 0, maxSurge: 1 롤링 업데이트: 현재 worker1=pod-A, worker2=pod-B 1. surge pod-C 생성 시도 -> required anti-affinity: 빈 노드 없음 -> Pending 2. maxUnavailable=0이라 기존 pod 못 지움 -> Deadlock: 새 pod 자리 없고, 기존 pod도 못 지움 ``` 원인은 세 제약이 서로 잠그기 때문이다: maxSurge=1은 *새 pod를 먼저* 띄우라 하는데, required anti-affinity는 빈 노드가 없어 새 pod를 Pending시키고, maxUnavailable=0은 *기존 pod를 못 지우게* 막아 빈 노드를 만들 길까지 봉쇄한다. rollout이 무한 정지. **해소 3종**: | 방법 | 명령 | 트레이드오프 | |---|---|---| | `maxUnavailable=1` | manifest 수정 후 apply | 순간 capacity -1 | | anti-affinity `preferred`로 완화 | `requiredDuring...` → `preferredDuring...` | 같은 노드 중복 허용 가능성 | | master1 untaint로 노드 +1 | `kubectl taint nodes master1 node-role.kubernetes.io/control-plane:NoSchedule-` | **control-plane taint 영구 제거** → master에 일반 워크로드 스케줄 허용. 실험 후 필요시 `NoSchedule` 재적용(`...control-plane:NoSchedule` 끝의 `-` 제거) | 이 홈랩은 **master1 untaint**를 택했다(포트 맵에 master1:30080/30180을 추가한 이유). 이유의 핵심: maxUnavailable=0은 **프로덕션 zero-downtime 제약을 그대로 재현하려는 의도**라 건드리지 않고, 대신 *노드를 늘려* 제약을 유지한 채 deadlock만 푼다. untaint는 영구 조치이므로 master 노드가 IGW뿐 아니라 일반 워크로드도 받게 됨에 유의. --- ## 5. read-only가 강제하는 volume — SDS UDS 3종 §2에서 본 `readOnlyRootFilesystem: true`(보안 표준)가 여기서 비용을 청구한다. SDS(Secret Discovery Service) agent는 인증서를 Envoy에 넘기려고 **Unix Domain Socket 파일**을 만들어야 하는데, image 파일시스템이 읽기 전용이라 그 위에 UDS 파일을 만들 수 없다. 그래서 `workload-socket`·`credential-socket`·`workload-certs` 세 emptyDir을 마운트해 *쓰기 가능한 경로*를 SDS에 내준다 — emptyDir은 kubelet이 노드에 마운트해주므로 read-only image 위에서도 쓰기가 된다. 이 셋은 **Istio 1.20에서 도입되어 1.30 현재까지 ingressgateway 표준 Helm chart 패턴**이다. `istioctl manifest generate` 출력의 ingressgateway Deployment에 동일한 세 emptyDir이 포함되며, 1.30 manifest generate에서도 동일하게 생략 불가다. 없으면 SDS agent가 부팅 시 UDS bind에 실패한다(§6 검증 참고). > 세 volume의 mount path 표·없을 때의 `SDS grpc server failed to set up UDS` 에러 상세는 코드 워크스루에 동일하게 정리됨 — 중복이므로 생략, 정본: [W3 manifests walkthrough](gt__src-manifests-walkthrough.html) §5 참조. --- ## 6. 떴는지 한 번 확인 — 평면 분리가 실제로 작동하나 평면 분리는 추상이 아니라 *관측 가능한* 상태다. apply 후 다음 네 가지로 "두 평면이 각자 다른 신호를 받고 있다"를 확인한다. ```bash # 1) Pod 2개가 서로 다른 노드에 떴나 (anti-affinity가 살아있나) kubectl get pod -l app=service-a-igw -o wide # 기대: 2개 Running, NODE 컬럼이 worker1 / worker2 (또는 untaint 후 master1 포함)로 서로 다름 # 2) LB 평면 신호: hc 헬스 포트가 200을 주나 curl -s -o /dev/null -w '%{http_code}\n' http://203.0.113.211:30180/health_check.html # 기대: 200 (preStop 진입 시 같은 호출이 503으로 바뀜 → HAProxy가 backend 제거) # 3) K8s 평면 신호: readiness가 endpoint를 채웠나 kubectl get endpoints service-a-igw -o jsonpath='{.subsets[*].addresses[*].ip}'; echo # 기대: pod 2개의 IP가 모두 나옴 (readiness=/health 200이라 endpoint 포함) # 4) Envoy 평면(별개): istio-proxy 자체 readiness curl -s -o /dev/null -w '%{http_code}\n' http://203.0.113.211:32021/healthz/ready # status NodePort → :15021 # 기대: 200 (Envoy가 config 수신 완료·listener up) ``` 검증 포인트: **2와 3이 동시에 200/채워짐이어야** "LB도 받고, K8s endpoint에도 있다"는 정상 상태다. 종료를 트리거하면(`kubectl delete pod ...`) 2가 먼저 503으로 떨어지고(LB 평면 먼저 빠짐), 그다음 endpoint에서 IP가 빠진다(K8s 평면) — 이 *순서*가 보이면 stagger가 작동하는 것이다. 만약 SDS volume이 빠졌다면 1단계에서 pod가 `CrashLoopBackOff`이고 로그에 `SDS grpc server failed to set up UDS: bind: no such file or directory`가 찍힌다(§5). --- ## 7. current vs improved — 5가지 차이 > current↔improved의 정확히 5가지 차이(probe path 분리, preStop 스크립트, env 파라미터)는 코드 워크스루에 라인 단위로 정리되어 있어 중복이므로 생략 — 정본: [W3 manifests walkthrough](gt__src-manifests-walkthrough.html) §4 참조. 핵심만: 이 5개 차이는 모두 §1의 *평면 분리를 얼마나 정교하게 다루느냐*에 대한 답이다. current는 LB·K8s 신호를 거칠게 동시에 떨어뜨리고, improved는 `graceful-drain.sh`로 probe path를 분리하고 preStop을 스크립트화해 "LB 먼저, K8s 나중"의 시점 어긋남을 명시적으로 만든다 → in-flight 보존 여부가 갈린다. --- ## 8. 회상 quiz
Q1. maxUnavailable=0 + required anti-affinity + replicas=workers 조합이 왜 deadlock? **A**: surge pod을 만들 빈 노드가 없고(모든 노드에 IGW pod 1개씩), maxUnavailable=0이라 기존 pod도 먼저 못 지운다. surge pod이 Pending 무한 대기, rollout이 멈춤.
Q2. SDS UDS volume 세 개가 없을 때 증상과 emptyDir이 해결책인 이유? **A**: `SDS grpc server failed to set up UDS: bind: no such file or directory`. readOnlyRootFilesystem=true라 image 파일시스템에 파일 생성 불가. emptyDir은 kubelet이 노드에 마운트해줘 쓰기 가능 → UDS 소켓 파일 생성 허용.
Q3. externalTrafficPolicy: Local이 없으면 왜 HAProxy check가 잘못된 pod의 health를 반환? **A**: `Cluster`(기본값)에서 NodePort는 수신 노드에 ready endpoint가 없어도 SNAT로 다른 노드 pod에 포워딩. worker1에 IGW pod이 없어도 worker2 pod에 연결됨 → `check port 30180 via worker1`이 worker2의 hc 상태를 반환 → worker1:30080이 응답 불가인데도 UP으로 표시.
--- ## 핵심 정리 - **두 평면 분리가 전부다**: LB(HAProxy) 평면과 K8s(kubelet) 평면을 서로 다른 신호 경로로 갈라야 "LB 먼저 빼고 K8s 나중에 빼는" 시점 어긋남으로 무중단이 된다. - hc의 세 endpoint는 소비자가 다르다: `/health_check.html`=HAProxy(LB), `/health`=K8s readiness, `/live`=K8s liveness. 같은 FSM을 세 갈래로 응답. - 평면 분리는 포트에서도 못 박힌다: 트래픽 포트(30080)와 헬스 포트(30180)를 쪼개고 `externalTrafficPolicy: Local`로 SNAT hop을 막아 헬스 신호와 트래픽이 같은 pod을 가리키게 한다. - `Local`이 "노드당 IGW pod 최소 1개"를 요구 → required anti-affinity + maxUnavailable=0 + replicas=노드수 조합이 rollout deadlock을 부르고, 홈랩은 master1 untaint로 노드를 늘려 푼다. - SDS UDS emptyDir 3종은 readOnlyRootFilesystem 하에서 SDS가 UDS를 만들 쓰기 경로가 필요해 도입(Istio 1.20~1.30 필수). - current vs improved 5개 차이 = 평면 분리를 얼마나 정교하게 다루느냐 → in-flight 보존 여부. ## What you might be missing - **maxUnavailable=0을 안 건드린 건 일부러다.** deadlock 해소를 위해 `maxUnavailable=1`로 바꾸는 게 가장 쉽지만, maxUnavailable=0은 프로덕션 zero-downtime 제약을 그대로 재현하려는 의도다 — 홈랩에선 노드를 늘려(master1 untaint) 제약을 유지한 채 푼다. - **`externalTrafficPolicy: Local`은 공짜가 아니다.** SNAT hop을 막아 health/traffic을 같은 pod에 묶지만, 부작용으로 **수신 노드에 ready endpoint가 0이면 그 노드로 온 트래픽은 블랙홀**된다(다른 노드로 재포워딩 안 함). 그래서 노드별로 IGW pod이 최소 1개 떠 있어야 하고, 이것이 replicas·anti-affinity·노드 수가 서로 얽히는 이유다. - **master1 untaint는 영구 조치다.** control-plane taint를 제거하면 그 노드는 IGW뿐 아니라 모든 일반 워크로드를 받게 된다. 실험이 끝나면 `NoSchedule`을 재적용해야 control-plane 격리가 복구된다. - **31443(future TLS)·32021(status)은 현재 트래픽 경로가 아니다.** 31443은 HAProxy `:8443` passthrough 대상이나 미사용, 32021은 HAProxy 매핑 없이 디버그용 status 노출일 뿐 — 둘을 health/traffic 포트와 혼동하지 말 것. - **Envoy의 drain은 hc FSM과 무관하다.** hc는 LB/K8s 평면 신호만 다루고, istio-proxy는 자신의 종료 타이머로 listener를 drain한다. 두 평면을 잘 분리해도 Envoy drain 타이밍(별개 축)이 어긋나면 in-flight가 깨질 수 있다 — drain 메커니즘은 별도로 본다. ## 이어 보기 - 코드 워크스루: [W3 manifests walkthrough](gt__src-manifests-walkthrough.html) - 다음: [W4. HAProxy walkthrough](gt__src-haproxy-walkthrough.html) — HAProxy `on-marked-down shutdown-sessions` - hc FSM 상세: [W2. hc FSM](gt__src-w2-hc-fsm.html) - 시리즈 인덱스: [graceful termination MOC](gt__MOC-graceful-termination.html)