Published on

systemd 서비스 무한 재시작 - Exit code 203 해결

Authors

서버에서 배포 직후 systemctl status를 보면 서비스가 계속 재시작되고, 로그에는 Main process exited, code=exited, status=203/EXEC 같은 문구가 찍히는 경우가 있습니다. 이때 많은 분들이 애플리케이션 내부 오류(예: 예외, 설정 파일 누락)를 먼저 의심하지만, **203/EXEC는 대개 “프로세스를 실행(execute)하는 단계에서 실패”**했다는 뜻이라 앱 로직까지 도달하지 못한 경우가 많습니다.

이 글에서는 Exit code 203의 의미를 systemd 관점에서 해부하고, 무한 재시작 루프를 끊고 원인을 빠르게 찾는 체크리스트를 제공합니다. (서비스가 계속 재시작되면 로그가 쏟아지며 장애가 커지므로, 먼저 “진단 모드”로 전환하는 게 핵심입니다.)

1) 203/EXEC가 의미하는 것: 앱이 아니라 “execve()” 단계 문제

systemd의 status=203/EXEC는 대략 다음 중 하나를 의미합니다.

  • ExecStart=에 지정한 경로가 없음
  • 실행 파일이지만 권한(x bit) 이 없음
  • 스크립트인데 shebang(#!) 누락/오류
  • 바이너리인데 동적 로더/라이브러리 미존재로 실행 불가
  • User=/Group=/WorkingDirectory= 등 유닛 설정 때문에 접근 불가
  • Windows CRLF, 잘못된 인코딩 등으로 인터프리터 경로 파싱 실패

즉, 애플리케이션 로그가 아니라 systemd/커널 레벨에서 실행 자체가 실패한 것입니다.

2) 무한 재시작 루프 먼저 끊기: 진단을 위한 안전장치

서비스가 Restart=always 또는 Restart=on-failure로 설정되어 있으면, 실행 실패가 발생할 때마다 계속 재시작합니다. 우선 루프를 끊어야 원인 파악이 쉬워집니다.

2.1 즉시 중지 및 재시작 카운터 리셋

sudo systemctl stop myapp.service
sudo systemctl reset-failed myapp.service

2.2 임시로 Restart 비활성화(드롭인 override)

sudo systemctl edit myapp.service

아래를 입력합니다.

[Service]
Restart=no

적용:

sudo systemctl daemon-reload
sudo systemctl start myapp.service
sudo systemctl status myapp.service -l

이제 한 번 실패하고 멈추므로 203/EXEC 원인을 안정적으로 확인할 수 있습니다.

3) 1분 안에 하는 1차 진단: status + journal + ExecStart 확인

3.1 상태와 마지막 로그를 길게 출력

systemctl status myapp.service -l --no-pager
journalctl -u myapp.service -b --no-pager -n 200

여기서 특히 봐야 할 포인트:

  • ExecStart=에 찍히는 실제 커맨드(경로/인자)
  • No such file or directory, Permission denied 같은 커널 에러
  • Failed at step EXEC spawning ... 문구

3.2 유닛 파일의 실제 내용 확인

드롭인/override가 섞여 있으면 cat이 가장 확실합니다.

systemctl cat myapp.service
systemctl show myapp.service -p ExecStart -p User -p Group -p WorkingDirectory

4) Exit 203의 대표 원인 7가지와 해결법

4.1 ExecStart 경로 오타 / 파일이 없음

가장 흔합니다.

ls -al /opt/myapp/bin/myapp
  • 파일이 없다면 배포 경로를 수정하거나, 유닛의 ExecStart=를 올바른 위치로 변경
  • 심볼릭 링크를 쓴다면 링크가 깨지지 않았는지 확인

팁: systemd는 PATH를 셸처럼 확장하지 않습니다. ExecStart=myapp처럼 쓰면 실패할 수 있으니 절대경로를 권장합니다.

4.2 실행 권한(x bit) 없음

stat /opt/myapp/bin/myapp
# 또는
ls -l /opt/myapp/bin/myapp

권한 부여:

sudo chmod +x /opt/myapp/bin/myapp

4.3 스크립트인데 shebang(#!)이 없거나 인터프리터 경로가 잘못됨

예: #!/usr/bin/env bash 또는 #!/bin/bash가 필요합니다.

head -n 1 /opt/myapp/bin/start.sh
file -b /opt/myapp/bin/start.sh

수정 예:

sudo sed -i '1s|^|#!/usr/bin/env bash\n|' /opt/myapp/bin/start.sh
sudo chmod +x /opt/myapp/bin/start.sh

또 다른 함정:

  • #!/bin/bash^M (CRLF) → 인터프리터를 못 찾아 No such file or directory가 날 수 있음

CRLF 제거:

sudo sed -i 's/\r$//' /opt/myapp/bin/start.sh

4.4 바이너리/스크립트가 의존하는 동적 로더/라이브러리가 없음

특히 다른 배포판에서 빌드한 바이너리를 가져오면 발생합니다.

file /opt/myapp/bin/myapp
ldd /opt/myapp/bin/myapp
  • not found가 보이면 해당 라이브러리를 설치하거나
  • 정적 빌드로 전환하거나
  • 컨테이너/패키징 방식(예: deb/rpm)으로 의존성을 명시하세요.

4.5 WorkingDirectory가 없거나 권한이 없어 chdir 실패

WorkingDirectory=/var/lib/myapp 같은 설정이 있고, 해당 디렉터리가 없으면 실행 단계에서 실패할 수 있습니다.

systemctl show myapp.service -p WorkingDirectory
ls -ald /var/lib/myapp

해결:

sudo mkdir -p /var/lib/myapp
sudo chown -R myapp:myapp /var/lib/myapp

4.6 User/Group 설정으로 인해 실행 파일 접근 불가

User=myapp로 실행하는데 바이너리가 root만 읽을 수 있으면 Permission denied가 납니다.

systemctl show myapp.service -p User -p Group
namei -l /opt/myapp/bin/myapp
  • 상위 디렉터리까지 실행 권한(x)이 있는지 확인
  • 필요한 경우 소유권/권한 조정

4.7 ExecStart에 셸 문법을 그대로 넣음(리다이렉션/파이프 등)

systemd는 기본적으로 셸을 거치지 않으므로 아래처럼 쓰면 의도대로 동작하지 않습니다.

ExecStart=/opt/myapp/bin/myapp >> /var/log/myapp.log 2>&1

해결 방법은 2가지입니다.

  1. 표준 출력은 systemd/journald로 보내고 journalctl로 조회 (권장)
  2. 꼭 셸이 필요하면 명시적으로 /bin/bash -lc 사용
ExecStart=/bin/bash -lc '/opt/myapp/bin/myapp >>/var/log/myapp.log 2>&1'

다만 셸을 끼우면 quoting 문제가 생기고, 장애 시 원인 파악이 어려워질 수 있어 신중히 선택하세요.

5) 재현 가능한 디버깅: systemd가 실행하려는 것을 그대로 실행해보기

유닛의 User=를 반영해서 직접 실행해보면 원인이 빠르게 드러납니다.

# myapp 유저로 실행해보기
sudo -u myapp -H /opt/myapp/bin/myapp

# WorkingDirectory가 중요하다면
sudo -u myapp -H bash -lc 'cd /var/lib/myapp && /opt/myapp/bin/myapp'

여기서도 실행이 안 된다면 systemd 문제가 아니라 실행 파일/권한/의존성 문제입니다.

6) “무한 재시작”을 예방하는 유닛 설정 패턴

203/EXEC 같은 즉시 실패는 재시작해도 해결되지 않는 경우가 대부분입니다. 아래 설정으로 장애 증폭을 막을 수 있습니다.

6.1 Restart 정책과 백오프 설정

[Service]
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
  • 1분 동안 3번 이상 실패하면 더 이상 시도하지 않게 만들어 알람/조치를 유도합니다.

6.2 실행 전 조건 체크(Preflight)

실행 파일 존재/권한/필수 파일을 미리 확인해 더 명확한 로그를 남길 수 있습니다.

[Service]
ExecStartPre=/usr/bin/test -x /opt/myapp/bin/myapp
ExecStartPre=/usr/bin/test -d /var/lib/myapp
ExecStart=/opt/myapp/bin/myapp

6.3 로그는 journald 중심으로

파일 리다이렉션 대신:

[Service]
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

이러면:

journalctl -u myapp.service -f

로 즉시 추적 가능합니다.

7) 실전 예시: 203/EXEC 문제 유닛을 올바르게 고치기

문제 유닛(예시):

[Unit]
Description=MyApp
After=network.target

[Service]
User=myapp
WorkingDirectory=/var/lib/myapp
ExecStart=/opt/myapp/bin/start.sh
Restart=always

[Install]
WantedBy=multi-user.target

원인: start.sh가 CRLF + shebang 누락 + 실행 권한 없음.

해결:

sudo sed -i 's/\r$//' /opt/myapp/bin/start.sh
sudo bash -lc "printf '%s\n' '#!/usr/bin/env bash' | cat - /opt/myapp/bin/start.sh > /tmp/start.sh && mv /tmp/start.sh /opt/myapp/bin/start.sh"
sudo chmod +x /opt/myapp/bin/start.sh
sudo chown myapp:myapp /opt/myapp/bin/start.sh

sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl status myapp.service -l

추가로 재시작 폭주 방지 설정을 반영:

sudo systemctl edit myapp.service
[Service]
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3

8) 운영 관점 체크리스트(장애 대응용)

  • systemctl status -l에서 status=203/EXEC 확인
  • systemctl cat으로 실제 ExecStart/User/WorkingDirectory 확인
  • ls -al/stat로 실행 파일 존재/권한 확인
  • 스크립트면 head -n1로 shebang 확인, sed -i 's/\r$//'로 CRLF 제거
  • 바이너리면 lddnot found 확인
  • sudo -u <User>로 동일 조건 실행 테스트
  • Restart/StartLimit*로 재시작 폭주 방지

9) 비슷한 “원인 추적형 장애” 접근법 참고

이 문제는 겉으로는 “서비스가 죽는다”지만, 실제로는 실행 단계(환경/권한/의존성)에서 실패하는 전형적인 케이스입니다. 비슷한 방식으로 원인을 계층적으로 좁혀가는 접근은 아래 글들도 도움이 됩니다.

마무리

Exit code 203/EXEC는 “앱이 실행되다가 죽었다”가 아니라, 애초에 실행이 성립하지 않았다는 신호입니다. 따라서 해결의 핵심은 애플리케이션 로직 디버깅이 아니라, ExecStart 경로/권한/shebang/CRLF/동적 라이브러리/유닛의 User·WorkingDirectory 같은 실행 조건을 빠르게 검증하는 것입니다.

무한 재시작 루프는 장애를 증폭시키므로, 먼저 Restart를 잠시 끄거나 StartLimit*로 제한을 걸고, 위 체크리스트 순서대로 확인하면 대부분 10~20분 내에 원인을 찾을 수 있습니다.