서버구축

systemd 서비스에서 restic 백업이 30배 느려진 이유

백업을 걸어두고 며칠 뒤, 잘 돌고 있는지 확인해봤다. 잘 돌고 있었다.
스냅샷도 매일 쌓이고, 무결성 검사도 매번 통과했다. 타이머는 계속 성공을 보고했다.

그런데 한 번 도는 데 한 시간 넘게 걸리고 있었다.

구성은 restic + rclone 으로 구글 드라이브에 백업하는 형태다.
서버는 2코어짜리 작은 인스턴스이고, 백업 대상은 200MB 정도다.


0. 증상

실패한 적이 없다. 로그에도 오류가 없다. 그냥 오래 걸린다.

sudo journalctl -u my-backup.service -o short-iso | grep -E "Starting|Finished"
19:03:27  Starting my-backup.service...
20:10:58  Finished my-backup.service.

67분이다. 200MB를 증분 백업하는 데.

1. 단계별로 재보기

스크립트에 단계마다 시각을 찍어두면 범인이 바로 좁혀진다. 이게 없으면
“느리다”에서 더 나아가지 못한다.

log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
단계 시간
DB 덤프 + 파일 복사 1초
restic backup 65분 4초
forget --prune 12초
check 2분

그리고 restic 이 스스로 남긴 요약이 결정적이었다.

Added to the repository: 1.529 MiB (260.207 KiB stored)
processed 8891 files, 173.070 MiB in 1:05:04

260KB를 올리는 데 65분. 대역폭 문제가 아니다.

2. 손으로 돌려보기

여기서 범위가 크게 좁혀진다. 같은 스크립트를 셸에서 직접 실행했다.

processed 8939 files, 172.468 MiB in 0:20

20초. 3분 전에 systemd 로 돌린 것과 같은 작업, 같은 서버, 같은 시각대다.

같은 시각에 손으로 돌린 것이 20초, systemd 로 돌린 것이 65분이라면
원인은 systemd 실행 환경 안에 있다. 네트워크나 원격 서비스 문제가 아니다.

이 한 번의 비교가 이후의 헛짚기를 대부분 걸러준다.

3. 틀린 가설 두 개

기록해두는 편이 낫겠다. 둘 다 그럴듯했고 둘 다 틀렸다.

가설 1: 구글 드라이브 API 할당량

로그에 Error 403: Quota exceeded ... rateLimitExceeded 가 찍혀 있었다.
rclone 의 기본 OAuth 클라이언트는 전 세계 사용자가 공유해서 할당량이 상시 빠듯하다.
그럴듯했다.

세어보니 그 67분 동안 403 은 4줄, 재시도는 1회였다. 그리고 55분 동안은
로그가 아예 한 줄도 없었다. 재시도로 시간을 보낸 것이 아니었다.

오류 메시지가 눈에 보인다고 그게 병목인 것은 아니다. 횟수를 세봐야 한다.

가설 2: IOSchedulingClass=idle

유닛에 이걸 걸어뒀었다. 2코어를 백업이 독차지하면 사이트가 느려질까 싶어서였다.
I/O 우선순위를 최하로 두면 굶을 수 있으니 의심스러웠다.

지우고 다시 돌렸다. 그대로 느렸다.

이 서버의 디스크 스케줄러를 보면 이유가 나온다.

cat /sys/block/sda/queue/scheduler
# [none] mq-deadline

none 이면 I/O 우선순위가 애초에 반영되지 않는다. 걸어둔 설정이 아무 일도
하지 않고 있었던 것이다.

4. 진짜 원인

systemd 서비스의 환경변수를 들여다봤다.

sudo systemctl show my-backup.service -p Environment
Environment=

비어 있다. 프로세스의 실제 환경도 확인했다.

sudo cat /proc/$(pgrep -f my-backup.sh)/environ | tr '\0' '\n' | grep -E '^(HOME|PATH|USER)='
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
USER=root

HOME 이 없다.

systemd 는 시스템 서비스에 HOME 을 넣어주지 않는다.
셸에서는 항상 있으니 없을 거라고 생각하기 어렵다.

5. HOME 이 없으면 restic 이 왜 느려지나

restic 은 저장소의 인덱스와 메타데이터를 ~/.cache/restic 에 캐시한다.
증분 백업이 빠른 이유가 이 캐시다. 이전 스냅샷 정보를 로컬에서 읽고,
바뀐 것만 올린다.

HOME 이 없으면 그 경로를 정하지 못하고 매 실행마다 임시 캐시로 떨어진다.
결과적으로 저장소 메타데이터를 원격에서 통째로 다시 읽는다.

구글 드라이브처럼 작은 요청 하나하나가 느린 백엔드에서 이 차이가 극단적으로 벌어진다.
같은 코드가 로컬 디스크 저장소라면 눈치도 못 챌 만큼 차이가 작을 것이다.

캐시가 살아 있는지는 이렇게 확인한다.

sudo du -sh /root/.cache/restic
# 1.8M   /root/.cache/restic   ← 쌓이고 있으면 정상

6. 고치기

유닛에 한 줄이다.

[Service]
Type=oneshot
Environment=HOME=/root
ExecStart=/usr/local/bin/my-backup.sh
sudo systemctl daemon-reload

실측 결과:

restic backup 단계
이전 65분 4초
이후 1분 9초

RESTIC_CACHE_DIR 을 직접 지정해도 된다. HOME 쪽이 rclone 설정 경로까지
같이 해결해줘서 더 간단하다.

7. 곁가지로 얻은 개선

고친 뒤 남은 가장 느린 단계는 forget --prune(약 4분)이었다. 팩 파일을 다시
쓰기 때문에 원래 무거운 작업이다.

보관 정책이 일 7 / 주 4 / 월 6 이라면 매일 돌릴 이유가 없다. 하루 늦게
지워질 뿐이다. 일요일에만 하도록 바꿨다.

if [ "${1:-}" = "--maintenance" ] || [ "$(date +%u)" = "7" ]; then
    restic forget --tag daily --host "$(hostname)" \
        --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
    restic check
else
    log "정리와 검사는 일요일에만 한다"
fi

타이머를 둘로 나누지 않은 이유가 있다. 같은 저장소에 두 작업이 겹치면
restic 잠금이 충돌한다. 한 스크립트 안에서 순서대로 하면 그 걱정이 없다.

최종 결과는 이렇다.

전체 소요
처음 67분
HOME 추가 후 7분
정리를 주 1회로 22초 (일요일만 약 6분)

8. 이 문제가 위험한 이유

아무것도 실패하지 않았다.

  • 타이머는 매번 성공을 보고했다
  • 스냅샷은 매일 쌓였다
  • 무결성 검사는 매번 통과했다
  • 복구도 실제로 해봤고 잘 됐다

즉 “백업 실패 시 알림” 같은 감시로는 이걸 절대 못 잡는다. 성공했으니까.

배치 작업은 성공/실패만 보면 안 된다. 소요 시간도 같이 봐야 한다.
어제 2분이던 것이 오늘 60분이면, 성공했어도 뭔가 잘못된 것이다.

가장 싼 대비책은 스크립트가 단계마다 시각을 찍는 것이다. 그것만 있으면
“느리다”에서 “이 단계가 느리다”로 바로 넘어간다.

9. 테스트 중에 만든 함정

원인을 찾느라 실행 중인 백업을 systemctl stop 으로 여러 번 끊었다.
그러다 다음 실행이 이렇게 실패했다.

unable to create lock in backend: repository is already locked by PID 2278747
lock was created at 2026-08-31 15:17:52 (9m8s ago)
the `unlock` command can be used to remove stale locks

끊긴 프로세스가 저장소에 잠금을 남긴 것이다. 다른 작업이 정말 돌고 있지 않은지
확인한 뒤 지운다.

sudo HOME=/root restic -r <저장소> unlock

체크리스트

  • [ ] systemd 로 도는 작업에 Environment=HOME=... 이 있는지 확인
  • [ ] systemctl show <서비스> -p Environment 로 실제 환경 보기
  • [ ] 스크립트에 단계별 시각 로그 남기기
  • [ ] restic 캐시 디렉터리가 쌓이고 있는지 확인
  • [ ] 손으로 실행한 시간과 systemd 실행 시간을 같은 시각대에 비교
  • [ ] 오류 메시지는 눈에 보이는지가 아니라 횟수로 판단
  • [ ] forget --prune 은 보관 정책에 맞춰 주 1회로
  • [ ] 실패 알림만이 아니라 소요 시간도 감시 대상에 넣기

이어지는 글