Published on

systemd 서비스 재시작 무한루프 원인추적

Authors

운영 중인 Linux 서버에서 systemd 서비스가 active 로 잠깐 떴다가 곧바로 내려가며 재시작을 반복하면, 증상 자체보다 왜 systemd가 재시작을 선택했는지를 먼저 확인해야 합니다. 무한루프는 애플리케이션 버그일 수도 있지만, 유닛 파일 설정(Restart=, TimeoutStartSec=), 환경 변수 누락, 권한/경로 문제, 의존 서비스 준비 미완료 같은 운영 레이어 문제가 원인인 경우가 매우 많습니다.

이 글은 “재시작 루프”를 **재현 가능한 관찰 단위(종료 코드, 타임라인, 조건)**로 쪼개고, systemd가 남긴 힌트를 기반으로 원인을 추적하는 순서를 제공합니다. 마지막에는 재발 방지용 유닛 설정 패턴도 함께 정리합니다.

1) 먼저 확인할 것: 재시작 루프의 형태를 분류

systemd의 재시작 루프는 보통 아래 중 하나입니다.

  1. 즉시 종료 반복: 프로세스가 시작 직후 바로 종료(설정/환경/실행파일 문제)
  2. 타임아웃 반복: 시작은 했지만 TimeoutStartSec= 내에 준비 완료 신호를 못 보내거나, 포그라운드 유지 실패
  3. 의존성/순서 문제: DB, 네트워크, 마운트가 준비되기 전에 서비스가 떠서 실패
  4. watchdog/헬스체크 실패: WatchdogSec= 또는 앱 내부 헬스체크 실패로 종료
  5. 리소스/권한 문제: 포트 바인딩, 파일 접근, cgroup 제한, NoNewPrivileges=

무한루프를 멈추지 않고 관찰하면 로그가 쌓이고 장애가 확대될 수 있으니, 진단 중에는 잠깐 재시작을 멈추는 것도 방법입니다.

sudo systemctl stop myservice
sudo systemctl reset-failed myservice

reset-failed 는 “Start request repeated too quickly” 같은 상태를 초기화해 이후 테스트를 쉽게 합니다.

2) 가장 중요한 2개 명령: status 와 journalctl

2-1) systemctl status 로 종료 코드와 재시작 정책 확인

systemctl status myservice -l

여기서 핵심은 다음입니다.

  • Main PID 가 무엇인지
  • code=exited, status=... 또는 code=killed, signal=... 형태의 종료 원인
  • Restart= 정책이 무엇인지
  • Start request repeated too quickly 가 떴는지

예를 들어 status=203/EXEC 는 실행 파일을 못 찾거나 실행 권한이 없을 때 자주 보입니다.

2-2) journalctl 로 “첫 실패 시점”을 잡기

재시작 루프는 로그가 많아져서 “결정적 첫 에러”가 묻히기 쉽습니다. 아래처럼 현재 부팅 기준, 해당 유닛만, 최근부터 보세요.

journalctl -u myservice -b --no-pager -n 200

실패 직전/직후에 흔히 보이는 단서들:

  • No such file or directory
  • permission denied
  • address already in use
  • Exec format error
  • Failed at step ... (systemd가 실행 단계에서 막힌 경우)
  • Timed out waiting for ...

만약 로그가 너무 빨리 흘러가면 follow 모드로 재현합니다.

journalctl -u myservice -f

3) “systemd가 왜 재시작했는지”를 유닛 파일에서 확인

실제 재시작 여부는 애플리케이션이 아니라 유닛 파일의 정책이 결정합니다. 유닛 전체를 확인하세요.

systemctl cat myservice
systemctl show myservice -p Restart -p RestartSec -p StartLimitIntervalSec -p StartLimitBurst

3-1) Restart= 설정이 과격한 경우

  • Restart=always 는 정상 종료(종료 코드 0)에도 재시작합니다.
  • 배치성 작업(한 번 실행하고 끝나는 작업)에 Restart=always 를 걸면 “무한루프”가 됩니다.

배치 작업이면 보통 Type=oneshotRemainAfterExit=yes 를 고려하거나, Restart=on-failure 로 바꿉니다.

[Service]
Type=oneshot
ExecStart=/usr/local/bin/my-batch
RemainAfterExit=yes
Restart=no

3-2) StartLimit 으로 인해 “반복 요청 차단”이 걸리는 경우

너무 자주 실패하면 systemd가 스스로 시작을 막습니다.

  • StartLimitBurst= 횟수 초과
  • StartLimitIntervalSec= 시간 창 내 반복

테스트 중에는 완화할 수 있지만, 근본 원인을 고친 뒤 적절히 조정하는 게 좋습니다.

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3

[Service]
Restart=on-failure
RestartSec=5

4) 종료 코드로 원인을 좁히는 빠른 체크리스트

4-1) 203/EXEC, 126, 127: 실행 자체가 안 됨

  • 경로 오타
  • 실행 권한 없음
  • shebang 누락
  • 바이너리 아키텍처 불일치
# 유닛에 설정된 ExecStart 경로 확인
systemctl show myservice -p ExecStart

# 실제 파일 존재/권한 확인
ls -al /usr/local/bin/myservice
file /usr/local/bin/myservice

# 스크립트라면 첫 줄 확인
head -n 1 /usr/local/bin/myservice

ExecStart=/path/to/bin --flag 형태에서 바이너리 경로에 공백이 있거나, 인용이 잘못되면 의도치 않게 파싱될 수 있습니다. 가능하면 공백 없는 경로를 사용하고, 인자 분리는 명확히 유지하세요.

4-2) 1, 2, 255: 앱이 “스스로 실패 종료”

이 경우는 앱 로그가 핵심입니다. 하지만 systemd 환경에서는 로컬 쉘에서 잘 되던 명령이 실패하는 일이 흔합니다.

  • 유닛 환경에 PATH가 없다
  • 작업 디렉터리가 다르다
  • 환경 변수(.env)가 로드되지 않는다
[Service]
WorkingDirectory=/srv/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /srv/myapp/server.js

그리고 실제로 어떤 환경으로 실행되는지 확인하려면 systemd-run 으로 유사 환경을 만들거나, 유닛에 임시로 ExecStartPre= 를 넣어 디버깅합니다.

[Service]
ExecStartPre=/usr/bin/env
ExecStartPre=/usr/bin/bash -lc 'pwd; id; umask; ulimit -a'

진단 후에는 반드시 제거하세요. 민감 정보가 로그에 남을 수 있습니다.

4-3) 137, 9, OOM: 강제 종료(killed)

code=killed, signal=KILL 이면 다음을 의심합니다.

  • OOM Killer
  • systemd의 MemoryMax= 제한
  • 컨테이너/VM 상위 레이어 제한
# 커널 OOM 흔적 확인
journalctl -k -b | grep -i oom

# systemd 리소스 제한 확인
systemctl show myservice -p MemoryMax -p CPUQuota -p TasksMax

4-4) TimeoutStartSec: 준비 완료 신호 문제(Type=notify 등)

Type=notify 인데 앱이 sd_notify 를 호출하지 않으면 systemd는 “시작이 끝나지 않았다”고 판단해 타임아웃으로 죽이고 재시작합니다.

  • Type=simple 로 바꾸거나
  • 애플리케이션이 notify를 올바르게 보내도록 수정합니다.
[Service]
Type=simple
ExecStart=/usr/local/bin/myservice
TimeoutStartSec=30

5) 흔한 운영 원인 7가지와 추적 방법

5-1) 포트 충돌: Address already in use

# 8080 포트 점유 프로세스 확인
sudo ss -lntp | grep ':8080'

# 혹은
sudo lsof -i :8080

이 경우는 기존 프로세스가 남아 있거나(이중 실행), 소켓 활성화 구성과 충돌했을 수 있습니다.

5-2) 권한/소유권 문제: Permission denied

서비스 유저가 파일/디렉터리에 접근 가능한지 확인합니다.

systemctl show myservice -p User -p Group

# 서비스 유저로 접근 테스트
sudo -u myuser bash -lc 'test -r /srv/myapp/config.yml && echo OK'

로그 디렉터리, PID 파일 경로, 소켓 파일 경로가 특히 자주 문제를 일으킵니다.

5-3) WorkingDirectory 누락으로 상대경로가 깨짐

로컬에서는 프로젝트 디렉터리에서 실행했는데, systemd에서는 기본 작업 디렉터리가 / 로 잡혀 상대 경로가 전부 깨질 수 있습니다.

[Service]
WorkingDirectory=/srv/myapp
ExecStart=/srv/myapp/bin/start

5-4) EnvironmentFile 경로/권한/개행 문제

EnvironmentFile= 은 파일이 없으면 실패할 수 있습니다(앞에 - 를 붙이면 없어도 무시).

[Service]
EnvironmentFile=-/etc/myapp/myapp.env

또한 env 파일에 따옴표, 공백, 윈도우 개행이 섞이면 파싱이 꼬일 수 있으니, 실패 시에는 파일을 최소화해서 재현해보는 게 빠릅니다.

5-5) 의존 서비스 준비 전 기동(DB, 네트워크, 마운트)

After= 는 “순서”만 보장하고 “준비 완료”를 보장하지 않습니다. DB가 뜨기 전에 앱이 접속하다 실패하고 재시작 루프가 생길 수 있습니다.

  • Wants=After= 로 순서를 잡고
  • 앱에서 재시도(backoff)를 구현하거나
  • 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/myservice
Restart=on-failure
RestartSec=3

5-6) 잘못된 Type 설정(oneshot, forking, simple)

  • 데몬이 포그라운드로 떠 있는데 Type=forking 으로 설정하면 systemd가 프로세스를 잘못 추적할 수 있습니다.
  • 반대로 백그라운드로 포크하는데 Type=simple 이면 메인 PID가 종료되어 실패로 판단될 수 있습니다.

애플리케이션의 실행 방식과 Type 을 일치시키세요.

5-7) RestartSec=0 으로 로그 폭주 및 자원 소모

재시작 간격이 0이면 CPU/IO를 태우며 장애를 키웁니다. 최소한의 대기 시간을 두고, 실패 원인을 고치기 전까지는 루프를 완화하세요.

[Service]
Restart=on-failure
RestartSec=5

6) 실전 진단 플로우: 10분 안에 원인 좁히기

아래 순서대로 하면 “감”이 아니라 증거 기반으로 좁힐 수 있습니다.

  1. systemctl status myservice -l 로 종료 코드와 마지막 로그 확인
  2. journalctl -u myservice -b -n 200첫 실패 지점 찾기
  3. systemctl cat myserviceExecStart, Type, Restart, EnvironmentFile, WorkingDirectory 확인
  4. 종료 코드가 203/EXEC 계열이면 실행 경로/권한부터 해결
  5. permission denied 면 서비스 유저로 파일/포트/디렉터리 접근 테스트
  6. address already in usess 또는 lsof 로 점유 프로세스 확인
  7. timeout 이면 Type 과 준비 신호(또는 TimeoutStartSec) 확인
  8. killed 면 OOM, MemoryMax= 등 리소스 제한 확인
  9. 변경 후 systemctl daemon-reload 와 재시작
  10. 정상화 후 StartLimit, Restart 정책을 “안전한 값”으로 정리
sudo systemctl daemon-reload
sudo systemctl restart myservice
sudo systemctl status myservice -l

7) 재발 방지: 안전한 유닛 템플릿 예시

웹 API 같은 장기 실행 서비스라면 보통 아래 조합이 무난합니다.

[Unit]
Description=My Service
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=simple
User=myuser
Group=myuser
WorkingDirectory=/srv/myapp
EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/usr/local/bin/myservice
Restart=on-failure
RestartSec=3

# 장애 시 관측성
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myservice

# 보안 하드닝(상황에 맞게 조정)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

[Install]
WantedBy=multi-user.target

배치 작업(정기 실행)은 systemd timer로 분리하고, 서비스 유닛에는 Restart=always 를 피하는 편이 운영 안정성이 좋습니다.

8) systemd 재시작 루프와 “CrashLoop”의 공통점

Kubernetes의 CrashLoopBackOff 와 systemd 재시작 루프는 본질적으로 같습니다. “프로세스가 반복적으로 비정상 종료하고, 오케스트레이터가 재기동한다”는 구조이기 때문입니다. 원인도 환경 변수, 권한, 의존성 준비, 리소스 제한처럼 비슷한 축에서 반복됩니다.

컨테이너 환경에서 비슷한 문제를 겪고 있다면 아래 글의 체크리스트도 같이 보면 진단 속도가 빨라집니다.

또한 외부 연동(예: 인증 토큰/JWKS, 네트워크 타임아웃) 문제로 앱이 즉시 종료하며 systemd가 재시작하는 케이스도 많습니다. 그런 경우에는 장애가 “systemd 문제”처럼 보이지만 실제로는 애플리케이션의 의존 네트워크 호출이 트리거인 경우가 많습니다.

9) 마무리: “재시작을 멈추는 것”과 “원인을 제거하는 것”을 분리

systemd 재시작 무한루프 대응에서 중요한 태도는 두 가지입니다.

  • 단기: RestartSecStartLimit*폭주를 멈추고 로그/리소스 피해를 줄인다
  • 중기: 종료 코드와 저널 로그를 기반으로 실패의 1차 원인을 제거한다

특히 Restart=always 는 강력하지만, 잘못 쓰면 장애를 증폭시키는 설정입니다. 유닛의 실행 타입과 애플리케이션의 실행 모델(포그라운드/포크/준비 신호)을 일치시키고, 환경/권한/의존성 준비를 명시적으로 관리하면 재시작 루프의 대부분은 빠르게 해결할 수 있습니다.