운영툴을 쓰는 사람들에게서 같은 제보가 반복됐다.
"배포하고 나면 잠깐 안 들어가져요."
몇 초 뒤에 다시 되니 아무도 심각하게 보지 않았고, 나도 한동안 "기동 중이라 그렇다"고 답했다.
그런데 매니페스트를 열어보니 기동 문제가 아니었다.
종료 절차가 잘려 있었다.
1. 배경
관리 서버는 replica 수가 적고, 롤링 업데이트로 배포한다.
Deployment 매니페스트는 대략 이런 상태였다.
spec:
# strategy 없음 - 기본값에 맡김
template:
spec:
terminationGracePeriodSeconds: 10
containers:
- name: admin
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 45"]
livenessProbe:
httpGet: { path: /, port: 9501 }
initialDelaySeconds: 125
periodSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet: { path: /, port: 9501 }
initialDelaySeconds: 125
periodSeconds: 2
failureThreshold: 3preStop에서 45초를 자도록 걸어둔 이유는 명확하다.
파드가 종료될 때 Endpoints에서 빠지는 것과 컨테이너가 죽는 것은 동시에 일어나지 않는다.
로드밸런서가 아직 이 파드로 트래픽을 보내는 동안 프로세스가 먼저 죽으면 그 요청은 실패한다.
preStop으로 잠깐 버티면서 그 사이 Endpoints 갱신이 전파되도록 하는 것이 일반적인 처방이다.
여기까지는 의도가 맞다.
문제는 그 아래 줄이었다.
2. 문제 정의
2.1 45초를 자는데 유예 시간은 10초
terminationGracePeriodSeconds는 preStop 훅과 SIGTERM 처리까지 모두 포함한 전체 예산이다.
kubelet은 파드를 종료할 때 이렇게 움직인다.
유예 시간이 만료되면 훅이 끝났든 말든 SIGKILL이 날아간다.
즉 preStop이 45초를 자도록 짜여 있어도, 예산이 10초면 10초에 잘린다.
의도한 것은 45초 동안의 배수 대기였지만 실제로는 10초만 확보됐고, 그마저도 훅이 sleep을 다 채우기 전에 강제 종료됐다.
전파가 덜 끝난 상태에서 프로세스가 사라지니 그 순간 들어온 요청이 실패한다.
preStop에 N초를 걸었다면 terminationGracePeriodSeconds는 N + 앱이 정리에 쓰는 시간보다 커야 한다.
둘 중 하나만 고치면 조용히 잘린 채로 돈다.
2.2 고정 대기 프로브의 딜레마
두 번째 문제는 initialDelaySeconds: 125였다.
이 값은 "이 앱은 뜨는 데 최대 125초쯤 걸리니 그때까지는 묻지 말라"는 뜻이다.
프로브에 startupProbe가 없을 때 흔히 쓰는 우회책인데, 대가가 두 가지 있다.
| 대가 | 내용 |
|---|---|
| 평상시가 느려진다 | 20초 만에 다 떠도 readiness는 125초까지 무조건 기다린다. 롤링 업데이트 한 스텝이 그만큼 늘어진다 |
| 진짜 장애에 둔감해진다 | liveness도 125초 동안 안 본다. 기동 직후 죽어버린 프로세스를 2분 넘게 방치한다 |
고정 대기는 최악의 기동 시간에 맞춰야 해서, 맞추는 순간 평상시와 장애 감지 양쪽을 함께 희생한다.
2.3 strategy를 안 적었다
strategy가 비어 있으면 기본값이 적용된다.
기본값에 맡기는 것 자체가 틀린 것은 아니지만, replica 수가 적을 때 몇 개까지 동시에 내려도 되는지를 명시하지 않은 상태가 된다.
가용성을 보장하고 싶으면 이 값은 추측이 아니라 선언이어야 한다.
3. 해결
3.1 유예 시간을 preStop보다 크게
terminationGracePeriodSeconds: 50 # preStop 45초 + 정리 여유preStop의 45초를 실제로 다 쓰게 만드는 것이 목적이다.
운영 환경은 연결이 더 오래 살아 있어 70초를 준다.
3.2 startupProbe로 기동과 감시를 분리
startupProbe:
httpGet: { path: /, port: 9501 }
initialDelaySeconds: 30
periodSeconds: 2
failureThreshold: 90 # 30s + (2s x 90) = 최대 210초까지 기동 허용
livenessProbe:
httpGet: { path: /, port: 9501 }
initialDelaySeconds: 30
periodSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet: { path: /, port: 9501 }
initialDelaySeconds: 30
periodSeconds: 2
failureThreshold: 3startupProbe가 성공할 때까지 liveness와 readiness는 아예 실행되지 않는다.
그리고 성공하는 즉시 둘이 활성화된다.
덕분에 앞의 딜레마가 사라진다.
40초에 다 뜨면 40초에 준비 완료로 잡히고, 그 뒤로는 liveness가 6초 안에 죽음을 잡아낸다.
기동이 유난히 오래 걸리는 날에도 210초까지는 재시작 없이 기다린다.
고정 대기였다면 이 여유를 얻는 대가로 평상시 125초를 계속 냈어야 한다.
3.3 내려가는 개수를 선언
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0maxUnavailable: 0은 새 파드가 Ready가 되기 전에는 기존 파드를 하나도 내리지 않는다는 뜻이다.
maxSurge: 1이 그 사이 한 개를 더 띄울 여유를 준다.
이 조합은 startupProbe와 같이 있어야 의미가 산다.
readiness가 늘 125초 뒤에 켜지는 상태였다면 maxUnavailable: 0은 배포 시간만 늘렸을 것이다.
3.4 배포 외의 축출도 막기
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: admin-pod-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: admin-deploy롤링 업데이트만 파드를 내리는 것이 아니다.
노드 드레이닝, 클러스터 오토스케일러의 노드 정리도 파드를 쫓아낸다.
PDB는 그런 자발적 축출(voluntary disruption) 에서 최소 한 개는 남기라고 선언한다.
4. 결과
바뀐 값은 셋이다.
| 설정 | 이전 | 이후 |
|---|---|---|
| readiness가 켜지는 시점 | 무조건 125초 뒤 | 기동을 마치는 즉시 |
terminationGracePeriodSeconds | 10초 | 50초 (운영 70초) |
| 배포 중 동시 미가용 파드 | 선언 없음 | 0개 |
배포 직후 잠깐 안 들어가진다는 제보가 사라졌다.
다만 응답 실패율을 따로 계측해 둔 게 아니라서, 확인한 것은 제보가 멈췄다는 것과 롤아웃 중에 새 파드가 Ready가 된 뒤에야 기존 파드가 내려간다는 것 두 가지다.
5. 한계
미뤄둔 것이 남아 있다.
제일 걸리는 건 프로브가 /를 찌른다는 점이다.
루트 경로가 200을 준다는 것은 HTTP 파이프라인이 살아 있다는 뜻일 뿐이라, DB나 Redis 연결이 끊긴 상태에서도 200이 나온다.
readiness는 의존성까지 확인하는 별도 엔드포인트를 봐야 맞고 liveness는 오히려 얕아야 하는데, 지금은 같은 경로를 둘 다 쓰고 있어서 역할이 겹친다.
preStop: sleep 45도 정확한 신호는 아니다.
45초는 "이 정도면 전파가 끝나겠지"라는 추정이다.
앱이 SIGTERM을 받아 진행 중인 요청을 마무리하고 스스로 빠지는 쪽이 맞고, sleep은 그 처리를 앱에 넣기 전까지 쓰는 임시 방편에 가깝다.
minAvailable: 1은 replica가 1일 때 오히려 위험하다.
파드가 하나뿐인데 최소 1개를 유지하라고 하면 드레이닝이 영원히 막혀서, 노드 교체 작업이 멈춰 선다.
replica 수와 같이 봐야 하는 값이다.