목차
● myapi.service - FastAPI Production Server
Loaded: loaded (/etc/systemd/system/myapi.service; enabled; vendor preset: enabled)
Active: failed (Result: exit-code) since Thu 2026-05-14 03:21:08 UTC; 6s ago
Main PID: 14233 (code=exited, status=203/EXEC)
CPU: 2ms
systemd[14233]: myapi.service: Failed to execute /home/deploy/.venv/bin/uvicorn: No such file or directory
systemd[14233]: myapi.service: Failed at step EXEC spawning /home/deploy/.venv/bin/uvicorn: No such file or directory
systemd[1]: myapi.service: Main process exited, code=exited, status=203/EXEC
linux systemd 서비스 등록 첫날 가장 자주 보는 에러가 status=203/EXEC다. unit 파일에 적은 경로를 systemd가 못 찾을 때 뜨는 코드인데, 메시지만 보면 분명히 존재하는 파일을 왜 못 찾는지 한참 헤맸다. Ubuntu 22.04 LTS, systemd 252 환경에서 FastAPI 앱을 서비스로 올리다가 두 시간을 날린 기록이다.
오늘 한 것 — FastAPI 앱을 systemd로 띄우기
배경부터 짧게. Ubuntu 22.04 EC2 인스턴스 한 대, Python 3.11 + FastAPI 앱 하나, nohup으로 띄워두던 걸 인스턴스 재부팅 후 자동 실행으로 바꾸는 작업이었다. nohup은 SSH 끊겨도 살아남지만 재부팅 한 번에 사라진다. tmux나 screen도 똑같다.
처음엔 그냥 nohup uvicorn main:app & 한 줄로 운영했다. 인스턴스가 죽을 일이 거의 없으니 괜찮다고 봤는데, 보안 패치 자동 재부팅이 한 번 돌면서 새벽에 서비스가 내려가 있었다. PagerDuty 알람이 울렸고, 그 김에 systemd로 갈아탔다.
한편, 작업 자체는 간단해 보였다. /etc/systemd/system/myapi.service 만들고 systemctl enable --now. 그런데 status=203/EXEC가 먼저 반겨줬고, 그다음 status=217/USER, 그다음 status=200/CHDIR이 차례로 나왔다. 에러 코드 세 개를 다 보고 나서야 unit 파일이 어떻게 해석되는지 감이 잡혔다.
status=203/EXEC가 진짜 의미하는 것
반면, 처음 본 메시지는 "Failed to execute /home/deploy/.venv/bin/uvicorn: No such file or directory"였다. SSH로 들어가서 그 경로를 그대로 치면 멀쩡히 실행된다. systemd만 없다고 한다. 미친 듯이 ls -la 찍고 stat 찍었다.
그러나, 원인은 두 가지였다. 첫째, ExecStart에 절대 경로를 안 적었다. 처음에 ExecStart=uvicorn main:app라고 적어뒀는데, systemd는 PATH를 거의 안 물려준다. 셸이 알아서 찾아주는 uvicorn 바이너리를 systemd는 못 찾는다.
또한, 둘째, 절대 경로로 고쳤는데도 같은 에러가 났다. 알고 보니 venv를 다른 사용자 홈에서 복사해 온 흔적이 남아 있었다. shebang 한 줄이 문제였다.
$ head -1 /home/deploy/.venv/bin/uvicorn
#!/home/old_user/.venv/bin/python3
이거 보고 한참 웃었다. systemd 에러는 "uvicorn을 못 찾는다"고 했지만, 진짜로 못 찾은 건 그 안의 shebang이 가리키는 python3였다. shebang 경로를 고치는 가장 깔끔한 방법은 venv를 다시 만드는 것이다. python3.11 -m venv /srv/myapi/.venv 한 줄로 끝났다.
새로 알게 된 것 — exit code 매핑
systemd가 뱉는 status 코드는 LSB 표준과 systemd 자체 확장이 섞여 있다. 헷갈려서 한 번 정리해뒀다.
| status 코드 | 의미 | 가장 흔한 원인 |
|---|---|---|
| 203/EXEC | execve 실패 | ExecStart 경로 오타, shebang 깨짐, 실행 권한 없음 |
| 217/USER | User=가 존재하지 않거나 셸 없음 | deploy 계정 만들기 전 enable한 경우 |
| 200/CHDIR | WorkingDirectory= 진입 실패 | 경로 오타, 권한 부족 |
| 226/NAMESPACE | 네임스페이스 마운트 실패 | PrivateTmp/ProtectSystem 옵션 충돌 |
| 1 | 일반 실패 | 앱 자체가 1로 종료 |
물론, 표를 만들고 보니 대부분 unit 파일의 한 줄짜리 실수였다. systemctl status는 5초 정도만 보여주다 잘리니까 journalctl -u myapi.service -e --no-pager 쪽이 추적하기 편했다.
unit 파일 — 최소 설정과 의미
그래서, 처음에는 인터넷에 떠도는 unit 파일 예제를 복붙했다. After=network.target 한 줄이 무슨 의미인지 모르고 그냥 베꼈는데, 한 번 부서지고 나니 줄 단위로 의미를 알게 됐다. 최소한으로 작동하는 버전부터 보자.
절대 경로가 필수인 이유
결국, 다음은 실제로 운영 중인 unit 파일이다. /etc/systemd/system/myapi.service에 저장해뒀다.
[Unit]
Description=FastAPI Production Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/srv/myapi
EnvironmentFile=/etc/myapi/env
ExecStart=/srv/myapi/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2
Restart=on-failure
RestartSec=5s
StartLimitBurst=5
StartLimitIntervalSec=60
# 로그를 journald로 모음
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapi
[Install]
WantedBy=multi-user.target
결국, ExecStart에 적힌 경로는 모두 절대 경로다. systemd는 PATH를 거의 비워둔다. 셸에서 echo $PATH를 찍으면 한 줄로 길게 나오는 것과 다르다. systemctl show-environment로 확인하면 /usr/bin과 /usr/local/bin 정도만 들어있다.
예를 들어, After=network-online.target과 Wants=network-online.target은 같이 써야 의미가 있다. After는 순서만 정하고, Wants가 있어야 systemd가 network-online.target을 실제로 활성화한다. 둘 중 하나만 쓰면 부팅 시 네트워크가 준비되기 전에 앱이 떠서 socket bind 실패가 난다.
따라서, EnvironmentFile=로 환경변수를 분리한 건 시크릿을 unit 파일에 직접 박지 않으려는 의도다. /etc/myapi/env 파일에 KEY=VALUE 형식으로 적어두면 systemd가 읽어서 프로세스에 주입한다. 권한은 600으로 잠그고 소유자는 root로 두는 게 안전했다.
Restart 옵션과 부팅 시 자동 실행
따라서, Restart=on-failure는 0이 아닌 종료 코드일 때만 재시작한다. Restart=always는 정상 종료 포함 무조건 재시작한다. 둘이 비슷해 보이지만 실제로는 다르다. 앱이 SIGTERM에 0으로 종료되는 정상 배포 흐름이 있다면 always는 위험하다. 배포 스크립트가 stop을 쳐도 systemd가 다시 살린다.
실제로, StartLimitBurst=5, StartLimitIntervalSec=60은 60초 안에 5번 이상 실패하면 더 이상 재시작하지 말라는 뜻이다. 안 적으면 무한 재시작 루프를 도는데, 앱이 시작하자마자 죽는 종류의 버그(설정 파일 누락, DB 접속 실패)에서는 CPU만 태우다 끝난다. 두 줄은 거의 항상 같이 쓰는 게 안전했다.
부팅 시 자동 실행은 systemctl enable myapi.service 한 줄이면 된다. enable이 하는 일은 단순하다. [Install] 섹션의 WantedBy에 적힌 타겟의 .wants 디렉터리에 심볼릭 링크를 만들어준다.
$ sudo systemctl enable myapi.service
Created symlink /etc/systemd/system/multi-user.target.wants/myapi.service → /etc/systemd/system/myapi.service.
$ ls -la /etc/systemd/system/multi-user.target.wants/myapi.service
lrwxrwxrwx 1 root root 35 May 14 03:48 /etc/systemd/system/multi-user.target.wants/myapi.service -> /etc/systemd/system/myapi.service
예를 들어, 이 심볼릭 링크가 부팅 시 systemd가 서비스를 찾는 단서다. 헷갈려서 unit 파일을 다른 위치에 두면 enable 자체가 안 먹는다. /etc/systemd/system이 정답이고, 패키지 매니저가 깔아준 서비스는 /lib/systemd/system 또는 /usr/lib/systemd/system에 있다.
User/Group 권한 분리
실제로, User=deploy로 적으면 그 유저로 프로세스가 떠야 한다. 처음엔 root로 띄우다가 status=217/USER가 한 번 나왔다. deploy 계정을 만들기 전에 enable을 친 것이 원인이었다. useradd -m -s /bin/bash deploy 한 줄 빼먹은 게 끝이었다.
또한, User=만 쓰면 Group=은 같은 이름의 그룹을 자동으로 잡는다. 그런데 deploy 유저가 추가 그룹(예: docker)에 들어있어야 한다면 SupplementaryGroups=docker 같은 식으로 명시해야 한다. 안 그러면 deploy 셸에서는 docker ps가 먹어도 systemd 컨텍스트에서는 권한 거부가 난다. 컨테이너 띄우다가 따로 알게 된 부분이다.
이처럼, ::: tip WorkingDirectory에 적힌 경로의 권한은 User=가 읽고 들어갈 수 있어야 한다. 700으로 잠궈둔 디렉터리에 다른 유저의 서비스를 띄우면 status=200/CHDIR이 난다. 권한은 가급적 750 또는 755로 두는 게 무난했다. :::
자동 재시작 옵션 — 무한 루프 막기
Restart= 한 줄로 끝나는 게 아니다. 옵션 조합이 의외로 많고, 잘못 쓰면 무한 재시작 루프에 빠진다. 옵션별로 정리해두면 unit 파일을 짤 때마다 도움 됐다.
| 옵션 | 동작 | 추천 상황 |
|---|---|---|
| Restart=no | 재시작 안 함 | 일회성 배치, oneshot 서비스 |
| Restart=on-failure | 비정상 종료 시 재시작 | 일반적인 웹 서버 |
| Restart=on-abnormal | 시그널/타임아웃에서만 재시작 | 정상 종료를 자주 쓰는 워커 |
| Restart=always | 항상 재시작 | 백그라운드 데몬, 절대 죽으면 안 되는 것 |
거기에, RestartSec=는 재시작까지 기다리는 시간이다. 너무 짧으면 죽었다 살아나는 사이에 외부 의존성이 못 따라온다. 너무 길면 다운타임이 길어진다. 5초가 무난했고, DB 연결이 느린 환경에선 10초까지 늘렸다.
예를 들어, StartLimitBurst와 StartLimitIntervalSec의 조합은 한 번 막혀보면 외워진다. 5번 실패가 60초 안에 일어나면 systemd가 "그만하자"고 결정하고 더 이상 재시작을 안 한다. 이 상태는 systemctl status에서 "start-limit-hit"로 보인다. 풀려면 systemctl reset-failed myapi.service 한 줄이 필요하다.
여기서 한 번 헤맸다. 앱 코드를 고치고 재배포했는데 systemctl restart가 먹지 않았다. start-limit-hit 상태였기 때문이다. reset-failed를 안 치면 unit 파일을 아무리 고쳐도 죽은 상태 그대로다. 이걸 모르고 unit 파일을 30분 동안 의심했다.
# start-limit-hit 풀고 재시작
$ sudo systemctl reset-failed myapi.service
$ sudo systemctl restart myapi.service
$ sudo systemctl status myapi.service | head -3
● myapi.service - FastAPI Production Server
Loaded: loaded (/etc/systemd/system/myapi.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-05-14 04:11:33 UTC; 2s ago
게다가, WatchdogSec= 옵션도 있다. 앱이 주기적으로 systemd에 "살아있다" 신호를 안 보내면 죽이고 재시작하는 옵션인데, Python에서 쓰려면 sd_notify를 호출하는 라이브러리가 필요하다. 아직 안 써봐서 모르겠다. 별도로 정리해볼 생각이다.
즉, unit 파일을 수정한 뒤에는 systemctl daemon-reload를 꼭 쳐야 한다. 안 치면 systemd가 디스크의 새 unit 파일을 안 읽고 메모리에 캐시된 옛 버전으로 돈다. 옵션 바꿨는데 왜 안 먹지 싶으면 9할은 daemon-reload를 빼먹은 거였다.
journalctl — 로그 추적과 필터
게다가, systemd 서비스의 로그는 기본적으로 journald가 받는다. StandardOutput=journal로 설정했으니 stdout/stderr가 전부 journal로 간다. 파일로 따로 떨굴 일은 거의 없었다.
실시간 추적과 시간 범위 자르기
실제로, 가장 많이 쓰는 명령은 이 네 개다.
# 실시간 추적 (tail -f 같은 느낌)
$ journalctl -u myapi.service -f
# 마지막 200줄
$ journalctl -u myapi.service -n 200 --no-pager
# 최근 1시간
$ journalctl -u myapi.service --since "1 hour ago"
# 특정 시간 범위
$ journalctl -u myapi.service --since "2026-05-14 03:00" --until "2026-05-14 04:00"
-u 옵션이 unit 단위 필터다. -f가 follow, -n이 줄 수, –since/–until이 시간 범위. tail -f와 grep을 합친 거랑 거의 같다. 다만 –since에 "1 hour ago" 같은 자연어를 받는 게 편하다.
우선순위와 부팅별 분리
따라서, 로그 우선순위로도 자를 수 있다. -p err을 붙이면 ERROR 이상만 보여준다. 디버깅할 때 INFO 로그가 너무 많으면 시야가 가려진다.
# ERROR 이상만
$ journalctl -u myapi.service -p err --since "1 hour ago"
# 현재 부팅 로그만
$ journalctl -u myapi.service -b
# 직전 부팅
$ journalctl -u myapi.service -b -1
부팅별 분리(-b)는 의외로 자주 썼다. 부팅 직후 서비스가 못 떠서 죽었는데 그 시점 로그만 보고 싶을 때 -b 0이 가장 빠르다. -b -1은 이전 부팅, -b -2는 그 전 부팅이다.
JSON 출력은 자동화에 쓰기 좋았다. -o json-pretty로 떨궈서 jq로 파이프 태우면 된다.
$ journalctl -u myapi.service --since "5 min ago" -o json | \
jq -r 'select(.PRIORITY == "3") | .MESSAGE'
PRIORITY는 syslog 레벨이다. 3이 ERROR, 4가 WARNING, 6이 INFO. 숫자가 작을수록 심각도가 높다.
디스크 용량 관리
한편, journald 로그가 디스크를 다 먹는 사고를 한 번 봤다. /var/log/journal이 30GB까지 부풀어 있었다. journald.conf에서 보관 정책을 안 잡아둬서 그랬다.
# /etc/systemd/journald.conf
[Journal]
SystemMaxUse=2G
SystemKeepFree=1G
MaxRetentionSec=30day
예를 들어, SystemMaxUse가 전체 상한, SystemKeepFree가 디스크 여유 확보, MaxRetentionSec이 보관 기간이다. 셋 중 가장 빨리 걸리는 조건에서 잘린다. 설정 바꾸면 systemctl restart systemd-journald 한 번 쳐줘야 적용된다.
수동으로 비우는 것도 가능하다.
# 2GB까지 줄이기
$ sudo journalctl --vacuum-size=2G
# 7일 이전 로그 삭제
$ sudo journalctl --vacuum-time=7d
그 외에도, 기록 정리 차원에서 오늘 배운 걸 짧게 박아둔다.
- ExecStart 경로는 무조건 절대 경로. venv 안 바이너리도 마찬가지.
- shebang 깨진 venv는 status=203/EXEC를 띄운다. 의심되면
head -1 /path/to/binary로 확인. - enable 전에 User= 계정과 WorkingDirectory= 디렉터리 먼저 만들기.
- Restart=on-failure + RestartSec=5s + StartLimitBurst=5 / StartLimitIntervalSec=60이 안전한 기본값.
- start-limit-hit에 걸리면 unit 파일을 고쳐도 안 뜬다. reset-failed부터.
- unit 파일 수정 후엔 daemon-reload. 빼먹지 말기.
- journalctl은 -u, -f, -n, –since, -p, -b 여섯 개만 외워두면 거의 다 된다.
- journald 보관 정책은 처음부터 잡아두기. 안 그러면 어느 날 디스크가 가득 찬다.
결국, 당장 해볼 액션 세 개로 줄이면, (1) 운영 중인 서비스 unit 파일 열어서 ExecStart 절대 경로 확인, (2) StartLimitBurst/Interval 두 줄 들어가 있는지 점검, (3) /etc/systemd/journald.conf에 SystemMaxUse 설정해두기. 세 가지면 새벽에 깨는 빈도가 확실히 준다.
참고로 옵션 의미가 헷갈리면 systemd 공식 문서(systemd.service(5), https://www.freedesktop.org/software/systemd/man/systemd.service.html, 작성 시점 기준 systemd 252 문서)와 systemd GitHub 저장소(https://github.com/systemd/systemd) 이슈 검색이 가장 빠르다. 에러 코드를 그대로 검색창에 박으면 비슷한 사례가 대부분 잡힌다.
결국, 다음엔 sd_notify를 붙여서 WatchdogSec으로 헬스체크 죽이기를 해볼 생각이다.
관련 글
- Linux 서버 관리 명령어 40선 — 프로세스·디스크·네트워크·로그 치트시트 – 운영 서버에서 자주 쓰는 linux 서버 관리 명령어 40개를 영역별로 정리한 치트시트다. 외울 필요는 없고, 어디에 어떤 명령어가 있는지…
- Bash 쉘 스크립트 자동화 실전 가이드 — cron·에러 핸들링·로깅 패턴 – bash 쉘 스크립트 자동화는 단순히 명령어 묶는 게 아니라 실패를 어떻게 다룰지 설계하는 일이다. cron, trap, 락 파일, 로깅까…
- Bitwarden이 조용히 바꾼 것들 – 오픈소스 비밀번호 관리자 2026년 업데이트 정리 – 다들 비밀번호 관리자는 1Password가 정답이라고 한다. 그런데 Bitwarden을 1년 넘게 굴려보면 그 통념이 흔들린다. 조용히 바…