Published on

systemd 서비스가 자꾸 재시작될 때 7단계 진단

Authors

서버에서 systemd 서비스가 “올라왔다가 바로 죽고”, Restart=always 같은 설정 때문에 계속 재시작되는 상황은 운영에서 매우 흔합니다. 문제는 재시작 자체가 원인이 아니라 증상이라는 점입니다. 즉, systemd는 단지 “프로세스가 비정상 종료했다”는 사실을 감지하고 정책에 따라 다시 띄울 뿐입니다.

이 글은 systemd 서비스가 자꾸 재시작될 때, 원인을 7단계로 좁혀가며 재현·확인·수정하는 실전 진단 루틴을 제공합니다. (애플리케이션 버그부터 환경/권한/의존성/리소스/타임아웃까지)

> 컨테이너 환경에서의 반복 재시작은 Kubernetes의 CrashLoopBackOff와 증상이 유사합니다. 원인 분류/진단 감각을 확장하고 싶다면 Kubernetes CrashLoopBackOff 원인별 10분 진단도 함께 참고하면 좋습니다.

1단계: “무엇이 재시작을 트리거했는지” 상태 요약부터

가장 먼저 할 일은 systemd가 보고 있는 상태를 확인하는 것입니다. 여기서 핵심은 다음 3가지입니다.

  • Active:activating (auto-restart)인지
  • Result:exit-code, signal, timeout 등 무엇인지
  • Main PID의 종료 코드/시그널이 무엇인지
sudo systemctl status myservice -l
sudo systemctl show myservice \
  -p ActiveState -p SubState -p Result \
  -p ExecMainStatus -p ExecMainCode -p ExecMainStartTimestamp -p ExecMainExitTimestamp \
  -p NRestarts
  • ExecMainCode=exited + ExecMainStatus=1이면 앱이 exit 1로 종료한 것입니다.
  • ExecMainCode=killed + ExecMainStatus=9SIGKILL로 죽었습니다(OOM killer, 관리자 kill, cgroup 제한 등 가능성).
  • Result=timeout이면 systemd가 정해진 시간 안에 시작/정지에 실패했다고 판단한 것입니다.

이 단계에서 목표는 “앱이 왜 죽는지”가 아니라, “죽는 형태가 무엇인지”를 분류하는 것입니다. 분류가 되면 다음 단계에서 로그/설정 확인이 빨라집니다.

2단계: journalctl로 ‘마지막 실패 직전/직후’ 로그를 좁혀라

재시작 루프에서 가장 흔한 실수는 로그를 너무 넓게 보는 것입니다. 마지막 실패 시점 주변만 정확히 보면 원인이 드러나는 경우가 많습니다.

# 최근 부팅 이후 해당 유닛 로그
sudo journalctl -u myservice -b --no-pager

# 가장 최근 5분만
sudo journalctl -u myservice -S "-5min" --no-pager

# 재시작 루프 중이면 tail로 추적
sudo journalctl -u myservice -f

추가로, systemd 자체 메시지(권한/실행 실패/타임아웃)는 커널/시스템 로그에 섞여 있을 수 있습니다.

sudo journalctl -b -p warning..alert --no-pager

여기서 자주 나오는 패턴:

  • ExecStart=/path: No such file or directory → 바이너리 경로/권한/동적 링커 문제
  • Permission denied → User/Group, 파일 권한, SELinux/AppArmor
  • Address already in use → 포트 점유(이전 인스턴스 잔존, 다른 서비스 충돌)
  • Failed at step USER spawning → 유닛의 User=가 존재하지 않음
  • 애플리케이션 스택트레이스/설정 파일 파싱 오류 → 앱 자체 문제

3단계: unit 파일의 Restart 정책이 “장애를 증폭”시키는지 확인

재시작 루프는 종종 원인을 가리는 역할을 합니다. 특히 아래 설정은 장애를 더 시끄럽게 만들 수 있습니다.

  • Restart=always (정상 종료도 재시작)
  • RestartSec=0 (너무 빠른 루프)
  • StartLimitIntervalSec, StartLimitBurst 기본값으로 인해 “갑자기 시작 제한” 발생
sudo systemctl cat myservice
sudo systemctl show myservice -p Restart -p RestartSec -p StartLimitIntervalSec -p StartLimitBurst

권장 점검 포인트:

  • 일반적인 서버 프로세스는 Restart=on-failure가 더 안전합니다.
  • 루프를 완화하려면 RestartSec=2s~10s로 지연을 둡니다.
  • 디버깅 중에는 잠시 재시작을 꺼서 “한 번 죽고 멈추게” 만드는 것도 유효합니다.

예시(디버깅용 override):

sudo systemctl edit myservice
[Service]
Restart=no

적용:

sudo systemctl daemon-reload
sudo systemctl restart myservice

4단계: ExecStart를 ‘systemd 밖에서’ 동일 조건으로 실행해 본다

systemd 환경에서만 실패하는 서비스는 의외로 많습니다. 대표적으로:

  • PATH/환경변수 차이
  • WorkingDirectory 미설정
  • 파일 디스크립터 제한, 권한, capabilities
  • 상대 경로 사용

unit 파일의 실행 조건을 그대로 재현하려면 아래를 씁니다.

# 유닛이 어떤 사용자로 실행되는지 확인
sudo systemctl show myservice -p User -p Group -p WorkingDirectory -p Environment -p EnvironmentFile

# 해당 사용자로 직접 실행(WorkingDirectory 반영 권장)
sudo -u myuser -H bash -lc 'cd /opt/myapp && /opt/myapp/bin/myservice --config /etc/myservice/config.yml'

또는 systemd가 제공하는 systemd-run으로 “유닛과 유사한 방식”으로 띄워볼 수도 있습니다.

sudo systemd-run --unit=myservice-debug \
  --property=WorkingDirectory=/opt/myapp \
  --property=User=myuser \
  /opt/myapp/bin/myservice --config /etc/myservice/config.yml

sudo journalctl -u myservice-debug -f

이 단계의 목표는 “앱이 진짜로 실행 가능한 상태인지”를 systemd 외부에서 검증하는 것입니다. 여기서 실패하면 앱/환경 문제이고, 여기서 성공하는데 systemd에서만 실패하면 unit 설정/권한/제한 문제일 확률이 큽니다.

5단계: 종료 코드/시그널을 기반으로 ‘죽인 주체’를 판별

재시작 루프의 원인은 크게 두 부류입니다.

  1. 앱이 스스로 종료한다(설정 오류, 예외, 의존 서비스 실패)
  2. 외부가 앱을 죽인다(OOM killer, watchdog, timeout, cgroup 제한)

OOM killer 의심 (SIGKILL, code 137 등)

SIGKILL(9)은 앱이 잡을 수 없기 때문에, OOM/관리자 kill/cgroup 제한을 의심해야 합니다.

# 커널 OOM 로그 확인
sudo journalctl -k -S "-30min" | egrep -i "oom|killed process|out of memory"

# systemd가 본 종료 형태
sudo systemctl show myservice -p ExecMainCode -p ExecMainStatus

메모리 급증이 원인이라면:

  • 앱 메모리 누수/캐시 폭주
  • MemoryMax= 같은 cgroup 제한
  • JVM/Node/Python 런타임 튜닝 부재

Watchdog/Timeout 의심

Result=timeout 또는 Watchdog 관련 로그가 보이면, 시작/정지 시간이 systemd 기대치를 넘었거나 watchdog ping이 없을 수 있습니다.

sudo systemctl show myservice -p TimeoutStartSec -p TimeoutStopSec -p WatchdogSec

6단계: 의존성(네트워크/DB/파일/포트)과 “준비 순서”를 점검

서비스가 재시작되는 흔한 원인은 외부 의존성이 아직 준비되지 않았는데 시작해버리는 것입니다. 예:

  • DB가 늦게 뜸 → 앱이 커넥션 실패로 종료
  • 마운트가 늦음 → 설정/데이터 디렉터리 없음
  • 포트 충돌 → 바인딩 실패
  • DNS 문제 → upstream 해석 실패

systemd 의존성/순서 확인

sudo systemctl show myservice -p After -p Wants -p Requires
sudo systemctl list-dependencies myservice

네트워크가 필요하면 최소한:

  • After=network-online.target
  • Wants=network-online.target

를 고려합니다(단, 배포판에 따라 NetworkManager-wait-online.service 등이 실제로 online을 보장하는지 확인 필요).

포트 점유 확인

sudo ss -ltnp | grep ':8080'
# 또는
sudo lsof -iTCP:8080 -sTCP:LISTEN -n -P

DB/외부 API 의존성으로 인해 앱이 즉시 종료되는 경우

애플리케이션 쪽에서 “연결 실패 시 즉시 exit” 대신 재시도(backoff) 를 두면 systemd 재시작 루프를 크게 줄일 수 있습니다. 이 관점은 API Rate Limit 대응에서의 지수 백오프와도 유사합니다. 필요하면 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기의 백오프 설계 아이디어를 인프라 의존성 재시도에도 응용할 수 있습니다.

7단계: systemd의 보안/리소스 제한(sandboxing, ulimit, cgroup) 확인

최근 서비스 유닛은 보안 강화를 위해 여러 제한을 겁니다. 이 제한이 앱과 충돌하면 “정상 실행이 불가능한데 계속 재시작”이 됩니다.

자주 문제되는 항목:

  • NoNewPrivileges=yes
  • ProtectSystem=strict
  • ProtectHome=yes
  • PrivateTmp=yes
  • ReadWritePaths= 미설정(쓰기 필요한 경로가 막힘)
  • LimitNOFILE= (FD 부족)
  • MemoryMax=, CPUQuota=
sudo systemctl show myservice \
  -p NoNewPrivileges -p ProtectSystem -p ProtectHome -p PrivateTmp \
  -p ReadWritePaths -p ReadOnlyPaths \
  -p LimitNOFILE -p LimitNPROC \
  -p MemoryMax -p CPUQuota

FD 부족(Too many open files) 예시

로그에 EMFILE/Too many open files가 보이면 LimitNOFILE을 올리거나(그리고 앱도 커넥션/소켓 누수를 막아야 함) OS 전역 제한도 확인해야 합니다.

# /etc/systemd/system/myservice.service.d/override.conf
[Service]
LimitNOFILE=65536
sudo systemctl daemon-reload
sudo systemctl restart myservice

파일 쓰기 금지로 인한 즉시 종료 예시

ProtectSystem=strict를 켜고 로그/소켓/캐시를 /var/lib/.../var/log/...에 쓰는데 ReadWritePaths를 안 열어두면 바로 죽을 수 있습니다.

[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/myservice /var/log/myservice

재시작 루프를 “진단 가능하게” 만드는 추천 설정

문제 해결 자체와 별개로, 다음 설정은 장애 시 원인 파악을 쉽게 합니다.

[Service]
# 실패 시에만 재시작
Restart=on-failure
RestartSec=3s

# 로그를 journal에 명확히
StandardOutput=journal
StandardError=journal

# 코어덤프/디버깅이 필요하면(보안 정책에 맞게)
# LimitCORE=infinity

또한 앱이 너무 빨리 죽어서 로그가 남지 않는다면, 임시로 ExecStartPre=/bin/sleep 2 같은 방식으로 “관찰 시간”을 주는 것도 디버깅에 도움이 됩니다(원인 해결 후 제거).

마무리: 7단계 체크리스트(요약)

  1. systemctl status/show로 종료 형태(코드/시그널/timeout) 분류
  2. journalctl -u -b로 마지막 실패 전후 로그 집중
  3. Restart=/StartLimit*로 루프 증폭 여부 점검(필요 시 디버깅 동안 Restart 끄기)
  4. systemd 밖에서 동일 사용자/디렉터리로 실행해 재현
  5. SIGKILL/OOM/timeout/watchdog 등 “죽인 주체” 판별
  6. 의존성(네트워크/마운트/DB/포트/DNS)과 준비 순서 확인
  7. sandbox/ulimit/cgroup 제한으로 인한 실행 불가 여부 확인

이 루틴대로 보면 대부분의 재시작 루프는 10~30분 내에 원인 범위를 좁힐 수 있습니다. 그래도 원인이 불명확하다면, systemctl show 출력과 journalctl -u myservice -b의 마지막 200줄을 확보한 뒤(민감정보 마스킹) 유닛 파일과 함께 분석하면 해결 속도가 크게 올라갑니다.