WordPress WP-Cron을 systemd로 바꾸고 Cloudflare R2에 백업하기: 복구 파일 검증까지

홈서버의 예약 작업과 로컬·클라우드 백업을 표현한 대표 이미지

작성자

카테고리:

3편까지 끝내자 사이트는 밖에서 열렸다. 하지만 사람이 접속하지 않는 시간에도 예약 작업이 제때 실행되는지, VM이 사라졌을 때 백업을 다시 내려받을 수 있는지는 별개의 문제였다.

이번에는 사이트를 더 공개하는 작업이 아니다. 사람이 보고 있지 않을 때도 WordPress의 예약 작업을 확인하게 만들고, 고장 뒤에 사용할 복구 재료를 남기는 작업이다. R2에 업로드 완료가 보였다는 이유만으로 끝내지 않고, 같은 세트를 다시 내려받아 체크섬과 압축 파일을 읽을 수 있는 데까지 확인했다.

systemd 타이머와 암호화된 Cloudflare R2 백업 구성

그림 1. WP-Cron은 5분마다, 로컬 복구 세트는 6시간마다 실행한다. R2에는 하루 최대 한 세트를 클라이언트 측에서 암호화해 보낸다.

이번 글을 끝내면 다음 상태가 된다.

  • 방문자 요청과 무관하게 WordPress 예약 작업을 5분마다 확인한다.
  • 6시간마다 MariaDB와 WordPress 파일을 하나의 UTC 시각 이름을 가진 폴더에 묶는다.
  • 작성 중인 백업과 완성된 백업을 구분하고 SHA-256을 생성 직후 검사한다.
  • .env, DB 비밀번호, Tunnel 토큰, R2 설정은 백업 압축에서 제외한다.
  • 하루 최대 한 세트를 rclone crypt로 암호화해 비공개 R2 버킷에 보낸다.
  • R2 사본을 다시 내려받아 SHA-256, gzip, tar 읽기 검사를 한다.
  • 최초 R2 왕복 검증 뒤 로컬 완성 세트의 14일 보존 정책을 활성화한다.

진행 순서는 WP-Cron 수동 확인 → 로컬 복구 세트 → R2 왕복 검증 → systemd 자동 운영이다. 앞 단계의 성공 문구를 확인하지 못했다면 다음 단계로 넘어가지 않는다.

여기서 확인한 것은 복구 파일의 무결성과 R2 왕복·복호화 경로다. 빈 Ubuntu VM에 WordPress 파일을 풀고 새 MariaDB에 SQL을 가져와 사이트를 실제로 여는 전체 복원 시험은 아직 하지 않았다. 따라서 이 글에서는 전체 복원 성공이라고 부르지 않는다.

이 글은 2026년 9월 1일의 Ubuntu Server 24.04 ARM64, WordPress 7.1.0, MariaDB 11.4.13, rclone 1.75.0과 실제 홈서버 구성을 기준으로 한다. 버전과 Cloudflare 메뉴는 나중에 달라질 수 있다.

시작 전에 확인할 조건

이 글은 3편을 끝낸 상태에서 이어진다.

  • /opt/ttakdap에 3편의 .env, compose.yaml, config, secrets가 있다.
  • db, wordpress, cloudflared가 정상 실행 중이다.
  • Mac에서 Ubuntu로 SSH 키 접속이 된다.
  • Ubuntu VM의 시간대가 Asia/Seoul이고 시간이 동기화돼 있다.
  • VM 디스크에 첫 전체 WordPress 파일 압축본을 둘 여유가 있다.
  • Cloudflare 계정에서 R2를 사용할 수 있다.
  • 긴 무작위 복구 암호를 VM 밖 비밀번호 관리자에 보관할 준비가 됐다.

백업 중에는 플러그인·테마 설치, 대규모 미디어 업로드, DB 구조 변경을 같이 하지 않는다. DB와 파일을 차례로 복사하기 때문에 작업 중 사이트 내용이 크게 바뀌면 두 사본의 시점이 어긋날 수 있다.

중요한 업데이트 직전에는 글 작성과 업로드를 잠시 멈추고 수동 백업을 한 번 만드는 편이 안전하다.

명령을 입력하는 화면 확인하기

글의 표시명령을 입력하는 화면하는 일
Mac에서 실행macOS 터미널Ubuntu SSH 접속과 외부 확인
SSH로 Ubuntu에서 실행Mac 터미널에서 Ubuntu에 접속한 화면Docker, systemd, 백업, rclone
Cloudflare 화면에서 설정웹브라우저의 Cloudflare 대시보드R2 버킷과 전용 API 자격증명

이 글의 설치 명령은 대부분 SSH로 들어간 Ubuntu 화면에서 실행한다. 위치가 헷갈리면 다음 두 줄을 먼저 입력한다.

uname -s
pwd

Linux가 보여야 한다. R2 Secret Access Key와 복구 암호를 입력할 때는 화면에 글자나 별표가 나타나지 않는다. 정상 동작이며, 값을 채팅이나 명령 끝에 붙이면 안 된다.

0. 컨테이너·시간·디스크 상태부터 확인하기

자동화를 만들기 전에 현재 사이트가 정상인지 확인한다. 이미 고장 난 WordPress 위에 타이머를 올리면 실패 원인을 나누기 어렵다.

[SSH로 Ubuntu에서 실행]

timedatectl

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  ps

df -h /
sudo docker system df

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  exec -T wordpress \
  du -sh /var/www/html

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  exec -T db \
  du -sh /var/lib/mysql

확인할 내용은 다음과 같다.

  • Time zone이 Asia/Seoul이다.
  • System clock synchronized가 yes다.
  • db와 wordpress는 healthy, cloudflared는 계속 실행 중이다.
  • 루트 디스크가 이미 80% 이상 찬 상태가 아니다.
  • 남은 공간이 WordPress 파일과 MariaDB 디렉터리의 현재 크기를 합친 값보다 충분히 크다.

백업 파일은 WordPress 전체 파일을 매번 압축하므로 미디어가 늘수록 빠르게 커진다. 6시간 × 14일 = 약 56세트라는 개수만 보고 공간이 충분하다고 판단하지 않는다.

Parallels에서는 Ubuntu를 정상 종료한 뒤 3편 공개 완료 같은 이름으로 스냅샷을 하나 만들어 두면 좋다. 만든 뒤 Ubuntu를 다시 켜고 SSH로 재접속한다. 이 스냅샷은 이번 설정 변경을 되돌리는 용도이지, 같은 Mac 저장장치에 있는 장기 백업을 대신하지는 않는다.

1. WP-Cron이 하는 일부터 구분하기

WordPress의 기본 WP-Cron은 계속 실행되는 데몬이 아니다. 일반 페이지 요청이 들어올 때 실행할 예약 작업이 있는지 확인한다. 방문이 적은 사이트에서는 예약 글, 업데이트 확인, 플러그인의 정리 작업이 예정 시각보다 늦어질 수 있다.

목적은 성능 개선보다 예약 작업의 실행 시각을 방문량과 분리하는 데 있다. systemd가 방문량과 상관없이 일정한 간격으로 wp-cron.php를 직접 호출하게 만드는 것이다.

이번 구성은 공개 인터넷의 /wp-cron.php URL을 거치지 않는다. Ubuntu가 WordPress 컨테이너 안에서 PHP 파일을 직접 실행한다.

Ubuntu systemd
  → Docker의 wordpress 컨테이너
  → www-data 사용자
  → php /var/www/html/wp-cron.php

5분 타이머는 예약 작업을 초 단위 정시에 실행한다는 뜻이 아니다. 다음 호출에서 마감된 작업을 처리하므로 최대 약 5분과 systemd의 짧은 정확도 여유만큼 늦을 수 있다.

2. 자동화 전에 WP-Cron을 한 번 직접 실행하기

먼저 컨테이너 안에서 같은 명령이 성공하는지 본다. 이 단계가 실패하면 timer 파일을 만들지 않는다.

[SSH로 Ubuntu에서 실행]

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  exec -T -u www-data wordpress \
  php /var/www/html/wp-cron.php &&
echo "WP-Cron 직접 실행 성공"

실행할 예약 작업이 없으면 PHP 출력이 전혀 없을 수 있다. 마지막의 WP-Cron 직접 실행 성공과 종료코드가 완료 기준이다.

service "wordpress" is not running, DB 연결 오류, PHP 오류가 보이면 여기서 멈춘다. timer는 실패한 명령을 정상으로 바꿔 주지 않는다.

3. WP-Cron 실행 스크립트와 systemd 서비스·타이머 만들기

먼저 systemd가 실행할 작은 스크립트를 만든다. 이 파일이 하는 일은 /opt/ttakdap의 Compose를 읽고, WordPress 컨테이너에서 www-data 권한으로 wp-cron.php를 실행하는 것뿐이다.

[SSH로 Ubuntu에서 실행]

sudo install -d -m 0755 -o root -g root \
  /opt/ttakdap/scripts

sudo tee /opt/ttakdap/scripts/wp-cron.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail

if [[ ${EUID} -ne 0 ]]; then
  echo "root 권한으로 실행해야 합니다." >&2
  exit 1
fi

docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  exec -T -u www-data wordpress \
  php /var/www/html/wp-cron.php
EOF

sudo chmod 0755 /opt/ttakdap/scripts/wp-cron.sh
sudo chown root:root /opt/ttakdap/scripts/wp-cron.sh
sudo bash -n /opt/ttakdap/scripts/wp-cron.sh

bash -n은 파일을 실행하지 않고 셸 문법만 확인한다.

이제 service와 timer를 만든다.

[SSH로 Ubuntu에서 실행]

sudo tee /etc/systemd/system/ttakdap-wp-cron.service >/dev/null <<'EOF'
[Unit]
Description=Run due ttakdap WordPress cron events
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
User=root
Group=root
ExecStart=/opt/ttakdap/scripts/wp-cron.sh
TimeoutStartSec=4m
EOF

sudo tee /etc/systemd/system/ttakdap-wp-cron.timer >/dev/null <<'EOF'
[Unit]
Description=Run ttakdap WordPress cron every five minutes

[Timer]
OnBootSec=5m
OnUnitActiveSec=5m
AccuracySec=30s
Unit=ttakdap-wp-cron.service

[Install]
WantedBy=timers.target
EOF

sudo chmod 0644 \
  /etc/systemd/system/ttakdap-wp-cron.service \
  /etc/systemd/system/ttakdap-wp-cron.timer

sudo systemd-analyze verify \
  /etc/systemd/system/ttakdap-wp-cron.service \
  /etc/systemd/system/ttakdap-wp-cron.timer

sudo systemctl daemon-reload
sudo systemctl start ttakdap-wp-cron.service

systemctl show ttakdap-wp-cron.service \
  -p Result \
  -p ExecMainStatus \
  -p ExecMainStartTimestamp \
  -p ExecMainExitTimestamp

service 실행 성공 기준은 Result=success, ExecMainStatus=0이다. 개별 플러그인의 예약 작업 결과까지 모두 검증했다는 뜻은 아니다. Type=oneshot 서비스는 일을 마치면 실행 중 상태를 유지하지 않으므로 inactive (dead)로 보여도 실패가 아니다.

오류가 있으면 다음 로그를 본다.

sudo journalctl \
  -u ttakdap-wp-cron.service \
  -n 50 \
  --no-pager

아직 timer를 자동 시작하지 않은 이유는 다음 단계에서 기본 WP-Cron 호출을 끄기 전 마지막 검사를 하기 위해서다.

4. systemd 성공을 확인한 뒤 기본 WP-Cron 호출 끄기

WordPress 공식 안내는 시스템 스케줄러를 만든 뒤 DISABLE_WP_CRON을 켜서 페이지 요청에 의한 중복 호출을 멈추도록 설명한다. 순서를 반대로 하면 timer가 잘못됐을 때 예약 작업이 전부 멈출 수 있다.

이 시리즈에서 사용한 WordPress 공식 이미지는 wp-config.php 안에서 WORDPRESS_CONFIG_EXTRA 환경값을 읽는다. 먼저 해당 연결이 남아 있는지 확인한다.

[SSH로 Ubuntu에서 실행]

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  exec -T wordpress \
  grep -n 'WORDPRESS_CONFIG_EXTRA' /var/www/html/wp-config.php

한 줄 이상 보이면 다음 Compose 수정이 현재 볼륨에도 적용된다. 아무 줄도 보이지 않으면 wp-config.php를 수동으로 교체한 환경일 수 있다. 이 경우 WP-Cron timer는 아직 켜지 말고 5~8절의 첫 로컬 백업까지 진행한 뒤, 18절의 기존 wp-config.php에서 WORDPRESS_CONFIG_EXTRA가 안 보인다 절차를 실행한다. 그 다음 이 절의 PHP 확인과 timer 활성화로 돌아온다.

3편 Compose의 strict SITE_URL과 나머지 설정은 그대로 두고 WP_AUTO_UPDATE_CORE 다음에 보호된 설정 세 줄만 추가한다. 같은 줄이 이미 있으면 다시 넣지 않고, 기준 줄이 정확히 하나가 아닐 때도 자동 수정을 중단한다.

[SSH로 Ubuntu에서 실행]

COMPOSE_FILE=/opt/ttakdap/compose.yaml
ANCHOR="        define( 'WP_AUTO_UPDATE_CORE', 'minor' );"

if sudo grep -Fq "DISABLE_WP_CRON" "$COMPOSE_FILE"; then
  echo "DISABLE_WP_CRON 설정이 이미 있습니다."
elif [[ "$(sudo grep -Fc "$ANCHOR" "$COMPOSE_FILE")" -ne 1 ]]; then
  echo "기준 줄을 정확히 하나 찾지 못해 수정하지 않았습니다." >&2
  false
else
  if ! sudo test -e /opt/ttakdap/compose.before-wp-cron.yaml; then
    sudo cp -a \
      "$COMPOSE_FILE" \
      /opt/ttakdap/compose.before-wp-cron.yaml
  fi

  COMPOSE_TMP="$(mktemp)"
  trap 'rm -f -- "$COMPOSE_TMP"' EXIT HUP INT TERM

  sudo awk -v anchor="$ANCHOR" '
    { print }
    $0 == anchor {
      print "        if ( ! defined( '\''DISABLE_WP_CRON'\'' ) ) {"
      print "          define( '\''DISABLE_WP_CRON'\'', true );"
      print "        }"
    }
  ' "$COMPOSE_FILE" > "$COMPOSE_TMP"

  sudo docker compose \
    --project-directory /opt/ttakdap \
    --env-file /opt/ttakdap/.env \
    -f "$COMPOSE_TMP" \
    --profile tunnel \
    config --quiet

  sudo install -m 0640 -o root -g root \
    "$COMPOSE_TMP" \
    "$COMPOSE_FILE"

  rm -f -- "$COMPOSE_TMP"
  trap - EXIT HUP INT TERM
fi

unset COMPOSE_FILE ANCHOR COMPOSE_TMP

수정 결과의 앞뒤 문맥을 확인한다. DB 비밀번호나 Tunnel 토큰은 이 파일에 없으므로 출력하지 않는다.

sudo grep -n -B 2 -A 4 \
  "DISABLE_WP_CRON" \
  /opt/ttakdap/compose.yaml

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  config --quiet

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  up -d --wait wordpress

WordPress가 다시 healthy가 되면 실제 PHP 설정도 확인한다.

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  exec -T -u www-data wordpress \
  php -r '
    require "/var/www/html/wp-load.php";
    echo defined("DISABLE_WP_CRON") && DISABLE_WP_CRON
      ? "DISABLE_WP_CRON=true\n"
      : "DISABLE_WP_CRON=false\n";
  '

DISABLE_WP_CRON=true가 보여야 한다. 이 값은 페이지 요청이 자동으로 Cron을 시작하는 동작을 끄는 것이지, wp-cron.php 파일을 삭제하거나 외부 URL 접근을 차단하는 설정은 아니다.

Mac 브라우저에서도 공개 홈페이지가 그대로 열리고 /wp-admin/은 3편에서 만든 Cloudflare Access 인증 뒤 로그인 화면으로 이어지는지 확인한다. 둘 중 하나라도 달라졌다면 timer를 켜기 전에 Compose 변경과 WordPress 로그부터 확인한다.

이제 timer를 활성화한다.

sudo systemctl enable --now ttakdap-wp-cron.timer

systemctl list-timers \
  ttakdap-wp-cron.timer \
  --all \
  --no-pager

출력에 ttakdap-wp-cron.timer 한 줄, NEXT 시각, 연결된 ttakdap-wp-cron.service가 보여야 한다.

OnBootSec=5m은 부팅 5분 뒤 첫 실행, OnUnitActiveSec=5m은 service가 최근 활성화된 시점부터 5분 간격으로 호출한다. 이미 부팅 5분이 지난 뒤 timer를 처음 켜면 첫 실행이 바로 잡힐 수도 있다. 서버가 꺼져 있던 동안의 5분 간격을 여러 번 재생하지는 않는다. WordPress 큐에 남은 마감 작업은 부팅 뒤 첫 wp-cron.php 실행에서 처리한다.

5. 복구 세트에 무엇을 넣을지 먼저 정하기

백업 스크립트를 보기 전에 결과물부터 구분하면 긴 명령의 목적이 보인다.

WordPress 로컬 복구 세트에 포함되는 파일과 제외되는 비밀값

그림 2. DB, WordPress 파일, 배포 정보와 체크섬은 같은 UTC 시각표에 둔다. 재발급하거나 따로 보관해야 하는 자격증명은 압축에서 제외한다.

파일용도
database.sql.gzMariaDB의 WordPress DB 논리 덤프
wordpress-files.tar.gz/var/www/html의 WordPress 전체 파일
deployment-config.tar.gz.env, .git, secrets를 제외한 배포 설정
compose-resolved.yaml당시 Compose가 해석한 서비스 구조
compose-images.json사용 중인 컨테이너 이미지 목록
manifest.txt생성 시각, 사이트 주소, 기준 이미지 버전
SHA256SUMS위 파일이 생성 뒤 바뀌지 않았는지 확인하는 값

DB 비밀번호, Tunnel 토큰, R2 Access Key와 crypt 복구 암호를 같은 압축에 다시 넣지 않는다. DB와 Tunnel 자격증명은 장애 뒤 재발급하고, crypt 복구 암호는 VM 밖의 비밀번호 관리자에서 가져오는 구조다.

스크립트는 먼저 .partial-UTC시각 디렉터리에서 파일을 만든다. 모든 SHA-256이 통과해야 점이 없는 UTC시각 디렉터리로 이름을 바꾼다. .partial-*는 완성된 백업이 아니다.

6. 첫 백업 전에는 자동 삭제를 끄기

R2에서 다시 내려받는 시험이 끝나기 전에는 오래된 로컬 세트를 자동 삭제하지 않는다. 현재는 LOCAL_BACKUP_RETENTION_DAYS=0으로 시작한다.

[SSH로 Ubuntu에서 실행]

bash <<'SCRIPT'
set -Eeuo pipefail

sudo install -d -m 0700 -o root -g root \
  /etc/ttakdap \
  /var/backups/ttakdap

if sudo test -e /etc/ttakdap/backup.env; then
  echo "기존 /etc/ttakdap/backup.env가 있어 덮어쓰지 않았습니다." >&2
  echo "이전 설치가 있다면 내용을 확인한 뒤 다음 단계로 진행하세요." >&2
  exit 1
fi

sudo tee /etc/ttakdap/backup.env >/dev/null <<'EOF'
RCLONE_DEST=
RCLONE_CONFIG=/etc/ttakdap/rclone.conf
OFFSITE_UPLOAD_INTERVAL_HOURS=24
LOCAL_BACKUP_RETENTION_DAYS=0
EOF

sudo chmod 0600 /etc/ttakdap/backup.env
sudo chown root:root /etc/ttakdap/backup.env

sudo stat -c '%U:%G %a %n' \
  /etc/ttakdap \
  /etc/ttakdap/backup.env \
  /var/backups/ttakdap
SCRIPT

정상 권한은 디렉터리 root:root 700, 환경 파일 root:root 600이다. backup.env에는 아직 R2 키가 없지만, root 전용 운영 파일로 처음부터 제한한다.

7. 백업 스크립트 전체를 만들기

아래 스크립트는 한 번 실행할 때 다음 순서로 움직인다.

  1. 동시에 두 백업이 실행되지 않도록 잠금 파일을 잡는다.
  2. .partial-UTC시각 디렉터리에 MariaDB 덤프와 WordPress 파일을 만든다.
  3. 비밀값을 제외한 배포 설정과 컨테이너 이미지 정보를 기록한다.
  4. 각 파일의 SHA-256을 만들고 즉시 다시 검사한다.
  5. 모두 통과했을 때만 점이 없는 최종 디렉터리로 이름을 바꾼다.
  6. R2가 설정돼 있고 최근 24시간 안에 성공한 전송이 없으면 암호화해 올린다.
  7. 설정한 보존 기간이 0보다 크고 검증된 R2 목적지가 있을 때만 오래된 완성 세트를 정리한다. 설정 파일을 읽지 못해도 기본값은 0이라 자동 삭제하지 않는다.

DB 덤프의 --single-transaction은 주로 WordPress가 쓰는 InnoDB 테이블을 한 시점처럼 읽고, --quick은 큰 테이블 전체를 메모리에 올리지 않고 차례로 처리한다. 다만 MyISAM 같은 비트랜잭션 테이블과 덤프 중의 ALTER, CREATE, DROP 같은 구조 변경에는 같은 일관성을 보장하지 않는다. WordPress 파일 압축도 DB 덤프 다음에 실행되므로 전체 VM을 순간 촬영한 스냅샷은 아니다.

긴 코드지만 숨겨진 설치 파일을 내려받는 방식이 아니다. 아래 내용 전체가 실제로 실행될 파일이다.

[SSH로 Ubuntu에서 실행]

sudo tee /opt/ttakdap/scripts/backup.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail

if [[ ${EUID} -ne 0 ]]; then
  echo "Run this script as root: sudo $0" >&2
  exit 1
fi

stack_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
backup_root="${BACKUP_ROOT:-/var/backups/ttakdap}"

if [[ -r /etc/ttakdap/backup.env ]]; then
  # shellcheck disable=SC1091
  source /etc/ttakdap/backup.env
fi

local_backup_retention_days="${LOCAL_BACKUP_RETENTION_DAYS:-0}"
if [[ ! ${local_backup_retention_days} =~ ^[0-9]+$ ]]; then
  echo "LOCAL_BACKUP_RETENTION_DAYS must be a non-negative integer." >&2
  exit 1
fi

offsite_upload_interval_hours="${OFFSITE_UPLOAD_INTERVAL_HOURS:-24}"
if [[ ! ${offsite_upload_interval_hours} =~ ^[0-9]+$ ]]; then
  echo "OFFSITE_UPLOAD_INTERVAL_HOURS must be a non-negative integer." >&2
  exit 1
fi

timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
partial_dir="${backup_root}/.partial-${timestamp}"
final_dir="${backup_root}/${timestamp}"

install -m 0700 -d "${backup_root}"
# Prevent a manual run and a timer run from overlapping.
exec 9>"${backup_root}/.backup.lock"
if ! flock -n 9; then
  echo "Another backup is already running." >&2
  exit 1
fi

if [[ -e ${partial_dir} || -e ${final_dir} ]]; then
  echo "Backup destination already exists for ${timestamp}." >&2
  exit 1
fi

install -m 0700 -d "${partial_dir}"
cd "${stack_dir}"

# Create the compressed MariaDB logical dump.
echo "Creating consistent MariaDB logical dump..."
docker compose exec -T db sh -ec '
  option_file="$(mktemp)"
  trap '\''rm -f "${option_file}"'\'' EXIT HUP INT TERM
  printf "[client]\npassword=%s\n" "$(cat /run/secrets/db_root_password)" > "${option_file}"
  chmod 0600 "${option_file}"
  mariadb-dump \
    --defaults-extra-file="${option_file}" \
    --single-transaction \
    --quick \
    --routines \
    --events \
    --triggers \
    --hex-blob \
    --default-character-set=utf8mb4 \
    --user=root \
    "${MARIADB_DATABASE}"
' | gzip -9 > "${partial_dir}/database.sql.gz"

# Archive the WordPress volume through the running container.
echo "Archiving WordPress files..."
docker compose exec -T wordpress \
  tar --numeric-owner -C /var/www/html -czf - . \
  > "${partial_dir}/wordpress-files.tar.gz"

# Keep deployment metadata, but leave credentials out.
echo "Archiving non-secret deployment configuration..."
tar \
  --exclude='./.env' \
  --exclude='./.git' \
  --exclude='./secrets' \
  -czf "${partial_dir}/deployment-config.tar.gz" \
  .

docker compose --profile tunnel config \
  > "${partial_dir}/compose-resolved.yaml"
docker compose --profile tunnel images --format json \
  > "${partial_dir}/compose-images.json"

{
  printf 'created_utc=%s\n' "${timestamp}"
  printf 'site_url=%s\n' "$(sed -n 's/^SITE_URL=//p' .env | tail -n 1)"
  printf 'wordpress_image=%s\n' 'wordpress:7.1.0-php8.4-apache'
  printf 'mariadb_image=%s\n' 'mariadb:11.4.13'
  printf 'cloudflared_image=%s\n' 'cloudflare/cloudflared:2026.8.2'
} > "${partial_dir}/manifest.txt"

# Record and immediately verify checksums before finalizing the set.
(
  cd "${partial_dir}"
  sha256sum \
    database.sql.gz \
    wordpress-files.tar.gz \
    deployment-config.tar.gz \
    compose-resolved.yaml \
    compose-images.json \
    manifest.txt \
    > SHA256SUMS
  sha256sum --check SHA256SUMS
)

mv "${partial_dir}" "${final_dir}"
echo "Local backup completed: ${final_dir}"

offsite_ready_for_pruning=0

# Upload at the configured interval when an encrypted remote exists.
if [[ -n ${RCLONE_DEST:-} ]]; then
  if ! command -v rclone >/dev/null 2>&1; then
    echo "RCLONE_DEST is set, but rclone is not installed." >&2
    exit 1
  fi

  if [[ -n ${RCLONE_CONFIG:-} && ! -r ${RCLONE_CONFIG} ]]; then
    echo "RCLONE_CONFIG is set, but the file is not readable." >&2
    exit 1
  fi

  offsite_state_dir=/var/lib/ttakdap
  offsite_marker="${offsite_state_dir}/last-offsite-upload"
  install -m 0700 -d "${offsite_state_dir}"

  should_upload=1
  if [[ ${FORCE_OFFSITE_UPLOAD:-0} != 1 \
    && ${offsite_upload_interval_hours} != 0 \
    && -f ${offsite_marker} ]]; then
    now_epoch=$(date +%s)
    last_upload_epoch=$(stat -c %Y "${offsite_marker}")
    minimum_interval_seconds=$((offsite_upload_interval_hours * 3600))
    if (( now_epoch - last_upload_epoch < minimum_interval_seconds )); then
      should_upload=0
    fi
  fi

  if (( should_upload == 1 )); then
    if [[ -n ${RCLONE_CONFIG:-} ]]; then
      export RCLONE_CONFIG
    fi
    remote_dir="${RCLONE_DEST%/}/${timestamp}"
    rclone copy "${final_dir}" "${remote_dir}" --immutable
    rclone cryptcheck "${final_dir}" "${remote_dir}" --one-way
    touch "${offsite_marker}"
    offsite_ready_for_pruning=1
    echo "Encrypted off-site backup verified: ${remote_dir}"
  else
    offsite_ready_for_pruning=1
    echo "Off-site upload skipped; the last verified upload is newer than ${offsite_upload_interval_hours} hour(s)."
  fi
else
  echo "RCLONE_DEST is not configured; this backup exists only inside the VM." >&2
fi

# Prune only completed timestamp directories when explicitly enabled.
if [[ ${local_backup_retention_days} == 0 ]]; then
  echo "Local backup pruning is disabled (LOCAL_BACKUP_RETENTION_DAYS=0)."
elif (( offsite_ready_for_pruning != 1 )); then
  echo "Local backup pruning skipped; no verified off-site destination is available." >&2
else
  cutoff_timestamp="$(date -u --date="${local_backup_retention_days} days ago" +%Y%m%dT%H%M%SZ)"
  pruned_count=0

  while IFS= read -r -d '' backup_dir; do
    backup_name="${backup_dir##*/}"
    if [[ ${backup_name} =~ ^[0-9]{8}T[0-9]{6}Z$ \
      && ${backup_name} < ${cutoff_timestamp} \
      && ${backup_dir} != "${final_dir}" \
      && -f ${backup_dir}/SHA256SUMS \
      && -f ${backup_dir}/manifest.txt ]]; then
      rm -rf -- "${backup_dir}"
      echo "Pruned expired local backup: ${backup_dir}"
      ((pruned_count += 1))
    fi
  done < <(find "${backup_root}" -mindepth 1 -maxdepth 1 -type d -print0)

  echo "Local backup pruning completed: removed ${pruned_count} set(s) older than ${local_backup_retention_days} day(s)."
fi
EOF

sudo chmod 0750 /opt/ttakdap/scripts/backup.sh
sudo chown root:root /opt/ttakdap/scripts/backup.sh
sudo bash -n /opt/ttakdap/scripts/backup.sh

마지막 bash -n이 아무 메시지 없이 끝나면 셸 문법은 통과한 것이다. 이 스크립트에는 오래된 로컬 세트를 지우는 코드가 있지만, 지금은 LOCAL_BACKUP_RETENTION_DAYS=0이라 삭제 구간을 실행하지 않는다. 나중에 14일로 바꾸더라도 이름이 정확한 UTC 형식이고 SHA256SUMS와 manifest.txt가 있는 완성 세트만 대상으로 삼는다.

8. R2보다 먼저 로컬 복구 세트 만들기

첫 실행은 일부러 R2 없이 한다. DB 덤프와 파일 압축 자체가 성공하는지 먼저 나누어 보는 편이 오류를 찾기 쉽다.

[SSH로 Ubuntu에서 실행]

sudo /opt/ttakdap/scripts/backup.sh

중간에 RCLONE_DEST is not configured 경고가 보이는 것은 지금 단계에서는 정상이다. 대신 Local backup completed: 뒤에 /var/backups/ttakdap/<UTC시각>Z 형태의 경로가 보여야 한다.

최신 완성 세트를 찾아 파일을 검사한다.

sudo bash <<'EOF'
set -Eeuo pipefail

LATEST_BACKUP="$(
  find /var/backups/ttakdap \
    -mindepth 1 \
    -maxdepth 1 \
    -type d \
    -regextype posix-extended \
    -regex '.*/[0-9]{8}T[0-9]{6}Z' \
    -printf '%f\n' |
  sort |
  tail -n 1
)"

if [[ -z ${LATEST_BACKUP} ]]; then
  echo "완성된 백업 디렉터리를 찾지 못했습니다." >&2
  exit 1
fi

cd "/var/backups/ttakdap/${LATEST_BACKUP}"
sha256sum --check SHA256SUMS
gzip -t database.sql.gz
tar -tzf wordpress-files.tar.gz >/dev/null
tar -tzf deployment-config.tar.gz >/dev/null

printf '검사한 세트: %s\n' "${LATEST_BACKUP}"
du -sh .
EOF

여섯 줄의 OK와 검사한 세트, 용량이 보이면 로컬 생성 검사는 끝났다. gzip -t와 tar -tzf는 성공하면 별도 메시지가 없다. SQL을 새 DB에 가져오거나 WordPress를 실행한 검사는 아직 아니다.

.partial-*만 남았다면 중간 단계에서 실패한 것이다. 그 디렉터리를 완성본으로 간주하지 말고, 화면에 나온 첫 오류부터 해결한다.

9. Cloudflare R2 버킷과 전용 키 만들기

이 단계만 웹브라우저에서 한다. 2026년 9월 1일 기준 Cloudflare 대시보드 메뉴 이름은 다음과 같다.

[Cloudflare 화면에서 설정]

  1. Storage & databases → R2 → Overview로 이동한다.
  2. Create bucket을 눌러 백업 전용 버킷을 만든다.
  3. 공개 접근은 켜지 않는다. 사용자 지정 도메인도 필요 없다.
  4. R2 Overview의 Account Details에서 API Tokens 옆 Manage로 들어간다.
  5. Create Account API token을 선택한다.
  6. 권한은 Object Read & Write, 범위는 Apply to specific buckets only로 정한다.
  7. 방금 만든 백업 버킷 하나만 선택해 토큰을 만든다.

Create Account API token 버튼이 보이지 않으면 계정 권한을 확인한다. Account API token 생성과 열람에는 Super Administrator 권한이 필요하다.

생성 화면에서 다음 네 값을 VM 밖 비밀번호 관리자에 저장한다.

  • Account ID
  • Access Key ID
  • Secret Access Key
  • S3 API endpoint

Secret Access Key는 생성 직후 한 번만 보일 수 있다. 이 화면은 캡처하지 않았고, 이 글의 예시에도 실제 값을 넣지 않았다. 기본 endpoint는 보통 다음 모양이다.

https://<ACCOUNT_ID>.r2.cloudflarestorage.com

EU·US·FedRAMP jurisdiction 버킷은 주소 모양이 다를 수 있다. 다음 설정에서는 Account ID를 조합하지 말고 Cloudflare 화면에 표시된 전체 S3 endpoint를 그대로 입력한다.

10. 검증한 rclone 패키지 설치하기

Ubuntu 24.04 ARM64에서 실제 확인한 rclone 1.75.0 공식 패키지를 설치한다. curl | sudo bash처럼 내려받은 내용을 바로 실행하지 않고, 파일의 SHA-256이 공식 배포값과 같은지 먼저 본다.

[SSH로 Ubuntu에서 실행]

dpkg --print-architecture

결과가 arm64일 때만 아래 블록을 사용한다. amd64가 보이면 이 패키지가 맞지 않으므로 중단해야 한다.

bash <<'SCRIPT'
set -Eeuo pipefail

if [[ $(dpkg --print-architecture) != arm64 ]]; then
  echo "이 설치 블록은 Ubuntu ARM64 전용입니다." >&2
  exit 1
fi

RCLONE_TMP="$(mktemp -d)"
trap 'rm -rf -- "${RCLONE_TMP}"' EXIT HUP INT TERM

curl --fail --location --show-error \
  --output "${RCLONE_TMP}/rclone.deb" \
  https://downloads.rclone.org/v1.75.0/rclone-v1.75.0-linux-arm64.deb

printf '%s  %s\n' \
  '081c605cbf47ade3114e78db2130766a23281a12b79cdd5774b11dad6c6c0931' \
  "${RCLONE_TMP}/rclone.deb" |
sha256sum --check

sudo dpkg --install "${RCLONE_TMP}/rclone.deb"
rclone version
SCRIPT

rclone.deb: OK와 rclone v1.75.0이 보여야 한다. SHA-256이 다르면 설치하지 않고 내려받은 파일을 폐기한다.

11. R2 위에 rclone crypt 원격 저장소 만들기

R2 자체도 서버 측 암호화를 제공하지만 키는 Cloudflare가 관리한다. 여기서는 그 위에 rclone crypt를 얹어 VM에서 파일 내용과 이름을 암호화한 다음 올린다.

아래 블록은 민감한 값을 화면에 보이지 않게 입력받고, 임시 설정으로 버킷 접근을 시험한 뒤 root만 읽을 수 있는 최종 파일을 만든다. 키와 암호를 명령행 인수에 쓰지 않으므로 셸 기록에도 남지 않는다.

[SSH로 Ubuntu에서 실행]

bash <<'SCRIPT'
set -Eeuo pipefail

OLD_UMASK="$(umask)"
umask 077
CONFIG_TMP="$(mktemp)"

cleanup() {
  rm -f -- "${CONFIG_TMP}"
  unset R2_ENDPOINT R2_BUCKET R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY
  unset CRYPT_PASSWORD CRYPT_PASSWORD_CONFIRM CRYPT_PASSWORD_OBSCURED
  umask "${OLD_UMASK}"
}
trap cleanup EXIT HUP INT TERM

read -r -p 'Cloudflare S3 endpoint 전체 주소: ' R2_ENDPOINT </dev/tty
read -r -p 'R2 백업 버킷 이름: ' R2_BUCKET </dev/tty
read -r -s -p 'R2 Access Key ID: ' R2_ACCESS_KEY_ID </dev/tty
printf '\n'
read -r -s -p 'R2 Secret Access Key: ' R2_SECRET_ACCESS_KEY </dev/tty
printf '\n'
read -r -s -p 'crypt 복구 암호: ' CRYPT_PASSWORD </dev/tty
printf '\n'
read -r -s -p 'crypt 복구 암호 다시 입력: ' CRYPT_PASSWORD_CONFIRM </dev/tty
printf '\n'

R2_ENDPOINT="${R2_ENDPOINT%/}"

if [[ ! ${R2_ENDPOINT} =~ ^https://[a-zA-Z0-9.-]+\.r2\.cloudflarestorage\.com$ ]]; then
  echo "Cloudflare 화면의 전체 S3 endpoint를 다시 확인하세요." >&2
  exit 1
fi

if [[ ! ${R2_BUCKET} =~ ^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$ ]]; then
  echo "버킷 이름 형식을 다시 확인하세요." >&2
  exit 1
fi

if [[ -z ${R2_ACCESS_KEY_ID} || -z ${R2_SECRET_ACCESS_KEY} ]]; then
  echo "R2 자격증명은 비워 둘 수 없습니다." >&2
  exit 1
fi

if [[ ${#CRYPT_PASSWORD} -lt 20 ]]; then
  echo "crypt 복구 암호는 20자 이상으로 만드세요." >&2
  exit 1
fi

if [[ ${CRYPT_PASSWORD} != "${CRYPT_PASSWORD_CONFIRM}" ]]; then
  echo "crypt 복구 암호가 서로 다릅니다." >&2
  exit 1
fi

CRYPT_PASSWORD_OBSCURED="$(printf '%s' "${CRYPT_PASSWORD}" | rclone obscure -)"

cat >"${CONFIG_TMP}" <<EOF
[ttakdap-r2]
type = s3
provider = Cloudflare
access_key_id = ${R2_ACCESS_KEY_ID}
secret_access_key = ${R2_SECRET_ACCESS_KEY}
region = auto
endpoint = ${R2_ENDPOINT}
no_check_bucket = true

[ttakdap-r2-crypt]
type = crypt
remote = ttakdap-r2:${R2_BUCKET}
filename_encryption = standard
directory_name_encryption = true
password = ${CRYPT_PASSWORD_OBSCURED}
EOF

chmod 0600 "${CONFIG_TMP}"

access_ready=0
for attempt in {1..12}; do
  if rclone --config "${CONFIG_TMP}" \
    lsf "ttakdap-r2:${R2_BUCKET}" \
    --max-depth 1 >/dev/null 2>&1; then
    access_ready=1
    break
  fi

  if (( attempt < 12 )); then
    sleep 5
  fi
done

if (( access_ready != 1 )); then
  echo "R2 버킷 권한을 확인하지 못했습니다." >&2
  echo "endpoint, 버킷 범위, Object Read & Write 권한을 확인하세요." >&2
  exit 1
fi

sudo install -d -m 0700 -o root -g root /etc/ttakdap
sudo install -m 0600 -o root -g root \
  "${CONFIG_TMP}" \
  /etc/ttakdap/rclone.conf

sudo stat -c '%U:%G %a %n' /etc/ttakdap/rclone.conf
echo 'R2 연결과 root 전용 설정 저장 완료'
SCRIPT

Secret Access Key와 crypt 암호를 입력할 때 화면에 글자나 별표가 안 나오는 것이 정상이다. 입력 뒤 Enter를 누르면 다음 질문으로 넘어간다.

root:root 600 /etc/ttakdap/rclone.conf가 보이면 권한이 맞다. rclone obscure는 crypt 암호를 설정 파일에서 바로 읽기 어렵게 바꾸지만, 비밀번호 관리자처럼 안전한 암호 저장소는 아니다. 설정 파일에는 R2 Secret Access Key도 있으므로 0600 권한이 중요하다.

crypt 복구 암호는 VM 밖 비밀번호 관리자에 별도로 보관한다. VM과 설정 파일을 모두 잃으면 R2 API 키는 다시 만들 수 있어도 이 암호 없이 기존 백업 내용을 복호화할 수 없다.

12. R2 전송을 켜고 첫 암호화 업로드 확인하기

이제 backup.env의 원격 목적지만 채운다. 로컬 자동 삭제는 아직 0일 그대로 둔다.

[SSH로 Ubuntu에서 실행]

bash <<'SCRIPT'
set -Eeuo pipefail

if [[ "$(sudo grep -c '^RCLONE_DEST=$' /etc/ttakdap/backup.env)" -ne 1 ]]; then
  echo "빈 RCLONE_DEST 줄을 정확히 하나 찾지 못했습니다." >&2
  exit 1
else
  sudo sed -i \
    's/^RCLONE_DEST=$/RCLONE_DEST=ttakdap-r2-crypt:/' \
    /etc/ttakdap/backup.env
fi

sudo chmod 0600 /etc/ttakdap/backup.env
sudo chown root:root /etc/ttakdap/backup.env

sudo env FORCE_OFFSITE_UPLOAD=1 \
  /opt/ttakdap/scripts/backup.sh
SCRIPT

처음에는 파일 크기에 따라 시간이 걸린다. 마지막에 Encrypted off-site backup verified:가 보이면 rclone copy와 rclone cryptcheck --one-way가 모두 통과한 것이다. --one-way는 로컬 원본 파일이 원격에 맞게 존재하는지 확인하며 원격에만 있는 추가 객체까지 오류로 보지는 않는다. timestamp별 새 디렉터리에만 복사하는 현재 구조와 맞는다. Cloudflare R2 화면에서 알아보기 어려운 파일명과 디렉터리명이 보이는 것도 crypt가 적용된 정상 모습이다.

여기서 사용한 --immutable은 같은 대상 파일이 같으면 건너뛰고, 기존 파일이 달라졌으면 수정하지 않고 실패하는 옵션이다. Cloudflare Bucket lock처럼 규칙이 유지되는 동안 객체 삭제·덮어쓰기 요청을 차단하는 기능은 아니다.

13. R2에서 다시 내려받아 같은 파일인지 확인하기

업로드 성공 문구만으로는 읽기 경로를 확인할 수 없다. 최신 세트를 crypt 원격 저장소에서 새 임시 디렉터리로 내려받고, 로컬 원본과 체크섬 파일이 같은지 비교한 뒤 압축 파일까지 연다.

로컬 생성부터 R2 재다운로드까지의 검증 단계

그림 3. 이번 편은 6단계의 R2 왕복 검사까지 진행한다. 빈 VM이나 격리 스택에서의 실제 전체 복원은 별도 단계로 남긴다.

[SSH로 Ubuntu에서 실행]

sudo bash <<'EOF'
set -Eeuo pipefail

LATEST_BACKUP="$(
  rclone \
    --config /etc/ttakdap/rclone.conf \
    lsf ttakdap-r2-crypt: \
    --dirs-only \
    --max-depth 1 |
  sed 's:/$::' |
  awk '/^[0-9]{8}T[0-9]{6}Z$/ { print }' |
  sort |
  tail -n 1
)"

if [[ -z ${LATEST_BACKUP} ]]; then
  echo "R2에서 완성된 백업 디렉터리를 찾지 못했습니다." >&2
  exit 1
fi

if [[ ! -f /var/backups/ttakdap/${LATEST_BACKUP}/SHA256SUMS ]]; then
  echo "같은 세트의 로컬 SHA256SUMS를 찾지 못했습니다." >&2
  echo "강제 업로드 직후 이 검사를 다시 실행하세요." >&2
  exit 1
fi

RESTORE_TEST_DIR="$(mktemp -d /var/tmp/ttakdap-r2-test.XXXXXX)"

cleanup() {
  if [[ ${RESTORE_TEST_DIR} == /var/tmp/ttakdap-r2-test.* \
    && -d ${RESTORE_TEST_DIR} ]]; then
    rm -rf -- "${RESTORE_TEST_DIR}"
  fi
}
trap cleanup EXIT HUP INT TERM

rclone \
  --config /etc/ttakdap/rclone.conf \
  copy \
  "ttakdap-r2-crypt:${LATEST_BACKUP}" \
  "${RESTORE_TEST_DIR}" \
  --immutable

cmp \
  "/var/backups/ttakdap/${LATEST_BACKUP}/SHA256SUMS" \
  "${RESTORE_TEST_DIR}/SHA256SUMS"

cd "${RESTORE_TEST_DIR}"
sha256sum --check SHA256SUMS
gzip -t database.sql.gz
tar -tzf wordpress-files.tar.gz >/dev/null
tar -tzf deployment-config.tar.gz >/dev/null

printf 'R2 재다운로드 검사 통과: %s\n' "${LATEST_BACKUP}"
EOF

여섯 줄의 OK와 R2 재다운로드 검사 통과가 보이면 다음을 실제로 확인한 것이다.

  • 해당 버킷을 읽을 수 있다.
  • VM 밖 R2에 암호화된 객체가 존재한다.
  • 저장해 둔 crypt 암호로 파일명과 내용을 복호화할 수 있다.
  • 내려받은 파일의 SHA-256이 업로드 전 값과 같다.
  • DB gzip과 두 tar 파일의 목록을 읽을 수 있다.

임시 시험 디렉터리는 성공과 실패 모두에서 정리된다. 위 블록의 /var/tmp/ttakdap-r2-test.* 안전 조건을 다른 넓은 경로로 바꾸지 않는다.

이 결과만으로 SQL이 새 MariaDB에 정상 import되거나 플러그인과 파일 권한까지 정상이라는 것은 알 수 없다. 운영 WordPress에 덮어써서 시험하지 말고, 다음 단계에서는 별도 VM이나 격리된 새 볼륨을 이용한 전체 복원 절차를 따로 만들어야 한다.

14. 왕복 검증 뒤에만 로컬 14일 보존 켜기

R2에서 다시 내려받는 데 성공했으므로 이제 자동 정리를 켠다. 정확히 0으로 된 한 줄만 14로 바꾸고 결과를 확인한다.

[SSH로 Ubuntu에서 실행]

if [[ "$(sudo grep -c '^LOCAL_BACKUP_RETENTION_DAYS=0$' /etc/ttakdap/backup.env)" -ne 1 ]]; then
  echo "LOCAL_BACKUP_RETENTION_DAYS=0 줄을 정확히 하나 찾지 못했습니다." >&2
  false
else
  sudo sed -i \
    's/^LOCAL_BACKUP_RETENTION_DAYS=0$/LOCAL_BACKUP_RETENTION_DAYS=14/' \
    /etc/ttakdap/backup.env
fi

sudo grep '^LOCAL_BACKUP_RETENTION_DAYS=' \
  /etc/ttakdap/backup.env

6시간마다 전부 성공하면 14일 동안 대략 56세트가 남지만, 실행 지연이나 실패에 따라 실제 개수는 달라진다. R2에는 24시간 marker가 있어 하루 최대 한 세트만 추가한다. 현재 구성은 R2의 오래된 객체를 자동 삭제하지 않는다.

15. 백업 service와 timer 만들기

로컬 생성, R2 업로드, 재다운로드를 모두 수동으로 확인한 뒤에 자동 실행을 만든다. service는 앞에서 만든 스크립트를 root로 한 번 실행하고, 파일 권한은 UMask=0077로 제한한다.

[SSH로 Ubuntu에서 실행]

sudo tee /etc/systemd/system/ttakdap-backup.service >/dev/null <<'EOF'
[Unit]
Description=ttakdap WordPress recovery-set backup
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=root
Group=root
UMask=0077
Environment=BACKUP_ROOT=/var/backups/ttakdap
ExecStart=/opt/ttakdap/scripts/backup.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF

sudo tee /etc/systemd/system/ttakdap-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Run ttakdap backup every six hours

[Timer]
OnCalendar=*-*-* 00,06,12,18:20:00
RandomizedDelaySec=10m
Persistent=true
Unit=ttakdap-backup.service

[Install]
WantedBy=timers.target
EOF

sudo chmod 0644 \
  /etc/systemd/system/ttakdap-backup.service \
  /etc/systemd/system/ttakdap-backup.timer

sudo systemd-analyze verify \
  /etc/systemd/system/ttakdap-backup.service \
  /etc/systemd/system/ttakdap-backup.timer

systemd-analyze calendar \
  '*-*-* 00,06,12,18:20:00'

sudo systemctl daemon-reload
sudo systemctl start ttakdap-backup.service

systemctl show ttakdap-backup.service \
  -p Result \
  -p ExecMainStatus \
  -p ExecMainStartTimestamp \
  -p ExecMainExitTimestamp

sudo journalctl \
  -u ttakdap-backup.service \
  -n 80 \
  --no-pager

Result=success, ExecMainStatus=0과 새 로컬 경로가 보여야 한다. 최근 24시간 안에 R2 업로드를 이미 성공했다면 이번 로그에는 Off-site upload skipped가 나올 수 있다. 오류가 아니라 일반 자동 실행에서는 하루 최대 한 세트만 보내도록 한 조건이 작동한 것이다.

반대로 로컬 백업 뒤 R2 전송에서 실패하면 로컬 세트는 남아 있어도 service 결과는 실패한다. Local backup completed 한 줄만 보고 정상으로 판단하지 않고, journal의 끝과 마지막 R2 성공 시각을 기록한 /var/lib/ttakdap/last-offsite-upload 파일을 같이 본다.

sudo stat /var/lib/ttakdap/last-offsite-upload

수동 실행까지 성공했다면 timer를 활성화한다.

sudo systemctl enable --now ttakdap-backup.timer

systemctl is-enabled \
  ttakdap-wp-cron.timer \
  ttakdap-backup.timer

systemctl is-active \
  ttakdap-wp-cron.timer \
  ttakdap-backup.timer

systemctl list-timers \
  'ttakdap-*' \
  --all \
  --no-pager
개인정보를 제외하고 재구성한 WP-Cron과 백업 타이머 정상 상태

그림 4. 실제 서버에서는 두 timer가 enabled·active였고, 최근 WP-Cron과 백업 service가 종료코드 0으로 끝난 것을 확인했다.

백업 예약 시각은 VM의 Asia/Seoul 기준으로 00시·06시·12시·18시 20분이다. RandomizedDelaySec=10m과 systemd의 기본 정확도 여유를 합치면 설정상 약 11분의 지연 창이 더해진다. 시스템이 멈췄거나 과부하인 상황까지 포함한 절대 상한은 아니다. 반면 백업 폴더 끝의 Z는 UTC 시각이라 화면 시각과 다르게 보인다.

백업 timer의 Persistent=true는 예약 시각에 VM이 꺼져 있었다면 부팅 뒤 놓친 작업을 한 번 실행한다. 여러 회를 놓쳤다고 백업 여러 개를 연달아 만들지는 않는다.

16. 일주일에 한 번 볼 운영 상태

자동화는 설치한 날보다 조용히 실패한 날이 문제다. 매주 한 번은 timer, 최근 종료 결과, 백업 디렉터리, 디스크를 함께 본다.

[SSH로 Ubuntu에서 실행]

systemctl list-timers \
  'ttakdap-*' \
  --all \
  --no-pager

systemctl show ttakdap-wp-cron.service \
  -p Result \
  -p ExecMainStatus \
  -p ExecMainExitTimestamp

sudo journalctl \
  -u ttakdap-wp-cron.service \
  --priority warning \
  -n 50 \
  --no-pager

systemctl show ttakdap-backup.service \
  -p Result \
  -p ExecMainStatus \
  -p ExecMainExitTimestamp

sudo journalctl \
  -u ttakdap-backup.service \
  -n 120 \
  --no-pager

sudo find /var/backups/ttakdap \
  -mindepth 1 \
  -maxdepth 1 \
  -type d \
  -regextype posix-extended \
  -regex '.*/[0-9]{8}T[0-9]{6}Z' \
  -printf '%TY-%Tm-%Td %TH:%TM  %f\n' |
sort

df -h /
sudo du -sh /var/backups/ttakdap

다음 실행 시각만 있으면 충분하지 않다. 아래 중 하나면 자동화를 정상으로 간주하지 않고 원인부터 확인한다.

  • timer 표에 NEXT 시각이 없다.
  • 최근 service가 Result=success, ExecMainStatus=0이 아니다.
  • 가장 최근 완성 백업이 8시간보다 오래됐다.
  • 루트 디스크 사용량이 80%를 넘었거나 다음 전체 세트를 만들 여유가 부족하다.

한 달에 한 번은 sudo env FORCE_OFFSITE_UPLOAD=1 /opt/ttakdap/scripts/backup.sh로 새 세트를 올린 직후 13절의 R2 재다운로드 검사를 다시 실행하는 편이 좋다. 그래야 원격 최신 세트와 비교할 로컬 원본이 확실히 남아 있다. crypt 암호를 비밀번호 관리자에서 꺼내 새 rclone 설정을 재구성하는 시험과 빈 VM 전체 복원은 더 강한 검증이지만, 운영 사이트와 분리한 환경에서 진행해야 한다.

17. 선택 사항: R2 Bucket lock은 시험 뒤에 켜기

Cloudflare R2에는 일정 기간 객체의 삭제와 덮어쓰기를 막는 Bucket lock이 있다.

R2 → 백업 버킷 → Settings → Bucket lock rules → Add rule

규칙이 유지되는 동안 객체 삭제·덮어쓰기 요청을 막아 운영 실수나 제한된 R2 객체 자격증명 유출의 위험을 줄일 수 있다. 다만 Cloudflare 관리자 권한을 장악한 공격자는 lock 규칙 자체를 제거할 수 있으므로 절대적인 WORM 보관은 아니다. 잘못 설정하면 그 기간 동안 테스트 객체와 큰 백업도 삭제할 수 있으니 저장 비용과 보존 기간을 먼저 확인하고, 작은 별도 테스트 버킷에서 규칙을 시험한 뒤 적용한다.

Bucket lock을 적용하면 lifecycle 삭제보다 잠금 기간이 우선한다. 현재 이 글의 스크립트에는 R2 원격 삭제나 일간·주간·월간 선별 보존 로직이 없으므로, --immutable만으로 장기 불변 보존이 완료됐다고 보지 않는다.

18. 막혔을 때 확인할 것

WP-Cron service가 아무 출력 없이 끝난다

실행할 예약 작업이 없으면 정상이어도 출력이 없다. systemctl show의 Result=success, ExecMainStatus=0은 service 실행 자체의 성공 기준이다. 각 플러그인이 예약한 업무 결과는 해당 플러그인 로그를 별도로 확인해야 한다.

systemctl show ttakdap-wp-cron.service \
  -p Result \
  -p ExecMainStatus

service가 inactive (dead)로 보인다

두 service 모두 Type=oneshot이다. 한 번의 일을 성공적으로 끝낸 뒤 inactive로 돌아가는 것이 정상이다. 계속 active여야 하는 것은 .service가 아니라 .timer다.

기존 wp-config.php에서 WORDPRESS_CONFIG_EXTRA가 안 보인다

수동으로 교체한 wp-config.php일 수 있다. 먼저 8절의 로컬 복구 세트를 하나 만든 뒤, 현재 파일 안에서 /* That's all, stop editing! 기준 줄을 정확히 하나 찾고 보호된 설정을 삽입한다.

[SSH로 Ubuntu에서 실행]

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  exec -T wordpress \
  sh -ec '
    file=/var/www/html/wp-config.php
    anchor="stop editing!"

    if grep -q "DISABLE_WP_CRON" "$file"; then
      echo "DISABLE_WP_CRON 설정이 이미 있습니다."
      exit 0
    fi

    if [ "$(grep -Fc "$anchor" "$file")" -ne 1 ]; then
      echo "wp-config.php 기준 줄을 정확히 하나 찾지 못했습니다." >&2
      exit 1
    fi

    tmp="$(mktemp /var/www/html/.wp-config.php.XXXXXX)"
    trap '\''rm -f "$tmp"'\'' EXIT HUP INT TERM

    awk -v anchor="$anchor" '\''
      index($0, anchor) > 0 {
        print "if ( ! defined( \047DISABLE_WP_CRON\047 ) ) {"
        print "    define( \047DISABLE_WP_CRON\047, true );"
        print "}"
        print ""
      }
      { print }
    '\'' "$file" > "$tmp"

    php -l "$tmp"
    chown --reference="$file" "$tmp"
    chmod --reference="$file" "$tmp"
    mv -f -- "$tmp" "$file"
    php -l "$file"
  '

이 블록은 같은 WordPress 볼륨의 숨김 임시 파일에서 결과를 먼저 php -l로 검사한다. 통과하면 기존 소유권과 권한을 복사한 뒤 원자적으로 교체하며, 시작 전에 만든 로컬 복구 세트가 되돌리기 사본 역할을 한다. No syntax errors detected가 보인 뒤 4절의 PHP 확인 명령에서 DISABLE_WP_CRON=true가 나오는지 본다. Compose의 보호된 설정도 그대로 남겨 두면 새 볼륨에서 설정을 다시 만들 때 적용된다.

부팅 뒤 WP-Cron이 한 번 실패한다

Docker는 시작됐지만 WordPress가 아직 healthy가 아닐 수 있다. 다음 실행이 성공했는지 journal을 확인한다. 반복된다면 컨테이너 상태와 DB 연결부터 본다.

sudo docker compose \
  --env-file /opt/ttakdap/.env \
  -f /opt/ttakdap/compose.yaml \
  --profile tunnel \
  ps

sudo journalctl \
  -u ttakdap-wp-cron.service \
  -n 50 \
  --no-pager

.partial-* 디렉터리만 남는다

DB 덤프, 파일 압축 또는 체크섬 전에 중단됐다는 뜻이다. 남은 디렉터리를 완성 세트로 이름만 바꾸지 않는다. journal의 첫 오류와 디스크 여유를 확인한 뒤 새 백업을 실행한다.

sudo find /var/backups/ttakdap \
  -mindepth 1 \
  -maxdepth 1 \
  -type d \
  -name '.partial-*' \
  -printf '%f\n'

df -h /

R2에서 403 Forbidden이 나온다

Account ID를 조합해 만든 주소 대신 Cloudflare에 표시된 전체 endpoint를 썼는지, 토큰 권한이 Object Read & Write인지, 정확한 백업 버킷 하나가 선택됐는지 확인한다. jurisdiction 버킷은 endpoint 모양이 다를 수 있다.

키를 다시 만들었으면 /etc/ttakdap/rclone.conf를 화면에 출력해 고치지 않는다. 11절의 입력 블록을 다시 실행해 root 전용 파일을 교체한다. R2 API 키만 교체하는 경우에는 비밀번호 관리자에 저장한 기존 crypt 복구 암호를 그대로 입력해야 예전 백업도 계속 읽을 수 있다.

R2에서 이름을 읽을 수 없다

filename_encryption = standard와 디렉터리명 암호화가 적용된 정상 모습이다. Cloudflare 화면의 암호문 이름을 손으로 바꾸지 않는다. 파일 확인과 다운로드에는 항상 ttakdap-r2-crypt: 원격 이름을 쓴다.

로컬 백업은 생겼는데 service는 실패한다

로컬 생성 뒤 R2 업로드나 cryptcheck에서 실패했을 수 있다. 가장 최근 완성 세트와 journal 끝부분, 마지막 R2 성공 시각 파일을 함께 본다. 실패한 상태에서 보존 기간이나 원격 파일을 임의로 지우지 않는다.

sudo journalctl \
  -u ttakdap-backup.service \
  -n 100 \
  --no-pager

sudo stat /var/lib/ttakdap/last-offsite-upload

crypt 복구 암호를 잃어버렸다

R2 API 토큰만 새로 발급해도 기존 암호문은 복호화되지 않는다. VM 밖의 비밀번호 관리자 사본이 없다면 기존 백업을 읽을 수 없을 수 있다. 이런 상황을 피하려고 첫 설정 때 복구 암호를 별도로 보관하고, 정기적으로 재다운로드 시험을 한다.

19. 자동화를 멈추거나 되돌리는 방법

백업 자동화만 문제가 생겼다면 WordPress 예약 작업은 건드리지 않고 백업 timer만 멈춘다.

[SSH로 Ubuntu에서 실행]

sudo systemctl disable --now \
  ttakdap-backup.timer

이 명령은 기존 로컬 복구 세트, R2 객체, WordPress 데이터 볼륨을 지우지 않는다.

WP-Cron은 DISABLE_WP_CRON=true인 상태에서 timer부터 끄면 예약 작업이 전부 멈출 수 있다. 원래의 방문 요청 방식으로 되돌릴 때는 다음 순서를 지킨다.

  1. /opt/ttakdap/compose.yaml과, 수동으로 수정했다면 컨테이너의 wp-config.php에서 이 글이 추가한 DISABLE_WP_CRON 보호 블록만 제거한다.
  2. PHP·Compose 문법을 확인하고 WordPress를 재생성한다.
  3. 4절의 PHP 확인 명령을 반대로 확인해 DISABLE_WP_CRON=false가 나온 뒤에만 sudo systemctl disable --now ttakdap-wp-cron.timer를 실행한다.

자동 백업만 잠시 멈추는 상황이라면 WP-Cron 설정을 되돌릴 필요는 없다.

진단 중에는 docker compose down -v, 볼륨 삭제, rclone sync, rclone purge를 사용하지 않는다. 모두 현재 데이터나 원격 백업을 지울 수 있어 롤백 명령이 아니다.

20. 4편 완료 점검표

  • ☐ WP-Cron service 수동 실행이 Result=success, 종료코드 0이다.
  • ☐ 실제 PHP에서 DISABLE_WP_CRON=true가 확인된다.
  • ☐ WordPress 재생성 뒤 공개 홈페이지와 관리자 경로가 정상이다.
  • ☐ WP-Cron timer가 enabled·active이고 다음 실행 시각이 보인다.
  • ☐ 로컬 복구 세트에서 SHA-256, gzip, tar 검사가 모두 통과한다.
  • ☐ R2 토큰은 백업 버킷 하나에만 Object Read & Write 권한이 있다.
  • ☐ /etc/ttakdap/rclone.conf가 root:root 600이다.
  • ☐ crypt 복구 암호를 VM 밖 비밀번호 관리자에 저장했다.
  • ☐ 강제 R2 업로드와 cryptcheck가 통과했다.
  • ☐ R2 사본을 다시 내려받아 SHA-256과 압축 파일을 검사했다.
  • ☐ 그 뒤에만 로컬 보존 기간을 14일로 바꿨다.
  • ☐ 백업 timer가 enabled·active이고 최근 service 결과도 성공이다.
  • ☐ 가장 최근 완성 백업 시각과 VM 디스크 여유를 확인했다.
  • ☐ 이번 검증이 빈 VM 전체 복원 성공은 아니라는 범위를 구분했다.

여기까지 구축하고 나서 달라진 점

1편의 VM, 2편의 내부 WordPress, 3편의 Tunnel 공개에 이어 4편에서는 사람이 접속하지 않는 시간의 운영까지 붙였다. 특히 백업은 파일이 있다보다 다시 읽을 수 있다를 기준으로 보니 확인해야 할 단계가 분명해졌다.

가장 안심된 순간은 R2 화면에 객체가 보였을 때가 아니라, 다시 내려받은 세트에서 여섯 파일의 OK가 확인됐을 때였다.

아직 남은 가장 큰 과제는 새 Ubuntu VM에서의 전체 복원 연습이다. 같은 버전의 MariaDB에 SQL을 가져오고, 새 WordPress 볼륨에 파일을 푼 뒤 대표 글·이미지·관리자 로그인을 확인해야 비로소 실제 복원 절차라고 부를 수 있다. 운영 서버에 바로 덮어쓰지 않고 별도 환경에서 진행할 내용이다.

참고한 공식 문서