Django + Gunicorn 환경에서 로그를 파일로 기록하고, logrotate로 자동 관리하며,
쌓인 데이터를 별도 블록 스토리지에 연/월 구조로 보관하는 전 과정을 다룬다.
lsblk
마운트 여부와 관계없이 연결된 블록 디바이스를 모두 트리 형태로 보여준다.
파일시스템 타입과 UUID까지 함께 확인하려면 -f 옵션을 붙인다.
lsblk -f
NHN Cloud 등 클라우드 환경에서 추가 볼륨을 attach하면 vdb와 같은 이름으로 나타난다.
클라우드 블록 스토리지를 단순 용량 확장 목적으로 쓴다면, 파티션 없이 디스크 전체를 그대로 사용하는 것이 깔끔하다.
파티션이 유용한 경우는 다음과 같다.
/data, /logs)로 용량을 강제 제한하고 싶을 때# ext4로 포맷
sudo mkfs.ext4 /dev/vdb
# 마운트 포인트 생성
sudo mkdir -p /mnt/data
# 마운트
sudo mount /dev/vdb /mnt/data
# 확인
df -h
장치명(vdb)은 재부팅 시 바뀔 수 있어 UUID 기반으로 등록해야 안전하다.
# UUID 확인
sudo blkid /dev/vdb
출력 예시:
/dev/vdb: UUID="3de85581-2e2a-43f5-878e-b6a87d2fd905" BLOCK_SIZE="4096" TYPE="ext4"
# fstab에 추가
echo "UUID=3de85581-2e2a-43f5-878e-b6a87d2fd905 /mnt/data ext4 defaults 0 2" | sudo tee -a /etc/fstab
# 재부팅 없이 검증
sudo mount -a
# 최종 확인
df -h
일반적인 gunicorn.service 파일은 이렇게 되어 있다.
ExecStart=/path/to/.venv/bin/gunicorn \
--access-logfile - \
--error-logfile - \
...
여기서 -는 stdout/stderr로 출력한다는 의미다.
journald로만 데이터가 흘러가 별도 경로에 기록되지 않는다.
journald는 용량 제한이 있고 검색도 불편하기 때문에, 운영 환경에서는 직접 경로를 지정하는 것이 일반적이다.
sudo mkdir -p /var/log/mch_api
sudo chown ubuntu:www-data /var/log/mch_api
sudo chmod 755 /var/log/mch_api
ubuntu:www-data: gunicorn이 ubuntu 계정으로 구동되므로 해당 소유자에게 쓰기 권한을 부여한다.ssc_api, mch_api 등).[Unit]
Description=gunicorn daemon for MCH
Requires=gunicorn.socket
After=network.target
[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/workspace/smart-silver-center/ssc-api
ExecStart=/workspace/smart-silver-center/ssc-api/.venv/bin/gunicorn \
--access-logfile /var/log/mch_api/access.log \
--error-logfile /var/log/mch_api/error.log \
--workers 4 \
--threads 2 \
--worker-class gthread \
--timeout 60 \
--graceful-timeout 30 \
--max-requests 1000 \
--max-requests-jitter 100 \
--keep-alive 5 \
--bind unix:/run/gunicorn.sock \
--preload \
--capture-output \
--enable-stdio-inheritance \
--log-level info core.michuhol.wsgi:application
Environment="ENV_FILE=.env"
Restart=on-failure
RestartSec=5
LimitNOFILE=4096
[Install]
WantedBy=multi-user.target
변경점은 두 줄이다.
- --access-logfile - \
- --error-logfile - \
+ --access-logfile /var/log/mch_api/access.log \
+ --error-logfile /var/log/mch_api/error.log \
sudo systemctl daemon-reload
sudo systemctl restart gunicorn
# 경로 생성 확인
ls -la /var/log/mch_api/
# 실시간 확인
tail -f /var/log/mch_api/access.log
기록이 안 된다면 아래 순서로 점검한다.
sudo systemctl status gunicorn.service
sudo journalctl -u gunicorn.service -n 30 --no-pager
ls -la /var/log/ | grep mch_api
cat /etc/systemd/system/gunicorn.service | grep logfile
| 옵션 | 설명 |
|---|---|
daily | 매일 회전 |
weekly | 매주 회전 |
monthly | 매월 회전 |
API 서버라면 daily가 일반적이다.
| 옵션 | 설명 |
|---|---|
rotate 14 | 회전된 항목을 최대 14개까지 유지. 초과 시 오래된 것부터 삭제 |
maxsize 100M | 주기 무관하게 100MB를 넘으면 강제 회전 |
minsize 1M | 1MB 미만이면 주기가 돼도 넘어감 |
maxage 30 | 30일 지난 항목 삭제 (rotate는 개수 기준, maxage는 날짜 기준) |
| 옵션 | 설명 |
|---|---|
compress | gzip으로 압축 |
delaycompress | 직전 회전본은 압축하지 않음. 장애 시 zcat 없이 바로 열람 가능 |
compresscmd bzip2 | 압축 프로그램 변경 |
| 옵션 | 설명 |
|---|---|
create 0644 ubuntu www-data | 회전 후 새 항목을 지정 권한/소유자로 생성 |
copytruncate | 원본 복사 후 비움. 디스크립터를 새로 열지 못하는 프로세스에 유용. 복사~truncate 사이 유실 가능 |
nocreate | 회전 후 새 항목 자동 생성 안 함 |
| 옵션 | 설명 |
|---|---|
missingok | 대상이 없어도 에러 없이 통과 |
notifempty | 비어 있으면 회전하지 않음 |
postrotate
systemctl reload gunicorn 2>/dev/null || true
endscript
| 옵션 | 설명 |
|---|---|
postrotate / endscript | 회전 후 실행할 명령. 보통 서비스에 시그널을 보내 새 경로를 열게 함 |
prerotate / endscript | 회전 전 실행 |
sharedscripts | *.log처럼 여러 항목 매칭 시 postrotate를 한 번만 호출 |
sharedscripts가 없으면 access.log 회전 후 한 번, error.log 회전 후 또 한 번 reload가 중복 호출된다.
2>/dev/null 이란?리눅스의 모든 프로세스는 세 가지 기본 스트림을 가진다.
| 번호 | 이름 | 의미 |
|---|---|---|
0 | stdin | 입력 |
1 | stdout | 일반 출력 |
2 | stderr | 에러 출력 |
즉 2>/dev/null은 stderr(에러 메시지)를 /dev/null(쓰레기통)로 버리라는 의미다.
systemctl reload gunicorn 2>/dev/null || true
# ↑ ↑
# 에러 메시지 무시 실패해도 종료 코드 0으로 처리
gunicorn이 내려가 있거나 reload 자체가 실패해도, logrotate 전체가 오류로 처리되지 않도록 하는 방어 코드다.
| 옵션 | 설명 |
|---|---|
dateext | access.log.1 대신 access.log-20260304 형식으로 날짜 기반 이름 생성 |
dateformat -%Y%m%d-%s | 날짜 포맷 커스터마이징 |
/etc/logrotate.d/ 아래에 서비스명으로 생성한다.
파일 이름은 서비스명과 일치할 필요 없다. 중요한 건 안에 적는 경로다.
sudo nano /etc/logrotate.d/ssc_api
/var/log/ssc_api/*.log {
daily
missingok
rotate 14
maxsize 100M
compress
delaycompress
notifempty
create 0644 ubuntu www-data
dateext
sharedscripts
postrotate
systemctl reload gunicorn 2>/dev/null || true
/usr/local/bin/archive_logs.sh >> /var/log/archive_logs.log 2>&1
endscript
}
# 문법 검증 (dry-run, 실제 동작 없음)
sudo logrotate -d /etc/logrotate.d/ssc_api
# 강제 실행 + verbose
sudo logrotate -vf /etc/logrotate.d/ssc_api
# 결과 확인
ls -lh /var/log/ssc_api/
| 옵션 | 설명 |
|---|---|
-d | dry-run. 실제 동작 없이 문제만 점검 |
-v | verbose. 과정을 출력하며 실행 |
-f | force. 주기 무관하게 강제 회전 |
logrotate는 데몬이 아니므로 설정 수정 후 별도 재시작이 필요 없다.
cron이 매일 호출하는 구조이기 때문에, 저장 즉시 다음 실행부터 반영된다.
glob finding logs to compress failed
glob finding old rotated logs failed
에러가 아니다. "이전에 회전된 항목을 찾으려 했는데 아직 없다"는 정보성 메시지로, 최초 실행 시 정상적으로 출력된다.
/var/log/ssc_api/ ← 현재 활성 로그 (그대로 유지)
gunicorn_access.log
gunicorn_error.log
gunicorn_error.log-20260304 ← delaycompress 대기 중 (미압축)
/mnt/data/logs/ssc_api/ ← 압축 완료된 항목 아카이브
2026/
03/
gunicorn_access.log-20260301.gz
gunicorn_error.log-20260304.gz
04/
...
sudo mkdir -p /mnt/data/logs/ssc_api
sudo chown ubuntu:ubuntu /mnt/data/logs/ssc_api
sudo nano /usr/local/bin/archive_logs.sh
#!/bin/bash
SRC="/var/log/ssc_api"
DEST="/mnt/data/logs/ssc_api"
# .gz 항목만 대상 (delaycompress로 압축 완료된 것만)
for f in "$SRC"/*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.gz; do
[ -f "$f" ] || continue
filename=$(basename "$f")
# 파일명에서 날짜 추출 (예: gunicorn_access.log-20260304.gz → 20260304)
datestr=$(echo "$filename" | grep -oP '\d{8}')
[ -z "$datestr" ] && continue
year="${datestr:0:4}"
month="${datestr:4:2}"
target_dir="$DEST/$year/$month"
mkdir -p "$target_dir"
mv "$f" "$target_dir/"
echo "[$(date)] Archived: $filename → $target_dir/"
done
sudo chmod +x /usr/local/bin/archive_logs.sh
delaycompress는 직전 회전본을 하루 동안 압축하지 않은 채 둔다.
glob 패턴을 .gz 없이 작성하면 미압축 항목도 매칭되어 /mnt/data로 이동해버린다.
다음 날 logrotate가 압축 대상을 찾지 못해 아카이브에 비압축 항목이 쌓이는 결과로 이어진다.
# ❌ 잘못된 패턴 (미압축도 매칭됨)
"$SRC"/*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*
# ✅ 올바른 패턴 (압축 완료본만 이동)
"$SRC"/*.log-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*.gz
Day 1 logrotate 실행:
- access.log → access.log-20260303 (압축 안 됨, delaycompress)
- postrotate: archive_logs.sh → .gz 없으므로 아무것도 안 함 ✅
Day 2 logrotate 실행:
- access.log-20260303 → access.log-20260303.gz (압축 완료)
- access.log → access.log-20260304 (새로 회전)
- postrotate: archive_logs.sh → access.log-20260303.gz를 /mnt/data/2026/03/ 으로 이동 ✅
/var/log/ssc_api/ 에는:
- gunicorn_access.log (현재 활성)
- gunicorn_access.log-20260304 (어제, 아직 압축 전)
→ 깔끔하게 유지 ✅
수동 생성 항목 등 누락 케이스를 대비한 추가 트리거다.
sudo crontab -e
# 매일 새벽 2시 실행
0 2 * * * /usr/local/bin/archive_logs.sh >> /var/log/archive_logs.log 2>&1
이미 이동된 항목은 glob 매칭에서 걸리지 않으므로 중복 실행해도 무방하다.
gunicorn_access.log 2.6GB / 5일 → 약 520MB/day
gunicorn_error.log 582MB / 5일 → 약 116MB/day
maxsize 100M 적용 시:
rotate 14는 회전된 항목 14개를 유지한다는 뜻이다.
하루에 5회 회전한다면 14개는 약 2~3일치에 불과하다.
| 대상 | 하루 회전 횟수 | rotate 14 기준 실제 보관 기간 |
|---|---|---|
| access.log | 5회 | 약 2~3일 |
| error.log | 1~2회 | 약 7~14일 |
14일치 보장이 필요하다면:
rotate 70 # 하루 5회 × 14일 = 70개
gzip 압축 시 텍스트 로그는 원본의 약 10~15% 수준이 된다.
access.log 기준 (rotate 14 적용):
| 항목 | 크기 |
|---|---|
| 현재 활성 (최대) | 100MB |
| delaycompress 대기 (미압축 1개) | 100MB |
| 압축 완료 12개 (100MB × 0.12 × 12) | 약 144MB |
| 소계 | 약 344MB |
error.log도 동일 구조로 약 344MB.
총 합계: 약 700MB (기존 7GB 대비 1/10 수준)
rotate 70 적용 시 약 1.2GB 수준으로 여전히 기존 대비 대폭 절감된다.
logrotate 도입 전 이미 쌓인 대용량 항목은 수동으로 처리한다.
# 압축 보관
sudo gzip /var/log/ssc_api/gunicorn_access.log
sudo gzip /var/log/ssc_api/gunicorn_error.log
# 불필요하다면 삭제
sudo rm /var/log/ssc_api/gunicorn_access.log
# 여유 공간 확인
df -h /var/log
| 단계 | 작업 |
|---|---|
| 1 | 블록 스토리지 attach 후 ext4 포맷 및 /mnt/data 마운트 |
| 2 | fstab에 UUID 기반으로 등록해 자동 마운트 보장 |
| 3 | gunicorn의 --access-logfile -를 실제 경로로 변경 |
| 4 | /etc/logrotate.d/ 아래에 설정 작성 |
| 5 | archive_logs.sh 작성 시 glob 패턴을 .gz로 한정 (delaycompress 충돌 방지) |
| 6 | postrotate에서 archive_logs.sh 호출, cron으로 보조 트리거 등록 |
| 7 | sudo logrotate -d로 문법 점검 → sudo logrotate -vf로 강제 실행 확인 |
| 8 | 로그 생성량에 따라 rotate 값 조정으로 원하는 보관 기간 확보 |