서버에 올린 앱이 재부팅 후 자동으로 다시 뜨지 않아서 매번 수동으로 켜야 했습니다. nohup으로 실행한 프로세스는 서버가 재시작되면 사라지고, 크래시가 나도 아무도 모릅니다. 이런 상황에서 새벽에 서버가 재부팅됐다면? 아침에 출근해서야 서비스가 죽어있음을 발견하게 됩니다.
systemd 서비스로 등록하면 부팅 시 자동 시작, 크래시 시 자동 재시작, 로그 통합 관리가 모두 해결됩니다. 운영 서버에 앱을 배포할 때 반드시 거치는 과정입니다.
systemd 서비스 관리
1. Unit 파일 구조와 서비스 타입
- 1systemd Unit 파일 구조([Unit]/[Service]/[Install])를 이해하고 서비스를 직접 작성할 수 있다
- 2systemctl로 서비스를 시작·중지·활성화하고 상태를 확인할 수 있다
- 3journalctl로 서비스 로그를 조회하고 필터링할 수 있다
- 4Override 파일로 패키지 기본 unit 설정을 덮어쓰고 Timer Unit으로 cron을 대체할 수 있다
- 5크래시 자동 재시작 정책과 보안 샌드박스 옵션을 설정할 수 있다
systemctl --versionsystemctl --failedjournalctl --disk-usagels /etc/systemd/system/systemd Unit 파일 구조
앱을 systemd 서비스로 등록하려면 어디에 무엇을 써야 하는지 알아야 합니다. 설정 파일 하나의 위치와 내용이 "부팅 시 자동 시작되는 서비스"와 "수동으로 켜야 하는 프로세스"의 차이를 만듭니다. Unit 파일은 크게 세 섹션([Unit], [Service], [Install])으로 구성되고, 각 섹션이 서비스의 다른 측면을 정의합니다. 이 구조를 한 번 이해하면 nginx, postgresql 같은 패키지가 설치한 unit 파일을 읽고 의도를 파악할 수 있고, 직접 서비스를 작성할 때도 막히지 않습니다.
서비스는 Unit 파일로 정의됩니다. 위치: /etc/systemd/system/ (운영자 정의) 또는 /lib/systemd/system/ (패키지 기본값)
확대
[Unit]
Description=My App Service
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server --port 8080
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=/etc/myapp/env
[Install]
WantedBy=multi-user.target
핵심 디렉티브:
After: 이 서비스가 시작되기 전에 시작되어야 할 유닛 (순서만, 의존성 아님)Requires: 강한 의존성 — 해당 유닛이 실패하면 이 서비스도 중단Wants: 약한 의존성 — 해당 유닛이 실패해도 이 서비스는 계속 시작Restart=always: 어떤 이유로든 종료 시 자동 재시작EnvironmentFile: 환경 변수를 파일에서 로드 (시크릿 관리에 유용)
서비스 타입 선택 기준:
Type=은 systemd가 서비스의 "준비 완료" 신호를 어떻게 판단하는지를 지정합니다. 대부분의 현대 앱은 simple이 맞고, 포크 방식 데몬이나 D-Bus 서비스는 별도 타입이 필요합니다.
| Type | 동작 | 사용 예 |
|---|---|---|
simple | ExecStart가 메인 프로세스 | 대부분의 현대 앱 |
forking | ExecStart가 fork 후 종료 | 전통적인 데몬 |
notify | 준비 완료를 sd_notify()로 알림 | systemd-aware 앱 |
oneshot | 단발성 실행, 종료 후 active 유지 | 초기화 스크립트 |
dbus | D-Bus 버스 이름 획득 시 준비 완료 | D-Bus 서비스 |
부팅 때 유닛이 순서대로 올라오는 법 — 타깃·의존성·활성화
전원을 켜면 유닛이 어떤 순서로 올라오나 — 수집·정렬·활성화 6단계
systemctl enable만 해두면 재부팅 후 서비스가 알아서 뜹니다. 그런데 그 "알아서"는 실제로는 systemd(PID 1)가 시작 목표(default.target)를 정하고 → 딸린 유닛을 그러모으고 → 순서를 매겨 → 병렬로 활성화하는 정해진 절차입니다. 이 흐름을 알면 "enable 했는데 왜 부팅에 안 뜨지", "왜 DB보다 내 앱이 먼저 떠서 죽지", "부팅이 왜 이렇게 느리지"를 단계로 좁혀 진단할 수 있습니다. 부팅은 마법이 아니라 아래 6단계의 결과입니다.
[전원 ON] → 커널 → PID 1 (systemd)
│
① default.target 결정 (graphical.target 또는 multi-user.target)
│
② 딸린 유닛 수집 (Wants=/Requires=/WantedBy= 를 재귀로 펼쳐 '시작할 유닛 집합' 확정)
│ → enable = target.wants/ 에 심볼릭 링크 생성 → 이 수집에 포함됨
│
③ 순서 정렬 (After=/Before= 로 부분 순서 부여, 순서 지정 없는 유닛끼리는 병렬)
│
④ 병렬 활성화 (순서 제약이 풀린 유닛부터 동시에 ExecStart)
│
⑤ Type별 '올라옴' 판정
│ simple=exec 즉시 · forking=부모 종료+PIDFile · oneshot=명령 완료
│ notify=sd_notify READY=1 · (+ExecStartPost 까지 성공해야 완료)
│
⑥ 활성화 완료 → 뒤 순서 유닛 해제 (After= 로 기다리던 유닛들이 이어서 시작)
▼
[default.target 도달] ← 묶인 유닛이 모두 기동되면 부팅 완료
각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 막히면 |
|---|---|---|
| ① 타깃 결정 | systemd가 부팅 종착점을 정한다. systemctl get-default 로 확인 | 서버인데 graphical.target로 잡히면 불필요한 GUI 스택까지 끌어옴 · 원하는 서비스 세트가 안 뜸 |
| ② 유닛 수집 | 타깃의 Wants=/Requires= 와 각 유닛의 WantedBy= 를 재귀로 펼쳐 '시작할 집합'을 확정. enable 은 target.wants/ 에 심볼릭 링크를 만들어 이 단계에 편입시키는 것 | [Install] 이 없거나 enable 을 안 했으면 링크가 없어 집합에서 아예 빠짐 → 부팅 시 안 뜸(수동 start만 됨) |
| ③ 순서 정렬 | After=/Before= 로 부분 순서만 부여(의존성 아님). 순서 없는 유닛끼리는 병렬 대상 | After= 를 서로 걸면 ordering cycle → systemd가 한쪽을 끊고 Found ordering cycle 경고, 끊긴 유닛이 안 뜰 수 있음 |
| ④ 병렬 활성화 | 순서 제약이 없는 유닛을 동시에 ExecStart. 그래서 부팅이 빠름 | After= 로 묶인 앞 유닛이 느리면 그 유닛을 기다리는 체인 전체가 지연 → 부팅 지연 |
| ⑤ Type 판정 | Type별로 '시작 완료' 기준이 다름 — simple은 exec 즉시, forking은 부모 종료+PIDFile, oneshot은 명령 종료, notify는 READY=1 수신, ExecStartPost 까지 성공해야 active | forking인데 PIDFile 오지정 → 죽어도 active 오판 · notify인데 READY=1 미전송 → TimeoutStartSec(기본 90s)까지 activating에 멈췄다 실패 |
| ⑥ 순서 해제·연쇄 | 앞 유닛이 활성되면 After= 로 기다리던 뒤 유닛을 시작. Requires= 대상이 실패하면 이 유닛도 연쇄 실패, Wants= 면 계속 진행 | 앞 유닛이 activating에 걸리면 뒤 체인 전체 대기 · Requires= 대상 실패 시 dependency failed로 연쇄 중단 |
socket·timer 유닛은 조금 다릅니다 — 이들은 부팅 때 자기 자신만 활성화되고(리스닝 소켓을 열거나 타이머를 건다), 짝이 되는 .service 는 첫 연결이 오거나 시각이 되면 그때 활성화됩니다(지연 시작). 그래서 sockets.target·timers.target 에 묶어두면 서비스 본체는 평소 안 떠 있어도 됩니다.
정리하면 "부팅 시 자동 시작"은 세 가지가 모두 참이라는 뜻입니다 — 유닛이 수집 집합에 들어갔고(②, enable), 순서 사이클이 없고(③), Type 기준으로 기동이 완료됐다(⑤). 안 뜰 때는 systemctl list-dependencies 로 수집·순서 그래프를, systemd-analyze critical-chain 으로 직렬 병목을, systemctl list-jobs 로 지금 무엇을 기다리는지를 봅니다. 예를 들어 is-enabled 가 disabled 면 ②에서 빠진 것이고, critical-chain에 내 앱 앞 유닛이 길게 잡히면 ④·⑥의 순서 지연입니다.
2. 기본 제어 명령
실습 전 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/linux/part2/exam_9 && cd /tmp/linux/part2/exam_9
# 실습용 애플리케이션 스크립트 생성
cat > /tmp/linux/part2/exam_9/myapp.sh << 'EOF'
#!/bin/bash
echo "MyApp PID: $$"
while true; do
echo "$(date): running" >> /tmp/linux/part2/exam_9/app.log
sleep 5
done
EOF
chmod +x /tmp/linux/part2/exam_9/myapp.sh
이제 실습을 진행합니다.
# 서비스 상태 확인 (가장 자주 쓰는 명령)
systemctl status nginx
# 시작 / 중지 / 재시작 / 재로드
systemctl start nginx
systemctl stop nginx
systemctl restart nginx
systemctl reload nginx # 설정만 재로드 (무중단)
# 부팅 시 자동 시작 설정
systemctl enable nginx
systemctl disable nginx
# 활성화 + 즉시 시작 (가장 편한 방법)
systemctl enable --now nginx
# 서비스 완전히 멈추고 재시작 금지 (마스킹)
systemctl mask nginx
systemctl unmask nginx
systemctl status nginx- systemctl status nginx 출력에서 Active: active (running)이 초록색으로 표시되면 서비스가 정상 동작 중이다
- systemctl enable nginx 실행 후 /etc/systemd/system/multi-user.target.wants/nginx.service 심볼릭 링크가 생성된다
- systemctl is-enabled nginx 명령이 'enabled'를 반환하면 부팅 시 자동 시작이 설정된 것이다
- systemctl mask nginx 후 systemctl start nginx 시도 시 'Failed to start nginx.service: Unit is masked.' 에러가 출력된다
# 실행 중인 모든 서비스 목록
systemctl list-units --type=service
# 모든 유닛 (실패 포함)
systemctl list-units --type=service --all
# 실패한 서비스만
systemctl --failed
# 부팅 시 활성화된 서비스 목록
systemctl list-unit-files --type=service --state=enabled
# 특정 서비스의 의존성 트리 확인
systemctl list-dependencies nginx
# 역방향: 이 서비스에 의존하는 유닛
systemctl list-dependencies --reverse nginx
# unit 파일 내용 바로 확인 (편집기 없이)
systemctl cat nginx
출력 예시:
UNIT LOAD ACTIVE SUB DESCRIPTION
nginx.service loaded active running A high performance web server
postgresql.service loaded active running PostgreSQL RDBMS
myapp.service loaded failed failed My Application Service
LOAD 컬럼이 not-found이면 unit 파일이 없는 것입니다. ACTIVE가 failed이면 journalctl -u 서비스명 -n 50으로 원인을 확인하세요.
systemctl list-units --type=service- 읽기 순서: systemctl list-units 출력은 ACTIVE → SUB → LOAD 순으로 본다. ACTIVE=failed 인 줄을 먼저 찾고, SUB=dead 면 중지 상태, SUB=failed 면 오류로 종료된 것이다
- systemctl --failed 출력이 '0 loaded units listed' 이면 현재 실패 서비스가 없는 정상 상태다
- LOAD=not-found 이면 unit 파일이 아예 없다 — systemctl cat <서비스명> 으로 존재 여부를 확인하고 없으면 패키지를 먼저 설치해야 한다
- systemctl list-dependencies nginx 출력에서 빨간 점(●)이 보이면 해당 의존성이 실패 상태 — 서비스가 시작 안 될 때 의존성 트리부터 확인하라
3. 로그 조회 (journalctl)
# 특정 서비스 전체 로그
journalctl -u nginx
# 실시간 로그 스트리밍 (tail -f와 동일)
journalctl -u nginx -f
# 최근 50줄
journalctl -u nginx -n 50
# 오늘 로그만
journalctl -u nginx --since today
# 특정 시간대
journalctl -u nginx --since "2026-03-25 10:00" --until "2026-03-25 12:00"
# 에러 레벨 이상만 (0=emerg ~ 7=debug)
journalctl -u nginx -p err
# JSON 출력 (로그 파이프라인에 유용)
journalctl -u nginx -o json-pretty | head -30
# 부팅 이후 로그만
journalctl -u nginx -b
# 이전 부팅 로그 (-1 = 직전 부팅)
journalctl -u nginx -b -1
journalctl -u nginx -f- 읽기 순서: journalctl 출력은 타임스탬프 → 호스트명 → 서비스명 → 메시지 순이다. 먼저 마지막 줄(최신 로그)을 보고, ERR 또는 WARN 레벨 줄을 역방향으로 추적한다
- journalctl -u nginx -p err 실행 후 아무것도 출력되지 않으면 에러 레벨 로그가 없는 정상 상태다 — 출력이 있다면 해당 에러 메시지를 복사해 구글 검색 또는 nginx 에러 로그 대조
- journalctl --disk-usage 결과가 1G 이상이면 오래된 로그가 쌓인 것 — journalctl --vacuum-time=30d 로 30일 이전 로그를 정리할 수 있다 (정기 점검 기준: 500MB 이상이면 정리 고려)
- journalctl -u nginx -b -1 이 'Specifying boot ID or offset has no effect' 오류를 내면 journald가 영구 저장 모드가 아닌 것 — /etc/systemd/journald.conf 에서 Storage=persistent 설정 필요
4. 나만의 서비스 Unit 파일 작성
# 1. Unit 파일 작성
sudo vim /etc/systemd/system/myapp.service
[Unit]
Description=My Application Server
Documentation=https://github.com/myorg/myapp
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server --port 8080
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
# 환경 변수
Environment=NODE_ENV=production
EnvironmentFile=-/etc/myapp/env # 앞의 -는 파일 없어도 오류 안 냄
# 표준 출력/에러를 journald로
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.target
# 2. systemd에 변경사항 반영 (파일 수정 후 항상 실행)
sudo systemctl daemon-reload
# 3. 서비스 시작 및 활성화
sudo systemctl enable --now myapp
# 4. 동작 확인
systemctl status myapp
journalctl -u myapp -f
StartLimitBurst=3 + StartLimitIntervalSec=60: 60초 안에 3번 재시작 실패하면 더 이상 재시작하지 않습니다. 무한 재시작 루프를 방지하는 중요한 설정입니다.
sudo systemctl daemon-reload- systemctl status myapp 출력에서 Active: active (running) since <시각>이 보이면 서비스가 정상 시작된 것이다 — Active: activating (start) 상태가 30초 이상 지속되면 ExecStart 경로나 권한 문제 의심
- systemctl enable --now myapp 실행 후 Created symlink /etc/systemd/system/multi-user.target.wants/myapp.service 메시지가 나오면 자동 시작 등록 완료
- journalctl -u myapp -f 로 로그를 보다가 같은 에러 메시지가 5초마다 반복되면 Restart=on-failure 가 재시작 루프에 빠진 것 — systemctl stop myapp 으로 먼저 중지 후 원인 분석
- 판단 기준 — daemon-reload 없이 unit 파일 수정 후 restart 하면 변경 전 설정으로 동작한다. 파일 수정 후에는 daemon-reload가 필수다
5. Drop-in Override 파일
Override 파일로 패키지 기본값 덮어쓰기
확대
패키지 관리자가 설치한 unit 파일(/lib/systemd/system/)을 직접 수정하면 패키지 업데이트 시 덮어써집니다. Drop-in override를 사용하면 원본을 건드리지 않고 특정 설정만 재정의할 수 있습니다.
# 권장 방법: systemctl edit (자동으로 경로 생성)
sudo systemctl edit nginx
# 직접 생성하는 방법
sudo mkdir -p /etc/systemd/system/nginx.service.d/
sudo vim /etc/systemd/system/nginx.service.d/override.conf
override 파일 예시 — nginx에 메모리 제한과 재시작 정책 추가:
[Service]
# 기존 값을 초기화하려면 빈 값으로 먼저 선언 후 재정의
Restart=
Restart=always
RestartSec=3
# 리소스 제한 추가
MemoryMax=512M
CPUQuota=50%
# 변경 적용
sudo systemctl daemon-reload
sudo systemctl restart nginx
# override 파일 확인 (원본 + override 합쳐진 결과)
systemctl cat nginx
systemctl cat은 적용된 모든 설정을 파일 출처와 함께 보여줍니다. 설정이 어디서 왔는지 추적할 때 필수입니다.
6. 리소스 제한과 서비스 하드닝
프로덕션 서비스 보안 설정
확대
컨테이너 없이 일반 systemd 서비스로 운영하던 API 서버가 취약점을 통해 침해되면, 프로세스는 루트 권한으로 파일 시스템 전체를 읽고 쓸 수 있는 상태가 됩니다. /etc/passwd, SSH 키, 데이터베이스 설정 파일까지 노출됩니다. systemd unit 파일에 보안 설정 몇 줄만 추가했더라면, 서비스가 접근 가능한 경로와 권한이 처음부터 제한되어 피해 범위를 크게 줄일 수 있었습니다. Docker나 Kubernetes 없이도 커널의 네임스페이스와 capability 제한 기능을 systemd가 직접 활성화해줍니다.
systemd는 Linux 커널의 보안 기능을 unit 파일에서 직접 활성화할 수 있습니다. 컨테이너 없이도 서비스 격리가 가능합니다.
실제 프로덕션 unit 파일 — 웹 API 서버:
[Unit]
Description=Production API Server
After=network-online.target postgresql.service
Requires=postgresql.service
[Service]
Type=notify
User=apiserver
Group=apiserver
WorkingDirectory=/opt/apiserver
ExecStart=/opt/apiserver/bin/api-server
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=5
TimeoutStartSec=30
TimeoutStopSec=30
# 환경 변수 (시크릿은 별도 파일로)
EnvironmentFile=/etc/apiserver/secrets.env
# -- 리소스 제한 --
# CPU: 최대 2코어 분량
CPUQuota=200%
# 메모리: 소프트 한도 512M, 하드 한도 768M
MemoryHigh=512M
MemoryMax=768M
# 오픈 파일 디스크립터 한도
LimitNOFILE=65536
# 프로세스 수 제한
LimitNPROC=512
# 파일 크기 제한 (코어 덤프 방지)
LimitCORE=0
# -- 보안 하드닝 --
# setuid/setgid 비트 실행 금지
NoNewPrivileges=yes
# /usr, /boot, /etc를 읽기 전용으로
ProtectSystem=strict
# /home, /root 접근 차단
ProtectHome=yes
# 서비스 전용 /tmp (다른 서비스와 격리)
PrivateTmp=yes
# /dev 접근 최소화
PrivateDevices=yes
# 커널 튜닝 파라미터 변경 금지
ProtectKernelTunables=yes
# 커널 모듈 로드 금지
ProtectKernelModules=yes
# 시스템 콜 필터 (화이트리스트 방식)
SystemCallArchitectures=native
# 쓰기 가능한 경로 명시적 허용
ReadWritePaths=/var/lib/apiserver /var/log/apiserver
[Install]
WantedBy=multi-user.target
각 하드닝 디렉티브의 효과:
각 설정이 어떤 공격 경로를 차단하는지 알면, 서비스 특성에 맞게 취사선택할 수 있습니다.
| 디렉티브 | 차단하는 공격 |
|---|---|
NoNewPrivileges=yes | SUID 바이너리를 통한 권한 상승 |
ProtectSystem=strict | 시스템 파일 변조 |
PrivateTmp=yes | /tmp를 통한 다른 서비스 공격 |
ProtectHome=yes | 홈 디렉토리 내 시크릿 파일 접근 |
PrivateDevices=yes | 원시 디바이스 파일 접근 |
ProtectKernelTunables=yes | /proc/sys 변조 |
# 서비스 보안 점수 분석 (0에 가까울수록 격리 강함)
systemd-analyze security myapp
# 출력 예시:
# NAME DESCRIPTION EXPOSURE
# ✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
# ✓ NoNewPrivileges= Service processes cannot acquire new privileges 0.0
# ✓ PrivateTmp= Service has access to private /tmp 0.0
# ...
# → Overall exposure level for myapp.service: 4.2 MEDIUM
# unit 파일 문법 검증 (배포 전 항상 실행)
systemd-analyze verify /etc/systemd/system/myapp.service
# 서비스에 실제 적용된 리소스 제한 확인
systemctl show myapp | grep -E 'Memory|CPU|Limit'
systemd-analyze security myapp- systemd-analyze security myapp 결과에서 Overall exposure level 숫자를 먼저 본다 — 0.0~4.0 SAFE/OK, 4.1~7.0 MEDIUM(개선 권장), 7.1~10.0 EXPOSED(즉시 조치)
- ✗ 표시 줄이 많아도 패닉할 필요 없다 — PrivateTmp, NoNewPrivileges, ProtectSystem 세 가지만 설정해도 점수가 크게 낮아진다. 이 세 가지를 먼저 추가하라
- systemd-analyze verify 실행 시 출력이 없으면 문법 오류가 없는 것이다 — 출력이 있으면 그 줄이 오류다. daemon-reload 전에 반드시 실행해서 잘못된 unit 파일 배포를 방지하라
- systemctl show myapp | grep MemoryMax 결과가 infinity 이면 메모리 제한 미설정 상태 — 메모리 누수 서비스가 호스트 전체를 다운시킬 수 있다
7. systemd Timer (cron 대체)
Timer Unit으로 cron 대체하기
확대
cron으로 등록한 배치 작업이 실패했는데 로그를 찾을 수 없어서, "실행이 됐는지조차 모르는" 상황이 있습니다. cron은 출력을 메일로 보내거나 직접 파일에 리다이렉트하지 않으면 로그가 없습니다. systemd Timer는 일반 서비스처럼 journalctl로 실행 로그를 볼 수 있고, 놓친 실행 보완(Persistent=yes), 의존성 지정, 다음 실행 시간 조회(systemctl list-timers) 등 cron에 없는 기능을 제공합니다. 기존 cron 작업을 Timer로 전환하면 운영 가시성이 크게 높아집니다.
cron 대비 systemd Timer의 장점:
journalctl로 실행 로그 통합 관리- 의존성 지정 가능 (네트워크 연결 후 실행 등)
- 놓친 실행 보완 (
Persistent=yes) systemctl list-timers로 다음 실행 시간 한눈에 확인
Timer unit은 항상 동명의 .service unit과 쌍으로 동작합니다.
OnCalendar 문법:
OnCalendar=는 cron 표현식과 유사하지만 더 읽기 쉬운 형식을 씁니다. 단축어(daily, weekly)로도 쓸 수 있고, 날짜/시각을 정밀하게 지정할 수도 있습니다.
*-*-* HH:MM:SS 매일 특정 시각
Mon-Fri *-*-* 09:00 평일 오전 9시
*-*-1 00:00 매월 1일 자정
weekly 매주 월요일 자정 (단축어)
daily 매일 자정 (단축어)
hourly 매시간 정각 (단축어)
모노토닉 타이머 (상대 시간):
OnCalendar 대신 모노토닉 타이머를 쓰면 절대 시각 대신 "부팅 후" 또는 "마지막 실행 후" 기준으로 간격을 지정할 수 있습니다.
OnBootSec=15min # 부팅 후 15분
OnUnitActiveSec=1h # 마지막 실행 후 1시간
OnActiveSec=10min # 타이머 활성화 후 10분
백업 작업을 매일 새벽 2시에 실행하는 타이머 예제입니다.
1단계: 서비스 유닛 작성
sudo vim /etc/systemd/system/db-backup.service
[Unit]
Description=PostgreSQL Daily Backup
After=postgresql.service
Requires=postgresql.service
# Timer에서 실행될 때는 [Install] 섹션 불필요
[Service]
Type=oneshot
User=backup
Group=backup
# 백업 스크립트 실행
ExecStart=/usr/local/bin/db-backup.sh
# 보안 하드닝
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/backups/postgresql
# 실패 시 알림 (선택)
OnFailure=notify-admin@.service
2단계: 타이머 유닛 작성
sudo vim /etc/systemd/system/db-backup.timer
[Unit]
Description=Daily PostgreSQL Backup Timer
Requires=db-backup.service
[Timer]
# 매일 새벽 2시 실행
OnCalendar=*-*-* 02:00:00
# 서버가 꺼진 동안 놓친 실행을 부팅 후 즉시 보완
Persistent=yes
# 정확히 2시가 아닌 ±30분 내 랜덤 지연 (서버 부하 분산)
RandomizedDelaySec=30min
# 함께 시작할 서비스 명시
Unit=db-backup.service
[Install]
WantedBy=timers.target
3단계: 활성화 및 확인
# 타이머 활성화 (.service가 아닌 .timer를 enable)
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer
# 모든 타이머 목록 및 다음 실행 시간 확인
systemctl list-timers
# 출력 예시:
# NEXT LEFT LAST PASSED UNIT
# Thu 2026-03-27 02:00:00 KST 9h left Wed 2026-03-26 02:00:12 KST 13h ago db-backup.timer
# 타이머 대기하지 않고 즉시 테스트 실행
sudo systemctl start db-backup.service
# 실행 로그 확인
journalctl -u db-backup.service -n 30
Persistent=yes 없이는 서버가 새벽 2시에 꺼져 있었다면 그날 백업이 영구 누락됩니다. 백업 타이머에서는 항상 설정하세요.
systemctl list-timers- 읽기 순서: systemctl list-timers 출력은 NEXT → LEFT → UNIT 순으로 본다. LEFT 값이 very long ago 이면 타이머가 꺼진 상태다
- LAST 컬럼이 n/a 이면 한 번도 실행되지 않은 것 — Persistent=yes 없이 서버가 재시작됐거나 타이머 자체가 disable 상태인지 확인
- 조합 해석 — PASSED 가 1일 이상이고 LAST 가 n/a 이면: 정해진 시각에 실행됐어야 하지만 서버 재시작으로 놓쳐진 것. Persistent=yes 추가가 필요하다
- 즉시 테스트 후 journalctl -u db-backup.service -n 30 에서 'Main process exited, code=exited, status=0/SUCCESS' 가 마지막 줄이면 성공적으로 실행된 것이다
8. 부팅 시간 분석 (systemd-analyze)
# 총 부팅 시간 요약
systemd-analyze
# 출력 예시:
# Startup finished in 1.842s (kernel) + 3.291s (initrd) + 8.103s (userspace) = 13.237s
# graphical.target reached after 7.812s in userspace
# 각 서비스별 시작 소요 시간 (느린 순)
systemd-analyze blame
# 출력 예시:
# 3.201s postgresql.service
# 1.893s cloud-init.service
# 1.241s NetworkManager-wait-online.service
# 892ms snapd.service
# ...
# 직렬 의존성 체인 (실제 병목 구간)
systemd-analyze critical-chain
# 특정 서비스까지의 체인
systemd-analyze critical-chain nginx.service
# SVG 타임라인 차트 생성 (브라우저로 열기)
systemd-analyze plot > /tmp/boot-timeline.svg
# unit 파일 검증 (문법 오류 탐지)
systemd-analyze verify /etc/systemd/system/myapp.service
# 보안 점수 (앞서 소개)
systemd-analyze security myapp.service
critical-chain은 총 부팅 시간에 직접 영향을 주는 의존성 체인만 보여줍니다. blame에서 느린 서비스를 발견해도 critical-chain에 없으면 병렬 실행 중이라 실제 병목이 아닐 수 있습니다.
systemd-analyze blame- 읽기 순서: systemd-analyze 출력은 kernel → initrd → userspace 세 구간을 보고, userspace 숫자가 크면 서비스 레벨 문제다. 5s 이하가 빠른 편, 15s 이상이면 개선 필요
- systemd-analyze blame 상위 3개 서비스 중 NetworkManager-wait-online.service 가 있으면 masking 제거 대상 — 네트워크 연결 대기가 아닌 경우 sudo systemctl disable NetworkManager-wait-online 으로 제거 가능
- 조합 해석 — blame 에서 느린 서비스인데 critical-chain 에 없으면: 병렬 실행 중이라 실제 부팅 시간에 영향 없음. critical-chain 에 있는 서비스만 최적화 대상이다
- systemd-analyze plot > /tmp/boot.svg 생성 후 브라우저로 열면 타임라인 시각화 — 병렬 vs 직렬 서비스 시작 패턴을 한눈에 파악할 수 있다
9. 소켓 활성화 (Socket Activation)
소켓 활성화 — 연결이 올 때만 서비스 시작
확대
평소에는 거의 사용되지 않는 백오피스 API나 내부 관리 툴을 항상 메모리에 올려둘 필요가 없을 때가 있습니다. 소켓 활성화는 서비스를 항상 켜두는 대신, 첫 번째 연결이 들어올 때 systemd가 자동으로 서비스를 시작하는 방식입니다. 부팅 시간을 단축할 수 있고, 서비스 재시작 중에도 소켓이 연결을 큐잉해서 요청을 잃지 않는 장점도 있습니다. Docker Desktop, DBus, SSH를 포함한 많은 시스템 서비스가 이 방식으로 동작합니다.
소켓 활성화는 서비스를 항상 실행하는 대신, 소켓에 첫 연결이 들어올 때 systemd가 서비스를 자동으로 깨우는 방식입니다.
클라이언트 요청 → 소켓 (.socket 유닛이 리스닝) → 서비스 자동 시작 → 처리
장점:
- 메모리 절약 (유휴 상태에서 서비스 미실행)
- 부팅 시간 단축 (필요할 때 지연 시작)
- 서비스 재시작 중에도 연결 큐잉 (무중단)
# myapp.socket
[Unit]
Description=My App Socket
[Socket]
ListenStream=8080
Accept=no
[Install]
WantedBy=sockets.target
# myapp.service — [Install] 섹션 없음 (소켓이 활성화하므로)
[Unit]
Description=My App Service
[Service]
ExecStart=/opt/myapp/bin/server
StandardInput=socket
# 소켓만 활성화 (서비스는 연결 시 자동 시작)
sudo systemctl enable --now myapp.socket
SSH, DBus, 많은 시스템 서비스가 이 방식으로 동작합니다.
10. journald 로그 보존 설정
journald 보존 정책 및 크기 제한
확대
디스크 사용률 경보가 울려 확인해보면 /var/log/journal이 수십 GB를 차지하고 있는 경우가 있습니다. 설정을 하지 않은 journald는 가용 디스크의 10%까지 로그를 계속 쌓고, 어느 날 갑자기 서비스가 로그를 쓰지 못해 오동작하거나 디스크 풀로 SSH 접속조차 안 되는 상황이 생깁니다. 로그 보존 정책은 서비스 초기 세팅 시 빠트리기 쉬운 항목이지만, 미리 한도를 잡아두면 이런 상황을 완전히 막을 수 있습니다.
기본 설정으로는 디스크가 가득 찰 때까지 로그가 쌓입니다. 프로덕션 서버에서는 명시적으로 한도를 설정해야 합니다.
sudo vim /etc/systemd/journald.conf
[Journal]
# 저장 방식: persistent(디스크), volatile(메모리), auto(기본)
Storage=persistent
# 최대 디스크 사용량 (전체 로그)
SystemMaxUse=2G
# 파일 하나의 최대 크기
SystemMaxFileSize=200M
# 보존 기간 (오래된 로그 자동 삭제)
MaxRetentionSec=30day
# 압축 여부
Compress=yes
# Rate limiting: 30초에 서비스당 최대 10000 메시지
RateLimitIntervalSec=30s
RateLimitBurst=10000
# 설정 적용
sudo systemctl restart systemd-journald
# 현재 로그 디스크 사용량 확인
journalctl --disk-usage
# 수동으로 오래된 로그 삭제
sudo journalctl --vacuum-size=1G # 1G 이하로 줄이기
sudo journalctl --vacuum-time=14d # 14일 이상 된 로그 삭제
# 특정 부팅 ID 목록
journalctl --list-boots
로그 수집 스택(ELK, Loki 등)을 운영한다면 MaxRetentionSec을 짧게 유지해도 됩니다. 로컬 로그는 장애 초기 대응용으로만 사용하기 때문입니다.
11. 실패 디버깅
원인: Type=simple로 설정된 서비스에서 fork 후 부모 프로세스가 바로 종료되는 경우. systemd는 원래 PID를 추적하므로 자식 프로세스가 죽어도 active 상태를 유지합니다.
확인:
systemctl status myapp
# 상태는 active(running)이지만 실제 동작 없음
# 실제 프로세스 확인
ps aux | grep myapp
# PID 추적
systemctl show myapp --property=MainPID
해결:
[Service]
# fork하는 데몬의 경우
Type=forking
PIDFile=/var/run/myapp.pid
# 또는 앱이 sd_notify를 지원하면
Type=notify
# 헬스체크로 실제 상태 보장
ExecStartPost=/usr/bin/curl -sf http://localhost:8080/health
흔한 원인 3가지:
- 환경 변수 없음: systemd 서비스는
/etc/environment와~/.bashrc를 읽지 않음 - 현재 디렉토리 없음:
WorkingDirectory지정 필요 - 권한 문제:
User=지정한 사용자가 실행 파일이나 로그 경로에 접근 불가
디버깅 순서:
# 1. 상세 에러 확인
journalctl -u myapp.service -n 50 --no-pager
# 2. 해당 사용자로 직접 실행 테스트
sudo -u myapp /opt/myapp/bin/server
# 3. 적용된 환경 변수 확인
systemctl show myapp | grep -E 'Exec|Environment|WorkingDir'
# 4. unit 파일 문법 검증
systemd-analyze verify /etc/systemd/system/myapp.service
# 5. SELinux/AppArmor 거부 여부
ausearch -m avc -ts recent | grep myapp
# 또는
journalctl -k | grep apparmor
증상: systemctl list-timers에 나타나지만 LAST 실행이 없거나, 예상 시각에 실행되지 않음.
확인 체크리스트:
# 1. 타이머 유닛 상태 확인 (.service가 아닌 .timer!)
systemctl status db-backup.timer
# 2. 연결된 서비스 유닛 상태
systemctl status db-backup.service
# 3. OnCalendar 문법 검증
systemd-analyze calendar "*-*-* 02:00:00"
# 출력:
# Original form: *-*-* 02:00:00
# Normalized form: *-*-* 02:00:00
# Next elapse: Thu 2026-03-27 02:00:00 KST
# (in UTC): Wed 2026-03-26 17:00:00 UTC
# 4. 서비스 직접 실행으로 스크립트 오류 확인
sudo systemctl start db-backup.service
journalctl -u db-backup.service -n 20
# 5. Persistent=yes 설정 여부 확인 (놓친 실행 보완)
systemctl cat db-backup.timer | grep Persistent
systemd-analyze calendar 명령으로 OnCalendar 문자열을 검증하면 잘못된 문법을 배포 전에 잡을 수 있습니다. 타임존도 함께 확인하세요.
증상: journalctl -u myapp에서 Memory limit exceeded 또는 Killed 메시지.
# 현재 메모리 사용량 확인
systemctl show myapp | grep -i memory
# cgroup 레벨에서 실제 사용량
cat /sys/fs/cgroup/system.slice/myapp.service/memory.current
# OOM 이벤트 커널 로그
journalctl -k | grep -i oom | tail -20
# 실시간 모니터링
systemd-cgtop
해결 접근:
[Service]
# 1. 한도 자체를 올림
MemoryMax=1G
# 2. 소프트/하드 구분하여 여유 확보
MemoryHigh=768M # 이 값 초과 시 메모리 회수 압력
MemoryMax=1G # 이 값 초과 시 OOM 킬
# 3. 스왑 사용 허용
MemorySwapMax=256M
심화 — systemd가 말하는 상태와 실제
심화: 'started'는 준비 완료도, 무한 재시작도 아니다 — 순서·준비성·재시작 한계
status가 active라고, 유닛에 After=를 적었다고, Restart=always를 걸었다고 안심하기 쉽습니다. 그런데 systemd가 말하는 상태는 우리가 기대하는 것과 미묘하게 다릅니다. 이 간극을 알면 재시작이 왜 멈추는지, 의존 서비스가 왜 아직 준비 안 됐는지가 설명됩니다.
- After=는 순서일 뿐, 준비 완료가 아니다:
After=postgresql.service는 postgres 유닛이 systemd 기준으로 시작된 뒤에 내 서비스를 시작하라는 순서 지정일 뿐, postgres가 연결을 받을 준비가 됐다는 보장이 아닙니다. 특히Type=simple은 프로세스를 exec 하는 순간 곧바로 시작됨으로 간주되므로, 의존 서비스가 아직 포트를 안 열었는데 내 앱이 붙으려다 실패할 수 있습니다. 준비성은 순서가 아니라Type=notify(앱이 sd_notify로 READY=1을 보냄)나 앱 자체의 재시도·헬스체크로 보장해야 합니다.network.target(주소 할당 완료가 아님)과network-online.target(실제 온라인)의 차이도 같은 함정입니다. - Restart=always도 무한이 아니다: 재시작 정책과 별개로, systemd에는 시작 rate limiter가 있습니다.
StartLimitIntervalSec(기본 10초) 안에StartLimitBurst(기본 5회)보다 자주 시작이 반복되면, systemd는 그 유닛을 포기하고failed로 둔 채 더는 재시작하지 않습니다 — 로그에start request repeated too quickly가 남습니다. 즉 빠르게 크래시-루프하는 서비스는 Restart=always여도 몇 초 만에 죽은 채 방치됩니다. 견디게 하려면RestartSec로 재시작 간격을 벌려 버스트에 안 걸리게 하거나, StartLimit 값을 워크로드에 맞게 조정합니다. - active는 healthy가 아니다(다음 단계): systemd는 기본적으로 프로세스의 생존만 압니다. 앱이 실제로 요청을 처리하는지까지 보장하려면
WatchdogSec+sd_notify(앱이 주기적으로 살아있음을 알리지 않으면 systemd가 재시작)나ExecStartPost헬스체크로 앱이 스스로 건강을 증명하게 합니다.
정리하면 systemd의 상태는 프로세스 수명주기에 대한 것이고, 준비성·건강함은 앱이 신호로 알려줘야 하는 별개의 층입니다. 이 경계를 알면 순서·재시작 설정이 왜 기대대로 안 도는지 바로 짚습니다.
상황: 죽으면 자동으로 살아나게 하려고 Restart=always를 설정했습니다. 그런데 앱이 기동 직후 계속 크래시하는 상황이 되자, systemd가 몇 초간 재시작을 반복하다가 아예 멈춰 버렸습니다. systemctl status는 failed이고, systemctl start를 다시 쳐도 바로 실패합니다.
원인: 재시작 정책과 별개로 동작하는 systemd의 시작 rate limiter입니다. StartLimitIntervalSec(기본 10초) 안에 StartLimitBurst(기본 5회)를 넘겨 시작이 반복되면, systemd는 이 유닛을 포기하고 failed로 둔 채 Restart 설정을 무시합니다. 로그에 start request repeated too quickly, refusing to start가 남습니다. Restart=always는 조건이 맞으면 재시작하겠다는 의사일 뿐, 이 상한을 넘지는 못합니다. 즉 크래시 루프가 너무 빠르면 자동 재시작이 스스로를 차단합니다.
진단: journalctl -u myapp에서 start request repeated too quickly와 그 직전의 실제 크래시 원인(exit code·스택·의존성 연결 실패)을 함께 확인합니다 — 핵심은 rate limit은 증상이고 반복 크래시의 진짜 원인이 따로 있다는 것입니다. systemctl show myapp -p StartLimitIntervalUSec -p StartLimitBurst -p RestartUSec로 현재 상한과 간격을 봅니다. 리미터에 걸려 멈춘 상태는 systemctl reset-failed myapp으로 카운터를 지워야 다시 start가 먹습니다.
해결: 두 갈래입니다. (1) 근본 — 왜 계속 크래시하는지를 고칩니다(대개 준비 안 된 의존성·설정 오류·권한). (2) 완충 — RestartSec=5s처럼 재시작 간격을 벌려 버스트 상한에 안 걸리게 하고, 필요하면 StartLimitIntervalSec·StartLimitBurst를 워크로드에 맞게 늘립니다. 무한 즉시 재시작(RestartSec=0 + 높은 Burst)은 오히려 CPU를 태우며 크래시 루프를 가리므로, 간격을 두고 근본 원인을 로그로 남기는 편이 낫습니다. 이 값들은 [Unit] 섹션(StartLimit*)과 [Service] 섹션(Restart*)에 나뉘어 있으니 위치를 헷갈리지 않게 합니다.
12. 실무 맥락
실무에서 새 버전을 배포할 때 다운타임 없이 서비스를 교체하는 패턴입니다.
# 1. 새 바이너리 배치
cp /tmp/myapp-v2 /opt/myapp/bin/server.new
chmod +x /opt/myapp/bin/server.new
chown myapp:myapp /opt/myapp/bin/server.new
# 2. unit 파일 변경이 있으면 반드시 reload
sudo systemctl daemon-reload
# 3. 설정에 ExecReload가 있으면 reload (SIGHUP으로 graceful reload)
systemctl reload myapp 2>/dev/null || systemctl restart myapp
# 4. 배포 후 헬스체크 (실패 시 롤백)
for i in 1 2 3 4 5; do
sleep 2
if curl -sf http://localhost:8080/health; then
echo "배포 성공"
exit 0
fi
done
# 5. 헬스체크 실패 시 롤백
echo "헬스체크 실패, 롤백 시작"
cp /opt/myapp/bin/server.bak /opt/myapp/bin/server
systemctl restart myapp
exit 1
핵심: Restart=on-failure + RestartSec=3 조합으로 크래시 자동 복구. StartLimitBurst=3으로 무한 재시작 루프 방지. Drop-in override로 환경별 설정 분리 (개발/스테이징/프로덕션).
팀에서 crontab으로 관리하던 작업들이 분산되어 추적이 어려운 상황에서 systemd Timer로 일원화하는 과정입니다.
# 현재 cron 목록 파악
crontab -l
sudo crontab -l
cat /etc/cron.d/*
# 예시 cron 항목:
# 0 2 * * * /usr/local/bin/db-backup.sh >> /var/log/backup.log 2>&1
# 마이그레이션 후 검증
systemctl list-timers --all
# cron과 timer 동시 실행 방지: cron 항목 주석 처리 후
# timer가 안정적으로 실행됨을 1주일 확인 후 cron 삭제
마이그레이션 이점:
- 로그가
journalctl -u db-backup으로 중앙 집중 - 실행 실패 시
OnFailure=디렉티브로 알림 자동화 systemctl list-timers로 다음 실행 시간 즉시 확인- 서비스에 리소스 제한과 보안 하드닝 적용 가능
주의: Persistent=yes를 설정하지 않으면 서버가 실행 시각에 꺼져 있던 경우 그 실행은 영구 누락됩니다. 백업, 정산 같은 중요 작업에는 반드시 설정하세요.
명령어·단축키 빠른 참조
이 모듈에서 다룬 systemctl 제어·유닛 관리·로그 조회 명령을 실전 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
systemctl status <svc> | 서비스 상태 확인 | systemctl status nginx (가장 자주 씀) |
systemctl start/stop/restart | 서비스 제어 | sudo systemctl restart nginx |
systemctl reload | 무중단 설정 재적용 | sudo systemctl reload nginx |
systemctl enable --now | 부팅 자동시작 + 즉시 시작 | sudo systemctl enable --now myapp |
systemctl mask | 서비스 완전 차단(start도 막음) | sudo systemctl mask <svc> |
systemctl list-units --type=service | 유닛 목록 탐색 | systemctl --failed (실패한 것만) |
systemctl list-dependencies | 의존성 트리 확인 | systemctl list-dependencies nginx |
systemctl cat <svc> | 유닛 파일 원본 바로 보기 | 편집기 없이 원본+override 확인 |
systemctl daemon-reload | 유닛 파일 수정 후 반영 | 파일 편집 뒤 항상 실행 |
systemctl edit <svc> | Drop-in override 생성 | sudo systemctl edit nginx (패키지 기본값 덮기) |
journalctl -u <svc> | 서비스 로그 조회 | journalctl -u myapp -p err -b |
관련 모듈로 더 깊이:
- 프로세스 관리와 작업 제어 — 서비스가 실제로 띄우는 프로세스를 ps·kill로 다루는 기초
- cron으로 리눅스 주기적 반복 작업(배치) 예약 및 백업 자동화 — systemd timer와 대비되는 전통적 스케줄링 방식 이해
- journalctl로 모든 커널/서비스 로그 검색 및 실시간 모니터링 — 서비스가 남긴 로그를 journalctl로 분석하는 법
다음 모듈에서는 텍스트 처리(Text Processing)를 다룹니다 — grep, awk, sed, sort, uniq로 로그를 분석하고 파이프라인을 구성하는 방법을 배웁니다.