Published on

systemd 서비스 재시작 무한루프 9단계 진단법

Authors

서버에서 서비스가 active 로 잠깐 떴다가 곧바로 failed 로 바뀌고, 다시 자동 재시작되는 상황은 흔합니다. 문제는 “왜 죽는지”가 아니라 “어디서부터 확인하면 가장 빨리 원인을 좁힐 수 있는지”입니다.

이 글은 systemd 서비스가 재시작 무한루프에 빠졌을 때, 재현 가능한 순서로 원인을 제거해 나가는 9단계 절차를 제공합니다. 각 단계는 바로 실행 가능한 명령과 함께, 어떤 신호를 보면 다음 단계로 넘어가야 하는지 기준을 포함합니다.

참고: 쿠버네티스에서 CrashLoopBackOff 를 추적하듯, systemd에서도 핵심은 “마지막 실패의 단서”를 잡는 것입니다. 무한 반복을 끊는 관점은 Terraform EKS 상태 꼬임으로 apply 무한 반복 끊기 글의 접근(루프를 먼저 멈추고 단서를 고정)과도 유사합니다.

0) 먼저 루프를 멈춰 단서를 고정하기

무한 재시작은 로그를 빠르게 덮어쓰고, 상태를 계속 바꾸며, 원인 파악을 어렵게 합니다. 우선 서비스 재시작을 멈춘 뒤 분석하세요.

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

서비스 파일을 수정할 수 있다면, 일시적으로 재시작을 꺼서 관찰하는 것도 좋습니다.

# /etc/systemd/system/myapp.service.d/debug.conf
[Service]
Restart=no

적용:

sudo systemctl daemon-reload
sudo systemctl start myapp.service

1단계) 현재 상태와 마지막 종료 코드 확인

가장 먼저 “systemd가 왜 다시 올리려 하는지”를 수치로 확인합니다.

systemctl status -l --no-pager myapp.service
systemctl show myapp.service -p ActiveState,SubState,Result,ExecMainStatus,ExecMainCode,ExecMainPID,Restart,RestartUSec

해석 포인트:

  • ExecMainStatus=1 같은 값은 프로세스가 반환한 exit code
  • ExecMainCode=exited 면 정상 종료(코드만 실패), killed 면 시그널 종료 가능성
  • Result=timeout 이면 systemd 타임아웃 문제 가능성이 큼

2단계) journal에서 “마지막 실패 직전”만 잘라 보기

journalctl -u 를 무작정 길게 보는 대신, 마지막 부팅 또는 마지막 N줄만 확인합니다.

# 이번 부팅에서 해당 유닛 로그
journalctl -u myapp.service -b --no-pager

# 마지막 200줄만
journalctl -u myapp.service -b -n 200 --no-pager

# 시간 구간을 좁혀 보기
journalctl -u myapp.service --since "10 min ago" --no-pager

자주 보이는 패턴:

  • permission denied : 권한/SELinux/AppArmor/파일 소유권
  • No such file or directory : 경로/WorkingDirectory/환경변수
  • address already in use : 포트 점유/이중 실행
  • Killed : OOM 또는 watchdog

로그가 너무 빨리 돌아가면, 앞서 Restart=no 로 고정한 뒤 다시 시도하는 편이 빠릅니다.

3단계) 서비스 정의(유닛 파일) 실제 적용값 확인

유닛 파일은 드롭인, 배포 스크립트, 패키지 업데이트 등으로 의도치 않게 바뀌는 경우가 많습니다. “내가 보고 있는 설정”과 “실제로 적용되는 설정”이 다를 수 있습니다.

systemctl cat myapp.service
systemctl show myapp.service | sed -n '1,120p'

특히 아래 항목을 집중 확인하세요.

  • ExecStart 경로가 존재하는지
  • WorkingDirectory 가 맞는지
  • User/Group 권한이 맞는지
  • Environment/EnvironmentFile 이 로드되는지
  • Restart/RestartSec/StartLimitIntervalSec/StartLimitBurst

4단계) ExecStart를 “systemd 밖에서” 동일 조건으로 실행

systemd 환경에서만 실패하는지, 바이너리 자체가 실패하는지 분리해야 합니다. 유닛의 실행 조건을 최대한 동일하게 맞춰 수동 실행합니다.

예: User=app 이라면

sudo -u app -H bash -lc '/usr/local/bin/myapp --config /etc/myapp/config.yaml'

WorkingDirectory=/opt/myapp 이라면

sudo -u app -H bash -lc 'cd /opt/myapp && /usr/local/bin/myapp'

여기서 바로 에러가 재현되면 애플리케이션/설정 문제일 가능성이 높고, 재현되지 않으면 systemd 설정(권한, 환경변수, 제한, 의존성) 쪽으로 좁힙니다.

5단계) 환경변수·비밀값·파일 인코딩/개행 문제 확인

EnvironmentFile= 로 읽는 파일이 CRLF, BOM, 잘못된 quoting 등으로 파싱 실패를 일으키는 경우가 있습니다. 특히 .env 파일을 윈도우에서 편집했거나, 배포 파이프라인에서 개행이 바뀐 경우가 흔합니다.

# env 파일이 실제로 로드되는지
systemctl show myapp.service -p Environment,EnvironmentFiles

# 개행/인코딩 체크
file -b /etc/myapp/myapp.env
sed -n '1,50p' /etc/myapp/myapp.env

또한 애플리케이션이 설정 파일을 읽다 인코딩 문제로 죽는 경우도 있습니다(예: CSV/JSON/INI). 이런 류의 “눈에 안 보이는 실패”는 Python UnicodeDecodeError - CSV 인코딩 자동탐지·복구 같은 접근처럼, 입력 데이터를 의심하는 것이 빠릅니다.

6단계) 의존성(네트워크/DB/파일시스템)과 순서 문제 확인

서비스가 부팅 초기에 올라오면서 DB나 네트워크가 준비되기 전에 실행되어 실패하고, 재시작 루프에 빠지는 경우가 많습니다.

유닛의 의존성을 확인:

systemctl list-dependencies myapp.service
systemctl show myapp.service -p After,Wants,Requires

네트워크 준비가 필요한데 After=network.target 만 걸어둔 경우가 흔한 함정입니다. 더 강하게는 network-online.target 와 해당 wait 서비스가 필요할 수 있습니다.

예시(드롭인 권장):

# /etc/systemd/system/myapp.service.d/deps.conf
[Unit]
Wants=network-online.target
After=network-online.target

DB 의존이면 애플리케이션 레벨에서 재시도(backoff)를 넣는 것이 더 안전합니다. “실패하면 즉시 재시작”은 외부 의존성이 불안정할 때 루프를 악화시킵니다. 이런 경우 백오프 설계는 OpenAI API 429·Rate limit 실전 백오프 패턴 글의 패턴을 그대로 응용할 수 있습니다.

7단계) 타임아웃·watchdog·notify 타입 불일치 점검

Type= 설정이 애플리케이션 동작과 맞지 않으면, systemd가 “시작이 안 됐다”고 판단해 죽이고 다시 올릴 수 있습니다.

자주 발생하는 케이스:

  • Type=notify 인데 애플리케이션이 sd_notify 를 보내지 않음
  • Type=forking 인데 포그라운드로만 실행됨
  • 초기화가 오래 걸리는데 TimeoutStartSec 가 너무 짧음

확인:

systemctl show myapp.service -p Type,NotifyAccess,TimeoutStartUSec,TimeoutStopUSec,WatchdogUSec

예시 수정:

# /etc/systemd/system/myapp.service.d/timeout.conf
[Service]
Type=simple
TimeoutStartSec=120

Type=simple 은 프로세스가 포그라운드로 계속 도는 일반적인 웹 서버/워커에 무난합니다.

8단계) 리소스 제한과 OOM/시그널 종료 확인

서비스가 Killed 로 끝나면 메모리 부족(OOM)이나 cgroup 제한에 걸렸을 수 있습니다.

# 커널 OOM 로그(이번 부팅)
journalctl -k -b --no-pager | grep -i -E 'oom|killed process|out of memory'

# 유닛 리소스 제한
systemctl show myapp.service -p MemoryMax,CPUQuota,TasksMax,LimitNOFILE,LimitNPROC

# 메모리/CPU 실시간 관찰
systemd-cgtop

파일 디스크립터 부족(too many open files)이면 LimitNOFILE 을 올리거나 애플리케이션 누수를 잡아야 합니다.

예시:

# /etc/systemd/system/myapp.service.d/limits.conf
[Service]
LimitNOFILE=1048576
TasksMax=4096

9단계) 재시작 정책을 “안전하게” 재설계하고, 루프 방지 장치 추가

원인을 해결한 뒤에도 재발 방지를 위해 재시작 정책을 정리하는 것이 좋습니다. 핵심은 다음 두 가지입니다.

  1. 즉시 재시작 루프를 피하기(백오프)
  2. 계속 실패하면 멈추기(레이트 리밋)

추천 예시:

# /etc/systemd/system/myapp.service.d/restart-policy.conf
[Service]
Restart=on-failure
RestartSec=5

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
  • Restart=always 는 “정상 종료”까지 재시작해 버리므로 대부분의 서버 앱에는 과합니다.
  • StartLimit* 로 5분 동안 5번 실패하면 멈추게 하여, 장애 시 로그/리소스 소모를 줄입니다.

적용 후:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl status -l --no-pager myapp.service

자주 만나는 원인별 빠른 체크 표

  • 포트 점유: ss -lntp | grep 8080 로 확인, 중복 실행/이전 프로세스 정리
  • 권한 문제: User= 로 실행 시 접근 가능한 경로인지, chmod/chown 점검
  • 경로 문제: ExecStart 절대경로, WorkingDirectory 존재 여부
  • 의존성 문제: network-online.target 또는 애플리케이션 레벨 재시도
  • 타임아웃: TimeoutStartSec 상향 또는 Type= 정정
  • OOM: 메모리 제한/누수/버퍼 설정 점검

마무리: “루프를 멈추고, 마지막 실패를 고정하라”

systemd 재시작 무한루프는 대부분 관찰이 어려워서 오래 걸립니다. 하지만 순서를 지키면 빠르게 좁힐 수 있습니다.

  1. 재시작을 멈춰 단서 고정
  2. systemctl show 로 종료 코드/결과 확인
  3. journalctl 로 직전 로그 확보
  4. 유닛 실제 적용값 확인
  5. 동일 조건 수동 실행으로 재현성 분리
  6. 의존성/순서 점검
  7. 타입/타임아웃/notify 점검
  8. OOM/리소스 제한 점검
  9. 재시작 정책을 안전하게 재설계

이 9단계를 템플릿처럼 반복 적용하면, 원인 파악뿐 아니라 “재발 시에도 서버가 스스로 무너지지 않는” 운영 설정까지 함께 갖출 수 있습니다.