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

systemd Type 설정: simple, forking, notify 차이점

systemd 유닛이 active 상태인데 데몬이 종료되는 원인을 분석합니다. simple, forking, notify, oneshot 등 각 Type 설정이 메인 PID와 서비스 상태에 미치는 영향을 설명하며, 올바른 설정으로 프로세스 감시 문제를 해결하는 방법을 안내합니다.

프로세스가 종료되었는데도 systemd가 유닛을 active 상태로 보고하는 이유

systemd 서비스 유닛은 systemd가 메인 프로세스로 지정한 프로세스가 살아 있는 동안 active 상태를 유지하며, [Service] 섹션의 Type= 설정이 어떤 프로세스를 메인으로 삼을지 결정합니다. 이 값을 잘못 설정하면 systemd는 셸 래퍼나 수명이 짧은 부모 프로세스를 감시하게 되고, 정작 중요한 데몬은 유닛 내부에서 종료되어 버립니다. 유닛은 자신이 감시하도록 지시받은 프로세스의 상태를 정확히 보고하고 있는 것입니다.

이 경우 재시작 정책을 변경해도 소용이 없습니다. Restart=은 메인 프로세스가 종료될 때 동작하므로, 메인 PID(process identifier)가 여전히 실행 중인 프로세스를 가리키고 있다면 Restart=always는 절대 실행되지 않습니다. 먼저 Type=을 수정하십시오. 메인 프로세스가 실제로 종료된 후 systemd가 수행할 동작은 별개의 결정 사항이며, 이는 Restart= 및 RestartSec= 가이드에서 다룹니다.

Type= 설정의 역할

모든 Type= 값은 두 가지 질문에 동시에 답합니다. systemd가 언제 이 유닛을 시작된 것으로 간주할 것인가, 그리고 어떤 프로세스가 메인 프로세스인가 하는 점입니다.

첫 번째 답변은 순서를 제어합니다. After=에 귀하의 유닛을 명시한 유닛은 systemd가 귀하의 유닛을 시작했다고 판단할 때까지 대기합니다. 너무 일찍 "시작됨"을 보고하는 Type=은 서비스가 준비되기 전에 의존 유닛들이 먼저 실행되게 만듭니다.

두 번째 답변은 감독을 제어합니다. systemd는 유닛이 생성하는 모든 프로세스를 cgroup(control group)에 배치합니다. 이는 프로세스를 그룹화하여 함께 제한하거나 종료할 수 있게 하는 커널 기능입니다. systemctl stop이 정리 작업을 수행하는 방식이 바로 이 cgroup을 통합니다. KillMode=은 기본적으로 control-group로 설정되어 있으므로, 유닛을 중지하면 그 안에 포함된 모든 프로세스에 신호가 전달됩니다. 메인 PID는 이보다 범위가 좁습니다. 메인 PID는 종료 시 유닛을 끝내고 그 종료 상태가 유닛의 결과가 되는 단일 프로세스를 의미합니다. cgroup을 메인 PID인 것처럼 해석하는 데서 혼란이 시작됩니다.

Type=simple은 바이너리 실행 전 시작을 보고합니다

Type=simpleExecStart=이 설정되어 있고 Type=BusName=이 없을 때의 기본값입니다. systemd는 프로세스를 생성하고 유닛이 즉시 시작된 것으로 간주하며, 해당 프로세스를 메인 PID로 처리합니다. 후속 유닛은 서비스 바이너리가 실행되기도 전에 즉시 시작됩니다.

마지막 세부 사항은 흔히 발생하는 의외의 상황을 설명해 줍니다. ExecStart= 경로에 오타가 있어도 시작 작업은 성공한 것으로 나타나며, 잠시 후 실행에 실패하면서 오류가 발생합니다. systemd는 이 경우를 종료 코드 203으로 기록하며, 자체 테이블에서는 이를 EXEC라고 명명하고 서비스 바이너리 실행 실패로 정의합니다. 따라서 systemctl start이 오류 없이 반환된다고 해서 바이너리가 존재한다는 것이 증명되지는 않습니다.

포그라운드에서 유지되며 스스로 백그라운드로 전환되지 않는 프로그램에는 simple을 사용하십시오. 이는 대부분의 현대적인 데몬과 직접 작성하는 거의 모든 프로그램에 해당합니다.

Type=exec는 프로그램이 실제로 시작될 때까지 대기합니다

Type=execsimple에 한 단계가 추가된 형태입니다. systemd는 포크(fork)와 바이너리 실행이 모두 성공해야만 유닛이 시작된 것으로 간주합니다. 바이너리가 없거나 User=을 해석할 수 없는 경우, 나중에 조용히 실패하는 대신 시작 작업 자체가 즉시 실패합니다.

Type=exec은 systemd 240부터 도입되었으므로 현재 사용 중인 모든 서버 배포판에서 지원합니다. 2026년 8월 기준으로 Ubuntu 24.04는 systemd 255를, Debian 13은 systemd 257을 탑재하고 있습니다. systemctl --version를 사용하여 본인의 버전을 확인하십시오.

이 방식의 비용은 시작 시 동기화 단계가 하나 추가된다는 점입니다. 얻는 이점은 systemctl start으로부터 정확한 종료 상태를 받을 수 있다는 것입니다. 포그라운드 프로그램의 경우 simple보다 exec를 사용하는 것을 권장합니다.

Type=forking 설정과 메인 PID를 잃어버리는 이유

Type=forking은 systemd에게 ExecStart=에 명시된 프로세스가 자식 프로세스를 생성한 뒤 의도적으로 종료될 것임을 알립니다. systemd는 첫 번째 프로세스가 종료될 때까지 기다린 후 해당 유닛이 시작된 것으로 간주합니다. 이때 남겨진 자식 프로세스가 데몬이 됩니다.

문제는 식별입니다. systemd가 실행한 프로세스는 이미 사라졌으므로, systemd는 살아남은 프로세스 중 어느 것이 메인 프로세스인지 파악해야 합니다. PIDFile=을 데몬이 PID를 기록하는 파일 경로(보통 /run 하위 경로)로 설정하면, systemd는 해당 파일에서 PID를 읽어 들입니다. 또한 systemd는 해당 파일에 적힌 PID가 이미 이 서비스에 속한 프로세스인지 확인하므로, 관련 없는 프로세스를 가리키는 오래된 파일은 신뢰하지 않고 거부합니다.

PIDFile=이 없으면 GuessMainPID=이 적용되며, 기본값은 yes입니다. 이 추측 방식은 서비스가 단일 프로세스로 안정화될 때만 신뢰할 수 있습니다. 매뉴얼은 이 한계를 명확히 밝히고 있습니다. 데몬이 두 개 이상의 프로세스로 구성된 경우 추측이 틀릴 수 있으며, 이로 인해 실패 감지 기능이 작동하지 않게 됩니다. 유닛의 메인 PID가 0이 될 수도 있는데, 이는 systemd가 관리할 대상이 전혀 없음을 의미합니다.

포크(fork)를 사용하는 대부분의 데몬은 프로세스를 포그라운드에서 실행하도록 유지하는 옵션을 제공합니다. 해당 옵션을 Type=exec와 함께 사용하고 PIDFile= 라인을 삭제하십시오. 동작하는 요소가 적을수록 PID를 잃어버릴 가능성도 줄어듭니다.

Type=oneshot을 이용한 작업 완료 처리

Type=oneshot는 프로세스가 실행된 뒤 종료될 것을 전제로 합니다. systemd는 해당 유닛이 종료된 후에야 비로소 시작된 것으로 간주하며, 이 때문에 다른 유닛이 대기해야 하는 작업에는 oneshot가 적합합니다. 또한 유닛 설정에 Type=이나 ExecStart=이 명시되지 않았을 때의 기본값으로 간주됩니다.

oneshot에는 두 가지 고유한 동작이 있습니다. 여러 개의 ExecStart= 라인을 허용하는 유일한 타입이며, 각 라인은 순차적으로 실행됩니다. 또한 기본적으로 시작 타임아웃이 비활성화되어 있으므로, TimeoutStartSec=을 직접 설정하지 않으면 oneshot이 멈췄을 때 무한정 대기하게 됩니다.

프로세스가 종료되면 유닛은 비활성 상태로 돌아갑니다. RemainAfterExit=yes는 프로세스가 전혀 실행되지 않는 상태에서도 유닛을 active으로 유지합니다. 이는 이 페이지 상단에서 언급한 증상을 의도적으로 구현한 것이며, 방화벽 규칙을 로드하거나 컨테이너 스택을 올리는 등 무언가를 계속 실행하는 대신 상태를 남겨두는 것이 목적일 때 올바른 방식입니다. 이는 재부팅 후 다시 살아나는 Docker Compose 스택을 구현하는 패턴이기도 합니다. 이 경우 유닛은 compose 명령을 실행하고 종료되지만, 유닛이 시작한 컨테이너들은 유닛보다 오래 유지되므로 활성 상태로 남습니다. oneshot 유닛은 스케줄러가 트리거하는 작업의 형태이기도 하며, 이는 cron 대신 systemd timer로 작업 실행하기의 나머지 절반을 구성합니다.

Type=notify를 사용하면 서비스가 준비 완료 시점을 직접 알릴 수 있습니다.

Type=notify는 결정 권한을 서비스 측으로 넘깁니다. systemd는 프로세스가 NOTIFY_SOCKET 환경 변수로 전달받은 Unix 소켓을 통해 READY=1 메시지를 보낼 때까지 시작 작업을 대기 상태로 유지합니다. C 인터페이스는 sd_notify(3)이며, 이미 많은 서버가 이를 지원합니다.

이는 "시작되었는가"라는 질문에 대한 정확한 답변입니다. simpleexec은 서비스가 설정을 읽거나 리스닝 소켓을 열기도 전에 시작된 것으로 보고하므로, 의존 유닛이 너무 일찍 시작되어 첫 연결에 실패할 수 있습니다. notify은 서비스가 스스로 준비되었다고 알리는 순간에 시작된 것으로 보고합니다.

systemd는 메인 프로세스가 보낸 메시지만 수락하며, 이것이 NotifyAccess=main의 의미이고 Type=notify이 이를 내포합니다. 메시지가 자식 프로세스나 보조 프로세스에서 온다면 NotifyAccess=all를 설정하십시오. 셸 스크립트는 systemd-notify --ready를 호출할 수 있지만, 이는 별도의 단기 프로세스로 실행되므로 NotifyAccess=all이 필요하며, 이미 종료된 발신자의 메시지를 systemd가 식별하지 못할 수 있습니다. 프로토콜을 직접 처리하는 서비스가 더 안정적입니다.

관련된 두 가지 설정도 알아둘 가치가 있습니다. systemd 253부터 사용 가능한 Type=notify-reload은 동일한 핸드셰이크를 리로드 작업까지 확장하므로, systemctl reload은 시그널 전송 시점이 아닌 서비스가 리로드 완료를 보고할 때 반환됩니다. WatchdogSec=는 알림을 보내는 서비스가 일정 간격으로 keep-alive 메시지를 보내도록 요청하며, systemd는 기한 내에 메시지가 오지 않으면 실패로 간주합니다.

Type=dbus 및 Type=idle

Type=dbus은 서비스가 시스템 및 데스크톱 서비스 간 통신에 사용하는 메시지 버스인 D-Bus에 이름을 등록할 때까지 대기합니다. 이 설정은 BusName=을 필요로 하며, BusName=가 설정되는 즉시 기본값으로 적용됩니다. 실제로 버스 이름을 등록하는 서비스에만 사용하십시오.

Type=idlesimple와 유사하게 동작하지만, 대기 중인 작업이 모두 처리될 때까지 프로그램 실행을 지연시킵니다. 이때 최대 5초까지만 지연됩니다. 이 설정은 부팅 시 콘솔 출력이 상태 메시지와 섞이지 않도록 하기 위해 존재합니다. 이는 순서 제어 도구가 아니며, 일반적인 서비스에는 적합하지 않습니다.

래퍼 스크립트가 systemd의 PID를 잘못 인식하게 만드는 이유

원래의 증상을 유발하는 구조는 다음과 같습니다.

[Service]
Type=simple
ExecStart=/opt/app/run.sh
#!/bin/bash
export APP_ENV=production
/opt/app/bin/server --config /etc/app.yaml &
/opt/app/bin/exporter --port 9101

systemd는 셸을 메인 PID로 기록합니다. exporter가 포그라운드에서 실행되는 동안 셸은 계속 살아 있습니다. server이 종료되어도 셸은 이를 감지하지 못하므로 메인 PID는 여전히 살아 있는 상태가 되고, 유닛은 여전히 active 상태로 남으며, Restart=은 아무런 조치를 취할 수 없습니다. 두 프로세스 모두 유닛의 cgroup 내에 계속 머물러 있으므로 systemctl stop는 여전히 정상적으로 정리 작업을 수행합니다. 문제가 발생한 것은 관리(supervision) 기능이지 정리 기능이 아닙니다.

해결 방법은 해당 유닛이 실제로 얼마나 많은 장기 실행 프로세스를 가지고 있는지에 따라 달라집니다.

프로세스가 하나라면, 셸을 해당 프로세스로 대체하십시오.

#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yaml

exec은 셸을 지정된 프로그램으로 대체하며 동일한 PID를 유지하므로, systemd가 기록한 PID는 이제 데몬의 것이 됩니다. 더 나은 방법은 래퍼를 제거하는 것입니다. Environment=EnvironmentFile=는 변수를 전달하고, ExecStartPre=은 설정 단계를 수행하므로, systemd가 데몬을 직접 실행하여 생성 시점부터 PID를 정확히 파악할 수 있습니다.

프로세스가 두 개라면, 단일 PID로 유닛을 대표할 수 없습니다. 이들을 두 개의 유닛으로 분리하고 After=Wants=를 사용하여 순서를 지정하십시오. 프로세스당 하나의 유닛을 할당하는 것이 systemd가 효과적으로 관리할 수 있는 구성이며, 각 프로세스가 고유한 재시작 동작을 가질 수 있는 유일한 방법입니다.

ExitType=cgroup 변경 사항

ExitType=는 systemd 250 버전에서 추가되었습니다. 기본값은 main이며, 이는 메인 프로세스가 종료되면 유닛도 중지된 것으로 간주함을 의미합니다. ExitType=cgroup을 사용하면 해당 cgroup 내의 프로세스가 하나라도 살아 있는 동안은 유닛이 실행 중인 것으로 간주합니다.

[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcher

이 설정은 특정 문제를 해결합니다. 실제 작업을 시작한 뒤 종료되는 런처(launcher)를 사용할 경우, ExitType=main 설정하에서는 systemd가 유닛을 중지된 것으로 판단하여 나머지 프로세스를 모두 종료시킵니다. ExitType=cgroup을 사용하면 유닛이 전체 그룹의 상태를 따르게 됩니다.

이 설정으로 해결할 수 없는 부분을 명확히 이해해야 합니다. ExitType=cgroup은 프로세스가 하나라도 살아 있으면 유닛을 활성 상태로 유지하므로, 두 개의 데몬을 관리하는 유닛에서 하나가 죽더라도 유닛은 계속 활성 상태로 남습니다. 이 설정은 런처 문제를 해결하기 위한 것이며, 하나의 유닛을 여러 독립적인 프로세스의 관리자로 만드는 기능은 아닙니다. 또한 ExitType=Type=oneshot과 함께 사용할 수 없습니다.

cgroup은 리소스 계측이 이루어지는 지점이기도 하므로, Type=이 메인 PID에 대해 어떻게 정의하든 MemoryMax=CPUQuota=와 같은 제한 사항은 유닛이 생성한 모든 프로세스에 적용됩니다. 이와 관련된 내용은 systemd를 이용한 서비스의 메모리 및 CPU 제한에서 확인할 수 있습니다.

systemd가 실제로 감시 중인 프로세스를 찾는 방법

디버깅 중인 유닛에서 다음 절차를 순서대로 수행합니다. systemd가 로드한 내용을 먼저 읽고, 추적 중인 대상을 확인한 뒤, 프로세스 테이블과 비교합니다.

systemctl cat app.service
systemctl show -p Type,ExitType,GuessMainPID,PIDFile,MainPID,RemainAfterExit app.service

systemctl cat은 유닛 파일과 적용된 모든 드롭인(drop-in) 파일을 함께 출력하므로, 직접 편집한 파일이 아닌 systemd가 실제로 로드한 내용을 확인할 수 있습니다. systemctl show은 명시적으로 작성하지 않은 기본값을 포함하여 유효한 설정값을 출력합니다. 다음 단계로 넘어가기 전에 MainPID 값을 확인하십시오.

systemd-cgls --unit=app.service
ps -o pid,ppid,stat,etime,args -p "$(systemctl show -p MainPID --value app.service)"

systemd-cgls은 해당 유닛의 cgroup에 속한 모든 프로세스를 나열합니다. ps 줄은 systemd가 관리하는 단일 프로세스를 설명합니다. 이 두 정보를 함께 읽으십시오. MainPID가 0이면 systemd가 감시할 프로세스가 없다는 의미입니다. cgroup에는 데몬이 있는데 MainPID이 셸로 확인된다면 앞서 언급한 래퍼(wrapper) 사례입니다. 예상보다 많은 프로세스가 cgroup에 있다면 런처나 포킹(forking) 데몬이 관여하고 있는 것입니다.

systemctl status app.service
journalctl -u app.service -b

systemctl status는 상태 줄과 cgroup 트리를 함께 출력하므로, 두 가지 질문에 대한 답을 동시에 얻는 경우가 많습니다. -b 옵션을 사용하여 현재 부팅 세션으로 제한한 journalctl -u는 systemd가 기록한 유닛의 시작 및 종료 이벤트와 종료 코드를 보여줍니다. 데몬이 journal이 아닌 자체 로그 파일에 기록한다면 해당 파일도 함께 확인하십시오. systemd는 자신에게 전달된 내용만 기록할 수 있기 때문입니다.

Type=을 변경하면 리로드 후 재시작해야 합니다.

systemd-analyze verify /etc/systemd/system/app.service
sudo systemctl daemon-reload
sudo systemctl restart app.service

systemd-analyze verify은 파일을 구문 분석하여 허용되지 않는 설정을 보고합니다. daemon-reload는 systemd가 디스크에서 유닛 파일을 다시 읽도록 합니다. 변경된 Type=은 이미 실행 중인 유닛에 즉시 적용되지 않으므로 재시작은 선택 사항이 아니라 필수입니다.

그다음 변경 사항을 테스트합니다. systemd-cgls에서 실제로 관리하려는 프로세스의 PID를 찾아 종료(kill)합니다. 그 직후 systemctl is-active app.service를 실행합니다. Type=이 올바르다면 유닛은 활성 상태에서 벗어납니다. 만약 여전히 활성 상태라면 systemd가 다른 무언가를 계속 감시하고 있는 것입니다.

어떤 systemd 서비스 Type=을 사용해야 하는가

  • 포그라운드에서 계속 실행되는 프로그램: Type=exec.
  • 준비 완료 알림을 지원하는 프로그램: Type=notify, 그리고 리로드까지 확인한다면 notify-reload.
  • 백그라운드로 전환하려는 데몬: PIDFile=을 사용한 Type=forking, 또는 포그라운드 전환 옵션이 있다면 Type=exec.
  • 작업을 수행하고 종료되는 스크립트: Type=oneshot, 상태를 남겨두는 것이 목적이라면 RemainAfterExit=yes.
  • 자식 프로세스를 남겨두고 종료되는 런처: ExitType=cgroup을 사용한 Type=simple.

서드 파티 데몬에 어떤 설정이 필요한지 확실하지 않다면, 먼저 패키지에 포함된 unit 파일을 읽어 보십시오. 배포판에서 제공한 unit 파일에 systemctl cat를 실행하면 업스트림에서 선택한 Type=를 확인할 수 있으며, 해당 설정은 이미 많은 사용자에 의해 검증된 상태입니다.

FAQ

프로세스가 종료되었는데도 systemd 유닛이 활성 상태로 유지되는 이유는 무엇입니까?

systemd가 메인 프로세스로 간주하는 프로세스가 여전히 살아 있기 때문입니다. systemd는 유닛의 cgroup에 속한 모든 프로세스가 아니라 Type=에 따라 선택된 서비스당 하나의 PID를 감시합니다. Type=simple로 시작된 래퍼 스크립트가 주된 원인입니다. 셸이 메인 PID가 되므로, 셸이 백그라운드에서 실행한 데몬이 종료되어도 유닛은 활성 상태를 유지합니다. systemctl show -p MainPID app.service을 실행한 다음 systemd-cgls --unit=app.service로 유닛의 cgroup을 나열하여 두 값을 비교해 보십시오.

Type=simple과 Type=exec의 차이점은 무엇입니까?

Type=simple은 바이너리가 실행되기 전, systemd가 프로세스를 생성하자마자 유닛이 시작된 것으로 간주합니다. 따라서 ExecStart=의 경로가 잘못되어도 시작 작업은 성공한 것으로 처리된 뒤 실패가 발생합니다. Type=exec는 실행이 성공할 때까지 기다리므로, 시작 작업 자체에서 실패가 보고됩니다. 두 설정 모두 동일한 프로세스를 메인 PID로 취급합니다. Type=exec은 systemd 240 버전 이상이 필요합니다.

Type=forking을 사용할 때도 PIDFile=이 여전히 필요합니까?

네, 데몬이 PID 파일을 생성하는 경우라면 항상 필요합니다. 이 설정이 없으면 systemd는 GuessMainPID=로 대체하는데, 이는 추측에 기반하므로 단일 프로세스로 동작하는 서비스에서만 신뢰할 수 있습니다. 추측이 틀리거나 불가능한 경우, 해당 유닛에 대한 실패 감지 및 자동 재시작 기능이 작동하지 않습니다. PIDFile=를 데몬이 파일을 생성하는 정확한 경로(보통 /run 하위)로 지정하십시오.

RemainAfterExit=yes는 언제 사용해야 합니까?

프로세스를 계속 실행하는 것이 아니라 시스템 상태를 변경하는 것이 유닛의 목적인 경우에 사용합니다. 방화벽 규칙을 로드하거나 컨테이너 스택을 시작하는 Type=oneshot 유닛은 작업이 완료되면 즉시 종료됩니다. RemainAfterExit=yes이 없으면 유닛은 비활성 상태가 되어, systemctl stop가 중지할 대상이 없어지고 ExecStop= 정리 작업을 실행할 방법도 사라집니다. 이 설정을 사용하면 프로세스가 없어도 유닛은 활성 상태로 유지되며, 이는 의도된 동작입니다.

Type=을 변경하면 daemon-reload가 필요합니까?

네, 유닛 재시작도 함께 필요합니다. systemctl daemon-reload은 systemd가 디스크의 유닛 파일을 다시 읽게 만들지만, 실행 중인 인스턴스는 시작 시점의 Type=를 유지합니다. 테스트하기 전에 sudo systemctl daemon-reload을 실행한 다음 sudo systemctl restart app.service를 수행하십시오. 그렇지 않으면 이전의 관리 동작을 계속 지켜보게 됩니다.