Chapter 17

애플리케이션 개발을 위한 모범 사례

  • 17.1 전체 그림 — 실제 앱은 어떤 리소스로 구성되나
  • 17.2 Pod 생명주기와 앱 개발자가 알아야 할 것
  • 17.3 클라이언트 요청을 깨뜨리지 않기
  • 17.4 운영하기 쉬운 앱 만들기
  • 17.5 개발/테스트 워크플로우

지금까지 K8s 리소스를 하나하나 봤다면, 17장은 그것들을 묶어서 "실제로 앱을 개발할 때 뭘 신경 써야 하는가"의 베스트 프랙티스야. 책 전체에서 가장 실무에 직결되는 장 중 하나야.

먼저 전형적인 앱의 K8s 리소스 구성을 보면, Deployment / StatefulSet(파드 템플릿 + replica), Service + 필요 시 Ingress(노출), ConfigMap / Secret(설정·비밀), PVC + 사전 생성된 StorageClass(영속 저장), ServiceAccount + imagePullSecret(신원 + 이미지 pull), HPA(자동 스케일), LimitRange / ResourceQuota(운영팀이 미리 설정), 그리고 자동 생성되는 ReplicaSet, Pod, Endpoints가 있어. ConfigMap은 보통 앱 manifest의 일부고 Secret은 운영팀이 별도 관리해. 모든 리소스에 라벨·어노테이션을 풍부하게 달아 — 나중에 자기 자신이 고마워해.

VM과 가장 큰 차이는 파드는 자주, 자동으로 죽고 옮겨진다는 거야. 사람이 옆에서 챙겨주지 않아. 옮겨질 때 사라지는 것들을 짚으면, IP는 매번 바뀌니까 IP 기반 클러스터 멤버십은 금지고, hostname은 일반 파드는 바뀌고 StatefulSet만 안정적이고, 컨테이너 파일시스템에 쓴 데이터는 컨테이너가 재시작만 돼도 사라져 (파드는 그대로여도). 파드가 옮겨지지 않아도 컨테이너는 OOMKill, liveness 실패, 크래시로 재시작될 수 있고, 그때마다 컨테이너 파일시스템은 새 layer로 시작하니까 데이터가 증발해. 보존하려면 최소한 emptyDir 볼륨이라도 마운트해야 해. 다만 양날의 검이 있는데, 디스크 캐시를 볼륨으로 보존하면 캐시가 깨졌을 때 새 컨테이너도 같은 데이터로 또 죽어서 CrashLoopBackOff가 돼. "캐시는 휘발 가능"이 안전한 기본값이야.

여기서 중요한 함정 하나 — 파드가 CrashLoopBackOff에 빠져 있어도 ReplicaSet은 그걸 정상으로 봐. Pod의 status가 Running이고, "컨테이너가 자주 죽는다"는 ReplicaSet 입장에선 replica count가 맞으니까 OK라는 거야. 다른 노드로 옮기면 살 수도 있는데도 안 옮겨. 운영자가 직접 파드를 지워야 새로 스케줄링돼.

K8s는 manifest의 순서대로 파드를 띄워주지 않아. "백엔드가 뜬 다음에 프론트엔드"를 보장하려면 init containers를 써. initContainers에 의존 서비스를 polling하는 컨테이너를 두면 돼. 여러 개면 순차 실행되고 모두 성공해야 메인 컨테이너가 떠. 단순 의존성 체크용으로 좋아. 더 좋은 설계는 앱이 의존 서비스 부재를 internally 처리하고 readiness probe로 신호하는 거야. init container는 시작 시점만 보장하지 운영 중 의존성 단절은 못 막거든.

Lifecycle hooks는 컨테이너 단위 훅이야. 파드 단위가 아니야 — 컨테이너가 재시작될 때마다 다시 호출돼. postStart는 컨테이너 시작 직후고 exec 또는 HTTP GET이 가능해. 끝날 때까지 컨테이너는 Pending이고, 실패하면 컨테이너 강제 종료돼. 표준출력이 안 보여서 디버그가 골치야. preStop은 종료 직전이고 SIGTERM 보내기 전에 호출돼. 결과 무관하게 어차피 종료되고. 흔한 안티패턴 하나 — "내 앱이 SIGTERM을 못 받는다"고 preStop에서 직접 SIGTERM 보내는 거. 진짜 원인은 보통 shell이 SIGTERM을 삼키는 것이야. Dockerfile에 ENTRYPOINT ["/mybinary"](exec form)으로 쓰면 해결돼. shell form(ENTRYPOINT /mybinary)을 쓰면 sh가 PID 1이 되어 신호가 안 전달돼.

파드 종료 시퀀스는 이래. 먼저 API 서버가 deletionTimestamp를 설정해(실제 삭제 X). Kubelet이 종료를 시작하면서 preStop hook 실행 → 끝날 때까지 대기 → SIGTERM → terminationGracePeriodSeconds(기본 30초) 동안 대기 → 안 끝나면 SIGKILL 순서야. 모든 컨테이너가 종료되면 Kubelet이 API 서버에 알려서 파드 리소스를 삭제하고. kubectl delete po mypod --grace-period=5로 오버라이드 가능하고, --grace-period=0 --force는 즉시 삭제인데 StatefulSet에서는 절대 함부로 쓰면 안 돼. 같은 ordinal의 두 파드가 동시에 떠서 데이터 손상 위험이 있어. 노드가 확실히 죽었거나 네트워크에서 분리된 게 확인됐을 때만 써.

파드가 종료될 때 "꼭 해야 할 일"은 어디서 할까? preStop이나 SIGTERM 핸들러에서 하면 안 돼. 두 가지 이유가 있어 — 컨테이너 종료가 곧 파드 종료는 아니거든(재시작일 수도 있고), grace period 내에 못 끝낼 수 있고 노드가 죽으면 아예 못 해. 해결은 별도의 정리용 파드(CronJob 또는 항상 도는 watcher 파드)가 orphan 데이터를 발견해서 처리하는 거야. 종료 핸들러에 의존하지 마.

이제 클라이언트 요청을 깨뜨리지 않는 법. 시작 시점에선 readiness probe가 없으면 파드가 뜨자마자 endpoint에 등록돼서 앱이 준비 안 됐는데 트래픽이 가서 "Connection Refused"가 나. readiness probe는 거의 필수야. 종료 시점은 미묘해. 파드 삭제 시 두 시퀀스가 병렬로 진행되거든. Path A(짧음)는 API server → Kubelet → 컨테이너 종료 시작(preStop, SIGTERM)이고, Path B(긺)는 API server → Endpoints controller → API server(Endpoints 객체 수정) → 모든 노드의 kube-proxy → iptables 업데이트야. Path A가 거의 항상 먼저 끝나. 즉 SIGTERM 받은 시점에도 일부 클라이언트는 여전히 그 파드로 연결을 시도해(iptables가 아직 안 바뀜).

흔한 잘못된 해결책이 "readiness probe를 SIGTERM 받으면 fail시키면 되지?"인데 안 돼. Endpoints controller는 deletionTimestamp가 찍히면 즉시 endpoint에서 빼버리고, 그 시점부터 readiness probe 결과는 무시되거든. 진짜 해결책은 SIGTERM을 받아도 몇 초 동안 더 받는 거야. iptables가 모든 노드에서 업데이트될 시간을 주는 거지. lifecycle.preStop.exec.command["sh", "-c", "sleep 5"]만 박아두면 코드 수정 없이 5초 buffer가 생겨. 완벽하진 않지만 90% 이상의 broken connection을 막아. 운영 부하가 높을 땐 더 길게. 올바른 종료 절차는 SIGTERM 받음 → 몇 초 대기(iptables 전파) → 새 connection accept 중단 → inactive keep-alive 닫기 → active 요청 완료 기다림 → 종료, 이 순서야.

운영하기 쉬운 앱을 만드는 디테일도 짚어두자. 작은 이미지 — OS 통째로 넣지 마. Go라면 FROM scratch + 단일 바이너리. 단, 너무 작으면 디버그가 지옥이야 — ping, curl, dig 같은 도구가 없으면 컨테이너 안에서 아무것도 못 해. 적절한 sweet spot이 필요해. 이미지 태그는 latest 금지. latest 쓰면 replica마다 어느 버전인지 알 수 없고, 롤백 불가하고, imagePullPolicy: Always를 강제해야 하는데 그러면 레지스트리 다운 시 파드가 안 떠. 운영 이미지는 항상 명시적 버전 태그를 써. 라벨은 다차원으로 — app 이름, tier(frontend·backend), environment(dev·staging·prod), version, release type(stable·canary), tenant, shard 같은 여러 축을 넣어. 선택자로 다양한 쿼리가 가능해져. 어노테이션은 설명, 담당자 연락처, 의존 서비스 목록, 빌드 정보, UI 메타데이터 같은 거야. 라벨과 다른 점은 selector로 못 쓴다는 것 — 정보 저장용이야.

종료 메시지도 좋은 도구야. terminationMessagePath에 파일 경로를 적으면 그 파일에 쓴 내용이 kubectl describe pod의 Last State Message에 보여. 로그 안 봐도 왜 죽었는지 즉시 확인 가능해. 또는 terminationMessagePolicy: FallbackToLogsOnError로 두면 비정상 종료 시 로그 마지막 몇 줄을 자동 사용하고. 로그는 stdout으로 — 파일 말고 stdout으로 쓰면 kubectl logs로 바로 보고, 이전 컨테이너 로그는 --previous로 봐. 클러스터 전체 중앙집중 로깅은 보통 EFK(Elasticsearch + Fluentd + Kibana) 스택을 써. 멀티라인 로그(Java stack trace 같은 거)는 한 줄씩 따로 인덱싱돼서 보기 어려우니까 JSON 로그 + 사이드카 패턴이 해결책 중 하나야.

마지막으로 개발·테스트 워크플로우. 매번 빌드+푸시+배포는 미친 짓이야. 개발 중엔 앱을 그냥 로컬에서 IDE로 돌려. K8s 안에서 안 돌려도 돼. 백엔드 서비스는 BACKEND_SERVICE_HOST/PORT 환경변수 수동 설정으로 접속하고, K8s 안의 서비스는 임시로 NodePort/LoadBalancer로 노출하거나 kubectl port-forward로 끌어와. API 서버가 필요하면 ServiceAccount 토큰을 kubectl cp로 가져오거나, 로컬에서 kubectl proxy 띄워두고 ambassador 패턴을 흉내내. Minikube + 로컬 도커를 쓸 때는 eval $(minikube docker-env)만 쳐두면 docker build가 minikube VM의 도커 데몬에 빌드돼. 푸시 필요 없고 즉시 사용 가능해. 운영 환경 시뮬레이션이 필요할 때 강력해. 매니페스트는 git에 두고 자동 배포하는 게 정석이야 — 선언적 모델의 장점이거든. kubectl apply로 동기화하는 게 GitOps의 시작점이야. 책에선 kube-applier를 언급하지만 지금은 ArgoCD, Flux가 표준이고. YAML 중복을 피하는 템플릿 시스템으로 책은 Ksonnet/Jsonnet을 언급하는데, Ksonnet은 2018년에 deprecated됐어. 지금은 Helm(차트), Kustomize(오버레이), CDK8s(타입스크립트·파이썬으로 매니페스트 작성)가 표준이야. 18장에서 Helm을 다뤄.


정리

17장 읽고 기억할 거 세 가지:

  1. 앱은 자주, 예측 불가하게 죽는다고 가정하고 짜라. IP·hostname 의존 금지, 컨테이너 파일시스템에 중요한 거 쓰지 말기, init container로 시작 의존성 풀기, readiness probe 필수, 종료 시 정리는 별도 watcher 파드로. CrashLoopBackOff 파드가 ReplicaSet 차원에서 자동 교체되지 않는다는 함정도 기억.
  2. Graceful shutdown은 sleep 5초로 시작하라. SIGTERM 받자마자 소켓 닫는 건 거의 항상 connection refused를 만든다. iptables 전파 시간을 벌어주는 preStop sleep이 코드 수정 없이 가장 효과 좋은 처치. 그 다음 단계는 새 connection 거부 → keep-alive 정리 → 활성 요청 완료 대기 → 종료. 그리고 SIGTERM이 안 가는 거 같으면 Dockerfile의 ENTRYPOINT exec form부터 의심.
  3. 운영성은 자잘한 디테일의 합. 작은 이미지(하지만 디버그 도구는 남기기), latest 태그 금지, 다차원 라벨, 종료 메시지 파일, stdout 로깅, ConfigMap/Secret 분리, 로컬 IDE에서 개발하고 git에 매니페스트 두고 자동 동기화. 책의 Ksonnet은 죽었지만 GitOps 아이디어는 ArgoCD/Flux로 살아남았다.