Ghost 2026.09.10 12 min read

HA 구성 후 직접 멈춰 봤다: DB 전환부터 백업 복원까지

Primary 프로세스를 종료하고, Ghost의 DB 연결을 끊고, 앱을 멈췄다. 실제 content 이동과 외부 백업 SQL 복원까지 시험하며 확인한 결과와 아직 시험하지 않은 범위를 나눴다.

MySQL 3개와 Longhorn 구성을 준비한 뒤 DB 세 개가 ONLINE이고 Pod가 모두 Ready라는 것까지는 확인했다. 그런데 그 상태만 보고 장애가 나도 복구된다고 말하기는 어려웠다. 특히 궁금했던 것은 Pod는 떠 있는데 앱만 멈춰 있는 경우였다. Kubernetes가 이 상황을 알아차리는지, 재시작만 하는지, 다른 노드로 옮기는지를 나눠 확인했다.

DB 장애 주입은 운영 이관 전에 별도 시험 스키마로 진행했다. Ghost 시험도 공개 서비스가 연결되지 않은 미리보기에서 실행했다. 새 구성에 실제 운영 쓰기를 연 뒤에는 같은 장애 시험을 반복하지 않고 읽기 점검과 백업 복원을 진행했다.

Primary를 강제 종료하고 쓰기가 돌아오는 시점을 봤다

정상적인 Pod 교체를 먼저 확인한 다음, 현재 Primary의 mysqld 프로세스만 SIGKILL로 종료했다. Router를 거쳐 재연결하는 클라이언트로 쓰기를 계속 시도하고, 성공 응답을 받은 데이터가 세 DB에 남아 있는지 비교했다.

  • 전체 쓰기 시도: 116회
  • 성공 응답: 86회, 실패: 30회
  • 새 Primary를 통한 쓰기 재개: 프로세스 종료 후 약 26.6초에 관찰
  • 세 멤버 모두 ONLINE으로 복귀: 약 103초에 관찰
  • 성공 응답을 받은 86건: 세 멤버에서 모두 동일하게 확인

전환 중 실패한 요청은 있었다. Router가 기존 트랜잭션을 대신 다시 실행해 주는 것은 아니다. 또 26.6초는 한 번의 DB 프로세스 고장 시험에서 관찰한 값이다. 전체 노드 장애나 네트워크 분할의 복구 시간, Ghost 연결 풀의 모든 동작을 측정한 수치로 쓰면 안 된다.

성공 응답을 받은 ID를 세 멤버에서 대조했다

쓰기 클라이언트는 요청 ID, 시작·종료 시각, 성공 여부를 파일로 남겼다. 복구 후에는 각 멤버에서 시험 테이블의 ID를 읽어 성공 응답 목록과 대조했다. 같은 개수여도 서로 다른 행이 남을 수 있어서 ID 집합을 비교했다.

아래의 namespace와 클러스터, 시험 스키마 이름은 공개용 예시다. 시험 데이터만 들어 있는 격리 환경을 전제로 한다. 기존 운영 스키마에는 시험 테이블을 만들지 않았다.

NS=blog-ha-lab
CLUSTER=blog-db-lab
k() { kubectl --request-timeout=30s -n "$NS" "$@"; }

for member in 0 1 2; do
  k exec "$CLUSTER-$member" -c mysql -- \
    mysql --protocol=socket -ulocalroot --batch --skip-column-names \
    --execute='SELECT id FROM ha_validation_example.crash_writes ORDER BY id;' \
    > "member-$member.ids"
done
import json
from pathlib import Path

# 쓰기 클라이언트가 남긴 id와 성공 여부
records = json.loads(Path("router-write-results.json").read_text())
acknowledged = {r["id"] for r in records if r["ok"]}
assert acknowledged, "No acknowledged writes to verify"
members = [
    {int(v) for v in Path(f"member-{i}.ids").read_text().split()}
    for i in range(3)
]
assert all(acknowledged <= ids for ids in members)
assert members[0] == members[1] == members[2]

실제 강제 종료는 현재 Primary의 Pod UID, CRI container ID, 실행 파일이 mysqld인 것을 먼저 확인하고 pidfd로 해당 프로세스에만 전달했다. 이 실험에서는 성공 응답 데이터 누락과 멤버 간 불일치가 모두 없었다.

DB 장애와 Ghost 자체의 멈춤을 구분했다

상태 확인은 세 가지로 나눴다. startup은 초기 기동을 기다리고, liveness는 Ghost HTTP 프로세스가 응답하는지 확인한다. readiness는 실제 홈페이지를 렌더할 수 있는지 확인한다. readiness 실패 시 서비스 대상에서 빠지고 liveness 반복 실패 시 컨테이너를 재시작하는 구분은 Kubernetes probe 동작에 맞췄다.

운영에 적용한 간격은 다음과 같다. HTTP 요청에는 Host와 X-Forwarded-Proto 헤더를 지정해 실제 HTTPS 서비스와 같은 호스트 조건으로 검사했다. 아래는 Ghost 컨테이너 설정에서 probe 부분만 추린 예시다.

startupProbe:
  httpGet:
    path: /ghost/api/admin/site/
    port: 2368
    httpHeaders: &ghostHeaders
      - {name: Host, value: sw-data.com}
      - {name: X-Forwarded-Proto, value: https}
  periodSeconds: 5
  timeoutSeconds: 5
  failureThreshold: 120
livenessProbe:
  httpGet:
    path: /ghost/api/admin/site/
    port: 2368
    httpHeaders: *ghostHeaders
  periodSeconds: 15
  timeoutSeconds: 5
  failureThreshold: 6
readinessProbe:
  httpGet:
    path: /
    port: 2368
    httpHeaders: *ghostHeaders
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3
  successThreshold: 2

시험 스크립트는 미리보기가 Ready 한 개인지, 메일이 stub인지, 사용하는 DB와 PVC가 격리 대상인지 먼저 검사했다. 어떤 공개 Service도 해당 Pod를 선택하지 않아야 했다. DB 연결 차단은 미리보기 전용 NetworkPolicy에서 Router 허용만 제거하는 방식으로 했고, 원래 정책은 finally 구간에서 복원했다. 다른 허용 정책이 겹쳐 있으면 실제 차단이 되지 않을 수 있어 적용되는 egress 정책도 확인했다.

DB 연결만 차단했을 때는 홈페이지가 실패하고 Ready가 false로 바뀌었다. 이 상태를 연속 190초 관찰하는 동안 DB 조회 없이 응답할 수 있는 site 경로는 HTTP 200을 유지했고, Ghost 재시작은 0회였다. 연결을 풀자 같은 Pod에서 다시 Ready가 됐다. 홈페이지 검사가 캐시만 보고 통과하지 않도록 public posts cache도 껐다.

다음에는 Ghost 프로세스에 SIGSTOP을 보내 HTTP 응답 자체를 멈췄다. liveness가 실패하고 같은 Pod, 같은 노드 안에서 컨테이너가 한 번 재시작한 뒤 정상으로 돌아왔다. 이 시험은 확인 시간을 줄인 probe 설정을 사용했고, 끝난 뒤 운영 설정으로 정확히 복원했다. 운영보다 짧은 probe 주기로 시험했으므로 운영 설정에서의 복구 시간은 별도로 측정해야 한다.

프로세스를 멈추기 전후에는 Pod UID, 노드, container ID, restartCount를 기록했다. 아래 명령은 이 네 값을 비교하는 읽기 점검이다. Pod UID가 바뀌면 같은 Pod 안에서 컨테이너만 복구됐다는 판정을 할 수 없다.

PREVIEW_SELECTOR=app=ghost-ha-preview-example
k get pods -l "$PREVIEW_SELECTOR" \
  -o custom-columns='POD:.metadata.name,UID:.metadata.uid,NODE:.spec.nodeName,RESTARTS:.status.containerStatuses[0].restartCount,CONTAINER:.status.containerStatuses[0].containerID,READY:.status.containerStatuses[0].ready'
k get events --field-selector type=Warning --sort-by=.lastTimestamp

멈춤 주입에서는 CRI에서 확인한 namespace, 미리보기 이름, Pod UID, 컨테이너 이름과 실행 상태가 모두 일치해야 진행했다. PID 재사용을 피하려고 프로세스 시작 식별자를 다시 확인하고 pidfd를 사용했다. 정해진 시간 안에 교체되지 않으면 SIGCONT로 원래 프로세스를 풀도록 보호 로직을 넣었다.

readiness가 실패한다고 다른 노드로 바로 옮겨지는 것은 아니다. 이번 앱 멈춤은 현재 노드에서의 재시작으로 복구했다. 노드 자체가 내려가는 상황에는 Pod 재배치와 볼륨 분리·연결이 추가로 필요하다.

실제 content를 예비 노드에 연결했다

작은 시험 볼륨을 정상 분리한 뒤 다른 노드에서 읽는 검사를 먼저 했다. 그다음 실제 새 content 볼륨과 격리 Ghost를 예비 실행 노드로 옮겼다. 이 노드에는 Longhorn 저장 복제 디스크가 없었다.

이동 전후 이미지와 사용 중인 테마의 93개 파일, 17,282,403바이트를 대조했고 집계 SHA256이 같았다. 새 노드에서 홈페이지가 HTTP 200으로 응답하고 볼륨도 healthy인 것을 확인했다. 여기서 비교한 범위는 이미지와 활성 테마다. 로그를 포함한 content 전체를 같은 해시로 검증했다는 뜻은 아니다.

이 시험에서는 기존 Pod를 정상 종료하고 볼륨을 분리했다. 기존 노드가 살아 있는지 모르는 상태에서의 강제 이동은 시험하지 않았다. 그 상황에서는 두 곳이 동시에 쓰지 않도록 기존 작성자를 먼저 차단해야 한다.

이동 전후에 같은 파일 범위를 비교했다

실제 검증은 Node로 작성했지만, 아래 Python도 같은 집계 방식이다. 파일의 상대 경로와 내용 해시를 연결하고 정렬해 다시 해시한다. 테마 디렉터리는 공개용 예시 이름이다. 복제한 미리보기에서 이동 전후 두 번 실행해 파일 수, 바이트 수, 집계값을 대조한다.

from pathlib import Path
import hashlib, json
base = Path("/var/lib/ghost/content")
roots = ("images", "themes/active-theme-example")
assert all((base / root).is_dir() for root in roots)
files = [
    p for root in roots
    for p in (base / root).rglob("*") if p.is_file() and not p.is_symlink()
]
rows = [
    "/" + p.relative_to(base).as_posix() + ":" +
    hashlib.sha256(p.read_bytes()).hexdigest()
    for p in files
]
print(json.dumps({
    "files": len(files), "bytes": sum(p.stat().st_size for p in files),
    "sha256": hashlib.sha256("\n".join(sorted(rows)).encode()).hexdigest()
}))

기존 Pod와 볼륨을 잡고 있던 임시 helper가 종료된 것을 확인한 뒤 예비 노드에서 기동했다. 새 Pod의 nodeName, Longhorn의 currentNodeID와 healthy 상태도 같이 검사했다.

백업은 다른 노드에서 실제 SQL로 복원했다

HA 운영 전환 후 외부 저장소에 생성된 백업 하나를 정확히 선택했다. 별도 ARM 노드에서 저장소 구조를 확인하고, restic의 전체 복원과 검증을 실행했다. 압축이 풀리는 것까지 확인한 뒤 새 MySQL 8.4.11에 SQL을 가져왔다.

복원 대상은 latest 대신 한 개를 지정했다

백업 저장소 설정과 복호화 정보만 연결한 검증 컨테이너에서 다음 순서로 진행했다. 실제 snapshot ID와 저장소 위치는 공개하지 않는다. RECOVERY_SNAPSHOT은 검증할 정확한 ID, RESTIC_REPOSITORY와 RESTIC_PASSWORD는 사전에 주입한 값이다. stdout 전체에 설정값을 출력하지 않았다.

set -euo pipefail
umask 077
: "${RECOVERY_SNAPSHOT:?exact snapshot ID required}"
: "${RESTIC_REPOSITORY:?repository configuration required}"
mkdir -p /restore/private /restore/data

restic --retry-lock 5m snapshots "$RECOVERY_SNAPSHOT" --json \
  > /restore/private/selected.json
restic --retry-lock 5m check > /restore/private/check.log 2>&1
restic --retry-lock 5m restore "$RECOVERY_SNAPSHOT" \
  --target /restore/data --verify > /restore/private/restore.log 2>&1

# 복원 목록에서 SQL gzip이 정확히 한 개인 것을 확인한 뒤 지정
: "${RESTORED_SQL_GZ:?verified SQL gzip path required}"
gzip -t "$RESTORED_SQL_GZ"
gzip -dc "$RESTORED_SQL_GZ" > /restore/private/dump.sql

그다음 새 MySQL을 초기화해 TCP와 X Protocol을 끈 상태로 실행했다. 이번에 복원한 정기 백업 SQL에는 CREATE DATABASE와 USE 구문이 모두 없었다. 임시 DB를 먼저 만들고 import할 때 그 DB를 명시했다. 전환 직전 따로 만든 --databases dump와 정기 백업의 형식이 달라 복원 전에 구문을 확인해야 했다. 아래 DB 이름과 경로는 실제 수행한 이 절차를 공개용 예시로 바꾼 것이다.

# 별도 emptyDir만 사용하는 검증 컨테이너 내부
test ! -e /restore/mysql
mysqld --no-defaults --initialize-insecure --user=root \
  --datadir=/restore/mysql > /restore/private/initialize.log 2>&1
mysqld --no-defaults --user=root --datadir=/restore/mysql \
  --skip-networking --mysqlx=OFF --skip-log-bin \
  --socket=/restore/mysql.sock --pid-file=/restore/mysql.pid \
  --innodb-buffer-pool-size=128M --max-connections=10 \
  --log-error=/restore/private/mysql.log &
ready=false
for attempt in $(seq 1 120); do
  if mysqladmin --protocol=socket --socket=/restore/mysql.sock -uroot ping \
      >/dev/null 2>&1; then ready=true; break; fi
  sleep 1
done
test "$ready" = true
# 이 컨테이너의 임시 MySQL만 종료하도록 socket을 고정
trap 'mysqladmin --protocol=socket --socket=/restore/mysql.sock -uroot shutdown >/dev/null 2>&1 || true' EXIT

# 실제 시험한 정기 백업 형식: CREATE DATABASE와 USE가 모두 없음
tail -n 5 /restore/private/dump.sql | grep -q '^-- Dump completed on '
if grep -Eq '^(CREATE DATABASE |USE )' /restore/private/dump.sql; then
  printf '%s\n' 'Unexpected database selection statements; stop and review.'
  exit 1
fi

mysql --protocol=socket --socket=/restore/mysql.sock -uroot \
  --execute='CREATE DATABASE blog_recovery_example CHARACTER SET utf8mb4' \
  > /restore/private/create-database.log 2>&1
mysql --protocol=socket --socket=/restore/mysql.sock -uroot blog_recovery_example \
  < /restore/private/dump.sql > /restore/private/import.log 2>&1

mysql --protocol=socket --socket=/restore/mysql.sock -uroot \
  --batch --skip-column-names blog_recovery_example \
  --execute='SELECT VERSION();
    SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE();
    SELECT COUNT(*) FROM posts;
    SELECT status,type,COUNT(*) FROM posts GROUP BY status,type ORDER BY status,type;
    SELECT COUNT(*) FROM settings;'

검증 컨테이너는 운영 자격 증명과 운영 볼륨을 연결하지 않고 별도 emptyDir를 사용했다. 임시 MySQL은 네트워크를 끄고 해당 컨테이너의 Unix socket으로만 접속했다. import 종료 코드와 실제 버전, 복원된 테이블·글·설정 수를 확인한 뒤 검증 인스턴스를 종료했다.

복원 결과는 97개 테이블, posts 63건, 그중 공개 글 59개와 공개 페이지 1개였다. content 401개 파일과 기존 마우스 효과 파일의 checksum도 확인했다.

다만 이 백업 복원 시험에서 복원된 Ghost까지 다시 기동하거나 관리자 저장 UI를 시험하지는 않았다. 확인한 범위는 선택한 백업의 전체 파일 복원과 실제 SQL import 성공까지다. 복구할 때는 이 백업 시점 이후의 변경도 따로 확인해야 한다.

운영 전환 뒤에는 원본 서버의 66개 경로와 세 DB의 데이터 수, 복제 오류 여부를 다시 확인했다. 이전 DB는 정지하고 볼륨은 남겨 뒀다. 장애가 다시 생기면 이 시험 기록을 출발점으로 삼되, 당시와 같은 고장인지부터 확인할 생각이다.

SEARCH JOURNAL

기록 검색

글 제목, 요약, 태그에서 찾습니다.

검색을 준비하고 있어요.

검색어를 입력해 주세요.

    Ctrl/⌘ K 검색 Esc 닫기 결과 이동