카테고리 없음

Docker + Synology NAS 배포, 그리고 9시간 어긋난 타임존 버그

aram 2026. 8. 31. 15:26

이메일 캠페인 도구 개발기 시리즈 (9/9). 전체 개요

배포 구조

  • Docker + Synology NAS. web 컨테이너 하나만 상시 실행(포트 5050:8000)
  • 예약 발송은 별도 컨테이너 없이, NAS Task Scheduler가 1분 주기로 docker exec unsub_web python scripts/send_scheduled_campaigns.py를 실행하는 방식 — 상시 대기하는 두 번째 컨테이너 없이도 예약 발송이 동작한다
  • 리버스 프록시로 unsub.example.com 연결(DSM 애플리케이션 포털 → 역방향 프록시)
# 배포
cd /volume1/docker/unsub_app
sudo docker compose up -d --build

# 이후 업데이트
git pull
sudo docker compose up -d --build

버그: 수신거부 처리 시각이 실제보다 9시간 느리게 찍힘

수신거부 페이지에 "처리 완료" 안내는 잘 뜨는데, 처리 시각이 실제보다 9시간 느리게 표시됐다.

원인

unsubscribed_at을 SQL NOW() 대신 Python datetime.now()로 만들어서 UPDATE와 화면 표시에 같이 쓰고 있었다. Docker 컨테이너의 시스템 시간대는 기본 UTC인데 (베이스 이미지에 별도 타임존 설정이 없으면), datetime.now()는 컨테이너의 시스템 시간대를 기준으로 계산된다. 반면 DB 서버(MySQL, 한국 시간대로 동작)의 NOW()는 그와 다른 값이 된다 — 한국(UTC+9)과 UTC의 차이만큼, 정확히 9시간이 어긋났다.

앱의 다른 모든 시간 컬럼(campaigns.sent_at, 구독자 수동 수정 시 unsubscribed_at 등)은 전부 SQL NOW()를 써서 컨테이너 시간대와 무관하게 항상 정확했는데, 이 부분만 예외적으로 Python 시간을 쓰다 문제가 생긴 것이었다.

수정 (두 겹)

  1. 원칙 수정: 다시 SQL NOW()로 UPDATE하고, 그 직후 SELECT로 방금 DB에 저장된 실제 값을 다시 읽어와서 화면에 표시하도록 변경. 컨테이너의 시스템 시간대 설정에 전혀 의존하지 않으므로 이런 종류의 오차가 재발할 수 없다. 이 앱에서 시간을 다루는 곳은 모두 DB의 NOW()에 맡기는 것을 원칙으로 삼았다.
  2. 방어책: 앞으로 로그 시각이나 Python 쪽에서 시간을 다뤄야 할 일이 생겨도 같은 문제가 재발하지 않도록, Dockerfile에서 컨테이너 시스템 시간대 자체를 Asia/Seoul로 고정(tzdata 설치 + /etc/localtime, /etc/timezone 설정).
RUN apt-get update && apt-get install -y tzdata \
    && ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime \
    && echo "Asia/Seoul" > /etc/timezone

교훈: "애플리케이션 서버(컨테이너)의 시간"과 "DB 서버의 시간"은 서로 다른 시간대로 동작할 수 있다는 걸 항상 전제해야 한다. 두 겹으로 막은 이유 — 원칙(DB NOW() 사용)만으로 충분하지만, 나중에 누군가 실수로 Python 시간을 다시 쓰더라도 컨테이너 시간대가 맞춰져 있으면 최소한 사고가 안 나도록 방어선을 이중으로 뒀다.

배포 시 겪은 자잘한 문제들

재빌드 후 유령 컨테이너

docker compose up -d --build로 이미지가 바뀌면 compose가 기존 컨테이너를 <해시>_unsub_web으로 임시 이름 변경한 뒤 새 컨테이너를 띄우고 옛 컨테이너를 삭제하는데, 이 마지막 삭제 단계가 간헐적으로 실패해 유령 컨테이너가 남을 수 있다(Container Manager GUI에서 이름이 이상하게 보이고 정지/삭제도 안 먹히는 증상, 실제 서비스 동작에는 영향 없음). uploads 볼륨과는 무관. 리빌드 직후 아래로 확인 후 정리한다:

cd /volume1/docker/unsub_app
sudo docker compose up -d --build

sudo docker ps -a --filter "name=_unsub_web"
sudo docker ps -a --filter "name=_unsub_web" -q | xargs -r sudo docker rm -f

sudo docker compose ps

한 번으로 끝나는 우연이 아니라 재발했다. 두 번째로 겪고 나서 원인 후보를 좀 더 구체화했다:

  • Synology 스토리지(볼륨 삭제)가 느려서 삭제 요청이 타임아웃되는 경우
  • 리빌드 도중 SSH 세션이 끊기거나 터미널을 닫아버려 정리 단계가 중간에 끊기는 경우
  • Container Manager GUI가 백그라운드에서 같은 컨테이너 상태를 폴링하면서 compose의 삭제 처리와 충돌하는 경우

즉 도커 자체를 지우고 재설치할 문제는 아니고, 리빌드할 때마다 습관적으로 확인하는 게 실질적인 해결책이다. 매번 신경 쓰기 번거로우면 위 세 명령을 리빌드 스크립트나 짧은 shell script/alias로 묶어두면 된다.

docker rm -f로 실제 컨테이너는 정리됐는데도 Container Manager GUI 화면에 계속 유령이 보이는 경우도 있었다(docker ps -a엔 정상 컨테이너 하나만 있는데 GUI에만 옛 이름이 남아있는 상태) — 이때는 도커 상태 자체가 아니라 GUI의 캐시가 실제 상태와 동기화되지 않은 것이라, 아래를 순서대로 시도한다:

sudo synopkg list | grep -i container   # 패키지 정확한 이름 확인 (보통 ContainerManager)
sudo synopkg restart <패키지 이름>

이래도 안 바뀌면 DSM 웹 화면 로그아웃 후 재로그인(브라우저 세션 캐시 문제일 수 있음), 그래도 안 되면 패키지 센터에서 Container Manager를 완전히 "중지 → 시작"한다. 실제 서비스는 영향이 없으니 급한 문제는 아니지만, GUI로 계속 관리하려면 이 동기화 과정이 필요하다.

정리: 리빌드는 GUI가 아니라 SSH의 docker compose 명령으로 하고, GUI는 상태 확인용으로만 쓰는 게 이런 혼란을 줄이는 실질적인 방법이었다.

Container Manager GUI의 env_file 이슈

Container Manager GUI의 "프로젝트" 기능으로 docker-compose.yml을 통째로 불러오면 대부분 자동 처리되지만, GUI가 env_file을 제대로 못 읽는 경우가 있어 "Environment" 단계에서 .env의 값들을 직접 하나씩 추가해주는 게 안전했다.

UPLOAD_FOLDER 경로

로컬 개발용 .env와 운영용 .env는 키는 동일하지만 값은 달라야 한다. 특히 UPLOAD_FOLDER는 반드시 컨테이너 내부 경로(/app/uploads, docker-compose.yml의 볼륨과 일치)로 설정해야 하고, 로컬 개발용 경로를 그대로 쓰면 안 된다.