Ghost를 Docker에서 RKE2 Kubernetes로 이전한 과정
구축·마이그레이션 작업: 2026-08-09 · 발행 기준: 2026-08-10 17:46 KST
환경: Ghost 6, MySQL 8, RKE2, Local PV, Kustomize, Cloudflare Tunnel
단일 서버의 Docker Compose에서 운영하던 Ghost를 RKE2 Kubernetes로 이전했다. 단순히 컨테이너를 다시 띄우는 작업이 아니라 데이터 일관성, 영구 저장소, 배포 순서, 외부 트래픽 전환, 검증과 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은 하나의 정체성을 가진 저장 상태 workload다. 안정적인 네트워크 이름과 종료·시작 순서를 명확히 하기 위해 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을 쓴다고 데이터가 자동으로 복제되거나 HA가 되는 것은 아니다. 이 구성의 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를 실수로 삭제해도 실제 데이터가 자동으로 제거되지 않게 하는 마지막 방어선이다. 다만 Retain은 백업이 아니며, 디스크나 노드가 사라지는 상황은 막지 못한다.
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
probe를 무조건 느슨하게 만드는 대신, 외부 프록시 뒤에서 Ghost가 받는 Host와 scheme을 재현한 것이다.
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용으로 보존한다.
기존 DNS를 즉시 삭제하고 새 레코드를 만들기보다 Tunnel의 published hostname과 기존 레코드 충돌 여부를 먼저 확인했다. 컷오버 후에도 원본 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가 Running인 것이 아니다. 데이터가 일치하고 외부 요청이 정상이며, 실패했을 때 되돌아갈 경로까지 검증되어야 한다.