이번 시간에는 Docker 안에 띄운 k3s 클러스터에서 부하를 건 채 kubectl rollout restart로 배포를 반복해 보면서, readiness probe와 preStop, 앱의 종료 처리가 배포 중 요청 실패를 어떻게 바꾸는지 측정하며 배워가는 시간입니다.
예를 들어서 우리가 파드 세 개짜리 Deployment에 초당 50건씩 요청을 보내면서 kubectl rollout restart로 배포를 한 번 한다고 해볼게요. 코드는 그대로이고 파드만 새로 바뀌는 배포예요. Deployment는 새 파드를 띄우고 옛 파드를 내리는 롤링 업데이트로 파드를 바꿉니다.
설정을 하나씩 더해 가면서, 파드가 내려갈 때 엔드포인트(Service가 요청을 보낼 파드 주소 목록)에서 빠지는 일과 컨테이너가 종료 신호를 받는 일이 어떤 순서로 일어나는지 앱 로그의 시간으로 확인해 볼게요.
실습 준비
이 글의 명령은 모두 실습 코드의 k8s-graceful-rollout/ 디렉터리에서 실행합니다.
compose.yaml: k3s 서버 노드server와 에이전트 노드agent두 컨테이너 정의app.py: 환경 변수로 시작 지연, 처리 시간,SIGTERM처리를 바꾸는 HTTP 앱load.py: Serviceapp에 요청마다 새 연결로 부하를 보내고 실패를 종류별로 세는 스크립트k8s/: Service와 부하 파드(base.yaml), 실습별 Deployment 매니페스트(파일 3개)run.sh: 매니페스트를 적용하고 부하를 건 채 배포를 정해진 횟수만큼 반복하는 스크립트
k3s는 Kubernetes를 바이너리 하나로 묶은 배포판이고 컨테이너 이미지로도 나와 있어서, Docker 컨테이너 두 개로 서버 노드 한 대와 에이전트 노드 한 대를 띄울 수 있어요. 에이전트 노드를 따로 둔 건 파드가 두 노드에 흩어지게 하기 위해서예요.
다만 엔드포인트 반영에 걸리는 시간 같은 숫자는 클러스터 규모와 구성에 따라 달라지니까, 관리형 클러스터에서도 같은 값이 나온다고 보지는 않아요.
compose.yaml은 k3s 서버 노드(server --disable=traefik --disable=servicelb)와 에이전트 노드 두 컨테이너를 띄우고, 실습 디렉터리를 서버 노드의 /labs에 읽기 전용으로 연결해요. 그래서 서버 노드 안의 kubectl에 주는 파일 경로는 /labs 아래 경로입니다.
이제 반복해서 쓸 셸 함수를 하나 만들어 놓을게요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
k() { docker compose -p k8sroll exec -T server kubectl "$@"; }
클러스터를 띄우고 노드를 확인해 볼게요.
docker compose -p k8sroll up -d
k get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
k8sroll-agent Ready <none> 12s v1.35.9+k3s1 172.27.0.3 <none> K3s v1.35.9+k3s1 6.12.5-linuxkit containerd://2.2.7-k3s1
k8sroll-server Ready control-plane 22s v1.35.9+k3s1 172.27.0.2 <none> K3s v1.35.9+k3s1 6.12.5-linuxkit containerd://2.2.7-k3s1
k8s/base.yaml에는 앱 파드를 묶는 Service app과, 부하를 보낼 파드 load가 들어 있어요.
앱
앱 app.py는 Python 표준 라이브러리의 ThreadingHTTPServer로 만든 HTTP 서버예요. 환경 변수로 동작을 바꿀 수 있습니다.
STARTUP_DELAY: 시작하고 이 시간(초)만큼 기다린 뒤에 포트를 열어요. 초기화가 오래 걸리는 앱을 흉내 냅니다.WORK_MS:GET /work요청 하나를 처리하는 데 걸리는 시간(밀리초)이에요.SHUTDOWN_MODE:SIGTERM을 받았을 때의 동작이에요.exit는 바로 프로세스를 끝내고,graceful은 새 연결을 그만 받고 처리 중인 요청을 마친 뒤 끝납니다(graceful shutdown).
/ready는 readiness probe용으로, graceful 모드에서 SIGTERM을 받은 뒤에는 503을 돌려줘요.
앱은 시작, 포트 열림, SIGTERM 수신, 종료를 밀리초 단위 시간과 파드 이름으로 찍어요. SIGTERM을 받은 줄에는 그 직전 6초 동안 1초마다 받은 /work 요청 수와 마지막 요청이 들어온 시간을 함께 찍습니다. 로그와 부하 결과의 시간은 모두 UTC예요.
부하
부하 스크립트 load.py는 정해진 간격으로 GET http://app/work를 보내고, 요청마다 새 연결을 맺어요. Service는 연결이 맺어질 때 파드를 고르기 때문에, 연결을 재사용하면 한 연결이 계속 같은 파드로 가요. 이 실습은 배포 중에 요청이 어느 파드로 가는지를 보려는 것이라 매번 새 연결을 씁니다.
결과는 요청을 보낸 시간(초 단위)별로 나눠 세요. 실패한 요청은 다음 네 가지로 나눕니다.
refused: 연결 거부예요. 목적지 IP에서 그 포트를 열고 있는 프로세스가 없을 때 커널이 연결을 바로 거절합니다.reset: 연결을 맺은 뒤 응답을 받기 전에 상대가 연결을 끊었어요.timeout: 2초 안에 연결이나 응답이 오지 않았어요.http5xx: 서버가 5xx 응답을 돌려줬어요. 이 실습에서는 한 번도 나오지 않았습니다.
/tmp/stop 파일이 생기면 보내기를 멈추고, 실패가 있었던 초만 출력한 뒤 합계를 찍어요. RATE는 초당 요청 수이고 기본값은 50이에요.
앱 이미지는 따로 만들지 않고 python:3.12-slim을 그대로 써요. 노드 안의 컨테이너 런타임은 호스트 Docker의 이미지를 볼 수 없으니까, docker save로 내보낸 이미지를 두 노드에 각각 넣어 줍니다. 앱과 부하 스크립트는 ConfigMap으로 만들어 파드에 파일로 연결해요.
for node in server agent; do
docker save python:3.12-slim | docker compose -p k8sroll exec -T "$node" ctr images import -
done
k create configmap scripts --from-file=/labs/app.py --from-file=/labs/load.py
k apply -f /labs/k8s/base.yaml
배포 한 번을 재는 방법
run.sh는 매니페스트를 적용한 뒤 배포를 정해진 횟수만큼 반복해요. 배포 한 번은 이렇게 진행합니다.
- 지금 떠 있는 옛 파드의 로그를 따라가며
out/<매니페스트 이름>-<회차>.old.log에 저장해요. 옛 파드는 배포가 끝나면 사라지니까 미리 붙어 있어야SIGTERM로그를 받을 수 있습니다. load파드에서 부하를 시작하고 5초 뒤kubectl rollout restart로 배포를 시작해요.kubectl rollout status가 끝나고 종료 중인 파드가 모두 사라질 때까지 기다린 뒤, 3초 더 부하를 보내고 멈춥니다. 새 파드의 로그는out/<매니페스트 이름>-<회차>.new.log에 저장해요.
배포 사이에는 125초를 쉬어요. 앞 배포에서 사라진 파드 IP로 가다 응답을 못 받은 연결 기록(conntrack의 SYN_SENT 항목, 수명 120초)이 다음 측정에 타임아웃으로 섞이지 않게 하려는 거예요.
실습 1: 아무 설정 없이 배포하면
첫 설정에는 probe도 preStop도 없어요. 앱은 시작하고 5초 뒤에 포트를 열고, SIGTERM을 받으면 바로 끝납니다. 요청 하나는 200밀리초 걸려요.
k8s/1-plain.yaml은 이 앱을 파드 3개로 띄우는 Deployment이고, 환경 변수는 SHUTDOWN_MODE=exit, STARTUP_DELAY=5, WORK_MS=200입니다.
mkdir -p out
chmod +x run.sh
./run.sh k8s/1-plain.yaml 3
첫 번째 배포의 결과예요.
## 1-plain 배포 1
restart 13:43:28
deployment.apps/app restarted
deployment "app" successfully rolled out
13:43:30 ok=33 refused=12 reset=1 timeout=4
13:43:31 ok=26 refused=13 reset=4 timeout=7
13:43:32 ok=8 refused=35 timeout=7
13:43:33 ok=2 refused=40 reset=3 timeout=5
13:43:34 refused=50
13:43:35 ok=3 refused=47
13:43:36 ok=31 refused=19
13:43:37 ok=10 refused=1
합계 ok=414 refused=217 reset=8 timeout=23
세 번 배포한 실패 수는 다음과 같아요.
- 1회차: 248건(연결 거부 217, 리셋 8, 타임아웃 23)
- 2회차: 249건(연결 거부 222, 리셋 14, 타임아웃 13)
- 3회차: 233건(연결 거부 199, 리셋 7, 타임아웃 27)
1회차의 옛 파드와 새 파드 로그(out/1-plain-1.old.log, out/1-plain-1.new.log)를 시간순으로 합쳐 보면 원인이 보여요. 합치는 명령은 싣지 않았고, 아래는 발췌예요.
13:43:30.411 app-5f657484ff-np5gl SIGTERM 받음, 직전 6초의 초당 요청 수 [13, 12, 14, 23, 11, 10], 마지막 요청 13:43:30.259
13:43:31.917 app-5f657484ff-pw267 SIGTERM 받음, 직전 6초의 초당 요청 수 [19, 15, 17, 18, 14, 14], 마지막 요청 13:43:31.913
13:43:33.310 app-5f657484ff-4p6r8 SIGTERM 받음, 직전 6초의 초당 요청 수 [14, 21, 18, 14, 16, 11], 마지막 요청 13:43:33.275
13:43:35.688 app-69d5b57d4d-cxr7s 포트 8080 열림
readiness probe가 없으니 새 파드는 컨테이너가 뜨자마자 준비된 것으로 처리됐어요. 그래서 Deployment는 새 파드가 포트를 열기도 전에 옛 파드를 하나씩 내렸고, 13:43:33에 옛 파드 세 개가 모두 끝났습니다.
새 파드가 처음 포트를 연 건 13:43:35.688이에요. 13:43:34에 보낸 50건이 모두 연결 거부로 끝난 건 그 순간 요청을 받을 수 있는 파드가 하나도 없었기 때문입니다.
실습 2: readiness probe를 걸면
그럼 같은 Deployment에 readiness probe만 더해 볼게요. kubelet이 1초마다 /ready를 부르고, 성공해야 파드를 준비된 것으로 봅니다. k8s/2-readiness.yaml은 1-plain.yaml에 readinessProbe(httpGet /ready, periodSeconds: 1)만 더한 파일이에요.
readiness probe는 실패하면 파드를 엔드포인트에서 뺄 뿐이지만, liveness probe는 실패하면 kubelet이 컨테이너를 다시 시작해요. startupProbe는 시작에만 쓰는 검사예요. 이 글은 배포 중 요청 실패만 보니까 readiness probe만 씁니다.
./run.sh k8s/2-readiness.yaml 3
## 2-readiness 배포 1
restart 13:50:45
deployment.apps/app restarted
deployment "app" successfully rolled out
13:50:51 ok=48 reset=2
13:50:52 ok=36 reset=2 timeout=12
13:50:53 ok=48 timeout=2
13:50:58 ok=38 refused=4 reset=5 timeout=3
13:50:59 ok=44 timeout=6
13:51:04 ok=49 reset=1
13:51:05 ok=39 refused=2 timeout=9
합계 ok=1344 refused=6 reset=10 timeout=32
같은 회차의 앱 로그(out/2-readiness-1.old.log, out/2-readiness-1.new.log)를 시간순으로 합친 발췌예요.
13:50:46.766 app-6b89df768-7w7cd 시작 mode=exit startup_delay=5.0s work=200ms
13:50:51.782 app-6b89df768-7w7cd 포트 8080 열림
13:50:52.024 app-6d68c97f67-gpsg7 SIGTERM 받음, 직전 6초의 초당 요청 수 [19, 20, 16, 16, 16, 22], 마지막 요청 13:50:52.006
13:50:52.703 app-6b89df768-xtmgc 시작 mode=exit startup_delay=5.0s work=200ms
13:50:57.718 app-6b89df768-xtmgc 포트 8080 열림
13:50:58.535 app-6d68c97f67-tcbzg SIGTERM 받음, 직전 6초의 초당 요청 수 [7, 18, 16, 12, 15, 13], 마지막 요청 13:50:58.489
13:50:59.196 app-6b89df768-lq9gv 시작 mode=exit startup_delay=5.0s work=200ms
13:51:04.209 app-6b89df768-lq9gv 포트 8080 열림
이제 Deployment는 새 파드가 포트를 열고 /ready에 성공한 뒤에야 옛 파드를 내려요. 실패가 몰린 시간은 13:50:51~53, 13:50:58~59, 13:51:04~05이고, 옛 파드 세 개가 SIGTERM을 받은 13:50:52, 13:50:58, 13:51:05와 겹쳐요. 세 번의 실패 수는 48건, 57건, 54건이었습니다.
첫 번째 옛 파드는 SIGTERM을 받기 0.018초 전까지 요청을 받고 있었어요. 즉 SIGTERM이 왔을 때 Kubernetes가 이미 이 파드로 가는 트래픽을 끊어 둔 게 아니었다는 뜻입니다.
readiness probe는 새 파드가 언제 트래픽을 받아도 되는지를 정할 뿐, 옛 파드가 언제 트래픽을 그만 받는지는 정하지 않기 때문이에요.
실습 3: preStop과 graceful 종료를 더하면
파드를 지우면 무슨 일이 어떤 순서로 일어나는지는 Kubernetes 문서의 Pod Lifecycle에 나와 있어요.
- API 서버가 파드에 종료 시간과 유예 시간을 적어 둬요. 이 시점부터 파드는
Terminating으로 보입니다. - 그 파드가 있는 노드의 kubelet이 이를 보고 종료 처리를 시작해요. 컨테이너에
preStop훅이 있으면 먼저 실행하고, 훅이 끝나면 컨테이너의 1번 프로세스에SIGTERM을 보냅니다. - kubelet이 종료 처리를 시작하는 것과 같은 때에, 컨트롤 플레인은 그 파드를 Service의 EndpointSlice에서 종료 중으로 표시해요. 각 노드의 kube-proxy는 이 변화를 받아 자기 노드의 규칙에서 그 파드를 뺍니다.
- 유예 시간(
terminationGracePeriodSeconds, 기본 30초)이 지나도 프로세스가 남아 있으면SIGKILL로 강제로 끝내요.
여기서 눈여겨볼 건 2와 3이 서로를 기다리지 않는다는 점이에요. 앱이 SIGTERM을 받고 바로 끝나면, 엔드포인트 변경이 모든 노드에 반영되기 전에 프로세스가 사라질 수 있어요. 실습 2의 종료 쪽 실패가 바로 이 구간에서 생겼습니다.
preStop 훅에 sleep을 넣으면 kubelet은 그 시간만큼 기다린 뒤에 SIGTERM을 보내요. 그동안 앱은 아무것도 모른 채 평소처럼 요청을 받고, 엔드포인트 변경은 그 사이에 반영됩니다.
훅의 sleep 동작은 컨테이너 안에 sleep 명령이 없어도 kubelet이 직접 기다려요. 이 기능(PodLifecycleSleepAction)은 1.30에서 베타로 기본 활성화됐고 1.34부터 정식 기능이에요. 1.33 이전 클러스터에서는 exec로 sleep 명령을 실행하는 방법을 써 주세요.
세 번째 설정 k8s/3-graceful.yaml은 2-readiness.yaml에 다음을 더합니다.
lifecycle.preStop.sleep.seconds: 5: 종료가 시작되면 5초 기다린 뒤에SIGTERM을 보내요.SHUTDOWN_MODE=graceful:SIGTERM을 받으면 새 연결을 그만 받고 처리 중인 요청을 끝낸 뒤 나가요.terminationGracePeriodSeconds: 30: 기본값과 같지만, preStop과 종료 처리를 합친 시간이 이 안에 들어가야 한다는 걸 드러내려고 적었어요.
graceful 모드의 앱은 이렇게 끝납니다.
/ready가 503을 돌려주게 바꿔요.server.shutdown()으로 새 연결을 받는 루프를 멈춰요.server.server_close()로 리슨 소켓을 닫고, 처리 중인 요청 스레드가 끝나기를 기다려요.daemon_threads = False로 둬야 이 대기가 일어납니다.
./run.sh k8s/3-graceful.yaml 3
## 3-graceful 배포 1
restart 13:58:49
deployment.apps/app restarted
deployment "app" successfully rolled out
합계 ok=1673
세 번 모두 실패가 0건이었어요(1,673건, 1,675건, 1,664건 모두 성공). 1회차 옛 파드의 로그(out/3-graceful-1.old.log)를 볼게요(발췌).
13:59:00.818 app-74dbf6c8c4-zmz4b SIGTERM 받음, 직전 6초의 초당 요청 수 [17, 15, 0, 0, 0, 0], 마지막 요청 13:58:56.764
13:59:01.204 app-74dbf6c8c4-zmz4b 처리 중 요청 마무리, SIGTERM 뒤 받은 요청 0건, 마지막 요청 13:58:56.764, 종료
SIGTERM은 preStop 5초가 끝난 뒤에 왔으니까, 직전 6초의 첫 칸은 preStop이 시작되기 직전 1초이고 두 번째 칸부터가 preStop 구간이에요. 이 파드는 preStop이 시작된 뒤에도 1초 동안 요청 15건을 더 받았고, 그 뒤로는 한 건도 받지 않았어요.
세 번의 배포에서 옛 파드 아홉 개가 마지막 요청을 받은 시간은 SIGTERM보다 3.97~5.07초 앞이었어요. 이 클러스터에서는 preStop이 시작되고 약 1초 안에 트래픽이 끊겼고, 5초면 넉넉했습니다.
이 값은 노드 수, API 서버와 kube-proxy의 부하, 클러스터 앞에 로드밸런서가 있는지에 따라 달라져요. 그러니 운영 클러스터에서는 같은 방식으로 직접 재 보고 정해 주세요.
정리
범위: k3s 1.35(rancher/k3s:v1.35.9-k3s1) 서버 1대와 에이전트 1대, macOS Docker Desktop, 파드 3개, 초당 50건, 설정마다 배포 3번.
- 아무 설정 없이 배포하자 한 번에 233~249건이 실패했고, 13:43:34에는 준비된 파드가 하나도 없어 50건이 모두 연결 거부였습니다. readiness probe를 걸어 새 파드가 포트를 연 뒤에만 옛 파드를 내리게 하세요.
- readiness probe만 걸자 시작 쪽 실패는 사라졌지만 48~57건이 옛 파드의
SIGTERM근처에 남았고, 옛 파드는SIGTERM0.018초 전까지 요청을 받았습니다. readiness probe만으로 무중단 배포가 된다고 보지 마세요. - preStop
sleep5초와 graceful 종료를 더하자 세 번 모두 실패가 0건이었고, 옛 파드 아홉 개의 마지막 요청은SIGTERM보다 3.97~5.07초 앞이었습니다. preStop 길이는 자기 클러스터에서 마지막 요청과SIGTERM의 간격을 재서 정하세요. - preStop과 앱의 종료 처리를 합친 시간은
terminationGracePeriodSeconds(기본 30초) 안에 들어가야 합니다. preStop을 늘릴 때는 이 값도 함께 확인하세요.