- Published on
systemd 재시작 루프 - ExecStart 디버깅 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 서비스가 갑자기 죽고 systemd가 다시 올리고, 또 죽고, 또 올리는 재시작 루프에 빠지면 장애는 길어지고 원인 파악은 어려워집니다. 특히 ExecStart가 실행은 되지만 즉시 종료되거나(Exit code), 실행 자체가 안 되는(권한/경로) 케이스는 로그만 보고는 감이 안 올 때가 많습니다.
이 글은 systemd 재시작 루프 상황에서 ExecStart를 중심으로 무엇을 어떤 순서로 확인하면 원인을 가장 빨리 좁힐 수 있는지를 실전 관점에서 정리합니다. 단순히 “로그 보세요”가 아니라, systemd가 어떤 조건에서 재시작을 걸고, 어떤 정보가 어디에 남는지, 그리고 흔한 함정(환경변수, 작업 디렉터리, 권한, 셸 확장, Type 불일치)을 어떻게 검증하는지까지 다룹니다.
재시작 루프를 먼저 정의하기
systemd에서 재시작 루프는 보통 다음 조합으로 발생합니다.
- 프로세스가 곧바로 종료됨(정상 종료든 비정상이든)
- 유닛에
Restart=가 설정되어 재시작을 시도함 - 짧은 시간 내 실패가 반복되어
StartLimit*에 걸리거나, 계속 반복됨
먼저 유닛의 재시작 정책을 확인합니다.
systemctl cat myapp.service
systemctl show myapp.service -p Restart -p RestartSec -p StartLimitIntervalSec -p StartLimitBurst
Restart=always 또는 Restart=on-failure가 걸려 있고, RestartSec가 짧으면 증상이 더 눈에 띄게 반복됩니다.
1단계: 상태에서 Exit code와 실패 지점을 뽑아내기
재시작 루프 디버깅은 “왜 재시작했는지”를 먼저 고정하는 게 핵심입니다.
systemctl status myapp.service --no-pager -l
여기서 특히 봐야 할 값은 다음입니다.
Main PID가 뜨는지(실행은 됐는지)code=exited, status=...또는code=killed, signal=...ExecStart=라인이 실제 어떤 커맨드로 해석되었는지
예를 들어 status=203/EXEC는 ExecStart 실행 자체가 실패했다는 신호입니다(경로/권한/포맷 문제 가능성이 큼). status=1/FAILURE 같은 값은 애플리케이션이 실행은 되었지만 내부 로직에서 실패하고 종료했을 가능성이 큽니다.
2단계: journalctl로 “이번 시도”의 로그만 좁히기
재시작 루프에서는 로그가 너무 빨리 쌓여서 맥락이 흐려집니다. 다음 방식으로 범위를 줄입니다.
# 최근 부팅에서 해당 유닛 로그
journalctl -u myapp.service -b --no-pager -n 200
# 가장 최근 실패한 실행의 직전/직후까지 따라가기
journalctl -u myapp.service -b --no-pager -f
만약 로그에 애플리케이션 출력이 거의 없고 systemd 메시지만 있다면, ExecStart가 애초에 실행되지 않았거나(203/EXEC), 실행 직후 즉시 죽는 케이스(동적 링크 실패, 권한, 환경변수)일 확률이 큽니다.
3단계: ExecStart는 셸이 아니다(가장 흔한 함정)
systemd의 ExecStart=는 기본적으로 셸이 아니라 “직접 실행”입니다. 즉 다음이 함정이 됩니다.
- 파이프
|, 리다이렉션>같은 셸 문법 $(...)커맨드 치환*글로빙export같은 셸 내장
이런 문법이 필요하다면 명시적으로 셸을 거쳐야 합니다.
# /etc/systemd/system/myapp.service
[Service]
ExecStart=/bin/bash -lc 'exec /opt/myapp/bin/myapp --port=8080 >> /var/log/myapp/app.log 2>&1'
여기서도 주의할 점은 bash -lc는 로그인 셸처럼 동작하여 환경이 달라질 수 있다는 것입니다. 가능하면 셸 의존을 줄이고, StandardOutput=/StandardError=로 로깅을 systemd에 맡기는 편이 안정적입니다.
[Service]
ExecStart=/opt/myapp/bin/myapp --port=8080
StandardOutput=journal
StandardError=journal
4단계: 203/EXEC, 126, 127 계열은 “실행 불가” 문제로 본다
실무에서 재시작 루프의 절반은 애플리케이션 버그가 아니라 실행 조건 문제입니다.
4-1. 파일 존재/실행 권한/소유자
ls -al /opt/myapp/bin/myapp
namei -l /opt/myapp/bin/myapp
sudo -u myapp /opt/myapp/bin/myapp --version
namei -l은 중간 디렉터리의 실행 권한(x)이 빠진 경우까지 잡아내서 유용합니다.
4-2. shebang 문제(스크립트 실행)
ExecStart가 스크립트인데 첫 줄 shebang이 잘못되어 있으면 실행이 실패합니다.
head -n 1 /opt/myapp/bin/start.sh
file /opt/myapp/bin/start.sh
예를 들어 #!/usr/bin/env bash가 필요한데 시스템에 bash가 없거나 경로가 다르면 실패할 수 있습니다.
4-3. 동적 링크 라이브러리 누락
바이너리가 실행 직후 죽고 로그가 거의 없다면 라이브러리 로딩 실패일 수 있습니다.
ldd /opt/myapp/bin/myapp
not found가 보이면 해당 라이브러리를 설치하거나, 런타임 경로(rpath) 문제를 해결해야 합니다.
5단계: 유닛에서의 환경변수는 “로그인 세션”과 다르다
터미널에서 잘 되는데 systemd에서만 죽는 대표 원인입니다.
PATH가 다름HOME가 기대와 다름.env,.profile,.bashrc가 로드되지 않음WorkingDirectory가 다름
유닛에 환경을 명시적으로 고정합니다.
[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="JAVA_HOME=/usr/lib/jvm/java-21"
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/opt/myapp/bin/myapp
그리고 실제로 systemd가 어떤 환경으로 실행하는지 확인하려면, 임시로 환경을 출력하는 ExecStartPre를 붙여도 됩니다.
[Service]
ExecStartPre=/usr/bin/env
ExecStart=/opt/myapp/bin/myapp
출력은 journalctl -u myapp.service -b에서 확인합니다.
6단계: Type= 불일치로 인한 “살아있는데 죽었다고 판단”
systemd는 서비스 타입에 따라 “정상 기동”을 판단합니다. 타입이 맞지 않으면 프로세스가 정상 동작 중인데도 systemd가 실패로 판단해 재시작 루프가 날 수 있습니다.
Type=simple(기본):ExecStart프로세스가 곧 서비스Type=forking: 데몬이 포크 후 부모가 종료되는 방식(옛 데몬)Type=notify:sd_notify로 readiness를 알림Type=oneshot: 실행 후 종료되는 작업
예를 들어 앱이 포크하지 않는데 Type=forking이면 systemd가 PID 추적을 잘못해 실패로 볼 수 있습니다. 반대로 포크형 데몬인데 Type=simple이면 메인 PID가 부모로 잡혀 종료 시 실패로 이어질 수 있습니다.
확인:
systemctl show myapp.service -p Type -p MainPID -p ExecMainPID -p ExecMainStatus
7단계: 재시작 루프를 “멈추고” 한 번만 실행해 보기
루프 상태에서는 관찰이 어렵습니다. 일단 재시작을 잠깐 막고 단발 실행으로 증상을 고정합니다.
# 즉시 중단
sudo systemctl stop myapp.service
# 재시작 정책을 런타임에서 임시로 무력화(드롭인 권장)
sudo systemctl edit myapp.service
드롭인에 다음을 넣습니다.
[Service]
Restart=no
적용 후:
sudo systemctl daemon-reload
sudo systemctl start myapp.service
sudo systemctl status myapp.service --no-pager -l
이제 한 번 실행하고 죽으면, 그 “한 번”의 로그와 종료 코드를 안정적으로 분석할 수 있습니다.
8단계: systemd-run으로 유닛과 유사한 조건에서 재현
터미널에서 직접 실행하면 환경이 달라서 재현이 안 될 수 있습니다. systemd-run으로 transient unit을 만들어 비슷한 조건에서 돌려봅니다.
sudo systemd-run --unit=myapp-debug \
--property=User=myapp \
--property=WorkingDirectory=/opt/myapp \
--property=Environment="PATH=/usr/bin:/bin" \
/opt/myapp/bin/myapp --port=8080
journalctl -u myapp-debug -b --no-pager -n 200
이 방식은 “유닛으로 돌리면 죽는다”를 빠르게 재현하는 데 효과적입니다.
9단계: ExecStart 디버깅을 위한 유용한 유닛 옵션
9-1. 종료 시그널/타임아웃 문제
애플리케이션이 느리게 뜨거나, 종료 시그널 처리에 시간이 오래 걸리면 TimeoutStartSec 또는 TimeoutStopSec에 걸려 강제 종료되고 재시작 루프로 보일 수 있습니다.
[Service]
TimeoutStartSec=120
TimeoutStopSec=60
KillSignal=SIGTERM
9-2. 코어덤프와 리소스 제한
갑자기 SIGSEGV 등으로 죽는다면 코어덤프를 남기는 게 빠릅니다.
[Service]
LimitCORE=infinity
그리고 시스템 코어덤프 설정(coredumpctl)로 확인:
coredumpctl list myapp
coredumpctl info myapp
9-3. 표준 출력 로깅을 확실히
[Service]
StandardOutput=journal
StandardError=journal
LogLevelMax=debug
애플리케이션이 stdout에 원인을 찍는데 파일 리다이렉션 실수로 로그를 못 보는 경우가 많습니다.
10단계: 자주 보는 원인 체크리스트
아래는 재시작 루프에서 반복적으로 마주치는 원인들입니다.
ExecStart경로 오타, 실행 권한 없음, 중간 디렉터리x권한 없음- 스크립트 CRLF(
\r\n)로 인한 실행 실패 - 필요한 환경변수 누락(
PATH,JAVA_HOME,NODE_ENV,HOME) WorkingDirectory누락으로 상대경로 파일을 못 찾아 종료- 포트 바인딩 실패(이미 사용 중)로 즉시 종료
- 동적 라이브러리 누락(
ldd에서not found) Type=불일치로 인한 잘못된 상태 판단TimeoutStartSec에 걸려 강제 종료
포트 충돌은 다음처럼 빠르게 확인합니다.
sudo ss -ltnp | grep -E '(:8080\b)'
11단계: 예시로 보는 “나쁜 유닛”과 “좋은 유닛”
11-1. 나쁜 예: 셸 문법을 그대로 넣음
[Service]
ExecStart=/opt/myapp/bin/myapp --port=8080 > /var/log/myapp.log 2>&1
Restart=always
RestartSec=1
이 경우 >가 셸 문법이므로 기대대로 동작하지 않거나, systemd가 인자를 그대로 넘겨 앱이 파라미터 파싱 실패로 종료할 수 있습니다.
11-2. 좋은 예: systemd 방식으로 로깅과 환경을 고정
[Unit]
Description=MyApp
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/opt/myapp/bin/myapp --port=8080
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
이 구성이면 재시작이 필요할 때만 재시작하고, 로그는 journalctl로 일관되게 수집할 수 있습니다.
운영 환경에서의 연결 포인트: readiness와 “정상처럼 보이는 로그”
서비스는 살아 있는데 로드밸런서나 오케스트레이터 관점에서는 비정상으로 판단되는 케이스가 있습니다. systemd 단에서도 Type=notify나 타임아웃/시그널 처리 문제로 비슷한 착시가 생깁니다. 애플리케이션 로그가 정상처럼 보여도 헬스체크/ready 조건이 충족되지 않아 재시작 또는 트래픽 차단이 발생할 수 있습니다.
이 관점은 쿠버네티스에서도 동일하게 나타납니다. 비슷한 문제를 다룬 글로 EKS에서 Readiness 실패인데 로그는 정상일 때도 함께 보면, “로그는 정상인데 바깥에서 실패로 보는 이유”를 구조적으로 이해하는 데 도움이 됩니다.
마무리: 가장 빠른 디버깅 루틴
재시작 루프에서 시간을 가장 아끼는 루틴은 다음 순서입니다.
systemctl status ... -l로status=...와ExecStart해석 결과를 확보journalctl -u ... -b로 해당 시도의 로그를 좁혀 확인203/EXEC계열이면 경로/권한/동적 링크/스크립트 shebang부터 해결- 터미널과 유닛 환경 차이를 의심하고
Environment,WorkingDirectory를 고정 Type=불일치를 점검- 루프를 잠시 끊고 단발 실행으로 원인을 고정
이 절차를 따르면 “systemd가 왜 계속 재시작하는지”가 감이 아니라 증거(Exit code, 로그, 실행 조건)로 정리되고, ExecStart 문제를 빠르게 수습할 수 있습니다.