직접 짠 파이썬 스크립트나 노드 서버를 서버에 띄울 때 처음에는 nohup ... &로 대충 돌리게 됩니다. 문제는 그 다음입니다. 서버 재부팅하면 사라지고, 프로세스가 죽으면 알아서 살아나지도 않고, 로그는 nohup.out이 수십 메가로 부풀어 오르고요. 결국 답은 systemd 서비스로 등록하는 건데, 처음 .service 파일을 열어보면 옵션이 너무 많아서 어디부터 손대야 할지 막막합니다. 오늘은 진짜 최소한의 systemd 서비스를 처음부터 끝까지 만들어 보고, 자주 빠지는 함정까지 정리해 볼게요.
개념 / 원리
systemd는 요즘 거의 모든 리눅스 배포판이 쓰는 init 시스템입니다. PID 1로 부팅 직후 떠서, 그 위의 모든 프로세스를 관리하는 부모죠. 한 번에 여러 서비스를 병렬로 띄우고, 죽으면 자동으로 재시작하고, 로그도 중앙에서 모아 줍니다.
systemd가 관리하는 단위를 unit이라고 부르는데, 종류가 여러 개입니다.
| Unit 종류 | 용도 |
|---|---|
.service |
데몬·프로세스. 가장 많이 쓰는 unit. |
.timer |
주기 실행. cron 대체. |
.socket |
포트 listen 후 요청 들어올 때 서비스 활성화. |
.target |
여러 unit 묶음 (예: multi-user.target). |
오늘은 .service만 봅니다. .service 파일은 보통 두 군데에 둡니다.
/etc/systemd/system/— 직접 만든 사용자 정의 서비스. 여기에 둡니다./usr/lib/systemd/system/— 패키지 설치 시 자동으로 들어오는 곳. 손대지 않는 게 좋습니다.
핵심 섹션은 [Unit], [Service], [Install] 세 개입니다.
[Unit]: 서비스 메타 정보, 의존성 (After=network.target).[Service]: 어떻게 실행할지.ExecStart,User,Restart,WorkingDirectory.[Install]:enable했을 때 어느 target에 묶을지 (WantedBy=multi-user.target).
실전 예제
간단한 파이썬 웹 서버를 서비스로 등록해 보겠습니다. /opt/myapp/app.py에 다음 같은 스크립트가 있다고 가정해요.
# /opt/myapp/app.py
from http.server import HTTPServer, BaseHTTPRequestHandler
class Hello(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"hello from systemd\n")
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8080), Hello).serve_forever()
먼저 서비스를 돌릴 전용 유저를 만듭니다(루트로 돌리지 않기 위함).
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /opt/myapp
이제 /etc/systemd/system/myapp.service를 작성합니다.
[Unit]
Description=My Python Web App
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/python3 /opt/myapp/app.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
저장 후 systemd에 새 파일이 생겼다고 알려주고, 활성화·시작합니다.
sudo systemctl daemon-reload
sudo systemctl enable myapp.service
sudo systemctl start myapp.service
상태와 로그 확인은 이렇게요.
# 현재 상태 (Active: active (running) 인지)
sudo systemctl status myapp.service
# 실시간 로그 추적 (Ctrl+C로 종료)
sudo journalctl -u myapp.service -f
# 최근 100줄만
sudo journalctl -u myapp.service -n 100
# 부팅 이후 로그만
sudo journalctl -u myapp.service -b
이제 서버가 재부팅돼도 부팅 직후에 알아서 뜨고, 프로세스가 어떤 이유로 죽어도 5초 뒤 재시작됩니다. nohup과는 완전히 다른 안정감이에요.
주의할 점
1) ExecStart에는 반드시 절대경로를 씁니다. python3 app.py처럼 쓰면 PATH 못 찾고 status=203/EXEC로 실패합니다. which python3로 확인한 전체 경로를 박아야 해요. 가상환경을 쓴다면 ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py 식으로 venv의 python을 직접 지정합니다.
2) User= 빼먹으면 root로 돕니다. 보안 관점에서 매우 위험합니다. 외부에서 들어오는 서비스라면 반드시 전용 유저를 만들어 분리하세요. 저는 이거 모르고 한 동안 nginx 옆에서 노드 서버를 root로 돌렸던 적이 있는데, 운영팀에서 한 소리 들었습니다.
3) .service 파일을 수정했으면 무조건 daemon-reload. 이거 안 하면 systemd가 옛날 설정 그대로 들고 있어서 "분명 바꿨는데 왜 안 먹지" 상태가 됩니다. 수정 → daemon-reload → restart 순서, 항상 같이 다닙니다.
4) Restart=always와 Restart=on-failure의 차이를 알아두세요. always는 정상 종료까지 다시 띄웁니다 — 의도적으로 종료하려고 해도 자꾸 살아나서 디버깅이 힘들어집니다. 보통은 on-failure가 안전한 기본값이에요. 그리고 RestartSec을 너무 짧게 두면(예: 1초) 죽는 서비스가 1초 만에 살아나면서 CPU만 태우는 무한 루프가 됩니다.
5) Type=simple vs Type=forking을 헷갈리지 마세요. 대부분의 모던 앱은 포그라운드에서 도니까 simple입니다. 옛날 데몬처럼 자기가 fork해서 자식 띄우고 부모는 죽는 방식이면 forking이고, 이때는 PIDFile=을 같이 지정해야 합니다. 잘못 고르면 systemd가 프로세스 상태를 추적 못 합니다.
6) journalctl 로그가 안 보이면 Type=simple인지부터 확인. 표준출력이 자동으로 journal에 잡혀야 하는데, 가끔 StandardOutput=이 다르게 잡혀 있는 경우가 있어요. 위 예제처럼 명시적으로 journal로 지정해 두면 편합니다.
마무리
정리하면 세 줄입니다.
.service파일은[Unit],[Service],[Install]세 섹션이 핵심. 위치는/etc/systemd/system/.- 수정 후엔
daemon-reload→enable→start, 로그는journalctl -u <name> -f로 본다. ExecStart는 절대경로,User=는 전용 유저로,Restart=on-failure가 안전한 기본값.
'IT·리눅스 > 리눅스' 카테고리의 다른 글
| cron vs systemd timer - 정기 작업, 어느 쪽을 써야 하나 (0) | 2026.06.04 |
|---|---|
| 파일 디스크립터와 ulimit - \"Too many open files\" 정체 (0) | 2026.05.29 |
| [Linux] 시그널 - SIGTERM, SIGKILL, SIGHUP 제대로 구분하기 (0) | 2026.05.13 |
| [Linux] 리눅스 기본 명령어 정리! (0) | 2025.05.22 |
| VirtualBox 란 무엇인가? 나만의 가상 환경 구축하기 (0) | 2025.05.07 |