실무에서 쿠버네티스 환경에 배포해야 할 상황이 왔다. 배포 가이드 문서를 읽는데 Pod, Deployment, Service 같은 용어가 쏟아진다. 명령어를 그대로 따라 칠 수는 있지만, 문제가 생겼을 때 대응하려면 구조를 알아야 한다.
그래서 세 편으로 정리한다. 1편(이 글)은 쿠버네티스를 구성하는 개념들, 이후는 실제 배포와 그 후 흐름이다. docker와 docker-compose까지는 써 봤다는 전제로 쓴다.
0. 세 개의 축
쿠버네티스 오브젝트는 종류가 많아 보이지만, 세 축으로 나누면 자리가 잡힌다.
Cluster
│
├─ [인프라] Node 1, Node 2, … ← 컨테이너가 실제로 도는 서버들
│
├─ [워크로드] Deployment ─▶ ReplicaSet ─▶ Pod ─▶ Container
│ │
│ PVC · ConfigMap · Secret (Pod에 꽂아 쓰는 부속)
│
└─ [네트워크] Ingress ─▶ Service ─(label 매칭)─▶ Pod들
- 인프라 축: 어디서 실행되나
- 워크로드 축: 무엇을 실행하나
- 네트워크 축: 어떻게 접근하나
세 개의 축을 하나씩 정리해본다.
1. 인프라 축
docker-compose 환경에서는 서버 한 대에 SSH로 들어가 docker compose up을 치면 끝이었다. 쿠버네티스에서 가장 먼저 버려야 할 감각이 이거다. 서버에 들어가지 않는다.
개발자 ──kubectl──▶ Control Plane (API 서버) ──▶ Node들이 컨테이너 실행
▲
container registry ── pull ──┘
Cluster: 여러 서버를 묶어 하나의 큰 컴퓨터처럼 다루는 단위. "이 컨테이너를 어느 서버에 놓을지"는 개발자가 아니라 클러스터가 정한다.
Node: 클러스터에 소속된 서버 한 대. 컨테이너가 실제로 실행되는 곳이다.
Control Plane: kubectl 요청을 받아 클러스터를 조율하는 관리 영역. EKS·AKS·GKE 같은 managed 서비스에서는 클라우드가 대신 운영해서 직접 볼 일이 거의 없다.
kubectl: 클러스터에 명령을 보내는 CLI. SSH가 아니라 API 서버에 "이렇게 해 줘"라고 요청하는 클라이언트다. 실무에서는
k로 alias를 걸어 쓴다.
여기서 헷갈리기 쉬운 것 하나. 이미지를 registry에서 pull 하는 주체는 노드다. kubectl을 실행하는 내 머신이 아니다. 내 머신에서 docker images를 쳐 봐야 그건 로컬 cache일 뿐, 클러스터에서 도는 이미지와 무관하다. "지금 무엇이 돌고 있는가"는 kubectl get deploy -o wide로 클러스터에 물어봐야 한다.
container registry: 이미지를 push/pull 하는 원격 이미지 창고. Docker Hub가 공개 registry이고, 회사는 보통 사설 registry(ECR, ACR, Harbor 등)를 쓴다. 이 글에서는 registry.example.com으로 쓴다.
2. 워크로드 축
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
myapp-web-bbcbb9688-gq6xp 1/1 Running 0 2d
Pod: 컨테이너를 실행하는 최소 단위. 대부분 Pod 하나 = 컨테이너 하나로 생각해도 된다. 자기만의 IP를 받는다.
이름 뒤의 랜덤 문자열(예시에서의 -gq6xp)이 Pod의 본질을 말해 준다. Pod는 일회용이다. 죽으면 같은 Pod가 되살아나는 게 아니라 새 이름, 새 IP의 새 Pod가 만들어진다. compose에서 컨테이너를 재시작하면 같은 컨테이너가 다시 뜨는 것과 감각이 다르다.
따라오는 원칙 두 가지:
- Pod 이름을 적어 두고 쓰지 마라. 다음 배포면 이미 다른 이름이다.
- 컨테이너 안에서 수정한 파일은 Pod와 함께 사라진다. 컨테이너 파일시스템은 임시다.
그럼 죽은 Pod는 누가 다시 만드나. 여기서 쿠버네티스의 핵심 사고방식이 나온다.
Deployment: "이 이미지로 Pod를 N개 유지하라"는 선언. Pod가 죽으면 Deployment가 새로 만든다. Pod 이름은 매번 바뀌지만 Deployment 이름(myapp-web)은 고정이다.
명령형("컨테이너를 띄워라")이 아니라 선언형("이 상태를 유지하라")이다. 프로세스가 죽으면 내가 다시 띄우는 게 아니라, 선언과 실제의 차이를 클러스터가 감지해 스스로 메꾼다. 그래서 로그를 볼 때도 Pod가 아니라 Deployment 이름으로 본다.
kubectl logs deploy/myapp-web --tail=100 # Pod 이름이 바뀌어도 항상 동작
Pod 이름의 구조도 여기서 나온다.
Deployment: myapp-web ← 내가 만드는 것. 이름 고정
└─ ReplicaSet: myapp-web-bbcbb9688 ← 버전마다 자동 생성
└─ Pod: myapp-web-bbcbb9688-gq6xp ← 랜덤 접미사, 일회용
ReplicaSet: "Pod N개 유지"를 실제로 수행하는 중간 오브젝트. Deployment가 버전(이미지·설정 조합)마다 하나씩 자동으로 만든다. 직접 다룰 일은 없지만, 옛 버전의 ReplicaSet이 남아 있는 덕분에 롤백이 가능하다.
정리하면: Deployment가 ReplicaSet을 만들고, ReplicaSet이 Pod를 만들고, Pod가 Node 위에서 컨테이너를 돌린다. 개발자가 만지는 건 양 끝이다. (선언은 Deployment에, 디버깅은 Pod에)
3. 매니페스트와 apply
Deployment는 compose처럼 YAML로 작성한다. 매니페스트(manifest)라고 부른다.
# myapp-web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-web
spec:
replicas: 2 # Pod 몇 개 유지할지
selector:
matchLabels: { app: myapp-web }
template: # 이 아래가 Pod의 정의
metadata:
labels: { app: myapp-web } # Pod에 붙는 꼬리표 — 네트워크 축에서 중요해진다
spec:
containers:
- name: myapp-web
image: registry.example.com/myapp:latest
적용은 kubectl apply -f myapp-web.yaml 명령어를 사용해서 한다. 여기까지는 compose와 비슷한데, 결정적 차이가 있다.
compose는 파일이 유일한 정의다. 하지만 쿠버네티스는 apply 하는 순간 클러스터 안에 오브젝트로 저장되고, 그쪽이 진실이 된다.
etcd: control plane이 쓰는 key-value 저장 시스템. apply로 만든 모든 오브젝트의 현재 정의가 여기 영속적으로 저장된다. 메모리가 아니라 디스크라서 클러스터가 재부팅돼도 남는다.
그래서 파일 없이도 현재 정의를 통째로 꺼내볼 수 있고
kubectl get deploy myapp-web -o yaml
파일을 안 거치고 클러스터 안의 오브젝트를 직접 고치는 명령들도 있다.
kubectl set image deploy/myapp-web myapp-web=registry.example.com/myapp:v2
kubectl scale deploy/myapp-web --replicas=4
그렇다면 apply 후에 set image로 고친 내용은 Pod가 죽으면 사라질까? 그건 아니다. 수정한 대상이 무엇이냐에 따라 운명이 갈린다.
| 수정한 곳 | 저장 위치 | Pod가 죽으면 | 사라지는 조건 |
|---|---|---|---|
오브젝트 (set image, edit) |
etcd (영속) | 유지 — 새 Pod가 수정된 정의로 뜬다 | 누군가 옛 yaml로 다시 apply 할 때 |
컨테이너 안 파일 (exec로 수정) |
컨테이너 파일시스템 (임시) | 즉시 사라짐 | Pod 재생성 |
| 디스크의 yaml 파일 | 내 디스크 | 무관 | — (클러스터에 반영 안 된 채 존재) |
오브젝트 수정은 오히려 인과가 반대다. Pod가 죽으면 etcd의 (수정된) Deployment 정의를 읽어서 새 Pod를 만드니까, 수정이 사라지기는커녕 계속 적용된다.
진짜 위험은 세 번째 줄이다. set image로 클러스터를 고쳐도 디스크의 yaml은 옛날 그대로다. 어긋난 채 지내다가 누군가 옛 파일로 apply 하는 순간 수동 수정이 덮여 사라진다. 그래서 수동 kubectl 수정을 했으면 yaml에도 반영해 두는 게 원칙이고, 이 동기화를 사람 손에 안 맡기려는 해법이 GitOps다.
GitOps / ArgoCD: git repo의 매니페스트를 진실로 삼고, 클러스터 상태가 어긋나면 자동으로 되돌리는 운영 방식과 그 대표 도구. ArgoCD가 붙은 클러스터에서 수동 set image를 하면 잠시 후 소리 없이 원상복구된다. 배포 전에 클러스터에 GitOps가 붙어 있는지 확인해야 하는 이유다.
compose와 또 하나의 차이는 compose 한 파일이 쿠버네티스에서는 여러 오브젝트로 쪼개진다는 것이다. 컨테이너 실행은 Deployment, 접근 경로는 Service와 Ingress, 디스크는 PVC, 환경변수는 ConfigMap/Secret. ---로 한 파일에 몰아넣거나 디렉토리째 apply 할 수 있다.
4. 네트워크 축
Pod는 IP가 계속 바뀐다. 그래서 고정 진입점이 계층으로 놓인다.
브라우저 (api.example.com)
│
▼
Ingress ← 도메인·경로 기반 라우팅 규칙
│
▼
Service (myapp-web) ← 고정 주소. label이 일치하는 Pod들로 부하 분산
│
├──▶ Pod (…-gq6xp)
└──▶ Pod (…-x7z2q)
Service: label selector로 Pod들을 묶어 고정 주소 하나를 주는 오브젝트. 클러스터 안에서는 Service 이름이 곧 DNS 이름이라 http://myapp-web으로 호출한다.
Ingress: 클러스터 밖 트래픽을 도메인/경로 규칙으로 Service에 나눠 주는 관문. HTTPS 인증서 처리도 보통 여기서 한다.
Service는 Pod를 띄운다고 자동으로 생기지 않는다. Deployment와 완전히 별개의 오브젝트라 직접 만들어야 한다.
kind: Service
metadata:
name: myapp-web
spec:
selector:
app: myapp-web # ← Deployment template의 label과 문자열이 일치해야 연결된다
ports:
- port: 80
targetPort: 8000 # 컨테이너가 listen 하는 포트
Deployment와 Service 사이에 직접 참조는 없다. app: myapp-web이라는 문자열이 일치하는 것이 연결의 전부다. 이들의 관계는 아래 표와 같다.
| 상황 | 결과 |
|---|---|
| Service를 안 만듦 | Pod는 잘 돈다. 단, 고정 주소가 없다 |
| Service는 있는데 label 불일치 (오타 등) | Service는 존재하지만 연결 대상이 빈다 → 배포는 성공했는데 503 |
| label과 selector 일치 | 트래픽 분산 정상 동작 |
다만 생성은 수동, 그 이후의 멤버 관리는 자동이다. Service를 한 번 만들어 두면 selector와 일치하는 Pod 목록을 클러스터가 실시간 갱신한다 — Pod가 죽으면 빼고, 새로 뜨면 넣고, 아직 기동 중이면 제외한다. 배포 중에 Pod가 전부 교체돼도 트래픽이 안 끊기는 이유다.
kubectl get svc # Service 목록
kubectl describe svc myapp-web # Endpoints가 비어 있으면 label 불일치 신호
worker처럼 호출받을 일 없는 앱(큐에서 작업을 가져가기만 하는)은 Service를 안 만드는 게 정상이다. Service는 "호출받는 쪽"에만 필요하다.
Service가 없으면 Pod에 어떻게 접근하나
클러스터 안에서는 Pod IP(kubectl get pods -o wide)로 직접 통신할 수 있지만, 재생성되면 IP가 바뀌므로 운영 수단이 아니다. 클러스터 밖에서는 Pod IP가 내부 전용 대역이라 아예 닿지 않는다. 외부 노출은 Service type이 정한다.
| Service type | 노출 범위 |
|---|---|
ClusterIP (기본값) |
클러스터 안에서만 |
NodePort |
모든 Node의 특정 포트로 외부 접근 |
LoadBalancer |
클라우드 로드밸런서로 외부 IP 부여 |
| + Ingress | 그 위에 도메인·경로 라우팅을 얹음 |
port-forward를 통해 연결할 수도 있다. 디버깅할 때 정말 자주 쓴다:
kubectl port-forward deploy/myapp-web 8000:8000
curl http://localhost:8000/health # 내 PC에서 바로 접근
port-forward: 내 PC의 포트를 kubectl의 인증된 연결을 타고 Pod 포트로 터널링하는 기능. 네트워크 경로가 안 닿아도 동작한다. 터미널을 끄면 끊기는 임시 통로라 서비스 트래픽용은 아니다.
5. 부속 오브젝트
Pod
├─ volumeMounts ──▶ PVC ← Pod가 죽어도 남는 디스크
├─ env ──────────▶ ConfigMap ← 일반 설정값
└─ env ──────────▶ Secret ← API 키 등 민감정보
PVC(PersistentVolumeClaim): "이만한 디스크가 필요하다"는 요청서. 컨테이너 파일시스템은 임시지만, PVC로 마운트한 경로(
/uploads같은)만은 Pod 재생성 후에도 데이터가 남는다.ConfigMap / Secret: 이미지에 넣으면 안 되는 설정값·비밀값을 담는 오브젝트. 값을 바꿔도 이미 뜬 Pod에는 반영되지 않는다. 환경변수는 프로세스 시작 시점에 읽히므로 Pod 재생성이 필요하다.
Namespace: 위의 모든 오브젝트를 담는 논리적 폴더. 한 클러스터에서 dev/prod를 나눌 때 쓴다. kubectl 명령에 -n이 없으면 default 또는 kubeconfig에 지정된 namespace를 본다. Node는 물리 자원이라 namespace에 속하지 않는 예외다.
6. 정리
| 오브젝트 | 비유 |
|---|---|
| Cluster | 데이터센터 |
| Node | 서버 한 대 |
| Deployment | systemd 서비스 정의 |
| Pod | 프로세스 (일회용) |
| Service | 사내 DNS + 로드밸런서 |
| Ingress | 최전방 nginx 설정 |
| PVC | NFS 마운트 |
| ConfigMap / Secret | .env 파일 |
| etcd 오브젝트 vs yaml 파일 | DB의 현재 상태 vs 마지막으로 실행한 SQL 스크립트 |
- Pod는 일회용이다. 고정된 것은 Deployment 이름과 볼륨뿐이고, 컨테이너 안 수정은 재생성과 함께 사라진다.
- 진실은 파일이 아니라 클러스터 안(etcd)에 있다. yaml은 apply 시점의 입력일 뿐이며, 오브젝트 수정은 영속이고 파일과의 어긋남이 사고의 씨앗이다.
- 연결은 label 문자열 매칭이다. Service는 자동으로 생기지 않고, 만든 뒤의 멤버 갱신만 자동이다.
'Infra > Kubernetes' 카테고리의 다른 글
| [Kubernetes] 쿠버네티스 배포 이후: 롤링 업데이트, 검증, 롤백 (1) | 2026.09.19 |
|---|---|
| [Kubernetes] 쿠버네티스 환경 배포 - 배포 경로, 이미지 태그, 배포 명령어 (0) | 2026.09.16 |