# Kubernetes

Kubernetes 배포 중 요청 실패, readiness probe로 막힐까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Kubernetes에서 rollout restart로 배포할 때 요청이 실패하지 않게 하려면 무엇을 설정해야 할까?
답
아무 설정 없이는 배포 한 번에 233~249건, readiness probe만 걸면 48~57건이 실패했고, preStop sleep 5초와 앱의 graceful 종료를 더하자 세 번 모두 0건이었다.
조건
k3s 1.35(rancher/k3s:v1.35.9-k3s1) 서버 1대와 에이전트 1대, macOS Docker Desktop, 파드 3개, 초당 50건, 설정마다 배포 3회

환경

Kubernetes
k3s 1.35(rancher/k3s:v1.35.9-k3s1), 서버 노드 1대와 에이전트 노드 1대
Docker
macOS Docker Desktop(Docker Engine 27.5.1), 리눅스 VM CPU 8개와 메모리 약 9.7GiB
앱
python:3.12-slim의 ThreadingHTTPServer, 파드 3개, 요청 하나 200밀리초
부하
클러스터 안의 부하 파드에서 초당 50건, 요청마다 새 연결
반복
설정마다 배포 3회, 회차 사이 125초 대기
목차
  1. 실습 준비
  2. 실습 1: 아무 설정 없이 배포하면
  3. 실습 2: readiness probe를 걸면
  4. 실습 3: preStop과 graceful 종료를 더하면
  5. 정리

이번 시간에는 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: Service app에 요청마다 새 연결로 부하를 보내고 실패를 종류별로 세는 스크립트
  • 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는 매니페스트를 적용한 뒤 배포를 정해진 횟수만큼 반복해요. 배포 한 번은 이렇게 진행합니다.

  1. 지금 떠 있는 옛 파드의 로그를 따라가며 out/<매니페스트 이름>-<회차>.old.log에 저장해요. 옛 파드는 배포가 끝나면 사라지니까 미리 붙어 있어야 SIGTERM 로그를 받을 수 있습니다.
  2. load 파드에서 부하를 시작하고 5초 뒤 kubectl rollout restart로 배포를 시작해요.
  3. 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에 나와 있어요.

  1. API 서버가 파드에 종료 시간과 유예 시간을 적어 둬요. 이 시점부터 파드는 Terminating으로 보입니다.
  2. 그 파드가 있는 노드의 kubelet이 이를 보고 종료 처리를 시작해요. 컨테이너에 preStop 훅이 있으면 먼저 실행하고, 훅이 끝나면 컨테이너의 1번 프로세스에 SIGTERM을 보냅니다.
  3. kubelet이 종료 처리를 시작하는 것과 같은 때에, 컨트롤 플레인은 그 파드를 Service의 EndpointSlice에서 종료 중으로 표시해요. 각 노드의 kube-proxy는 이 변화를 받아 자기 노드의 규칙에서 그 파드를 뺍니다.
  4. 유예 시간(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 모드의 앱은 이렇게 끝납니다.

  1. /ready가 503을 돌려주게 바꿔요.
  2. server.shutdown()으로 새 연결을 받는 루프를 멈춰요.
  3. 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 근처에 남았고, 옛 파드는 SIGTERM 0.018초 전까지 요청을 받았습니다. readiness probe만으로 무중단 배포가 된다고 보지 마세요.
  • preStop sleep 5초와 graceful 종료를 더하자 세 번 모두 실패가 0건이었고, 옛 파드 아홉 개의 마지막 요청은 SIGTERM보다 3.97~5.07초 앞이었습니다. preStop 길이는 자기 클러스터에서 마지막 요청과 SIGTERM의 간격을 재서 정하세요.
  • preStop과 앱의 종료 처리를 합친 시간은 terminationGracePeriodSeconds(기본 30초) 안에 들어가야 합니다. preStop을 늘릴 때는 이 값도 함께 확인하세요.
내 링크 만들기