서버에서 상시 실행되는 스크립트나 데몬을 만들 때 가장 흔한 실수는 nohup ./script.sh &나 crontab의 @reboot 항목으로 급하게 띄우는 것이다. 이런 방식은 서버가 재부팅되거나 프로세스가 죽었을 때 아무도 재시작해주지 않고, 로그는 어딘가의 텍스트 파일에 흩어지며, 의존성이 있는 다른 서비스(예: DB가 뜬 다음에 실행돼야 하는 앱)와의 순서도 보장할 수 없다. Ubuntu를 포함한 대부분의 최신 배포판은 init 시스템으로 systemd를 쓰기 때문에, 프로세스 하나를 제대로 관리하려면 systemd 서비스 유닛(unit) 파일을 작성하는 게 정석이다. 이 글에서는 systemd 서비스 유닛 파일의 기본 구조부터 재시작 정책, 의존성 설정, 보안 강화 옵션까지 실전 예제로 정리한다.
서비스 유닛 파일의 기본 구조
systemd 서비스 유닛은 [Unit], [Service], [Install] 세 섹션으로 구성된 INI 형식 텍스트 파일이다. 파일은 보통 /etc/systemd/system/ 아래에 <서비스이름>.service로 저장한다.
[Unit]
Description=My Custom Worker Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/worker/worker.py
Restart=on-failure
RestartSec=5
User=worker
WorkingDirectory=/opt/worker
[Install]
WantedBy=multi-user.target[Unit]은 설명과 다른 유닛과의 순서/의존 관계를, [Service]는 실제 실행 방법을, [Install]은 enable 했을 때 어떤 target에 연결될지를 정의한다.
Type=은 systemd가 프로세스를 어떻게 관리할지 결정하는 핵심 옵션이다.
| Type | 동작 방식 | 적합한 경우 |
|---|---|---|
| simple | ExecStart 프로세스 자체를 메인 프로세스로 간주(기본값) | foreground로 계속 실행되는 일반 스크립트/서버 |
| forking | 프로세스가 fork 후 부모가 종료되는 전통적 데몬 | nginx, sshd 같은 클래식 데몬 |
| oneshot | 실행 후 종료되는 것을 정상으로 간주 | 초기화 스크립트, 백업 작업 등 1회성 작업 |
| notify | 프로세스가 sd_notify()로 준비 완료를 알림 | systemd 연동을 직접 구현한 프로그램 |
재시작 정책과 의존성 설정
운영 중 가장 중요한 옵션은 Restart=다. 아무 값도 지정하지 않으면 프로세스가 죽어도 systemd는 아무것도 하지 않는다.
[Service]
Restart=on-failure # 0이 아닌 종료 코드/시그널로 죽었을 때만 재시작
RestartSec=5 # 재시작 전 대기 시간
StartLimitIntervalSec=60 # 이 기간 동안
StartLimitBurst=3 # 3번 이상 재시작 시도하면 중단(무한 재시작 방지)Restart=on-failure는 정상 종료(exit code 0)나 systemctl stop으로 인한 종료는 재시작하지 않고, 크래시나 비정상 종료일 때만 재시작한다. Restart=always는 정상 종료 포함 모든 상황에서 재시작하므로 원치 않는 무한 루프가 될 수 있어 주의가 필요하다. StartLimitIntervalSec과 StartLimitBurst를 함께 설정하지 않으면, 설정 파일에 버그가 있어 즉시 죽는 프로세스가 초당 수십 번 재시작을 시도하며 CPU를 잡아먹는 상황이 발생할 수 있다.
다른 서비스와의 순서를 보장하려면 After=/Before=를, 실제 의존성(해당 유닛이 실패하면 이 유닛도 실패 처리)까지 강제하려면 Requires=를 쓴다.
[Unit]
Description=API Server depending on PostgreSQL
After=postgresql.service network-online.target
Requires=postgresql.service
Wants=network-online.targetAfter=만 쓰면 순서만 보장되고 postgresql이 실패해도 이 서비스는 계속 시도해서 뜬다. 실제로 DB가 없으면 의미가 없는 서비스라면 Requires=를 함께 써서 DB가 죽으면 이 서비스도 같이 내려가도록 하는 게 맞다. 반대로 Wants=는 대상이 실패해도 이 유닛의 시작을 막지 않는, 느슨한 권장 관계다.
cron 대체하기 — timer 유닛
정기적으로 실행되는 작업을 crontab 대신 systemd timer로 관리하면, 실행 로그가 journalctl로 통합되고 이전 실행이 끝나기 전에 다음 실행이 겹치지 않도록 막는 옵션도 기본 제공된다. timer는 같은 이름의 .service 유닛과 짝을 이룬다.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly Backup
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup.service daily at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.targetPersistent=true는 서버가 03:00에 꺼져 있어서 실행을 놓쳤을 경우, 다음 부팅 시 곧바로 밀린 작업을 실행해준다는 뜻이다. crontab에는 없는 기능이라 백업처럼 “놓치면 안 되는” 작업에 유용하다.
보안 강화 옵션
서비스 하나가 뚫렸을 때 시스템 전체로 피해가 번지지 않도록, systemd는 [Service] 섹션에서 프로세스 sandboxing 옵션을 다양하게 제공한다. 외부에서 요청을 받는 웹 서비스나 신뢰할 수 없는 입력을 처리하는 워커라면 아래 옵션들을 기본으로 켜두는 게 좋다.
[Service]
NoNewPrivileges=true # setuid 등으로 권한 상승 금지
PrivateTmp=true # 격리된 /tmp 사용
ProtectSystem=strict # /usr, /boot 등을 읽기 전용으로 마운트
ProtectHome=true # /home, /root 접근 차단
ReadWritePaths=/opt/worker/data # strict 모드에서 예외적으로 쓰기 허용할 경로
ProtectKernelTunables=true
ProtectControlGroups=trueProtectSystem=strict를 켜면 서비스 자체가 필요로 하는 쓰기 경로(로그, 데이터 디렉터리 등)까지 막힐 수 있으므로, ReadWritePaths=로 예외를 명시해야 한다. 옵션을 하나씩 켜고 systemctl restart 후 정상 동작을 확인하는 식으로 점진적으로 적용하는 편이 디버깅에 유리하다.
systemctl로 관리하고 로그 확인하기
유닛 파일을 작성했으면 아래 순서로 등록하고 실행한다.
# 유닛 파일 작성 후 systemd에 새 파일을 인식시킴
sudo systemctl daemon-reload
# 부팅 시 자동 시작 등록
sudo systemctl enable worker.service
# 즉시 시작
sudo systemctl start worker.service
# 상태 확인
sudo systemctl status worker.service
# 실시간 로그 확인
sudo journalctl -u worker.service -f
# 최근 로그 100줄만
sudo journalctl -u worker.service -n 100 --no-pager유닛 파일을 수정할 때마다 daemon-reload를 잊으면 systemd가 여전히 이전 버전의 설정을 쓰고 있는데도 겉으로는 정상 실행 중인 것처럼 보여서 헷갈리기 쉽다. 기존 유닛을 부분적으로만 조정하고 싶다면 파일을 직접 고치는 대신 systemctl edit worker.service로 override 파일을 만드는 방법도 있다. 이 경우 /etc/systemd/system/worker.service.d/override.conf에 변경할 항목만 남기 때문에, 패키지가 유닛 파일을 업데이트해도 커스터마이징이 덮어써지지 않는다.
주의사항
ExecStart=에는 반드시 절대 경로를 써야 한다. systemd는 서비스 프로세스에 로그인 셸의PATH를 상속하지 않는다.Type=simple인데 실제 프로그램이 시작 직후 fork해서 백그라운드로 빠지는 구조라면, systemd가 자식 프로세스를 추적하지 못해status가 곧바로 exited로 표시된다. 이런 경우Type=forking이나 프로그램 자체를 foreground 모드로 실행하는 옵션을 확인해야 한다.Restart=always+StartLimitBurst미설정 조합은 설정 오류가 있는 서비스를 무한 재시작 루프에 빠뜨릴 수 있다. 운영 환경에 적용하기 전에 반드시StartLimit*옵션을 함께 검토한다.- 환경 변수는
Environment=KEY=value한 줄씩 나열하거나, 여러 개면EnvironmentFile=/etc/worker/env로 별도 파일을 참조하는 편이 관리하기 쉽다.
마무리
systemd 서비스 유닛은 처음 문법을 익힐 때는 옵션이 많아 번거로워 보이지만, 일단 기본 템플릿 하나를 만들어두면 이후에는 ExecStart와 의존성 몇 줄만 바꿔가며 재사용할 수 있다. cron이나 nohup으로 임시로 띄워둔 스크립트가 있다면 이번 기회에 유닛 파일로 옮기고, 재시작 정책과 sandboxing 옵션까지 함께 챙겨두면 운영 중 장애 대응이 훨씬 수월해진다.