- Published on
systemd 서비스 재시작 반복 원인 추적 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 systemd 서비스가 “잘 떠 있는 것처럼 보이는데” 어느 순간부터 계속 재시작되는 문제는, 실제로는 systemd가 매우 성실하게 “죽은 프로세스를 복구”하고 있는 상황인 경우가 많습니다. 핵심은 재시작 자체를 멈추는 게 아니라, 왜 프로세스가 종료되는지를 systemd 관점(Exit Code, 타임아웃, watchdog, 의존성, cgroup 리소스)에서 역추적하는 것입니다.
아래는 현장에서 가장 빠르게 원인을 좁히는 순서대로 정리한 체크리스트입니다.
1) 먼저 “재시작 루프”인지 “정상 재기동”인지 구분
서비스가 재시작되는 것처럼 보여도, 실제로는 아래 케이스가 섞여 있습니다.
- 프로세스가 즉시 종료되고
Restart=정책으로 재시작되는 restart loop - 프로세스는 살아있는데
systemd가 헬스체크 실패로 kill 후 재기동하는 watchdog/health 실패 Type=설정이 맞지 않아systemd가 시작 실패로 판단하는 타입 불일치- 의존 서비스(예: DB, 네트워크)가 준비되기 전에 떠서 죽는 의존성/레이스 컨디션
우선 상태와 최근 이벤트를 한 번에 확인합니다.
systemctl status myservice --no-pager
systemctl show myservice -p ActiveState -p SubState -p Result -p ExecMainStatus -p ExecMainCode
systemctl list-units --failed --no-pager
Result=exit-code또는Result=signal이면 “프로세스가 종료”된 것입니다.ExecMainStatus가0이 아닌 값이면, 종료 코드를 기반으로 원인 추적이 가능합니다.
추가로 재시작 횟수와 타이밍을 확인합니다.
systemctl show myservice -p NRestarts -p Restart -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst
NRestarts가 빠르게 증가한다면 전형적인 루프입니다.
2) journalctl로 “마지막 실패 직전” 로그를 좁혀 읽기
가장 흔한 실수는 journalctl -u myservice를 길게 보는 것입니다. 루프 상황에서는 “마지막 실패 직전 수십 줄”이 제일 중요합니다.
# 최근 5분만
journalctl -u myservice --since "5 min ago" --no-pager
# 현재 부팅에서만
journalctl -u myservice -b --no-pager
# 가장 최근 실패 시점 중심으로(라인 수 제한)
journalctl -u myservice -b -n 200 --no-pager
로그에서 특히 아래 키워드를 찾습니다.
Main process exited, code=exited, status=...killed by signal ...Start request repeated too quicklywatchdog timeoutFailed to start직전 애플리케이션 예외(설정 파일, 권한, 포트 바인딩 등)
관련해서 ExecStart 자체 디버깅을 더 깊게 다룬 글은 아래를 함께 보면 좋습니다.
3) Exit Code가 말해주는 “즉시 원인” 해석
ExecMainStatus 또는 저널의 status=...는 아주 강력한 단서입니다.
자주 나오는 종료 코드/시그널 패턴
status=1/FAILURE: 앱 내부 예외, 설정 누락, 잘못된 인자status=127: 실행 파일 없음, PATH 문제, 스크립트 shebang 문제status=203/EXEC:ExecStart경로/권한/ELF 문제status=217/USER:User=가 존재하지 않거나 권한 문제code=killed, status=9/KILL: OOMKill 또는 외부에서 강제 kill 가능성code=killed, status=15/TERM: 정상 종료 요청을 받았는데 종료 처리 중 문제 가능
다음 명령으로 systemd가 기록한 종료 정보를 더 자세히 봅니다.
systemctl show myservice \
-p ExecStart \
-p ExecMainPID \
-p ExecMainCode \
-p ExecMainStatus \
-p StatusText
4) Type= 설정 불일치로 “떠 있는데 실패”처럼 보이는 경우
systemd 서비스 타입은 생각보다 중요합니다.
Type=simple(기본):ExecStart가 실행되면 바로 “started”로 간주Type=forking: 데몬이 포크 후 부모가 종료되는 패턴(구식 데몬)Type=notify: 프로세스가sd_notify로 준비 완료를 알려야 함
예를 들어 앱은 정상 실행 중인데 Type=notify로 되어 있고 notify를 보내지 않으면, TimeoutStartSec 이후 systemd는 “시작 실패”로 판단하고 kill 후 재시작할 수 있습니다.
유닛 파일을 확인합니다.
systemctl cat myservice
타입/타임아웃을 의심할 때는 아래 필드를 점검합니다.
Type=TimeoutStartSec=NotifyAccess=
5) Watchdog으로 인한 재시작(헬스체크 실패)
WatchdogSec=가 설정되어 있고 앱이 주기적으로 watchdog ping을 못 보내면, systemd가 프로세스를 재시작합니다.
systemctl show myservice -p WatchdogUSec -p NotifyAccess
journalctl -u myservice -b | grep -i watchdog
해결 방향은 두 가지입니다.
- 앱이
sd_notifywatchdog ping을 보내도록 구현/설정 - 필요 없다면
WatchdogSec=0또는 해당 옵션 제거
6) 의존성/부팅 레이스 컨디션: DB·네트워크·마운트
부팅 직후에만 죽고, 몇 번 재시작 후 “어느 순간” 정상화된다면 의존성 레이스일 가능성이 큽니다.
체크 포인트
After=만 있고Requires=가 없음- 네트워크가 올라오기 전에 시작(
network.target와network-online.target차이) - 볼륨/마운트(
/mnt/data등) 준비 전 접근
예시(네트워크 온라인 보장):
[Unit]
Wants=network-online.target
After=network-online.target
DB 의존은 systemd만으로 완벽히 보장하기 어렵기 때문에, 앱 레벨에서 재시도(backoff) 로직을 두는 것이 가장 안전합니다.
7) StartLimit*에 걸려 “계속 재시작하다가 멈춤”
재시작이 무한정 되는 게 아니라, 어느 순간 failed로 굳어버리면 StartLimitIntervalUSec와 StartLimitBurst 제한에 걸린 것입니다.
systemctl show myservice -p StartLimitIntervalUSec -p StartLimitBurst
journalctl -u myservice -b | grep -i "Start request repeated"
근본 원인을 해결하기 전까지는 제한을 완화해도 운영 관점에서 큰 도움이 되지 않습니다. 다만 디버깅 중에는 일시적으로 조정할 수 있습니다.
8) OOMKill, 메모리/디스크/파일디스크립터 같은 “리소스 원인”
재시작 루프의 상당수는 앱 버그가 아니라 리소스 고갈입니다.
OOMKill 확인
journalctl -b | grep -i -E "oom|out of memory|killed process"
# systemd가 남긴 OOM 관련 상태
systemctl show myservice -p OOMPolicy -p MemoryMax -p MemoryCurrent
MemoryMax가 너무 낮거나, 컨테이너/VM 메모리가 부족한데 캐시/버퍼가 늘어난 경우가 있습니다.
디스크 100%로 인한 비정상 동작
로그 기록, 임시 파일 생성, DB 쓰기 등이 실패하면서 앱이 종료될 수 있습니다.
df -h
journalctl -u myservice -b | grep -i -E "no space|ENOSPC"
디스크가 꽉 찼는데 용량이 안 줄어드는(삭제했는데도) 케이스는 아래 글이 직접적으로 도움이 됩니다.
파일 디스크립터(ulimit) 부족
systemctl show myservice -p LimitNOFILE
journalctl -u myservice -b | grep -i -E "too many open files|EMFILE"
필요하면 유닛에 다음을 추가합니다.
[Service]
LimitNOFILE=1048576
9) ExecStart가 쉘 스크립트일 때 흔한 함정
ExecStart=/bin/bash -lc "..." 형태는 편하지만, 환경 차이로 인해 로컬에서는 되는데 서비스에서는 실패하는 경우가 많습니다.
체크 포인트
WorkingDirectory=가 없어서 상대 경로가 깨짐Environment=또는EnvironmentFile=누락- PATH 차이로 바이너리를 못 찾음(
status=127패턴)
예시(환경 파일과 작업 디렉터리 고정):
[Service]
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/opt/myapp/bin/start.sh
그리고 스크립트는 반드시 실행 권한/라인 엔딩을 확인합니다.
ls -l /opt/myapp/bin/start.sh
file /opt/myapp/bin/start.sh
10) 재현 가능한 “원인 추적 루틴” 템플릿
마지막으로, 장애 대응 중에 빠르게 반복 적용할 수 있는 루틴을 남깁니다.
# 1) 현재 상태/종료 코드
systemctl status myservice --no-pager
systemctl show myservice -p Result -p ExecMainCode -p ExecMainStatus -p NRestarts
# 2) 최근 로그 200줄
journalctl -u myservice -b -n 200 --no-pager
# 3) 유닛 파일 확인(실제 적용된 내용)
systemctl cat myservice
# 4) 리소스/커널 이벤트(OOM 등)
journalctl -b -n 300 --no-pager | grep -i -E "oom|killed process|ENOSPC|I/O error" || true
# 5) 수동 실행으로 차이 확인(가능하면 동일 User로)
# 주의: 아래의 `<user>`와 같은 부등호 표기는 MDX에서 문제될 수 있으니 실제 명령에선 사용자로 치환하세요.
sudo -u myapp /opt/myapp/bin/start.sh
수동 실행이 성공하는데 systemd에서만 실패한다면, 거의 항상 WorkingDirectory/환경변수/권한/Type=/타임아웃 중 하나입니다. 반대로 수동 실행도 실패한다면, 앱 설정/의존성/리소스 문제로 범위가 좁혀집니다.
11) 운영 관점의 “재시작 정책”도 같이 점검
원인 해결과 별개로, 재시작 정책이 장애를 더 키우는 경우가 있습니다.
Restart=always는 치명적 설정 오류에서도 무한 재시작을 유발Restart=on-failure가 일반적으로 더 안전RestartSec=를 너무 짧게 두면 로그가 폭주하고 외부 의존 서비스에 부하
예시(보수적 재시작 정책):
[Service]
Restart=on-failure
RestartSec=3s
StartLimitIntervalSec=60
StartLimitBurst=10
재시작은 “복구 메커니즘”이지 “원인 은폐 장치”가 아닙니다. 위 체크리스트대로 Exit Code와 저널의 마지막 실패 지점을 중심으로 추적하면, 대부분의 재시작 루프는 30분 안에 원인 범위를 한 자리수 후보로 줄일 수 있습니다.