Ghost는 한 개로 두고, DB와 볼륨을 다른 노드로 옮길 수 있게 했다
단일 Ghost에 MySQL 3개와 Router 2개를 연결하고, DB 5Gi씩과 content 10Gi에 Longhorn 3복제를 적용했다. 실행 노드와 저장 노드를 나눠 생각한 이유와 자원 부담을 기록했다.
Ghost와 MySQL 업그레이드를 먼저 마친 뒤 HA 구성을 진행했다. 블로그가 Kubernetes 위에 올라가 있어도 데이터가 특정 노드의 로컬 디스크에만 있으면 그 노드를 쉽게 비울 수 없다. Pod를 다른 곳에 띄우는 설정과, 그곳에서 같은 글과 이미지를 읽을 수 있는지는 따로 확인해야 했다.
Ghost는 한 개만 실행하고, 필요할 때 다른 노드에서 같은 블로그를 다시 실행할 수 있도록 구성했다. 이를 위해 DB 복제와 콘텐츠 볼륨의 연결 방식을 함께 바꿨다.
DB 전환과 콘텐츠 이동을 나눠 구성했다
DB는 MySQL 8.4.11 세 개를 single-primary InnoDB Cluster로 묶었다. 한 멤버가 쓰기를 받고 다른 두 멤버가 복제한다. Ghost는 특정 DB 주소 대신 Router 두 개 앞의 서비스에 접속한다. Primary가 바뀌면 Router가 새 연결을 쓰기 가능한 멤버로 보낸다. 이 역할 분리는 MySQL Group Replication 문서에서도 확인할 수 있다.
이미지와 테마가 들어 있는 content에는 Longhorn 볼륨을 연결했다. DB 멤버별 볼륨에도 Longhorn을 적용해, DB 실행 위치가 바뀌더라도 기존 볼륨을 다시 연결할 수 있게 했다.
| 대상 | 구성 | 담당하는 일 |
|---|---|---|
| Ghost | 단일 active, Recreate 배포 | 동시에 실행하는 작성자를 한 개로 유지 |
| MySQL | 8.4.11, 3멤버 | DB 복제와 Primary 선출 |
| Router | 8.4.10, 2개 | 현재 Primary로 새 연결 전달 |
| Longhorn | 1.12.1, V1 engine, 볼륨별 3복제 | 저장 복제본 유지와 다른 노드의 볼륨 연결 |
Pod 상태와 DB 그룹 상태를 따로 읽었다
아래 명령은 실제 작업에서 사용한 절차를 공개용 예시로 정리했다. namespace, Pod, PVC, 파일 위치는 예시 이름이며, 접속할 클러스터와 준비된 리소스에 맞춰 바꿔야 한다. 비밀번호 값은 명령에 적거나 출력하지 않는다.
DB 멤버끼리, Router끼리 같은 노드에 겹쳐 배치되지 않도록 각각 anti-affinity를 넣었다. 배포 후에는 선언한 설정과 실제 생성된 Pod가 일치하는지 확인했다. 다음 조회는 Secret 값을 출력하지 않는다.
NS=blog-ha-example
CLUSTER=blog-db-ha
k() { kubectl --request-timeout=30s -n "$NS" "$@"; }
kubectl get nodes -L kubernetes.io/arch
k get innodbcluster "$CLUSTER"
k get pods -o wide
k get pvc
k get endpointslices -l kubernetes.io/service-name="$CLUSTER"
for member in 0 1 2; do
k exec "$CLUSTER-$member" -c mysql -- \
mysql --protocol=socket -ulocalroot --batch --skip-column-names \
--execute='SELECT VERSION(),@@read_only,@@super_read_only;
SELECT MEMBER_ID,MEMBER_HOST,MEMBER_ROLE,MEMBER_STATE
FROM performance_schema.replication_group_members
ORDER BY MEMBER_HOST;'
done통과 기준은 세 멤버 모두 같은 그룹의 ONLINE 상태이고 Primary는 한 개인 것이다. Secondary는 read_only와 super_read_only가 켜져 있어야 한다. Pod가 Ready로 바뀐 직후에는 그룹 재가입이 아직 끝나지 않을 수 있어 SQL 조회까지 확인했다.
앱이 실제로 사용할 연결도 별도로 점검했다. 임시 클라이언트에 앱 계정의 Secret 참조만 연결하고 Router를 통해 SELECT VERSION(), @@read_only와 공개 글 수를 조회했다. 관리용 socket 조회만 성공한 상태와 구분하기 위해서다.
# DB_HOST는 Router 서비스명, 사용자/암호/DB명은 Secret 참조로 주입
k exec sql-check-client -- sh -ec '
export MYSQL_PWD="$MYSQL_PASSWORD"
exec mysql --host="$DB_HOST" --user="$MYSQL_USER" \
--database="$MYSQL_DATABASE" --batch --skip-column-names \
--execute="SELECT VERSION(),@@read_only;
SELECT COUNT(*) FROM posts WHERE status=\"published\" AND type=\"post\";"
'용량은 DB 5Gi씩, content 10Gi로 잡았다
개인 블로그에 DB 볼륨 20Gi씩은 너무 큰 것 아닌가 하는 생각이 먼저 들었다. 당시 글 규모와 백업 크기를 확인하고 DB는 멤버당 5Gi, content는 10Gi로 정했다. 현재 데이터에 맞춰 이 용량으로 시작하고, DB 로그와 스냅샷, 사용량 증가에 따라 확장할 수 있게 했다.
계산할 때는 복제를 두 번 세어야 했다. MySQL 세 멤버가 5Gi씩 사용하니 DB 논리 용량은 15Gi다. 여기에 content 10Gi를 더한 25Gi를 Longhorn이 세 벌 유지한다. 기본 복제 용량 합계는 75Gi, 저장 노드별로는 25Gi다. 실제 파일이 처음부터 이만큼 차 있다는 뜻은 아니며, 파일시스템과 스냅샷 등에 필요한 여유 공간도 별도다.
DB 복제와 스토리지 복제를 함께 쓰면 저장 공간과 쓰기 비용이 늘어난다. 이번에는 DB 볼륨까지 이동 가능하게 만드는 범위를 선택했기 때문에 그 비용을 받아들였다. 복제본이 많아도 잘못 삭제한 데이터까지 함께 반영될 수 있어 외부 백업은 계속 유지했다.
볼륨의 복제 설정과 실제 복제본을 대조했다
스토리지 클래스에는 3복제, Retain, 용량 확장 허용을 설정했다. V1 engine을 사용하고, 같은 노드에 복제본이 겹치지 않도록 Longhorn 설정에서 node/disk anti-affinity를 강제했다. 노드별 운영 여유 공간을 남기고 동시 재구성 수도 제한했다.
볼륨 상태와 복제본 수는 아래처럼 PVC가 가리키는 실제 볼륨 이름으로 찾았다. 설정된 복제본 수 3개와 실제 실행 상태를 대조하고 서로 다른 저장 노드에 배치됐는지 확인했다.
CONTENT_PVC=blog-content
PV=$(k get pvc "$CONTENT_PVC" -o jsonpath='{.spec.volumeName}')
VOL=$(kubectl get pv "$PV" -o jsonpath='{.spec.csi.volumeHandle}')
LH_NS=longhorn-system
kubectl -n "$LH_NS" get volumes.longhorn.io "$VOL" \
-o custom-columns='NAME:.metadata.name,SIZE:.spec.size,REPLICAS:.spec.numberOfReplicas,NODE:.status.currentNodeID,HEALTH:.status.robustness'
kubectl -n "$LH_NS" get replicas.longhorn.io -o json |
python3 -c 'import json,sys
for r in json.load(sys.stdin)["items"]:
if r["spec"].get("volumeName") == sys.argv[1]:
print(r["metadata"]["name"], r["spec"].get("nodeID"),
r["status"].get("currentState"),
"failedAt=" + r["spec"].get("failedAt", ""))' "$VOL"
# DB PVC 세 개에도 같은 조회를 반복
kubectl -n "$LH_NS" get nodes.longhorn.io최종 기준은 DB 세 볼륨과 content 한 볼륨이 모두 healthy이고, 각각 세 저장 노드에 복제본을 가진 상태였다. 설정 YAML만 보관하면 실제로 어느 볼륨이 연결됐는지 빠질 수 있어서 PVC, PV, Longhorn 상태를 함께 기록했다.
지연 시간을 보고 클라우드 안의 세 노드를 골랐다
처음 후보에는 별도 위치의 노드도 포함했다. 확인 당시 클라우드 내부 사설망 왕복 지연은 약 0.4ms, 외부 위치와 클라우드 사이는 약 11~14ms였다. DB와 Longhorn이 쓰기 과정에서 네트워크를 사용하므로, 최종 데이터 배치는 클라우드 내부의 세 ARM 노드로 모았다. 이 값은 경로의 왕복 지연이며 DB 처리량을 측정한 결과는 아니다.
ARM에서 실행할 이미지도 확인했다. MySQL Server, Router, Operator의 ARM 이미지와 digest를 고정하고 재시작과 멤버 재가입 뒤에도 유지되는지 시험했다. 아키텍처가 다른 원본 DB에서 가져오는 작업은 SQL로 처리했다. MySQL Clone의 플랫폼 제약을 피하면서 실제 데이터를 대조할 수 있었다.
이미지와 예약 자원도 실행 결과로 확인했다
일반 태그와 ARM 태그가 가리키는 플랫폼을 확인하고 이미지 digest를 고정했다. 실제 Pod의 init container, DB, sidecar, Router 이미지도 대조했다. 아래 조회는 컨테이너의 환경 변수를 출력하지 않는다.
k get pod "$CLUSTER-0" -o jsonpath='{range .spec.initContainers[*]}{.name}{"\t"}{.image}{"\n"}{end}{range .spec.containers[*]}{.name}{"\t"}{.image}{"\n"}{end}'
k get deployment -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{range .spec.template.spec.containers[*]}{.name}{"\t"}{.image}{"\n"}{end}{end}'
# 요청량은 스케줄링 기준, top은 해당 시점 사용량
k top pods --containers
kubectl top nodes
kubectl describe node cloud-node-exampleMySQL은 멤버당 CPU 300m와 메모리 1Gi, sidecar는 CPU 50m와 128Mi를 요청하도록 맞췄다. Router는 각각 CPU 50m와 128Mi다. MySQL 버퍼 풀 512Mi, redo 용량 128Mi, 최대 연결 80개도 함께 설정했다. MySQL Operator는 버전 변경 때 이미지를 다시 생성할 수 있다. 다음 업그레이드에서도 생성된 템플릿과 실제 ARM 이미지를 다시 확인해야 한다.
실행할 수 있는 노드와 저장 복제본이 있는 노드는 다르다
클라우드의 네 번째 ARM 노드는 예비 실행 위치로 두고 Longhorn 복제 디스크는 등록하지 않았다. 이 노드에서도 다른 세 노드에 저장된 볼륨을 연결할 수 있다. 시험 볼륨에 이어 실제 content와 격리 Ghost를 옮겼고, 이미지와 활성 테마의 파일 일치 및 홈페이지 응답을 확인했다.
다만 저장 노드 하나가 계속 내려가 있으면 복제본은 줄어든 상태로 남는다. DB나 Ghost가 예비 노드에서 실행됐다고 저장 복제본까지 세 개로 채워지는 것은 아니다. 기존 저장 노드가 돌아오거나 대체 디스크를 등록해야 한다.
또 기존 노드가 응답하지 않는다고 곧바로 볼륨을 강제로 떼어 다른 곳에 붙이지 않도록 했다. 네트워크만 끊긴 채 기존 Ghost가 계속 쓰고 있을 수 있다. 이런 경우에는 기존 작성자를 차단한 사실을 먼저 확인해야 한다.
각 클라우드 노드가 2 vCPU라 장애 후 CPU 예약 여유도 넓지 않다. 배치 가능한 요청량인지 계산하는 것과 복구 중 부하를 감당하는지는 다른 문제다. 현재 구성에서는 실제 사용량과 Pending Pod, 복제 상태를 함께 봐야 한다. 구성 후 수행한 DB 종료·앱 멈춤·볼륨 이동 시험 결과는 따로 남겼다.