--- type: MOC tags: [istio, graceful-termination, production-prep] created: 2026-06-07 --- # MOC — Istio Graceful Termination > [!abstract] > 이 MOC는 **homelab Istio graceful-termination 실험**의 허브이자, 그 실험이 "왜 이렇게 설계됐는가"를 한 번에 세워주는 **멘탈 모델 게이트웨이**다. 핵심 결론 한 줄: 서비스 팀은 LB를 직접 명령할 권한이 없으므로, "끊김 없는 Pod 종료"는 **health 신호(200↔503)의 타이밍으로 LB의 backend 제거 시점을 간접 제어하는 문제**로 환원된다. 사내 LB(Citrix `downStateFlush` 류)가 backend DOWN 마킹 시 in-flight RST하는 환경에서도, `hc` 사이드카 + Envoy graceful drain + active-request 폴링 패턴으로 long/streaming 요청 끊김을 막을 수 있음을 입증했다. 아래는 그 메커니즘을 먼저 세우고, 산출물(W1~W6 + 코드 워크스루)로 드릴다운하는 항법도다. > [!note] FSM 명명 매핑 (시리즈 정본 — 여기를 기준으로) > READY/DRAINING/DRAINED_WAIT_LB/TERMINATING/FAILED → **OPEN/DRAINING/CLOSING/CLOSED/FAULT** (게이트 비유). 옛 artifacts(`tests/artifacts/2026*/`)는 옛 명칭으로 보존됨. > **`POST /reopen`**: DRAINING/CLOSING → OPEN abort. CLOSED는 unrecoverable(409). CLOSING→OPEN 응답엔 `Warning: 199 ...` 헤더(LB rise 2 = +4s 재투입). > 개별 src 노트는 이 callout을 중복 서술하지 말고 본 MOC를 cross-ref할 것. 코드 원본 위치: `~/labs/istio/`. 결과 기록: `~/labs/istio/{PLAN.md, EXECUTION-LOG.md}`. --- ## 0. 배경 — 왜 이 문제가 어려운가 K8s Pod 종료는 본질적으로 **여러 비동기 신호의 경쟁(race)**이다. kubelet이 SIGTERM을 보내는 순간, LB가 backend를 pool에서 빼는 순간, Envoy가 listener를 drain하는 순간 — 이 셋은 서로 독립적으로 진행되며 누구도 다른 쪽을 기다려주지 않는다. 종료되는 Pod이 들고 있던 long/streaming 요청은, 이 신호들의 순서가 어긋나는 순간 **RST(강제 연결 절단)**로 죽는다. 여기서 핵심 제약은 **권한과 판단 근거** 두 가지다. - **권한**: 서비스 팀은 보통 사내 LB(HAProxy/Citrix NetScaler)에 "이 backend를 빼라"는 명령을 직접 내릴 권한이 없다. LB 운영은 인프라 팀 소관이다. - **판단 근거**: LB는 오로지 **health check 응답(200 vs 503)** 하나만으로 backend의 UP/DOWN을 판단한다. 즉 서비스 팀이 LB에 줄 수 있는 입력은 health 신호 하나뿐이다. 이 두 제약을 합치면 문제의 형태가 바뀐다. "끊김 없는 종료"는 LB를 직접 제어하는 문제가 아니라, **우리가 쥔 유일한 레버(health 응답)의 타이밍으로 LB가 backend를 빼는 시점을 간접 제어하는 문제**다. 이 한 줄이 시리즈 전체(W1~W6)의 출발점이다. 더 나쁜 점은, 사내 LB(`downStateFlush ENABLED` / `on-marked-down shutdown-sessions`)가 backend를 DOWN 마킹하는 순간 **그 backend가 들고 있던 in-flight 세션을 즉시 flush(RST)**한다는 것 — graceful close가 아니라 즉시 절단이다. 그래서 "DOWN 마킹 시점"이 곧 "끊김 시점"이고, 우리의 과제는 그 시점을 **모든 요청이 끝난 뒤로** 미루는 것이 된다. > **선행 개념**: K8s Pod lifecycle(preStop/SIGTERM/terminationGracePeriodSeconds), Service `externalTrafficPolicy: Local`, Envoy admin API, HAProxy health check(`inter`/`rise`/`fall`). 모르면 [W1 Big Picture](gt__src-w1-big-picture.html) §1·§2가 무대를 깔아준다. --- ## 1. 핵심 메커니즘 — 멘탈 모델 ANCHOR 머릿속에 담을 단 한 장면부터. > **event 5(LB DOWN @ ~4s)는 두 모드에서 똑같이 일어난다. 모든 차이는 그 전에 event 1(health 200→503 flip)이 *언제* 발생하느냐 하나다.** 이 한 줄에서 시리즈의 모든 디테일이 파생된다. health 503 flip을 **즉시** 하면(current) LB는 아직 active 요청을 들고 있는 backend를 DOWN 마킹하고 → in-flight RST. flip을 **active=0 뒤로 미루면**(improved) LB가 DOWN 마킹할 때는 이미 끊을 게 없어 → RST가 무력화된다. "503 flip을 active=0 뒤로 미룬다" — 이게 전부다. ### 이 anchor가 동작하는 구조 세 부품이 health 레버를 함께 쥔다. | 부품 | 그게 답하는 질문 | 정본 노트 | |---|---|---| | **포트 분리** (data 30080 / health 30180) | 어떻게 "요청은 계속 받으면서 health만 503"을 만드나? | [W1 Big Picture](gt__src-w1-big-picture.html) §2 | | **hc FSM 5-state** (OPEN/DRAINING/CLOSING/CLOSED/FAULT) | health 레버를 누가, 어떤 규칙으로 쥐나? | [W2 hc FSM](gt__src-w2-hc-fsm.html) §2~3 | | **drain.sh active 폴링** | flip 시점을 어떻게 active=0까지 미루나? | [W2 hc FSM](gt__src-w2-hc-fsm.html) §4~5 | **포트 분리**가 출발점이다. HAProxy는 traffic 포워딩(`worker:30080`)과 health 체크(`check port 30180` → hc:18180)를 분리된 포트로 한다. 두 포트가 독립이기 때문에 hc는 **traffic은 200으로 계속 받으면서 health만 503으로 떨어뜨려** LB에게만 "나를 빼라"고 신호할 수 있다. **hc FSM**이 그 레버를 규칙으로 묶는다. 핵심은 DRAINING 상태에서도 `/health_check.html`이 여전히 200을 반환한다는 것 — drain이 시작돼도 LB는 아직 backend를 UP으로 본다. active=0이 확인돼 CLOSING으로 전이할 때 비로소 `/health_check.html`이 503으로 flip한다. illegal transition(OPEN→CLOSING 직접 점프 등)은 전이 함수 `advance(expectedFrom, to)` 한 곳에서 409로 봉쇄된다. **drain.sh**가 flip 시점을 만든다. preStop 훅에서 ① hc를 DRAINING으로 보내고(health 여전히 200) ② Envoy admin 15000으로 `POST /drain_listeners?graceful&skip_exit`(새 연결 거부, in-flight는 유지) ③ Envoy `/stats`의 `downstream_rq_active + upstream_rq_active`를 폴링하며 **active=0이 될 때까지 health 200을 붙잡고** ④ active=0이면 hc를 CLOSING으로(여기서 503 flip) → LB가 ~4초 후 DOWN 마킹해도 끊을 in-flight가 없음 ⑤ `LB_BUFFER` 대기 후 CLOSED로(`/health` 503 → K8s endpoint 제거). ### 6 events FSM — 신호 정렬 문제의 구체화 `Pod terminated`(kubectl delete)는 **T+0 트리거**이며 별도 event로 세지 않는다. 정본 W1과 동일하게 event는 1~6의 6개다. ``` T+0 (trigger) Pod terminated (kubectl delete) T+0 [event 1] hc /health_check.html 200->503 flip <- current는 즉시, improved는 active=0 후 T+later [event 2] hc /health 200->503 flip -> K8s endpoint 제거 T+0~5 [event 3] Envoy /drain_listeners?graceful&skip_exit <- improved에서만 즉시 T+0~60 [event 4] active=0 (응답 완료 시) <- improved에서만 폴링 T+~4s [event 5] HAProxy backend DOWN 마킹 (inter 2s x fall 2) T+~4s [event 6] on-marked-down shutdown-sessions -> in-flight TCP RST ``` 두 모드의 차이는 event 순서 하나로 환원된다. - **current 모드**: `1 → 5 → 6` (RST). event 3·4가 통째로 빠진다 — Envoy drain은 Pod kill 후에야 시작돼 너무 늦다. health 503이 즉시 떠 LB가 active 요청을 든 채 DOWN 마킹 → event 6 RST. - **improved 모드**: `3 → 4 → 1 → 5 → (6 무력) → 2`. active=0(event 4)까지 health 200을 유지하므로, LB가 DOWN 마킹(event 5)할 땐 이미 in-flight가 없어 event 6이 무력화된다. event 2(readiness 503 → endpoint 제거)는 이미 active=0인 뒤의 **비동기 후행 정리**일 뿐, 무결성의 인과 경로가 아니다. ### 시리즈 구조 다이어그램 아래는 산출물 사이의 학습 의존 그래프다. Hub(이 MOC)에서 W1으로 들어가 멘탈 모델을 세우고, 도메인 워크스루(W2~W5)로 메커니즘을 코드 수준까지 내린 뒤, W6/runbook으로 사내 적용에 수렴한다. ```mermaid graph TB subgraph Hub["MOC Hub (this note)"] MOC[MOC - Graceful Termination] end subgraph W1G["W1: Big Picture"] W1O["w1-big-picture
(mental model)"] W1L["quickstart
(replay cmds)"] end subgraph Walk["Domain walkthroughs"] W2["w2-hc-fsm / apps
backend + hc + drain.sh"] W3["w3-igw / manifests
IGW custom Deployment"] W4["haproxy
L7 + on-marked-down"] W5["w5-test-scenarios / tests
4 scenarios + artifacts"] end subgraph W6G["W6: Apply"] W6O["w6-production-apply
(strategy)"] W6L["runbook
(checklist)"] end MOC --> W1O MOC --> W1L W1O --> W2 W1O --> W3 W1O --> W4 W1O --> W5 W2 --> W6O W3 --> W6O W4 --> W6O W5 --> W6O W6O --> W6L ``` --- ## 2. Learning Roadmap — anchor를 따라 드릴다운 > 처음 펼친 사람: §0~§1로 멘탈 모델을 세운 뒤 아래 순서대로. 다시 펼친 사람: 5분이면 W1 + quickstart만, 30분이면 W2~W5 quiz만, §3 회상 카드로 즉시 복구. ### Step 1 — Big Picture (15분) · "신호 정렬 문제를 본다" 1. [W1 — Big Picture](gt__src-w1-big-picture.html) — 트래픽 경로, 6 events FSM, 4 시나리오 비교, current vs improved 시퀀스 다이어그램 2. [Quickstart](gt__src-quickstart.html) — 5분 안에 실험 다시 돌리기 (시나리오 재현 명령) ### Step 2 — 도메인별 메커니즘 (각 30분) · "레버를 쥐는 부품을 본다" 3. [W2 — hc FSM](gt__src-w2-hc-fsm.html) + [Apps walkthrough](gt__src-apps-walkthrough.html) — Go 코드: backend Flusher 함정, hc FSM 5-state, drain.sh 7단계 4. [W3 — IGW deployment](gt__src-w3-igw-deployment.html) + [Manifests walkthrough](gt__src-manifests-walkthrough.html) — K8s 매니페스트: IGW 커스텀 Deployment, NodePort 분리, anti-affinity deadlock, SDS UDS volumes 5. [HAProxy walkthrough](gt__src-haproxy-walkthrough.html) — HAProxy: 5 frontend 결정 트리, on-marked-down, retries 3 5xx 흡수, master1 backend 추가 6. [W5 — Test scenarios](gt__src-w5-test-scenarios.html) + [Tests walkthrough](gt__src-tests-walkthrough.html) — 시나리오 4종 변수 격리, replicas=1 의도, HTTP/2 vs HTTP/1.1 RST 차이, artifacts 해석 ### Step 3 — 사내 적용 검토 (60분) · "홈랩 결론을 온프렘으로 매핑한다" 7. [W6 — production apply](gt__src-w6-production-apply.html) + [Runbook](gt__src-runbook.html) — Citrix↔HAProxy 매핑, 미적용 운영 디테일 정공법, 6단계 체크리스트, 장애 대응 시나리오 ### Step 4 — 깊이 보강 (선택) · "Envoy drain 내부를 본다" 8. [Envoy drain listeners](gt__src-envoy-drain-listeners.html) — `/drain_listeners?graceful&skip_exit` API 동작 ### 노트 카탈로그 (역할별 빠른 점프) **멘탈 모델 (Why·What)** | 노트 | 핵심 질문 | |---|---| | [W1 — Big Picture](gt__src-w1-big-picture.html) | 트래픽이 어디서 흐르고 6 events 어떻게 정렬되나? | | [W2 — hc FSM](gt__src-w2-hc-fsm.html) | hc FSM 5-state는 어떤 응답 매트릭스인가? drain.sh가 어떻게 active=0까지 health 200을 유지하나? | | [W3 — IGW deployment](gt__src-w3-igw-deployment.html) | 왜 표준 IngressGateway가 아니라 커스텀 Deployment인가? Service NodePort 분리 의미? anti-affinity deadlock? | | [HAProxy walkthrough](gt__src-haproxy-walkthrough.html) | Citrix downStateFlush와 어떤 매핑? `retries 3`이 5xx를 어떻게 흡수하나? | | [W5 — Test scenarios](gt__src-w5-test-scenarios.html) | 시나리오 4개가 각각 잡는 변수와 못 잡는 변수는? replicas=1 의도? | | [W6 — production apply](gt__src-w6-production-apply.html) | 사내 LB가 어느 모델? 미적용 운영 디테일 정공법? | **코드 워크스루 (Where·How)** | 노트 | 코드 라인 가이드 | |---|---| | [Quickstart](gt__src-quickstart.html) | 시나리오 1줄 재현 명령 (모드 전환, S1/S3/S4 인라인) | | [Apps walkthrough](gt__src-apps-walkthrough.html) | `apps/backend/main.go` L28~145, `apps/hc/main.go` L17~362, `graceful-drain.sh` L1~97 | | [Manifests walkthrough](gt__src-manifests-walkthrough.html) | 7개 yaml + current vs improved 정확한 5가지 차이 표 | | [HAProxy walkthrough](gt__src-haproxy-walkthrough.html) | `haproxy-current.cfg` 5 frontend block 라인별 + diff vs improved | | [Tests walkthrough](gt__src-tests-walkthrough.html) | `tests/lib/common.sh` + `01~05.sh` 핵심 라인 + artifacts 디렉터리 가이드 | | [Runbook](gt__src-runbook.html) | 사내 도입 6단계 + 모니터링 메트릭 + 장애 대응 절차 | **결과·결정 기록 (외부 파일 — 이 문서 사이트 밖이라 링크 대상 없음)** | 위치 | 내용 | |---|---| | `~/labs/istio/PLAN.md` | 7-Phase 설계, Verification Matrix, §9.3 결정 기록 | | `~/labs/istio/EXECUTION-LOG.md` | Phase 0~7 + 보정 1·2 + 학습 노트 9가지 누적 | | `~/labs/istio/docs/README.md` | 코드 디렉터리 측 시리즈 인덱스 | --- ## 3. 예시·결과 — anchor가 실측으로 검증되는 곳 멘탈 모델이 맞다면, "503 flip을 active=0 뒤로 미룬다"는 단 하나의 변화가 4개 시나리오 모두에서 끊김을 0으로 바꿔야 한다. 실제로 그랬다. (시나리오 변수 격리 정본: [W5 — Test scenarios](gt__src-w5-test-scenarios.html), 6 events 정의 정본: [W1 — Big Picture](gt__src-w1-big-picture.html).) | # | replicas | 시나리오 | current | improved | anchor 검증 포인트 | |---|---|---|---|---|---| | **S1** | 1 | long `/sleep?seconds=60` + delete @ T+5s | **502 / 8.25s** (retry exhausted) | **200 / 60.01s** | flip 즉시 → LB가 in-flight 든 채 DOWN → RST. 미루면 60초 완주 | | **S2** | 1 | improved 단독 (drain 거동) | — | active=1 60초 유지, hc OPEN→DRAINING | drain.sh가 active>0 동안 health 200을 붙잡음을 직접 관측 | | **S3** | 2 | continuous 90s + rollout restart | **conn_err=9** / 5xx=0 | conn_err=0 / 5xx=0 | `retries 3`이 5xx를 흡수 → disruption은 conn_err로만 보임 | | **S4** | 1 | streaming `/stream?seconds=60&interval=1` + delete @ T+8s | chunks=12 / curl_exit=92 (HTTP/2 CANCEL) | chunks=59/60 / curl_exit=0 | T+8s delete + ~4s detect = chunk 12에서 RST. 미루면 거의 완주 | **왜 S1·S2·S4는 replicas=1, S3만 replicas=2?** roundrobin에서 multi-replica + 한 pod만 delete하면 curl traffic이 다른 pod으로 흘러 영향이 격리되지 않는다 → single-pod로 변수 통제. S3는 운영 rollout 모사라 multi-replica가 필요하다. **검증법(어떻게 "끊김 0"을 판정했나)**: S1/S4는 curl exit code와 수신 chunk 수, S3는 5xx **그리고** connection_err 둘 다 — `retries 3`이 5xx를 다른 worker로 흡수하므로 5xx=0만 보면 깨끗해 보이지만 conn_err=9가 실제 끊김을 드러낸다. 신호 타이밍은 hc.log(`event=transition`), Envoy `/stats`(active gauge), HAProxy `show stat`(UP→DOWN), tcpdump(첫 RST)를 한 타임라인에 정렬해 6 events로 읽었다. --- ## 정리 — 멘탈 모델 요약 이 시리즈를 한 문장으로 압축하면: **graceful termination은 Envoy를 drain하는 문제가 아니라, 우리가 쥔 유일한 레버(health 200↔503)의 flip 시점을 active=0 뒤로 미뤄 LB의 backend 제거가 in-flight를 끊지 못하게 만드는 타이밍 정렬 문제다.** event 5(LB DOWN)는 어차피 일어나니, event 1(health flip)을 그 전이 아니라 event 4(active=0) 뒤에 놓는 것 — 나머지는 전부 이 한 줄의 구현 디테일이다. ### 핵심 정리 — 핵심 발견 8가지 (다시 펼쳤을 때 빠른 회상) 1. **Citrix downStateFlush ≡ HAProxy `on-marked-down shutdown-sessions`** — 4초 detect + in-flight RST. 본 홈랩 실험으로 1:1 대응 입증. 2. **drain.sh 본질은 Envoy drain이 아니라 health 200 유지** — active=0까지 `/health_check.html=200`을 지속해 LB pool membership을 간접 제어. LB 명령 권한 없이도 LB 동작 제어 가능. 3. **HAProxy `retries 3`이 5xx를 흡수** — backend DOWN 시 자동 retry로 다른 backend에 재전송. 짧은 요청은 5xx=0이지만 long/streaming은 RST 노출. **disruption은 5xx + connection_err 둘 다 봐야**. 4. **anti-affinity required + maxUnavailable=0 + N=N nodes는 deadlock** — 새 RS pod이 들어갈 좌석 없음. master1 untaint 또는 maxUnavailable=1로 해소. 5. **`externalTrafficPolicy: Local` + 컨테이너 ImagePullBackOff = 노드 NodePort 미응답 도미노** — 한 컨테이너 이미지 문제가 노드 외부 traffic 거부로 증폭. 6. **Go middleware의 옵셔널 인터페이스 forwarding 함정** — `*statusRecorder`가 `Flusher` promote 안 함 → `/stream` type assertion 실패. 명시적 forward 메소드 필요 (또는 Go 1.20+ `http.ResponseController`). 7. **HTTP/1.1 vs HTTP/2 RST 표현 차이** — HAProxy `alpn h2,http/1.1` 시 동일 RST 사건이 HTTP/2 stream cancellation(`curl exit 92`)으로 표현. HTTP/1.1이면 502 또는 exit 18. 8. **`tests/01-baseline`(`mode`) vs `tests/03-continuous`(`deploy-mode`) 라벨 키 불일치** — 매니페스트에 두 라벨 모두 필요한 함정. ### 한 페이지 회상 카드 (offline 버전) | 영역 | 5초 회상 | 30초 회상 | |---|---|---| | 트래픽 경로 | client → HAProxy:443(L7+TLS) → worker:30080 → IGW(istio-proxy+hc) → backend | + check port 30180(hc 별도), externalTrafficPolicy=Local, anti-affinity required(hostname) | | 6 events 순서 (good) | drain start → active=0 → health 503 → LB DOWN → endpoint 제거 | improved 모드. current는 health 503 즉시 → LB DOWN → RST | | current 결과 | S1: 502/8.25s. S4: HTTP/2 CANCEL @ chunk12 | S3: conn_err=9 (5xx=0이지만 retries 3이 흡수) | | improved 결과 | S1: 200/60s. S4: chunks 59/60. S3: conn_err=0 | active 1 폴링 60초 유지, hc OPEN→DRAINING만 발생 | | 사내 적용 핵심 | hc 사이드카 주입 + 사내 LB 매핑 검증 + p99 측정으로 timeout 산정 | terminationGracePeriodSeconds = max(preStop, drainDuration) + 여유 | ### Open Questions (후속 검증 필요) - [ ] WebSocket / gRPC streaming 환경에서 active count가 long-lived로 유지되면 drain timeout 강제 종료 정책을 어떻게 설계할지 - [ ] HTTP/3 (QUIC) 환경에서 backend DOWN 시 거동 (UDP라 TCP RST 개념 없음) - [ ] 사내 LB가 Citrix가 아닌 F5 BIG-IP면 `Action On Service Down` 동작이 정확히 같은지 staging 검증 - [ ] 다중 IGW Pod (replicas≫2) 환경에서 한 pod 종료가 다른 pod에 미치는 부하 spike 측정 - [ ] 사내 long request **p99 분포**가 실제로 얼마인가 — `terminationGracePeriodSeconds` 산정의 유일한 입력값. APM/Envoy access log histogram 측정 필요. - [ ] 워커 containerd `config_path` 정상화로 `imagePullPolicy: Always` 복원 (현재 `IfNotPresent` + `ctr import` 우회) ### 인접 도메인 (별도 MOC 없음 — 컨텍스트만 표기) - **Networking** — L3/L4 패킷 처리, NodePort + iptables. 본 실험은 L7/HAProxy/Envoy 중심이나 NodePort 경로에서 겹침. - **DevOps** — Kubernetes rollout 정책. anti-affinity deadlock + maxUnavailable 패턴이 여기 속함. - **Observability** — Envoy `/stats`, Prometheus scrape (운영 모니터링 메트릭). --- ## What you might be missing - **drain의 주체는 LB membership이지 Envoy가 아니다.** 흔한 오해는 "Envoy graceful drain만 켜면 끊김이 없다"는 것이다. 실제 보호의 핵심은 active=0까지 `/health_check.html=200`을 유지해 LB가 backend를 pool에 묶어두게 하는 데 있다(발견 #2). Envoy `/drain_listeners?graceful&skip_exit`는 신규 연결을 받지 않게 할 뿐, 이미 LB가 DOWN 마킹하면 on-marked-down RST를 막지 못한다. - **disruption 지표는 5xx 단독으로 부족하다.** HAProxy `retries 3`이 5xx를 다른 backend로 흡수하므로 짧은 요청은 5xx=0으로 깨끗해 보인다. long/streaming은 retry가 무력해 connection_err로만 드러난다(발견 #3). 사내 적용 시 SLO를 5xx rate만으로 잡으면 streaming 끊김을 놓친다. - **이벤트 번호는 W1 정본(1~6) 기준.** `Pod terminated`는 event가 아니라 T+0 트리거다. 옛 artifacts·일부 노트에 0~6 7개로 적힌 흔적이 있으면 무시하고 6개 체계로 통일해 회상할 것. - **FSM 명칭은 2종이 공존한다.** OPEN/DRAINING/CLOSING/CLOSED/FAULT(현행)와 READY/.../FAILED(옛 artifacts). 옛 `tests/artifacts/2026*/` 로그를 읽을 때 명칭 매핑(§abstract callout)을 먼저 떠올릴 것. - **event 2(readiness 503)는 무결성의 인과 경로가 아니다.** improved 모드에서 무중단을 보장하는 것은 event 3·4(drain → active=0)와 event 1(health 503)의 순서이며, K8s endpoint 제거는 이미 active=0인 뒤의 비동기 후행 정리일 뿐이다. SIGTERM과 endpoint 제거의 선후를 인과로 오해하지 말 것. - **이 MOC가 가리키는 코드 리포 파일(PLAN.md/EXECUTION-LOG.md 등)은 문서 사이트 밖이다.** 링크가 없는 것은 누락이 아니라 의도된 것 — 코드 리포(`~/labs/istio/`)에서 직접 열어야 한다.