Systemd Unit 서비스 운영 심화

왜 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=notifysd-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 파일은 패키지 업데이트 시에도 유지되므로, 운영 환경에서 설정을 변경할 때 항상 이 방식을 사용하세요.

관련 글

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux