SSD Nodes Learn 🎉 VPS $5.50/월부터
가이드 Matt Connor작성자 Matt Connor

systemd 서비스가 자동으로 재시작되지 않는 이유

Restart 설정은 메인 프로세스만 감시하므로 자식 프로세스 종료를 인식하지 못합니다. Type 설정과 재시작 제한, 그리고 cgroup 동작 원리를 분석하여 서비스가 중단되었을 때 systemd가 왜 반응하지 않는지 그 기술적 원인을 상세히 설명합니다.

간단한 답변: systemd 재시작 정책은 하나의 프로세스만 감시합니다

systemd 재시작 정책은 유닛당 하나의 프로세스, 즉 메인 프로세스만 감시합니다. Restart=은 오직 그 프로세스의 종료 상태만을 읽으며 다른 것은 확인하지 않습니다. 유닛의 컨트롤 그룹(control group)에 20개의 프로세스가 있더라도, 그중 하나가 죽으면 메인 프로세스가 살아있다는 이유로 유닛은 여전히 active (running) 상태를 유지합니다. systemd가 보기에 실패한 것은 없으므로 아무것도 재시작되지 않습니다.

systemd가 다른 프로세스들에 대해 모르는 것은 아닙니다. 유닛이 중지될 때 해당 프로세스들을 종료하고, 유닛의 메모리 제한에 그들의 사용량을 합산하며, 유닛의 CPU 할당량을 적용하고, systemctl status에 그들을 출력하기도 합니다. 단지 그들의 종료 상태를 읽지 않을 뿐입니다. 재시작 로직과 cgroup은 서로 다른 개념이며, 이 가이드의 대부분은 이 둘 사이의 간극을 다룹니다.

cgroup이 보유하는 정보와 재시작 로직이 읽는 정보

cgroup(control group)은 프로세스 집합을 소유하는 커널 객체입니다. 모든 서비스 유닛은 유닛 이름을 딴 cgroup을 하나씩 할당받습니다. 프로세스는 이 그룹을 떠날 수 없습니다. 자식 프로세스는 부모의 cgroup을 상속하며, 권한이 없는 프로세스는 스스로 다른 곳으로 이동할 수 없습니다. 이것이 바로 systemd가 두 번 fork하는 데몬을 깔끔하게 정리할 수 있는 이유이며, 기존의 init 스크립트로는 안정적으로 수행할 수 없었던 작업입니다.

다음 두 가지 사실을 나란히 비교해 보십시오.

systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Restart -p RestartUSec myapp.service

systemd-cgls은 유닛 내의 모든 프로세스를 나열합니다. MainPID은 재시작 정책이 읽어 들이는 단일 숫자입니다. 이 두 가지가 사용자의 예상과 다르다면, 그 불일치가 곧 버그입니다. MainPID=0는 잘못된 PID보다 더 심각한 상황입니다. 이는 systemd가 아무것도 추적하지 못하고 있다는 뜻이므로, 어떤 Restart= 값도 작동할 수 없습니다.

메인 프로세스 규칙에는 한 가지 실제 예외가 있습니다. 커널의 out-of-memory killer가 유닛의 cgroup 내에 있는 프로세스를 종료하면, systemd는 cgroup의 memory.events 파일을 감시하고 있으므로 이를 즉시 인지합니다. OOMPolicy=는 그다음 동작을 결정하며, 기본값은 stop입니다. 이 경우 유닛 전체가 중지되고 결과는 oom-kill로 기록됩니다. 이는 실패로 간주되므로 Restart=on-failure이 작동합니다. 저널 로그에는 이 내용이 명확하게 기록됩니다.

myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Failed with result 'oom-kill'.

따라서 메모리 부족으로 종료된 자식 프로세스는 유닛 전체를 중단시키지만, 세그멘테이션 오류로 종료된 동일한 자식 프로세스는 그렇지 않습니다. 유닛에 메모리 제한을 설정하는 경우, 재시작 정책을 조정하기 전에 MemoryMax와 CPUQuota가 유닛의 cgroup에 적용되는 방식을 먼저 읽어 보십시오. 이 두 기능이 만나는 지점은 바로 여기뿐이기 때문입니다.

Type=이 메인 프로세스를 결정하는 방식

Type=[Service] 섹션 설정은 단순히 시작 순서만을 결정하지 않습니다. 이 설정은 어떤 PID(프로세스 ID)가 MainPID이 될지를 결정하며, 이는 곧 Restart=가 무엇을 감시할 수 있는지를 결정하는 규칙이기도 합니다.

  • Type=simple은 기본값입니다. systemd가 ExecStart=에서 포크(fork)한 프로세스가 메인 프로세스가 됩니다. systemd는 exec가 실제로 성공했는지 확인하기 전에 즉시 유닛을 시작된 것으로 표시합니다. 바이너리 경로에 오타가 있으면 시작 작업은 성공한 것으로 나오지만, 잠시 후 Main process exited, code=exited, status=203/EXEC 상태가 됩니다.
  • Type=execsimple과 유사하게 동작하지만, 시작 작업이 exec가 성공할 때까지 기다린다는 점이 다릅니다. 이 설정을 사용하면 앞서 언급한 오타 발생 시 정직하게 시작 실패를 보고합니다. 이 설정은 systemd 240 버전 이상이 필요하며, 현재 지원되는 모든 배포판에서 사용할 수 있습니다. simple보다 이 설정을 권장합니다.
  • Type=forkingExecStart=에서 실행된 프로세스가 백그라운드 데몬을 포크하고 종료될 것으로 예상합니다. systemd는 부모 프로세스가 종료되기를 기다린 후 실제 데몬을 찾습니다. 이때 PIDFile=을 지정해야 합니다. 이를 지정하지 않으면 (기본값인) GuessMainPID=는 cgroup에 정확히 하나의 프로세스만 남아 있을 때만 작동합니다. 두 개 이상의 프로세스가 남으면 MainPID0 상태로 유지됩니다.
  • Type=notify은 서비스가 sd_notify(3)을 호출하고 트래픽을 처리할 준비가 되었을 때 READY=1를 보낸다는 의미입니다. 또한 MAINPID=을 보내 systemd가 추적할 다른 프로세스를 전달할 수도 있습니다. NotifyAccess=은 기본적으로 main이므로, 자식 프로세스가 보낸 알림은 무시되며 저널에는 알림을 보낸 PID가 기록됩니다.
  • Type=oneshot은 지속되는 메인 프로세스가 없습니다. RemainAfterExit=yes를 설정하지 않으면 ExecStart=가 완료되는 즉시 유닛은 비활성 상태가 됩니다. 여기서는 Restart=alwaysRestart=on-success 설정이 거부되며 Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing. 메시지가 출력됩니다. on-failure를 포함한 다른 값들은 허용됩니다.

두 가지 Type=forking 오류는 기억해 둘 가치가 있습니다. 이 오류들은 겉보기에는 아무 이유 없이 유닛이 고장 난 것처럼 보이게 만들기 때문입니다.

myapp.service: Can't open PID file /run/myapp.pid (yet?) after start: No such file or directory
myapp.service: New main PID 4711 does not belong to service, and PID file is not owned by root. Refusing.

첫 번째 오류는 데몬이 PID 파일을 다른 곳에 쓰거나, systemd가 확인하는 시점보다 늦게 쓸 때 발생합니다. 두 번째 오류는 PID 파일이 유닛의 cgroup 외부에 있는 프로세스를 가리킬 때 발생합니다. systemd는 이를 채택하기를 거부하는데, 만약 허용된다면 쓰기 가능한 PID 파일이 시스템의 어떤 프로세스에든 신호를 보낼 수 있는 수단으로 악용될 수 있기 때문입니다.

래퍼 스크립트가 자식 프로세스의 종료를 숨기는 이유

제목과 같은 의문을 자아내는 구조는 다음과 같습니다.

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait

이 유닛은 Type=simple이므로 메인 프로세스는 셸입니다. 인자 없이 실행된 wait는 모든 자식 프로세스가 종료된 후에야 반환됩니다. 워커 프로세스를 강제로 종료해도 셸은 웹 프로세스를 계속 기다리므로 셸은 종료되지 않고, 따라서 MainPID도 종료되지 않으며, Restart=는 호출되지 않습니다. 이제 cgroup에는 프로세스가 하나 줄어든 상태로 남고, systemctl status는 더 짧아진 트리 구조를 출력하며, 유닛 상태는 여전히 active (running)입니다. systemd는 해당 트리 내부의 변경 사항을 감시하지 않습니다.

동일한 실수의 두 번째 버전은 더 조용하게 나타납니다.

ExecStart=/bin/sh -c 'export APP_ENV=production; /usr/local/bin/myapp'

메인 프로세스는 myapp이 아니라 셸입니다. systemctl stop 시점에 systemd는 메인 프로세스에 SIGTERM을 보내지만, 포그라운드 자식 프로세스를 기다리는 셸은 이 신호를 전달하지 않습니다. 그 결과 종료 과정은 기본값인 90초 동안 TimeoutStopSec를 모두 소모한 뒤 다음과 같이 끝납니다.

myapp.service: State 'stop-sigterm' timed out. Killing.
myapp.service: Killing process 4711 (myapp) with signal SIGKILL.

해결책은 exec입니다. exec /usr/local/bin/myapp을 작성하면 셸이 프로그램으로 대체되므로 MainPID는 해당 프로그램이 되며 신호가 정상적으로 전달됩니다. 더 좋은 방법은 셸을 제거하고 유닛 파일에서 Environment=이나 EnvironmentFile=를 사용하는 것입니다. 이 버그는 -c 문자열에 단일 명령만 포함된 경우 숨겨진다는 점에 유의하십시오. bash와 dash 모두 해당 경우를 직접적인 exec으로 최적화하기 때문입니다. 문자열에 두 번째 명령을 추가하면 셸이 프로그램 앞단에서 계속 살아 있게 됩니다.

테스트 VPS에서 2분 만에 재현하기

위의 래퍼를 /usr/local/bin/two-children.sh로 저장하고 chmod +x을 사용하여 실행 권한을 부여한 뒤, 두 프로그램 경로를 sleep 3600로 바꿉니다. Type=simpleRestart=on-failure을 사용하여 유닛을 지정하고, systemctl daemon-reload를 실행한 뒤 서비스를 시작합니다. systemd-cgls --unit two-children.service을 실행하여 셸과 두 자식 프로세스의 PID 3개를 확인합니다. 자식 프로세스 하나를 sudo kill <pid>로 종료합니다. 유닛 상태를 다시 확인하면 트리 구조에서 프로세스가 하나 줄어들었음에도 상태는 여전히 active (running)이며, 저널에는 새로운 기록이 남지 않습니다. 이제 대신 sudo kill -9 <shell pid>을 실행해 보십시오. 유닛은 실패하고, KillMode=control-group이 기본값이므로 살아남은 자식 프로세스는 정리되며, 저널에는 Scheduled restart job, restart counter is at 1.이 표시됩니다.

Restart= 설정의 전체 어휘와 on-failure가 always보다 나은 경우

Restart=는 7가지 값 중 하나를 취하며, 이들을 구분하는 기준은 무엇을 정상 종료로 간주하느냐입니다. systemd는 종료 코드 0, SuccessExitStatus=에 나열된 모든 코드, 그리고 SIGHUP, SIGINT, SIGTERM, SIGPIPE 신호를 정상 종료로 처리합니다. SIGKILL과 SIGSEGV를 포함한 그 외의 모든 경우는 비정상 종료입니다.

  • no는 기본값입니다. 유닛은 스스로 재시작하지 않으며, 이것이 Restart= 줄이 없는 유닛이 첫 번째 충돌에서 죽고 그대로 멈춰 있는 이유입니다.
  • on-success는 정상 종료 후에만 재시작합니다.
  • on-failure는 0이 아닌 종료 코드, 비정상 신호, 시작 또는 중지 타임아웃, watchdog 만료 시 재시작합니다.
  • on-abnormal은 비정상 신호, 타임아웃, watchdog 만료 시 재시작하지만, 단순한 0이 아닌 종료 코드에서는 재시작하지 않습니다.
  • on-abort은 비정상 신호, 즉 충돌이 발생했을 때만 재시작합니다.
  • on-watchdogWatchdogSec=가 만료될 때만 재시작합니다.
  • always은 정상 종료(상태 코드 0)를 포함하여 위에서 언급한 모든 경우에 재시작합니다.

on-failure은 장기 실행 데몬을 위한 올바른 기본값입니다. 충돌 시 서비스를 복구하며, 의도적인 exit 0는 그대로 둡니다. always은 원격지 연결이 끊겼을 때 0을 반환하는 터널 클라이언트처럼, 제어할 수 없는 이유로 정상 종료되는 프로그램에 적합합니다. always의 단점은 버그를 숨긴다는 것입니다. 서비스가 시작되어 손상된 설정 파일을 읽고 오류를 기록한 뒤 0으로 종료되면 무한 루프에 빠지며, 유일한 징후는 재시작 카운터가 올라가는 것뿐입니다.

SuccessExitStatus=는 정상과 비정상의 경계를 조정합니다. Borg는 경고 시 1, 오류 시 2를 반환하므로 SuccessExitStatus=1이 없는 백업 유닛은 읽을 수 없는 파일 하나를 건너뛸 때마다 실패로 표시됩니다. RestartPreventExitStatus=always 설정 하에서도 재시작을 차단할 코드를 나열하며, 이는 프로그램이 재시작하지 말아야 함을 알리는 올바른 방법입니다. RestartForceExitStatus=는 그 반대입니다. 백업 작업은 재시작 루프가 아닌 타이머에 의해 구동되는 Type=oneshot 유닛에 속해야 하며, 일정에 따라 작업을 실행하는 서비스와 타이머 쌍이 이를 구현하는 올바른 형태입니다.

테스트 시 주의할 점이 하나 있습니다. kill <pid>로 서비스를 종료하면 정상 목록에 있는 SIGTERM이 전송되므로, Restart=on-failure는 올바르게 아무 작업도 수행하지 않으며 사용자는 설정이 잘못되었다고 결론 내리게 됩니다. 대신 kill -9 <pid>이나 systemctl kill -s SIGKILL myapp.service를 사용하십시오. 또한 systemctl stop 이후나 BindsTo= 또는 PartOf= 의존성이 사라져 유닛이 중지된 경우에는 어떠한 Restart= 값도 작동하지 않는다는 점을 기억하십시오. 중지 작업은 실패가 아닙니다.

RestartSec 및 100밀리초 기본값

RestartSec=는 유닛이 중지된 후 systemd가 다시 시작하기까지의 대기 시간이며, 기본값은 100밀리초입니다. 유닛이 실제로 무엇을 로드했는지 확인하십시오:

systemctl show -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst myapp.service

설정하지 않은 유닛은 RestartUSec=100ms을 출력합니다. 이 기본값은 서비스가 일시적으로 충돌했다가 복구되는 경우에는 적절합니다. 하지만 아예 시작할 수 없는 서비스에는 부적합합니다. 0.5초 안에 5번의 재시작이 발생하여 다음에 설명할 속도 제한(rate limit)에 걸리기 때문입니다. 데이터베이스, 마운트, 네트워크 경로를 기다려야 하는 모든 서비스에는 RestartSec=5s 이상을 설정하십시오.

2026년 8월 기준으로, systemd 254 이상 버전은 RestartSteps=RestartMaxDelaySec=을 제공합니다. 이는 시도 횟수에 따라 RestartSec=에서 설정한 지연 시간을 상한선까지 점진적으로 늘려줍니다. Ubuntu 24.04는 systemd 255를 포함하고 있어 이 기능을 사용할 수 있습니다. Debian 12는 systemd 252를 포함하고 있어 이 기능을 지원하지 않습니다. 의존 서비스가 장시간 중단될 가능성이 있다면 지연 시간을 점진적으로 늘리는 방식이 올바른 해결책입니다.

"start request repeated too quickly" 오류의 실제 의미

이 상태는 systemd가 임의로 서비스를 포기하는 것처럼 보이지만, 실제로는 카운터에 의한 결과입니다. 규칙은 다음과 같습니다. 유닛이 StartLimitIntervalSec= 이내에 StartLimitBurst= 번 이상 시작되면, systemd는 재시작을 거부하고 실패 상태로 전환합니다. 기본값은 10초 동안 5회 시작입니다.

저널에는 다음과 같은 순서가 나타납니다.

myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.
Failed to start myapp.service - My application.

그리고 systemctl start은 이미 해결책을 제시하고 있습니다.

Job for myapp.service failed because start of the service was attempted too often. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details. To force a start use "systemctl reset-failed myapp.service" followed by "systemctl start myapp.service" again.

systemctl reset-failed myapp.service은 카운터와 실패 상태를 초기화합니다. 이 명령 외에는 초기화할 방법이 없으므로, 명령을 실행하기 전까지는 일반적인 systemctl start 시도가 계속 거부됩니다. 수동 시작도 제한 횟수에 포함되므로, 설정 파일을 수정하는 동안 성급하게 systemctl restart을 몇 번 실행하면 실제 충돌이 없더라도 제한에 걸릴 수 있습니다.

사람들이 오해하는 부분은 start-limit-hit이 서비스가 왜 실패했는지 그 이유를 알려주지 않는다는 점입니다. 이 메시지는 단지 서비스가 반복적으로 빠르게 실패했다는 사실만 알릴 뿐입니다. 실제 원인은 그 위쪽의 저널 라인에 기록되어 있습니다.

두 설정 모두 [Unit] 섹션에 작성해야 합니다. 예전 systemd 버전에서 허용하던 [Service]에 이 설정을 넣는 예시들이 혼란을 야기합니다. 반드시 [Unit]에 작성한 뒤, systemctl show를 사용하여 systemd가 실제로 어떤 값을 불러왔는지 확인하십시오. 로드된 값이 유일하게 적용되는 값이기 때문입니다.

[Unit]
Description=My application
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=exec
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=10s

위 설정은 유닛이 5분 동안 5번 시도할 수 있도록 허용합니다. StartLimitIntervalSec=0은 제한을 완전히 해제합니다. 이 설정을 선택할 때는 주의가 필요합니다. 시작할 수 없는 서비스가 영원히 재시도를 반복하며 매번 저널에 기록을 남기게 되기 때문입니다. 시스템 전체 기본값은 /etc/systemd/system.conf 파일 내의 DefaultStartLimitIntervalSec=DefaultStartLimitBurst=에서 관리됩니다.

주의해야 할 인접 설정이 하나 있습니다. StartLimitAction=은 제한에 도달했을 때의 동작을 결정하며, reboot, reboot-force, poweroff 등의 값을 설정할 수 있습니다. 기본값은 none이며, 이는 유닛을 실패 상태로 두고 시스템에는 영향을 주지 않습니다. 원격 VPS 환경에서 poweroff를 설정하면, 사용자가 직접 제공업체의 콘솔에 접속할 때까지 서버가 꺼진 상태로 유지될 수 있습니다.

해결 방법 1: 유닛당 하나의 프로세스

거의 모든 경우에 이 방법이 정답입니다. 두 개의 프로그램을 실행해야 한다면 두 개의 유닛을 작성하십시오. 그러면 각 유닛은 실제 메인 프로세스, 실제 종료 상태, 고유한 재시작 정책을 갖게 됩니다. 또한 로그, 리소스 제한, 재시작 카운터도 각각 분리되는데, 이는 새벽 3시에 장애가 발생했을 때 꼭 필요한 요소입니다.

유닛 간의 관계는 셸 스크립트가 아닌 유닛 파일 내에서 정의하십시오.

  • After=은 시작 순서만 지정합니다. 실패 시 동작에 대해서는 아무것도 정의하지 않습니다.
  • Requires=은 다른 유닛을 이 유닛과 함께 시작하며, 다른 유닛이 명시적으로 중지되면 이 유닛도 중지합니다.
  • BindsTo=Requires=에 더해 중요한 기능을 포함합니다. 다른 유닛이 충돌을 포함한 어떤 이유로든 중지되면 이 유닛도 함께 중지됩니다. After=과 함께 사용하십시오. 그렇지 않으면 순서가 정의되지 않습니다.
  • PartOf=은 중지 및 재시작 명령을 하위로 전파하므로, systemctl restart myapp.targetPartOf=된 모든 유닛에 도달합니다.
  • Upholds= (systemd 249 이상, 즉 Ubuntu 22.04 이상)는 지정된 유닛이 계속 실행되도록 유지합니다. 유닛이 중지되면 systemd가 다시 시작합니다. 다른 모든 유닛과 동일한 시작 속도 제한(start rate limit)이 적용됩니다.

API 서버 없이는 절대 실행되어서는 안 되며, API가 실행 중일 때 systemd가 항상 유지해야 하는 워커의 예시입니다.

# /etc/systemd/system/myapp-api.service
[Unit]
Description=myapp API server
Wants=network-online.target
After=network-online.target
Upholds=myapp-worker.service

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp serve
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/myapp-worker.service
[Unit]
Description=myapp background worker
BindsTo=myapp-api.service
After=myapp-api.service
StartLimitIntervalSec=120
StartLimitBurst=5

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp worker
Restart=on-failure
RestartSec=5s

워커에는 [Install] 섹션이 없으며 수동으로 활성화하지 않습니다. API 유닛이 Upholds=을 통해 워커를 불러오므로, systemctl enable --now myapp-api.service 명령만 실행하면 됩니다. 설정을 다시 불러오고 systemd가 이 쌍을 어떻게 구성했는지 확인하십시오.

sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp-worker.service
systemctl list-dependencies myapp-api.service

systemd-analyze verify은 파일에 문제가 없을 때 아무것도 출력하지 않습니다. 출력이 발생한다면 문제가 있는 것입니다. 주로 해당 섹션에서 systemd가 인식하지 못하는 키를 사용했거나, 존재하지 않는 유닛에 의존성을 설정했을 때 발생합니다.

두 번째 해결책: Type=notify를 사용하여 systemd가 PID 이상의 정보를 알게 하기

프로그램이 systemd 알림 프로토콜을 지원한다면 이를 사용하십시오. Type=notify를 사용하면 서비스가 준비되었을 때 systemd에 직접 알릴 수 있습니다. 이를 통해 단순히 기대에 의존하는 것이 아니라 실제 준비 상태에 기반한 순서 제어가 가능해지며, MAINPID=을 사용하여 systemd가 런처가 아닌 실제 중요한 프로세스를 가리키도록 할 수 있습니다.

WatchdogSec=은 노력할 가치가 있는 부분입니다. 이 값을 설정하면 서비스는 최소한 그 주기마다 sd_notify(3)을 통해 WATCHDOG=1를 전송해야 합니다. 메시지가 중단되면 systemd는 SIGABRT를 보내 서비스를 종료하고 실패 상태로 표시하므로, Restart=on-failureRestart=on-watchdog를 통해 서비스를 복구할 수 있습니다. 이는 프로세스가 살아있지만 멈춘 상태(stuck)를 감지할 수 있는 유일한 내장 방식이며, 어떤 종료 상태(exit-status) 정책으로도 이를 잡아낼 수 없습니다.

[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp serve
WatchdogSec=30s
Restart=on-failure
RestartSec=5s

워치독이 작동하면 저널에 myapp.service: Watchdog timeout (limit 30s)!이 기록된 후 프로세스가 종료됩니다. 만약 유닛이 TimeoutStartSec이 만료될 때까지 activating (start) 상태로 머물러 있다면, 이는 READY=1가 도착하지 않았음을 의미합니다. 프로그램이 해당 프로토콜을 지원하지 않거나, NotifyAccess=main이 자식 프로세스로부터 온 알림을 거부하고 있는 경우이며, 저널에는 두 PID가 모두 기록됩니다.

HTTP 상태 확인 엔드포인트는 제공하지만 sd_notify 지원은 없는 소프트웨어의 경우, 엔드포인트를 주기적으로 확인하여 systemctl restart를 호출하는 작은 타이머 유닛을 만들거나, 컨테이너 런타임이 확인을 수행하도록 하는 것이 합리적인 선택입니다. 후자는 Compose healthchecks and their restart behaviour가 존재하는 이유이기도 합니다.

세 번째 해결책: 선택의 여지가 없을 때 유닛 내부의 슈퍼바이저 사용하기

일부 소프트웨어는 분리할 수 없는 런처 뒤에 여러 프로세스가 묶인 형태로 배포됩니다. 이 경우 유닛 내부에 슈퍼바이저를 실행해야 하며, 그에 따른 결과를 감수해야 합니다. systemd는 슈퍼바이저를 감시하고, 슈퍼바이저는 나머지 모든 프로세스를 감시하게 되며, 재시작 정책은 두 개의 파일로 나뉘게 됩니다.

이러한 형태의 가장 흔한 예는 컨테이너 런타임입니다. docker compose 또는 podman 유닛이 정확히 이 패턴을 따르며, 컨테이너별 재시작 정책은 Compose 파일에 정의하고 systemd 유닛은 런타임 자체를 유지하는 역할만 수행합니다. 이러한 구조를 사용한다면 부팅 시 Compose 스택을 실행하는 유닛에서 작동하는 버전을 확인할 수 있으며, 왜 Type=oneshotRemainAfterExit=yes을 함께 사용하는 것이 일반적으로 올바른지 알 수 있습니다.

cgroup은 여전히 유용하게 작동합니다. 슈퍼바이저가 시작하는 모든 프로세스는 해당 유닛의 cgroup 내부에 머무르므로, MemoryMax=, CPUQuota= 및 중지 시 정리 작업은 전체 트리 구조에 적용됩니다. 오직 재시작 결정권만 위임될 뿐입니다.

어떤 슈퍼바이저를 선택하든, 외부 유닛에 Restart=always를 설정하고 내부에는 공격적인 재시작 정책을 신중한 고려 없이 설정하지 마십시오. 각각 고유한 백오프(backoff)를 가진 두 계층의 재시작 로직이 충돌하면 서비스가 수 분 동안 불안정하게 반복(flapping)될 수 있으며, 저널 로그만으로는 그 이유를 파악하기 어렵게 됩니다.

ExitType=cgroup은 "프로세스 하나라도 죽으면 재시작"을 의미하지 않습니다

ExitType=(systemd 250 이상, 즉 Ubuntu 24.04와 Debian 12 모두 포함)은 이 문제를 검색할 때 흔히 발견하는 설정이지만, 이름이 암시하는 것과는 정반대로 동작합니다. 기본값인 ExitType=main은 메인 프로세스가 종료될 때 서비스를 중지된 것으로 간주한다는 의미입니다. ExitType=cgroup는 cgroup 내의 마지막 프로세스가 종료될 때까지 서비스를 실행 중인 것으로 간주합니다.

따라서 ExitType=cgroup은 프로세스 하나가 죽었을 때 유닛이 더 민감하게 반응하도록 만드는 것이 아니라, 오히려 덜 민감하게 만듭니다. 이 설정은 실제 작업 프로세스를 fork하고 PID 파일 없이 부모 프로세스를 종료하는 프로그램(Type=forking가 데몬을 찾을 수 없는 경우)에 적합합니다. 여기서 설명하는 장애 상황에는 적절하지 않은 설정입니다.

"cgroup 내의 프로세스 중 하나라도 죽으면 유닛을 재시작"하는 것을 의미하는 Restart= 값은 존재하지 않습니다. 그러한 동작이 필요하다면 유닛당 하나의 프로세스를 할당해야 합니다. 프로그램을 분리할 수 없고 래퍼 스크립트를 제어할 수 있는 상황이라면, 첫 번째 자식 프로세스가 종료되는 즉시 반환하는 wait -n 방식이 가장 유사합니다.

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait -n
exit 1

이제 자식 프로세스 중 하나라도 죽으면 래퍼 프로세스도 0이 아닌 상태 코드로 종료되므로 Restart=on-failure이 동작하게 됩니다. 이는 근본적인 해결책이 아니라 타협안입니다. 여전히 두 프로그램이 하나의 재시작 카운터를 공유하고 로그 스트림이 하나로 합쳐지며, 실패한 절반만 따로 재시작할 방법은 없습니다.

실제로 무슨 일이 일어났는지 확인하는 방법

다음 네 가지 명령어를 순서대로 실행합니다.

systemctl status myapp.service
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Result -p ExecMainStatus myapp.service
journalctl -u myapp.service -b -o short-precise

systemctl status은 서비스의 상태, 메인 PID, cgroup 트리를 한 화면에 보여줍니다. 정상적인 유닛은 Active: active (running) 상태를 나타내며, Main PID: 줄에 예상한 프로세스 이름이 표시됩니다. 하단 트리에 알 수 없는 프로세스가 나열되어 있거나 예상한 프로세스가 없다면, 이미 원인을 파악한 것입니다.

systemd-cgls --unit은 생략 없이 전체 트리를 출력합니다. 유닛이 여러 프로세스를 관리할 때 유용합니다.

systemctl show는 기계가 읽을 수 있는 형식으로 정보를 제공합니다. NRestarts=은 재시작 횟수를 나타내며, 서비스가 부팅 이후 계속 실행 중인지 아니면 40번이나 재시작되었는지 가장 빠르게 확인할 수 있는 방법입니다. Result=에는 마지막 실패 원인이 담겨 있습니다: exit-code, signal, timeout, oom-kill, watchdog 또는 start-limit-hit. ExecMainStatus=은 마지막 메인 프로세스의 원시 종료 상태 코드입니다.

저널(journal)에는 실행 순서가 기록되어 있습니다. 다음 세 줄을 찾아보십시오.

myapp.service: Main process exited, code=exited, status=1/FAILURE
myapp.service: Failed with result 'exit-code'.
myapp.service: Scheduled restart job, restart counter is at 1.

code=exited, status=N는 프로그램이 N을 반환하기로 결정했음을 의미하며, 따라서 오류는 프로그램 자체나 설정에 있습니다. code=killed, signal=SEGV은 프로그램이 충돌했음을 의미합니다. code=killed, signal=TERM는 보통 다른 주체에 의해 종료 요청을 받았음을 의미하며, 이는 실패가 아니므로 Restart=on-failure를 트리거하지 않습니다. code=dumped은 코어 파일이 생성되었음을 의미하며, systemd-coredump이 설치되어 있다면 coredumpctl list 명령어로 이를 확인할 수 있습니다.

여러 대의 서버를 운영한다면 NRestarts 값을 주기적으로 수집해야 합니다. 매일 카운터가 올라가는 유닛은 누군가 인지했는지 여부와 관계없이 매일 실패하고 있는 것입니다. 서버가 2~3대를 넘어가면 모든 서버에서 명령어를 일관되게 실행하는 방법을 사용하여 추측이 아닌 보고서 형태로 문제를 파악할 수 있습니다.

FAQ

프로세스가 종료되었는데 systemctl은 왜 서비스가 active라고 합니까?

systemd는 서비스 유닛당 하나의 메인 프로세스만 추적하며, Restart=은 해당 프로세스의 종료 상태만 읽습니다. 유닛이 시작한 다른 모든 프로세스는 동일한 cgroup 내에서 실행됩니다. 유닛이 중지될 때 systemd가 이 프로세스들을 종료시키기는 하지만, 종료 여부를 감시하지는 않습니다. systemctl show -p MainPID myapp.service을 실행하여 그 결과값을 systemd-cgls --unit myapp.service와 비교하십시오. 종료된 프로세스가 트리에는 나타나지만 MainPID이 아니라면, systemd는 설계된 대로 동작한 것입니다. 해결책은 유닛당 하나의 프로세스만 실행하고, 유닛 간의 관계를 BindsTo=Upholds=로 정의하는 것입니다.

"start request repeated too quickly"는 무슨 뜻입니까?

해당 유닛이 StartLimitIntervalSec= 동안 StartLimitBurst=번 이상 시작되었음을 의미합니다. 기본값은 10초 동안 5회이며, 이 제한을 넘으면 systemd는 재시도를 중단합니다. 이는 속도 제한(rate limit)일 뿐이며 서비스가 왜 실패했는지 알려주지 않으므로, 로그의 이전 줄들을 확인해야 합니다. systemctl reset-failed myapp.service로 상태를 초기화한 뒤 근본적인 실패 원인을 수정하십시오. 서비스가 준비되는 데 시간이 걸린다면 RestartSec=를 높이십시오. 기본값인 100밀리초 간격으로는 1초도 안 되어 5번의 시도를 모두 소진하기 때문입니다.

Restart=always와 Restart=on-failure 중 무엇을 써야 합니까?

대부분의 경우 on-failure을 사용하십시오. 이는 충돌, 0이 아닌 종료 코드, 타임아웃, 워치독 트리거 시 재시작을 수행하며, 의도적인 exit 0은 그대로 둡니다. always는 피어 연결이 끊겼을 때 0을 반환하는 클라이언트처럼, 프로그램이 제어할 수 없는 이유로 정상 종료되는 경우에만 사용하십시오. always을 사용할 때의 위험은, 잘못된 설정을 읽고 오류를 기록한 뒤 0으로 종료되는 서비스가 무한 루프에 빠질 수 있다는 점입니다. 이때 유일한 증상은 systemctl show에서 NRestarts가 계속 증가하는 것입니다.

프로세스를 수동으로 종료했는데 왜 재시작되지 않습니까?

systemd는 SIGHUP, SIGINT, SIGTERM, SIGPIPE를 정상 종료로 간주하며, 일반적인 kill <pid>은 SIGTERM을 보냅니다. Restart=on-failure 설정에서 정상 종료는 실패가 아니므로 재시작이 일어나지 않으며, 설정이 잘못된 것처럼 보일 수 있습니다. 테스트하려면 kill -9 <pid>이나 systemctl kill -s SIGKILL myapp.service를 사용하십시오. 이는 비정상 종료이므로 재시작 정책이 트리거됩니다. 같은 규칙에 따라 systemctl stop은 재시작 정책과 충돌하지 않습니다.

StartLimitIntervalSec과 StartLimitBurst는 어디에 작성합니까?

[Unit] 섹션에 작성하십시오. 오래된 자료나 구버전 systemd에서는 [Service]에 작성하기도 했으므로, 복사한 예제마다 내용이 다를 수 있습니다. 본인의 버전이 무엇을 따르는지 추측하지 마십시오. systemctl daemon-reload 이후에 systemctl show -p StartLimitBurst -p StartLimitIntervalUSec myapp.service를 사용하여 systemd가 무엇을 로드했는지 확인하고, 그 값을 사실로 받아들이십시오. systemd-analyze verify /etc/systemd/system/myapp.service는 systemd가 인식하지 못하는 키를 찾아내며, 파일이 올바르면 아무것도 출력하지 않습니다.

#systemd#restart#service-unit#cgroups#reliability