Published on

systemd 재시작 루프 - ExecStart 디버깅 가이드

Authors

서버에서 서비스가 갑자기 죽고 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/EXECExecStart 실행 자체가 실패했다는 신호입니다(경로/권한/포맷 문제 가능성이 큼). 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는 셸이 아니다(가장 흔한 함정)

systemdExecStart=는 기본적으로 셸이 아니라 “직접 실행”입니다. 즉 다음이 함정이 됩니다.

  • 파이프 |, 리다이렉션 > 같은 셸 문법
  • $(...) 커맨드 치환
  • * 글로빙
  • 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 실패인데 로그는 정상일 때도 함께 보면, “로그는 정상인데 바깥에서 실패로 보는 이유”를 구조적으로 이해하는 데 도움이 됩니다.

마무리: 가장 빠른 디버깅 루틴

재시작 루프에서 시간을 가장 아끼는 루틴은 다음 순서입니다.

  1. systemctl status ... -lstatus=...ExecStart 해석 결과를 확보
  2. journalctl -u ... -b로 해당 시도의 로그를 좁혀 확인
  3. 203/EXEC 계열이면 경로/권한/동적 링크/스크립트 shebang부터 해결
  4. 터미널과 유닛 환경 차이를 의심하고 Environment, WorkingDirectory를 고정
  5. Type= 불일치를 점검
  6. 루프를 잠시 끊고 단발 실행으로 원인을 고정

이 절차를 따르면 “systemd가 왜 계속 재시작하는지”가 감이 아니라 증거(Exit code, 로그, 실행 조건)로 정리되고, ExecStart 문제를 빠르게 수습할 수 있습니다.