Published on

systemd 서비스 무한 재시작 - StartLimit 해결

Authors

서버에서 데몬을 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=forkingPIDFile이 필요할 수 있습니다.
  • 반대로 이미 포그라운드 실행을 지원하는데 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에서 왜 즉시 종료되는지를 잡아야 재발이 멈춥니다.