Ghost를 Docker에서 RKE2 Kubernetes로 이전한 과정
Docker Compose의 Ghost를 RKE2로 옮겼다. Local PV와 배포 리소스를 선택한 이유, 데이터 비교, 트래픽 전환과 rollback 순서를 기록했다.
구축·마이그레이션 작업: 2026-08-09 · 발행 기준: 2026-08-10 17:46 KST
환경: Ghost 6, MySQL 8, RKE2, Local PV, Kustomize, Cloudflare Tunnel
단일 서버의 Docker Compose에서 운영하던 Ghost를 RKE2 Kubernetes로 이전했다. DB와 content를 같은 상태로 옮기고, 저장소 연결과 배포 순서를 확인한 뒤 외부 트래픽을 전환했다. 문제가 생겼을 때 사용할 rollback 경로도 먼저 준비했다.
이전 후 구조는 아래와 같다.
사용자
→ Cloudflare Edge
→ Cloudflare Tunnel
→ cloudflared Pod ×2
→ Ghost Service:2368
→ Ghost Deployment
├─ content PVC → Local PV 10 GiB
└─ MySQL Service → MySQL StatefulSet
└─ database PVC → Local PV 20 GiB
사용한 Kubernetes 리소스와 선택 이유, 데이터를 복사하고 운영 요청을 전환한 순서를 남긴다.
이전 전 먼저 고정한 것
새 리소스를 만들기 전에 기존 Docker 환경을 확인했다. 실제 데이터 위치와 연결 설정을 알아야 복사 대상과 되돌릴 대상을 정할 수 있었다.
- Ghost와 MySQL 이미지 버전
- Ghost content의 실제 bind mount 경로
- MySQL data directory와 논리 DB 이름
- 공개 URL과 관리자 URL
- 환경변수, SMTP 설정, DB 계정
- 게시물·테이블·콘텐츠 파일 개수
- 기존 Cloudflare Tunnel origin과 rollback 경로
특히 DB raw directory를 그대로 복사하지 않고 mysqldump --single-transaction으로 논리 dump를 만들었다. MySQL 버전, 파일 권한, InnoDB 내부 상태가 다른 환경으로 raw 복사되는 위험을 줄이기 위해서다.
mysqldump \
--single-transaction \
--quick \
--no-tablespaces \
-u <user> -p <database> | gzip > ghost.sql.gz
--no-tablespaces는 제한된 백업 계정에 PROCESS 권한을 추가하지 않고도 dump할 수 있도록 넣었다. dump 파일이 비어 있지 않은지, gzip 검사가 통과하는지, SHA-256이 원본과 전송본에서 같은지도 확인했다.
gzip -t ghost.sql.gz
sha256sum ghost.sql.gz ghost-content.tar.gz
왜 Ghost는 Deployment이고 MySQL은 StatefulSet인가
Ghost는 Pod 이름과 생성 순서에 의존하지 않아 Deployment로 구성했다. Deployment에서 replicas를 유지하고 rolling update를 관리할 수 있다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ghost
spec:
replicas: 1
selector:
matchLabels:
app: ghost
template:
metadata:
labels:
app: ghost
spec:
containers:
- name: ghost
image: ghost:<pinned-version>
ports:
- containerPort: 2368
volumeMounts:
- name: content
mountPath: /var/lib/ghost/content
volumes:
- name: content
persistentVolumeClaim:
claimName: ghost-content
MySQL은 데이터와 연결되는 인스턴스의 이름을 안정적으로 유지할 필요가 있었다. 네트워크 이름과 종료·시작 순서를 관리하기 위해 StatefulSet과 headless Service를 사용했다.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ghost-mysql
spec:
serviceName: ghost-mysql
replicas: 1
selector:
matchLabels:
app: ghost-mysql
template:
metadata:
labels:
app: ghost-mysql
spec:
containers:
- name: mysql
image: mysql:<pinned-version>
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumes:
- name: data
persistentVolumeClaim:
claimName: ghost-db
StatefulSet을 사용해도 데이터가 자동 복제되지는 않는다. 당시 MySQL은 1 replica였고, 장애 복구는 백업과 DR로 보완하는 구성이었다.
Local PV와 PVC 설계
온프레미스 worker의 LVM 여유 공간에 Ghost content용 10 GiB LV와 MySQL용 20 GiB LV를 분리했다. 두 LV는 ext4로 포맷하고 서로 다른 mount point에 연결했다.
단순 hostPath 대신 Local PV와 PVC를 사용했다. 애플리케이션 manifest가 물리 경로를 직접 알 필요가 없고, 스케줄러가 PV의 nodeAffinity를 보고 올바른 노드에 Pod를 배치할 수 있기 때문이다.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-static
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: ghost-content-local
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-static
local:
path: /srv/kubernetes-pv/ghost-content/volume
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [<storage-node>]
WaitForFirstConsumer는 Pod scheduling 정보를 고려한 뒤 binding을 결정한다. Retain은 PVC 삭제 시 실제 데이터가 자동으로 제거되지 않도록 설정했다. 디스크나 노드 자체가 사라지면 이 설정으로 보호할 수 없으므로 백업은 별도로 필요했다.
PVC는 workload에서 사용할 논리 이름만 유지한다.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ghost-content
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-static
resources:
requests:
storage: 10Gi
나중에 CSI 또는 분산 스토리지로 전환해도 애플리케이션의 mountPath와 PVC 이름을 유지하면 변경 범위를 줄일 수 있다.
Secret은 매니페스트와 분리
다음 값은 Git에 저장하지 않았다.
- MySQL root 및 애플리케이션 계정 비밀번호
- Ghost SMTP 자격 증명
- restic repository 암호
- rclone OAuth client secret과 token
- Cloudflare Tunnel token
예제 파일에는 key 이름만 두고, 실제 Secret은 별도 절차로 생성했다. kubectl get secret -o yaml의 base64 문자열도 암호화된 공개 정보가 아니므로 작업 로그와 블로그에 남기지 않았다.
probe가 redirect 때문에 실패했던 이유
Ghost의 canonical URL이 HTTPS 공개 주소로 설정돼 있으면 단순 HTTP probe가 redirect될 수 있다. 프로세스는 정상인데 readiness가 실패해 Service endpoint에서 제외되는 상황이 생긴다.
실제 요청 조건과 맞도록 HTTP header를 포함했다.
readinessProbe:
httpGet:
path: /
port: 2368
httpHeaders:
- name: Host
value: blog.example.com
- name: X-Forwarded-Proto
value: https
외부 프록시 뒤에서 Ghost가 받는 Host와 scheme을 probe에도 전달해 요청 조건을 맞췄다.
Cloudflare Tunnel을 두 replicas로
외부 전환 대상은 Pod IP가 아니라 Kubernetes Service DNS로 지정했다.
http://ghost.<namespace>.svc.cluster.local:2368
Pod IP는 재시작할 때 바뀌지만 Service DNS는 유지된다. cloudflared는 Kubernetes Deployment 2 replicas로 실행하고 pod anti-affinity를 적용해 서로 다른 노드에 배치했다.
spec:
replicas: 2
template:
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: cloudflared
topologyKey: kubernetes.io/hostname
Tunnel replica의 목적은 connector나 노드 하나가 중단돼도 Cloudflare와의 연결을 유지하는 것이다. Ghost와 Local PV 자체가 자동 HA가 되는 것은 아니다.
실제 컷오버 순서
- Kubernetes 리소스를 production과 분리된 상태로 준비한다.
- 원본 Ghost의 content를 보관하고 DB pre-stage dump를 가져온다.
- MySQL을 먼저 기동해 dump를 import한다.
- Ghost를 기동하고 내부 Service DNS로 확인한다.
- 게시물·테이블·content 파일 수를 원본과 비교한다.
- 짧은 쓰기 중단 구간을 잡고 최종 dump와 content archive를 만든다.
- 최종 데이터를 다시 반영하고 checksum을 확인한다.
- Cloudflare Tunnel origin을 Kubernetes Service로 전환한다.
- 공개 URL, 관리자 로그인, 이미지와 기존 게시물을 확인한다.
- 기존 Docker 컨테이너와 원본 데이터는 rollback용으로 보존한다.
Tunnel의 published hostname이 기존 DNS 레코드와 충돌하는지 먼저 확인했다. 전환 후에도 원본 Docker 데이터는 복구용으로 남겼다.
검증 기준
kubectl -n <namespace> get deploy,statefulset,pod,svc,pvc -o wide
kubectl get pv
kubectl -n <namespace> get endpointslice
kubectl -n <namespace> logs deploy/ghost --tail=200
kubectl -n <namespace> logs statefulset/ghost-mysql --tail=200
- Ghost와 MySQL Pod가 Ready인가?
- PVC 두 개와 PV 두 개가 Bound인가?
- Pod가 Local PV의 nodeAffinity와 같은 노드에 있는가?
- Service에 Ready endpoint가 있는가?
- DB 테이블과 게시물 수가 원본과 같은가?
- content 파일 수와 checksum이 일치하는가?
- 내부 Service HTTP와 외부 HTTPS가 모두 성공하는가?
- 관리자 로그인과 이미지 로딩이 정상인가?
- cloudflared 두 Pod가 서로 다른 노드에 있는가?
검증 결과 Ghost와 MySQL은 Ready가 되었고, 게시물 12개, MySQL 테이블 92개, content 파일 39개가 원본과 일치했다. 내부 Service와 세 개의 외부 hostname도 HTTP 200을 반환했다.
rollback도 배포의 일부다
문제가 생기면 Cloudflare origin을 기존 Docker 서비스로 되돌리고 Kubernetes의 Ghost replicas를 0으로 줄여 양쪽에서 동시에 쓰는 상황을 막도록 계획했다. 원본 Docker 데이터, 최종 dump, content archive와 checksum을 보존했기 때문에 새 환경을 폐기해도 원래 서비스로 돌아갈 수 있었다.
Pod 기동 뒤에도 원본과 데이터가 같은지, 외부 요청이 정상인지 확인했다. 전환이 실패했을 때 되돌릴 원본과 절차까지 준비한 상태에서 이전을 마쳤다.