- Published on
systemd 서비스 재시작 반복? ExecStart 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 데몬을 올려두었는데 갑자기 서비스가 activating (auto-restart) 상태로 반복 재시작되는 경우가 있습니다. 이때 대부분의 실마리는 ExecStart 주변에 있습니다. 실제로는 바이너리가 잘못된 게 아니라, systemd가 실행하는 방식(작업 디렉터리, 환경 변수, 사용자 권한, 표준 출력 처리, 종료 코드 해석)이 애플리케이션의 기대와 어긋나면서 재시작 루프가 만들어집니다.
이 글은 ExecStart 디버깅을 중심으로 재시작 원인을 빠르게 좁히는 체크리스트와, 현장에서 자주 겪는 함정을 코드와 함께 정리합니다. 먼저 “왜 재시작되는지”를 systemd 관점에서 관찰하고, 그 다음 “같은 커맨드를 systemd와 동일 조건으로 재현”하는 흐름으로 접근합니다.
관련해서 재시작 원인 자체를 폭넓게 훑고 싶다면 systemd 서비스가 무한 재시작되는 원인 7가지도 함께 보시면 진단 속도가 더 빨라집니다.
1) 먼저 상태에서 힌트를 뽑는다: systemctl status
재시작 루프는 대개 다음 3가지 중 하나로 압축됩니다.
- 프로세스가 즉시 종료됨(Exit code 비정상 또는 정상 종료인데도 Restart 정책 때문에 재시작)
- 프로세스는 살아있지만 systemd가 “실패”로 판정함(타입 불일치, PID 추적 실패 등)
- 시작 제한(StartLimit)이나 watchdog 등 systemd의 보호 장치에 걸림
가장 먼저 아래를 봅니다.
sudo systemctl status myapp.service -l
sudo systemctl show myapp.service -p ExecStart -p Restart -p RestartSec -p Type -p User -p WorkingDirectory -p Environment
여기서 특히 중요한 포인트는 다음입니다.
Main PID가 잠깐 생겼다가 사라지는지code=exited, status=...숫자(종료 코드)Restart=값이always인지on-failure인지Type=이 실제 프로세스 동작과 맞는지
2) 로그는 journalctl -u로, 그리고 “이번 부팅”만 보라
재시작 루프에서는 로그가 너무 많이 쌓여 현재 원인 로그가 묻힙니다. 이번 부팅 기준으로 자르고, 최신부터 추적합니다.
sudo journalctl -u myapp.service -b --no-pager -n 200
sudo journalctl -u myapp.service -b -r --no-pager | head -n 80
로그를 볼 때는 “애플리케이션 로그”뿐 아니라 systemd가 추가로 남기는 라인도 중요합니다.
Failed at step ...형태(권한, chdir 실패, 실행 파일 없음 등)ExecStart=라인이 의도한 커맨드로 보이는지WorkingDirectory=관련 오류가 있는지
자주 보이는 systemd 단계 실패 예시
Failed at step CHDIR spawning /usr/local/bin/myapp: No such file or directory
Failed at step USER spawning ...: No such process
Failed at step EXEC spawning ...: Permission denied
이런 메시지는 애플리케이션 이전 단계에서 이미 실패했다는 뜻이라, 애플리케이션 로그를 아무리 봐도 답이 안 나옵니다.
3) ExecStart는 쉘이 아니다: 인자 파싱부터 의심
systemd의 ExecStart=는 기본적으로 쉘이 아닙니다. 따라서 아래는 기대대로 동작하지 않습니다.
- 파이프(
|)나 리다이렉션(>),&&같은 쉘 문법 - 환경 변수 치환 같은 쉘 확장
- 글롭(
*) 확장
예를 들어 아래는 실패하거나 엉뚱하게 해석될 수 있습니다.
# 잘못된 예: 쉘 문법을 ExecStart에 직접 사용
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yml > /var/log/myapp.log 2>&1
해결 방법은 두 가지입니다.
- systemd의 로깅을 사용한다(StandardOutput)
- 정말 쉘이 필요하면
bash -lc로 감싼다(권장하진 않음)
# 권장: systemd 로깅 사용
StandardOutput=journal
StandardError=journal
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yml
# 불가피할 때만: 쉘로 감싸기
ExecStart=/usr/bin/bash -lc 'exec /usr/local/bin/myapp --config /etc/myapp/config.yml >> /var/log/myapp.log 2>&1'
ExecStart 파싱 문제는 “서비스는 켜지려다 죽는데, 내 로컬에서는 커맨드가 잘 됨” 패턴의 대표 원인입니다.
4) 동일 조건 재현: systemd가 쓰는 사용자·디렉터리로 실행해보기
가장 강력한 디버깅은 “systemd와 동일한 조건으로 수동 실행”입니다.
User=가 있으면 그 사용자로 실행WorkingDirectory=가 있으면 그 디렉터리로 이동Environment=가 있으면 동일 환경 변수로 실행
예시:
sudo -u myappuser -H bash -lc 'cd /var/lib/myapp && /usr/local/bin/myapp --config /etc/myapp/config.yml'
여기서 바로 실패하면 문제는 애플리케이션이나 런타임 의존성(파일 권한, 라이브러리, 설정 경로)일 가능성이 큽니다.
반대로 수동 실행은 되는데 systemd에서만 재시작한다면, 다음 섹션의 systemd 설정 이슈(서비스 타입, 종료 코드 해석, 타임아웃, PID 추적)를 의심합니다.
5) Type= 불일치로 systemd가 “죽었다”고 오해하는 경우
Type=simple이 기본인데, 포크해서 데몬화하는 프로그램(백그라운드로 분리)이면 systemd는 메인 프로세스를 추적하지 못해 실패처럼 보일 수 있습니다.
패턴 A: 앱이 자체적으로 데몬화(포크)한다
- 앱이 실행 직후 부모가 종료되고 자식이 살아남음
- systemd는 “메인 프로세스 종료”로 보고 실패 처리
해결:
- 가능하면 앱을 포그라운드 모드로 실행하고
Type=simple유지 - 불가피하면
Type=forking과PIDFile=등을 설정
[Service]
Type=forking
PIDFile=/run/myapp.pid
ExecStart=/usr/local/bin/myapp --daemon --pidfile /run/myapp.pid
ExecStop=/bin/kill -TERM $MAINPID
Restart=on-failure
패턴 B: Type=oneshot인데 계속 살아야 하는 프로세스
oneshot은 “작업을 수행하고 종료”가 정상인 타입입니다. 서버처럼 계속 떠 있어야 하는 프로세스에 쓰면 systemd 기대와 어긋납니다.
# 잘못된 예
Type=oneshot
ExecStart=/usr/local/bin/myapp
6) 재시작 정책이 과격해서 정상 종료도 재시작되는 경우
애플리케이션이 “조건이 안 맞으면 정상 종료(Exit 0)”하도록 설계된 경우가 있습니다. 그런데 unit에 Restart=always가 걸려 있으면 정상 종료도 즉시 재시작되어 루프처럼 보입니다.
[Service]
Restart=always
RestartSec=1
이럴 땐 정책을 바꿉니다.
[Service]
Restart=on-failure
RestartSec=2
또는 특정 종료 코드를 “성공”으로 취급하거나 “재시작 금지”로 취급할 수 있습니다.
# 예: 2번 종료 코드는 재시작하지 않기
RestartPreventExitStatus=2
# 예: 143(SIGTERM) 등은 성공으로 간주
SuccessExitStatus=143
7) WorkingDirectory와 상대 경로: 로컬에서는 되는데 서비스에서만 실패
로컬에서 ./myapp --config ./config.yml이 잘 되더라도, systemd에서는 기본 작업 디렉터리가 /인 경우가 많습니다. 상대 경로로 설정 파일을 찾다가 실패하고 즉시 종료하면서 재시작 루프가 생깁니다.
해결은 둘 중 하나입니다.
WorkingDirectory=를 명시- 설정 경로를 절대 경로로 변경
[Service]
WorkingDirectory=/var/lib/myapp
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yml
8) 환경 변수 누락: PATH, NODE_ENV, JAVA_HOME, DOTENV 등
systemd는 로그인 셸이 아니므로, 개발자가 기대하는 PATH나 프로파일 로딩이 없습니다. 특히 Node, Python, Ruby, Java 런타임에서 흔합니다.
Node 예시: node를 못 찾거나, dotenv가 로드되지 않음
# 위험: node가 PATH에 없으면 실패
ExecStart=node /opt/myapp/server.js
권장:
[Service]
Environment=NODE_ENV=production
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
ExecStart=/usr/bin/node /opt/myapp/server.js
환경 변수가 많다면 파일로 분리합니다.
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js
EnvironmentFile은 파일 권한 문제로 로드 실패하는 경우도 있으니 journalctl에서 로드 실패 로그를 확인하세요.
9) 실행 권한과 소유권: Permission denied는 두 종류가 있다
Permission denied는 크게 두 갈래입니다.
- 실행 파일에 실행 비트가 없음
- 보안 정책(SELinux, AppArmor) 또는 마운트 옵션(noexec)에 의해 차단
기본 체크:
ls -l /usr/local/bin/myapp
file /usr/local/bin/myapp
mount | grep -E 'noexec|/usr/local|/opt'
SELinux가 켜져 있다면 컨텍스트 문제로 실행이 막힐 수 있습니다. 이 경우 journalctl에 AVC 메시지가 남거나, ausearch로 추가 확인이 필요합니다.
10) 타임아웃으로 systemd가 죽이는 경우: TimeoutStartSec
앱이 시작 시 마이그레이션, 원격 의존성 체크 등으로 오래 걸리면 systemd의 시작 타임아웃에 걸릴 수 있습니다. 그 결과 systemd가 프로세스를 종료하고 재시작합니다.
[Service]
TimeoutStartSec=120
Restart=on-failure
반대로 무한 대기형 초기화라면 근본적으로 “준비 완료 신호”를 주는 방식(예: Type=notify)을 고려해야 합니다.
11) 디버깅을 위한 일시적 오버라이드: Drop-in으로 안전하게
운영 중인 unit 파일을 직접 수정하기보다 drop-in으로 디버깅 옵션을 잠깐 얹는 편이 안전합니다.
sudo systemctl edit myapp.service
아래처럼 임시 설정을 추가할 수 있습니다.
[Service]
# 재시작 루프를 잠깐 멈춰서 로그 관찰
Restart=no
# 더 자세한 로깅
Environment=SYSTEMD_LOG_LEVEL=debug
StandardOutput=journal
StandardError=journal
적용:
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
sudo journalctl -u myapp.service -b -n 200 --no-pager
재시작을 꺼두면 “딱 한 번 실패하고 멈춘 상태”에서 원인 로그를 훨씬 쉽게 잡을 수 있습니다.
12) ExecStart 자체를 검증하는 체크리스트
ExecStart 디버깅은 결국 아래 질문에 답하는 과정입니다.
- systemd가 실행한 커맨드는 내가 의도한 문자열인가
- 그 커맨드는 쉘 없이도 유효한가(리다이렉션, 파이프 제거)
- 실행 파일 경로가 절대 경로인가
WorkingDirectory가 필요한가User와 파일 권한이 맞는가- 환경 변수(PAH, 설정 키)가 필요한가
- 프로세스가 포크하는가(서비스
Type일치 여부) - 종료 코드가 무엇이며, Restart 정책이 그 코드를 어떻게 해석하는가
이 체크리스트를 따라가면 재시작 루프의 대부분은 짧은 시간 내에 해결됩니다.
13) systemd 재시작 루프는 K8s의 CrashLoopBackOff와 닮았다
원인 구조가 비슷합니다. “프로세스가 기대 조건에서만 정상 동작한다”는 점에서, systemd의 재시작 루프는 쿠버네티스의 CrashLoopBackOff와 디버깅 감각이 유사합니다. 컨테이너 환경에서의 접근법이 익숙하다면 K8s CrashLoopBackOff 원인 9가지와 즉시 복구도 함께 참고하면, 로그 수집과 재현 관점에서 힌트를 얻을 수 있습니다.
14) 마무리: 가장 빠른 해결 루트
정리하면, ExecStart 디버깅의 가장 빠른 루트는 다음 순서입니다.
systemctl status -l로 종료 코드와 실패 단계 확인journalctl -u -b로 “이번 부팅” 로그에서 첫 실패 지점 찾기User,WorkingDirectory,Environment를 동일하게 맞춰 수동 실행으로 재현Type과Restart정책이 앱 동작과 충돌하지 않는지 점검- 필요하면 drop-in으로
Restart=no로 잠깐 멈춰 로그를 뽑고 수정
재시작 자체는 증상일 뿐이고, 원인은 대부분 “systemd가 실행하는 방식과 애플리케이션의 가정이 어긋난 지점”에 있습니다. 그 어긋남을 ExecStart 주변에서 하나씩 제거하면, 무한 재시작은 자연스럽게 끝납니다.