- Published on
systemd 서비스 무한 재시작 - StartLimit 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 데몬을 systemd로 올려두면, 장애가 났을 때 자동 복구를 기대하게 됩니다. 그런데 현실에서는 두 가지 극단을 자주 봅니다.
- 서비스가 실패하자마자 초당 수 회 재시작하며 CPU와 로그를 태우는 무한 재시작 루프
- 반대로 systemd가 재시작을 너무 많이 시도했다고 판단해 StartLimitHit 상태로 더 이상 올려주지 않는 상황
이 글은 두 상황을 한 번에 정리합니다. 무엇이 문제인지 빠르게 확인하고, Restart= 정책과 StartLimit* 계열을 올바르게 조정해 “복구는 하되 폭주는 막는” 형태로 튜닝하는 것이 목표입니다.
특히 운영 환경에서 systemd 쪽을 만지기 전에, 애플리케이션이 왜 즉시 종료되는지부터 잡아야 합니다. 이 관점은 쿠버네티스의 재시작 루프와도 유사합니다. 필요하면 K8s CrashLoopBackOff 원인 10가지·즉시 진단법도 함께 참고하면 문제를 더 빨리 좁힐 수 있습니다.
증상: 무한 재시작과 StartLimitHit의 차이
1) 무한 재시작 루프
대개 유닛 파일에 Restart=always 또는 Restart=on-failure가 설정되어 있고, 프로세스가 매우 빠르게 종료합니다. systemd는 즉시 재시작을 시도하며, 짧은 간격으로 실패가 반복됩니다.
2) StartLimitHit로 재시작 차단
systemd에는 “짧은 시간 동안 너무 많이 재시작되면 더 이상 시도하지 말라”는 레이트 리미트가 있습니다. 이 제한에 걸리면 상태가 failed로 남고, 로그에 Start request repeated too quickly 같은 메시지가 찍힙니다.
둘은 연속된 흐름입니다. 무한 재시작 루프가 계속되면 결국 StartLimit에 걸려 멈춥니다.
5분 진단: 지금 무엇 때문에 재시작되는가
아래 순서대로 보면 원인을 빠르게 특정할 수 있습니다.
1) 최근 로그 확인
sudo journalctl -u myapp.service -b --no-pager -n 200
- 애플리케이션 자체 에러(예: 설정 파일 없음, 포트 바인딩 실패, 권한 문제)
- systemd 레벨 에러(예:
ExecStart경로 틀림,User=권한 문제) Start request repeated too quickly문구가 보이면 StartLimit에 걸린 것입니다.
2) 유닛 상태와 종료 코드 확인
sudo systemctl status myapp.service
sudo systemctl show myapp.service -p ExecMainStatus -p ExecMainCode -p Result -p NRestarts
ExecMainStatus가 애플리케이션 종료 코드입니다.NRestarts가 빠르게 증가하면 재시작 루프입니다.
3) 유닛 파일 실제 적용 값 확인
드롭인 포함 최종 값을 확인해야 합니다.
sudo systemctl cat myapp.service
sudo systemd-analyze verify /etc/systemd/system/myapp.service
4) StartLimit에 걸렸다면 리셋
StartLimit에 걸린 상태에서는 설정을 고쳐도 바로 안 올라올 수 있습니다.
sudo systemctl reset-failed myapp.service
sudo systemctl start myapp.service
StartLimit의 동작 원리(핵심만)
systemd는 일정 시간 창(window) 안에서 시작 시도가 너무 많으면 더 이상 시작하지 않습니다.
StartLimitIntervalSec: 카운트할 시간 창StartLimitBurst: 그 시간 창 안에서 허용하는 최대 시작 시도 횟수
주의할 점은, 이 제한은 “프로세스가 죽어서 재시작하는 경우”뿐 아니라, 시작 자체가 실패하는 경우(예: ExecStart 오류)에도 동일하게 카운트될 수 있다는 것입니다.
또한 유닛 파일에서 Restart=를 공격적으로 설정해두면, 제한에 도달하는 속도도 빨라집니다.
해결 전략 1: 애플리케이션이 즉시 종료되는 원인부터 제거
StartLimit을 늘리는 것은 증상을 늦추는 것일 뿐, 루트 원인을 해결하지 못하면 결국 다시 터집니다. 운영에서 흔한 즉시 종료 원인은 다음과 같습니다.
- 포트 충돌: 이미 다른 프로세스가 리슨 중
- 환경변수 누락:
EnvironmentFile경로 오류, 권한 문제 - 파일/디렉터리 권한:
User=로 실행했는데 로그 디렉터리 쓰기 불가 - 의존 서비스 미기동: DB, Redis, 네트워크 준비 전 실행
Type=불일치: 포그라운드/백그라운드 동작과 systemd 기대가 다름
예를 들어, 포트 바인딩 실패라면 로그에서 bind: address already in use가 보입니다. 이 경우 Restart=always는 “포트가 계속 점유된 동안 무한 재시작”을 만들 뿐입니다.
해결 전략 2: Restart 정책을 올바르게 설정
1) 기본 권장: Restart=on-failure
일반적으로는 아래가 가장 안전합니다.
- 정상 종료(코드 0)는 재시작하지 않음
- 비정상 종료만 재시작
[Service]
Restart=on-failure
RestartSec=2s
2) Restart=always는 신중히
always는 정상 종료도 다시 올립니다. 워커/잡 프로세스가 “작업 끝나면 종료”하는 형태라면, 의도치 않은 반복 실행이 됩니다.
3) 재시작 제외 코드 지정
애플리케이션이 특정 종료 코드를 “치명적 설정 오류”로 명확히 구분할 수 있다면, 그 코드는 재시작하지 않게 만들 수 있습니다.
[Service]
Restart=on-failure
RestartPreventExitStatus=2
예: 설정 파일 파싱 실패 시 2로 종료하도록 만들고, 이 경우 재시작 대신 운영자가 개입하도록 유도합니다.
해결 전략 3: StartLimit 튜닝(폭주 방지 + 자동복구)
아래는 운영에서 자주 쓰는 균형점입니다.
- 1분 동안 최대 5회까지만 시작 시도
- 그 이상이면 실패 상태로 고정(알람과 함께 수동 조치)
[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=3s
왜 RestartSec가 중요한가
RestartSec는 재시작 간격을 강제로 벌려서,
- 로그 폭증을 줄이고
- CPU/메모리 스파이크를 완화하며
- 외부 의존성(DB 기동, 네트워크 준비 등)이 회복될 시간을 줍니다.
즉, StartLimit은 “최대 시도 횟수 제한”, RestartSec는 “시도 간격(백오프의 가장 단순한 형태)”입니다.
해결 전략 4: 의존성 준비 전 기동 문제(네트워크/DB)
서비스가 부팅 직후 자주 죽는다면, 의존성이 준비되기 전에 올라왔다가 실패하는 패턴일 수 있습니다.
1) 네트워크 의존
[Unit]
After=network-online.target
Wants=network-online.target
2) DB나 다른 서비스 의존
예를 들어 PostgreSQL 이후에 올려야 한다면:
[Unit]
After=postgresql.service
Wants=postgresql.service
단, After=는 “순서”만 보장합니다. DB가 실제로 쿼리를 받을 준비가 되었는지는 보장하지 않습니다. 필요하면 애플리케이션 레벨에서 재시도 로직을 넣거나, ExecStartPre로 간단한 체크를 두는 방식을 씁니다.
[Service]
ExecStartPre=/usr/bin/bash -lc 'for i in {1..20}; do nc -z 127.0.0.1 5432 && exit 0; sleep 1; done; exit 1'
위처럼 하면 DB 포트가 열릴 때까지 기다리다가, 끝내 안 열리면 실패로 처리되어 systemd 재시작 정책이 발동합니다.
해결 전략 5: Type과 프로세스 포그라운드 문제
systemd는 기본적으로 Type=simple이며, ExecStart로 실행한 프로세스가 포그라운드에서 계속 살아있길 기대합니다.
- 데몬이 스스로 포크하여 백그라운드로 가는 전통적 방식이라면
Type=forking과PIDFile이 필요할 수 있습니다. - 반대로 이미 포그라운드 실행을 지원하는데
forking을 쓰면 상태 판단이 꼬일 수 있습니다.
예시(전통적 포킹 데몬):
[Service]
Type=forking
PIDFile=/run/myapp.pid
ExecStart=/usr/local/bin/myapp --daemon --pidfile /run/myapp.pid
Restart=on-failure
RestartSec=3s
가능하다면 요즘은 포그라운드 실행(논포킹)으로 바꾸고 Type=simple로 단순화하는 편이 장애 대응이 쉽습니다.
실전 예시: 무한 재시작을 안전한 자동복구로 바꾸기
문제 상황:
Restart=always- 프로세스가 설정 오류로 즉시 종료
- 1초도 안 되어 재시작 반복, 곧 StartLimitHit
개선된 유닛 예시:
[Unit]
Description=MyApp API
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=6
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/opt/myapp/bin/myapp
Restart=on-failure
RestartSec=5s
# 설정 오류 등 즉시 재시작이 의미 없는 케이스는 제외
RestartPreventExitStatus=2
# 종료 시그널/타임아웃은 환경에 맞게
TimeoutStartSec=30
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target
운영 절차:
sudo systemctl daemon-reload
sudo systemctl reset-failed myapp.service
sudo systemctl restart myapp.service
sudo journalctl -u myapp.service -f
핵심은 “재시작을 없애는 것”이 아니라, 재시작이 시스템에 피해를 주지 않도록 제한하고, 의미 없는 재시작은 막는 것입니다.
자주 하는 실수 체크리스트
Restart=always를 습관처럼 넣고RestartSec를 안 둔다- StartLimit에 걸렸는데 원인 로그를 보기 전에
StartLimitBurst만 올린다 EnvironmentFile경로/권한 문제로 즉시 종료하는데 재시작으로 덮는다Type=forking과 실제 실행 방식이 불일치한다systemctl cat으로 드롭인까지 합쳐진 최종 설정을 확인하지 않는다
운영 팁: 알람과 함께 쓰기
StartLimit은 “안전장치”이므로, 걸렸을 때 누군가 알아야 합니다. 보통은
OnFailure=로 알림 유닛을 연결하거나- 로그/메트릭에서
StartLimitHit또는Result=exit-code를 감지 하는 방식으로 운영합니다.
재시작 루프는 클라우드 비용에도 영향을 줍니다. 예를 들어 NAT를 통해 외부로 계속 나가며 실패/재시도를 반복하면 트래픽 비용이 의미 없이 늘 수 있습니다. 비용 이상징후를 빠르게 잡는 관점에서는 VPC NAT Gateway 비용 폭증 10분 진단·절감 같은 접근도 도움이 됩니다.
정리
- 무한 재시작은 대개
Restart=정책과 “즉시 종료되는 원인”이 결합된 결과입니다. - StartLimitHit는 그 폭주를 막는 안전장치이며, 걸렸다면
reset-failed후 재시도해야 합니다. - 올바른 방향은
Restart=on-failure+ 적절한RestartSec+ 합리적인StartLimitIntervalSec/StartLimitBurst조합입니다. - 무엇보다 먼저, journal에서 왜 즉시 종료되는지를 잡아야 재발이 멈춥니다.