Published on

systemd 서비스 무한 재시작 10분 진단 체크리스트

Authors

서버에서 systemctl status 를 봤더니 activating (auto-restart) 가 반복되고, CPU가 튀거나 로그가 폭주하는 상황은 운영에서 자주 만납니다. systemd의 재시작은 "원인을 가려주는" 장치가 아니라 "증상을 빠르게 반복" 시키는 장치이기 때문에, 진단은 재시작 루프를 잠깐 멈추고(또는 완화하고) 로그와 종료 코드를 확보하는 것부터 시작해야 합니다.

이 글은 systemd 서비스가 무한 재시작될 때, 10분 안에 원인을 좁히는 순서를 체크리스트 형태로 정리합니다. 컨테이너의 CrashLoopBackOff 를 5분 안에 보는 방식과 유사한데, 접근법이 도움이 된다면 EKS Pod CrashLoopBackOff? OOMKilled 5분 진단 도 함께 참고해도 좋습니다.

0분~1분: 재시작 루프 멈추고 “증거” 확보

무한 재시작 중에는 로그가 빠르게 밀리고, 상태가 계속 바뀌어 관찰이 어렵습니다. 우선 아래 중 하나로 루프를 멈춰서 관찰 가능한 상태를 만듭니다.

가장 안전한 방법: 일시 중지 후 수동 실행

sudo systemctl stop myapp.service
sudo systemctl reset-failed myapp.service

# 수동으로 한 번만 실행해 종료 코드/에러를 확인
sudo systemctl start myapp.service

재시작 완화(디버깅용): RestartSec 늘리기

운영 중 완전 중지가 부담되면, 일시적으로 재시작 간격을 늘려 로그를 보기 쉽게 만듭니다.

sudo systemctl edit myapp.service
[Service]
RestartSec=10s

적용:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

1분~3분: systemctl status 에서 핵심 4가지 읽기

다음 명령으로 현재 상태를 한 번에 봅니다.

systemctl status myapp.service -l --no-pager

여기서 즉시 확인할 포인트는 4가지입니다.

  1. Active: activating (auto-restart) 인지, failed 인지
  2. Main PIDExecStart: 실제 어떤 바이너리/스크립트를 실행하는지
  3. Exit code: status=1/FAILURE 같은 종료 코드
  4. 최근 로그 10줄: 에러 메시지가 이미 찍혀 있는지

특히 아래 패턴은 원인 분류에 직접 도움이 됩니다.

  • status=203/EXEC : 실행 파일 경로가 틀렸거나 실행 권한/형식 문제
  • status=217/USER : User= 설정이 잘못되었거나 해당 유저가 없음
  • status=1/FAILURE : 앱 자체 오류(환경변수 누락, 설정 파일 문제 등) 가능성 큼
  • status=137 : 보통 SIGKILL 로 종료. OOM 또는 강제 kill 가능성

3분~5분: journalctl 로 “이번 부팅에서의 실패 원인” 고정

systemctl status 의 로그는 일부만 보여줍니다. 실패 원인은 journalctl 로 고정해서 봐야 합니다.

최근 실패 로그만 빠르게 보기

journalctl -u myapp.service -b --no-pager -n 200

시간순으로 따라가기(재시작 루프 관찰)

journalctl -u myapp.service -b -f

systemd 자체 메시지도 함께 보기

서비스 로그만 보면 Start request repeated too quickly 같은 systemd 레벨 메시지를 놓칩니다.

journalctl -b --no-pager -n 300 | grep -E "myapp|systemd\[1\]"

여기서 가장 자주 나오는 원인군은 다음입니다.

  • 설정 파일 파싱 실패(예: YAML/JSON 문법 오류)
  • 환경변수/시크릿 누락(예: DATABASE_URL 미설정)
  • 포트 바인딩 실패(이미 사용 중, 권한 부족)
  • 파일/디렉터리 권한 문제(로그 디렉터리 생성 실패 등)
  • 외부 의존성 실패(DB 접속 실패, DNS 실패)

5분~7분: unit 파일에서 “재시작을 부르는 설정” 점검

서비스가 죽는 이유가 앱 자체가 아니라 unit 설정인 경우가 많습니다. 아래를 덤프해서 확인합니다.

systemctl cat myapp.service
systemctl show myapp.service --no-pager | sed -n '1,180p'

1) Type= 와 프로세스 생명주기 불일치

Type=simple 이 기본인데, 앱이 포그라운드에서 계속 살아 있어야 합니다. 데몬화(fork)하거나 즉시 종료하는 프로그램을 simple 로 띄우면 "정상 종료" 가 systemd에 의해 실패로 오인되거나, 반대로 준비되기 전에 다음 단계가 진행될 수 있습니다.

  • Type=forking : 전통적인 데몬이 fork 후 부모가 종료되는 패턴
  • Type=notify : 앱이 systemd notify로 준비 완료를 알림
  • Type=oneshot : 작업 1회 실행 후 종료가 정상인 작업

예를 들어 마이그레이션 스크립트 같은 작업을 서비스로 만들었는데 Restart=always 를 걸어두면, 정상 종료해도 계속 재시작됩니다.

[Service]
Type=oneshot
ExecStart=/opt/myapp/migrate.sh
RemainAfterExit=yes
Restart=no

2) Restart= 정책이 문제를 증폭시키는지

  • Restart=always : 정상 종료도 재시작
  • Restart=on-failure : 비정상 종료만 재시작(대부분 권장)

디버깅 중에는 아래처럼 바꿔 한 번만 실패하게 만들어 원인을 고정하는 게 좋습니다.

[Service]
Restart=no

3) StartLimit* 로 인해 “너무 빨리 재시작” 차단 발생

로그에 Start request repeated too quickly 가 나오면 아래 값을 확인합니다.

systemctl show myapp.service --property=StartLimitIntervalUSec --property=StartLimitBurst

임시로 완화:

[Unit]
StartLimitIntervalSec=0

[Service]
RestartSec=5s

주의: 근본 원인을 해결하기 전까지는 폭주를 막기 위해 무한 완화는 피하는 게 좋습니다.

7분~9분: 실행 파일/권한/환경변수/작업 디렉터리 확인

무한 재시작에서 빈도가 높은 실수는 "서비스로 실행할 때만" 깨지는 조건입니다.

1) ExecStart 경로, 실행 권한, shebang

status=203/EXEC 가 보이면 거의 여기입니다.

# unit에 적힌 경로를 그대로 확인
ls -al /opt/myapp/myapp
file /opt/myapp/myapp

# 스크립트라면 첫 줄 shebang 확인
head -n 1 /opt/myapp/myapp
  • 스크립트인데 실행 권한이 없음
  • #!/bin/bash 경로가 실제 서버와 다름
  • 윈도우 개행(CRLF)로 실행 실패

2) User= Group= 및 런타임 디렉터리 권한

서비스 유저로 실행했을 때만 쓰기 권한이 없어 죽는 경우가 많습니다.

# 어떤 유저로 뜨는지 확인
systemctl show myapp.service --property=User --property=Group

# 해당 유저로 직접 실행해보기
sudo -u myappuser -H /opt/myapp/myapp --version

# 로그/데이터 디렉터리 권한
sudo -u myappuser -H bash -lc 'touch /var/log/myapp/test && rm /var/log/myapp/test'

권장 패턴은 systemd의 RuntimeDirectory= StateDirectory= 를 사용해 디렉터리를 systemd가 만들어주게 하는 것입니다.

[Service]
User=myappuser
Group=myappuser
RuntimeDirectory=myapp
StateDirectory=myapp
LogsDirectory=myapp

3) 환경변수 로딩 차이(EnvironmentFile, 셸 프로파일 미적용)

터미널에서 잘 되는데 서비스로만 실패한다면, 대개 환경변수 차이입니다.

systemctl show myapp.service --property=Environment --property=EnvironmentFiles

EnvironmentFile= 을 쓰는 경우 파일 존재/권한을 확인합니다.

sudo ls -al /etc/myapp/myapp.env
sudo cat /etc/myapp/myapp.env

서비스에서 셸 문법(예: export KEY=VALUE)을 그대로 넣어 파싱 실패하는 경우도 있으니, systemd 환경 파일 형식(그냥 KEY=VALUE)을 지키는지 확인합니다.

4) WorkingDirectory= 존재 여부

작업 디렉터리가 없으면 시작 즉시 실패할 수 있습니다.

systemctl show myapp.service --property=WorkingDirectory
ls -al /opt/myapp

9분~10분: 리소스(OOM), 포트, 의존성 순서만 빠르게 체크

1) OOM Kill 여부 확인

status=137 또는 로그가 갑자기 끊기면 OOM을 의심합니다.

# 커널 OOM 로그
journalctl -k -b --no-pager | grep -i -E "oom|killed process"

# 서비스에 OOM 관련 표시가 있는지
systemctl status myapp.service -l --no-pager

메모리 상한을 systemd에서 걸어둔 경우도 확인합니다.

systemctl show myapp.service --property=MemoryMax --property=MemoryHigh --no-pager

2) 포트 점유/권한 문제

# 8080 예시
sudo ss -ltnp | grep ':8080'

1024 미만 포트를 non-root로 바인딩하면 실패합니다. 이때는 포트를 올리거나 AmbientCapabilities=CAP_NET_BIND_SERVICE 같은 설정을 검토합니다(보안 영향이 있으니 신중).

3) 의존성(DB, 네트워크, 마운트) 준비 전 기동

부팅 직후만 실패하고 조금 지나면 성공하는 경우 After= Wants= 정렬 문제일 수 있습니다.

[Unit]
Wants=network-online.target
After=network-online.target

# DB나 마운트가 필요하면 해당 유닛/마운트를 After에 추가

Kubernetes의 동기화/상태 루프처럼 "순서" 문제로 계속 실패하는 케이스는 운영에서 은근히 많습니다. 비슷한 관점의 루프 진단 글로 Argo CD Sync Failed - OutOfSync 무한루프 해결 도 참고할 만합니다.

재현 가능한 “최소 실행”으로 원인 분리하기

10분 진단의 목표는 완벽한 해결이 아니라, 원인을 다음 중 하나로 분류하고 재현 경로를 확보하는 것입니다.

  • systemd 설정 문제(unit 파일, Type/Restart/StartLimit)
  • 실행/권한 문제(경로, 권한, 유저, 작업 디렉터리)
  • 환경 문제(환경변수, 파일 경로, 시크릿)
  • 리소스 문제(OOM, 파일 디스크립터, CPU 제한)
  • 외부 의존성 문제(DB, DNS, 네트워크, 마운트, 포트 충돌)

가장 효과적인 방법은 ExecStart 를 그대로 복사해서, 서비스 유저로, 같은 작업 디렉터리에서, 같은 환경변수로 실행해보는 것입니다.

# unit에서 WorkingDirectory, User, EnvironmentFile을 확인했다고 가정
sudo -u myappuser -H bash -lc '
  cd /opt/myapp && 
  set -a && [ -f /etc/myapp/myapp.env ] && . /etc/myapp/myapp.env && set +a &&
  /opt/myapp/myapp --config /etc/myapp/config.yml
'

이렇게 실행했을 때도 즉시 종료된다면 앱/환경 문제이고, 수동 실행은 정상인데 systemd에서만 실패한다면 unit 설정(특히 Type= WorkingDirectory= User= EnvironmentFile= TimeoutStartSec=) 쪽으로 범위를 좁히면 됩니다.

자주 쓰는 디버깅용 unit 예시(템플릿)

아래는 "로그를 더 남기고, 한 번 실패하면 멈추게" 하는 디버깅 친화 설정 예시입니다.

[Unit]
Description=MyApp
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myappuser
Group=myappuser
WorkingDirectory=/opt/myapp

EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/opt/myapp/myapp --config /etc/myapp/config.yml

# 디버깅 중에는 재시작을 꺼서 원인 고정
Restart=no

# 로그를 저널로 고정
StandardOutput=journal
StandardError=journal

# 너무 빠른 실패 방지(관찰용)
TimeoutStartSec=60

[Install]
WantedBy=multi-user.target

마무리: “무한 재시작”은 대부분 3줄에서 끝난다

현장에서 systemd 무한 재시작의 상당수는 아래 셋 중 하나로 수렴합니다.

  1. journalctl -u ... -b 에 이미 찍힌 설정/환경변수 에러
  2. status=203/EXEC 류의 실행 경로/권한 문제
  3. 서비스 유저 권한 또는 WorkingDirectory 부재

위 순서대로 보면 10분 안에 원인 범위를 크게 줄일 수 있습니다. 다음 단계로는 재시작 정책을 on-failure 로 정리하고, 의존성 준비(network-online.target, 마운트, DB)와 리소스 상한을 명시해 같은 장애가 재발하지 않게 만드는 것이 핵심입니다.