Published on

systemd 서비스가 계속 재시작될 때 원인 추적법

Authors

서버에서 systemd 서비스가 “자꾸 재시작”되는 현상은 대개 프로세스가 빠르게 종료(exit/crash) 하거나, watchdog/health-check에 의해 강제 재시작되거나, 의존성/환경 문제로 시작 직후 실패하는 경우입니다. 문제는 동일한 증상(재시작) 뒤에 원인이 매우 다양하다는 점입니다.

이 글에서는 증거를 남기고(로그/코어덤프), 재시작 트리거를 확인하고, 유닛 설정을 통해 원인을 분류하는 방식으로 추적합니다. 운영 환경에서 흔히 겪는 “로그가 없어서 못 찾는” 상황을 피하기 위한 팁도 포함합니다.

1) 먼저 “누가” 재시작시키는지 확인하기

재시작은 크게 두 축입니다.

  • systemd가 Restart= 정책으로 재시작
  • 외부 요인(Watchdog, OOM Killer, 배포 스크립트, cron, 모니터링 에이전트)이 죽이거나 재기동

가장 먼저 서비스 상태에서 재시작 카운터와 마지막 종료 코드를 확인합니다.

systemctl status myservice.service
systemctl show myservice.service -p NRestarts -p ExecMainStatus -p ExecMainCode -p Result -p ActiveEnterTimestamp -p ActiveExitTimestamp
  • NRestarts가 빠르게 증가하면, systemd가 재시작 루프를 만들고 있을 가능성이 큽니다.
  • ExecMainCode/Status0이 아닌 종료 코드면 애플리케이션이 종료하거나 크래시했을 확률이 높습니다.
  • Result=exit-code, Result=signal은 단서가 됩니다.

“재시작 루프”가 맞는지 시간축으로 보기

systemctl list-units --type=service --state=failed
journalctl -u myservice.service --since "-30min" --no-pager

--since시간 범위를 좁히면 배포 직후인지, 트래픽 급증 시점인지, 특정 스케줄과 겹치는지 확인하기 쉽습니다. (트래픽/리소스 이슈가 의심되면, 원인 분석 방식은 EKS Ingress 503인데 Pod 정상일 때 점검 가이드처럼 “증상-계층별 체크리스트” 접근이 도움이 됩니다.)

2) 유닛 파일에서 재시작 조건(정책) 읽기

서비스가 “자꾸 재시작”되는 가장 흔한 이유는 유닛에 다음이 설정되어 있기 때문입니다.

  • Restart=always|on-failure|on-abnormal|on-watchdog
  • RestartSec=...
  • StartLimitIntervalSec, StartLimitBurst

현재 적용된 값을 덤프합니다.

systemctl cat myservice.service
systemctl show myservice.service \
  -p Restart -p RestartSec -p StartLimitIntervalSec -p StartLimitBurst \
  -p TimeoutStartSec -p TimeoutStopSec -p WatchdogSec \
  -p Type -p ExecStart -p ExecStartPre -p ExecStartPost

빠른 분기: Restart=always 인가?

  • Restart=always정상 종료(Exit 0) 도 재시작합니다.
  • 배치성 작업(한 번 실행 후 종료)인데 Type=simple + Restart=always이면, “정상인데도 계속 재시작”됩니다.

이 경우는 원인 추적이 아니라 유닛 설계 문제입니다. 배치라면 Type=oneshot + RemainAfterExit=yes 또는 Restart=no를 고려해야 합니다.

3) journalctl로 “마지막 실패 직전 로그”를 정확히 잡기

재시작 루프에서 가장 중요한 건 마지막 실패 직전의 로그입니다. 아래 옵션 조합이 실전에서 가장 유용합니다.

# 최근 200줄만
journalctl -u myservice.service -n 200 --no-pager

# 재시작 사이클을 시간순으로
journalctl -u myservice.service --since "-10min" --no-pager

# 우선순위 경고 이상만
journalctl -u myservice.service -p warning --since "-30min" --no-pager

# 부팅 단위로(최근 부팅에서만)
journalctl -u myservice.service -b --no-pager

로그가 너무 빨리 끊길 때: 표준출력/표준에러부터 확보

애플리케이션이 시작 직후 죽으면 파일 로그를 남기기도 전에 종료합니다. 이때는 stdout/stderr가 journald로 들어오도록 유닛을 점검합니다.

# /etc/systemd/system/myservice.service
[Service]
StandardOutput=journal
StandardError=journal

이미 기본이 journald일 때도 많지만, 컨테이너 런타임/래퍼 스크립트 때문에 누락되는 경우가 있습니다.

4) 종료 원인을 “exit-code vs signal vs OOM”으로 분류

4-1) exit-code: 애플리케이션이 스스로 종료

이 경우 ExecMainStatus=1 같은 형태로 나타나며, 로그에 설정 파일 파싱 실패, 포트 바인딩 실패, DB 연결 실패 등이 남습니다.

자주 보는 패턴:

  • bind: address already in use
  • permission denied (포트 1024 미만, 파일/디렉토리 권한)
  • No such file or directory (WorkingDirectory, ExecStart 경로)
  • EnvironmentFile 누락

실전 팁: systemd가 실행하는 환경은 로그인 셸과 다릅니다. PATH, HOME, ulimit 등이 달라서만 실패하는 경우가 많습니다.

systemctl show myservice.service -p Environment -p EnvironmentFiles -p WorkingDirectory -p User -p Group

4-2) signal: SIGSEGV/SIGABRT 등 크래시

Result=signal 또는 로그에 segfault가 보이면 코어덤프를 확인합니다.

coredumpctl list myservice
coredumpctl info myservice
coredumpctl gdb myservice
  • 네이티브 바이너리(C/C++/Go 일부 CGO, JVM의 native, Python 확장 등)에서 크래시가 날 수 있습니다.
  • 컨테이너/샌드박스 환경에서 코어덤프 저장이 제한될 수 있으니, Storage= 정책과 디스크 여유도 확인하세요.

4-3) OOM Killer: 메모리 부족으로 강제 종료

프로세스가 갑자기 죽고 로그가 깨끗하면 OOM을 의심합니다.

# 커널 로그에서 OOM 흔적
journalctl -k --since "-1h" | grep -i -E "oom|killed process|out of memory"

# systemd 관점의 종료 이유
systemctl status myservice.service

systemd 유닛에 MemoryMax= 같은 제한이 걸려 있어도 OOM처럼 보일 수 있습니다.

systemctl show myservice.service -p MemoryMax -p MemoryHigh -p TasksMax -p CPUQuota

5) “시작은 되는데 바로 죽는” 경우: Type/Notify/Timeout 함정

systemd는 서비스가 “정상 기동”했다고 판단하는 기준이 Type=에 따라 달라집니다.

  • Type=simple: ExecStart가 실행되면 즉시 active로 간주
  • Type=forking: 부모가 포크 후 종료해야 함(PIDFile 필요)
  • Type=notify: 프로세스가 sd_notify(READY=1)를 보내야 함

Type=notify인데 애플리케이션이 notify를 안 보내면 TimeoutStartSec 이후 실패 → 재시작 루프가 됩니다.

systemctl show myservice.service -p Type -p TimeoutStartSec
journalctl -u myservice.service --since "-10min" | grep -i -E "timeout|start"

해결은 둘 중 하나입니다.

  • 애플리케이션이 sd_notify를 지원하도록 설정
  • 또는 유닛에서 Type=simple로 변경(가능한 경우)

6) ExecStartPre/ExecStartPost, 스크립트 실패도 흔한 원인

서비스 본체가 아니라 사전 작업(마이그레이션, 설정 생성, 디렉토리 생성) 에서 실패하면 systemd는 서비스 실패로 처리합니다.

systemctl show myservice.service -p ExecStartPre -p ExecStart -p ExecStartPost
journalctl -u myservice.service --since "-30min" --no-pager

사전 스크립트는 “셸의 -e 옵션”이나 파이프 실패로 쉽게 죽습니다. 가능하면 스크립트에 명확한 로깅을 넣고, 실패 시 exit code를 의도적으로 반환하세요.

7) 재시작 루프를 “멈춰서” 디버깅하기 (운영 안전)

재시작이 너무 빠르면 로그가 밀려 분석이 어려워집니다. 임시로 재시작을 멈추고 단발 실행으로 원인을 확인합니다.

7-1) 임시 override로 Restart 끄기

sudo systemctl edit myservice.service
# drop-in: /etc/systemd/system/myservice.service.d/override.conf
[Service]
Restart=no

적용:

sudo systemctl daemon-reload
sudo systemctl restart myservice.service

7-2) StartLimit으로 폭주 방지

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3

[Service]
Restart=on-failure
RestartSec=5

이렇게 하면 1분에 3번 이상 실패 시 start-limit-hit로 멈춰서, 장애가 다른 리소스를 잠식하는 것을 막습니다.

8) 환경/권한 문제 체크리스트 (의외로 가장 많음)

systemd는 기본적으로 더 “엄격한” 환경을 제공합니다. 다음을 집중 점검하세요.

  • User=로 실행하는데 로그/소켓/데이터 디렉토리 권한이 없음
  • WorkingDirectory=가 존재하지 않음
  • EnvironmentFile= 경로가 틀림(특히 배포 시 심볼릭 링크 변경)
  • NoNewPrivileges=yes, ProtectSystem=strict, PrivateTmp=yes 등 하드닝 옵션 때문에 파일 접근 실패
systemctl show myservice.service \
  -p User -p Group -p WorkingDirectory -p EnvironmentFile \
  -p NoNewPrivileges -p ProtectSystem -p ProtectHome -p PrivateTmp

디스크가 100%에 가까우면 journald/애플리케이션 로그 기록이 막혀 “원인 로그가 사라지는” 현상이 생깁니다. 이 경우는 먼저 디스크부터 정상화해야 하며, 삭제했는데도 용량이 안 줄면 deleted-but-open 상태를 의심해 보세요: 리눅스 디스크 100%인데 용량이 안 줄 때 - deleted-but-open(lsof)

9) 실전 예시: 재시작 원인 3분 안에 좁히는 명령 세트

아래 순서대로 실행하면 대부분의 케이스에서 “무슨 종류의 문제인지”는 빠르게 잡힙니다.

# 1) 상태/종료코드/재시작 횟수
systemctl show myservice -p NRestarts -p ExecMainCode -p ExecMainStatus -p Result

# 2) 최근 로그
journalctl -u myservice -n 200 --no-pager

# 3) 커널(OOM/segfault)
journalctl -k --since "-30min" --no-pager | tail -n 200

# 4) 코어덤프(크래시 의심 시)
coredumpctl list myservice | tail -n 5

# 5) 유닛 재시작 정책 확인
systemctl show myservice -p Restart -p RestartSec -p StartLimitIntervalSec -p StartLimitBurst -p Type -p WatchdogSec

여기서 갈림길은 다음과 같습니다.

  • ExecMainStatus != 0 + 애플리케이션 에러 로그 존재 → 앱 설정/의존성 문제
  • Result=signal + 코어덤프 존재 → 크래시(버그/네이티브/라이브러리)
  • 커널 로그에 OOM → 메모리/리소스 제한
  • Type=notify + timeout → 유닛 Type/notify 불일치
  • Restart=always + 정상 종료 → 유닛 설계 문제

10) 재발 방지: 관측 가능성(Observability)과 유닛 설계

원인을 찾았더라도, 다음을 해두면 “다음 장애 때” 추적 시간이 크게 줄어듭니다.

  • SyslogIdentifier=로 서비스 로그 식별자 고정
  • 애플리케이션 시작 시 설정/버전/환경을 한 줄로 로깅
  • Restart=on-failure를 기본으로 두고, StartLimit*로 폭주 방지
  • TimeoutStartSec를 현실적인 값으로(너무 짧으면 오탐)
  • 리소스 상한(MemoryMax, CPUQuota)을 둔다면, 초과 시 어떤 로그가 남는지 사전 테스트

특히 “증상은 네트워크처럼 보이는데 실제 원인은 다른 계층”인 경우가 많습니다. 예를 들어 서비스가 재시작하면서 순간적으로 포트가 닫혀 503이 발생하기도 하고, 반대로 upstream 503 때문에 앱이 예외 처리 없이 종료하며 재시작 루프에 빠지기도 합니다. 이런 경우에는 계층별로 원인을 분리하는 접근이 중요합니다. (네트워크/게이트웨이 관점 점검은 EKS Ingress 503인데 Pod 정상일 때 점검 가이드에서도 같은 원리로 다룹니다.)

마무리

systemd 서비스 재시작 문제는 “재시작된다”는 현상만으로는 답이 나오지 않습니다. 대신 다음 3가지만 기억하면 추적이 빨라집니다.

  1. systemctl show/status재시작 정책과 종료 코드를 먼저 확인한다.
  2. journalctl -ujournalctl -k유저 영역/커널 영역 로그를 나눠 본다.
  3. 크래시가 의심되면 coredumpctl코어덤프 기반으로 파고든다.

이 흐름대로 보면 대부분의 재시작 루프는 “설정/환경”, “크래시”, “리소스(OOM/제한)”, “유닛 타입/타임아웃” 중 하나로 정리되며, 그 다음부터는 재현과 수정이 훨씬 쉬워집니다.