🏠 목록 W3. IGW 커스텀 Deployment 설계 — K8s 매니페스트 구조 📄 MD 원본 🌓 테마
istiograceful-terminationk8singressgatewayanti-affinitysds

W3. IGW 커스텀 Deployment 설계 — K8s 매니페스트 구조

NOTE

홈랩 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가 backend를 빼기도 전에 K8s가 endpoint를 먼저 제거하면, LB는 살아있다고 믿고 보낸 요청이 빈 endpoint로 떨어진다(in-flight 유실). 반대로 LB가 늦게 빼면 drain 중인 Envoy로 새 요청이 계속 들어온다. 무중단의 본질은 "LB가 빼는 시점"과 "K8s가 빼는 시점"을 의도적으로 어긋나게(stagger) 만드는 것이고, 그러려면 두 평면에 물리적으로 다른 신호 경로를 줘야 한다.

표준 Helm ingressgateway로는 이 신호 경로를 실험적으로 빠르게 바꾸기 어렵다. probe path·preStop·env 조합을 실험마다 갈아끼우려면 values.gateways.istio-ingressgateway.podAnnotationsadditionalContainers overlay 계층을 거쳐야 하는데, 이 간접 계층이 학습을 흐린다. 커스텀 Deployment는 모든 설계 결정을 YAML 한 파일에 노출해 실험 의도가 코드에 그대로 드러나고, current↔improved 변경점 5개가 diff로 즉시 보인다.

ℹ 사내(Helm) 적용은 별도

프로덕션처럼 Istio가 Helm으로 운영 중이면 표준 IGW Deployment에 hc를 더하는 현실적 방법은 values.gateways.istio-ingressgateway.additionalContainers[0]에 hc 스펙을, extraVolumes/extraVolumeMounts로 shared socket 마운트를 추가하는 것이다. upgrade 시 additionalContainers가 의도치 않게 초기화될 수 있으므로 values를 GitOps로 관리하고 upgrade 후 pod 재기동을 확인해야 한다. 상세 사내 적용 절차는 W6. 프로덕션 적용 참조.


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 참조.

Pod: service-a-igw (gracePeriod 210s)istio-proxy (Envoy router):8080 traffic:15021 status / :15090 promrunAsUser 1337, readOnlyRootFSreadinessProbe :15021/healthz/readyhc (FSM, :18180)/health_check.html (LB health)/health (K8s readiness)/live (K8s liveness)preStop: drain+close-lb / graceful-drainHAProxy (LB)K8s kubeletpoll /health_checkreadiness/liveness
그림 1. IGW Pod = istio-proxy(Envoy router) + hc 사이드카. HAProxy는 /health_check.html을 poll, kubelet은 /health·/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 §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 §5 참조.


6. 떴는지 한 번 확인 — 평면 분리가 실제로 작동하나

평면 분리는 추상이 아니라 관측 가능한 상태다. apply 후 다음 네 가지로 "두 평면이 각자 다른 신호를 받고 있다"를 확인한다.

# 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 §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으로 표시.

핵심 정리

What you might be missing

이어 보기