왜 Systemd Unit을 직접 작성하는가?
대부분의 애플리케이션은 패키지 매니저가 제공하는 기본 Unit 파일로 충분합니다. 하지만 직접 빌드한 바이너리, Node.js/Java 애플리케이션, 사이드카 프로세스 등은 커스텀 Unit 파일이 필요합니다. Systemd Unit을 제대로 이해하면 프로세스 관리, 자동 복구, 리소스 제한, 의존성 제어를 OS 레벨에서 안정적으로 처리할 수 있습니다.
Unit 파일 기본 구조
Systemd Unit 파일은 /etc/systemd/system/에 위치하며, INI 형식의 섹션으로 구성됩니다.
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp API Server
Documentation=https://docs.example.com
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=notify
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/.env
ExecStartPre=/opt/myapp/migrate.sh
ExecStart=/opt/myapp/bin/server --port 3000
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=30s
WatchdogSec=60s
[Install]
WantedBy=multi-user.target
- [Unit]: 서비스 설명과 의존성 정의.
After는 시작 순서,Requires는 필수 의존성 - [Service]: 실행 방식, 사용자, 재시작 정책 등 핵심 설정
- [Install]:
systemctl enable시 어떤 타겟에 연결할지 지정
Service Type 선택 가이드
Type은 Systemd가 프로세스의 “준비 완료”를 판단하는 방식을 결정합니다. 잘못 선택하면 서비스가 시작됐는데 Systemd는 타임아웃으로 실패 처리합니다.
| Type | 동작 방식 | 적합한 경우 |
|---|---|---|
simple |
ExecStart 실행 즉시 “시작됨” 판단 | 포그라운드 실행 프로세스 (Node.js, Go) |
exec |
바이너리 exec() 성공 시 “시작됨” | simple과 유사, exec 실패 감지 필요 시 |
notify |
sd_notify(“READY=1”) 호출 시 “시작됨” | 초기화 시간이 긴 앱 (DB 연결, 캐시 워밍 등) |
forking |
부모 프로세스 종료 후 자식이 메인 | 전통적 데몬 (nginx, apache) |
oneshot |
프로세스 종료까지 “시작 중”, 완료 후 “시작됨” | 마이그레이션, 초기화 스크립트 |
Node.js 앱이라면 Type=simple이 기본이지만, Type=notify와 sd-notify 라이브러리를 조합하면 DB 연결 완료 후에만 “준비됨”을 알릴 수 있어 더 안전합니다.
Restart 정책과 Rate Limiting
서비스 크래시 시 자동 복구는 Systemd의 핵심 기능입니다.
[Service]
Restart=on-failure
RestartSec=3s
StartLimitIntervalSec=300
StartLimitBurst=5
- Restart=on-failure: exit code ≠ 0일 때만 재시작 (SIGTERM 정상 종료는 제외)
- Restart=always: 어떤 이유로 종료되든 재시작 (컨테이너 런타임 스타일)
- RestartSec: 재시작 전 대기 시간 (너무 짧으면 CPU 낭비)
- StartLimitBurst/IntervalSec: 300초 내 5회 초과 재시작 시 서비스 중단 (무한 루프 방지)
실전에서는 RestartSec=5s + StartLimitBurst=5 조합이 균형 잡힌 설정입니다. 너무 빠른 재시작은 DB 연결 폭주를 유발하고, 너무 느리면 다운타임이 길어집니다.
리소스 제한: cgroup v2 통합
Systemd는 Linux cgroup v2와 직접 통합되어, Unit 파일에서 CPU·메모리·I/O 제한을 선언적으로 설정할 수 있습니다.
[Service]
# CPU: 최대 200% (2코어), 최소 50% 보장
CPUQuota=200%
CPUWeight=100
# 메모리: 최대 2GB, 1.5GB 초과 시 경고
MemoryMax=2G
MemoryHigh=1500M
# I/O: 디스크 대역폭 제한
IOReadBandwidthMax=/dev/sda 50M
IOWriteBandwidthMax=/dev/sda 30M
# 프로세스 수 제한
TasksMax=256
# OOM 킬 우선순위 (낮을수록 보호)
OOMScoreAdjust=-500
- MemoryMax: 하드 리밋. 초과하면 OOM Killer가 프로세스를 종료
- MemoryHigh: 소프트 리밋. 초과하면 메모리 회수 압력 증가 (throttle)
- CPUQuota: 100% = 1코어. 멀티코어 사용 시 200%, 400% 등으로 설정
- OOMScoreAdjust: -1000(절대 보호) ~ 1000(우선 종료). 중요 서비스는 음수로 설정
보안 샌드박싱
Systemd는 컨테이너 없이도 강력한 프로세스 격리를 제공합니다.
[Service]
# 파일시스템 보호
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp/data /var/log/myapp
PrivateTmp=true
# 네트워크 제한
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
IPAddressDeny=any
IPAddressAllow=10.0.0.0/8 172.16.0.0/12
# 커널 기능 제한
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
# 시스템콜 필터링
SystemCallFilter=@system-service
SystemCallArchitectures=native
- ProtectSystem=strict: 전체 파일시스템을 읽기 전용으로 마운트,
ReadWritePaths만 쓰기 허용 - NoNewPrivileges: 실행 중 권한 상승 차단 (setuid 바이너리 무효화)
- SystemCallFilter: 허용된 시스템콜만 실행 가능 (seccomp 기반)
- CapabilityBoundingSet: 1024 미만 포트 바인딩만 허용하는 등 세밀한 권한 제어
systemd-analyze security myapp.service 명령으로 보안 점수를 확인할 수 있습니다. 10점 만점에 낮을수록 안전합니다.
Watchdog: 헬스체크 기반 자동 복구
Type=notify와 Watchdog을 결합하면, 프로세스가 살아있지만 응답하지 않는(hang) 상황도 감지합니다.
[Service]
Type=notify
WatchdogSec=30s
# 30초마다 sd_notify("WATCHDOG=1")을 보내야 함
# 미전송 시 Systemd가 프로세스를 강제 종료 후 재시작
Node.js에서 Watchdog 구현:
import { notify } from 'sd-notify';
// 앱 초기화 완료 후
notify.ready();
// 주기적 헬스체크 + Watchdog 핑
setInterval(async () => {
const healthy = await checkDatabaseConnection();
if (healthy) {
notify.watchdog(); // Systemd에 "살아있음" 알림
}
// healthy가 false면 watchdog 미전송 → Systemd가 재시작
}, 15000); // WatchdogSec의 절반 주기로 전송
이 패턴은 데드락, 이벤트 루프 블로킹, DB 연결 끊김 등 프로세스는 살아있지만 서비스 불가능한 상태를 자동으로 복구합니다.
실전 Node.js 서비스 Unit 파일
지금까지의 모든 개념을 적용한 프로덕션 레벨 Unit 파일입니다.
[Unit]
Description=Production API Server
After=network-online.target postgresql.service redis.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=notify
User=api
Group=api
WorkingDirectory=/opt/api
# 환경 변수
EnvironmentFile=/opt/api/.env
Environment=NODE_ENV=production
Environment=NODE_OPTIONS="--max-old-space-size=1536"
# 실행
ExecStartPre=/usr/bin/node -e "require('./dist/main')"
ExecStart=/usr/bin/node dist/main.js
ExecReload=/bin/kill -USR2 $MAINPID
# 재시작 정책
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=600
StartLimitBurst=5
# Watchdog
WatchdogSec=30s
# 종료
TimeoutStopSec=30s
KillMode=mixed
KillSignal=SIGTERM
# 리소스 제한
MemoryMax=2G
MemoryHigh=1536M
CPUQuota=200%
TasksMax=512
# 보안
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/api/data /var/log/api
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
# 로깅
StandardOutput=journal
StandardError=journal
SyslogIdentifier=api-server
[Install]
WantedBy=multi-user.target
운영 명령어 정리
# 서비스 관리
systemctl daemon-reload # Unit 파일 변경 후 반드시 실행
systemctl enable --now myapp # 활성화 + 즉시 시작
systemctl status myapp # 상태 확인
systemctl restart myapp # 재시작
# 로그 조회
journalctl -u myapp -f # 실시간 로그
journalctl -u myapp --since "1h ago" # 최근 1시간
journalctl -u myapp -p err # 에러만 필터
# 디버깅
systemd-analyze security myapp # 보안 점수 분석
systemctl show myapp --property=MemoryCurrent # 현재 메모리 사용량
systemd-cgtop # cgroup별 리소스 모니터링
# 오버라이드 (원본 수정 없이 설정 덮어쓰기)
systemctl edit myapp # drop-in 파일 생성
# /etc/systemd/system/myapp.service.d/override.conf
systemctl edit으로 생성하는 drop-in 파일은 패키지 업데이트 시에도 유지되므로, 운영 환경에서 설정을 변경할 때 항상 이 방식을 사용하세요.
관련 글
- Docker Compose 심화 운영 — 컨테이너 기반 서비스 관리와 비교
- Linux 서버 초기 보안 설정 7단계 — Systemd 보안 샌드박싱과 함께 적용할 서버 하드닝