Published on

systemd 서비스가 계속 재시작될 때 원인 9가지

Authors

서론

운영 중인 리눅스 서버에서 systemctl status를 봤더니 서비스가 몇 초마다 재시작되고, 로그에는 Scheduled restart job만 반복되는 상황은 생각보다 흔합니다. systemd는 기본적으로 서비스가 비정상 종료되면 Restart= 정책에 따라 자동 재기동을 수행하는데, 문제는 **"왜 종료되는지"**를 정확히 잡지 못하면 재시작 루프가 계속되어 장애가 확산된다는 점입니다.

이 글에서는 systemd 서비스가 계속 재시작될 때 현업에서 가장 자주 만나는 원인 9가지를 정리하고, 각 원인별로 어떤 로그/필드를 봐야 하는지, 유닛 파일에서 어디를 고쳐야 하는지를 함께 제시합니다.

> 참고: 쿠버네티스에서도 CrashLoopBackOff처럼 “재시작 루프”는 관측 포인트가 비슷합니다. 인프라 레벨 트러블슈팅 감각을 확장하고 싶다면 EKS kubelet NotReady - CNI plugin not initialized 해결 같은 글도 함께 보면 좋습니다.


재시작 루프를 먼저 ‘증거 기반’으로 고정하기

아래 3가지는 원인 분석 전에 꼭 확인하세요. 이 3개만으로도 절반은 결론이 납니다.

# 1) 현재 상태/최근 종료 코드/재시작 카운트
systemctl status myapp.service

# 2) 이번 부팅에서의 상세 로그(시간 역순)
journalctl -u myapp.service -b -e

# 3) systemd가 기록한 마지막 종료 원인/시그널/코드
systemctl show myapp.service \
  -p ExecMainStatus -p ExecMainCode -p ExecMainSignal \
  -p Result -p Restart -p RestartUSec -p NRestarts
  • ExecMainStatus: 프로세스 exit code
  • ExecMainSignal: 시그널로 죽었는지(SIGKILL 등)
  • Result: exit-code, signal, timeout, watchdog, resources 같은 systemd 관점의 분류

이제부터는 ResultExecMain*를 중심으로 9가지 원인을 좁혀갑니다.


원인 1) 애플리케이션이 즉시 종료(설정/인자 오류)

가장 흔한 케이스입니다. 바이너리가 실행되긴 하지만 설정 파일 경로, 환경변수, 포트 점유, 권한 문제 등으로 즉시 exit 1 하고 systemd가 재시작합니다.

확인 포인트

journalctl -u myapp.service -b --no-pager | tail -n 200
systemctl show myapp.service -p ExecStart -p ExecMainStatus -p ExecMainCode
  • 로그에 “config not found”, “permission denied”, “address already in use” 등이 직접 찍히는 경우가 많습니다.

유닛 파일 개선 팁

  • 환경변수/인자를 명시하고, 실패 시 더 빨리 드러나게 ExecStartPre=로 검증 단계를 둡니다.
[Service]
Type=simple
EnvironmentFile=/etc/myapp/myapp.env
ExecStartPre=/usr/bin/test -r /etc/myapp/config.yaml
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
Restart=on-failure
RestartSec=3

원인 2) Type= 불일치로 systemd가 “죽었다”고 오판

systemd는 Type=simple/forking/notify/oneshot에 따라 서비스의 생존을 판단합니다. 예를 들어 데몬이 포그라운드로 떠야 하는데 Type=forking으로 설정하면, 부모 프로세스가 종료되는 순간 systemd가 “서비스 종료”로 인식해 재시작할 수 있습니다.

확인 포인트

systemctl cat myapp.service
systemctl show myapp.service -p Type -p MainPID -p ExecMainPID
  • MainPID=0이거나 PID가 계속 바뀌면 의심하세요.

해결 예시

  • 포그라운드 실행이면 보통 Type=simple
  • systemd notify 지원 앱이면 Type=notify + sd_notify()
[Service]
Type=simple
ExecStart=/usr/local/bin/myapp --foreground
Restart=on-failure

원인 3) Restart= 정책이 너무 공격적(정상 종료도 재시작)

Restart=always는 **정상 종료(exit 0)**도 재시작합니다. 배치성 작업이나 “실행 후 종료”가 정상인 프로그램에 always를 걸면, 의도치 않은 무한 루프가 됩니다.

확인 포인트

systemctl show myapp.service -p Restart -p ExecMainStatus -p Result
  • ExecMainStatus=0인데도 재시작이면 거의 이 케이스입니다.

해결

  • 서버형은 on-failure, 배치형은 oneshot + RemainAfterExit=yes를 고려합니다.
[Service]
Type=oneshot
ExecStart=/usr/local/bin/my-batch
RemainAfterExit=yes
# Restart는 보통 필요 없음

원인 4) StartLimit(재시작 제한)과 짧은 RestartSec의 조합

서비스가 빠르게 죽으면 systemd는 일정 횟수 이상 재시작 후 Start request repeated too quickly로 막아버리기도 합니다. 반대로 StartLimit을 늘려두면 “무한히” 재시작하는 것처럼 보일 수 있습니다.

확인 포인트

journalctl -u myapp.service -b | grep -E "Start request repeated too quickly|start-limit" -n
systemctl show myapp.service -p StartLimitIntervalUSec -p StartLimitBurst -p RestartUSec

운영 관점 권장값

  • 장애 시 로그를 남기고 멈추게 하려면 StartLimit을 보수적으로
  • 빈번한 크래시로 시스템 부하가 커지는 걸 막기 위해 RestartSec를 늘립니다.
[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=10

원인 5) Watchdog 타임아웃(헬스 핑 미전송)

WatchdogSec=를 설정했거나, 배포된 유닛에 이미 들어있는 경우 앱이 systemd watchdog에 주기적으로 sd_notify("WATCHDOG=1")를 보내지 않으면 systemd가 강제 재시작합니다. 이때 Result=watchdog로 남습니다.

확인 포인트

systemctl show myapp.service -p WatchdogUSec -p Result
journalctl -u myapp.service -b | tail -n 200

해결

  • 앱이 sd_notify를 지원하지 않으면 WatchdogSec를 제거/완화합니다.
  • 지원한다면 Type=notify와 함께 구현을 맞춥니다.
[Service]
Type=notify
WatchdogSec=30
# 앱이 READY=1 / WATCHDOG=1을 보내야 함

원인 6) Timeout으로 인한 강제 종료(시작/정지 타임아웃)

서비스 시작이 오래 걸리거나(예: 마이그레이션, 캐시 워밍업), 종료 시그널을 무시해 정리가 지연되면 systemd가 TimeoutStartSec 또는 TimeoutStopSec 이후 강제로 죽이고 재시작할 수 있습니다. Result=timeout이 대표적입니다.

확인 포인트

systemctl show myapp.service -p TimeoutStartUSec -p TimeoutStopUSec -p Result
journalctl -u myapp.service -b | grep -i timeout -n

해결 예시

  • 시작이 느리면 TimeoutStartSec를 늘리되, 근본적으로는 초기화 작업을 분리하는 게 좋습니다.
[Service]
TimeoutStartSec=120
TimeoutStopSec=60
KillSignal=SIGTERM

원인 7) OOMKill/리소스 제한으로 종료(MemoryMax, TasksMax 등)

메모리 누수나 순간 스파이크로 커널 OOM Killer가 프로세스를 죽이면 systemd는 재시작합니다. 또는 유닛에 MemoryMax=, TasksMax=, CPUQuota= 같은 제한이 걸려 있어도 Result=resources로 떨어질 수 있습니다.

확인 포인트

# 커널 OOM 흔적
journalctl -k -b | grep -E "Out of memory|Killed process" -n

# systemd 리소스 제한 확인
systemctl show myapp.service -p MemoryMax -p TasksMax -p CPUQuota -p Result

해결

  • 누수/스파이크를 먼저 해결하고, 임시로 제한을 완화합니다.
[Service]
MemoryMax=1G
TasksMax=512
Restart=on-failure

원인 8) 의존 서비스/네트워크 준비 전 실행(순서 문제)

DB, Redis, 파일시스템 마운트, 네트워크가 준비되기 전에 앱이 뜨면 연결 실패로 즉시 종료 → 재시작 루프가 됩니다. 특히 부팅 직후에만 재현되는 경우가 많습니다.

확인 포인트

systemctl list-dependencies myapp.service
systemctl show myapp.service -p After -p Wants -p Requires
journalctl -u myapp.service -b | head -n 80

해결

  • After=/Wants=/Requires=를 명확히 하고, 네트워크는 network-online.target을 고려합니다.
  • 단, “서비스가 준비됨”은 target만으로 보장되지 않으니, ExecStartPre로 실제 포트 체크를 넣는 편이 더 안전합니다.
[Unit]
Wants=network-online.target
After=network-online.target

[Service]
ExecStartPre=/usr/bin/bash -lc 'until nc -z 127.0.0.1 5432; do sleep 1; done'
ExecStart=/usr/local/bin/myapp
Restart=on-failure

쿠버네티스에서도 비슷하게 “외부로 egress는 되는데 ingress만 실패” 같은 준비/경로 문제로 재시도가 반복되곤 합니다. 네트워크 경로 관점은 EKS에서 Pod egress는 되는데 ingress만 실패할 때도 참고할 만합니다.


원인 9) 시그널 처리/종료 코드 설계가 systemd와 충돌

앱이 SIGTERM을 받았을 때 정상 종료를 해야 하는데, 시그널을 잘못 처리해 비정상 종료 코드로 끝나면 systemd가 on-failure에서도 재시작합니다. 혹은 자식 프로세스만 남기고 부모가 죽어 PID 추적이 꼬이는 경우도 있습니다.

확인 포인트

systemctl show myapp.service -p ExecMainSignal -p ExecMainStatus -p KillMode
journalctl -u myapp.service -b -e

개선 포인트

  • graceful shutdown을 구현하고, 종료 시 exit code를 일관되게 설계합니다.
  • 멀티 프로세스/포크 모델이면 KillMode=PIDFile=을 신중히.
[Service]
KillSignal=SIGTERM
KillMode=control-group
SuccessExitStatus=0 143
Restart=on-failure
  • 143은 SIGTERM(15)로 종료된 프로세스가 흔히 반환하는 코드(128+15)라, 정상 종료로 취급하고 싶을 때 SuccessExitStatus에 포함합니다.

재시작 루프를 빠르게 끊는 응급 조치(운영 팁)

원인 분석 중에도 서버 부하를 줄이기 위해 “재시작 폭주”를 먼저 멈추는 것이 좋습니다.

# 즉시 중지
systemctl stop myapp.service

# 자동 재시작 방지(마스킹)
systemctl mask myapp.service

# 수정 후 다시 활성화
systemctl unmask myapp.service
systemctl daemon-reload
systemctl start myapp.service

또한 재시작 루프는 결국 “재시도 정책이 시스템을 어떻게 흔드는가”의 문제이기도 합니다. API/클라이언트 재시도 설계 관점이 필요하다면 OpenAI 429와 Rate Limit 헤더로 재시도 설계처럼 재시도 간격/백오프/상한을 다루는 글도 같은 맥락에서 도움이 됩니다.


결론

systemd 서비스 재시작 루프는 무작정 로그만 오래 보는 것보다, systemd가 제공하는 구조화된 신호(Result, ExecMainStatus, ExecMainSignal, Type, Watchdog, Timeout, 리소스 제한)를 기준으로 분류하면 훨씬 빠르게 해결됩니다.

정리하면 다음 순서가 효율적입니다.

  1. systemctl show로 종료 원인(코드/시그널/Result) 고정
  2. journalctl -u로 앱 로그에서 직접 원인 확인
  3. Type/Restart/Timeout/Watchdog/Limit을 유닛 파일에서 교정
  4. 의존성/준비성 문제는 After=보다 **실제 체크(ExecStartPre)**로 보강

이 9가지를 체크리스트로 돌리면, “왜 계속 재시작되는지”는 대부분 30분 안에 결론이 납니다.