3-node 클러스터에서 직접 실습하며 배운 Kubernetes Operations의 모든 것
Day 2에서 클러스터 아키텍처와 네트워킹을 이해했다면, Day 3는 실전 운영에 필요한 기술들을 익히는 날이었습니다. Secret 관리부터 Rolling Update, Health Check까지 - Production 환경에서 반드시 알아야 할 개념들을 직접 실습하며 체득했습니다.
특히 이번 Day에서는 "왜 이 기능이 필요한가?"라는 질문을 끊임없이 던지며, 단순히 명령어를 외우는 것이 아니라 설계 철학을 이해하는 데 집중했습니다.
처음엔 의문이었습니다. "둘 다 설정 정보 저장하는 거 아닌가? 왜 굳이 나눠놨을까?"
핵심 차이:
# ConfigMap: 일반 텍스트
data:
app.env: |
LOG_LEVEL=info
MAX_CONNECTIONS=100
# Secret: base64 인코딩
data:
password: c3VwZXJzZWNyZXQxMjM= # echo -n 'supersecret123' | base64
Secret은 base64로 인코딩되고, etcd에 암호화되어 저장됩니다. (etcd encryption 설정 시)
1. 환경변수로 주입:
kubectl create secret generic db-credentials \
--from-literal=username=admin \
--from-literal=password=supersecret123
# Pod에서 환경변수로 사용
env:
- name: DB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
검증:
$ kubectl exec secret-env-pod -- env | grep DB_USER
DB_USER=admin
2. 볼륨으로 마운트:
volumes:
- name: secret-volume
secret:
secretName: db-credentials
실제로 들어가보니 신기한 구조:
$ kubectl exec secret-volume-pod -- ls -la /etc/secrets
total 0
drwxrwxrwt 3 root root 120 Nov 1 10:23 .
drwxr-xr-x 1 root root 4096 Nov 1 10:23 ..
drwxr-xr-x 2 root root 80 Nov 1 10:23 ..2025_11_01_10_23_45.1234567890
lrwxrwxrwx 1 root root 32 Nov 1 10:23 ..data -> ..2025_11_01_10_23_45.1234567890
lrwxrwxrwx 1 root root 15 Nov 1 10:23 password -> ..data/password
lrwxrwxrwx 1 root root 15 Nov 1 10:23 username -> ..data/username
심볼릭 링크 구조! 이렇게 하면 Secret을 업데이트해도 파일 경로는 동일하게 유지됩니다.
문서에서 "Rolling Update는 무중단 배포를 지원합니다"라는 말은 많이 봤지만, 실제로 어떻게 동작하는지 보고 싶었습니다.
전략 설정:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0 # 추가 Pod 생성 안 함
maxUnavailable: 1 # 한 번에 1개씩만 교체
업데이트 실행:
$ kubectl set image deployment/nginx-rolling nginx=nginx:1.21
실시간 모니터링 결과:
# 시작 (3개 모두 Running)
nginx-rolling-56d8f-abc 1/1 Running
nginx-rolling-56d8f-def 1/1 Running
nginx-rolling-56d8f-ghi 1/1 Running
# 첫 번째 교체
nginx-rolling-56d8f-abc 1/1 Terminating <- 종료 중
nginx-rolling-56d8f-def 1/1 Running
nginx-rolling-56d8f-ghi 1/1 Running
nginx-rolling-7c9d4-jkl 0/1 ContainerCreating <- 생성 중
# 새 Pod Ready
nginx-rolling-56d8f-def 1/1 Running
nginx-rolling-56d8f-ghi 1/1 Running
nginx-rolling-7c9d4-jkl 1/1 Running <- Ready!
# 두 번째 교체
nginx-rolling-56d8f-def 1/1 Terminating
nginx-rolling-56d8f-ghi 1/1 Running
nginx-rolling-7c9d4-jkl 1/1 Running
nginx-rolling-7c9d4-mno 0/1 ContainerCreating
놀라운 점:
maxSurge=0, maxUnavailable=1
→ 총 Pod 수는 항상 3개 유지
→ 하나씩 차근차근 교체
maxSurge=1, maxUnavailable=1 (기본값)
→ 총 Pod 수는 3~4개 (최대 4개까지 가능)
→ 더 빠른 업데이트, 약간의 리소스 오버헤드
처음엔 간단하게 생각했습니다. "각 노드에 같은 경로로 PV 만들면 되겠지?"
현실은...
gpu1 노드에서 데이터 생성:
$ kubectl exec pvc-test-pod -- sh -c "echo 'Data from gpu1' > /data/test.txt"
$ kubectl exec pvc-test-pod -- cat /data/test.txt
Data from gpu1
Pod를 cpu1 노드로 이동:
$ kubectl delete pod pvc-test-pod
$ kubectl apply -f pvc-test-pod.yaml # nodeSelector: cpu1
$ kubectl exec pvc-test-pod -- cat /data/test.txt
cat: can't open '/data/test.txt': No such file or directory
아하! HostPath는 노드 로컬 스토리지였습니다. 다른 노드에선 접근 불가!
"그럼 여러 노드에서 같은 데이터를 쓰려면 어떻게 하지?"
해답: 분산 스토리지 시스템
┌─────────────────────────────────────┐
│ Kubernetes Cluster │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ cpu1│ │ cpu2│ │ gpu1│ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │
│ └────────┼────────┘ │
│ │ │
│ ┌────▼─────┐ │
│ │ Ceph │ ← 네트워크 스토리지
│ │ Cluster │ │
│ └──────────┘ │
│ (cpu1, cpu2, gpu1의 디스크를 │
│ 통합하여 하나의 스토리지로 제공) │
└─────────────────────────────────────┘
Ceph:
AWS EFS, GCP Persistent Disk 등도 같은 원리!
"PV만 있으면 되는 거 아냐? PVC는 왜 필요하지?"
설계 철학:
PV (PersistentVolume)
├─ 클러스터 레벨 리소스
├─ 관리자가 프로비저닝
└─ 실제 스토리지 백엔드 정의
PVC (PersistentVolumeClaim)
├─ 네임스페이스 레벨 리소스
├─ 개발자가 요청
└─ 필요한 용량/접근모드만 명시
비유:
장점:
1. 추상화: 개발자는 스토리지 구현 몰라도 됨
2. 격리: 네임스페이스별 권한 관리
3. 동적 프로비저닝: StorageClass로 자동 생성 가능
"limits 넘으면 OOMKilled되는데, QoS는 또 뭐지?"
핵심 차이:
OOMKilled (개별 Pod)
├─ Pod가 자신의 limits를 초과할 때
├─ 언제든 발생 가능
└─ 해당 Pod만 종료
QoS Eviction (노드 전체)
├─ 노드 전체 메모리 부족 시
├─ 여러 Pod 중 누구를 죽일지 결정
└─ 우선순위: BestEffort > Burstable > Guaranteed
1. Guaranteed: 최고 우선순위
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "100m" # requests = limits
memory: "128Mi"
2. Burstable: 중간 우선순위
resources:
requests:
memory: "128Mi"
limits:
memory: "256Mi" # limits > requests
3. BestEffort: 최하위 우선순위
resources: {} # 아무것도 지정 안 함
검증:
$ kubectl get pod qos-guaranteed -o jsonpath='{.status.qosClass}'
Guaranteed
$ kubectl get pod qos-burstable -o jsonpath='{.status.qosClass}'
Burstable
처음엔 "다 필요한 Pod인데 왜 죽여?"라고 생각했지만, 실제 사례를 들으니 보험 같은 개념이라는 걸 깨달았습니다.
시나리오 1: 클라우드 비용 최적화
# 핵심 API 서버 - Guaranteed
api-server:
resources:
requests: {memory: 2Gi}
limits: {memory: 2Gi}
# 로그 수집기 - Burstable
log-collector:
resources:
requests: {memory: 256Mi}
limits: {memory: 1Gi}
# 통계 분석 - BestEffort
analytics:
resources: {} # 평소엔 여유 리소스 사용, 압박 시 희생
시나리오 2: 피크 타임 대응
시나리오 3: 스팟 인스턴스 활용
Liveness Probe
Readiness Probe
시나리오: 30초 후 파일 삭제
livenessProbe:
exec:
command:
- cat
- /tmp/healthy
initialDelaySeconds: 5
periodSeconds: 5
# Container command
command:
- sh
- -c
- |
touch /tmp/healthy
sleep 30
rm -f /tmp/healthy # 30초 후 삭제!
sleep 600
결과:
$ kubectl get pod liveness-test -w
NAME READY STATUS RESTARTS AGE
liveness-test 1/1 Running 0 10s
liveness-test 1/1 Running 0 30s
liveness-test 1/1 Running 1 40s <- 재시작!
정확히 30초 후 재시작! Liveness Probe가 실패를 감지하고 자동 복구했습니다.
시나리오: /ready 파일 없으면 트래픽 차단
readinessProbe:
httpGet:
path: /ready
port: 80
initialDelaySeconds: 5
periodSeconds: 5
배포 직후:
$ kubectl get pod -n production
NAME READY STATUS RESTARTS AGE
production-app-7c9d4-abc 0/1 Running 0 10s <- 0/1!
production-app-7c9d4-def 0/1 Running 0 10s
Endpoints 확인:
$ kubectl get endpoints production-app -n production
NAME ENDPOINTS AGE
production-app <none> 30s <- 비어있음!
/ready 파일 생성:
$ kubectl exec -n production production-app-7c9d4-abc -- \
sh -c "echo 'ready' > /usr/share/nginx/html/ready"
$ kubectl get pod -n production
production-app-7c9d4-abc 1/1 Running 0 1m <- 1/1!
$ kubectl get endpoints production-app -n production
production-app 10.244.5.224:80,10.244.102.153:80,10.244.184.91:80
완벽하게 동작! Readiness가 통과되자 Endpoints에 자동 등록되었습니다.
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30 # 30번 실패까지 허용
periodSeconds: 10 # 10초마다 체크
# → 최대 300초(5분) 대기
Java Spring Boot처럼 초기화가 오래 걸리는 앱에 유용합니다.
Day 3의 모든 개념을 통합한 Production 애플리케이션을 배포했습니다.
아키텍처:
production namespace
├── ConfigMap (nginx.conf + app.env)
├── Secret (DB_PASSWORD, API_KEY)
├── Deployment (3 replicas)
│ ├── Resource requests/limits (QoS: Burstable)
│ ├── Liveness Probe (/health)
│ ├── Readiness Probe (/ready)
│ ├── Rolling Update (maxSurge: 1, maxUnavailable: 1)
│ └── ConfigMap/Secret mount
├── Service (ClusterIP)
└── Service (NodePort 30080)
apiVersion: apps/v1
kind: Deployment
metadata:
name: production-app
namespace: production
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
spec:
containers:
- name: nginx
image: nginx:1.21
# QoS: Burstable
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
# Health Checks
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 80
initialDelaySeconds: 5
periodSeconds: 5
# Secret 환경변수
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secrets
key: DB_PASSWORD
# ConfigMap 마운트
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
volumes:
- name: nginx-config
configMap:
name: app-config
1. Pod 분산 배포 확인:
$ kubectl get pods -n production -o wide
NAME NODE IP READY
production-app-849b867f78-9vkkp cpu1 10.244.184.91 1/1
production-app-849b867f78-b2fjx gpu1 10.244.5.224 1/1
production-app-849b867f78-kkdjv cpu2 10.244.102.153 1/1
완벽하게 3개 노드에 분산!
2. QoS 클래스 확인:
$ kubectl get pod production-app-849b867f78-9vkkp -n production \
-o jsonpath='{.status.qosClass}'
Burstable
3. 내부 접근 테스트:
$ kubectl run test-v1 --rm -i --restart=Never -n production \
--image=busybox:1.28 -- wget -qO- http://production-app
Hello from Production App v1.0
4. 외부 접근 테스트 (NodePort):
$ curl http://172.30.1.43:30080
Hello from Production App v1.0
ConfigMap 업데이트:
$ kubectl apply -f app-config-v2.yaml
configmap/app-config configured
Deployment restart로 Rolling Update 트리거:
$ kubectl rollout restart deployment production-app -n production
deployment.apps/production-app restarted
$ kubectl rollout status deployment production-app -n production
deployment "production-app" successfully rolled out
업데이트 확인:
$ kubectl get pods -n production
NAME READY STATUS AGE
production-app-849b867f78-9vkkp 1/1 Running 34s <- 모두 새로 생성!
production-app-849b867f78-b2fjx 1/1 Running 34s
production-app-849b867f78-kkdjv 1/1 Running 23s
$ curl http://172.30.1.43:30080
Hello from Production App v2.0 - UPDATED!
무중단 배포 성공! 서비스 중단 없이 v2.0으로 업데이트되었습니다.
문제:
$ kubectl apply -f new-configmap.yaml
configmap/app-config configured
$ curl http://app
Hello from v1.0 <- 여전히 v1.0!
원인:
해결:
$ kubectl rollout restart deployment production-app -n production
문제:
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY STORAGECLASS
pvc-hostpath Pending
원인:
해결:
$ kubectl describe pvc pvc-hostpath
Events:
Warning ProvisioningFailed no persistent volumes available
PV의 accessModes와 capacity를 확인하고 매칭시키세요!
문제:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "200m" # requests와 다름!
memory: "128Mi"
원인:
해결:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "100m" # requests와 동일!
memory: "128Mi"
이건 정상입니다!
Readiness 실패로 Pod를 죽이고 싶다면 Liveness Probe도 함께 설정하세요.
| 항목 | ConfigMap | Secret |
|---|---|---|
| 용도 | 일반 설정 | 민감 정보 |
| 인코딩 | 없음 | base64 |
| etcd 암호화 | 선택 | 권장 |
| RBAC | 가능 | 가능 |
| 예시 | nginx.conf, app.env | password, API key |
maxSurge=1, maxUnavailable=0
→ 새 Pod 먼저 생성 후 기존 Pod 종료
→ 리소스 오버헤드 있지만 가장 안전
maxSurge=0, maxUnavailable=1
→ 기존 Pod 종료 후 새 Pod 생성
→ 리소스 절약, 약간의 Capacity 감소
maxSurge=1, maxUnavailable=1
→ 균형잡힌 기본값
PV (관리자)
├─ hostPath: /mnt/data
├─ capacity: 1Gi
└─ accessModes: ReadWriteOnce
⬇ Binding (1:1)
PVC (개발자)
├─ requests: 500Mi
└─ accessModes: ReadWriteOnce
⬇ Mount
Pod
└─ volumeMounts: /data
리소스 압박 시 Eviction 순서:
1. BestEffort (requests/limits 없음)
2. Burstable (requests < limits)
3. Guaranteed (requests = limits) ← 최후까지 보호
# 가장 권장하는 조합
livenessProbe: # 데드락 복구
httpGet: /health
initialDelaySeconds: 30
readinessProbe: # 초기화 대기
httpGet: /ready
initialDelaySeconds: 5
startupProbe: # 느린 시작 허용
httpGet: /health
failureThreshold: 30
periodSeconds: 10
Day 3에서 개별 기능들을 익혔다면, Day 4는 고급 패턴과 실전 시나리오를 다룰 예정입니다:
특히 HPA와 Ingress는 Production 환경에서 거의 필수이기 때문에, Day 4의 하이라이트가 될 것 같습니다.
Day 3를 돌아보니, 단순히 "어떻게 하는가"를 넘어 "왜 이렇게 설계되었는가"를 이해하는 데 집중했던 것 같습니다.
특히 기억에 남는 것들:
3-node 클러스터에서 직접 실습하며, 문서로만 봤을 때는 몰랐던 실제 동작을 눈으로 확인할 수 있었던 게 가장 큰 수확입니다.
Day 4에서는 더 복잡한 시나리오와 Production 환경에서의 Best Practice를 익혀보겠습니다!