coredot.today
쿠버네티스 첫걸음 4편 — 디플로이먼트와 서비스로 내 앱 배포하기: 늘리고, 바꾸고, 되돌리기
블로그로 돌아가기
KubernetesDeploymentServiceReplicaSet롤링 업데이트롤백ConfigMapSecretreadinessProbe라벨 셀렉터튜토리얼

쿠버네티스 첫걸음 4편 — 디플로이먼트와 서비스로 내 앱 배포하기: 늘리고, 바꾸고, 되돌리기

도커 첫걸음 시리즈에서 만든 hello-api 이미지를 쿠버네티스에 올립니다. 로컬 클러스터 도구별로 내가 빌드한 이미지를 클러스터에 넣는 법, 디플로이먼트 YAML을 한 줄씩 읽는 법, 파드를 죽여도 되살아나는 것 확인하기, replicas로 개수 늘리기, 새 버전을 무중단으로 교체하는 롤링 업데이트와 한 줄 롤백을 실습합니다. 이어서 서비스가 라벨로 파드를 찾는 원리를 셀렉터 실험실 위젯으로 확인하고, ClusterIP·NodePort·LoadBalancer의 차이, ConfigMap·Secret으로 설정 분리, readinessProbe로 준비된 파드에만 트래픽 보내기까지 다룹니다.

코어닷투데이2026-09-2727분

들어가며 — 이제 내 앱을 올릴 차례

앱 배포하기크게 보기

3편에서 파드를 직접 만들어 봤고, 한 가지 약점을 확인했습니다. 직접 만든 파드는 죽으면 끝이라는 것. 실제 앱은 파드를 직접 만들지 않고 디플로이먼트에게 "이 이미지로 파드를 N개 유지해"라고 맡깁니다. 그리고 파드들 앞에 서비스를 세워 바뀌지 않는 주소를 줍니다. 이 두 개가 쿠버네티스에서 앱을 배포하는 기본 조합이고, 회사 저장소에서 가장 많이 보게 될 YAML입니다.

재료는 도커 첫걸음 4편에서 만든 파이썬 웹 API hello-api 이미지입니다. 아직 없다면 그 글의 1~3장을 따라 docker build -t hello-api:0.2 .까지 해 두세요.

📚
쿠버네티스 첫걸음 시리즈 (6편)
1편 개념 잡기 · 2편 로컬 클러스터 만들기 · 3편 kubectl로 파드와 노드 다루기 · 4편 디플로이먼트와 서비스(이 글) · 5편 관리 도구 비교 · 6편 팀 협업

1. 내가 빌드한 이미지를 클러스터가 보게 하기

도커 5편에서 말했듯 쿠버네티스는 빌드하지 않고, 이미지를 레지스트리에서 받아 옵니다. 운영에서는 GHCR 같은 레지스트리에 올리는 것이 정석이지만, 로컬 클러스터에서는 매번 올리기 번거로우니 도구마다 지름길이 있습니다.

로컬 도구내 이미지를 쓰는 법
Rancher Desktop (엔진 dockerd)그대로 된다 — docker build한 이미지를 쿠버네티스가 같은 엔진에서 본다
OrbStack그대로 된다 — 빌드한 이미지가 바로 파드에서 보인다
Docker Desktop (kubeadm 방식)그대로 된다. kind 방식은 레지스트리에 올리거나 아래 kind 방법
kindkind load docker-image hello-api:0.2 --name dev
minikubeminikube image load hello-api:0.2
k3dk3d image import hello-api:0.2 -c dev
⚠️
로컬 이미지에 latest 태그를 쓰지 마세요. 태그가 latest(또는 생략)이면 쿠버네티스는 기본적으로 매번 레지스트리에서 새로 받으려고 시도합니다(imagePullPolicy: Always). 레지스트리에 없는 로컬 이미지라 ImagePullBackOff가 납니다. 0.2처럼 버전 태그를 붙이면 기본값이 IfNotPresent(있으면 그대로 사용)가 되어 로컬 이미지를 씁니다. 도커 4편의 "latest 금지" 습관이 여기서 또 도움이 됩니다.

2. 첫 디플로이먼트

실습 폴더를 만들고 k8s/deployment.yaml을 작성합니다.

bash
mkdir -p ~/docker-lab/hello-api/k8s && cd ~/docker-lab/hello-api
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-api
  template:
    metadata:
      labels:
        app: hello-api
    spec:
      containers:
        - name: api
          image: hello-api:0.2
          ports:
            - containerPort: 8000
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 256Mi
부분뜻
replicas: 3원하는 상태 — "파드를 항상 3개"
selector.matchLabels"app: hello-api 라벨이 붙은 파드가 내 담당이다"
template새 파드를 찍어 낼 틀. 안의 내용은 3편의 파드 YAML과 똑같다
template.metadata.labels찍어 낸 파드에 붙일 라벨. 반드시 selector와 일치해야 한다 (안 맞으면 apply가 거부된다)

디플로이먼트 YAML은 결국 "몇 개" + "누가 내 담당인지(라벨)" + "파드 틀"입니다. 적용합니다.

bash
kubectl apply -f k8s/deployment.yaml
kubectl get deployments
kubectl get pods -l app=hello-api

-l app=hello-api는 "이 라벨이 붙은 것만"이라는 필터입니다. 파드 이름이 hello-api-6f8c7d9b4-x2k9p처럼 생겼는데, 가운데 부분은 ReplicaSet의 이름입니다. 디플로이먼트는 버전마다 ReplicaSet을 하나씩 만들고, ReplicaSet이 실제로 파드 개수를 맞춥니다. 우리가 직접 만질 일은 없지만, 롤백이 가능한 이유가 이것입니다(5장).

bash
kubectl get replicasets

3. 자가 치유 확인 — 파드를 죽여 보자

1편 시뮬레이터에서 본 것을 진짜 클러스터에서 확인합니다. 터미널을 두 개 엽니다. 한쪽에서 지켜보고:

bash
kubectl get pods -l app=hello-api -w

다른 쪽에서 파드 하나를 지웁니다(이름은 목록에서 하나 골라 복사).

bash
kubectl delete pod <hello-api-로 시작하는 파드 이름>

지운 파드가 Terminating되는 사이 새 이름의 파드가 곧바로 생겨 Running이 됩니다. 3편의 맨 파드와 정반대입니다. 디플로이먼트가 "3개여야 하는데 2개네?"를 알아채고 메운 것입니다.

4. 늘리고 줄이기 — scale

bash
kubectl scale deployment hello-api --replicas=5
kubectl get pods -l app=hello-api

5개가 됩니다. 이 명령은 편하지만, 팀에서 쓸 때는 주의가 필요합니다. YAML 파일에는 여전히 replicas: 3이라 적혀 있어서, 다음에 누군가 kubectl apply를 하면 3개로 돌아갑니다. 원칙은 YAML을 고치고 apply하는 것입니다(6편).

bash
sed -i.bak 's/replicas: 3/replicas: 4/' k8s/deployment.yaml && rm k8s/deployment.yaml.bak
kubectl apply -f k8s/deployment.yaml

(에디터로 숫자를 직접 고쳐도 됩니다.) 트래픽에 따라 자동으로 늘리고 줄이는 HorizontalPodAutoscaler도 있지만, 입문 단계에서는 "숫자를 바꾸면 쿠버네티스가 맞춘다"는 것만 기억하면 충분합니다.

5. 무중단 업데이트와 롤백

새 버전을 만들어 봅시다. main.py의 메시지를 "도커 안에서 인사합니다 v0.3"처럼 바꾸고 빌드합니다(kind·minikube·k3d라면 1장의 load 명령도).

bash
docker build -t hello-api:0.3 .

YAML의 이미지 태그를 0.3으로 바꾸고 apply 한 뒤, 교체 과정을 지켜봅니다.

bash
sed -i.bak 's/hello-api:0.2/hello-api:0.3/' k8s/deployment.yaml && rm k8s/deployment.yaml.bak
kubectl apply -f k8s/deployment.yaml
kubectl rollout status deployment hello-api
새 ReplicaSet
디플로이먼트가 0.3용 ReplicaSet을 새로 만들고 새 파드를 하나 띄운다.
하나씩 교체
새 파드가 준비되면 옛 파드를 하나 내린다. 이걸 반복한다. 기본값은 "최대 25% 초과 생성, 최대 25% 동시 중단".
완료
전부 0.3이 되면 옛 ReplicaSet은 파드 0개로 남는다 — 되돌릴 때 쓰려고.

교체하는 동안 항상 일정 수 이상의 파드가 떠 있어서 서비스가 멈추지 않습니다. 이것이 롤링 업데이트입니다.

새 버전에 문제가 있다면? 한 줄로 되돌립니다.

bash
kubectl rollout history deployment hello-api
kubectl rollout undo deployment hello-api
⚠️
undo 후에는 YAML도 되돌리세요. rollout undo는 클러스터만 되돌립니다. 파일에는 여전히 0.3이 적혀 있어 다음 apply 때 다시 0.3이 올라갑니다. 급할 때는 undo로 막고, 곧바로 Git에서 YAML을 되돌리는 커밋을 하는 것이 팀의 정석입니다.

일부러 망가진 버전을 올려 보기 — 없는 태그로 바꾸면 어떻게 될까요?

bash
kubectl set image deployment/hello-api api=hello-api:9.9
kubectl get pods -l app=hello-api

새 파드 하나가 ErrImagePull/ImagePullBackOff에 걸리지만 기존 파드들은 그대로 Running입니다. 새 파드가 준비되지 않으면 옛 파드를 내리지 않기 때문입니다. 확인했으면 되돌립니다.

bash
kubectl rollout undo deployment hello-api

6. 서비스 — 파드 묶음의 고정 주소

파드는 죽고 새로 생길 때마다 IP가 바뀝니다. 방금 롤링 업데이트로 파드가 전부 바뀌었죠. 다른 앱이 파드 IP를 보고 접속한다면 매번 끊길 것입니다. 서비스는 파드 묶음 앞에 서서 바뀌지 않는 이름·IP를 주고, 들어온 요청을 살아 있는 파드들에 나눠 줍니다.

사용자 요청이 서비스를 거쳐 디플로이먼트의 파드 셋으로 나뉜다크게 보기

k8s/service.yaml을 만듭니다.

yaml
apiVersion: v1
kind: Service
metadata:
  name: hello-api
spec:
  selector:
    app: hello-api
  ports:
    - port: 80
      targetPort: 8000
  • selector — 이 라벨이 붙은 파드에게 트래픽을 보낸다. 서비스는 파드 이름이 아니라 라벨로 대상을 찾습니다.
  • port: 80 — 서비스가 받는 포트. targetPort: 8000 — 파드(컨테이너)가 듣는 포트.
bash
kubectl apply -f k8s/service.yaml
kubectl get service hello-api

클러스터 안에서는 이제 http://hello-api(같은 네임스페이스) 또는 http://hello-api.default.svc.cluster.local로 접속됩니다. 도커 5편에서 compose가 db:5432처럼 서비스 이름으로 통신하게 해 준 것과 같은 원리입니다. 확인해 봅시다.

bash
kubectl run tmp --rm -it --image=busybox:1.37 -- wget -qO- http://hello-api

내 컴퓨터에서는 port-forward로 서비스에 연결합니다. 파드 하나가 아니라 서비스로 연결하는 것이 3편과 다릅니다.

bash
kubectl port-forward service/hello-api 8080:80

서비스가 라벨로 파드를 고른다는 것이 정확히 무슨 뜻인지, 아래 실험실에서 셀렉터를 바꾸고 파드의 준비 상태를 꺼 보며 확인해 보세요. 한 글자 오타가 어떤 결과를 내는지도요.

실제 클러스터에서 서비스가 어떤 파드에 연결됐는지는 이렇게 봅니다. 목록이 비어 있으면 셀렉터와 라벨이 안 맞는 것입니다.

bash
kubectl get endpointslices -l kubernetes.io/service-name=hello-api

서비스의 종류 — 누구에게 공개하나

type누가 접속하나로컬 클러스터에서
ClusterIP (기본값)클러스터 안의 다른 파드만내 컴퓨터에서는 port-forward로
NodePort노드의 특정 포트(30000~32767)로 바깥에서테스트용. 운영에서는 잘 안 쓴다
LoadBalancer클라우드가 외부 로드밸런서를 붙여 인터넷에 공개Rancher Desktop·OrbStack(k3s 계열)은 localhost로 동작. kind는 별도 도구, minikube는 minikube tunnel

웹 서비스를 도메인(api.example.com)으로 공개할 때는 보통 서비스 앞에 Ingress(또는 새 표준인 Gateway API)를 둡니다. 여러 서비스를 도메인·경로별로 나눠 주는 관문입니다. 입문 단계에서는 "ClusterIP 서비스 + 개발 중엔 port-forward, 공개는 Ingress" 정도로 기억하면 됩니다.

7. 설정 분리 — ConfigMap과 Secret

도커 5편에서 설정은 environment, 비밀번호는 .env로 분리했습니다. 쿠버네티스에서는 ConfigMap과 Secret입니다.

bash
kubectl create configmap hello-api-config --from-literal=LOG_LEVEL=info --from-literal=GREETING=안녕하세요
kubectl create secret generic hello-api-secret --from-literal=DB_PASSWORD=secret

디플로이먼트의 컨테이너에 연결합니다(k8s/deployment.yaml의 containers 항목 아래, image와 같은 들여쓰기).

yaml
          envFrom:
            - configMapRef:
                name: hello-api-config
          env:
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: hello-api-secret
                  key: DB_PASSWORD
bash
kubectl apply -f k8s/deployment.yaml
kubectl exec deploy/hello-api -- env | grep -E "LOG_LEVEL|GREETING|DB_PASSWORD"
🔒
Secret은 암호화가 아닙니다. 기본 Secret은 값을 base64로 인코딩할 뿐이라 kubectl get secret -o yaml로 누구나 풀어 볼 수 있습니다. 그래서 ① Secret YAML은 Git에 커밋하지 않고 ② 네임스페이스 권한으로 볼 수 있는 사람을 제한하고(6편) ③ 운영에서는 Sealed Secrets, External Secrets(클라우드 비밀 저장소 연동) 같은 도구를 씁니다. 도커의 ".env 커밋 금지" 원칙 그대로입니다.

ConfigMap을 바꿔도 이미 떠 있는 파드의 환경변수는 바뀌지 않습니다. 파드를 새로 띄워야 반영됩니다.

bash
kubectl rollout restart deployment hello-api

8. 준비된 파드에만 트래픽 — readinessProbe

파드가 Running이어도 앱이 아직 시작 중일 수 있습니다(DB 연결, 모델 로딩 등). 그때 트래픽을 보내면 에러가 납니다. readinessProbe는 "이 주소가 응답해야 준비된 것"이라고 알려 줍니다. 위 셀렉터 실험실에서 NotReady 파드가 트래픽을 못 받던 것이 바로 이것입니다.

yaml
          readinessProbe:
            httpGet:
              path: /
              port: 8000
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /
              port: 8000
            initialDelaySeconds: 10
            periodSeconds: 10
프로브실패하면용도
readinessProbe서비스 대상에서 잠시 빠진다 (재시작 안 함)시작 중·일시적 과부하 동안 트래픽 차단. 롤링 업데이트가 "새 파드 준비됨"을 판단하는 기준
livenessProbe컨테이너를 재시작한다앱이 멈춰(데드락) 응답이 없을 때 되살리기. 너무 빡빡하면 멀쩡한 앱을 계속 죽여 CrashLoopBackOff

도커 compose의 depends_on: condition: service_healthy와 비슷한 역할이지만 방식이 다릅니다. 쿠버네티스는 순서를 기다리지 않고, 준비된 것에만 트래픽을 보내고 앱이 스스로 재시도하게 합니다(6편 compose 대응표).

9. 정리

bash
kubectl delete -f k8s/
kubectl delete configmap hello-api-config
kubectl delete secret hello-api-secret

-f k8s/처럼 폴더를 주면 안의 YAML 전부에 적용됩니다. apply도 마찬가지 — kubectl apply -f k8s/ 한 줄로 디플로이먼트와 서비스를 함께 올립니다. 이 폴더를 Git에 커밋하는 것이 6편의 출발점입니다.

마치며

🧠
4편 요약
· 앱은 디플로이먼트로: replicas(몇 개) + selector(라벨) + template(파드 틀)
· 로컬 이미지는 도구별 지름길(Rancher·OrbStack은 그대로, kind는 kind load), 태그는 latest 금지
· 이미지 태그를 바꿔 apply → 롤링 업데이트, 문제 시 rollout undo 후 YAML도 되돌리기
· 서비스는 라벨로 파드를 찾아 고정 이름을 준다 — 클러스터 안에서 http://hello-api
· 설정은 ConfigMap, 비밀은 Secret(암호화 아님, Git 금지), 준비 판단은 readinessProbe

지금까지는 kubectl만 썼습니다. 그런데 파드가 수십 개가 되면 명령을 매번 치기가 버겁습니다. 다음 편에서는 kubectl을 왜 기본으로 익혀야 하는지, 그리고 k9s·Headlamp·Freelens·Lens·kubectx·stern·Helm 같은 도구가 각각 무엇을 더해 주는지 비교합니다.

👉 5편: kubectl·k9s·Headlamp·Freelens — 쿠버네티스 관리 도구 비교


참고 자료