CKAD — 쿠버네티스 애플리케이션 개발자 · 관측성과 유지보수 · 이론
'Ready' 와 '실제로 동작한다' 는 다른 명제다
한 줄 요약
프로브는 애플리케이션 상태를 쿠버네티스가 알아들을 수 있는 언어로 번역하는 인터페이스다. liveness 는 "죽었으니 다시 띄워라", readiness 는 "지금은 보내지 마라", startup 은 "아직 뜨는 중이니 기다려라"를 뜻하고, 셋의 결과는 완전히 다른 행동으로 이어진다.
왜 이게 필요했나
컨테이너 프로세스가 살아 있다는 것과 서비스가 정상이라는 것은 다르다. JVM 이 데드락에 빠지면 프로세스는 멀쩡히 존재하고 PID 도 그대로다. 캐시를 채우는 중인 앱은 프로세스도 살아 있고 포트도 열려 있지만 요청을 처리하면 전부 오류가 난다. 쿠버네티스는 컨테이너 안을 들여다볼 수 없으므로, 애플리케이션이 자기 상태를 알려 줄 창구가 필요했다.
그런데 "상태"는 한 종류가 아니다. 재시작하면 나아지는 상태(데드락, 커넥션 풀 고갈)와 기다리면 나아지는 상태(캐시 워밍업, 의존 서비스 일시 장애)는 대응이 정반대다. 후자에 재시작을 걸면 영원히 준비되지 않는다. 그래서 프로브가 두 개로 갈렸다.
세 번째가 나중에 추가된 이유도 명확하다. 레거시 앱은 뜨는 데 5분이 걸리기도 하는데, liveness 의 initialDelaySeconds 를 300초로 잡으면 정상 운영 중에도 장애 감지가 5분 늦어진다. startup 프로브는 이 둘을 분리한다 — 뜨는 동안은 느슨하게, 뜬 뒤에는 촘촘하게.
어떻게 동작하나
| 프로브 | 실패하면 | 성공 전까지 |
| --- | --- | --- |
| livenessProbe | 컨테이너를 재시작 | 계속 검사 |
| readinessProbe | Service 엔드포인트에서 제거 (재시작 없음) | 트래픽 안 받음 |
| startupProbe | 컨테이너를 재시작 | liveness/readiness 를 아예 시작하지 않음 |
검사 방식은 셋 다 같다. httpGet(200~399 면 성공), tcpSocket(연결되면 성공), exec(종료 코드 0 이면 성공). gRPC 헬스 체크용 grpc 도 있다.
타이밍 필드는 계산해서 정해야 한다.
initialDelaySeconds— 컨테이너 시작 후 첫 검사까지 대기(기본 0)periodSeconds— 검사 주기(기본 10)timeoutSeconds— 한 번의 검사 제한 시간(기본 1)failureThreshold— 연속 몇 번 실패해야 조치할지(기본 3)successThreshold— 연속 몇 번 성공해야 회복으로 볼지(liveness/startup 은 1 고정)
감지까지의 최악 지연 ≈ initialDelaySeconds + periodSeconds × failureThreshold 다. 재시작이 너무 자주 일어난다면 failureThreshold 를 올리고, 장애 감지가 느리다면 periodSeconds 를 줄인다. startup 프로브의 예산은 periodSeconds × failureThreshold 이고, 이 값이 앱의 최대 기동 시간보다 커야 한다.
종료 쪽도 대칭으로 설계돼 있다. 파드를 지우면 kubelet 이 SIGTERM 을 보내고 terminationGracePeriodSeconds(기본 30) 만큼 기다린 뒤 SIGKILL 한다. 동시에 엔드포인트에서 제거되지만 두 일은 비동기라, 이미 라우팅된 요청이 잠깐 더 들어올 수 있다. 그래서 preStop 훅에 짧은 sleep 을 넣어 엔드포인트 전파를 기다리는 패턴을 쓴다. preStop 이 도는 시간도 grace period 안에서 소모된다는 점을 잊으면 안 된다.
컨테이너가 왜 죽었는지는 terminationMessagePath(기본 /dev/termination-log)에 남고, terminationMessagePolicy: FallbackToLogsOnError 를 주면 그 파일이 비었을 때 컨테이너 로그의 마지막 부분을 대신 채워 준다. kubectl describe pod 의 Last State 에 보이는 값이 이것이다.
PodDisruptionBudget 은 자발적 중단(노드 drain, 업그레이드)에만 적용된다. 노드가 갑자기 죽는 비자발적 중단은 막지 못한다. minAvailable: 2 를 3 레플리카에 걸면 drain 이 한 번에 하나씩만 진행된다.
현장에서 만나는 모습
홈랩 클러스터에서 이 교훈을 세 번 확인했다. KubeVirt 를 올렸을 때 모든 컴포넌트가 AllComponentsReady 였는데 VM 은 뜨지 않았다. virt-launcher 파드를 뜯어 보니 init 컨테이너가 실행할 바이너리를 담은 볼륨 마운트가 빠져 있었다. 상태 필드가 Ready 라는 것과 기능이 동작한다는 것은 다른 명제다. 그래서 GPU Operator 를 올린 뒤에도 "파드 32개 전부 Running" 에서 멈추지 않고, 실제로 파드 안에서 nvidia-smi 가 GPU 를 보는지까지 확인했다. 결과는 NVIDIA GeForce RTX 5090, 32607 MiB, 570.195.03 — 여기까지 봐야 검증이 끝난다.
로그 쪽에서도 같은 원칙이 적용된다. 장애 한복판에서 실제로 필요한 것은 읽기가 아니라 필터링과 집계다. 그래서 메시지 문자열은 상수로 두고 변하는 값은 전부 필드로 뺀다.
// 나쁨 — 같은 사건을 세려면 정규식이 필요하다logger.info(`user ${userId} checkout failed after ${ms}ms`)// 좋음 — 메시지는 상수, 값은 필드logger.error({ event: 'checkout.failed', user_id: userId, duration_ms: ms }, 'checkout failed')레벨 판단 기준도 하나로 고정하면 논쟁이 끝난다. ERROR 는 우리 시스템이 자기 책임을 다하지 못했다는 뜻이다. 400번대 응답(잘못된 요청, 만료된 토큰, 권한 없음)은 시스템이 정상 동작한 결과이므로 ERROR 가 아니다. 이것들을 ERROR 로 찍는 순간 ERROR 로그의 대부분이 정상 트래픽으로 채워지고 진짜 결함이 그 안에 묻힌다.
조사 순서도 정해져 있다. 메트릭 → 트레이스 → 로그. 메트릭이 "언제부터 무엇이 얼마나"에 답하고, 트레이스가 "어느 서비스 어느 구간"에 답하고, 로그가 "그 안에서 정확히 무슨 값으로 무슨 분기를 탔는지"에 답한다. trace_id 로 필터링해 들어가면 읽어야 할 줄이 수십 줄로 줄어든다.
다음 실습에서 할 것
ckad-obs 네임스페이스에서 httpGet·tcpSocket·exec 세 방식의 프로브를 만들고, startup 프로브의 기동 예산을 계산해서 채우고, Deployment 에 프로브를 붙여 준비 상태를 제어하고, PodDisruptionBudget 을 건다.
이 실습 환경에서는 실제 로그 조회와 kubectl exec 이 불가능하다. kubectl logs --previous, -c, --since, --tail, kubectl exec -it POD -c CONTAINER -- sh, kubectl debug 같은 조사 명령은 반드시 알아야 하지만 여기서는 손으로 실행할 수 없으므로 퀴즈로 다룬다. 시험장에서는 이 명령들이 그대로 나오니 문법을 정확히 외워 두자.