systemd 유닛 시작 실패 원인 분석 및 해결 방법
systemctl status 명령어로 종료 코드를 확인하는 방법을 설명합니다. 203/EXEC 및 226/NAMESPACE 오류의 의미와 유닛이 즉시 종료되는 원인을 분석하여 문제 해결의 핵심 기준을 제시합니다.
systemd 유닛이 시작되지 않는 이유
시작되지 않는 systemd 유닛은 그 이유를 특정 필드에 표시합니다. systemctl status <unit>을 실행한 뒤 실패를 보고하는 줄에서 code=와 status=을 확인하십시오. 200번대 상태 코드는 systemd가 프로그램에 도달하지 못했음을 의미하며, 이는 유닛 파일이 요구한 환경을 구성하는 과정에서 실패했기 때문입니다. 200 미만의 상태 코드는 프로그램이 실행되었으나 스스로 종료되었음을 의미하므로, 유닛 파일은 올바르지만 애플리케이션 자체에 문제가 있을 가능성이 큽니다.
이러한 구분은 문제 해결의 기준점이 됩니다. 아래의 모든 내용은 이 기준에 따라 번호 순서대로 진행됩니다.
질문에 답하는 세 가지 명령어와 그 순서
systemctl status myapp.service
journalctl -u myapp.service -b --no-pager
systemd-analyze verify /etc/systemd/system/myapp.servicesystemctl status은 판결을 내립니다. Loaded: 줄을 먼저 읽으십시오. systemd가 실제로 파싱한 파일이 무엇인지, 유닛이 활성화(enabled)되었는지, 마스크(masked)되었는지, 아니면 아예 찾을 수 없는지 알려주기 때문입니다. 그 다음 Active: 줄과 그 아래의 code= 및 status= 쌍을 읽으십시오.
journalctl -u myapp.service -b --no-pager은 세부 정보를 제공합니다. -u은 해당 유닛으로 필터링하고, -b은 출력을 현재 부팅 세션으로 제한하여 지난주에 발생한 오류를 읽지 않게 합니다. --no-pager는 터미널로 직접 출력하므로 grep으로 파이프를 연결할 수 있습니다. status은 최근 로그 몇 줄만 보여주며 긴 줄은 잘라냅니다. 저널에는 프로그램이 종료되기 전에 출력한 모든 내용이 기록되며, 보통 여기에 실제 오류가 담겨 있습니다. 더 많은 기록을 보려면 -n 100를 추가하거나, 유닛을 재시작하는 동안 두 번째 터미널에서 -f을 실행하십시오.
systemd-analyze verify는 유닛 파일을 실행하지 않고 로드만 합니다. 알 수 없는 섹션이나 지시어에 대해 경고하며, ExecStart=에서 실행할 수 없는 명령어를 표시합니다. 이를 통해 두 가지 조용한 오류 유형을 잡아낼 수 있습니다. 하나는 오타가 난 키(systemd는 로드 시 경고만 띄우고 무시하며 대부분의 사용자는 이를 읽지 않습니다)이고, 다른 하나는 존재하지 않는 경로입니다.
유닛 파일을 수정한 후에는 반드시 sudo systemctl daemon-reload을 실행하십시오. 이 작업을 수행하기 전까지 systemd는 이전에 로드한 복사본을 계속 사용하며, systemctl status은 디스크의 파일이 변경되었다는 경고를 추가합니다. "아무런 효과가 없는" 수정 사항은 대부분 systemd가 아직 읽지 않은 상태인 경우가 많습니다.
두 가지 명령어를 더 알아둘 필요가 있습니다. systemctl cat myapp.service은 메인 파일과 /etc/systemd/system/myapp.service.d/ 아래의 모든 드롭인(drop-in) 파일을 합친 유효 유닛을 출력합니다. systemctl show myapp.service -p ExecStart -p User -p WorkingDirectory은 systemd가 파싱한 값을 그대로 출력하며, 이것이 실제로 실행될 내용입니다.
status=203/EXEC는 무엇을 의미합니까?
203/EXEC은 systemd가 설정을 완료하고 execve()를 호출했으나 커널이 이를 거부했음을 의미합니다. 프로그램은 자신의 코드를 단 한 줄도 실행하지 못했습니다. 거의 모든 사례는 다음 네 가지 원인 중 하나에 해당합니다.
ExecStart=에 지정된 경로가 잘못되었거나 절대 경로가 아닙니다. 유닛 파일에 적힌 정확한 문자열과ls -l의 결과를 비교하여 확인하십시오.- 파일에 실행 권한(execute bit)이 없습니다.
sudo chmod +x /opt/myapp/run.sh로 이 문제를 해결할 수 있습니다. 아카이브에서 압축을 풀거나 다른 머신에서 복사한 파일은 종종 이 권한을 잃어버립니다. - 셰뱅(shebang) 라인이 깨졌습니다. 커널은 스크립트의 첫 번째 줄을 읽어 지정된 인터프리터를 실행합니다. 따라서 서비스의 PATH에
python3이 없으면#!/usr/bin/env python3은 실패합니다. 또한 Windows 줄 바꿈 문자로 저장된 파일은/bin/bash\r이라는 이름의 인터프리터를 찾으려 시도하는데, 이는 존재하지 않으므로 실패합니다. - 파일이 현재 머신에서 실행할 수 없는 형식입니다. 아키텍처가 맞지 않거나, 셰뱅이 없는 텍스트 파일인 경우입니다.
설정을 변경하기 전에 서비스 사용자 권한으로 직접 명령을 실행하여 재현해 보십시오.
sudo -u appuser /opt/myapp/run.sh
file /opt/myapp/run.sh
head -1 /opt/myapp/run.sh | cat -Afile는 아키텍처를 명시하며, 줄 바꿈 문자가 문제일 경우 "with CRLF line terminators"라는 메시지를 출력합니다. cat -A은 동일한 문제를 ^M이 포함된 형태로 보여줍니다. sed -i 's/\r$//' /opt/myapp/run.sh를 사용하여 이를 제거하십시오.
종료 코드 범위에 대한 주의 사항이 하나 있습니다. 200 이상의 값은 관례일 뿐 보장되는 것은 아닙니다. 애플리케이션이 직접 203을 종료 코드로 사용할 수 있으며, 이 경우 systemd는 시스템 오류와 애플리케이션의 종료 코드를 구분할 수 없습니다. systemd-analyze exit-status 203은 코드의 이름과 클래스를 출력하여 표를 읽는 데 도움을 주지만, 애플리케이션이 199 이상의 종료 코드를 사용한다면 이를 변경하는 것이 좋습니다.
왜 217/USER 또는 216/GROUP 오류가 발생합니까?
217/USER는 서비스가 시작되는 시점에 User=라는 이름의 계정이 존재하지 않음을 의미합니다. 216/GROUP은 Group= 또는 SupplementaryGroups=에 대해 동일한 실패가 발생했음을 나타냅니다. 각각 하나의 명령어로 이를 확인하십시오.
getent passwd appuser
getent group appgroup각 명령어는 한 줄을 출력하거나, 아무것도 출력하지 않고 0이 아닌 값을 반환합니다. 아무것도 출력되지 않는다면 시스템이 해당 이름을 알지 못하는 상태이므로, systemd가 해당 계정으로 전환할 수 없어 실행(exec) 전에 중단됩니다. 해결 방법은 User=root를 설정하는 것이 아니라 계정을 생성하는 것입니다. 최소 권한을 가진 전용 시스템 계정으로 서비스를 실행하는 것이 해당 지시어의 핵심 목적입니다.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin appuserDynamicUser=yes은 systemd가 시작할 때마다 임시 계정을 할당하도록 하여 이 문제를 우회합니다. 이는 상태를 유지하지 않는 서비스에 적합합니다. 파일을 기록하는 모든 서비스는 StateDirectory=을 함께 사용해야 합니다. 시작할 때마다 사용자 ID가 변경되어 일반 경로의 파일들이 더 이상 존재하지 않는 계정의 소유가 될 수 있기 때문입니다.
226/NAMESPACE란 무엇입니까?
226/NAMESPACE는 샌드박싱 지시어에서 발생합니다. 유닛 파일이 ProtectSystem=, ProtectHome=, PrivateTmp=, ReadWritePaths= 또는 이와 유사한 설정을 포함하면, systemd는 프로그램을 실행하기 전에 해당 서비스를 위한 전용 마운트 네임스페이스를 생성합니다. 여기서 네임스페이스란 특정 프로세스만을 위한 독립적인 파일 시스템 뷰를 의미합니다. 이 계획 중 마운트 작업이 하나라도 실패하면 서비스 시작은 226 오류와 함께 실패하며 프로그램은 실행되지 않습니다.
일반적인 원인은 ReadWritePaths=에 지정된 경로가 존재하지 않는 경우입니다. ProtectSystem=strict은 전체 파일 시스템을 읽기 전용으로 마운트하며, ReadWritePaths=는 지정된 경로를 쓰기 가능하도록 다시 엽니다. 이때 존재하지 않는 디렉터리를 systemd가 다시 열 수는 없습니다. 두 가지 해결 방법이 있습니다. 첫째, StateDirectory=을 사용하여 systemd가 시작 시마다 /var/lib/<name>을 생성하고 서비스 사용자에게 권한을 부여하도록 합니다. 둘째, 경로 앞에 -를 붙여 원본 경로가 없을 경우 해당 항목을 무시하도록 systemd에 지시합니다. 보안 설정을 삭제하는 것은 좋지 않은 해결책이며, 이는 5분이면 해결할 문제를 영구적인 보안 취약점으로 바꿉니다.
[Service]
ProtectSystem=strict
ProtectHome=yes
StateDirectory=myapp
ReadWritePaths=-/srv/uploads어떤 설정 줄이 문제인지 알 수 없을 때는 보안 설정 블록 전체를 제거한 뒤 설정을 다시 불러오고 서비스를 시작하십시오. 서비스가 정상적으로 실행된다면, 제거했던 줄을 하나씩 다시 추가하면서 매번 재시작하여 범인을 찾으십시오. 이와 관련된 설정으로 233/RUNTIME_DIRECTORY과 238/STATE_DIRECTORY가 있습니다. 이 오류들은 systemd가 RuntimeDirectory=나 StateDirectory=에 지정된 디렉터리를 생성하거나 소유권을 가져올 수 없을 때 발생하며, 보통 해당 경로가 이미 존재하고 다른 사용자의 소유로 되어 있을 때 나타납니다.
WorkingDirectory가 올바른데도 200/CHDIR이 발생하는 이유는 무엇입니까?
200/CHDIR은 WorkingDirectory=로의 chdir()이 실패했음을 의미합니다. 해당 디렉터리가 없거나, 서비스 사용자가 해당 디렉터리에 진입할 수 없는 경우입니다. 디렉터리에 진입하려면 해당 디렉터리와 상위의 모든 경로에 대해 실행 권한이 필요합니다. 따라서 /home/deploy이 700 모드이고 서비스가 appuser로 실행되는 경우, 완벽하게 읽기 가능한 /home/deploy/app이라 하더라도 접근할 수 없습니다.
sudo -u appuser test -x /srv/myapp && echo ok
namei -l /srv/myappnamei -l은 경로의 모든 구성 요소에 대한 소유자와 모드를 출력하며, 이는 경로를 차단하는 디렉터리를 찾는 가장 빠른 방법입니다. WorkingDirectory=-/srv/myapp를 작성하면 디렉터리가 없어도 치명적인 오류가 발생하지 않습니다. 이는 시작 위치가 중요하지 않은 프로그램에는 적합하지만, 상대 경로로 파일을 여는 프로그램에는 적절하지 않습니다.
서비스가 시작된 후 1초 만에 종료되는 이유는 무엇입니까?
이 경우 200번대 코드가 나타나지 않으며, 종종 오류 메시지조차 출력되지 않습니다. 유닛은 시작 직후 inactive (dead) 상태를 보이거나 activating (auto-restart) 상태를 반복합니다. systemd는 환경을 올바르게 구성했습니다. 문제는 귀하의 프로그램이 수행하는 동작과 Type= 설정이 보장하는 동작 사이의 불일치입니다.
기본값인 Type=simple은 프로그램이 포그라운드에서 실행 상태를 유지해야 함을 의미합니다. 백그라운드로 포크(fork)하고 종료되는 데몬을 실행하면, systemd는 메인 프로세스가 종료된 것으로 간주하여 서비스가 완료되었다고 판단합니다. 대부분의 데몬은 nginx -g 'daemon off;'와 같이 포그라운드 실행을 유지하는 플래그를 제공합니다.
Type=forking은 첫 번째 프로세스가 자식 프로세스 준비를 마치면 종료됨을 의미합니다. 포그라운드 프로그램을 이 설정으로 실행하면, 시작 작업은 TimeoutStartSec=이 만료될 때까지(기본값 90초) 대기하다가 systemd가 이를 종료시키고 타임아웃을 기록합니다.
Type=notify는 프로그램이 준비 완료를 알리기 위해 sd_notify()을 호출함을 의미합니다. 해당 기능을 지원하지 않는 프로그램은 아무런 신호를 보내지 않으므로, 시작 작업은 타임아웃되고 저널에는 프로토콜 실패로 기록됩니다.
프로그램의 실제 동작 방식에 맞는 타입을 선택하십시오. simple, forking, oneshot, notify 타입의 차이점을 이해하는 것이 이러한 유형의 오류를 해결하는 핵심입니다.
서비스가 반복적으로 종료되면 systemd는 시도를 중단하고 시작 요청이 너무 빠르게 반복되었다고 보고합니다. 이후 유닛은 속도 제한 창이 지나거나 sudo systemctl reset-failed myapp.service를 실행하기 전까지 실패 상태로 유지됩니다. 제한을 높이는 것은 증상을 숨길 뿐입니다. 마지막 실패가 아닌 첫 번째 실패 시점의 저널을 확인하고, 설정을 변경하기 전에 Restart=on-failure가 실제로 재시도하는 범위를 확인하십시오.
왜 유닛이 오류 없이 비활성 상태인가요?
유닛은 시작되는 대신 건너뛰어질 수 있습니다. Condition* 지시어는 설계상 조용하게 작동합니다. 검사가 실패하면 systemd는 작업을 성공으로 표시하고 아무런 동작도 하지 않습니다. ConditionPathExists=/etc/myapp/config.yml을 포함한 유닛은 해당 파일이 없으면 절대 시작되지 않으며, 오류 또한 보고하지 않습니다.
systemctl show myapp.service -p ConditionResult -p ConditionTimestamp
journalctl -u myapp.service -b --no-pager | grep -i conditionConditionResult=no은 건너뛰기 상태를 확인해주며, 저널에는 충족되지 않은 검사 항목이 기록됩니다. 필수 조건이 누락되었을 때 명확하게 실패 처리를 해야 한다면 대신 Assert* 지시어를 사용하십시오. 조건, 단언 및 유닛 순서에서 각 검사가 어디에 적합한지 다룹니다.
이 외에도 조용하게 실패하는 몇 가지 경우가 더 있습니다. "찾을 수 없음(could not be found)" 오류는 보통 파일이 잘못된 디렉터리에 있거나 설정을 다시 불러오지 않았음을 의미합니다. 직접 작성한 유닛 파일은 /etc/systemd/system/에 위치해야 합니다. 마스킹된 유닛은 sudo systemctl unmask myapp.service으로 해제하기 전까지 모든 시작 요청을 거부합니다. 또한 systemctl enable은 [Install] 섹션이 없는 유닛에서 실패하므로, 해당 유닛에 WantedBy=multi-user.target 섹션을 추가하십시오.
프로세스가 실패한 것이 아니라 강제로 종료되었다면 어떻게 해야 합니까?
code=killed는 code=exited와는 다른 문제입니다. 외부의 어떤 요인이 프로세스를 종료시킨 것입니다. status=9/KILL은 OOM(Out of Memory) 킬러를 가리키며, 저널에는 해당 킬러가 선택한 프로세스 이름이 기록됩니다. 사용자가 직접 설정한 제한 값이 cgroup(control group) 내부에서 동일한 결과를 초래할 수 있으므로, free -m을 사용하여 호스트의 가용 메모리를 확인하고 유닛 파일에서 MemoryMax= 설정이 있는지 확인하십시오. MemoryMax, CPUQuota 및 기타 cgroup 제한 문서에서 어떤 제한이 프로세스를 종료시키고 어떤 제한이 단순히 속도를 늦추는지 설명합니다.
시작 시도 직후 발생하는 status=15/TERM는 일반적으로 systemd가 시작 시간을 초과하여 프로세스를 종료했음을 의미하며, 이 경우 Type=의 문제 해결 단계로 돌아가야 합니다.
이러한 장애를 대부분 방지하는 두 가지 습관
모든 곳에 절대 경로를 사용하십시오. systemd는 사용자의 로그인 셸을 실행하지 않으므로 .bashrc, .profile, 활성화된 가상 환경은 존재하지 않습니다. 시스템 서비스의 $PATH은 짧은 내장 목록으로 구성되며, /opt나 언어 버전 관리자의 shim은 포함되지 않습니다. /usr/bin/python3나 /opt/myapp/venv/bin/python을 전체 경로로 작성하십시오. 셸에서 command -v myapp을 실행하면 붙여넣을 경로가 출력됩니다. 같은 규칙이 WorkingDirectory=, EnvironmentFile= 및 ReadWritePaths=의 모든 경로에 적용됩니다.
ExecStart=은 셸이 아닙니다. systemd는 행을 단어 단위로 분할하여 직접 execve()를 호출합니다. 파이프, 리다이렉션, glob, &&, 백틱, ~는 아무런 의미가 없으며, 프로그램에 리터럴 인수로 전달될 뿐입니다. ExecStart=/usr/bin/myapp --flag > /tmp/out.log는 >과 /tmp/out.log을 myapp에 넘기며, 이 경우 systemd 문제와는 전혀 상관없는 사용법 오류가 발생하며 종료됩니다. 셸 기능이 필요하다면 셸을 명시적으로 호출해야 합니다.
ExecStart=/bin/sh -c '/usr/bin/myapp --flag | /usr/bin/tee -a /var/log/myapp.log'단순히 출력을 기록하는 용도라면 셸이 필요하지 않습니다. 서비스 출력은 기본적으로 저널(journal)로 전송되며, StandardOutput=append:/var/log/myapp.log는 셸 없이도 파일에 기록할 수 있습니다.
변수 확장도 같은 방식으로 제한됩니다. $MYVAR과 ${MYVAR}은 Environment=와 EnvironmentFile=에서 대체되지만, 그 외에는 확장되지 않습니다. $HOME는 명시적으로 설정하지 않는 한 시스템 서비스에 대해 설정되지 않습니다. EnvironmentFile= 역시 셸 스크립트가 아닙니다. export은 여기에 사용할 수 없으며, 인용 규칙이 bash와 다르고, 파일이 없으면 치명적인 오류가 발생합니다(경로 앞에 -을 붙이지 않은 경우).
운영 서버에서 문제 해결하기
코드를 읽고, 원인을 증명하고, 한 가지를 변경한 뒤, 재시작하십시오. 이 순서는 모든 수치를 외우는 것보다 중요합니다. 세 가지 추측성 수정을 한꺼번에 적용하여 무엇이 문제를 해결했는지 놓치는 상황을 방지하기 때문입니다. 직접 작성하지 않은 유닛에도 동일한 경로를 적용할 수 있습니다. 실행되지 않는 타이머는 시작되지 않은 서비스와 같습니다. 따라서 먼저 서비스를 디버깅하십시오. systemd 타이머와 그 타이머가 트리거하는 서비스는 위에서 언급한 방식과 정확히 동일한 방식으로 실패하며, 저널에서 로그를 요청하기 전까지는 타이머가 출력을 숨깁니다.
FAQ
systemctl status에서 status=203/EXEC은 무엇을 의미합니까?
systemd가 유닛이 요청한 모든 설정을 마쳤으나 execve() 호출이 실패하여 프로그램이 시작되지 않은 상태입니다. 다음 네 가지를 순서대로 확인하십시오. ExecStart=에 지정된 경로가 존재하며 절대 경로인지, 파일에 실행 권한(execute bit)이 있는지, 셔뱅(shebang)에 명시된 인터프리터가 서비스의 PATH 내에 존재하는지, 파일이 Unix 줄바꿈 형식을 사용하는지 확인하십시오. 마지막 항목의 경우 file 명령이 "with CRLF line terminators"라고 출력한다면, 인터프리터 이름이 /bin/bash\r로 인식되어 커널이 실행을 거부하게 됩니다.
서비스가 시작된 직후 바로 종료되는 이유는 무엇입니까?
유닛 파일에 정의된 동작과 실제 프로그램의 동작이 일치하지 않기 때문입니다. Type=simple 설정에서 systemd는 프로그램이 포그라운드에서 계속 실행되기를 기대하므로, 백그라운드로 포크(fork)하는 데몬은 포크되는 즉시 종료된 것으로 간주됩니다. Type=forking 설정에서는 systemd가 첫 번째 프로세스가 종료될 때까지 기다리므로, 포그라운드 프로그램은 TimeoutStartSec= 시간이 만료될 때까지 시작 작업이 중단된 상태로 남습니다. 프로그램의 특성에 맞춰 Type=를 설정하십시오. 프로그램이 포그라운드 실행 플래그를 지원한다면 해당 플래그를 사용하고 Type=simple을 기본값으로 유지하십시오.
짧은 상태 출력 대신 실제 오류 내용을 확인하려면 어떻게 해야 합니까?
systemctl status은 저널의 마지막 몇 줄만 출력하며 긴 줄은 잘라냅니다. 이번 부팅 중에 유닛이 기록한 모든 내용을 보려면 journalctl -u myapp.service -b --no-pager을 실행하십시오. 더 많은 내용을 보려면 -n 200 옵션을 추가하거나 grep으로 파이프를 연결하십시오. 애플리케이션이 자체 로그 파일을 작성하는 경우 해당 파일도 함께 확인하십시오. systemd는 프로그램이 표준 출력(stdout)과 표준 오류(stderr)로 보낸 내용만 캡처하기 때문입니다.
오류 메시지 없이 유닛이 비활성 상태인 이유는 무엇입니까?
대부분 Condition* 지시문에 의해 건너뛰어진 경우입니다. 해당 검사는 조용히 수행되며, 조건이 충족되지 않으면 시작 작업은 성공한 것으로 간주됩니다. systemctl show myapp.service -p ConditionResult를 실행하여 ConditionResult=no 항목을 찾은 뒤, 해당 검사를 명시한 저널 라인을 읽어보십시오. 또 다른 흔한 원인은 유닛이 마스크(masked)된 경우입니다. 이 상태에서는 sudo systemctl unmask로 마스크를 해제하기 전까지 모든 시작 요청이 거부됩니다.
유닛 파일을 수정할 때마다 daemon-reload를 실행해야 합니까?
그렇습니다. 유닛 파일이나 드롭인(drop-in) 파일을 수정했다면 반드시 실행해야 합니다. sudo systemctl daemon-reload는 systemd가 디스크에서 파일을 다시 읽어오게 하며, 이후 sudo systemctl restart myapp.service을 통해 실행 중인 서비스에 변경 사항을 적용합니다. systemctl edit을 사용하면 자동으로 리로드를 수행하므로 별도로 실행할 필요가 없습니다. 또한 systemd가 아닌 애플리케이션 자체의 설정 파일을 변경한 경우에는 이 과정이 필요하지 않습니다.