근묵자흑
Kubernetes Pattern: Stateful Service 본문
쿠버네티스는 기본적으로 스테이트리스(Stateless) 애플리케이션을 위해 설계되었습니다. Deployment로 배포하면 Pod가 무작위 이름으로 생성되고, 언제든 교체 가능하며, 공유 스토리지를 사용하죠. 하지만 현실은 어떨까요?
실무에서는 데이터베이스, 메시지 큐, 분산 캐시 등 상태를 유지해야 하는 애플리케이션이 필수적입니다. 이런 애플리케이션들은:
- 각 인스턴스가 고유한 신원(identity)을 가져야 하고
- 재시작 후에도 동일한 네트워크 주소를 유지해야 하며
- 각자의 영구 스토리지를 가져야 하고
- 시작과 종료 순서가 보장되어야 합니다
바로 이런 요구사항을 해결하기 위해 쿠버네티스는 StatefulSet을 제공합니다.
이 글에서는 StatefulSet을 테스트하면서 마주친 문제들과 해결 방법을 공유하겠습니다.
왜 Deployment로는 부족한가?
먼저 Deployment의 한계를 살펴보겠습니다.
Deployment의 문제점
# 잘못된 예: Deployment로 MongoDB 배포
apiVersion: apps/v1
kind: Deployment
metadata:
name: mongodb
spec:
replicas: 3
template:
spec:
containers:
- name: mongodb
image: mongo:6.0
volumeMounts:
- name: data
mountPath: /data/db
volumes:
- name: data
persistentVolumeClaim:
claimName: mongodb-pvc # 모든 Pod가 동일한 PVC 공유!
문제점:
- Pod 이름이
mongodb-7c9f6d8b4-xyz12같은 무작위 해시값 - Pod 재시작 시 이름과 IP가 변경됨
- 모든 Pod가 동일한 스토리지를 공유하거나, 각자 설정이 복잡
- 시작/종료 순서가 보장되지 않음
이런 문제는 특히 클러스터형 데이터베이스에서 문제로 작용할 수 있습니다. MongoDB Replica Set이나 Kafka 클러스터는 각 노드가 고유한 ID와 안정적인 네트워크 주소를 필요로 하기 때문입니다.
StatefulSet 구현
그럼 실제로 StatefulSet을 구현 및 테스트 해보겠습니다. 로그를 영구 스토리지에 저장하는 random-generator 애플리케이션을 예제로 사용하겠습니다.
1단계: PersistentVolume 생성
먼저 각 Pod가 사용할 스토리지를 준비합니다.
# pvs.yml
apiVersion: v1
kind: PersistentVolume
metadata:
name: log-1
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 10Mi
storageClassName: hostpath # Docker Desktop 환경
hostPath:
path: /tmp/k8s-patterns/stateful-service/1
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: log-2
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 10Mi
storageClassName: hostpath
hostPath:
path: /tmp/k8s-patterns/stateful-service/2
프로덕션에서는 hostPath 대신 클라우드 프로바이더의 스토리지(EBS, GCE PD 등)를 사용하세요.
2단계: Headless Service 생성
StatefulSet은 반드시 Headless Service가 필요합니다. 이를 통해 각 Pod에 안정적인 DNS 이름을 부여합니다.
# service.yml
apiVersion: v1
kind: Service
metadata:
name: random-generator
labels:
app: random-generator
spec:
clusterIP: None # Headless Service의 핵심!
selector:
app: random-generator
ports:
- name: http
port: 8080
targetPort: 8080
이렇게 하면 다음과 같은 DNS 패턴으로 각 Pod에 접근할 수 있습니다:
rg-0.random-generator.default.svc.cluster.localrg-1.random-generator.default.svc.cluster.local
3단계: StatefulSet 정의
이제 핵심인 StatefulSet을 만듭니다.
# statefulset.yml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: rg
labels:
app: random-generator
spec:
serviceName: random-generator # Headless Service와 연결
replicas: 2
selector:
matchLabels:
app: random-generator
template:
metadata:
labels:
app: random-generator
spec:
# fsGroup 설정으로 볼륨 권한 문제 해결
securityContext:
fsGroup: 1000
# initContainer로 로그 디렉토리 권한 설정
initContainers:
- name: fix-permissions
image: busybox:1.35
command: ['sh', '-c', 'chmod 777 /opt/logs']
volumeMounts:
- name: logs
mountPath: /opt/logs
containers:
- name: random-generator
image: k8spatterns/random-generator:1.0
env:
- name: LOG_FILE
value: /opt/logs/random.log
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- containerPort: 8080
name: http
volumeMounts:
- name: logs
mountPath: /opt/logs
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
# volumeClaimTemplates: 각 Pod마다 독립적인 PVC 자동 생성
volumeClaimTemplates:
- metadata:
name: logs
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: hostpath
resources:
requests:
storage: 10Mi
핵심 포인트
- serviceName: Headless Service와 연결
- volumeClaimTemplates: 각 Pod마다 자동으로 PVC 생성
logs-rg-0,logs-rg-1이 자동 생성됨
- initContainer: 볼륨 권한 문제를 사전에 해결
- securityContext: fsGroup으로 그룹 권한 설정
배포 및 검증
배포하기
# 1. PersistentVolume 생성
kubectl apply -f pvs.yml
# 2. Headless Service 생성
kubectl apply -f service.yml
# 3. StatefulSet 배포
kubectl apply -f statefulset.yml
# 4. NodePort Service 생성 (외부 접근용)
kubectl apply -f service-nodeport.yml
배포 결과 확인
$ kubectl get pods -l app=random-generator
NAME READY STATUS RESTARTS AGE
rg-0 1/1 Running 0 2m
rg-1 1/1 Running 0 1m30s
중요: Pod가 순차적으로 생성됩니다. rg-0이 완전히 Ready 상태가 된 후에 rg-1이 시작됩니다.
$ kubectl get pvc -l app=random-generator
NAME STATUS VOLUME CAPACITY
logs-rg-0 Bound log-1 10Mi
logs-rg-1 Bound log-2 10Mi
각 Pod마다 독립적인 PVC가 자동으로 생성되었습니다!
$ kubectl get svc
NAME TYPE CLUSTER-IP PORT(S)
random-generator ClusterIP None 8080/TCP
random-generator-np NodePort 10.96.80.22 8080:30704/TCP
마주친 문제들과 해결 방법
문제 1: Permission Denied 에러
증상:
2025-10-11 05:35:23.214 ERROR 1 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]
java.io.FileNotFoundException: /opt/logs/random.log (Permission denied)
Pod가 계속 재시작되고 Readiness Probe가 실패했습니다.
원인:
- PersistentVolume의 디렉토리가 root 소유로 생성됨
- 컨테이너 내부 프로세스(uid 1000)가 쓰기 권한이 없음
해결 방법:
- securityContext에 fsGroup 추가
spec: securityContext: fsGroup: 1000 # 볼륨을 gid 1000으로 마운트- initContainer로 권한 설정
이 두 가지를 조합하니 권한 문제가 완전히 해결되었습니다.
문제 2: StorageClass 불일치
증상:
Warning ProvisioningFailed storageclass.storage.k8s.io "standard" not found
PVC가 Pending 상태에서 멈췄습니다.
원인:
- GitHub 예제는 Minikube 환경(StorageClass:
standard)을 가정 - Docker Desktop Kubernetes는
hostpathStorageClass 사용
해결 방법:
# 사용 가능한 StorageClass 확인
$ kubectl get storageclass
NAME PROVISIONER
hostpath (default) docker.io/hostpath
모든 YAML 파일에서 storageClassName을 hostpath로 변경:
# pvs.yml
spec:
storageClassName: hostpath # standard → hostpath
# statefulset.yml
volumeClaimTemplates:
- spec:
storageClassName: hostpath # standard → hostpath
각 환경의 기본 StorageClass를 확인하고 적절히 사용하세요.
- Minikube:
standard- Docker Desktop:
hostpath- GKE:
standard(GCE PD)- EKS:
gp2(AWS EBS)- AKS:
default(Azure Disk)
문제 3: PV Released 상태
증상:
NAME STATUS CLAIM
log-1 Released default/logs-rg-0
PVC를 삭제하고 재생성했더니 PV가 Released 상태로 남아서 새 PVC가 바인딩되지 않았습니다.
원인:
- PV의 기본
reclaimPolicy가Retain - 이전 PVC의 데이터가 남아있어서 보안상 자동 재사용 불가
해결 방법:
# Released 상태의 PV 삭제 후 재생성
kubectl delete pv log-1 log-2
kubectl apply -f pvs.yml
또는 PV를 재사용하려면:
# PV의 claimRef 제거
kubectl patch pv log-1 -p '{"spec":{"claimRef": null}}'
기능 검증
이제 StatefulSet의 핵심 기능들이 제대로 작동하는지 검증해보겠습니다.
1. Persistent Identity
$ kubectl get pods -l app=random-generator
NAME READY STATUS
rg-0 1/1 Running
rg-1 1/1 Running
- Pod 이름이 순서대로 생성: rg-0, rg-1
# Pod 삭제 후 재생성
$ kubectl delete pod rg-0
pod "rg-0" deleted
$ kubectl get pods -l app=random-generator
NAME READY STATUS
rg-0 1/1 Running # 동일한 이름으로 재생성!
rg-1 1/1 Running
- 동일한 이름으로 재생성 확인
2.Dedicated Storage
$ kubectl get pvc -l app=random-generator
NAME STATUS VOLUME CAPACITY
logs-rg-0 Bound log-1 10Mi
logs-rg-1 Bound log-2 10Mi
- 각 Pod마다 독립적인 PVC 자동 생성
3. Data Persistence
Pod 삭제 전 로그 확인:
$ kubectl exec rg-0 -- cat /opt/logs/random.log | head -3
11:42:56.449,f446e257-3e18-473c-96f3-9fd4117b72bf,16459,-107823759
11:43:01.388,f446e257-3e18-473c-96f3-9fd4117b72bf,20083,1643007215
11:43:06.384,f446e257-3e18-473c-96f3-9fd4117b72bf,7875,226405971
Pod 삭제 후 재생성:
$ kubectl delete pod rg-0
$ sleep 30
로그 다시 확인:
$ kubectl exec rg-0 -- cat /opt/logs/random.log | head -5
11:42:56.449,f446e257-3e18-473c-96f3-9fd4117b72bf,16459,-107823759 # 기존 데이터!
11:43:01.388,f446e257-3e18-473c-96f3-9fd4117b72bf,20083,1643007215 # 기존 데이터!
11:43:06.384,f446e257-3e18-473c-96f3-9fd4117b72bf,7875,226405971 # 기존 데이터!
11:43:11.385,f446e257-3e18-473c-96f3-9fd4117b72bf,9500,887873635 # 새 데이터
11:43:16.388,f446e257-3e18-473c-96f3-9fd4117b72bf,16750,626974804 # 새 데이터
- Pod 재생성 후에도 기존 로그 데이터가 완벽하게 유지됨!
각 Pod의 로그를 비교해보면:
# rg-0의 UUID
$ kubectl exec rg-0 -- cat /opt/logs/random.log | head -1
11:42:56.449,f446e257-3e18-473c-96f3-9fd4117b72bf,16459,-107823759
# rg-1의 UUID (다름!)
$ kubectl exec rg-1 -- cat /opt/logs/random.log | head -1
11:40:25.462,42f57721-2912-4177-9180-f06b8428aac0,17125,2021406239
- 각 Pod가 독립적인 데이터를 가지고 있음을 확인
4. Stable Network
# Headless Service 확인
$ kubectl get svc random-generator
NAME TYPE CLUSTER-IP PORT(S)
random-generator ClusterIP None 8080/TCP
DNS 레코드 확인:
$ kubectl run -it --rm debug --image=busybox --restart=Never -- \
nslookup rg-0.random-generator.default.svc.cluster.local
Server: 10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local
Name: rg-0.random-generator.default.svc.cluster.local
Address 1: 10.1.0.28 rg-0.random-generator.default.svc.cluster.local
- 각 Pod에 안정적인 DNS 이름 할당
5. Ordinality(순서 보장)
StatefulSet 배포 과정을 관찰하면:
$ kubectl get pods -l app=random-generator -w
NAME READY STATUS
rg-0 0/1 ContainerCreating
rg-0 0/1 Running
rg-0 1/1 Running # rg-0이 Ready된 후
rg-1 0/1 Pending # rg-1 생성 시작
rg-1 0/1 ContainerCreating
rg-1 1/1 Running
- 순차적 시작 확인 (rg-0 완료 후 rg-1 시작)
StatefulSet vs Deployment 비교
| 특징 | StatefulSet | Deployment |
|---|---|---|
| Pod 이름 | 순서 보장 (rg-0, rg-1) | 무작위 해시 (app-xyz) |
| 네트워크 ID | 안정적 DNS 이름 | 불안정 |
| 스토리지 | 각 Pod별 독립 PVC | 공유 또는 수동 설정 |
| 시작/종료 순서 | 순차적 | 병렬 |
| 사용 사례 | DB, 메시지 큐, 캐시 | 웹 서버, API 서버 |
| 복잡도 | 높음 | 낮음 |
Operator 패턴으로의 진화
StatefulSet만으로는 복잡한 애플리케이션 운영이 어려울 수 있습니다.
이 경우 Operator 패턴을 고려해야 합니다.
주요 Operator들
| 애플리케이션 | Operator | 추가 기능 |
|---|---|---|
| PostgreSQL | CloudNativePG | 자동 백업, 복제 관리 |
| MongoDB | MongoDB Community Operator | Replica Set 자동 구성 |
| Elasticsearch | ECK | 클러스터 자동 확장 |
| Kafka | Strimzi | 토픽 관리, 모니터링 |
| Redis | Redis Enterprise Operator | 샤딩, 페일오버 |
Operator는 StatefulSet 위에 다음을 추가로 제공합니다:
- 자동 백업 및 복원
- 클러스터 자동 구성
- 롤링 업그레이드 자동화
- 장애 자동 복구
- 스케일링 자동화
참고 자료
'k8s > kubernetes-pattern' 카테고리의 다른 글
| Kubernetes Pattern: Service Discovery (4) | 2025.10.25 |
|---|---|
| Kubernetes Pattern: Stateless Service (8) | 2025.10.18 |
| Kubernetes Pattern: Singleton Service (2) | 2025.09.27 |
| Kubernetest Pattern: DaemonService (4) | 2025.09.20 |
| Kubernetes Pattern: Periodic Job (2) | 2025.09.13 |