- Published on
systemd 재시작 루프(StartLimitHit) 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서비스를 배포했는데 systemctl status에 Active: 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-hitStart request repeated too quickly.
핵심은 두 가지입니다.
- 서비스가 실패(크래시/즉시 종료)한다.
- 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-failureRestartSec를 충분히 주기- 앱 로그/종료 코드를 명확히
- 의존성 준비/재시도 전략 수립
빠른 체크리스트(10분 진단 루틴)
아래 순서대로 보면 대부분 10분 내 윤곽이 나옵니다.
systemctl status -l myapp에서 종료 코드 확인journalctl -u myapp -b -n 200에서 “죽기 직전” 메시지 확인systemctl show myapp -p ExecStart,Restart,RestartSec,Type,User,WorkingDirectory확인ExecStart를 쉘에서 동일 사용자로 직접 실행해보기- 포트 충돌(
ss -lntp)과 파일 권한 확인 RestartSec를 늘리고Restart=on-failure로 조정- 필요하면
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 쿼터·레이트리밋 대응처럼 “재시도/백오프” 설계도 함께 참고해보면 운영 안정성을 높이는 데 도움이 됩니다.