Troubleshooting 2026.09.15 9 min read

백업은 성공했는데 복원 검증이 멈췄다: restic 잠금과 MySQL 설정 확인

Ghost 백업을 실제로 복원하면서 restic 잠금 경합과 테스트 MySQL의 시작 오류를 만났다. 백업 생성, 파일 복원, SQL import를 나눠 확인하고 검증 절차를 고쳤다.

Ghost와 MySQL을 업그레이드하고 HA 구성을 마친 뒤, 외부 저장소의 백업을 새 환경에서 복원해 봤다. 백업 Job은 성공했고 snapshot도 생성돼 있었다. 확인하고 싶었던 것은 그 파일을 내려받아 MySQL에 다시 넣을 수 있는지였다.

복원 검증은 운영 PVC나 DB 계정을 연결하지 않은 별도 Job에서 진행했다. 임시 저장 공간에 지정한 snapshot 전체를 복원하고, 임시 MySQL 8.4.11을 띄워 SQL을 넣도록 만들었다. 이 과정에서 두 번 멈췄다. 둘 다 백업 데이터가 손상돼서 발생한 문제는 아니었다.

첫 번째 중단은 저장소 잠금 경합이었다

검증이 자정 이후의 예약 백업과 겹쳤다. snapshot 목록을 읽는 단계는 통과했지만, 저장소 구조 검사에서 잠금을 얻지 못해 실패했다. 같은 저장소를 사용하던 예약 백업도 잠금이 필요한 단계에서 영향을 받았다.

먼저 실패한 단계와 실행 중인 Job을 확인했다. 앞서 수동 백업으로 만든 snapshot은 정상적으로 남아 있었다. 사용 중인 잠금을 강제로 지우지 않고, 충돌한 작업들이 종료된 뒤 다시 실행했다.

사용 중인 restic 0.18.1에서 --retry-lock 5m을 지원하는 것을 확인하고 백업, prune, 검증 명령에 반영했다. 아래 경로와 snapshot 값은 설명을 위한 예시다.

restic --retry-lock 5m check
restic --retry-lock 5m restore "$SNAPSHOT_ID" \
  --target /tmp/restore-example --verify

잠금을 무시하도록 바꾼 것은 아니다. 다른 작업이 짧게 먼저 잡은 잠금을 최대 5분 기다리도록 한 것이다. 그 시간을 넘기거나 오래 남은 잠금이 보이면 어떤 작업이 사용 중인지 다시 확인해야 한다. 검증 대상도 실행할 때마다 달라질 수 있는 latest 대신, 방금 생성한 정확한 snapshot ID로 고정했다.

두 번째 중단은 SQL을 넣기 전이었다

다시 실행했을 때는 저장소 검사와 전체 파일 복원이 통과했다. gzip 무결성도 정상이었다. 그런데 복원용 MySQL이 준비 상태가 되지 않았다. 데이터 디렉터리 초기화는 성공했지만, 서버가 시작되면서 전용 오류 로그 파일을 열지 못했다. 로그에는 Permission denied가 남았다.

이때도 SQL import 실패라고 묶지 않았다. 아직 SQL을 넣기 전이었으므로 백업 없이 임시 MySQL만 시작하는 작은 진단 Job으로 분리했다. 같은 오류가 재현됐고, 이미지의 기본 설정을 읽지 않도록 초기화와 시작 명령 모두에 --no-defaults를 첫 옵션으로 넣자 정상 기동했다.

이 옵션은 다른 옵션보다 앞에 둬야 한다. MySQL의 옵션 파일 처리 문서를 확인하고 명령 순서도 맞췄다. 테스트 서버의 데이터 경로, 소켓, 로그 경로는 Job의 임시 공간으로 직접 지정했다.

임시 서버는 TCP와 MySQL X를 끄고 내부 Unix socket으로만 접근했다. 실제로 MySQL 8.4.11, skip_networking=1, port=0을 확인했다. 복원 검증용 격리 환경의 시작 설정을 고친 것이므로 운영 MySQL 설정을 바꾸지는 않았다.

임시 MySQL의 시작 옵션을 직접 지정했다

다음은 실제 검증 스크립트의 초기화와 시작 구간을 예시 경로로 정리한 것이다. TEST_OS_USER는 격리 컨테이너에서 실행하도록 정한 사용자다. 운영 데이터 디렉터리에서는 실행하지 않으며, 매번 비어 있는 임시 디렉터리를 사용했다.

TEST_DATADIR='/tmp/restore-example/mysql-data'
TEST_SOCKET='/tmp/restore-example/mysql.sock'
TEST_OS_USER="$(id -un)"

mysqld --no-defaults --initialize-insecure \
  --user="$TEST_OS_USER" --datadir="$TEST_DATADIR"

mysqld --no-defaults --user="$TEST_OS_USER" \
  --datadir="$TEST_DATADIR" --skip-networking --mysqlx=OFF \
  --socket="$TEST_SOCKET" \
  --pid-file=/tmp/restore-example/mysql.pid \
  --log-error=/tmp/restore-example/mysql-error.log \
  --skip-log-bin --innodb-buffer-pool-size=128M --max-connections=10 &
TEST_MYSQL_PID=$!

비밀번호 없이 초기화하는 이 설정은 네트워크를 열지 않는 일회성 복원 시험에 한정했다. 서버는 Job 내부 소켓으로만 접속하고, 종료 시 저장한 PID의 프로세스를 정리했다. 준비 상태가 되기 전에는 import를 시작하지 않았고 다음 값을 조회했다.

SELECT @@version, @@skip_networking, @@port;

예상 결과는 8.4.11 / 1 / 0이었다. 초기화 종료 코드가 0이어도 시작 후 ping과 이 조회가 실패하면 SQL 복원 단계로 넘기지 않았다. 이 구분 덕분에 로그 파일 권한 문제를 덤프 손상과 분리할 수 있었다.

덤프 파일이 데이터베이스 선택까지 해 주는지도 확인했다

기존 백업은 애플리케이션 DB 하나를 덤프하는 방식이었다. 파일 안에는 테이블과 데이터가 있었지만 CREATE DATABASEUSE는 없었다. 이런 파일을 데이터베이스 지정 없이 넣는 절차로 작성하면 안 된다.

검증 스크립트는 덤프 형식을 확인한 뒤 처리하도록 수정했다. DB 생성과 선택 구문이 함께 있는 파일은 그 구문을 사용하고, 둘 다 없는 단일 DB 덤프는 임시 DB를 만들어 import 명령에서 명시적으로 선택했다. 어느 한쪽만 있는 예상 밖 형식은 중단하도록 했다. 다음은 운영 이름을 사용하지 않은 예시다.

CREATE DATABASE recovery_example CHARACTER SET utf8mb4;
mysql --protocol=SOCKET \
  --socket=/tmp/restore-example/mysql.sock \
  --user=root recovery_example < restored.sql

gzip 검사 외에 mysqldump 완료 구문도 확인했다. 파일의 끝까지 생성됐는지 한 번 더 보는 용도였고, 실제 데이터 사용 가능 여부는 그다음 SQL import와 조회로 확인했다.

파일 검사와 DB 조회의 통과 기준

아래는 선택한 snapshot에서 SQL 파일 하나를 복원한 뒤 적용한 검사다. 예시 파일명만 사용했고, 검사 로그에는 SQL 내용이나 게시물 본문을 출력하지 않았다.

SQL_GZIP='/tmp/restore-example/dump.sql.gz'
SQL_FILE='/tmp/restore-example/dump.sql'
gzip -t "$SQL_GZIP"
gzip -dc "$SQL_GZIP" > "$SQL_FILE"
tail -n 5 "$SQL_FILE" | grep -q '^-- Dump completed on '

# 두 구문의 존재 여부를 확인해 import 방식을 선택한다.
grep -q '^CREATE DATABASE ' "$SQL_FILE"
grep -q '^USE ' "$SQL_FILE"

마지막 두 조회에서 구문이 발견되지 않아도 이 덤프 형식에서는 정상이다. 실제 스크립트에서는 각각의 결과를 조건문으로 받아, 둘 다 없을 때 임시 DB를 선택하는 분기로 넘겼다. 단일 DB 덤프에 무조건 mysql < dump.sql만 적용하지 않은 이유다.

SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE();

SELECT type, status, COUNT(*) AS objects
FROM posts
GROUP BY type, status;

SELECT COUNT(*) FROM settings;

실제 검증기는 테이블, 게시물, 공개 상태의 레코드, 회원, 설정의 개수를 집계했다. 위 SQL의 type별 묶음은 글과 페이지를 구분해 같은 수치를 읽기 쉽게 확인하는 예시다. 애플리케이션 DB가 정확히 하나 복원됐는지 먼저 확인했고, 예상한 테이블과 공개 데이터 개수를 함께 대조했다.

콘텐츠 파일 수와 용량도 기록하고, 보존해야 할 테마 스크립트는 sha256sum으로 기존 값과 비교했다. restic check는 저장소 구조를 확인하는 단계이고, 선택한 백업 파일의 실제 복원은 restore --verify로 확인했다. 이 둘 중 하나만 성공한 상태에서 전체 검증 성공 로그를 내보내지 않도록 했다.

마지막 실행에서 확인한 것

수정한 검증 Job은 별도 ARM64 노드에서 성공했다. 저장소 구조 검사, 선택한 snapshot 전체 복원과 --verify, gzip 검사, MySQL 8.4.11 import를 모두 통과했다. 복원된 DB는 97개 테이블이었고, 공개 글 59개와 공개 페이지 1개를 포함한 게시물 레코드 63개를 확인했다. 콘텐츠는 401개 파일, 약 127MB였으며 기존 마우스 효과 스크립트의 SHA256도 일치했다.

restic의 snapshot 복원은 파일을 되돌리는 단계다. 이번에는 그 뒤에 DB import를 붙여, 내려받은 SQL을 실제 서버가 읽을 수 있는지까지 확인했다. 전체 역사 백업의 모든 데이터를 검사한 것은 아니고, 선택한 snapshot에 대한 결과다.

또한 이 Job에서 복원한 Ghost 애플리케이션을 새로 띄우지는 않았다. 운영 전환 때 수행한 애플리케이션 검증과 별도 증거로 남겼다. 서비스가 쓰기를 받는 동안 생성한 일반 백업은 DB와 업로드 파일의 시점이 완전히 일치한다고 보장할 수 없어, 전환 직전에는 쓰기를 멈춘 DB와 콘텐츠의 짝을 따로 보관했다.

백업 생성 성공, 파일 복원 성공, SQL import 성공을 각각 기록하도록 바꿨다. 이후 검증이 다시 멈추더라도 어느 단계부터 확인해야 하는지 로그에서 바로 찾을 수 있게 됐다.

SEARCH JOURNAL

기록 검색

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

검색을 준비하고 있어요.

검색어를 입력해 주세요.

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