본문으로 건너뛰기

"운영" 태그로 연결된 29개 게시물개의 게시물이 있습니다.

모든 태그 보기

PG18 autovacuum 슬롯

· 약 6분

PostgreSQL 18에서 autovacuum_max_workers가 드디어 SIGHUP로 받아지는 파라미터가 됐어요. 정확히는 한 파라미터가 둘로 쪼개졌어요 — 시작 시 한 번 예약하는 autovacuum_worker_slots(재시작 필요)와 그 범위 안에서 자유롭게 움직이는 autovacuum_max_workers(reload만으로 적용)예요. 운영자가 vacuum 부하를 보다가 worker 수를 늘리려고 maintenance window를 잡아야 했던 시대가 끝났어요.

이 글은 The Build의 Christophe Pettus가 "All Your GUCs in a Row" 시리즈에서 다룬 autovacuum_worker_slots 편을 한국어로 풀고, 함정 한두 가지를 더 짚습니다.

왜 max_workers는 재시작 파라미터였나

PostgreSQL의 autovacuum launcher는 postmaster가 띄우는 백그라운드 프로세스입니다. launcher가 깨운 worker는 별도 프로세스로 동작하는데, 이 worker들이 자리 잡을 shared memory 구조는 postmaster가 시작될 때 한 번에 예약됩니다. autovacuum_max_workers가 그 크기를 결정했고, 따라서 변경하려면 재시작이 필요했습니다.

이 설정이 운영에서 만든 압박은 두 가지입니다.

첫째, 초기 과다 provisioning. "혹시 모르니 worker 16개 정도 확보해두자"는 식의 보수적 설정이 일반적이었습니다. 평소엔 절반도 안 쓰면서 shared memory를 점유합니다.

둘째, 워크로드 변동 대응 실패. 새 큰 테이블 N개를 한꺼번에 적재하는 야간 배치를 추가하면 vacuum 부담이 갑자기 커집니다. worker를 5개에서 10개로 늘리고 싶어도 그건 다음 maintenance window에서나 가능했습니다. 그동안 dead tuple은 쌓이고, bloat는 자라고, query latency는 흔들립니다.

"The cost of being wrong is a SIGHUP, not an outage." — Christophe Pettus, The Build

PostgreSQL 18은 이 비용을 SIGHUP 한 번으로 줄였습니다.

PG18의 worker_slots와 max_workers 분리

원래 하나였던 GUC가 둘로 쪼개집니다.

  • autovacuum_worker_slots: postmaster가 시작할 때 예약할 shared memory worker slot의 개수이며 재시작 파라미터입니다.
  • autovacuum_max_workers: 위 slot 범위 안에서 실제로 동시에 깨어 있을 수 있는 worker의 상한이며 SIGHUP로 받아집니다.

쉽게 말하면 worker_slots주차장 크기, max_workers는 지금 동시에 받을 차의 수입니다. 주차장은 미리 지어둬야 하지만, 진입 제한은 그때그때 바꾸면 됩니다.

Before / After

파라미터PostgreSQL 17 이하PostgreSQL 18
worker 상한 GUC 이름autovacuum_max_workersautovacuum_max_workers (역할 변경)
공유 메모리 예약 GUC없음 (= max_workers가 결정)autovacuum_worker_slots
재시작 필요worker_slots 만 예 / max_workers는 SIGHUP
기본값max_workers = 3worker_slots = 16, max_workers = 3
변경 비용maintenance windowreload

기본값이 worker_slots = 16으로 잡혀 있는 점에 주목할 만합니다. "어차피 사후에 늘리지 못 하니 처음부터 넉넉히 잡아두자"는 의도된 권장입니다.

구조도

worker_slots는 부팅 때 한 번 결정되고, max_workers는 SIGHUP로 그때그때 위아래로 움직입니다.

동작 메커니즘

세 가지를 짚어두면 충분합니다.

  1. slot 예약은 postmaster 시작 시 한 번: worker_slots = 16이면 shared memory에 16개의 worker 자리가 잡힙니다. 이 크기는 실행 중 바꿀 수 없습니다.
  2. max_workers > slots는 자동 capping: worker_slots = 16인데 max_workers = 20으로 reload하면 PostgreSQL은 16으로 capping하고 서버 로그에 경고를 남깁니다. 에러가 아니어서 reload는 성공하지만, 의도대로 동작하지 않는다는 신호입니다.
  3. max_workers는 SIGHUP로 즉시 반영: pg_reload_conf() 한 번이면 다음 worker 사이클부터 새 상한이 적용됩니다.

maintenance window 없는 운영 튜닝 흐름

새 흐름은 단순합니다.

설치할 때 worker_slots는 "최악의 상황에서 필요할 worker 수"에 맞춰 넉넉히 잡습니다. 16이면 대부분 충분하며, shared memory 비용은 worker 1개당 수 KB 수준이라 부담이 작습니다.

평상시에는 max_workers를 보수적으로 설정합니다(예: 3~5). vacuum이 늦지 않으면 그대로 유지합니다. 큰 테이블 적재, partition 증가, dead tuple 누적이 보이면 postgresql.conf에서 max_workers를 올리고 pg_reload_conf()를 실행합니다. 다음 vacuum 사이클부터 worker가 늘어납니다. 부하가 진정되면 다시 max_workers를 내리고 reload하며, shared memory는 그대로 두면 됩니다.

이 흐름에서 중요한 변화는 부하 증가에 대응하는 3번째 단계가 SIGHUP라는 점입니다. vacuum 부하를 더 받기 위해 데이터베이스를 내리고 다시 띄울 필요가 없습니다.

함정과 주의사항

크게 셋입니다.

첫째, worker_slots의 hard ceiling. 설치 시점에 worker_slots를 짜게 잡으면 사후에 그 위로 올릴 수 없습니다. 16이 적당해 보여도, partition 1만 개에 야간 배치까지 도는 환경이라면 32로 잡아두는 편이 낫습니다. shared memory 예약 비용이 vacuum 지연 비용보다 훨씬 쌉니다.

둘째, worker_slots를 거꾸로 줄이고 싶을 때. 재시작이 필요합니다. "지금 max_workers = 5로 줄어들었으니 slot도 8 정도면 충분하지 않을까"는 자연스러운 생각이지만, 그 효과는 다음 재시작 때만 봅니다. 평소에는 그냥 두고, 다른 재시작 사유가 생겼을 때 함께 조정하는 게 실무적입니다.

셋째, parallel autovacuum과의 관계. PostgreSQL 18에서는 같은 시기에 autovacuum_max_parallel_workers도 들어왔습니다. 한 vacuum이 인덱스 정리를 위해 추가로 끌어쓰는 worker는 별도 카운팅이라, 동시에 도는 worker 수를 셀 때는 둘을 함께 봐야 합니다. 자세한 내용은 같은 저자의 "Parallel Autovacuum: It's Not About The CPU"에 잘 정리돼 있습니다.

넷째, max_workers를 올릴 때의 메모리 곱셈. worker_slots가 차지하는 shared memory는 slot 1개당 대략 5~20KB 수준입니다 — PGPROC 슬롯, PgBackendStatus 엔트리, LWLock 같은 자투리를 합산한 값입니다. worker_slots = 16으로 잡아도 총 수백 KB 안쪽이라 셋업 부담은 거의 없습니다. 하지만 slot이 깨어나서 실제 vacuum을 도는 동안에는 별도 비용이 붙습니다. autovacuum_work_mem(미설정 시 maintenance_work_mem, 기본 64MB)이 worker 프로세스의 private memory로 잡힙니다. max_workers = 16으로 올린 상태에서 16개가 동시에 도는 순간 OS RSS에 약 1GB가 추가된다는 뜻입니다. 정리하면 worker_slots는 넉넉히 키워도 무방하지만, max_workers는 RAM과 maintenance_work_mem의 곱셈을 같이 보고 결정합니다. "주차장은 크게, 동시 진입은 천천히"가 기준입니다.

maintenance window라는 비용

OLTP 클러스터를 운영해 봤다면 maintenance window의 무게를 압니다. 사용자에게 공지를 띄우고, 야간 새벽 시간대를 잡고, 운영자 두세 명이 대기하는 비용입니다. autovacuum_max_workers 하나 늘리겠다고 그 비용을 다 치르기는 어렵습니다. 그래서 한참을 견딥니다 — dead tuple이 쌓이고, 큰 테이블이 bloat로 1.5배쯤 부풀고, query plan이 흔들리기 시작한 다음에야 다음 정기 점검에 끼워 넣습니다. PostgreSQL 18의 분리는 이 견디는 구간이 사라진다는 뜻입니다. dead tuple 알람이 뜨면 그날 안에 worker를 두세 개 더 풀어 vacuum을 따라잡게 한 뒤, 부하가 가라앉으면 다시 줄여둡니다. 운영자 입장에서는 vacuum 튜닝이 처음으로 "회의 없이 가능한 일"이 됐습니다.

정리

PG17 이하PG18
worker 수 변경 비용restartSIGHUP
초기 셋업 부담"충분히 크게" 1회 결정worker_slots만 크게, max_workers는 작게
워크로드 변동 대응다음 maintenance window다음 reload
운영 사고 빈도bloat 누적이 잦음즉시 대응 가능

PostgreSQL 18은 "worker 풀은 사전에 크게, 사용량은 그때그때"라는 평범한 운영 패턴을 vacuum에도 들여왔어요. 작은 변화 같지만 운영 자동화 관점에서는 큰 차이예요. 한밤중 dead tuple 누적 알림에 더 이상 maintenance window를 잡지 않아도 돼요.

참고 자료

Barman 보존 정책

· 약 6분

정책 한 줄이 디스크와 PITR을 결정한다

보존 정책을 잘못 잡으면 두 방향 중 하나로 사고가 납니다. 너무 좁게 잡아서 PITR을 원하는 시점이 이미 사라져 있는 경우이거나, 너무 넓게 잡아서 Barman 서버 디스크가 폭발하는 경우입니다. 둘 다 운영에서 흔히 만나는 시나리오입니다.

이 글에서는 Barman의 두 가지 보존 정책인 REDUNDANCY nRECOVERY WINDOW OF n DAYS를 같은 lab에서 차례로 적용하며 어떤 backup이 언제 사라지는가를 직접 확인합니다. 처음 보는 독자도 따라올 수 있게 환경을 짧게 정리합니다.

호스트역할Barman 라벨
demo-pg01PostgreSQL 17 primary
demo-barman01Barman 3.18 서버pg01

환경 셋업이 처음이라면 2편을 먼저 보고 오는 게 빠릅니다.

두 정책 한눈에

항목REDUNDANCY nRECOVERY WINDOW OF n DAYS
기준보유할 backup 개수보장할 PITR 기간
문법 예REDUNDANCY 5RECOVERY WINDOW OF 4 WEEKS
적합 환경backup 빈도가 고정이고 작은 클러스터backup 빈도와 무관하게 PITR 보장이 필요한 운영 환경
함정backup 빈도 변경 시 보존 기간이 달라짐사용 디스크는 backup 빈도/크기에 따라 들쭉날쭉

Barman은 운영 환경에서 RECOVERY WINDOW OF 4 WEEKS를 기본 권장합니다. 운영자가 "몇 개 보관"보다 "몇 주간 PITR 보장"으로 사고하는 게 자연스럽기 때문입니다.

WAL은 backup 정책에 종속된다

이 원리를 모르고 보존 정책을 만지면 함정에 빠집니다. wal_retention_policy = main(기본값)일 때, Barman은 살아 있는 가장 오래된 backup의 시작점까지 WAL을 보존합니다.

즉 보존 정책이 backup 5개를 남기라고 하면, 가장 오래된 backup의 시작 LSN부터 현재까지의 WAL이 모두 보관됩니다. backup 1개가 OBSOLETE로 정리되는 순간, 그 backup이 의존하던 WAL도 같이 정리 후보가 됩니다.

이 원리 때문에 backup만 늘리고 정책을 좁히지 않으면 WAL이 무한히 누적됩니다. Barman 디스크 폭발의 90%는 여기서 비롯됩니다.

시나리오 A: REDUNDANCY 3

backup 5개를 떠 둔 상태에서 REDUNDANCY 3을 적용해 봅니다.

/etc/barman.d/pg01.conf:

retention_policy = REDUNDANCY 3
sudo -u barman barman cron # 정책 적용 트리거
sudo -u barman barman list-backup pg01

기대 출력 (요약):

pg01 20260506T130005 - F - 2026-05-06 13:00:35 - Size: 41.5 MiB - WAL Size: 16 MiB
pg01 20260506T120005 - F - 2026-05-06 12:00:35 - Size: 41.5 MiB - WAL Size: 16 MiB
pg01 20260506T110005 - F - 2026-05-06 11:00:35 - Size: 41.5 MiB - WAL Size: 16 MiB
pg01 20260506T100005 - O - OBSOLETE # 정책 위반
pg01 20260506T090005 - O - OBSOLETE

F(FULL/DONE)는 보존, O(OBSOLETE)는 삭제 후보입니다. 다음 barman cron 사이클이 OBSOLETE 백업과 그에 종속된 WAL을 실제로 디스크에서 제거합니다.

여기에는 함정이 있습니다. backup 빈도가 시간당이라면 REDUNDANCY 3지난 3시간만 PITR이 가능하다는 뜻입니다. 사고가 6시간 전에 발생했다면 이미 늦습니다.

시나리오 B: RECOVERY WINDOW OF 7 DAYS

같은 5개 backup을 가진 상태에서 정책을 시간 기반으로 바꿉니다.

retention_policy = RECOVERY WINDOW OF 7 DAYS
sudo -u barman barman cron
sudo -u barman barman list-backup pg01

이번에는 지난 7일 동안의 어느 시점으로도 PITR이 가능하도록 backup과 WAL이 함께 보존됩니다. 시간 창 에 있는 backup만 OBSOLETE로 마크됩니다.

이 정책의 강점은 backup 빈도와 무관하게 PITR 보장이 일정하다는 점입니다. 시간당으로 떠도 일주일치, 일일로 떠도 일주일치를 유지하므로 운영자는 몇 개보다 얼마나 오래된 시점까지 보장할지를 정할 수 있습니다.

다만 디스크 사용량은 backup 빈도/크기에 따라 들쭉날쭉합니다. 시간당 backup으로 7일치를 보관하면 168개의 backup이 누적됩니다(reuse_backup = link로 dedup해도 작지 않습니다).

시나리오 C: minimum_redundancy 안전망

정책이 너무 공격적으로 잡혀 있으면 모든 backup이 사라지는 사고도 가능합니다. 이를 막는 게 minimum_redundancy.

retention_policy = RECOVERY WINDOW OF 1 DAY
minimum_redundancy = 2

RECOVERY WINDOW OF 1 DAY만 있으면 어제 backup 한 개만 남는 시점도 가능한데, minimum_redundancy = 2최소 2개는 항상 유지하도록 강제합니다. 정책과 안전망이 충돌할 때 안전망이 이깁니다.

기본값은 0, 즉 안전망이 없습니다. 운영 환경에서는 항상 1 이상으로 두는 편이 안전합니다.

시나리오 D: barman keep으로 영구 보존

특정 backup을 retention 정책에서 영구 제외하려는 경우가 있습니다. 예를 들면 마이그레이션 직전의 안전 base나 분기 마감 시점입니다.

sudo -u barman barman keep pg01 20260506T090005 --target full

--target 옵션:

의미
full이 backup과 모든 의존 WAL을 영구 보존하므로 full PITR 가능
standalonebackup 자체만 보존(WAL은 정책 따름), 디스크를 절약하며 backup 시점 복원만 가능
# 보존 대상 확인
sudo -u barman barman list-backup pg01
# → 'KEEP' 마크가 붙은 backup은 retention 정책에서 자동 제외된다

barman keep --release pg01 <backup_id>로 보호를 해제합니다. 운영 가이드에 "위험한 마이그레이션 직전엔 keep --target full을 걸어둔다"를 정착시키면 한 단계 단단해집니다.

OBSOLETE에서 DELETED까지의 라이프사이클

backup이 정책 위반에서 디스크 제거까지 두 단계로 흐릅니다.

DONE ──(barman cron 평가)──▶ OBSOLETE ──(다음 cron)──▶ DELETED (디스크 정리)

barman cron은 매 분 한 번 도는데(/etc/cron.d/barman 기본 설정), 매 사이클마다:

  1. 정책 위반 backup을 OBSOLETE로 마크
  2. 이전 사이클에서 OBSOLETE로 마크된 backup을 실제로 삭제
  3. 종속된 WAL도 같이 정리

즉 정책을 적용한 직후가 아니라 cron 한두 사이클 뒤에 디스크가 줄어듭니다. "왜 안 줄어들지"라고 헷갈리는 흔한 지점입니다.

수동으로 즉시 삭제하려면:

sudo -u barman barman delete pg01 20260506T090005

상황별 결정 가이드

상황권장
PITR 기간이 SLA에 명시된 운영 환경RECOVERY WINDOW OF N DAYS (또는 WEEKS/MONTHS), SLA 그대로 매핑
backup 빈도/크기가 안정적이고 디스크 예측이 중요한 lab/소규모REDUNDANCY n, 디스크 사용량이 거의 일정
안전망 (정책 사고 대비)minimum_redundancy = 1 또는 2 (권장)
마이그레이션/분기 마감 등 영구 보존이 필요한 시점barman keep <backup-id> --target full
WAL이 별도 정책으로 더 길게 보관 필요wal_retention_policy = main 그대로 (대부분 충분)

정리

항목내용
두 정책REDUNDANCY n (개수) / RECOVERY WINDOW OF n DAYS (시간)
Barman 권장RECOVERY WINDOW OF 4 WEEKS (운영 환경)
핵심 원리WAL은 살아 있는 가장 오래된 backup까지 보존되며 backup이 줄면 WAL도 줄어든다
안전망minimum_redundancy ≥ 1 (정책 사고 대비)
영구 보존`barman keep --target full
라이프사이클DONE → OBSOLETE → DELETED (cron 사이클 단위)

보존 정책의 초점은 몇 개를 남기느냐보다 얼마나 오래 PITR을 보장하느냐에 있습니다. 운영자에게는 개수보다 기간으로 사고하는 편이 대체로 자연스럽습니다.

참고 자료

Barman PITR 워크북

· 약 7분

백업이 아니라 복구가 시험이다

2편 마지막에 이렇게 적었어요. "백업의 가치는 백업이 아니라 복구에서 결정됩니다." 이 글은 그 한 줄을 직접 돌려보는 워크북이에요. Barman의 PITR target 옵션 4가지를 한 lab에서 한 번씩 시도해 봐요.

처음 보는 독자도 따라올 수 있게 환경을 짧게 정리합니다.

호스트역할Barman 라벨
demo-pg01PostgreSQL 17 primary해당 없음
demo-barman01Barman 3.18 서버pg01

pg01은 Barman 서버 라벨이고, demo-pg01은 실제 호스트네임이므로 두 이름은 별개입니다. 환경 셋업이 처음이라면 2편을 먼저 보는 편이 빠릅니다.

참고로 barman recoverbarman restore는 같은 명령입니다. Barman 3.x에서는 restore가 새 권장 이름이지만 recover도 그대로 동작합니다. 이 글은 2편과 톤을 맞춰 recover로 통일했습니다.

시나리오 준비

지웠다가 살릴 데이터를 만듭니다.

PITR을 그럴듯하게 돌려 보려면 의도적으로 손상된 시점이 필요합니다. 다음 SQL을 demo-pg01에서 미리 실행해 둡니다.

-- T0: 기준 데이터
sudo -u postgres psql <<'SQL'
CREATE TABLE notes (
id int PRIMARY KEY,
body text,
ts timestamptz default now()
);
INSERT INTO notes(id, body)
SELECT g, 'note-' || g FROM generate_series(1, 1000) g;
SQL
-- T1 (예: 14:30:00): 안전 시점 — 이후로 되감을 라벨 생성 + XID 기록
sudo -u postgres psql <<'SQL'
BEGIN;
SELECT pg_create_restore_point('safe-state'); -- 시나리오 C 용
SELECT pg_current_xact_id(); -- 시나리오 B 용 — 예: 12345
COMMIT;
SQL
-- T2 (예: 14:32:00 이후): 사고 — 테이블 삭제
sudo -u postgres psql -c "DROP TABLE notes;"

이제 notes 테이블이 사라졌습니다. base backup이 T0 이전에 떠 있고 WAL이 계속 수집되고 있다고 가정합니다(2편의 lab 그대로). 이 base backup과 WAL을 가지고 T1 직후 / XID 12345 직후 / 명시 라벨 / base backup 직후 4가지 시점으로 되감아 봅니다.

복원 대상은 빈 디렉토리입니다. 매 시나리오 사이에 비우고 시작합니다.

sudo -u barman rm -rf /var/lib/barman/restore && \
sudo -u barman mkdir -p /var/lib/barman/restore

시나리오 A: --target-time

시계로 되감는 방식이 가장 직관적입니다. 사고 직전 시각으로 돌립니다.

sudo -u barman barman recover pg01 latest /var/lib/barman/restore \
--target-time "2026-05-06 14:32:00"

기대 동작은 14:32:00 시점까지 WAL을 replay한 뒤 멈추는 것입니다. notes 테이블은 살아 있고, DROP TABLE에는 도달하지 않습니다.

타임존은 Barman 서버의 시스템 시간대가 기본입니다. 다른 TZ로 명시하려면 2026-05-06 14:32:00+09 형태로 붙입니다. 운영에서는 항상 명시하는 편이 안전합니다.

"Recovery targets must be a value after the end of the backup." — base backup 시작 시점 이전은 reach 불가능. 시점이 base backup 시작보다 이르면 즉시 실패한다.

시나리오 B: --target-xid

트랜잭션 ID로 되감습니다.

시계는 누적된 운영 환경에서 의외로 부정확합니다. Barman 호스트와 PostgreSQL 호스트의 시계가 살짝 어긋나 있거나, 동시에 여러 트랜잭션이 들어올 수 있기 때문입니다. 트랜잭션 ID(XID)가 가장 정확한 좌표입니다.

sudo -u barman barman recover pg01 latest /var/lib/barman/restore \
--target-xid 12345

XID 12345가 commit된 직후까지 replay하고 멈추는 동작을 기대합니다. T1 시점에 기록해 둔 XID가 사고 직전 마지막 안전 트랜잭션이므로 여기에서 멈추면 notes는 살아 있습니다.

항목내용
강점시점이 논리적으로 정확하며 시계 오차/동시 트랜잭션의 영향이 없음
약점사고 직후가 되어서야 그 XID를 알게 되므로 사고 직전에 미리 기록해 두는 편이 이상적
보조pg_waldump으로 WAL을 훑어 commit 레코드의 XID 시퀀스 추적 가능

--exclusive 플래그를 같이 주면 그 XID 직전까지만 replay합니다(그 트랜잭션 자체는 제외). 기본값은 그 XID 포함입니다.

시나리오 C: --target-name

명시한 라벨로 되감습니다.

운영 중 위험한 작업 직전에 라벨을 만들어 두는 패턴입니다.

-- 사고 직전(T1)에 미리 만들어 둔 라벨
SELECT pg_create_restore_point('safe-state');
sudo -u barman barman recover pg01 latest /var/lib/barman/restore \
--target-name 'safe-state'

safe-state restore point까지 replay하고 멈추는 동작을 기대합니다.

이 옵션의 가치는 언어가 자연스럽다는 점입니다. 시각/XID는 사고 후 재구성해야 하지만 라벨은 팀이 기억할 만한 이름입니다. before-migration-2026q2, pre-DROP-INDEX-experiment 같은 이름을 쓸 수 있습니다. 운영 가이드를 "위험한 ALTER 직전엔 restore point부터 만든다"로 정착시키면 PITR이 한층 평이해집니다.

시나리오 D: --target-immediate

base backup 직후로 되감습니다.

가장 단순한 옵션입니다. 마지막 base backup이 일관성을 확보하는 그 시점까지만 replay하고 멈춥니다. 즉 base backup 종료 직후의 클러스터 상태로 일어섭니다.

sudo -u barman barman recover pg01 latest /var/lib/barman/restore \
--target-immediate

"가장 최근 base backup의 그 순간으로 일단 돌려놓고, 이후 사고 영향을 분리해 분석하고 싶다" 같은 forensic 시나리오에서 사용합니다. WAL replay를 최소화해 빠르게 일관성 상태에 도달합니다.

그 외 옵션 빠른 참조

옵션의미비고
--target-lsnLSN(3/64000000 형식)으로 되감기XID보다 더 세밀, 특정 WAL 위치를 정확히 알 때
--target-tli특정 timeline으로 복구latest / current / 숫자 ID
--target-action도달 후 동작: shutdown / pause / promote미지정 시 PostgreSQL이 paused 후 운영자 결정 대기
--exclusivetarget 직전까지(target 자체 제외) replay기본은 target 포함
--standby-modereplica로 일어나도록 standby.signal 생성target과 무관한 옵션

--target-action이 특히 중요합니다. 지정하지 않으면 paused 상태가 되어, 운영자가 SELECT pg_wal_replay_resume()을 직접 호출할 때까지 새 트랜잭션이 돌지 않습니다. 이걸 모르면 "왜 안 살아나지" 하고 한참 헤매게 됩니다.

backup_id=auto(가장 최근 backup 자동 선택)일 때는 제약이 있습니다. --target-time, --target-lsn, --target-tli만 허용됩니다. --target-xid--target-name을 쓰려면 명시적인 backup-id를 지정해야 합니다(barman list-backup pg01로 확인).

시점을 정하는 법

barman show-backuppg_waldump로 좌표를 찾습니다.

PITR의 절반은 어디로 되감을지 정하는 일입니다. 두 명령어가 그 좌표를 줍니다.

sudo -u barman barman show-backup pg01 latest

출력에서 다음 항목을 봅니다.

항목의미
Begin time / End timebase backup 경계이며 그 이전 시점에는 도달 불가
Begin LSN / End LSNbase backup의 LSN 경계이며 --target-lsn 사용 시 기준점
Begin Offset / End OffsetWAL 파일 내 위치

WAL을 더 세밀하게 보려면 pg_waldump로 commit 레코드를 훑습니다.

sudo -u postgres /usr/pgsql-17/bin/pg_waldump \
/var/lib/barman/pg01/streaming/000000010000000000000004 \
| grep COMMIT | head

각 줄에 LSN, XID, timestamp가 함께 나오므로, 사고 직전의 어떤 좌표를 사용할지 골라잡기 쉬워집니다.

자주 만나는 함정 5가지

증상원인해결
Recovery targets must be a value after the end of the backuptarget이 base backup 시작 이전barman show-backupBegin time 이후로 잡거나 더 오래된 backup 사용
복구 후 PostgreSQL이 안 살아남--target-action 미지정으로 paused 상태SELECT pg_wal_replay_resume(); 또는 처음부터 --target-action promote
timeline mismatch이전 복구 후 새 timeline으로 진입--target-tli latest 또는 명시적 timeline ID
target-name 못 찾음restore point가 현재 timeline의 WAL에 없음pg_create_restore_point해당 timeline에 기록됐는지 확인
barman recover가 아무 진행 안 함--remote-ssh-command로 cross-host 복원인데 SSH 키 미설치barman 사용자에서 postgres@<host>로 키 기반 접속 미리 잡기

정리

항목내용
4가지 target--target-time (시계) / --target-xid (XID) / --target-name (라벨) / --target-immediate (base 직후)
가장 정확XID이며 시계 오차/동시 트랜잭션과 무관
가장 운영 친화--target-name이며 위험 작업 직전 pg_create_restore_point를 만드는 패턴
시점 결정barman show-backup + pg_waldump 조합
필수 동반--target-action(기본 paused), --exclusive(stop-before vs include)
backup_id=auto 제약--target-time, --target-lsn, --target-tli만 허용하며 그 외는 명시 backup-id 필요

PITR은 두 단계로 나뉘어요. 시점을 정하는 일이 반이고 복구하는 일이 반인데, 보통은 시점을 정하는 쪽이 더 어려워요.

참고 자료

Barman rsync 모드

· 약 6분

streaming은 알겠고, rsync는 언제 쓰나

2편에서 Barman의 streaming-only 모델을 셋업했어요. 이 글에서는 같은 lab을 rsync 모델로 바꾸거나 처음부터 rsync로 셋업하고, 왜 rsync를 골랐는지 정리해 보려고 해요.

처음 보는 독자도 따라올 수 있게 환경을 짧게 정리합니다.

호스트역할Barman 라벨
demo-pg01PostgreSQL 17 primary해당 없음
demo-barman01Barman 3.18 서버pg01

pg01은 Barman 서버 라벨이고, demo-pg01은 실제 호스트네임입니다. 환경 셋업이 처음이라면 2편을 먼저 보고 오는 편이 빠릅니다.

rsync 모델 vs streaming 모델 한눈에

항목rsync 모델 (이 글)streaming 모델 (2편)
도입 시기Barman 1.x (2012-)Barman 2.0 (2016-)
전송 채널SSH + rsyncPostgreSQL streaming replication
WAL 수집archive_command (폴링)pg_receivewal (실시간 stream)
의존양방향 SSH 키replication slot, replication user
증분 dedup하드링크 (reuse_backup = link)(PG 17+ 블록 레벨 점진 이행)
병렬 복사parallel_jobs = N(단일 stream)
적합 환경베어메탈/VM, SSH 통제 가능컨테이너/K8s, SSH 미허용

이 글의 주제는 다섯/여섯 번째 행에 있는 하드링크 dedup과 parallel_jobs입니다. 둘 다 rsync 모델 고유의 강점입니다.

양방향 SSH 셋업

rsync 모델은 두 방향의 SSH가 필요합니다.

방향용도
barman@demo-barman01postgres@demo-pg01base backup 시 rsync로 데이터 디렉토리 풀링
postgres@demo-pg01barman@demo-barman01archive_command로 WAL을 Barman에 푸시

비밀번호 없이 통과해야 cron이 자동으로 돌므로 SSH 키 기반입니다.

첫 번째 방향은 demo-barman01barman 사용자에서 설정합니다.

sudo -u barman ssh-keygen -t ed25519 -N "" -f ~barman/.ssh/id_ed25519 \
-C "barman@demo-barman01"

# 공개키를 demo-pg01의 ~postgres/.ssh/authorized_keys에 등록
sudo -u barman ssh-copy-id postgres@demo-pg01

# 검증 — 비밀번호 없이 통과해야 함
sudo -u barman ssh postgres@demo-pg01 'echo ok'

두 번째 방향은 demo-pg01postgres 사용자에서 설정합니다.

sudo -u postgres ssh-keygen -t ed25519 -N "" -f ~postgres/.ssh/id_ed25519 \
-C "postgres@demo-pg01"
sudo -u postgres ssh-copy-id barman@demo-barman01
sudo -u postgres ssh barman@demo-barman01 'echo ok'

운영 환경에서는 authorized_keysfrom="..." 호스트 제약이나 command="..." 락다운을 거는 편이 안전합니다. 이 글은 lab이라 단순화했습니다.

PostgreSQL 측의 archive_command 활성화

/var/lib/pgsql/17/data/postgresql.conf 핵심 항목:

listen_addresses = '*'
wal_level = replica
archive_mode = on
archive_command = 'barman-wal-archive -U barman demo-barman01 pg01 %p'

barman-wal-archive는 Barman 패키지에 같이 설치됩니다. 내부적으로 SSH로 barman@demo-barman01에 접속해 WAL 파일을 /var/lib/barman/pg01/incoming/에 정확히 전달합니다 — cp나 직접 scp하는 것보다 안전합니다.

pg_hba.conf에는 Barman의 conninfo 접속을 열어 둡니다. replication slot은 rsync 모델에서 필수가 아니지만, barman check가 PostgreSQL 메타데이터를 읽으려면 이 접속이 필요합니다.

host postgres barman demo-barman01 scram-sha-256

PostgreSQL 재시작 후 사용자 생성:

sudo systemctl restart postgresql-17
sudo -u postgres psql <<'SQL'
CREATE USER barman WITH ENCRYPTED PASSWORD 'changeme';
GRANT pg_read_all_settings, pg_read_all_stats TO barman;
SQL

streaming 모델과 달리 REPLICATION 속성/replication slot은 생성하지 않습니다.

Barman 측의 backup_method = rsync 설정

/etc/barman.d/pg01.conf를 다음과 같이 설정합니다.

[pg01]
description = "Production PostgreSQL primary (rsync mode)"
ssh_command = ssh postgres@demo-pg01
conninfo = host=demo-pg01 user=barman dbname=postgres
backup_method = rsync
parallel_jobs = 2
reuse_backup = link
archiver = on
retention_policy = RECOVERY WINDOW OF 4 WEEKS

핵심 다섯 줄의 의미는 이렇습니다.

의미
backup_method = rsyncbase backup 때 SSH+rsync로 데이터 디렉토리를 가져옵니다
ssh_command = ssh postgres@demo-pg01rsync가 사용할 SSH 명령입니다. barman 사용자에서 postgres@demo-pg01로 접속합니다
parallel_jobs = 2병렬 rsync worker 수입니다. 디스크/네트워크 여유에 맞춰 조정합니다
reuse_backup = link이전 backup에서 변경되지 않은 파일을 하드링크로 재사용합니다. 핵심 dedup 옵션입니다
archiver = onarchive_command로 들어오는 WAL을 자동으로 처리합니다

streaming 모델의 streaming_conninfo, streaming_archiver, slot_name 키는 모두 사용하지 않습니다.

설정 검증:

sudo -u barman barman check pg01

receive-wal running은 streaming 모델 전용이므로 rsync 모드에서는 항목이 빠지거나 disabled로 나옵니다. 그 외 항목이 모두 OK면 셋업이 끝난 것입니다.

첫 backup + 하드링크 dedup 실측

# 첫 base backup
sudo -u barman barman backup pg01

기대 출력 (요약):

Starting backup using rsync-over-ssh method for server pg01 ...
Copy done (time: 12 seconds)
Backup size: 41.5 MiB

이번에는 의도적으로 작은 변경만 만든 뒤 두 번째 backup을 수행합니다.

# 데이터 일부 갱신
sudo -u postgres psql -c "UPDATE notes SET body = body || '!' WHERE id < 100;"

# 두 번째 base backup — reuse_backup = link 가 동작
sudo -u barman barman backup pg01
sudo -u barman barman list-backup pg01

이제 디스크 사용량을 두 가지 방식으로 잽니다.

# 실제 디스크 사용량 (하드링크는 한 번만 카운트)
sudo du -sh /var/lib/barman/pg01/base/
# 예: 42.0 MiB

# 논리 사용량 (하드링크가 중복 카운트되어 backup별로 따로 잡힘)
sudo du -sh --apparent-size /var/lib/barman/pg01/base/
# 예: 83 MiB

두 측정값의 차이인 41 MiB ≈ 첫 backup 크기가 dedup으로 절약된 디스크 공간입니다. 두 번째 backup은 변경된 페이지만 새로 차지하고 나머지는 첫 backup 파일에 hardlink로 연결되므로, backup 횟수가 늘수록 누적 절약 효과가 커집니다.

rsync를 선택하는 기준 4가지

기준rsync 유리streaming 유리
환경베어메탈/VM, SSH 통제 가능컨테이너/K8s, SSH 미허용
디스크 절약하드링크 dedup → 동일 backup 다수 보관 시 절약 큼블록 레벨 dedup은 PG 17+로 점진 이행 중
병렬화parallel_jobs로 N개 worker단일 stream
운영 부담양방향 SSH 키 관리replication slot 관리

베어메탈/VM에서 디스크 효율과 병렬 복사가 중요하면 rsync가 잘 맞습니다. 컨테이너/K8s에서 SSH를 피하고 운영을 단순화하려면 streaming이 유리합니다. 둘 다 가능한 환경이라면 운영자의 SSH 키 관리 부담이 적은 streaming이 무난합니다.

streaming과 rsync 사이의 전환

같은 라벨로 두 모델을 동시에 운영할 수는 없습니다. backup_method는 하나만 지정할 수 있기 때문입니다. 전환 절차는 다음과 같습니다.

# 1단계: 기존 라벨 비활성화
[pg01]
active = false
...
# 2단계: 새 conf 파일로 다른 라벨 만들기 (예: pg01-rsync)
[pg01-rsync]
backup_method = rsync
...

새 라벨에 backup이 안정적으로 들어오는 것이 확인되면, 보통 1주일+ 경과/복구 리허설 1회 이상을 기준으로 기존 라벨을 폐기합니다.

운영 환경에서는 기존 backup을 즉시 버리지 않습니다. 보존 정책에 따라 자연 만료될 때까지 두 라벨을 함께 보관하면 이전 시점 PITR이 필요할 때 안전망이 됩니다.

정리

항목내용
백업 모델backup_method = rsync, SSH+rsync 기반
WAL 수집archive_command = 'barman-wal-archive ...'
SSH 방향양방향 (rsync용 + archive_command용)
핵심 dedupreuse_backup = link, 하드링크 기반이며 백업 횟수가 누적될수록 절약 큼
병렬 복사parallel_jobs = N
streaming과의 관계동시 사용 불가 (라벨당 한 모델), 전환 시 새 라벨로 병행 운영 후 폐기

streaming은 운영 단순화에 강하고 rsync는 디스크 효율과 병렬화에 강해요. 환경 때문에 한쪽을 골라야 하는 상황이 아니라면 streaming부터 시도해 보는 편이 무난해요.

참고 자료

Barman 빠른 시작

· 약 8분

어떻게 Barman인가

1편이 "왜 Barman인가"였다면, 이 글은 "어떻게 Barman인가"예요. 1편에서 Barman의 14년 궤적과 아키텍처를 정리했어요. 이번에는 같은 자리에서 한 발짝 더 들어가 두 대의 Rocky Linux 머신을 준비하고, streaming-only 모드로 PostgreSQL을 설정한 뒤, 5개 명령어로 첫 백업과 PITR 복구까지 끝내 봅니다.

이번에 다룰 시나리오를 한 화면에 펼치면 다음과 같습니다.

단계명령어의미
1barman check pg01PG 연결, streaming, 권한 확인
2barman backup pg01첫 base backup
3barman list-backup pg01카탈로그 조회
4barman recover pg01 latest <dir>최신 백업 복원
5barman recover ... --target-time "..."임의 시점 (PITR) 복원

읽으면서 따라 할 수 있도록 모든 명령어와 설정 파일 내용을 그대로 옮겨 둡니다.

사전 준비

두 호스트로 lab 환경을 구성합니다.

호스트역할OS
demo-pg01PostgreSQL 17 primaryRocky Linux 9
demo-barman01Barman 3.18 서버Rocky Linux 9

두 호스트가 hostname으로 서로 통신한다고 가정합니다(DNS 또는 /etc/hosts). 단일 머신에서 시험하려면 demo-pg01demo-barman01을 모두 localhost로 두고 진행해도 됩니다.

설치

PGDG 저장소에서 설치합니다.

demo-pg01demo-barman01 양쪽에 PGDG 저장소를 등록합니다.

sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
sudo dnf -qy module disable postgresql

demo-pg01에서 PostgreSQL 17 설치/초기화:

sudo dnf install -y postgresql17-server postgresql17-contrib
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
sudo systemctl enable --now postgresql-17

demo-barman01에 Barman 3.18 설치:

sudo dnf install -y barman barman-cli

설치 시 barman 시스템 사용자가 자동 생성되며, 백업 카탈로그 기본 경로는 /var/lib/barman입니다. /etc/cron.d/barman도 함께 깔리므로 별도 systemd timer 설정 없이 cron이 매 분 한 번 barman cron을 돌립니다.

설정 1: PostgreSQL 측 (demo-pg01)

streaming 백업은 PostgreSQL의 replication 프로토콜 위에서 동작하므로, replication user와 replication slot이 필요합니다.

/var/lib/pgsql/17/data/postgresql.conf 핵심 항목:

listen_addresses = '*'
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10

/var/lib/pgsql/17/data/pg_hba.conf에 Barman 측 접속을 열어 둡니다.

# TYPE DATABASE USER ADDRESS METHOD
host replication barman demo-barman01 scram-sha-256
host postgres barman demo-barman01 scram-sha-256

PostgreSQL을 재시작한 후 사용자와 슬롯을 생성합니다.

sudo systemctl restart postgresql-17
sudo -u postgres psql <<'SQL'
CREATE USER barman WITH REPLICATION ENCRYPTED PASSWORD 'changeme';
GRANT pg_read_all_settings, pg_read_all_stats TO barman;
SELECT pg_create_physical_replication_slot('barman');
SQL

실제 운영에서는 changeme을 비밀 관리자(Vault, AWS Secrets Manager 등)로 옮기고 .pgpass 또는 환경변수로 분리해야 합니다.

설정 2: Barman 측 (demo-barman01)

/etc/barman.conf는 default 값 그대로 두는 편이 무난합니다. 서버별 설정만 추가합니다(default 항목 자체는 별도 글에서 다룹니다).

/etc/barman.d/pg01.conf:

[pg01]
description = "Production PG primary"
conninfo = host=demo-pg01 user=barman dbname=postgres
streaming_conninfo = host=demo-pg01 user=barman
backup_method = postgres
streaming_archiver = on
slot_name = barman
create_slot = manual
retention_policy = RECOVERY WINDOW OF 4 WEEKS

[pg01]Barman 서버 라벨이자 서버 식별자입니다. CLI(barman backup pg01), 카탈로그 디렉토리(/var/lib/barman/pg01/...), conf 파일명(pg01.conf)에 같은 값을 씁니다. 실제 호스트네임 demo-pg01과는 별개이며, Barman이 부르는 이름과 네트워크가 부르는 이름을 분리한 것입니다.

conninfo = host=demo-pg01 ...에는 Barman이 PostgreSQL에 접속할 때 사용하는 실제 호스트네임을 지정하므로 라벨과 달라도 자연스럽습니다. backup_method = postgrespg_basebackup을 통한 streaming 백업을 뜻하고, streaming_archiver = on은 WAL을 pg_receivewal 방식으로 받도록 설정합니다. slot_name = barman은 앞에서 만든 replication slot을 사용합니다. create_slot = manual로 지정한 이유는 슬롯을 SQL로 이미 만들었으므로 Barman이 자동 생성하지 않게 하기 위해서입니다.

비밀번호는 barman 사용자의 ~/.pgpass로 분리합니다.

sudo -u barman tee ~barman/.pgpass > /dev/null <<'EOF'
demo-pg01:5432:*:barman:changeme
EOF
sudo chmod 600 ~barman/.pgpass
sudo chown barman:barman ~barman/.pgpass

5개 명령어 시나리오

이제부터는 모든 명령어를 barman 사용자로 실행합니다. sudo -i -u barman으로 barman 셸에 들어가거나, 명령어마다 sudo -u barman을 앞에 붙입니다.

1. barman check pg01

셋업을 검증합니다.

sudo -u barman barman check pg01

기대 출력 (요약):

Server pg01:
PostgreSQL: OK
wal_level: OK
replication slot: OK
directories: OK
retention policy settings: OK
pg_basebackup: OK
pg_basebackup compatible: OK
systemid coherence: OK
pg_receivexlog: OK
receive-wal running: OK
archiver errors: OK

한 줄이라도 FAILED가 나오면 그 항목에 셋업 단계의 문제가 있습니다. 가장 자주 보이는 receive-wal running: FAILED는 cron이 아직 한 번도 돌지 않았거나 slot 이름이 어긋났다는 뜻입니다. 한 번 강제로 돌려 두는 편이 빠릅니다.

sudo -u barman barman cron

2. barman backup pg01

첫 base backup을 실행합니다.

sudo -u barman barman backup pg01

기대 출력 (요약):

Starting backup using postgres method for server pg01 in /var/lib/barman/pg01/base/20260504T143012
Backup start at LSN: 0/3000028
Starting backup copy via pg_basebackup for 20260504T143012
Copy done (time: 12 seconds)
Backup size: 41.5 MiB
Backup end at LSN: 0/4000060
Marking backup as DONE
Backup completed (start time: 2026-05-04 14:30:12, elapsed time: 14 seconds)

/var/lib/barman/pg01/base/<timestamp>/ 아래에 base backup이, /var/lib/barman/pg01/streaming/에 WAL이 누적됩니다.

3. barman list-backup pg01

카탈로그를 조회합니다.

sudo -u barman barman list-backup pg01

기대 출력:

pg01 20260504T143012 - F - 2026-05-04 14:30:26 - Size: 41.5 MiB - WAL Size: 16 MiB

F는 full backup입니다. backup-id는 timestamp 기반(20260504T143012)이며, latest 키워드를 별칭으로 쓸 수 있습니다.

상세 보기:

sudo -u barman barman show-backup pg01 latest

Begin time / End time / Begin LSN / End LSN 등 PITR 타깃을 결정할 때 필요한 값이 모두 여기에 있습니다.

4. barman recover

최신 백업으로 복원합니다.

복원 대상은 빈 디렉토리여야 합니다. PostgreSQL이 새로 기동할 자리를 미리 비워 둡니다.

sudo mkdir -p /var/lib/barman/restore
sudo chown barman:barman /var/lib/barman/restore

sudo -u barman barman recover pg01 latest /var/lib/barman/restore

복원이 끝나면 그 디렉토리 안에 base backup이 풀리고, recovery.signalpostgresql.auto.confrestore_command 항목이 자동 생성됩니다. PostgreSQL을 그 데이터 디렉토리로 띄우면 곧장 기동합니다.

다른 호스트로 직접 복원하려면 --remote-ssh-command "ssh postgres@<host>"를 추가합니다. 이때 barman 사용자에서 그 호스트의 postgres로 SSH 키 기반 접속이 미리 설정되어 있어야 합니다.

5. barman recover --target-time

특정 시점으로 되감는 PITR을 실행합니다.

sudo -u barman barman recover pg01 latest /var/lib/barman/restore \
--target-time "2026-05-04 14:30:00"

복원 시점은 base backup 시작 시점 이후, 가장 마지막에 받은 WAL 이전 사이여야 합니다. 정확한 경계가 헷갈리면 barman show-backup pg01 latestBegin time / End time을 기준으로 잡습니다.

다른 PITR 타깃 옵션도 같은 자리에 지정합니다.

옵션의미
--target-time시점 기준
--target-xid트랜잭션 ID 기준
--target-namepg_create_restore_point()로 만든 명시적 라벨
--target-immediatebase backup 직후 일관성 시점

일상 운영

cron 한 줄과 보존 정책을 설정합니다.

설치 시 동봉된 /etc/cron.d/barman이 매 분 barman cron을 실행합니다(WAL 수신과 아카이브 정리 담당). 정기 백업은 별도 cron 한 줄로 잡는 방식이 표준입니다.

# /etc/cron.d/barman-backup — 매일 02:00에 모든 등록 서버 백업
0 2 * * * barman /usr/bin/barman backup all

보존 정책은 서버별 conf 한 줄로 끝납니다.

retention_policy = RECOVERY WINDOW OF 4 WEEKS
# 또는 개수 기반:
# retention_policy = REDUNDANCY 5

RECOVERY WINDOW는 "이 시점부터 N 단위(WEEKS/DAYS) 전까지 PITR이 가능하도록 보장"한다는 의미입니다. 지난 4주 동안 임의 시점으로 되감을 수 있도록 base backup과 WAL을 함께 보존합니다. 정책에서 벗어난 backup은 barman cron이 자동으로 정리합니다.

자주 만나는 에러 빠른 가이드

증상원인한 줄 해결
receive-wal running: FAILEDstreaming WAL receiver 미실행barman cron 수동 실행, slot 이름/권한 재점검
replication slot: FAILEDPG 측 슬롯이 없음 / 이름 불일치pg_create_physical_replication_slot('barman') 다시 실행
pg_basebackup: FAILEDreplication 권한/pg_hba 미흡barman 사용자의 REPLICATION 속성 확인, pg_hba에 host replication
Connection refusedlisten_addresses, 방화벽postgresql.conflisten_addresses = '*', firewalld에서 5432 허용

barman check는 한 번에 끝내려 들기보다 "FAIL 한 줄씩 잡아 나가는 도구"로 보면 마음이 편합니다. 위 4개를 해결하면 첫 셋업의 90%가 끝납니다.

정리

항목내용
백업 모델streaming-only (SSH 없이 pg_basebackup + replication slot)
카탈로그 위치/var/lib/barman/pg01/{base,streaming,wals}
일상 명령어barman cron (자동) + barman backup all (cron 1회/일)
검증 명령어barman check pg01 — 셋업 직후/이상 발생 시 첫 진단
PITRbarman recover ... --target-time "..." 한 줄
보존 정책RECOVERY WINDOW OF 4 WEEKS 권장

백업의 가치는 백업이 아니라 복구에서 결정돼요. 셋업 직후 PITR을 한 번은 반드시 돌려봐요.

참고 자료

CCS 설정과 라우팅

· 약 14분

1편의 잔상

Claude Code Switcher (CCS) — 프로바이더 전환, 멀티 계정, 로컬 모델까지 한 명령으로표면의 글이었어요. CCS가 등장했는지, 무엇을 풀고 있는지, 누구에게 언제 맞고 안 맞는지를 다뤘어요. 그 글의 한 단락을 그대로 옮기면 이렇습니다.

CCS는 Claude Code가 환경변수 두 개로 정의되는 도구라는 사실을 그대로 받아들인 도구다. 그 두 변수를 프로파일 이라는 단위로 정리하고, OAuth 우회와 다중 계정까지 한 명령 표면 위로 올려놓았다.

2편은 그 표면 아래를 다룹니다. ~/.ccs/ 한 디렉토리에 무엇이 어떻게 사는지, config.yaml의 어느 키가 무엇을 결정하는지, instance와 shared가 어떻게 갈리는지, 로컬 프록시는 정확히 어떤 변환을 거치는지 살펴봅니다. 1편에서 표 한 줄로만 끝낸 부분을 시스템의 그림으로 풀어 봅니다.

이 글의 모든 디렉토리/키 이름은 사용자 머신의 실제 CCS 설치를 직접 들여다본 결과입니다. 토큰/세션 같은 민감한 값은 보지 않고 구조만 인용합니다.

~/.ccs/ 한 장 지도

CCS가 머신에 자리 잡으면 홈 디렉토리에 ~/.ccs/ 한 폴더가 생깁니다. 그 안의 트리가 사실상 CCS의 운영 모델 전부인데, 진짜 그림은 심볼릭 링크 체인입니다.

~/.claude/ ← 사용자의 기존 Claude Code 글로벌 (canonical)
├── settings.json
├── commands/, skills/, agents/, plugins/
└── projects/

~/.ccs/ ← CCS 영역
├── config.yaml ← 메인 설정 (YAML, 11K 정도)
├── .session-secret ← 세션 비밀 (64B, 절대 공유 금지)
├── .claude/ ← CCS *번들* (ccs.md, ccs-delegation 스킬 등 자체 주입)
├── cliproxy/bin/ ← CLIProxyAPI 바이너리 (자동 다운로드)
├── cache/, completions/, logs/

├── shared/ ← 사용자 정의 공유 영역
│ ├── settings.json → ~/.claude/settings.json (symlink)
│ ├── commands → ~/.claude/commands (symlink)
│ ├── skills → ~/.claude/skills (symlink)
│ ├── agents → ~/.claude/agents (symlink)
│ ├── plugins → ~/.claude/plugins (symlink)
│ └── context-groups/default/projects/ (실 디렉토리)

└── instances/ ← per-account 격리 영역
├── team/ ← 회사 Team account
│ ├── settings.json → ~/.ccs/shared/settings.json (symlink)
│ ├── commands → ~/.ccs/shared/commands (symlink)
│ ├── skills → ~/.ccs/shared/skills (symlink)
│ ├── agents → ~/.ccs/shared/agents (symlink)
│ ├── projects → ~/.ccs/shared/context-groups/default/projects/ (symlink)
│ ├── plugins/, .anthropic/, sessions/, session-env/ ← 실 디렉토리
│ ├── todos/, file-history/, shell-snapshots/, logs/ ← 실
│ ├── backups/, cache/, debug/, image-cache/, paste-cache/, plans/ ← 실
│ └── .claude.json, history.jsonl, .session-stats.json,
│ mcp-needs-auth-cache.json, policy-limits.json, remote-settings.json
└── enterprise/ ← 회사 Enterprise account (구조 동일)

여기서 눈여겨볼 것은 두 가지입니다. 하나는 2단계 symlink 체인입니다. instances/team/settings.json을 따라가면 ~/.ccs/shared/settings.json으로, 다시 ~/.claude/settings.json으로 이어집니다. 사용자의 기존 Claude Code 글로벌 설정이 모든 인스턴스에 자동 반영된다는 뜻이며, 한 번 만든 슬래시 커맨드, 스킬, MCP가 team과 enterprise 양쪽에서 살아 있는 이유도 여기에 있습니다.

다른 하나는 projects/만 체인이 한 단계라는 점입니다. 대화 히스토리는 instances/team/projects에서 ~/.ccs/shared/context-groups/default/projects/(실 디렉토리)까지만 가고 ~/.claude/projects/까지는 이어지지 않습니다. CCS가 자기 context-group 단위로 대화를 모아 사용자의 평소 Claude Code 대화와 자연히 분리해 두는 것으로, 회사 대화가 개인용 Claude Code에 섞이지 않도록 의도한 끊음입니다.

이 2단계 체인이 이중 구조라고 표현했던 것의 실제 메커니즘이며, 1편의 "공유 vs 분리"가 단일 축이 아니라 어느 단위까지 체인을 잇느냐의 문제였다는 뜻입니다. 5~6절에서 이 단위들을 풀어 봅니다.

config.yaml의 9개 영역

설정의 거의 전부는 ~/.ccs/config.yaml 한 파일에 모입니다. JSON이 아니라 YAML인 점이 의외인데, 사람이 손으로 읽고 고치기 좋은 쪽을 택한 것으로 보입니다.

키 구조만 추리면 9개 영역으로 나뉩니다.

version: # 메타
default: # 기본 프로파일

accounts: # *instance 단위* 메타
team:
context_mode: # isolated | shared
context_group: # shared 일 때 그룹 이름
continuity_mode: # 추가 공유 정책
enterprise:
...

profiles: # 프로바이더 프로파일 (glm, kimi, ollama, ...)

cliproxy: # OAuth 프록시 백엔드 설정
backend:
oauth_accounts:
providers:
routing:
strategy: # round-robin / fill-first
session_affinity:
session_affinity_ttl:

proxy: # 로컬 OpenAI-호환 프록시
profile_ports: # 프로파일별 포트 매핑
routing:
longContextThreshold:

cliproxy_server: # 원격/로컬 CLIProxy 서버
remote: { enabled, host, protocol, auth_token }
fallback: { enabled, auto_start }
local: { port, auto_start }

logging: # 로그 회전·보관
preferences: # theme, telemetry, auto_update

websearch: # ★ 8개 fallback 프로바이더
providers:
exa, tavily, brave, searxng, duckduckgo, gemini, opencode, grok

# --- 통합 (Claude Code 외부 도구들) ---
copilot: # GitHub Copilot 라우팅
cursor: # Cursor 통합 (ghost_mode 포함)
channels: # Telegram/Discord/iMessage
thinking: # opus/sonnet/haiku 별 thinking 기본값
global_env: # DISABLE_TELEMETRY 등 전역 env 자동 주입

이 9개를 한 번 훑으면 CCS가 단순 스위처가 아니라 Claude Code 주변의 운영 표면 전체를 흡수해 가고 있다는 인상이 분명해집니다. 12절에서 이 중 안 알려진 부분을 따로 추립니다.

Instance 시스템, symlink와 실 상태의 갈림

instances/<name>/ 디렉토리는 언뜻 보면 Claude Code의 모든 상태를 담은 통 같지만, ls -la로 열어 보면 절반은 shared/로의 symlink이고 나머지 절반만 인스턴스별 실 상태입니다. 둘을 갈라 두면 멘탈 모델이 명확해집니다.

먼저 모든 인스턴스가 공유하는 symlink 항목입니다.

항목가는 곳
settings.json~/.ccs/shared/settings.json~/.claude/settings.json
commands/~/.ccs/shared/commands~/.claude/commands/
skills/~/.ccs/shared/skills~/.claude/skills/
agents/~/.ccs/shared/agents~/.claude/agents/
projects/~/.ccs/shared/context-groups/default/projects/ (한 단계만)

settings.json의 최상위 키 9개는 체인을 따라가 본 결과 다음과 같습니다. 사용자의 기존 ~/.claude/settings.json 스키마 그대로입니다.

alwaysThinkingEnabled
enabledPlugins
env
extraKnownMarketplaces
hooks
permissions
skipAutoPermissionPrompt
skipDangerousModePermissionPrompt
statusLine

다음은 인스턴스별로 격리된 실 상태입니다.

분류항목
인증 (절대 안 섞임).anthropic/ (OAuth), .claude.json (사용자 메타)
세션/실행sessions/, session-env/, shell-snapshots/, history.jsonl
작업 상태todos/, file-history/, plans/ (team만), backups/
로깅/캐시logs/, cache/, debug/, image-cache/, paste-cache/
플러그인plugins/ — 인스턴스별 (claude-hud 같은 설치)
메타/정책policy-limits.json, remote-settings.json, mcp-needs-auth-cache.json, .session-stats.json
외부 통합.omc/ (oh-my-claudecode 상태)

한 가지 눈에 띄는 사실은 이 글의 plan 파일이 instances/team/plans/ccs-hashed-llama.md에 있다는 점입니다(이 항목은 real이고 symlink가 아닙니다). 이 글을 쓰는 동안 사용자의 Claude Code 세션이 team 인스턴스에서 돌고 있었다는 뜻입니다. CCS는 보이지 않게 CLAUDE_CONFIG_DIR 같은 환경변수로 인스턴스 컨텍스트를 끼워 넣습니다.

enterprise 인스턴스는 team보다 가볍게 비어 있는 상태였는데, 활성 사용 중인지 아닌지가 디렉토리 충실도에 그대로 드러납니다. CCS는 인스턴스를 만들 때 symlink와 빈 디렉토리만 만들어 두고, 실제로 사용해야 실 데이터가 채워지는 lazy 모델입니다.

shared/와 context-groups로 1편의 빈칸 메우기

1편의 "설정/대화는 공유하면서 계정만 분리하고 싶다면" 단락의 진짜 답이 여기 있습니다. CCS는 이 시나리오를 shared/라는 별도 영역과 context_mode/context_group 설정으로 정식 지원합니다.

config.yamlaccounts: 섹션:

accounts:
team:
context_mode: isolated # 또는 shared
context_group: default # shared 일 때만 의미
continuity_mode: deeper # 또는 default
enterprise:
context_mode: shared
context_group: default
continuity_mode: deeper

세 키의 역할:

context_mode: isolated | shared는 인스턴스가 자기 디렉토리에만 사는지, 같은 그룹의 다른 인스턴스와 공유하는지를 정합니다. context_groupshared일 때 쓸 그룹 이름이며, ~/.ccs/shared/context-groups/<group>/가 그 그룹의 공유 데이터 자리입니다. continuity_mode: deeper는 공유의 깊이를 정하며, deeper는 더 많은 디렉토리를 공유 대상에 포함합니다.

이 셋을 조합해 보면 1편에서 "공유 vs 분리"라고 단순화한 구도가 사실 세 축의 조합임이 드러납니다.

무엇이 공유 가능하고 무엇이 항상 격리되는가

CCS의 공식 docs는 이 부분을 명시해 두지 않습니다.

CCS only shares workspace context paths (project/session context files). It does not merge or copy authentication credentials between accounts.

docs/session-sharing-technical-analysis.md

요약 표:

분류대상동작
shared (조건부)session-env/shared + deeper 일 때 그룹 안에서 공유
shared (조건부)file-history/동일
shared (조건부)shell-snapshots/동일
shared (조건부)todos/동일
항상 격리.anthropic/ (OAuth)인증은 어떤 모드에서도 인스턴스별
항상 격리인증 토큰 / 자격증명동일
수동 공유commands/, skills/, agents/, plugins/shared/<dir>/에 두면 모든 인스턴스가 사용

인증은 어떤 경우에도 섞이지 않습니다. 한 인스턴스의 OAuth 토큰이 실수로 다른 인스턴스의 요청에 흘러갈 일이 구조적으로 없으므로, 회사 환경에서 가장 큰 사고 가능성을 시스템 자체가 막아 둡니다.

ccs -r (resume)는 현재 활성 lane만 이어가고, ccs <account> -r은 그 인스턴스의 lane만 이어갑니다. 두 인스턴스 모두 다른 continuity 인벤토리를 가질 수 있다는 점을 의식하고 운영해야 합니다.

4가지 진입점: Target Adapter System

ccs가 Claude Code만 호출하는 게 아닙니다. CCS는 runtime 자체를 바꾸는 4가지 바이너리를 노출합니다.

바이너리runtime
ccsClaude Code (기본)
ccsd / ccs-droidFactory Droid
ccsx / ccs-codexCodex CLI (네이티브)
ccsxpCodex CLI + CLIProxy 프로바이더 오버라이드

내부적으로는 각 바이너리가 CCS_INTERNAL_ENTRY_TARGET 환경변수를 세팅한 후 target resolver에 위임합니다. resolver의 우선순위:

  1. CLI 플래그 (--target)
  2. 진입 바이너리 자체
  3. argv[0] 이름 검출
  4. 프로파일별 config
  5. 기본값

이 추상화 덕분에 "Claude Code로 GLM 돌리기"와 "Droid로 GLM 돌리기"가 같은 명령 표면 위에서 가능합니다(ccs glm vs ccs --target droid glm). Codex의 경우 자기 ~/.codex/ 상태를 별도 보존하려고 환경변수만 임시로 덮어쓰는 식으로 신경을 더 씁니다.

로컬 프록시 (127.0.0.1:포트)의 5단계 흐름

ccs glm 같은 OpenAI-호환 프로바이더 명령이 들어오면 다음 5단계를 거칩니다(docs/openai-compatible-providers.md 정리).

  1. 127.0.0.1에서 그 프로파일의 로컬 포트 바인딩 (포트는 proxy.profile_ports에서 할당)
  2. Claude Code가 보내는 Anthropic 형식 /v1/messages 요청 수신
  3. OpenAI chat-completions 형식으로 변환
  4. 업스트림 프로바이더로 포워드
  5. 스트리밍 응답을 다시 Anthropic SSE로 역변환해 Claude Code에 돌려줌

정리하면 Claude Code는 자기가 Anthropic과 이야기하는 줄 알고, 변환은 프록시 한 점에서만 일어나므로 디버깅 지점도 한 곳으로 모입니다.

Anthropic-호환 엔드포인트는 프록시를 우회합니다. https://api.anthropic.com이나 Z.AI의 /api/anthropic으로 가는 요청은 변환이 필요 없어 그냥 직통하고, 이 분기 덕분에 GLM 사용은 추상화 비용이 거의 0입니다.

CLIProxyAPI 서버 자체는 기본적으로 port 8317에서 동작하는데, cliproxy_server.local.port로 바꿀 수 있고 remote.host를 켜면 다른 머신의 CLIProxy를 쓸 수도 있습니다.

시나리오 라우팅 4종

CCS가 수동으로 프로바이더를 명시하지 않아도 자동으로 라우팅하는 4가지 시나리오가 있습니다.

시나리오트리거
background요청이 Haiku를 포함 (가벼운 백그라운드 작업)
thinkAnthropic extended thinking 활성화
longContext토큰 추정치가 임계값 초과 (proxy.routing.longContextThreshold)
webSearchweb_search 툴 호출

config.yamlproxy.routing.longContextThreshold 키가 실제로 있어 임계값을 손으로 조절할 수 있습니다. 자동화의 깊이는 워크로드에 따라 잘 맞기도 하고 헷갈리기도 하므로, 처음부터 라우팅 룰을 복잡하게 짜기보다 한 주 동안 단순하게 운영한 뒤 패턴을 보고 추가하는 편이 낫습니다(1편 팁 7절 참조).

CLIProxy 서브시스템, OAuth 프로바이더의 자리

API 키가 없는 OAuth 기반 프로바이더(Gemini, GitHub Copilot, AWS Kiro 등)는 별도 서브시스템이 책임집니다. ~/.ccs/cliproxy/bin/에 자동 다운로드되는 CLIProxyAPI 바이너리가 그 핵심입니다.

이 서브시스템의 Account manager는 OAuth 토큰의 lifecycle(발급, 갱신, 만료)을 관리하고, Quota manager와 Quota fetcher는 각각 프로바이더별 quota 추적과 자동 failover, 사용량 실시간 동기화를 맡습니다. Auth handler는 Anthropic, Gemini, Copilot, Kiro 등 각 프로바이더의 OAuth 흐름을 처리합니다. Model catalogs에는 프로바이더별 모델 목록과 compatibility 가드가 있으며, codex-plan-compatibility.ts 같은 파일이 plan별로 맞지 않는 모델 조합을 막아 줍니다. Hybrid quota strategy는 round-robin 또는 fill-first(cliproxy.routing.strategy) 방식으로 같은 프로바이더의 여러 OAuth 계정을 어떻게 분산할지 정합니다.

config.yamlcliproxy.oauth_accounts가 등록된 OAuth 계정 목록을 담는데, 이 부분은 토큰을 포함하므로 git에 올리면 안 되는 영역입니다. 다음 11절에서 정리합니다.

파일 관리 실전: symlink가 dotfiles의 답입니다

2절의 symlink 체인을 보고 나면 자연스레 이런 질문이 나옵니다. 내 CCS 환경을 다른 머신에 어떻게 옮기는지, ~/.ccs/를 통째로 git에 넣어도 되는지 궁금해집니다. ~/.ccs/를 versioning하지 않고 ~/.claude/를 versioning해 CCS가 그쪽을 끌어들이게 하면 됩니다.

멘탈 모델: ~/.claude/가 source of truth

체인의 시작점은 ~/.claude/입니다. CCS의 shared/와 instance들은 그쪽으로 가는 포인터일 뿐입니다. 따라서 dotfiles의 운용 원칙은 하나로 압축됩니다. ~/.claude/를 git으로 관리해 두면, 새 머신에 CCS를 설치하더라도 shared/만 같은 symlink로 다시 걸어 모든 인스턴스가 같은 환경을 보게 됩니다.

git에 올리기 좋은 자산 (~/.claude/ 쪽)

~/.claude/commands/, ~/.claude/skills/, ~/.claude/agents/에는 각각 슬래시 커맨드, 스킬, 에이전트 정의가 들어가므로 git에 올리기 좋습니다. 글로벌 메모리인 ~/.claude/CLAUDE.md도 관리 대상입니다. ~/.claude/settings.json은 토큰을 직접 적어 두지 않은 경우에만 포함해야 하며, env 필드에 비밀이 있다면 settings.local.json 패턴으로 분리합니다. 실 디렉토리인 ~/.ccs/shared/context-groups/default/에서는 projects/를 빼고 CCS의 그룹 메타만 versioning하는 식으로 관리합니다.

~/.ccs/.claude/ (CCS 번들)은 npm 패키지 일부라 install 시 자동으로 따라옵니다. 직접 versioning 할 필요 없습니다.

절대 git 금지 (자격증명, 세션, 트랜스크립트)

~/.ccs/config.yaml에는 OAuth 토큰/refresh_token이 있으므로 머신별로 보관하고, 세션 비밀인 ~/.ccs/.session-secret도 제외합니다. ~/.ccs/instances/*/.anthropic/의 Anthropic OAuth와 계정 식별자를 포함한 ~/.ccs/instances/*/.claude.json도 올리면 안 됩니다.

~/.ccs/instances/*/sessions/, session-env/에는 세션과 세션별 env가 있어 토큰 환경변수가 들어갈 가능성이 있습니다. ~/.ccs/instances/*/history.jsonl, file-history/에는 명령과 파일 history가 남아 회사 코드의 흔적이 섞일 수 있습니다. 대화 히스토리 본체~/.ccs/shared/context-groups/*/projects/도 PII나 회사 코드를 포함할 가능성이 있으므로 제외합니다. 바이너리와 휘발성 데이터가 담긴 ~/.ccs/cliproxy/, cache/, logs/, 평소 Claude Code 대화 히스토리인 ~/.claude/projects/도 git에 올리지 않습니다.

새 머신 부트스트랩 4단계

dotfiles가 ~/.claude/만 들고 있다고 할 때 새 머신에서 같은 환경을 만들려면:

  1. dotfiles를 clone해 ~/.claude/를 채웁니다.
  2. npm install -g @kaitranntt/ccs로 CCS를 설치합니다.
  3. ccs config로 초기 ~/.ccs/ 골격을 생성합니다.
  4. symlink를 재구성합니다. ~/.ccs/shared/{commands,skills,agents,plugins,settings.json}~/.claude/의 동명 항목으로 link하며, 한 줄 스크립트로 자동화하기를 권장합니다.
ln -sf ~/.claude/commands ~/.ccs/shared/commands
ln -sf ~/.claude/skills ~/.ccs/shared/skills
ln -sf ~/.claude/agents ~/.ccs/shared/agents
ln -sf ~/.claude/plugins ~/.ccs/shared/plugins
ln -sf ~/.claude/settings.json ~/.ccs/shared/settings.json

이 다섯 줄로 새 머신에서도 1편의 instance들이 평소 환경을 그대로 봅니다. OAuth 인증만 각 인스턴스에서 새로 하면 끝입니다.

격리의 운영 이점

한 인스턴스가 망가져도(plugins/ 깨짐, policy-limits.json 누락 등) 다른 인스턴스는 그대로이므로 단일 장애점이 줄어드는 부수 효과가 있습니다. 인스턴스 단위 백업에는 tar czf team-backup.tgz -C ~/.ccs/instances team을 씁니다. symlink는 그대로 보존하며, 옵션 -h를 추가하면 링크가 가리키는 파일까지 따라가 archive합니다. 복원할 때 같은 dotfiles 환경을 가정하므로 보통은 symlink를 그대로 두는 편이 의미 있습니다.

config.yaml의 비공식 영역들

CCS의 README가 강조하지 않지만 config.yaml 키만 봐도 드러나는, 단순 스위처라기엔 풍부한 통합이 여럿입니다.

가장 눈에 띄는 것은 WebSearch fallback입니다. websearch.providers 아래에 exa, tavily, brave, searxng, duckduckgo, gemini, opencode, grok 여덟 곳이 등록돼 있어, Claude Code의 WebSearch가 Anthropic 밖 8개 프로바이더로 넘어갈 수 있습니다. docs/websearch.md가 별도 문서로 있을 만큼 일급 통합입니다. 외부 에디터/툴 통합도 넓어서, copilot.account_type, rate_limit, model로 GitHub Copilot을 프로바이더 한 슬롯처럼 다루고 cursor.ghost_mode/cursor.port로 Cursor의 ghost-mode까지 건드립니다. channels.selected, channels.unattended를 통해 Telegram, Discord, iMessage로 Claude에게 일을 시키는 통합도 있습니다(1편의 free-claude-code 텔레그램 봇 사례와 같은 결입니다).

세밀한 정책 키도 있습니다. thinking.tier_defaults는 opus, sonnet, haiku별로 thinking 기본 모드를 다르게 잡게 해주고(Opus는 항상 deep thinking, Haiku는 끄는 식), global_env.envDISABLE_BUG_COMMAND, DISABLE_ERROR_REPORTING, DISABLE_TELEMETRY는 보안, 프라이버시에 민감한 회사 환경에서 텔레메트리를 자동으로 꺼줍니다. cliproxy.routing.session_affinitysession_affinity_ttl은 같은 세션을 같은 OAuth 계정으로 sticky하게 묶는 정책입니다. 이런 영역들은 CCS가 단순 스위처에서 운영 플랫폼으로 진화 중이라는 신호입니다.

한 줄로

1편이 CCS의 표면이었다면 2편은 안쪽입니다. 안쪽을 보고 나면 "프로파일로 옮겨 다닌다"는 1편의 단순한 설명이 사실은 instance 격리, shared 공유, OAuth 분리, OpenAI와 Anthropic 사이의 변환, 시나리오 라우팅, 여러 통합 지점이라는 축들로 짜여 있다는 게 보입니다. 추상화가 두껍다는 1편의 단점 평가는 그래서 정확하지만, 그 두께가 풀고 있는 문제도 같이 두껍다는 사실이 디렉토리 한 통에 적나라하게 드러납니다.

다음 편이 있다면 두 갈래 중 하나일 가능성이 커요. proxy.routing 시나리오를 실제 워크로드로 맞춰 보는 실측 운영 글이거나, CCS와 dotfiles, Anthropic Workspaces를 엮는 멀티-머신 계정 운영 글일 거예요. 어느 쪽이 먼저 나올지는 다음 글에서 정할게요.

참고

systemd 유닛 11종

· 약 10분

"유닛"이라는 단어가 묶고 있는 것

SysVinit → Upstart → systemd: 리눅스 init 세대 연표의 마지막 단락은 이렇게 끝났어요.

systemd는 init만이 아니라 logind, journald, networkd, resolved, timedated 등 시스템 영역의 여러 컴포넌트를 흡수해 갔다.

이 글에서는 그 한 줄을 펼쳐 봅니다. 시스템 영역에서 systemd가 흡수해 간 것 중 가장 가시적인 흔적은 흩어진 옛 도구를 **유닛(unit)**이라는 단일 언어로 묶은 모습입니다. cron, fstab, inetd, autofs, inotify, runlevel, cgroup은 저마다 다른 시대에 다른 사람이 다른 이유로 만든 도구지만, *.service, *.timer, *.mount처럼 같은 모양의 파일 안으로 모여들었습니다.

서버에 들어가서 systemctl --type=help 한 번 실행해 보면 출력이 짧습니다. 11줄.

service
socket
target
device
mount
automount
timer
swap
path
slice
scope

이 11종이 무엇을 흡수했는지 먼저 표로 보여준 다음, 한 종류씩 풀어봅니다.

유닛흡수한 옛 도구
.service/etc/init.d/* 셸 스크립트
.socketinetd / xinetd / launchd
.timercron / anacron / at
.mountfstab
.automountautofs
.pathinotify 사용처 (디렉토리 감시 데몬)
.targetrunlevel
.slicecgroup 트리의 그룹 이름
.scope외부에서 만든 프로세스를 cgroup 으로 묶는 래퍼
.deviceudev 와의 다리
.swapswapon

.service: 옛 init.d 스크립트의 자리

가장 익숙한 유닛입니다. 1편의 hello-web.service 9줄 예제를 다시 떠올리면 충분합니다.

[Unit]
Description=hello web server
After=network.target

[Service]
ExecStart=/usr/local/bin/hello-web --port 8080
Restart=on-failure

[Install]
WantedBy=multi-user.target

중요한 부분은 Type=의 6종입니다(man systemd.service).

Type언제 쓰나
simple기본값. ExecStart 가 즉시 메인 프로세스
execsimple + 자식 exec 까지 끝나야 활성화 처리
forking옛 데몬 스타일. fork 후 부모는 종료하고 자식이 데몬으로 동작
oneshot일회성 작업 (마이그레이션, 셸 스크립트 등)
notify자식이 sd_notify(3) 로 "준비됨" 통지
dbusDBus 이름 등록 시점에 활성화 처리
idlesimple 변형. 다른 잡이 콘솔 출력을 끝낼 때까지 대기

옛 init.d 시절의 가장 큰 골칫거리였던 "데몬이 fork한 자식을 init이 추적하지 못한다"는 문제는 cgroup으로 깔끔하게 해결됐습니다(1편의 3절 참조). PID 파일 위조도 더블 fork도 cgroup 트리에서 빠져나가지 못합니다. Restart=on-failure 한 줄이 monit/supervisord의 자리를 흡수한 것도 이 추적 덕분입니다.

.service 한 종류만 따로 깊게 다룬 글을 한 편 더 쓸 만큼 옵션이 많습니다. 이 글에서는 이 정도만 살펴보겠습니다.

.socket: inetd → xinetd → launchd → systemd, 21년의 계보

.socket이 가져온 모델을 흔히 "socket activation"이라 부릅니다. 처음 들으면 systemd의 발명 같지만 사실 40년짜리 계보의 마지막 단계입니다.

연도도구한 줄
1980년대 초inetd (4.3BSD)슈퍼 서버. 소켓을 listen하다가 연결이 들어오면 데몬을 fork
1990년대 후반xinetdinetd 를 보안성 강화로 대체 (Panagiotis Tsirigotis(파나기오티스 치리고티스))
2005-04-29launchdMac OS X 10.4 Tiger 도입. Dave Zarzycki(데이브 자지키) 설계. 데몬을 미리 안 띄우고 첫 연결로 깨운다
2010systemd socket activationlaunchd에서 영감을 받아 Linux로 도입

1편에서 "macOS launchd에서 영감을 받았다"는 한 줄로 끝낸 부분의 21년짜리 사연입니다. 공교롭게도 launchd의 데뷔일은 정확히 21년 전 오늘입니다.

systemd가 자식에게 listen 소켓을 어떻게 넘기는지는 sd_listen_fds(3) man page에 정확히 적혀 있습니다.

The first file descriptor may be found at file descriptor number 3 (i.e. SD_LISTEN_FDS_START), the remaining descriptors follow at 4, 5, 6, ...

세 개의 환경변수가 따라옵니다. $LISTEN_PID는 이 fd들이 자기 것인지 PID 일치 여부를 검사하고, $LISTEN_FDS는 넘어온 소켓 개수를 나타냅니다. $LISTEN_FDNAMES에는 각 소켓의 라벨(FileDescriptorName=)이 담깁니다.

자식은 accept()만 하면 되고, listen은 systemd가 부팅 직후 미리 해 둔 상태입니다. 그 결과는 다음과 같습니다.

  • 부팅 시 데몬을 미리 띄우지 않아도 됩니다(첫 연결로 깨움)
  • 데몬을 재시작해도 listen 소켓이 살아 있어 연결이 잘리지 않습니다
  • 의존성도 자연스럽게 연결됩니다. A가 B의 소켓을 두드리면 systemd가 B를 알아서 깨웁니다

간단한 예로 sshd.socket을 보겠습니다.

[Unit]
Description=OpenSSH Server Socket

[Socket]
ListenStream=22
Accept=no

[Install]
WantedBy=sockets.target

Accept=yes면 연결마다 인스턴스화된 sshd@.service가 깨어납니다. 이것이 inetd의 원래 모델입니다.

.timer: cron / anacron / at의 후계

cron 표현식은 "분 시 일 월 요일" 5컬럼입니다. systemd의 OnCalendar=는 같은 일을 하지만 표현법이 다릅니다.

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=300

트리거 키워드는 6종입니다(man systemd.timer).

OnActiveSec=는 타이머가 활성화된 시점을 기준으로 삼고, OnBootSec=는 부팅 후 N초, OnStartupSec=는 systemd가 뜬 후 N초를 가리킵니다. 반복 잡에는 짝꿍 유닛이 마지막으로 활성화된 시점을 기준으로 하는 OnUnitActiveSec=나 마지막으로 멈춘 시점을 기준으로 하는 OnUnitInactiveSec=를 씁니다. OnCalendar=는 cron의 자리를 맡은 달력식입니다.

보조 키워드도 그대로 정확히 적어둡니다.

Persistent=true는 시스템이 꺼져 있던 시간을 보정해 부팅 직후 한 번 실행하며 anacron의 자리를 맡습니다. RandomizedDelaySec=는 N초 범위에서 무작위로 지연해 cron으로 풀기 어려운 thundering herd를 피합니다. AccuracySec=는 1분 단위로 묶어 깨워서 노트북 배터리와 디스크 사용을 줄이고, OnClockChange=OnTimezoneChange=는 시계가 점프했을 때 트리거됩니다.

타이머는 자기가 일을 하지 않습니다. .timer 는 짝꿍 .service 를 한 번씩 깨울 뿐입니다. 잡 본체는 .service 에 적습니다.

# /etc/systemd/system/certbot-renew.timer
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target
# /etc/systemd/system/certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet

cron과 비교하면 journald에 자동으로 기록되고, Wants= / After=로 의존성을 표현하며, 실패할 때 OnFailure=로 알림 잡을 트리거한다는 이점이 분명합니다.

.mount / .automount: fstab의 그림자와 autofs 흡수

리눅스를 오래 다룬 사람도 잘 모르는 사실이 있습니다. fstab은 부팅 시점에 그대로 쓰이지 않습니다. systemd-fstab-generator(8)가 끼어들어 fstab의 각 줄을 .mount.swap 유닛으로 변환한 다음 systemd의 의존성 그래프에 넣습니다.

systemd-fstab-generator is a generator that translates /etc/fstab into native systemd units... instantiating mount and swap units as necessary.

이름 규칙도 정해져 있습니다(man systemd.mount의 예시).

Mount units must be named after the mount point directories they control. Example: the mount point /home/lennart must be configured in a unit file home-lennart.mount.

따라서 /var/logvar-log.mount가 됩니다. 슬래시는 하이픈으로 바꾸고 첫 슬래시는 떼어냅니다. 이 변환 로직은 systemd-escape 명령으로 직접 확인할 수 있습니다.

$ systemd-escape -p --suffix=mount /var/log
var-log.mount

.automount는 autofs의 자리를 흡수했습니다. 마운트 지점에 처음 접근할 때 비로소 mount하고, 일정 시간 idle 상태가 이어지면 unmount합니다. NFS 같은 큰 볼륨을 lazy mount할 때 유용합니다.

# /etc/systemd/system/mnt-bigdata.automount
[Unit]
Description=Lazy mount for /mnt/bigdata

[Automount]
Where=/mnt/bigdata
TimeoutIdleSec=600

[Install]
WantedBy=multi-user.target

짝꿍 .mount가 함께 있어야 합니다(mnt-bigdata.mount). .automount는 트리거를 담당하고 .mount는 실제로 마운트합니다.

.path: inotify를 유닛 언어로

"파일이 생기거나 바뀌거나 사라지면 잡을 깨운다"는 패턴은 오래전부터 있었습니다. cron 폴링, inotifywait 스크립트, 별도의 파일 감시 데몬이 그 역할을 했습니다. .path는 이를 유닛 언어로 흡수했습니다.

키워드트리거 조건
PathExists=경로가 존재하면
PathExistsGlob=글롭 패턴이 매치되면
PathChanged=파일/디렉토리가 변경되면 (close-after-write)
PathModified=매 write 마다
DirectoryNotEmpty=디렉토리에 항목이 있으면
# /etc/systemd/system/upload-watch.path
[Path]
PathChanged=/srv/upload
Unit=upload-process.service

[Install]
WantedBy=multi-user.target

cron으로 1분마다 폴링하던 잡을 즉시 반응형으로 바꿀 수 있습니다. .timer와 마찬가지로 .path도 직접 일하지 않고 짝꿍 .service를 깨우는 모델입니다.

.target: runlevel의 후계

1편의 런레벨 표를 그대로 가져와 매핑하면 다음과 같습니다.

런레벨systemd target
0poweroff.target
1rescue.target
3multi-user.target
5graphical.target
6reboot.target

init 3 자리에 systemctl isolate multi-user.target 이 들어왔습니다.

런레벨은 단순한 모드 번호였습니다. target은 의존성 그래프 위의 동기화 지점입니다. network-online.target은 "네트워크가 실제로 도달 가능해질 때까지 기다리는 자리", local-fs.target은 "로컬 파일시스템이 모두 마운트된 자리", sockets.target은 "모든 socket activation listen이 끝난 자리"입니다.

/etc/systemd/system/default.target은 심볼릭 링크입니다. 데스크톱이면 graphical.target, 서버면 보통 multi-user.target으로 연결됩니다.

$ systemctl get-default
multi-user.target

.slice / .scope: cgroup 트리에 이름을 붙이는 두 형태

1편에서는 "cgroup 추적이 systemd 승리의 결정타였다"고 적었습니다. 그 cgroup 트리를 시스템에서 직접 들여다보게 해 주는 유닛이 .slice.scope입니다.

$ systemd-cgls
Control group /:
├─user.slice
│ └─user-1000.slice
│ ├─user@1000.service
│ └─session-3.scope
│ └─sshd 와 자식 프로세스들
├─system.slice
│ ├─nginx.service
│ ├─postgresql.service
│ └─sshd.service
└─machine.slice
└─runc 컨테이너들

최상위 슬라이스는 세 개로 나뉩니다. system.slice에는 시스템 데몬이, user.slice에는 로그인 사용자(login session)가, machine.slice에는 VM과 컨테이너가 속합니다.

**.scope**는 systemd가 직접 만들지 않은 프로세스, 예를 들어 SSH 로그인 세션이나 runc가 띄운 컨테이너를 cgroup으로 묶는 래퍼입니다. systemd 입장에서는 "외부에서 도착한 프로세스 무리에 이름표를 붙여 트리에 끼워 넣는" 도구입니다.

자원 제한은 슬라이스에든 서비스에든 들어갈 수 있습니다.

[Slice]
MemoryMax=4G
CPUQuota=50%
TasksMax=200

/etc/systemd/system/heavy-jobs.slice 한 파일에 4GB / 50% / 200 task 제한을 걸어두고, 거기에 속한 .service들이 그 한도를 공유하는 방식입니다.

.device / .swap: udev와 swapon의 다리

직접 작성하는 일이 거의 없는 두 종류입니다.

.device는 udev 이벤트로 자동 생성됩니다. /dev/sda가 인식되면 dev-sda.device가 자동으로 활성화되며, 다른 유닛에서 BindsTo=dev-sda.device처럼 의존성을 거는 데 주로 씁니다.

.swap은 fstab의 swap 항목을 앞에서 본 systemd-fstab-generator가 변환해 만듭니다. 직접 작성할 일이 거의 없고, 작성하더라도 Where= 대신 What=으로 디바이스를 지정합니다.

정직하게 말하면, 11종 중 운영자가 직접 작성하는 것은 사실상 6~7종이에요. .device.swap은 자동으로 생성되고 .scope도 외부 도구가 만듭니다. 직접 손으로 쓰는 것은 .service, .socket, .timer, .mount, .automount, .path, .target, .slice 정도입니다.

"init 비대화" 비판이 가리키는 것

여기까지 9개 섹션을 거치면 자연스럽게 한 그림이 보입니다. init 한 자리에 cron, fstab, inetd, autofs, inotify, runlevel, cgroup 도구가 모두 모여 있습니다. 1편에서 비껴간 "Unix 철학과 어긋난다"는 비판이 정확히 이 그림을 가리킵니다.

비판 진영의 가장 또렷한 한마디는 Slackware 창립자 Patrick Volkerding(패트릭 볼커딩)의 2013년 인터뷰에 나옵니다.

"I don't spend all day rebooting my machine, and having looked at systemd config files it seems to me a very foreign way of controlling a system to me, and attempting to control services, sockets, devices, mounts, etc., all within one daemon flies in the face of the UNIX concept of doing one thing and doing it well."

— Patrick Volkerding(패트릭 볼커딩), 2013

"services, sockets, devices, mounts, etc., all within one daemon"은 이 글이 2절부터 9절까지 보여준 11종 투어와 정확히 같은 그림을 묘사합니다. Volkerding은 그것을 부담으로 봤습니다.

반대편에서 Lennart Poettering(레나르트 푀터링)은 2010년 "Rethinking PID 1"에서 정반대 입장을 폈습니다. 단일 데몬이 의존성 그래프와 cgroup 추적을 한 자리에서 가지고 있어야 socket activation, parallel boot, 정확한 프로세스 정리가 일관되게 동작한다는 논지였습니다.

이 글은 어느 쪽이 옳다고 말하지 않습니다. 다만 두 진영이 가리키는 그림 자체, 곧 init 한 자리에 옛 도구 7~8종이 흡수된 모습은 같다는 데까지 보여줍니다. 그 그림을 어떻게 평가할지는 운영하는 시스템의 성격과 운영자의 취향에 달렸습니다.

한 줄로

1편이 "PID 1 자리에 누가 앉느냐"의 이야기였다면, 2편은 "그 자리에 앉은 것이 자기 영역을 어디까지 정의했느냐"의 이야기입니다. 유닛이라는 한 단어가 cron부터 cgroup까지 끌어안았다는 사실을 두고, 좋은 평가와 나쁜 평가가 같은 그림 위에서 출발합니다.

다음 편에서는 둘 중 하나를 더 깊이 파고들 예정이에요. .service 한 종류를 끝까지 살피는 운영자용 다이브, 또는 1편에서 중요하게 다룬 의존성 그래프(Wants= / Requires= / After=)가 실제로 풀리는 방식이에요. 어느 쪽이 먼저 나올지는 다음 글에서 정할게요.

참고

systemd 공식 man pages는 freedesktop.org가 upstream입니다. 아래 링크는 모두 그곳을 가리킵니다.

리눅스 init 세대 연표

· 약 12분

30년 버틴 init, 5년 만에 갈아치워진 표준

리눅스에서 PID 1로 부팅 직후 가장 먼저 실행되는 프로세스가 init이에요. 이 자리는 SysVinit이 30년 가까이 지켰지만 2010년대 초반부터 갑자기 풍경이 바뀌었어요. 2011년 Fedora 15에서 등장한 systemd가 5년 만에 거의 모든 메이저 배포판의 기본 init을 차지했고, 그 사이에는 Upstart가 잠깐 등장했다 사라졌죠.

이 글은 세 init 시스템을 기능 비교 대신 연표로 다룹니다.

  • 어떤 배포판이 언제 SysVinit을 버렸는가
  • Upstart는 왜 짧게 살았는가
  • systemd 채택을 둘러싼 Debian 투표, Devuan 분기, Ubuntu의 입장 변화는 어떻게 흘러갔는가
  • 지금도 systemd를 쓰지 않는 배포판은 어디인가

서버에 SSH로 들어가서 ps -p 1 -o comm=을 한 번 실행했을 때, 출력 한 줄이 어떤 역사 위에 서 있는지 정리해 보겠습니다.

세 세대, 한눈에

세대이름등장부팅 모델설정 단위대표 배포판 (전환 시점)
1세대SysVinit1983 (System V)직렬 / 런레벨/etc/inittab + /etc/init.d/*.sh거의 모든 리눅스 (~ 2010s)
2세대Upstart2006이벤트 기반/etc/init/*.confUbuntu 6.10 ~ 14.04, RHEL 6
3세대systemd2010병렬 / 의존성 + cgroup*.service, *.socket, *.timer, *.targetFedora 15+, RHEL 7+, Debian 8+, Ubuntu 15.04+, Arch, openSUSE 12.1+

세 시스템의 본질적 차이는 한 줄로 줄일 수 있습니다. SysVinit은 셸 스크립트를 순서대로 돌리고, Upstart는 이벤트가 발생하면 잡(job)을 돌리며, systemd는 의존성 그래프를 따라 유닛(unit)을 병렬로 돌린 뒤로도 계속 지켜봅니다.

1세대: SysVinit (1983~)

뿌리는 AT&T가 1983년에 낸 Unix System V입니다. 리눅스에는 1990년대 초 Miquel van Smoorenburg(미컬 반 스모렌부르흐)가 포팅한 sysvinit 패키지로 흘러들어왔고, 그 후 약 20년간 거의 모든 리눅스 배포판의 기본 init이 됐습니다.

핵심 구조는 단순합니다.

/etc/inittab → 어느 런레벨에 어떤 스크립트를 돌릴지 선언
/etc/init.d/* → 각 서비스의 start/stop/restart 스크립트
/etc/rc{0..6}.d/ → 런레벨별 심볼릭 링크 (S20foo, K80foo …)

런레벨(runlevel)은 시스템의 모드 번호입니다. 관습적으로 7개를 씁니다.

런레벨의미
0halt (종료)
1 (S)single user mode
2multi-user, 네트워크 없음 (Debian 계열은 네트워크 포함)
3multi-user, 네트워크 있음 (서버 기본값)
4미정의/사용자 정의
5multi-user + GUI (데스크톱 기본값)
6reboot

런레벨 진입 시 /etc/rcN.d/S로 시작하는 링크를 번호 순으로 실행하고, 빠져나갈 때 K 링크를 실행합니다. 부팅이란 곧 "셸 스크립트를 정해진 순서대로 한 줄씩 돌리는" 일이었습니다.

SysVinit의 한계

SysVinit이 30년을 살아남은 건 단순함 덕이고, 5년 만에 밀려난 것도 그 단순함 탓입니다.

가장 큰 문제는 직렬 부팅이었습니다. S20foo가 끝나야 S21bar가 시작되니, CPU가 놀고 있어도 두 서비스가 서로 무관해도 순서를 바꿀 수 없었고 이게 부팅이 느린 결정적 이유였습니다. 의존성 표현이 약한 것도 여기 얽힙니다. "DB가 떠 있어야 web이 뜬다" 같은 관계를 번호(S20, S21)로만 나타냈고, LSB 헤더가 의존성 기술을 거들긴 했지만 여전히 선형 정렬이 전제였습니다.

나머지 둘은 프로세스 관리 쪽입니다. 스크립트가 &로 백그라운드에 넘긴 자식을 init이 직접 추적하지 않아 PID 파일이 진실의 원천이 되는데, 이게 어긋나면 service foo status가 거짓말을 하고 자식이 더블 fork로 도망가면 더 곤란해집니다. 서비스가 죽었을 때 되살리는 것도 init 밖의 일이라, respawn을 inittab에 직접 걸거나 monit/supervisord 같은 외부 도구를 끌어와야 했습니다.

이 한계들이 2000년대 후반 "부팅이 빨라야 하는 노트북"과 "의존성 많은 데스크톱 환경"의 시대와 충돌했습니다. 다음 두 세대는 모두 이 중 하나 이상을 풀려고 출발합니다.

2세대: Upstart (2006~)

Canonical의 Scott James Remnant(스콧 제임스 렘넌트)가 만들었습니다. 첫 출시는 **Ubuntu 6.10 "Edgy Eft" (2006-10)**입니다. 이때부터 Ubuntu의 기본 init은 SysVinit이 아니라 Upstart였습니다(호환 모드로 SysV 스크립트도 돌렸습니다).

Upstart의 출발점은 "부팅은 단순한 순서가 아니라 일련의 사건들이다"라는 발상입니다. USB가 꽂히고 네트워크가 올라오며 디스크가 마운트되는 등, 이벤트가 발생할 때마다 그에 맞는 잡이 트리거되도록 모델을 짰습니다.

설정은 /etc/init/*.conf에 한 잡씩 둡니다.

description "Hello Web"

start on runlevel [2345]
stop on runlevel [!2345]

respawn
respawn limit 10 5

exec /usr/local/bin/hello-web --port 8080

respawn 한 줄로 자동 재시작이 해결되고, start on filesystem and net-device-up 식으로 이벤트 조합도 가능했습니다. SysVinit에 비하면 진짜 진보였습니다.

채택도 빨랐습니다. **Ubuntu 9.10 "Karmic Koala" (2009-10)**에서 SysV 호환 레이어 없이 native Upstart 부팅을 기본화했고, **RHEL 6 (2010-11)**이 정식 채택했습니다. Google의 ChromeOS와 일부 Fedora 릴리스도 한때 Upstart를 썼습니다.

Upstart가 짧게 살았던 이유

그런데 RHEL 6 이후 Upstart의 채택은 거기서 멈춥니다. 2010년대 초반 새로 등장한 systemd로 흐름이 갈아탔고 Ubuntu 본진마저 결국 같은 길을 갔는데, 단순히 "더 좋은 게 나왔다"로는 설명이 부족합니다.

가장 자주 꼽히는 건 Canonical의 CLA 정책입니다. Upstart 코드에 기여하려면 Canonical이 요구하는 Contributor License Agreement에 동의해야 했는데, 받은 코드를 상용으로 재배포할 권리를 회사에 양도하는 형태라 다른 진영이 부담스러워했습니다. 같은 시기 Linux 커널을 비롯한 여러 핵심 프로젝트가 CLA 없이 기여를 받던 흐름과 대조됐습니다.

기술적 한계도 겹쳤습니다. "A 이후 B" 같은 단순 의존이 이벤트 모델로는 어색했던 반면, systemd가 들고 나온 의존성 그래프와 Wants= / Requires= / After= 선언이 더 자연스럽다는 평가가 빠르게 자리 잡았습니다. 자식 프로세스 추적도 Upstart는 SysVinit과 비슷한 한계 안에 머물러, cgroup을 fork-bomb에도 끄떡없는 추적 수단으로 쓴 systemd에 비하면 약했습니다. 여기에 Fedora와 RHEL이 systemd를 적극적으로 밀면서 큰 생태계 플레이어 하나가 Upstart 진영에서 빠져나갔고, RHEL 6의 Upstart는 단명한 뒤 RHEL 7부터 systemd로 갔습니다.

결정타는 Debian과 Ubuntu가 차례로 systemd로 넘어간 시점입니다. 그 부분은 뒤에서 따로 정리합니다.

3세대: systemd (2010~)

2010년 4월, Red Hat의 Lennart Poettering(레나르트 푀터링)과 Kay Sievers(카이 지버스)가 발표한 글 한 편("Rethinking PID 1")으로 등장합니다. 핵심 아이디어는 세 가지였습니다.

첫째는 의존성 기반 병렬 부팅입니다. 유닛(*.service, *.socket, *.target 등)이 의존성을 명시하고, 의존성이 풀린 것부터 병렬로 출발합니다. 둘째는 소켓 활성화(socket activation)로, 데몬을 미리 띄우는 대신 systemd가 먼저 listen 소켓을 열어두고 첫 연결이 들어올 때 데몬을 깨웁니다. macOS의 launchd에서 영감을 받은 모델입니다. 셋째는 cgroup 기반 프로세스 추적입니다. 서비스가 fork한 자식과 손자까지 cgroup에 묶여 정확히 추적/정리되므로 PID 파일 위조나 더블 fork로 도망갈 수 없습니다.

이 세 가지가 동시에 풀린 게 결정적이었습니다. 이전 세대가 풀지 못한 한계를 한꺼번에 정리했고, 부팅 시간 단축이라는 가시적 효과도 따라왔습니다.

다만 systemd는 init만이 아니라 logind, journald, networkd, resolved, timedated 등 시스템 영역의 여러 컴포넌트를 흡수해 갔고, "Unix 철학과 어긋난다"는 비판도 같은 시기에 나왔습니다. 이 글은 그 논쟁에 한 발 들이지 않고, 채택 흐름만 따라갑니다.

systemd 채택 연표

발표 시점부터 메이저 배포판들의 채택 시점을 연도순으로 보면 흐름이 분명해집니다.

연/월사건
2010-04systemd 첫 발표 (Lennart Poettering, Kay Sievers)
2011-05Fedora 15: 메이저 배포판 중 첫 systemd 기본 채택
2011-11openSUSE 12.1: systemd 기본
2012-05Mageia 2: systemd 기본
2012-10Arch Linux: SysVinit에서 systemd로 전환
2013CoreOS: 출범부터 systemd가 핵심 (컨테이너 호스트 OS)
2014-06RHEL 7 / CentOS 7: 엔터프라이즈 표준이 바뀐 분기점
2014-11Debian Technical Committee 투표: systemd를 Jessie의 기본 init으로 결정
2014-11-27Devuan 분기 발표: "Veteran Unix Admins" 명의
2015-04Debian 8 "Jessie" 정식 출시 (systemd 기본)
2015-04-23Ubuntu 15.04 "Vivid Vervet": Upstart에서 systemd로
2017-05-25Devuan 1.0 "Jessie": Debian 기반, systemd 없는 첫 안정판

4년 (2011~2015) 만에 Fedora, RHEL, Debian, Ubuntu, openSUSE, Arch가 모두 systemd로 정렬됩니다. 이 정도 속도로 init 같은 핵심 컴포넌트가 통일된 적은 리눅스 역사에 거의 없습니다.

Debian의 투표와 Devuan 분기

Debian은 의사결정에 시간이 오래 걸리는 프로젝트입니다. systemd 채택도 예외가 아니어서 2013년 후반부터 Technical Committee 안에서 격론이 오갔고, 결국 2014년 11월 표결로 Debian 8 "Jessie"의 기본 init은 systemd로 결정됩니다.

표결 자체는 그것대로 정리됐지만, 같은 결의에 끼어 있던 또 다른 항목, 즉 "패키지가 systemd 의존성을 강제로 걸 수 있느냐"가 분기를 불렀습니다. 결의는 "다른 init 시스템 지원이 권장되지만 의무는 아니다(recommended, but not mandatory)"로 나왔습니다. 패키지가 systemd 외에 안 돌게 만들어도 막지 않는다는 의미였습니다.

이 결과에 반발한 일부 Debian 사용자/개발자가 "Veteran Unix Admins" 이름으로 2014년 11월 27일 Devuan 분기를 발표합니다. 약 2년 반의 패키지 감사/수정 끝에 2017년 5월 25일 Devuan 1.0 "Jessie"가 나왔습니다. Debian 8을 베이스로 systemd 훅을 모두 들어내고 SysVinit(또는 OpenRC)을 기본 init으로 하는 버전입니다.

Devuan은 이후로도 Debian을 한 단계씩 따라가며 출시를 이어가고 있습니다. 규모는 작지만 "systemd 없이도 Debian 생태계를 쓰고 싶다"는 수요에 답하는 진영으로 살아남았습니다.

Ubuntu가 자존심을 접은 결정

Ubuntu에게 init은 단순한 부품이 아니었습니다. Upstart는 Canonical의 자체 프로젝트였고, 거의 10년간 Ubuntu의 기본 init이었습니다. 그런데 Debian이 systemd로 결정한 직후, Mark Shuttleworth(마크 셔틀워스)는 "Ubuntu도 upstream(Debian)과 보조를 맞추겠다"고 발표합니다.

마이그레이션은 비교적 부드러웠습니다. Ubuntu 15.04 "Vivid Vervet"(2015-04-23)에서 기본 init이 systemd로 전환됐고, Ubuntu Touch(모바일)만 예외였습니다. 이후 15.04부터 16.10까지는 부팅 시 GRUB에서 Upstart와 systemd를 고를 수 있는 듀얼 부팅 기간을 유지했는데, 회귀가 생겼을 때 도망갈 길을 일정 기간 열어둔 운영적 선택이었습니다. 16.10 이후 Upstart 옵션이 제거되면서 이때부터 Ubuntu는 완전히 systemd 단독으로 갔습니다.

Canonical 입장에서는 자기 프로젝트를 접고 경쟁 프로젝트를 받아들인 결정이었지만, 그 무렵엔 systemd가 사실상 표준이 된 상태였고 Debian과 다른 init을 유지하는 비용이 더 커졌습니다.

systemd를 안 쓰는 배포판들

2026년 현재도 systemd가 아닌 init을 기본으로 쓰는 배포판이 남아 있습니다. 컨테이너 베이스 이미지나 임베디드, 보수적 운영을 위한 선택지로 의외로 자주 등장합니다.

배포판기본 init비고
Alpine LinuxOpenRC컨테이너 베이스 이미지 점유율이 높음. musl + busybox + OpenRC 조합
Void Linuxrunit단순함과 빠른 부팅이 강점
GentooOpenRC (기본) / systemd 옵션profile 선택으로 둘 다 사용 가능
DevuanSysVinit / OpenRCDebian 8 분기 후 독자 노선
SlackwareBSD-style init15.0 (2022) 시점에도 SysV가 아닌 BSD 스타일 유지
Artix LinuxOpenRC / runit / s6 / dinitArch 기반의 systemd-free 분기

여기서 중요한 점이 하나 있습니다. Alpine은 컨테이너 이미지 시장에서 표준급 점유율을 가지고 있습니다. "제 노트북은 Ubuntu고 systemd만 만져봤다"고 해도, 컨테이너에 FROM alpine:... 한 줄을 넣는 순간 OpenRC 기반 시스템과 만납니다. 다만 컨테이너 안에서는 init이 거의 의미를 갖지 않는다는 별도의 이슈가 있는데, 그건 마지막에서 다시 봅니다.

같은 작업, 세 가지 표현

세 init 시스템의 차이를 가장 빨리 느끼는 방법은 같은 서비스를 세 가지 형식으로 옆에 두고 보는 것입니다. hello-web이라는 가상의 HTTP 서버를 부팅 시 자동 기동하고 죽으면 자동 재시작하도록 등록한다고 해보겠습니다.

SysVinit: /etc/init.d/hello-web

#!/bin/sh
### BEGIN INIT INFO
# Provides: hello-web
# Required-Start: $network $remote_fs
# Required-Stop: $network $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: hello web server
### END INIT INFO

DAEMON=/usr/local/bin/hello-web
PIDFILE=/var/run/hello-web.pid

case "$1" in
start)
start-stop-daemon --start --background \
--make-pidfile --pidfile $PIDFILE \
--exec $DAEMON
;;
stop)
start-stop-daemon --stop --pidfile $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop; sleep 1; $0 start
;;
status)
[ -f $PIDFILE ] && kill -0 $(cat $PIDFILE) 2>/dev/null \
&& echo "running" || echo "stopped"
;;
*)
echo "Usage: $0 {start|stop|restart|status}"; exit 1
;;
esac

그 후 update-rc.d hello-web defaults(Debian 계열) 또는 chkconfig hello-web on(RHEL 계열)으로 런레벨 링크를 만들어야 합니다. 자동 재시작은 별도 도구가 필요합니다.

Upstart: /etc/init/hello-web.conf

description "hello web server"

start on runlevel [2345]
stop on runlevel [!2345]

respawn
respawn limit 10 5

exec /usr/local/bin/hello-web --port 8080

스크립트가 아니라 선언문입니다. start-stop-daemon, PID 파일, status 분기, 런레벨 링크가 전부 사라졌습니다. respawn 한 줄로 자동 재시작도 끝납니다.

systemd: /etc/systemd/system/hello-web.service

[Unit]
Description=hello web server
After=network.target

[Service]
ExecStart=/usr/local/bin/hello-web --port 8080
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

등록은 systemctl enable --now hello-web 한 줄. 의존성을 After=로 명시하고, 재시작 정책도 Restart=on-failure로 명확합니다. 자식 프로세스 추적은 cgroup이 알아서 해줍니다.

같은 의도가 41줄에서 9줄, 다시 12줄로 짧아집니다. 줄어든 만큼 init이 책임지는 부분과 사용자가 짜야 하는 부분의 경계가 옮겨갔다는 뜻입니다.

컨테이너 시대의 init: PID 1 문제

여기까지가 "호스트 OS의 init" 이야기였습니다. 그런데 컨테이너 시대로 들어오면 init의 의미가 한 번 더 뒤집힙니다.

도커 컨테이너 안에서 PID 1은 실제 init 시스템 대신 사용자가 실행한 프로세스입니다. CMD ["node", "server.js"]면 node가 PID 1이 됩니다. 그런데 PID 1에는 두 가지 특별한 책임이 있습니다.

하나는 좀비 자식 프로세스 수확입니다. 자식이 죽으면 부모가 wait()으로 거둬야 좀비가 정리되는데, PID 1이 이걸 안 하면 좀비가 영구히 쌓입니다. 다른 하나는 시그널 처리입니다. 커널은 PID 1에게 기본 시그널 핸들러를 붙여주지 않아서, 명시적으로 처리하지 않으면 SIGTERM이나 SIGINT가 무시됩니다.

대부분의 애플리케이션은 이 두 가지를 신경 쓰고 만들어지지 않았습니다. 그래서 컨테이너 생태계에는 경량 init들이 등장했습니다.

tini는 Docker가 --init 플래그로 채택한 사실상 표준입니다. dumb-init은 Yelp가 만든 alternatives이고, s6-overlay는 컨테이너 안에서 멀티 프로세스를 다룰 때 씁니다.

Kubernetes는 이 문제를 한 단계 더 위에서 다룹니다. Pod 종료 시 컨테이너 PID 1에 SIGTERM을 보내고 grace period 후 SIGKILL로 가는데, 앱이 SIGTERM을 안 잡으면 매번 강제 종료가 발생합니다. (이 흐름은 SIGINT, SIGTERM, SIGHUP, SIGKILL: 쿠버네티스 시대의 유닉스 시그널에서 깊게 다뤘습니다.)

호스트의 init은 의존성과 부팅 속도를 고민하지만, 컨테이너의 PID 1은 시그널 전파와 좀비 수확이라는 더 원초적인 책임으로 돌아갑니다.

한 줄로

SysVinit의 30년은 단순함이 호환성을 만든 시대였고, systemd의 5년은 의존성과 cgroup이 표준을 만든 시대였어요. 그 사이의 Upstart는 좋은 아이디어 한 가지로 잠깐 빛났지만 큰 생태계의 흐름을 못 이긴 사례로 남았고, PID 1 자리에 무엇이 앉느냐는 결국 그 시대 운영체제가 무엇을 가장 중요하게 여기는지를 비추는 거울이에요.


→ 다음 편: systemd 유닛이라는 언어: 11종을 한 바퀴. 이 글에서 살짝만 비춘 유닛 11종이 cron, fstab, inetd, autofs, inotify 같은 옛 도구를 어떻게 흡수했는지 한 바퀴 돕니다.

참고

쿠버네티스 유닉스 시그널

· 약 7분

오래된 주제와 새로운 무대

유닉스 시그널은 운영체제 수업 첫 단원에서 가볍게 훑고 지나가는 주제예요. 그런데 Docker, Kubernetes, systemd, PM2 같은 프로세스 오케스트레이터들이 일상이 된 지금, 이 네 개의 시그널은 오히려 운영 사고의 주요 원인이 되고 있어요.

  • "Docker stop을 했는데 왜 10초 뒤에야 죽나요?"
  • "K8s rolling update 중 request가 잘립니다"
  • "Ctrl+C가 안 먹히는데 뭐가 잘못됐죠?"
  • "nohup으로 돌렸는데 SSH 끊기니까 죽었어요"

전부 시그널 이해와 직결된 질문입니다. 한 번 제대로 정리해두면 두고두고 도움이 됩니다.

네 시그널 개요

시그널번호기본 동작핸들 가능대표 발생 경로
SIGINT2프로세스 종료캐치 가능터미널 Ctrl+C
SIGTERM15프로세스 종료캐치 가능kill PID, docker stop, K8s pod 종료
SIGHUP1프로세스 종료캐치 가능터미널 세션 종료, 관용적으로 "config reload"
SIGKILL9즉시 종료불가kill -9, Docker grace period 초과, OOM killer

핵심 차이는 마지막 칼럼인 핸들 가능 여부입니다. SIGKILL만 프로세스가 가로챌 수 없습니다. 이것이 왜 중요한지는 뒤에서 자세히 살펴봅니다.

SIGINT

SIGINT는 우리가 가장 자주 만나는 시그널입니다.

터미널에서 Ctrl+C를 누르면 현재 포그라운드 프로세스 그룹에 SIGINT가 날아갑니다. "사용자 인터럽트"의 약자로 interrupt에서 왔습니다.

프로세스는 이 시그널을 받으면 다음과 같이 동작합니다.

  • 기본적으로는 즉시 종료
  • 핸들러를 등록하면 정리 후 종료 가능 (열린 파일, DB 트랜잭션 롤백 등)
  • 완전히 무시할 수도 있음 (일부 REPL이 이렇게 동작)

Ctrl+C가 안 먹는 경우들

  • 자식 프로세스가 별도 세션으로 분리돼 있을 때 (setsid)
  • 프로세스가 I/O 블록 상태(디스크, 네트워크)라 인터럽트 후에도 시스템 콜 복귀가 늦을 때
  • 핸들러가 SIGINT를 잡아놓고 일부러 안 끝낼 때
  • TUI 앱(vim, tmux 등)이 터미널 raw 모드로 Ctrl+C를 키 입력으로 받아먹을 때

Ctrl+C가 안 먹히면 보통 SIGTERM을 쏴보는 게 다음 수순입니다. kill PID (기본값이 SIGTERM).

SIGTERM

SIGTERM은 정중한 종료 요청이며, 가장 중요한 시그널이자 현대 운영 환경의 기본 종료 경로입니다.

kill PID를 인자 없이 쓰면 기본이 SIGTERM입니다. docker stop CONTAINER는 컨테이너 PID 1에 SIGTERM을 전송하고, Kubernetes의 Pod 종료가 시작될 때도 각 컨테이너에 SIGTERM을 보냅니다. systemd가 unit을 멈출 때와 PM2, foreman, supervisor 등 프로세스 매니저가 stop할 때도 기본 시그널은 SIGTERM입니다.

SIGTERM은 "그만 종료해 주세요" 라는 의사 표현입니다. 프로세스가 이 시그널을 캐치할 수 있고, 원하는 시간 동안 정리할 수 있습니다.

Graceful shutdown 패턴 (Go 예제)

프로덕션 서버가 SIGTERM을 받았을 때 해야 할 일은 대체로 다음과 같습니다.

  1. 신규 요청 수신을 중단
  2. 진행 중인 요청을 완료할 때까지 기다림
  3. DB 커넥션, 파일, 임시 자원 정리
  4. 종료

Go 1.16+에서 signal.NotifyContext를 쓰면 깔끔합니다.

package main

import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := &http.Server{Addr: ":8080"}

go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()

<-ctx.Done() // SIGINT/SIGTERM 대기

shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("bye")
}

25초 타임아웃은 K8s 기본 grace period(30초)보다 살짝 짧게 잡는 것이 관행입니다. grace period 안에 정리를 끝내고 자진 종료하기 위해서입니다.

Node.js 예제

const server = app.listen(3000)

const shutdown = (signal) => async () => {
console.log(`${signal} 수신, 정리 중...`)
server.close(() => process.exit(0))
// 25초 지나도 종료 안 되면 강제
setTimeout(() => process.exit(1), 25_000).unref()
}

process.on('SIGTERM', shutdown('SIGTERM'))
process.on('SIGINT', shutdown('SIGINT'))

Python 예제

import signal, sys, time

def shutdown(signum, frame):
print(f"signal {signum} 수신, 정리 중...")
# cleanup code here
sys.exit(0)

signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)

while True:
time.sleep(1)

SIGKILL

SIGKILL을 아껴 써야 하는 이유가 있습니다. kill -9 PID는 개발자들의 오랜 습관입니다 — 하지만 프로세스에게 정리할 기회를 주지 않습니다.

  • 커널이 프로세스를 즉시 종료
  • 프로세스는 시그널을 캐치하거나 무시하거나 핸들러를 등록할 수 없다 (불가능)
  • DB 커넥션이 절반만 닫힌 상태로 방치, 파일이 잠금 해제되지 않은 채 남을 수 있음
  • 쓰기 중이던 데이터가 flush 안 된 상태로 증발

쓸 만한 때

  • SIGTERM을 보냈는데 충분히 기다린 뒤에도 안 죽음 (핸들러 무한 루프 등)
  • Docker grace period 초과 → Docker가 자동으로 SIGKILL 전송
  • K8s grace period 초과 → kubelet이 SIGKILL
  • OOM killer가 메모리 확보 위해 희생자로 선정

즉 순서는 항상 SIGTERM → 대기 → (안 되면) SIGKILL입니다. 처음부터 kill -9로 가는 것은 게으름이거나 데이터 손상 리스크를 감수하는 행위입니다.

SIGHUP

SIGHUP은 의미가 뒤집힌 시그널입니다. 이름 그대로 "HangUp"에서 왔습니다. 1970년대 전화 연결이 끊기면 모뎀이 보내주던 신호입니다. 지금은 그 용도가 거의 사라졌지만 두 가지로 남아 있습니다.

의미 1: 터미널 세션 종료

SSH로 원격 서버에 접속해서 명령을 실행하고 접속을 끊으면, 그 자식 프로세스들에 SIGHUP이 갑니다. 기본 동작은 종료입니다.

그래서 이를 피하는 도구들이 생겼습니다. nohup은 SIGHUP 무시 플래그를 설정하고 실행하는 "no hup"이고, disown은 셸의 job 테이블에서 프로세스를 제거해 SIGHUP 전파를 끊습니다. setsid는 새 세션을 만들어 터미널과의 연결 자체를 분리합니다. tmux나 screen은 결이 조금 다른데, 세션을 원격 호스트의 상주 데몬이 잡고 있어 클라이언트 연결이 끊겨도 프로세스가 유지됩니다.

주의: nohup은 SIGHUP만 막습니다. SIGTERM, SIGKILL은 그대로 받습니다. 시스템 재부팅/종료 때도 보호하지 못합니다.

의미 2: 설정 리로드 관용

현대 데몬들은 SIGHUP을 "설정 다시 읽어라"라는 신호로 재해석해서 씁니다. 공식 표준은 아니지만 사실상 관례입니다. 대표적인 예가 nginx입니다. kill -HUP이나 nginx -s reload를 받으면 마스터가 새 설정으로 워커를 띄우고 기존 워커는 현재 연결이 끝나면 종료합니다. rsyslog/syslog-ng도 같은 방식으로 설정을 리로드하고, haproxy도 리로드용으로 씁니다(최근 버전은 seamless-reload를 따로 둡니다). postgres 역시 pg_reload_conf()kill -HUP postmaster로 설정을 다시 읽습니다.

HUP의 원래 의미와는 전혀 상관없는 용도인데, 이제는 이쪽이 더 유명합니다.

쿠버네티스의 Termination 시퀀스

K8s에서 Pod를 지우면 내부적으로 다음 순서로 돕니다.

  1. Pod의 status가 Terminating으로 변경
  2. 서비스의 endpoint에서 해당 Pod 제거 (신규 트래픽 차단)
  3. preStop hook 실행 (정의돼 있다면)
  4. 각 컨테이너의 PID 1에 SIGTERM 전송
  5. Termination grace period (기본 30초) 대기
  6. 그래도 살아 있으면 SIGKILL

여기서 3번 preStop과 4번 SIGTERM 사이의 순서가 중요합니다. 많은 이가 preStop이 먼저 실행되고 그 끝에 SIGTERM이 간다고 오해하는데, 실제로는 둘이 사실상 동시에 시작됩니다(preStop은 먼저 호출되지만 SIGTERM 전송을 막지 않는다는 것이 정확한 표현입니다). preStop은 주로 "LB에서 빠질 시간을 벌기" 같은 용도로 사용합니다.

grace period 설정

spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
# ...
lifecycle:
preStop:
exec:
command: ["sleep", "10"] # LB가 endpoint 제거를 반영할 시간

앱 내부의 graceful shutdown 타임아웃은 이 값보다 짧게 잡습니다.

흔한 함정들

Dockerfile의 shell form ENTRYPOINT

# 나쁜 예 — /bin/sh가 PID 1이 되어 SIGTERM을 흡수
CMD node server.js

# 좋은 예 — node가 직접 PID 1
CMD ["node", "server.js"]

shell form(문자열)은 sh -c로 감싸져 실행되는데, sh는 기본적으로 시그널을 자식에게 전파하지 않습니다. docker stop이 SIGTERM을 PID 1(sh)에 보내지만 실제 앱은 그것을 받지 못합니다. 결국 10초를 기다렸다가 SIGKILL로 박살납니다.

긴 preStop을 graceful shutdown 대체용으로 쓰지 말 것

preStop: sleep 60 같은 것으로 "종료 시간을 버는" 것은 꼼수에 가깝습니다. 앱이 실제로 정리하는 것이 아니라 단지 종료를 늦출 뿐입니다. 제대로 된 해법은 앱에 SIGTERM 핸들러를 두는 것입니다.

Python의 KeyboardInterrupt는 SIGINT만

try/except KeyboardInterrupt는 SIGINT만 잡습니다. SIGTERM은 안 잡힙니다. 컨테이너에서 돌리는 Python이면 반드시 signal.signal(signal.SIGTERM, ...)을 등록해야 합니다.

Node.js에서 동기 블로킹 작업

이벤트 루프를 오래 점유하는 동기 작업(큰 파일 sync read, 무거운 crypto 연산 등)은 process.on('SIGTERM', ...) 핸들러조차 실행하지 못합니다. Node 프로세스가 시그널을 처리할 기회를 얻지 못하기 때문입니다. graceful shutdown이 의미 있으려면 이벤트 루프를 풀어주는 코드여야 합니다.

nohup 과신

nohup long_task.sh &로 돌려놓았다고 "이제 안전하다"라고 여기기 쉽지만, SIGHUP만 막습니다. reboot나 SIGTERM 앞에서는 무력합니다. 장기 작업은 systemd 유닛이나 tmux 세션 쪽이 더 안전합니다.

한 줄로

"SIGTERM을 잡을 줄 아는 코드"와 "SIGTERM을 쏠 줄 아는 운영자"가 만나면 SIGKILL이 필요 없는 날이 많아져요. 컨테이너 오케스트레이션 시대에 이 오래된 주제가 다시 중요해진 이유는 결국 이것이에요.

참고