
서비스 · 프로세스 관리 25 / 50 · Part 3. systemd와 서비스
실습 환경: Rocky Linux 9.8 · Ubuntu 24.04.5 (systemd로 부팅한 Docker 격리 컨테이너, 테스트 계정analyst)
10편에서 "운영 작업은 nohup 대신 systemd 서비스로 관리하라"고 했다. 이번 글에서 그 방법을 직접 실습한다. 간단한 Python 웹 앱을 만들고, 이를 root가 아닌 전용 계정으로 실행되고, 죽으면 다시 살아나고, 부팅 시 자동으로 시작되는 systemd 서비스로 등록한다.
unit 파일의 각 설정이 무엇을 하는지 한 줄씩 이해하면, 남이 만든 서비스를 점검할 때도 "이 서비스는 무슨 권한으로 무엇을 실행하는가" 를 빠르게 읽을 수 있다. 관제에서 의심 unit을 분석하는 능력(46편)의 기초다.
| 설정 | 의미 | 기본값 |
|---|---|---|
Type= | 시작 완료를 판단하는 방식 | simple |
ExecStart= | 실행 명령 (절대 경로) | 필수 |
ExecStartPre= / ExecStartPost= | 시작 전/후 명령 | — |
ExecReload= / ExecStop= | reload·stop 시 명령 | — |
User= / Group= | 실행 계정 | root |
WorkingDirectory= | 작업 디렉터리 | / |
Environment= / EnvironmentFile= | 환경변수 / 환경변수 파일 | — |
Restart= | 재시작 정책 (29편) | no |
StandardOutput= | 출력 위치 | journal |
| Type | 시작 완료 시점 | 쓰는 경우 |
|---|---|---|
simple | fork 직후 (즉시) | 대부분의 현대 서비스 (포그라운드 실행) |
exec | exec 성공 후 | simple과 비슷, 실행 실패를 더 정확히 감지 |
forking | 부모 프로세스가 종료했을 때 | 스스로 데몬화하는 구형 프로그램 (19편), PIDFile= 필요 |
oneshot | 프로세스가 끝났을 때 | 일회성 작업, 설정 적용 스크립트 |
notify | 프로그램이 READY=1을 보낼 때 | sshd 등 systemd 연동 프로그램 |
ExecStart는 셸을 거치지 않고 직접 exec 된다. 그래서 |, >, &&, * 같은 셸 문법이 해석되지 않는다. 셸 문법이 필요하면 ExecStart=/bin/bash -c '...' 형태로 명시하되, 가능하면 별도 스크립트 파일로 분리한다.

systemctl enable --now labapp
① enable: multi-user.target.wants/labapp.service → /etc/systemd/system/labapp.service 링크
② start : PID 1 fork
→ 자식: setgid/setuid(analyst) → chdir(/opt/labapp) → 환경변수 설정
→ stdout/stderr 를 journald 로 연결 → execve(python3 app.py)
③ Type=simple → fork 직후 "active (running)"
④ 프로세스 출력 → journal (journalctl -u labapp)
계정 전환·디렉터리·환경변수·로그 연결을 systemd가 exec 직전에 처리 한다. 앱 코드에는 권한을 낮추는 코드가 필요 없다.
# 1) 앱 작성
mkdir -p /opt/labapp && cat > /opt/labapp/app.py <<'EOF'
import http.server, os, sys
port = int(os.environ.get("LAB_PORT", "8081"))
print(f"labapp start pid={os.getpid()} user={os.getuid()} port={port}", flush=True)
http.server.HTTPServer(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler).serve_forever()
EOF
# 2) unit 파일 작성
cat > /etc/systemd/system/labapp.service <<'EOF'
[Unit]
Description=Lab web app (python http.server)
After=network.target
[Service]
Type=simple
User=analyst
Group=analyst
WorkingDirectory=/opt/labapp
Environment=LAB_PORT=8081
ExecStart=/usr/bin/python3 /opt/labapp/app.py
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# 3) 검사 → 등록 → 확인
systemd-analyze verify /etc/systemd/system/labapp.service && echo "문법 검사 통과"
systemctl daemon-reload; systemctl enable --now labapp
systemctl status labapp --no-pager | head -9
ps -o pid,ppid,user,cmd -p $(systemctl show labapp -p MainPID --value)
python3 -c 'import urllib.request as u; print(u.urlopen("http://127.0.0.1:8081/").status)'
journalctl -u labapp --no-pager -o cat | tail -3
이 앱은 작업 디렉터리의 파일을 그대로 웹으로 노출 하는 http.server다. 실습용으로 127.0.0.1에만 바인딩했다. 외부 인터페이스에 열지 않는다.

텍스트 원본(실제 출력):
[root@rocky9-lab ~]# mkdir -p /opt/labapp && cat > /opt/labapp/app.py <<'EOF'
> import http.server, os, sys
> port = int(os.environ.get("LAB_PORT", "8081"))
> print(f"labapp start pid={os.getpid()} user={os.getuid()} port={port}", flush=True)
> http.server.HTTPServer(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler).serve_forever()
> EOF
[root@rocky9-lab ~]# cat > /etc/systemd/system/labapp.service <<'EOF'
> [Unit]
> Description=Lab web app (python http.server)
> After=network.target
>
> [Service]
> Type=simple
> User=analyst
> Group=analyst
> WorkingDirectory=/opt/labapp
> Environment=LAB_PORT=8081
> ExecStart=/usr/bin/python3 /opt/labapp/app.py
> Restart=on-failure
>
> [Install]
> WantedBy=multi-user.target
> EOF
[root@rocky9-lab ~]# systemd-analyze verify /etc/systemd/system/labapp.service && echo "문법 검사 통과"
문법 검사 통과
[root@rocky9-lab ~]# systemctl daemon-reload; systemctl enable --now labapp; sleep 1
Created symlink /etc/systemd/system/multi-user.target.wants/labapp.service → /etc/systemd/system/labapp.service.
[root@rocky9-lab ~]# systemctl status labapp --no-pager | head -9
● labapp.service - Lab web app (python http.server)
Loaded: loaded (/etc/systemd/system/labapp.service; enabled; preset: disabled)
Active: active (running) since Thu 2026-09-24 12:10:36 UTC; 1s ago
Main PID: 2892 (python3)
Tasks: 1 (limit: 51293)
Memory: 8.6M
CGroup: /docker/5a46f907edb23397a00c52e8214c9c387f1cf283dd50c13b81f6c01a58252f3b/system.slice/labapp.service
└─2892 /usr/bin/python3 /opt/labapp/app.py
[root@rocky9-lab ~]# ps -o pid,ppid,user,cmd -p $(systemctl show labapp -p MainPID --value)
PID PPID USER CMD
2892 1 analyst /usr/bin/python3 /opt/labapp/app.py
[root@rocky9-lab ~]# python3 -c 'import urllib.request as u; print(u.urlopen("http://127.0.0.1:8081/").status)'
200
[root@rocky9-lab ~]# journalctl -u labapp --no-pager -o cat | tail -3
Started Lab web app (python http.server).
labapp start pid=2892 user=1000 port=8081
127.0.0.1 - - [24/Sep/2026 12:10:37] "GET / HTTP/1.1" 200 -
| 관찰 | 의미 |
|---|---|
systemd-analyze verify 통과 | 오타·잘못된 설정·없는 실행 파일을 적용 전에 잡아 준다 |
Created symlink .../multi-user.target.wants/labapp.service | [Install]의 WantedBy에 따라 enable 링크가 생겼다 |
Loaded: ...; enabled; preset: disabled | 관리자가 enable했고, 배포판 preset 정책에는 없는 서비스다 |
Active: active (running), Main PID: 2892 | simple 타입이라 fork 직후 running으로 표시된다 |
CGroup: .../system.slice/labapp.service | 서비스 전용 cgroup이 생겼다 (18편) |
PPID 1, USER analyst | PID 1이 실행했고, root가 아닌 analyst 권한 으로 동작한다 |
HTTP 응답 200 | 서비스가 정상 동작 |
journal labapp start pid=2892 user=1000 port=8081 | 앱의 print 출력이 journal로 수집되었다. Environment=의 포트 값이 전달되었다 |
접근 로그 "GET / HTTP/1.1" 200 | 앱이 stderr로 쓰는 접근 로그도 journal에 남는다 |
| 주제 | 내용 |
|---|---|
| 최소 권한 | User= 없이 등록하면 root로 실행 된다. 웹 앱 취약점이 곧 root 탈취가 된다. 전용 계정(가능하면 로그인 불가 계정, 30편)을 쓴다 |
| 실행 파일 권한 | ExecStart 파일이나 WorkingDirectory를 서비스 계정이 쓸 수 있으면 코드 변조 → 재시작 시 실행이 가능하다. 코드는 root 소유, 읽기 전용으로 둔다 |
| 비밀 값 | Environment=에 비밀번호를 넣으면 systemctl show로 누구나 볼 수 있다. EnvironmentFile=(권한 600) 또는 LoadCredential=을 쓴다 |
| 바인딩 주소 | 내부용 서비스는 127.0.0.1에 바인딩한다. 16편의 ss -tlnp로 확인 |
| 샌드박싱 | NoNewPrivileges=, ProtectSystem= 등으로 더 좁힐 수 있다 (38편) |
unit 파일을 분석 대상 으로 읽을 때의 체크리스트다.
[의심 unit 발견] systemctl cat suspicious.service
↓
[읽는 순서]
① ExecStart / ExecStartPre / ExecStartPost → 무엇을 실행하나? 경로가 /tmp·/dev/shm·숨김 디렉터리?
② User / Group → 없음 = root
③ Restart / RestartSec → 죽여도 다시 살아나는가?
④ [Install] WantedBy → 부팅 시 자동 시작되는가?
⑤ Description → 정상 서비스를 흉내 낸 이름인가?
↓
[확인] ls -l --time-style=full-iso <unit 파일> <ExecStart 파일>
rpm -qf <unit 파일> (패키지 소유 여부)
| 악성 unit의 전형 | 정상 unit의 전형 |
|---|---|
ExecStart=/bin/bash -c "curl ... \| bash" | ExecStart=/usr/sbin/서비스명 ... |
| User= 없음 (root) | 전용 서비스 계정 |
Restart=always, RestartSec=짧게 | Restart=on-failure |
| 패키지 미소유, 최근 생성 | 패키지 소유 |
| 실수 | 결과 | 예방 |
|---|---|---|
| ExecStart에 상대 경로 | 로드 실패 | 절대 경로 |
ExecStart에 &&, > 사용 | 인자로 넘어가 오작동 | 스크립트 파일로 분리 |
앱이 스스로 데몬화하는데 Type=simple | 시작 직후 "종료됨"으로 판단 | 포그라운드 옵션 사용 또는 Type=forking |
| unit 수정 후 daemon-reload 누락 | 이전 설정으로 동작 | 수정 → daemon-reload → restart |
| User= 없이 등록 | root 실행 | 전용 계정 지정 |
[ ] [Unit] / [Service] / [Install] 세 섹션으로 unit 파일을 작성했다
[ ] systemd-analyze verify 로 문법을 검사했다
[ ] enable --now 로 등록과 시작을 한 번에 했다
[ ] 서비스가 analyst 계정, PPID 1 로 실행되는 것을 확인했다
[ ] Environment 값이 앱에 전달된 것을 로그로 확인했다
[ ] 의심 unit 을 ExecStart → User → Restart → Install 순서로 읽을 수 있다
[Unit](설명·순서), [Service](실행 방법), [Install](부팅 연결)로 구성된다.Type=simple이 기본이며, 앱은 포그라운드로 실행하면 된다.User=를 지정하지 않으면 root로 실행된다. 최소 권한 계정을 지정한다.다음 글 「26. Unit 의존성과 실행 순서」 에서는 Requires, Wants, After, Before의 차이를 다룬다. 의존성(무엇을 함께 띄우나)과 순서(누가 먼저인가)가 별개 라는 점을, 느린 DB 서비스와 앱 서비스로 실험해 확인한다.