본문 바로가기

Infra/Kubernetes

[Kubernetes] 쿠버네티스 배포 이후: 롤링 업데이트, 검증, 롤백

반응형

1편에서 쿠버네티스의 구조(Pod·Deployment·Service·etcd)를, 2편에서 이미지 태그 전략과 배포 명령을 정리했다. 이번 편은 배포 명령을 실행한 다음 클러스터 안에서 벌어지는 일들 — Pod가 교체되는 과정, 배포 성공의 판정, 롤백, 그리고 Pod가 안 뜰 때 보는 순서 — 를 정리해본다. 마지막에는 표준에서 벗어난 현실의 배포 환경 세 가지를 다룬다.

 

예시는 계속 가상의 앱 myapp(API 서버 myapp-web + background worker myapp-worker)이다.

1. 롤링 업데이트

2편의 배포 명령어를 실행하여 Pod 정의가 바뀌면 Deployment는 롤링 업데이트를 시작한다. 1편의 ReplicaSet이 여기서 일한다.

 

Deployment: myapp-web
├─ ReplicaSet (구): myapp-web-bbcbb9688   3개 → 2개 → 1개 → 0개
└─ ReplicaSet (신): myapp-web-7f9d4c2b1   0개 → 1개 → 2개 → 3개
                                          새 Pod가 "정상"이 될 때마다 옛 Pod를 하나씩 내린다

 

교체 속도는 아래 두 값이 정한다.

 

strategy:
  rollingUpdate:
    maxSurge: 25%          # 선언한 개수보다 얼마나 더 띄워도 되나 (새 Pod를 먼저 띄울 여유)
    maxUnavailable: 25%    # 선언한 개수에서 얼마나 모자라도 되나 (옛 Pod를 먼저 내릴 여유)

 

maxSurge가 있어서 새 Pod를 먼저 띄우고 maxUnavailable이 0이면 "항상 정원 유지"가 보장된다. 그래서 새 Pod가 못 뜨면 옛 Pod가 살아남는다 — 롤링 업데이트에 내장된 안전장치다.

readiness probe

그렇다면 위 그림의 "새 Pod가 정상이 될 때마다"에서 '정상'은 누가 판정할까? 아무 설정이 없으면 기준은 "컨테이너 프로세스가 떠 있음"뿐이다. 앱이 아직 초기화 중이거나(DB 연결 전, cache warm-up 전) 떠 있지만 500만 뱉는 상태여도 쿠버네티스 눈에는 정상이다 — 그 Pod로 트래픽이 들어가고 옛 Pod는 내려간다.

 

readiness probe: "이 Pod가 트래픽을 받을 준비가 됐는가"를 판정하는 검사. 주기적으로 실행되어 통과한 Pod만 Service의 멤버 목록(1편 4절)에 들어간다.

 

설정은 아래와 같이 한다.

 

containers:
  - name: myapp-web
    readinessProbe:
      httpGet: { path: /health, port: 8000 }
      initialDelaySeconds: 5      # 기동 후 첫 검사까지 대기
      periodSeconds: 10           # 검사 주기

 

이게 있어야 롤링 업데이트의 "정상 확인 후 교체"가 실제 의미를 갖는다. 없으면 무중단 배포는 "프로세스가 뜨기만 하면 무중단"이라는 약한 보장으로 격하된다. 짝꿍인 liveness probe는 "살아 있는가"(실패하면 컨테이너 재시작)를 판정하는 별개 검사인데 readiness가 트래픽 차단이라면 liveness는 재기동 트리거다.

함께 배포할 것들의 순서

API 서버와 worker가 같은 코드를 공유하면 함께 배포한다 — 한쪽만 새 버전이면 API가 큐에 넣은 작업 형식과 worker가 기대하는 형식이 어긋날 수 있다. 순서를 나눠야 한다면 받는 쪽 먼저: worker 먼저, API 나중(새 API가 옛 worker에 작업을 던지는 상황을 피한다). 서비스 간 호출 관계도 같은 논리로 호출받는 쪽을 먼저 올린다.

2. 배포 검증

배포의 마지막 단계는 명령이 아니라 확인이다. 배포 전과 배포 후 두 번에 걸쳐 확인한다.

 

배포 전: 이미지 자체를 검증한다. 클러스터에 올리기 전에, 만든 이미지가 뜰 수 있는 이미지인지 아래처럼 로컬에서 확인할 수 있다.

 

docker run --rm --entrypoint sh registry.example.com/myapp:$TAG -c '
  ls /app/entrypoint.sh /app/gunicorn.py &&
  sh -n /app/entrypoint.sh && echo "entrypoint ok"'

 

기동 스크립트나 필수 파일이 빌드에서 빠지면(예: .gitignore가 배포에 필요한 파일까지 걸러낸 경우) 그 Pod는 CrashLoopBackOff다. 클러스터에서 겪으면 롤백까지 해야 하는 문제를, 로컬에서는 명령 한 줄로 미리 잡는다.

 

배포 후: 상태를 대조한다. 여기서 중요한 원칙 — rollout status의 성공 메시지는 "요청한 rollout이 끝났다"이지 "내가 의도한 배포가 됐다"가 아니다. 실제 사례: set image에서 =:로 잘못 치면 명령이 조용히 무시되는데 그 뒤의 rollout status는 (변경이 없으니 이미 완료 상태인) 옛 rollout을 "successfully rolled out"으로 보고한다. 성공 메시지를 봤는데 옛 코드가 도는 상황이다.

 

그래서 판정은 단순히 성공 메시지로 하는 것이 아니라 아래처럼 상태 대조로 한다.

 

kubectl get deploy -o wide | grep myapp     # IMAGES 열이 새 태그인가 ← 1차 판정
kubectl get pods | grep myapp               # AGE 가 분 단위인가 (하루 전이면 교체 안 된 것)
kubectl logs deploy/myapp-web --tail=50     # 앱이 정상 기동 로그를 찍었는가

 

불변 태그(2편 2절)를 사용하면 IMAGES 열의 태그가 곧 "지금 무엇이 도는가"라서 대조가 간단하게 끝난다.

3. 롤백

배포가 잘못됐을 때 되돌리는 방법은 아래 두 가지다.

 

# 방법 1: 직전 revision으로
kubectl rollout undo deploy/myapp-web

# 방법 2: 특정 태그를 지목 (불변 태그 운영의 방식)
kubectl set image deploy/myapp-web myapp-web=registry.example.com/myapp:20260915-002
kubectl rollout status deploy/myapp-web
kubectl get deploy -o wide | grep myapp     # 2절과 같은 대조

 

rollout undo는 Deployment가 보관한 이전 ReplicaSet으로 되돌리는 명령인데 가변 태그 운영에서는 무력할 수 있다 — 이전 revision도 똑같이 latest를 가리키고 있으면 되돌려도 같은 이미지를 받는다. 불변 태그 운영이라면 revision마다 태그가 다르므로 undo도 잘 동작하고 그냥 직전 태그를 set image로 지목해도 된다 — 어느 쪽이든 "되돌아갈 이름이 registry에 남아 있다"는 게 전제이고 그걸 보장하는 게 2편의 태그 전략이다.

 

롤백 범위 주의: 같은 코드를 공유하는 Deployment들(web·worker)은 전부 같은 태그로 되돌린다. 한쪽만 옛 코드면 1절의 시그니처 어긋남이 롤백 중에 터진다.

 

DB 마이그레이션은 되돌리지 않는다. 새 버전이 추가한 테이블·컬럼은 남겨 둔다 — 옛 코드는 새 컬럼을 모를 뿐 쓰지 않으므로 대개 무해하고 역마이그레이션은 데이터 손실 위험이 정방향보다 크다. "코드는 뒤로, 스키마는 앞으로"가 롤백의 기본 자세다. (그래서 마이그레이션을 작성할 때부터 옛 코드와 공존 가능하게 — 컬럼 삭제·rename은 여러 배포에 나눠서 — 만드는 게 좋다.)

4. 이미지 밖에 있는 것들

이미지를 갈아도 안 바뀌는 두 가지가 배포 절차에 따라붙는다.

  • DB 스키마: 코드가 새 이미지에 있어도 DB는 별개다. kubectl exec로 파드 안에서 migration 명령을 실행하거나 배포 파이프라인에 Job/initContainer로 넣어 자동화한다.
  • 환경변수·시크릿: ConfigMap/Secret(1편)에 있으므로 이미지와 무관하다. 값 수정 → apply → Pod 재생성(2편 3절의 ③)까지 해야 반영된다. kubectl set env로 직접 주입하는 건 ConfigMap보다 우선순위가 높아서 남겨 두면 나중에 ConfigMap을 고쳐도 반영이 안 되는 함정이 된다 — 급할 때 썼더라도 파일에 옮기고 지워 둔다.

5. Pod가 안 뜰 때

Pod가 안 뜰 때는 아래 명령어들로 하나씩 상태를 확인해가면서 원인을 좁혀 간다.

 

kubectl get pods                              # STATUS 확인
kubectl get deploy -o wide                    # 어떤 이미지가 돌고 있는지
kubectl describe pod <이름> | tail -30        # Events 절 — 스케줄링·이미지 문제
kubectl logs deploy/myapp-web --tail=100      # 표준 출력 로그
kubectl logs <파드이름> --previous            # 죽기 직전 로그
kubectl exec -it <파드이름> -- /bin/bash      # 컨테이너 안으로 진입

 

STATUS별 뜻과 흔한 원인은 아래 표와 같다.

 

STATUS 흔한 원인
Running 정상
ImagePullBackOff 이미지를 못 받음 push 누락, 태그 오타, registry 인증 만료
CrashLoopBackOff 뜨자마자 죽기를 반복 앱 기동 오류, 기동 스크립트·필수 파일 부재(2절의 사전 검증으로 예방)
Pending 노드 배정이 안 됨 리소스 부족, PVC 바인딩 실패
OOMKilled 메모리 초과로 강제 종료 memory limit 초과

 

진단은 get podsdescribe pod의 Events → logs --previous 순서로 진행하고 기동 오류면 롤백한다.

 

--previous가 필요한 이유: CrashLoopBackOff인 Pod에 그냥 logs를 치면 방금 재시작된 컨테이너의 (아직 비어 있는) 로그가 나온다. 죽은 원인은 직전 컨테이너의 로그에 있다.

6. 현실의 변주들

여기까지가 표준이다. 실무에서는 제약에 따라 표준이 변형되는데 원리를 알면 변주도 읽힌다. 실제로 겪은 세 가지를 정리해본다.

 

변주 1: 폐쇄망 — registry에 직접 push 할 수 없을 때. 보안망 때문에 빌드하는 곳과 registry 사이가 막혀 있으면 아래처럼 이미지를 파일로 만들어 사람이 옮긴다.

 

docker save registry.example.com/myapp:$TAG | gzip > myapp-$TAG.tar.gz
sha256sum myapp-$TAG.tar.gz        # 받는 쪽과 대조 — 전송 중 손상이 load 실패의 흔한 원인
# (파일 전달) 받는 쪽:
docker load -i myapp-$TAG.tar.gz   # gzip 자동 인식. save 가 태그를 파일에 담아 두므로 load 후 바로 push 가능
docker push registry.example.com/myapp:$TAG

 

표준 경로에서 docker push 한 줄이 "save → 전달 → 검증 → load → push"로 늘어난 것뿐, 나머지는 동일하다.

 

변주 2: docker가 없는 배포 서버. 운영망의 배포 서버에 docker를 아예 안 깔아 둔 환경이 있다. 그래도 배포가 되는 이유는 1편의 그 원리다 — kubectl은 pull 하지 않는다. set image는 etcd의 선언을 고칠 뿐이고, 이미지를 받는 건 노드에 상주하는 에이전트(kubelet)와 컨테이너 runtime이다 — 요즘 노드에는 docker조차 없다(containerd가 그 역할을 한다). 그래서 이미지 반입(docker 있는 서버에서 push)과 배포 지시(kubectl만 있는 서버)를 물리적으로 분리할 수 있고 이는 운영망에서 이미지를 만들거나 변조할 수단을 없애는 보안 설계가 된다. 이런 멀티 서버·멀티 클러스터 환경에서 작업 첫 줄은 항상 아래 명령어다.

 

kubectl config current-context     # 지금 어느 클러스터에 명령이 나가는지

 

변주 3: 소스가 이미지 밖에 있는 레거시. 오래된 앱 중에는 소스 코드를 이미지가 아니라 볼륨(PVC)에 두고 배포를 "변경 파일 zip을 볼륨에 덮어쓰기"로 하는 경우가 있다. 볼륨은 Pod가 재생성돼도 남으므로(1편) 이 방식이 성립한다. 반대로 소스가 이미지에 내장된 표준 구성에서는 컨테이너 안에서 파일을 고쳐 봐야 Pod 재생성과 함께 증발한다 — 소스가 어디 있느냐가 배포 단위(파일 vs 이미지)를 결정한다. zip 방식은 이미지 빌드 없이 빠르지만 "지금 서버에 무엇이 올라가 있는가"를 git이 보장하지 못하게 되는 대가를 치른다. 표준이 이미지 내장인 이유다.

7. 정리

docker-compose 사용자를 위한 대응표로 정리한다.

 

docker-compose 쿠버네티스
services.web 항목 Deployment
docker compose up -d kubectl apply -f
docker compose restart web kubectl rollout restart deploy/web
docker compose logs web kubectl logs deploy/web
docker compose exec web bash kubectl exec -it <파드> -- bash
ports: Service (+ Ingress)
volumes: PVC + volumeMounts
environment: / env_file: ConfigMap / Secret
compose 파일 = 진실 etcd 오브젝트 = 진실 (파일은 apply 시점의 입력)

 

이전 1편, 2편과 함께 세 글을 요약하면 아래와 같다.

  1. 배포 단위는 이미지, 배포 기록은 태그다. 불변 태그로 배포하면 "무엇이 도는가"와 "어디로 돌아가는가"가 태그 목록에 남는다.
  2. 무중단은 readiness probe가 만든다. 롤링 업데이트의 "정상 확인 후 교체"는 판정 기준을 줬을 때만 유효하다.
  3. 배포의 끝은 명령이 아니라 대조다. 성공 메시지 대신 이미지 태그와 Pod AGE를 본다.
반응형