
컨테이너화된 워크로드와 서비스를 여러 서버에 걸쳐 자동으로 배치·복구·확장·노출해주는 오픈소스 오케스트레이션 플랫폼
Docker가 "컨테이너 하나를 만들고 실행"하는 도구라면,
Kubernetes는 "컨테이너 수백 개를 서버 수십 대 위에서 관리"하는 도구다.
문제
파드는 죽고 다시 뜨는 게 정상이다. 그때마다 IP가 바뀐다.
order-service가 payment-service를 부르려는데 IP를 어떻게 알까? 하드코딩하면 다음 배포에 바로 깨진다.
해결
payment-service.default.svc.cluster.local
│
├──► Pod A (10.0.1.5)
├──► Pod B (10.0.2.9)
└──► Pod C (10.0.1.7)
Spring Boot 관점
# application.yml — IP가 아니라 서비스 이름으로 호출
payment:
base-url: http://payment-service:8080
Eureka 같은 별도 서비스 디스커버리 서버 없이도 이게 된다. K8s가 DNS 레벨에서 해결해주기 때문.
Service 타입 4가지
| 타입 | 용도 |
|---|---|
ClusterIP (기본) | 클러스터 내부에서만 접근. 내부 MSA 통신용 |
NodePort | 노드의 특정 포트(30000~32767)로 외부 노출. 테스트용 |
LoadBalancer | 클라우드 LB를 자동 생성해 외부 노출. NKS에서 실서비스 표준 |
ExternalName | 외부 도메인으로 CNAME 매핑 |
Ingress: L7(HTTP) 라우팅. 경로/호스트 기반으로 여러 Service를 하나의 LB 뒤에 묶는다.
api.example.com/orders→ order-service,api.example.com/pay→ payment-service
문제
컨테이너는 무상태(stateless)가 원칙이지만, DB·파일 업로드·로그처럼 살아남아야 하는 데이터가 있다.
파드가 다른 노드로 옮겨가면 로컬 디스크 데이터는 사라진다.
해결
Pod ──uses──► PVC ──bound──► PV ──backed by──► NHN Cloud Block Storage
▲
StorageClass가 동적 생성
접근 모드
| 모드 | 의미 | 블록 스토리지 | NAS |
|---|---|---|---|
ReadWriteOnce (RWO) | 노드 1개에서만 읽기/쓰기 | o | o |
ReadOnlyMany (ROX) | 여러 노드에서 읽기만 | x | o |
ReadWriteMany (RWX) | 여러 노드에서 읽기/쓰기 | x | o |
파드 여러 개가 같은 파일을 공유해야 하면 블록 스토리지(RWO)로는 안 된다. NAS 계열이 필요.
주의
프로덕션 DB를 K8s 안에 넣는 건 신중해야 한다. 대부분은 관리형 DB(RDS 등)를 클러스터 밖에 두고 K8s에는 stateless 앱만 올리는 게 안전하다.
문제
docker compose down && up 하면 그 사이 서비스가 죽는다. 새 버전에 버그가 있으면 되돌리는 것도 수작업.
해결 — Rolling Update
Deployment가 파드를 한 번에 다 바꾸지 않고 조금씩 교체한다.
v1 v1 v1 v1 (시작)
v1 v1 v1 v2 (1개 교체 → readinessProbe 통과 대기)
v1 v1 v2 v2
v1 v2 v2 v2
v2 v2 v2 v2 (완료, 다운타임 0)
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 목표 개수보다 최대 몇 개 더 띄울지
maxUnavailable: 0 # 동시에 몇 개까지 죽어도 되는지 (0이면 무중단 보장)
롤백
Deployment는 이전 ReplicaSet을 히스토리로 보관한다.
kubectl rollout history deployment/my-api
kubectl rollout undo deployment/my-api # 직전 버전으로
kubectl rollout undo deployment/my-api --to-revision=3
kubectl rollout status deployment/my-api # 진행 상황 모니터링
핵심 전제 — Probe 설정이 필수
readinessProbe가 없으면 K8s는 "컨테이너 프로세스가 떴다 = 준비 완료"로 착각하고 다음 파드를 교체한다. Spring Boot는 기동에 20~60초가 걸리므로, probe 없이 롤링 업데이트하면 트래픽이 아직 준비 안 된 파드로 흘러가 502가 난다.
readinessProbe: # 트래픽 받을 준비가 됐나? (실패 시 Service에서 제외)
httpGet: { path: /actuator/health/readiness, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 5
livenessProbe: # 살아있나? (실패 시 컨테이너 재시작)
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 60
periodSeconds: 10
startupProbe: # 기동 중인가? (통과 전까지 liveness 유예)
httpGet: { path: /actuator/health, port: 8080 }
failureThreshold: 30
periodSeconds: 5
Spring Boot Actuator는
management.endpoint.health.probes.enabled=true설정 시
/actuator/health/liveness,/actuator/health/readiness를 제공한다. K8s와 궁합이 좋다.
문제
노드가 5대 있고 파드를 20개 띄워야 한다. 어느 노드에 무엇을 넣어야 자원이 안 남고 안 터질까?
해결
스케줄러가 각 파드의 resources.requests를 보고 남은 자원에 가장 잘 맞는 노드를 골라 배치한다. (상자에 짐을 빈틈없이 채우는 bin packing 문제에서 유래)
resources:
requests: # 스케줄링 기준. "최소 이만큼은 보장해줘"
cpu: "500m" # 0.5 코어
memory: "512Mi"
limits: # 상한. 넘으면 CPU는 스로틀링, 메모리는 OOMKilled
cpu: "1000m"
memory: "1Gi"
꼭 알아야 할 함정
UseContainerSupport가 기본이지만, 명시하는 게 안전:JAVA_OPTS=-XX:MaxRAMPercentage=75.0limits가 1Gi면 힙을 768Mi 정도로 잡아, 나머지는 메타스페이스·스레드 스택용으로 남긴다.QoS 클래스 (자원 부족 시 축출 우선순위)
| 클래스 | 조건 | 축출 순서 |
|---|---|---|
Guaranteed | requests == limits (전 컨테이너) | 마지막 |
Burstable | requests < limits | 중간 |
BestEffort | 아무것도 안 적음 | 첫 번째로 죽음 |
문제
새벽 3시에 앱이 죽었다. 알람 받고 일어나서 SSH 접속해서 재시작?
해결 — 선언적 상태 유지 (Reconciliation Loop)
사용자가 "replicas: 3"이라고 원하는 상태(desired state)를 선언하면, 컨트롤러가 실제 상태(current state)를 계속 감시하며 차이를 메운다.
원하는 상태: 3개
실제 상태: 2개 ← 파드 하나 죽음
↓
컨트롤러가 차이를 감지 → 새 파드 1개 생성 → 3개 복구
복구되는 레벨 3가지
| 레벨 | 감지 방법 | 동작 |
|---|---|---|
| 컨테이너 | 프로세스 종료 / livenessProbe 실패 | 컨테이너 재시작 (restartPolicy) |
| 파드 | 파드 삭제·축출 | ReplicaSet이 새 파드 생성 |
| 노드 | 노드 NotReady (기본 5분) | 다른 정상 노드로 파드 재배치 |
주의
Self-healing은 일시적 장애를 고치지 재현되는 버그는 못 고친다.
CrashLoopBackOff는 "재시작 → 즉시 죽음"이 반복되는 상태로, 재시작 간격이 점점 늘어난다(10s → 20s → 40s ... 최대 5분). 이걸 보면 자동 복구를 기다릴 게 아니라 로그를 봐야 한다.
kubectl logs <pod> --previous # 죽기 직전 로그
kubectl describe pod <pod> # 이벤트 확인
문제
DB 비밀번호를 application.yml에 적어 이미지에 굽는다 → 이미지 유출 = 계정 유출.
게다가 dev/staging/prod마다 값이 다른데 이미지를 3개 빌드해야 하나?
해결
설정을 이미지 밖으로 빼서 런타임에 주입한다.
| 용도 | 저장 방식 | |
|---|---|---|
| ConfigMap | 비민감 설정 (프로파일, 로그 레벨, 외부 API URL) | 평문 |
| Secret | 민감 정보 (DB 비번, JWT 키, API 토큰) | base64 인코딩 |
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata: { name: app-config }
data:
SPRING_PROFILES_ACTIVE: "prod"
LOGGING_LEVEL_ROOT: "INFO"
---
# Secret
apiVersion: v1
kind: Secret
metadata: { name: app-secret }
type: Opaque
stringData: # stringData면 평문으로 써도 알아서 인코딩
SPRING_DATASOURCE_PASSWORD: "s3cr3t!"
주입 방법 2가지
# (a) 환경 변수로 — Spring Boot는 SPRING_DATASOURCE_PASSWORD 를 자동 인식
envFrom:
- configMapRef: { name: app-config }
- secretRef: { name: app-secret }
# (b) 파일로 마운트 — application.yml 통째로 넣거나 인증서 파일
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
configMap: { name: app-config }
Secret은 암호화가 아니다. base64는 인코딩일 뿐
base64 -d로 즉시 풀린다.
실무에서는 (1) etcd 저장 시 암호화 활성화, (2) RBAC로 Secret 조회 권한 제한,
(3) 외부 Vault / Sealed Secrets / External Secrets Operator 사용을 병행한다.
Secret YAML을 Git에 커밋하면 안 된다.
┌──────────────── Control Plane (제어 영역) ────────────────┐
│ kube-apiserver 모든 요청의 관문. 인증/인가/검증 │
│ etcd 클러스터의 모든 상태를 담은 K-V 저장소 │
│ kube-scheduler 새 파드를 어느 노드에 놓을지 결정 │
│ controller-manager Reconciliation Loop를 돌리는 컨트롤러들 │
│ cloud-controller 클라우드 LB/볼륨 등 연동 │
└──────────────────────────────────────────────────────────┘
▲ ▼ (통신)
┌──────────────── Worker Node (여러 대) ───────────────────┐
│ kubelet apiserver 명령을 받아 컨테이너 실행 │
│ kube-proxy Service 가상 IP → 실제 파드 네트워크 규칙│
│ Container Runtime containerd 등 실제 컨테이너 실행 엔진 │
│ │
│ [Pod] [Pod] [Pod] ← 사용자 애플리케이션 │
└──────────────────────────────────────────────────────────┘
NKS의 핵심 가치: 위 컨트롤 플레인을 NHN Cloud가 대신 관리해준다. etcd 백업, apiserver HA, 버전 업그레이드 같은 난이도 높은 운영을 안 해도 된다. 사용자는 노드, 서비스, 파드만 신경 쓰면 된다.
Deployment "이 앱을 3개 유지해줘"
└─ ReplicaSet "파드 개수를 세고 맞춰라" (버전마다 하나씩 생김)
└─ Pod "컨테이너 1개 이상을 묶은 최소 배포 단위"
└─ Container
| 오브젝트 | 언제 쓰나 | 예시 |
|---|---|---|
| Deployment | stateless 앱. 대부분의 경우 | API 서버, 웹 |
| StatefulSet | 고유 ID·순서·전용 볼륨이 필요한 앱 | Kafka, Redis, MySQL |
| DaemonSet | 모든 노드에 하나씩 | 로그 수집기, 모니터링 에이전트 |
| Job | 한 번 실행하고 끝 | DB 마이그레이션, 배치 |
| CronJob | 주기적 실행 | 매일 새벽 정산 |
논리적 격리 단위. dev, staging, prod를 한 클러스터 안에서 분리하거나 팀별로 나눈다.
같은 네임스페이스 안에서는 Service를 짧은 이름(payment-service)으로 부를 수 있고,
다른 네임스페이스는 payment-service.other-ns처럼 붙여야 한다.
| 이름 | 무엇을 늘리나 |
|---|---|
| HPA (Horizontal Pod Autoscaler) | 파드 개수 — CPU/메모리/커스텀 메트릭 기준 |
| VPA (Vertical Pod Autoscaler) | 파드 하나의 requests/limits 크기 |
| Cluster Autoscaler | 노드 개수 — 파드가 Pending이면 노드 추가 |
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: my-api-hpa }
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-api
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
HPA는
requests대비 사용률로 계산한다. requests를 안 적으면 HPA도 동작하지 않는다.
HPA(파드 증가) + Cluster Autoscaler(노드 증가)를 같이 써야 실제로 확장된다.
쿠버네티스를 이해하는 가장 중요한 관점.
| 명령형 (Imperative) | 선언형 (Declarative) |
|---|---|
| "컨테이너를 켜라, 꺼라" | "최종 상태는 이래야 한다" |
docker run ... | kubectl apply -f deploy.yaml |
| 실패하면 사람이 개입 | 시스템이 알아서 수렴 |
이 사고방식이 GitOps(ArgoCD 등)로 이어진다.
Git 저장소의 YAML = 원하는 상태, 클러스터는 그걸 계속 따라간다.
kubectl rollout undo로 롤백해봤다