Manifests Walkthrough — IGW 커스텀 Deployment + Service + Gateway/VS
홈랩 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.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를 우리 손으로 제어한다.
이 구조를 이해하려면 종료 시점에 누가 누구에게 트래픽을 보내는지부터 그려야 한다.
선행 개념: 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 끄기
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 strategy — maxUnavailable=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-affinity — required(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):
핵심: /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 역할.핵심 정리
- 모든 숫자는 두 축에서 나온다. 축 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에 봉사. requiredanti-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-certsvolume이 없으면 proxy가 UDS를 만들 곳이 없어 시작 즉시 죽는다(런타임 503이 아니라 CrashLoop). - readiness와 LB health는 다른 path여야 한다. improved가
/health(K8s)와/health_check.html(LB)를 분리한 이유 — 둘이 같으면 drain 중 503 flip이 LB 차단과 endpoint 제거를 동시에 일으켜 순서 제어가 불가능하다(축 B 붕괴).