Published on

systemd 서비스 자동 재시작 - 죽었다 깨도 복구

Authors

운영 중인 리눅스 서비스가 한 번 죽으면, 새벽에 알람을 보고 수동으로 systemctl restart 하는 패턴은 오래 못 갑니다. 특히 JVM, Node.js, Python 같은 런타임 기반 서비스는 메모리 단편화, 외부 의존성 타임아웃, 커넥션 누수, 로그 적재 지연 등으로 “가끔” 죽거나 멈추는 일이 반복됩니다.

이 글은 “죽었다 깨도 다시 살아나는” 수준을 목표로, systemd만으로 만들 수 있는 자동 재시작 구성을 단계별로 정리합니다.

  • 단순 재시작이 아니라, 비정상 종료, 시그널 종료, 행(hang) 상태까지 다룹니다.
  • 무한 재시작 루프를 막기 위해 rate limit백오프(backoff) 를 설계합니다.
  • 재시작이 “성공”인지 판단하기 위해 헬스체크 기반 재기동도 함께 넣습니다.

관련해서 장애가 커넥션 고갈이나 누수에서 시작되는 경우가 많으니, 원인 분석은 아래 글도 같이 보면 좋습니다.

systemd 자동 재시작의 핵심: Restart, StartLimit, Timeout

systemd에서 자동 재시작은 유닛 파일의 Service 섹션에서 결정됩니다. 가장 중요한 옵션은 아래 4가지입니다.

  • Restart=: 어떤 상황에서 재시작할지
  • RestartSec=: 재시작 전 대기 시간(간단한 백오프)
  • StartLimitIntervalSec= / StartLimitBurst=: 일정 시간 내 재시작 횟수 제한(무한 루프 방지)
  • TimeoutStartSec= / TimeoutStopSec=: 시작/종료가 멈췄을 때 강제 처리

Restart= 값은 아래를 많이 씁니다.

  • on-failure: 비정상 종료(프로세스 exit code 비정상, watchdog timeout, signal 등)일 때만 재시작
  • always: 정상 종료라도 무조건 재시작

대부분의 API 서버나 워커는 정상 종료가 “의도된 종료”가 아닌 경우가 많아서 on-failure 로 시작하고, 정말 절대 죽으면 안 되는 데몬이면 always 를 고려합니다.

기본형: 가장 많이 쓰는 “죽으면 재시작” 유닛

예시로 myapp 이라는 서비스를 만든다고 가정합니다.

/etc/systemd/system/myapp.service

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

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

# 실제 실행 커맨드
ExecStart=/opt/myapp/bin/myapp

# 장애 시 자동 재시작
Restart=on-failure
RestartSec=3s

# 시작/종료가 멈췄을 때 처리
TimeoutStartSec=30s
TimeoutStopSec=20s

# 너무 빨리 죽는 경우(크래시 루프) 차단
StartLimitIntervalSec=60
StartLimitBurst=5

# 로그는 journalctl 로 모으는 구성이 가장 단순
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

적용/실행:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service

여기까지면 “프로세스가 죽으면 다시 띄움”은 됩니다. 하지만 운영에서 자주 마주치는 진짜 문제는 아래 두 가지입니다.

  1. 프로세스가 죽지 않고 멈춘다 (스레드 데드락, 이벤트 루프 정지, 외부 의존성 영구 대기)
  2. 죽자마자 바로 재시작을 반복하며 장애 루프가 된다 (설정 오류, DB 다운, 포트 충돌)

이걸 systemd 방식으로 탄탄하게 만들려면 다음 단계가 필요합니다.

“죽진 않았는데 멈춤” 대응 1: watchdog(권장)

systemd는 서비스가 주기적으로 “나 살아있음” 신호를 보내도록 만들 수 있습니다. 이를 WatchdogSec= 로 켜고, 서비스가 제때 ping을 못 하면 systemd가 강제 재시작합니다.

유닛 설정:

[Service]
WatchdogSec=30s
Restart=on-failure

애플리케이션에서 systemd notify를 보내야 합니다.

예시: Bash로 systemd watchdog ping 보내기

애플리케이션이 watchdog를 직접 지원하지 않는다면, 래퍼 스크립트로 우회할 수 있습니다.

/opt/myapp/bin/run-with-watchdog.sh

#!/usr/bin/env bash
set -euo pipefail

# systemd-notify 가 없으면 watchdog는 동작하지 않음
command -v systemd-notify >/dev/null 2>&1 || {
  echo "systemd-notify not found" >&2
  exec /opt/myapp/bin/myapp
}

# 실제 프로세스 실행(백그라운드)
/opt/myapp/bin/myapp &
APP_PID=$!

# systemd에 준비 완료 신호(선택)
systemd-notify --ready || true

# watchdog ping 루프
while kill -0 "$APP_PID" 2>/dev/null; do
  systemd-notify WATCHDOG=1 || true
  sleep 10
done

# 앱이 종료되면 exit code 전달
wait "$APP_PID"

유닛에서 ExecStart= 를 이 스크립트로 바꿉니다.

ExecStart=/opt/myapp/bin/run-with-watchdog.sh
WatchdogSec=30s
Restart=on-failure

이 방식은 “프로세스가 살아있기만 하면 ping을 보낸다”는 한계가 있습니다. 즉, 앱이 논리적으로 멈췄는데 프로세스만 살아있으면 ping도 계속 나가서 감지가 안 됩니다. 그래서 다음 방법이 더 실전적입니다.

“죽진 않았는데 멈춤” 대응 2: 헬스체크 실패 시 강제 재시작(실전)

서비스가 HTTP 헬스 엔드포인트를 제공한다면, 외부에서 주기적으로 체크하고 실패 시 systemctl restart 를 실행하는 것이 가장 확실합니다.

핵심은 두 가지입니다.

  • 헬스체크는 curlhttp://127.0.0.1:PORT/health 를 두드린다
  • 실패가 몇 번 연속될 때만 재시작한다(일시적 네트워크/GC 튐 방지)

헬스체크 스크립트

/usr/local/bin/myapp-health-restart.sh

#!/usr/bin/env bash
set -euo pipefail

SERVICE_NAME="myapp.service"
URL="http://127.0.0.1:8080/health"
TIMEOUT_SEC=2
FAIL_THRESHOLD=3
STATE_FILE="/run/myapp-health-fails"

fails=0
if [[ -f "$STATE_FILE" ]]; then
  fails=$(cat "$STATE_FILE" || echo 0)
fi

if curl -fsS --max-time "$TIMEOUT_SEC" "$URL" >/dev/null; then
  echo 0 > "$STATE_FILE"
  exit 0
fi

fails=$((fails+1))
echo "$fails" > "$STATE_FILE"

if [[ "$fails" -ge "$FAIL_THRESHOLD" ]]; then
  logger -t myapp-health "healthcheck failed ${fails} times, restarting ${SERVICE_NAME}"
  echo 0 > "$STATE_FILE"
  systemctl restart "$SERVICE_NAME"
fi

권한 설정:

sudo chmod 755 /usr/local/bin/myapp-health-restart.sh

systemd timer로 10초마다 실행

/etc/systemd/system/myapp-health.timer

[Unit]
Description=MyApp health check timer

[Timer]
OnBootSec=30s
OnUnitActiveSec=10s
AccuracySec=1s
Unit=myapp-health.service

[Install]
WantedBy=timers.target

/etc/systemd/system/myapp-health.service

[Unit]
Description=MyApp health check and restart

[Service]
Type=oneshot
ExecStart=/usr/local/bin/myapp-health-restart.sh

적용:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp-health.timer
systemctl list-timers | grep myapp-health

이 구성이 좋은 이유는 명확합니다.

  • 프로세스가 살아있어도 헬스가 죽으면 재시작된다
  • 헬스 실패가 연속될 때만 재시작해서 오탐을 줄인다
  • cron 대신 systemd timer를 쓰니 로그/상태 추적이 쉽다

크래시 루프 방지: StartLimit과 “대기 시간 늘리기”

RestartSec=3s 만으로는 DB 장애 같은 상황에서 재시작 폭풍이 생길 수 있습니다. systemd는 기본적으로 StartLimitIntervalSecStartLimitBurst 로 일정 횟수 초과 시 더 이상 시작을 시도하지 않고 실패 상태로 둡니다.

실전 권장값 예시:

Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=300
StartLimitBurst=10
  • 5분 동안 10번 이상 죽으면 “지금은 뭔가 근본적으로 잘못됨”으로 보고 멈추게 합니다.
  • 멈춘 상태에서 원인(설정, 의존성, 인증서, 포트 충돌)을 해결한 뒤 systemctl reset-failed myapp.servicesystemctl start myapp.service 로 복구합니다.
sudo systemctl reset-failed myapp.service
sudo systemctl start myapp.service

지수 백오프가 필요하면?

systemd 자체는 지수 백오프를 직접 제공하지 않습니다. 대신 아래 중 하나로 해결합니다.

  • 애플리케이션이 의존성이 준비될 때까지 내부적으로 재시도(backoff)하도록 구현
  • ExecStartPre= 에서 의존성 체크 후 실패 시 exit code로 빠져 systemd 재시작에 맡기되, RestartSec 를 크게 잡기
  • 별도 래퍼 스크립트에서 sleep 시간을 늘리는 방식(운영 난이도 증가)

대부분은 RestartSecStartLimit* 조합으로 충분합니다.

종료 신호와 정리: KillSignal, ExecStop, KillMode

서비스가 죽을 때 “깔끔하게” 내려가야 다음 기동이 안전합니다. 예를 들어 포트가 남아있거나, 락 파일이 남거나, 임시 파일이 쌓이면 재시작이 실패합니다.

자주 쓰는 옵션:

KillSignal=SIGTERM
ExecStop=/bin/kill -s SIGTERM $MAINPID
KillMode=mixed
TimeoutStopSec=20s
  • KillMode=mixed 는 메인 프로세스는 정상 종료를 기다리고, 나머지 자식 프로세스는 필요 시 강제 종료하는 쪽으로 동작합니다.
  • 애플리케이션이 SIGTERM을 받으면 정상 종료하도록 구현돼 있어야 합니다.

재시작이 “성공”인지 판단: ExecStartPost로 검증

프로세스가 떠도, 실제로는 포트 바인딩 실패 등으로 즉시 죽을 수 있습니다. ExecStartPost= 로 기동 직후 헬스 검증을 넣으면 “떴다”가 아니라 “쓸 수 있다”를 기준으로 재시작 루프를 제어할 수 있습니다.

ExecStartPost=/usr/bin/curl -fsS --max-time 2 http://127.0.0.1:8080/health

이 검증이 실패하면 유닛은 실패로 간주되고, Restart=on-failure 에 의해 다시 시도합니다.

관측과 트러블슈팅: journalctl로 재시작 원인 추적

자동 재시작을 걸어두면, “왜 죽었는지”를 놓치기 쉽습니다. 재시작 횟수와 직전 로그를 빠르게 보는 커맨드만 습관화해도 운영 품질이 올라갑니다.

# 최근 로그
journalctl -u myapp.service -n 200 --no-pager

# 오늘 발생한 로그만
journalctl -u myapp.service --since today --no-pager

# 재시작/실패 관련 메시지 필터링
journalctl -u myapp.service | grep -E "Failed|restart|Start request repeated" -n

만약 서비스가 DB 병목이나 락으로 멈추는 계열이라면, 단순 재시작으로 숨기기보다 원인 제거가 먼저입니다. 예를 들어 MySQL 데드락이 원인이라면 아래 글처럼 쿼리 추적부터 들어가는 것이 맞습니다.

운영용 추천 조합(템플릿)

API 서버/워커처럼 “상시 떠 있어야 하는” 서비스 기준으로, 가장 무난한 조합은 아래입니다.

  • Restart=on-failure
  • RestartSec=5s
  • StartLimitIntervalSec=300, StartLimitBurst=10
  • TimeoutStartSec=30s, TimeoutStopSec=20s
  • 헬스체크 timer로 10초 내외 주기 점검, 연속 실패 시 재시작

최종 예시 유닛(요약):

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

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

Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=300
StartLimitBurst=10

TimeoutStartSec=30s
TimeoutStopSec=20s
KillMode=mixed

StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

마무리

systemd의 자동 재시작은 단순히 Restart=always 한 줄로 끝내면, 언젠가 “조용히 망가진 채로 계속 재시작만 하는 서버”를 만들기 쉽습니다. 운영에서 진짜 강한 구성은 다음을 동시에 만족해야 합니다.

  • 죽으면 올라온다(비정상 종료 대응)
  • 안 죽고 멈춰도 복구된다(헬스체크 또는 watchdog)
  • 무한 루프는 막는다(StartLimit, 적절한 RestartSec)
  • 왜 죽는지는 남긴다(journalctl 기반 관측)

위 템플릿을 기반으로 서비스 특성에 맞게 헬스 기준과 제한 값을 조정하면, “죽었다 깨도 복구되는” 운영 자동화의 최소 단위를 만들 수 있습니다.