Published on

systemd 재시작 루프(StartLimitHit) 해결법

Authors

서비스를 배포했는데 systemctl statusActive: failed (Result: start-limit-hit) 이 뜨고, 로그에는 재시작만 반복되다가 결국 systemd가 포기하는 상황을 종종 만납니다. 겉으로는 “systemd가 이상하다”처럼 보이지만, 실제로는 서비스가 너무 빨리 죽는 구조이거나, Restart 정책/의존성/환경 설정이 잘못되어 systemd의 StartLimit 보호 장치가 발동한 경우가 대부분입니다.

이 글에서는 StartLimitHit가 발생하는 메커니즘을 이해하고, journalctl/systemctl show로 원인을 좁힌 뒤, 유닛 파일을 어떻게 고쳐야 재시작 루프를 끊을 수 있는지 실전 중심으로 정리합니다.

> 장애 진단 글을 자주 쓰다 보니, 네트워크/권한 이슈처럼 “증상은 비슷한데 원인은 다른” 케이스가 많습니다. 예를 들어 EKS에서 egress가 막히는 경우도 겉보기엔 타임아웃 하나로 보이지만 원인이 다양하죠. 비슷한 접근으로 원인을 분해해보는 관점은 EKS에서 Pod는 정상인데 egress만 막힐 때 점검 같은 글에서도 동일합니다.

StartLimitHit란 무엇인가

systemd는 서비스가 짧은 시간에 너무 자주 시작 실패하면, 시스템을 보호하기 위해 더 이상 시작을 시도하지 않고 차단합니다. 이때 status에 흔히 다음이 보입니다.

  • Result: start-limit-hit
  • Start request repeated too quickly.

핵심은 두 가지입니다.

  1. 서비스가 실패(크래시/즉시 종료)한다.
  2. systemd가 설정된 창(window) 내에서 일정 횟수 이상 실패를 감지하면 StartLimit에 걸어버린다.

이 제한은 유닛 단위로 적용되며, systemd 버전/배포판에 따라 기본값이 다를 수 있습니다(또는 매니저 기본값을 따름).

가장 먼저 해야 할 것: “StartLimit”이 아니라 “왜 죽는지”를 본다

StartLimitHit는 원인이 아니라 결과입니다. 따라서 아래 순서로 접근하는 게 가장 빠릅니다.

1) 상태/최근 실패 원인 확인

systemctl status myapp.service -l
  • ExecStart=가 무엇인지
  • Main PID가 떴다가 바로 죽는지
  • 종료 코드가 무엇인지(예: status=1/FAILURE, status=203/EXEC 등)

2) 저널 로그로 실패 직전 메시지 확인

journalctl -u myapp.service -b --no-pager -n 200
  • -b는 현재 부팅 기준
  • 실패 직전의 애플리케이션 로그/스택트레이스/환경 변수 누락 메시지 확인

3) systemd가 보는 상세 속성 확인

systemctl show myapp.service \
  -p ExecStart \
  -p Restart -p RestartSec \
  -p StartLimitIntervalUSec -p StartLimitBurst \
  -p TimeoutStartUSec -p TimeoutStopUSec \
  -p Type -p User -p WorkingDirectory

여기서 특히 중요한 건 다음입니다.

  • Restart=always/on-failure인지
  • RestartSec=가 너무 짧지 않은지(0~100ms면 재시작 폭주)
  • Type=이 실제 프로세스 동작과 맞는지
  • ExecStart= 경로/권한이 올바른지

StartLimitHit를 유발하는 대표 원인 7가지

현장에서 가장 자주 보는 패턴을 정리하면 아래와 같습니다.

1) ExecStart 경로/권한 문제 (status=203/EXEC)

  • 바이너리 경로 오타
  • 실행 권한 없음
  • shebang 잘못된 스크립트

검증:

ls -al /opt/myapp/myapp
file /opt/myapp/myapp
/opt/myapp/myapp --version

2) 환경 변수/설정 파일 누락

  • .env를 기대했는데 systemd에서는 로드되지 않음
  • EnvironmentFile= 경로가 틀림

로그에 KeyError, missing config, cannot open file 류가 보입니다.

3) WorkingDirectory/상대 경로 의존

로컬에서 ./config.yaml로 읽는데 systemd에서 작업 디렉터리가 달라 실패.

해결:

  • WorkingDirectory=/opt/myapp
  • 또는 설정 파일을 절대 경로로

4) Type 설정 불일치 (특히 forking/notify)

  • 데몬이 fork() 하는데 Type=simple로 두면 PID 추적이 꼬일 수 있음
  • 반대로 단일 포그라운드 프로세스인데 forking이면 시작 판정이 어긋남

5) 의존 서비스(DB/Redis) 준비 전 기동

부팅 직후 DB가 아직 준비되지 않았는데 앱이 즉시 연결 실패 → 종료 → 재시작 루프.

해결은 아래 “의존성/대기 전략”에서 다룹니다.

6) 포트 충돌

이미 같은 포트를 점유한 프로세스가 있어 바인딩 실패 후 종료.

ss -lntp | grep ':8080'

7) 너무 공격적인 Restart 정책

Restart=always + RestartSec=0 조합은 실패 시 순간적으로 폭주하며 StartLimitHit를 매우 빨리 유발합니다.

즉시 복구(응급처치): StartLimit 카운터 리셋

원인을 고치기 전이라도, 수정한 설정을 적용하고 다시 시작하려면 카운터를 리셋해야 할 때가 있습니다.

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

단, 이건 “다시 시도”일 뿐이며, 근본 원인이 해결되지 않으면 다시 StartLimitHit로 돌아옵니다.

근본 해결 1: 유닛 파일에서 Restart/StartLimit을 올바르게 설계

아래는 웹 애플리케이션(포그라운드 실행, 실패 시 재시작) 기준의 권장 예시입니다.

예시: 안정적인 서비스 유닛

/etc/systemd/system/myapp.service

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

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

# 환경 변수 로드
EnvironmentFile=-/etc/myapp/myapp.env

# 실행
ExecStart=/opt/myapp/bin/myapp --config /etc/myapp/config.yaml

# 재시작 정책: 실패시에만
Restart=on-failure
RestartSec=3s

# 너무 빨리 반복 실패하면 잠시 차단(기본값을 명시적으로 조절)
StartLimitIntervalSec=60
StartLimitBurst=5

# 기동/종료 타임아웃
TimeoutStartSec=30
TimeoutStopSec=30

# 종료 시그널(앱이 SIGTERM을 정상 처리하도록)
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target

적용:

sudo systemctl daemon-reload
sudo systemctl reset-failed myapp.service
sudo systemctl enable --now myapp.service

포인트

  • Restart=on-failure가 기본적으로 안전합니다. “정상 종료(0)”까지 재시작하면 배치성 프로세스가 무한 루프가 됩니다.
  • RestartSec를 2~5초로 두면, 외부 의존성 준비/일시 오류에 대한 회복력이 좋아지고 StartLimitHit도 늦춰집니다.
  • StartLimitIntervalSec/StartLimitBurst는 “무한 재시작 폭주 방지”를 위한 안전장치입니다. 무작정 크게 잡기보다, 장애 감지/알림 체계와 함께 설계하세요.

근본 해결 2: 의존성(DB/Redis 등) 준비 전 기동 문제 다루기

systemd의 After=는 “순서”일 뿐 “준비 완료”를 보장하지 않습니다. 예를 들어 After=postgresql.service를 둬도 DB가 연결 가능한 상태가 아닐 수 있습니다.

해결 전략은 보통 두 가지입니다.

1) 앱 자체에 재시도(backoff) 로직 넣기

가장 이상적입니다. 연결 실패 시 즉시 종료하지 말고, 일정 횟수/시간 재시도 후 실패로 처리합니다.

2) ExecStartPre로 헬스체크(간단 대기) 추가

예: 5432 포트가 열릴 때까지 기다린 후 앱 실행.

[Service]
ExecStartPre=/bin/bash -c 'for i in {1..30}; do nc -z 127.0.0.1 5432 && exit 0; sleep 1; done; exit 1'
ExecStart=/opt/myapp/bin/myapp
Restart=on-failure
RestartSec=3s
  • ExecStartPre가 실패하면 서비스는 실패로 간주되며, Restart 정책에 따라 재시작될 수 있습니다.
  • 너무 긴 대기는 TimeoutStartSec에 걸릴 수 있으니 함께 조정하세요.

근본 해결 3: Type/포그라운드 실행 정합성 맞추기

systemd는 기본적으로 포그라운드 프로세스 1개를 잘 관리합니다. 가능하면 데몬화(fork)하지 말고 포그라운드로 실행하세요.

  • Node.js, Python(FastAPI/Uvicorn), Go 서버 등: 보통 Type=simple + 포그라운드 실행 권장
  • 전통적인 forking 데몬: Type=forking + PIDFile= 필요할 수 있음

예: nginx처럼 forking 성격이면(설정에 따라 다름) 다음이 필요할 수 있습니다.

Type=forking
PIDFile=/run/nginx.pid
ExecStart=/usr/sbin/nginx
ExecReload=/usr/sbin/nginx -s reload
ExecStop=/bin/kill -s QUIT $MAINPID

Type이 맞지 않으면 systemd가 “시작 완료”를 잘못 판단하거나, 메인 PID를 추적하지 못해 재시작 루프처럼 보이는 문제가 생길 수 있습니다.

StartLimit을 “끄는 것”은 최후의 수단

검색하면 StartLimitIntervalSec=0로 제한을 꺼버리라는 글도 보이는데, 운영 환경에서는 권장하지 않습니다.

  • 진짜 크래시 루프가 발생하면 CPU/로그/디스크를 소모하며 장애를 악화시킵니다.
  • 장애 알림이 늦어지고, 원인 분석도 어려워집니다(로그 폭주).

대신 다음을 권합니다.

  • Restart=on-failure
  • RestartSec를 충분히 주기
  • 앱 로그/종료 코드를 명확히
  • 의존성 준비/재시도 전략 수립

빠른 체크리스트(10분 진단 루틴)

아래 순서대로 보면 대부분 10분 내 윤곽이 나옵니다.

  1. systemctl status -l myapp에서 종료 코드 확인
  2. journalctl -u myapp -b -n 200에서 “죽기 직전” 메시지 확인
  3. systemctl show myapp -p ExecStart,Restart,RestartSec,Type,User,WorkingDirectory 확인
  4. ExecStart를 쉘에서 동일 사용자로 직접 실행해보기
  5. 포트 충돌(ss -lntp)과 파일 권한 확인
  6. RestartSec를 늘리고 Restart=on-failure로 조정
  7. 필요하면 ExecStartPre로 의존성 대기 추가

덤: 자주 쓰는 명령 모음

# 유닛 파일 편집(드롭인 override 권장)
sudo systemctl edit myapp.service

# 전체 유닛 내용 확인(드롭인 포함)
systemctl cat myapp.service

# 실패 원인/코드 중심으로 보기
systemctl status myapp.service -l

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

# 실패 카운터 리셋
sudo systemctl reset-failed myapp.service

# 설정 반영
sudo systemctl daemon-reload

마무리

StartLimitHit는 systemd가 “이 서비스가 지금 상태로는 계속 실패할 것”이라고 판단해 자동 재시작을 멈춘 신호입니다. 따라서 해결의 핵심은 StartLimit 값을 만지는 것보다

  • 서비스가 왜 즉시 종료하는지(환경/권한/경로/포트/의존성)
  • Restart 정책이 합리적인지(특히 RestartSec)
  • Type과 실행 방식이 맞는지

를 정리하는 데 있습니다.

재시작 루프는 애플리케이션/인프라 어디서든 흔한 장애 패턴입니다. 원인을 좁혀가는 방식(증상→관측→가설→검증)은 레이트리밋이나 타임아웃 같은 문제를 다룰 때도 동일하게 적용됩니다. 필요하다면 API 장애 대응 관점에서 OpenAI Responses API 429 쿼터·레이트리밋 대응처럼 “재시도/백오프” 설계도 함께 참고해보면 운영 안정성을 높이는 데 도움이 됩니다.