Ghost 2026.09.10 8 min read

Ghost와 MySQL을 올리기 전에 되돌릴 환경부터 만들었다

Ghost 6.63.0과 MySQL 8.4.11로 옮기면서 원본 DB와 콘텐츠를 따로 남겼다. 격리 검증, 쓰기 중단 시점의 백업, 운영 전환 이후의 복구 기준까지 정리했다.

블로그를 여러 노드에서 복구할 수 있게 만들기로 했다. 그 전에 Ghost와 MySQL 버전부터 올렸다. 버전 변경과 HA 전환을 한꺼번에 진행하면 문제가 생겼을 때 어느 쪽부터 확인해야 할지 애매해진다. 먼저 단일 인스턴스에서 새 버전이 정상적으로 동작하는지 확인하고, 그 데이터를 HA 환경으로 옮기는 순서로 진행했다.

작업은 2026년 9월 9일부터 10일 새벽까지 진행했다. Ghost는 6.56.0에서 6.63.0으로, MySQL은 8.0.46에서 8.4.11로 올렸다. 이 글에 적은 검증 결과와 버전은 그 작업 시점 기준이다.

이전 DB와 content를 복구용으로 남겼다

되돌릴 때 사용할 Ghost 버전, DB와 content를 한 묶음으로 준비했다. Ghost의 DB 마이그레이션이 끝나면 이전 이미지에 맞는 스키마도 필요하기 때문이다. MySQL도 8.4가 사용한 디렉터리에 8.0을 바로 연결하는 방식은 선택하지 않았다. MySQL의 다운그레이드 조건을 확인하고 이전 버전의 원본을 따로 보관했다.

그래서 기존 DB와 콘텐츠 볼륨을 건드리지 않고 새 볼륨을 만들었다. 원본 SQL, 이미지와 테마가 들어 있는 content, 배포 설정을 함께 보관했다. 압축 파일을 읽을 수 있는지 검사하고, 다른 서버에 복사한 파일의 크기와 SHA256을 원본과 대조했다.

백업과 복사본의 통과 기준

아래 명령은 실제 작업에서 사용한 절차를 공개용 예시로 정리했다. namespace, Pod, PVC, 파일 위치는 예시 이름이며, 접속할 클러스터와 준비된 리소스에 맞춰 바꿔야 한다. 비밀번호 값은 명령에 적거나 출력하지 않는다.

전환용 백업은 작성자를 멈춘 뒤 만들었다. SQL은 애플리케이션 DB만 덤프하고 GTID를 새 서버에 강제로 가져가지 않도록 했다. content-reader는 원본 content를 읽기 전용으로 연결한 임시 Pod다. 예시에서는 DB 컨테이너에 기존 Secret 참조를 통해 MYSQL_DATABASE와 MYSQL_ROOT_PASSWORD가 주입돼 있다고 가정한다.

set -euo pipefail
umask 077
NS=blog-upgrade-example
SOURCE_DB=mysql-source-0
CONTENT_READER=content-reader
CHECKPOINT=$(mktemp -d "$PWD/upgrade-checkpoint.XXXXXX")
k() { kubectl --request-timeout=30s -n "$NS" "$@"; }

# Ghost와 DB/content 작성 작업이 종료된 것을 확인한 뒤 실행
k exec "$SOURCE_DB" -- sh -ec '
  export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"
  exec mysqldump --user=root --single-transaction --quick \
    --routines --events --triggers --hex-blob --no-tablespaces \
    --set-gtid-purged=OFF --default-character-set=utf8mb4 \
    --databases "$MYSQL_DATABASE"
' | gzip > "$CHECKPOINT/database.sql.gz"

k exec "$CONTENT_READER" -- \
  tar --numeric-owner --acls --xattrs -czf - -C /source-content . \
  > "$CHECKPOINT/content.tar.gz"

gzip -t "$CHECKPOINT/database.sql.gz"
tar -tzf "$CHECKPOINT/content.tar.gz" >/dev/null
(cd "$CHECKPOINT" && sha256sum database.sql.gz content.tar.gz > SHA256SUMS)
(cd "$CHECKPOINT" && sha256sum --check SHA256SUMS)

압축 검사에 이어 송신 측에서 기록한 바이트 수와 SHA256을 수신 측 결과와 비교했다. SQL에는 dump 완료 표시도 있어야 한다. 이 검사들이 통과하고 DB와 content의 캡처 구간이 같아야 전환용 복구 지점으로 취급했다.

운영 데이터를 복사해 먼저 실행했다

새 MySQL 8.4에 논리 백업을 가져온 뒤 Ghost 6.63.0을 별도 미리보기 환경에서 실행했다. 나중에 DB 실행 아키텍처도 AMD64에서 ARM64로 바뀌기 때문에, 데이터 디렉터리를 통째로 옮기는 대신 SQL dump와 import를 사용했다.

미리보기는 공개 서비스에 연결하지 않았다. 메일 발송은 stub으로 바꾸고 외부 통신도 제한했다. 운영 데이터를 복사한 앱이 메일이나 webhook을 중복 전송하면, 화면 검증을 하다가 실제 사용자에게 영향을 줄 수 있기 때문이다.

Ghost의 22개 마이그레이션이 끝난 뒤 공개 글, 페이지, 태그와 연결 관계의 수를 비교했다. 이어서 글과 관리자 읽기 경로를 포함한 66개 경로, 정적 파일 5개를 확인했다. 기존 테마와 마우스 효과 파일도 대조했다. 로그인 후 글을 저장하는 UI 시험은 이번 검증에 포함하지 않았다.

복원 전에는 대상 DB가 비어 있는지 확인했다

실제 복원 스크립트는 대상 스키마의 테이블 수가 0이 아니면 중단했다. 기존 데이터 위에 SQL을 겹쳐 넣지 않기 위한 조건이다. 다음은 그 확인과 import 부분이다.

TARGET_DB=mysql-upgrade-0
tables=$(k exec "$TARGET_DB" -- sh -ec '
  export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"
  exec mysql --user=root --batch --skip-column-names "$MYSQL_DATABASE" \
    --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()"
')
test "$tables" = 0

gzip -dc "$CHECKPOINT/database.sql.gz" |
  k exec -i "$TARGET_DB" -- sh -ec '
    export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"
    exec mysql --user=root
  '

Ghost 기동 후에는 마이그레이션 때문에 테이블 수가 달라질 수 있다. 그래서 테이블 수가 무조건 같아야 한다고 정하지 않고, 글의 상태와 종류, 태그 및 연결 관계처럼 보존돼야 하는 데이터를 비교했다.

SELECT VERSION();
SELECT status, type, COUNT(*) FROM posts
  GROUP BY status, type ORDER BY status, type;
SELECT COUNT(*) FROM tags;
SELECT COUNT(*) FROM posts_tags;
SELECT COUNT(*) FROM users;
SELECT COUNT(*) FROM members;

원본과 복원본에서 같은 조회를 실행해 결과를 대조했다. DB 계정의 쓰기 권한은 트랜잭션을 시작하고 기존 설정값을 같은 값으로 UPDATE한 뒤 ROLLBACK하는 방식으로 확인했다.

Ghost에 직접 요청해 응답을 확인했다

미리보기 Pod 안에서 로컬 Ghost에 요청해 CDN 캐시의 성공 응답과 구분했다. 아래는 대표 경로만 보여 주는 예시다. 실제 검증은 DB에서 공개 글의 slug를 읽어 경로 목록에 더했고, 응답 코드와 본문 크기, 홈페이지가 참조한 정적 파일도 확인했다.

// 준비된 미리보기 Pod 내부에서 node로 실행
const http = require('http');
const paths = ['/', '/ghost/', '/ghost/api/admin/site/', '/rss/'];
(async () => {
  for (const path of paths) {
    await new Promise((resolve, reject) => {
      const req = http.get({
        host: '127.0.0.1', port: 2368, path,
        headers: {Host: 'sw-data.com', 'X-Forwarded-Proto': 'https'}
      }, res => {
        let bytes = 0;
        res.on('data', b => bytes += b.length);
        res.on('end', () => {
          if (res.statusCode !== 200 || bytes < 20) {
            return reject(new Error(path + ': invalid response'));
          }
          console.log(path, res.statusCode, bytes);
          resolve();
        });
      });
      req.setTimeout(12000, () => req.destroy(new Error('timeout')));
      req.on('error', reject);
    });
  }
})().catch(e => { console.error(e.message); process.exit(1); });

마지막 복사는 쓰기를 멈춘 상태에서 했다

예행연습용 복사본은 그 이후 수정 내용을 따라가지 않는다. 실제 전환 직전에는 새 요청을 막고 Ghost를 정지한 뒤, DB와 content를 다시 확보했다. SQL의 일관된 백업만으로 이미지 업로드까지 같은 시점이 되는 것은 아니어서, 둘에 쓰는 작업이 멈춘 구간에서 한 묶음으로 저장했다.

전환 순서는 다음처럼 고정했다.

  1. 이전 배포 설정과 DB·content를 보존한다.
  2. 새 환경을 검증할 동안 공개 요청을 닫아 둔다.
  3. 새 버전의 데이터와 페이지 응답을 확인한다.
  4. 백업 작업이 새 DB와 content를 바라보도록 함께 바꾼다.
  5. 확인이 끝난 뒤 공개 요청을 연다.

전환 중 문제가 생기면 이전 구성을 복구하도록 타이머도 준비했다. 다만 운영 전환이 완료되면 그 스크립트는 더 이상 실행되지 않게 했다. 새 환경에서 글을 한 줄이라도 고친 뒤에는, 오래된 복사본으로 돌아가는 순간 그 수정이 사라질 수 있다.

운영을 연 뒤에는 복구 기준이 달라진다

업그레이드가 끝난 뒤 외부 백업을 새로 만들고 전체 파일을 내려받아 검증했다. 이어서 HA 이관을 마친 뒤에는 백업 SQL을 별도 MySQL 8.4.11에 실제로 가져오는 시험까지 진행했다. 백업 작업의 성공 표시와 데이터 복원 성공을 구분해서 기록했다.

이전 DB는 정지했지만 원본 볼륨은 남겨 뒀다. 이 복사본은 운영 전환 이후의 변경을 자동으로 반영하지 않는다. 지금 다시 옮겨야 한다면 현재 쓰기를 멈추고 최신 DB와 content를 함께 확보한 뒤, 같은 버전의 새 환경에 복원해야 한다.

버전 업그레이드를 마친 다음 MySQL 3개와 Longhorn을 사용하는 구성으로 넘어갔다. 외부에서 보이는 디자인과 마우스 효과는 그대로 유지했다.

SEARCH JOURNAL

기록 검색

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

검색을 준비하고 있어요.

검색어를 입력해 주세요.

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