본문으로 건너뛰기

"Kubernetes" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

모든 태그 보기

CNPG exporter 취약점 점검

· 약 14분

CloudNativePG metrics exporter는 1.29.1과 1.28.3 이전까지 postgres superuser로 접속한 뒤 SET ROLE pg_monitor로 권한을 낮춰 쿼리를 실행했어요. SET ROLE은 current_user만 바꾸고 session_user는 그대로 두기 때문에, scrape 세션 안에서 평가되는 SQL 식이 RESET ROLE로 superuser를 되찾을 수 있었습니다. 이 결함이 CVE-2026-44477(GHSA-423p-g724-fr39)이에요. 이 글은 방어와 점검 관점에서 원인, 수정 내용, 업그레이드 순서, 점검 쿼리를 정리하고 공격 절차는 다루지 않습니다. PostgreSQL 쪽 동작은 순수 PostgreSQL 컨테이너에서 하나씩 측정했고, exploit 체인은 재현하지 않았습니다.

요약​

항목내용
식별자CVE-2026-44477, GHSA-423p-g724-fr39
공개2026-05-08 (저장소 advisory와 패치 릴리스 같은 날)
심각도Critical, CVSS 3.1 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CWECWE-250, CWE-271, CWE-426
영향 버전1.28.3 미만, 1.29.0
해결 버전1.28.3, 1.29.1
필요 권한scrape 대상 database 안에서 search_path에 걸리는 객체를 만들 수 있는 계정
결과PostgreSQL superuser 획득, primary Pod 안에서 postgres OS 사용자로 명령 실행
제보Mehmet Ince

영향 범위와 해결 버전은 GitHub advisory의 패키지 범위(< 1.28.3, >= 1.29.0, < 1.29.1)와 1.29.1 릴리스 노트, 1.28.3 릴리스 노트에서 확인했습니다. 1.27 이하 라인에는 별도 패치 릴리스가 없습니다. 1.27.x는 2026-03-09에 이미 지원이 끝났습니다.

경고

2026-06-29에 같은 계열의 후속 취약점 CVE-2026-55769(GHSA-x8c2-3p4r-v9r6)가 공개됐고 1.28.4, 1.29.2, 1.30.0에서 수정됐습니다. 1.29.1이나 1.28.3에서 멈추면 이 후속 건이 남습니다. 업그레이드 목표는 아래 "업그레이드" 절을 참고하십시오.

원인: SET ROLE 뒤에 남는 superuser 신원​

수정 전 exporter는 Pod 로컬 Unix socket으로 postgres에 접속했고, 쿼리마다 read-only 트랜잭션을 열어 다음을 실행했습니다. 수정 commit의 createMonitoringTx에서 지워진 줄입니다.

// Set the pg_monitor role
_, err = tx.Exec("SET ROLE TO pg_monitor")

advisory는 이 구조를 연쇄의 출발점으로 봅니다. 요지는 세 문장입니다.

  • SET ROLE은 current_user만 바꾸고 session_user는 postgres로 남습니다.
  • scrape 세션에서 평가되는 SQL 식은 무엇이든 RESET ROLE로 superuser를 되찾고, COPY ... TO PROGRAM으로 Pod 안에서 프로세스를 띄울 수 있습니다.
  • READ ONLY 트랜잭션은 데이터베이스 상태에 대한 쓰기를 막을 뿐, 외부 프로세스 실행은 막지 않습니다.

아래 세 절은 이 문장들을 PostgreSQL 18.4 컨테이너에서 하나씩 확인한 결과입니다. 공격 객체와 권한 회복을 한 번에 엮는 코드는 쓰지 않았습니다.

SET ROLE과 session_user​

superuser로 접속해 read-only 트랜잭션 안에서 SET ROLE pg_monitor를 실행한 결과입니다.

BEGIN READ ONLY;
SET ROLE pg_monitor;
SELECT session_user, current_user,
(SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) AS cur_super,
(SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = session_user) AS sess_super;
SELECT count(*) FROM pg_catalog.pg_authid;
session_user | current_user | cur_super | sess_super
--------------+--------------+-----------+------------
postgres | pg_monitor | f | t

ERROR: permission denied for table pg_authid

권한 검사는 current_user 기준이라 pg_authid 조회가 막힙니다. 여기까지만 보면 권한이 잘 낮아진 것처럼 보입니다. 같은 트랜잭션에서 RESET ROLE을 실행하면 결과가 달라집니다.

BEGIN READ ONLY;
SET ROLE pg_monitor;
RESET ROLE;
SELECT session_user, current_user;
SELECT count(*) AS authid_rows FROM pg_catalog.pg_authid;
session_user | current_user
--------------+--------------
postgres | postgres

authid_rows
-------------
17

RESET ROLE은 current_user를 session_user로 되돌리는 명령이고, 이 명령에는 별도 권한이 필요 없습니다. session_user가 superuser인 한 SET ROLE은 격리 수단이 되지 못합니다.

SET SESSION AUTHORIZATION도 같은 결과​

advisory는 SQL 수준에서 SET SESSION AUTHORIZATION pg_monitor로 한 단계 더 내려도 부족하다고 적었습니다. SET SESSION AUTHORIZATION의 권한 검사가 현재 session_user가 아니라 처음 인증된 사용자가 superuser인지를 보기 때문입니다. 측정 결과도 같았습니다.

SET SESSION AUTHORIZATION cnpg_metrics_exporter;
SELECT session_user, current_user;
RESET SESSION AUTHORIZATION;
SELECT session_user, current_user;
SET SESSION AUTHORIZATION cnpg_metrics_exporter;
SET SESSION AUTHORIZATION postgres;
SELECT session_user, current_user;
session_user | current_user
-----------------------+-----------------------
cnpg_metrics_exporter | cnpg_metrics_exporter

session_user | current_user
--------------+--------------
postgres | postgres

session_user | current_user
--------------+--------------
postgres | postgres

session_user를 비 superuser로 바꾼 뒤에도 RESET SESSION AUTHORIZATION이나 SET SESSION AUTHORIZATION postgres로 다시 superuser가 됩니다. 인증 단계의 사용자 자체를 바꿔야 한다는 advisory의 결론이 여기서 나옵니다.

READ ONLY가 막는 것과 막지 못하는 것​

superuser 세션에서 BEGIN READ ONLY 뒤에 실행한 결과입니다.

문장결과
CREATE TABLE t2(id int)cannot execute CREATE TABLE in a read-only transaction
INSERT INTO t VALUES (1)cannot execute INSERT in a read-only transaction
SELECT nextval('s')cannot execute nextval() in a read-only transaction
SELECT pg_read_file('postgresql.conf')성공 (32,557바이트)
COPY (SELECT ...) TO '/tmp/ro_copy.txt'성공, 파일 생성 확인
COPY (SELECT 1) TO PROGRAM 'true'성공
SELECT set_config('work_mem', '8MB', true)성공

transaction_read_only는 데이터베이스 안의 테이블, 시퀀스, 카탈로그 변경을 막습니다. 서버 파일 읽기와 쓰기, 외부 프로그램 실행은 트랜잭션이 기록하는 데이터 변경이 아니어서 검사 대상에서 빠집니다. 그래서 이 조합을 막는 것은 트랜잭션 모드가 아니라 role의 권한입니다.

영향 조건​

advisory는 영향 대상을 두 갈래로 정리했습니다. 공격 방법은 이 글에서 다루지 않고 대상 여부를 판단하는 데 필요한 조건만 옮깁니다.

  • 기본 모니터링을 켠 모든 지원 릴리스. 기본 제공 default-monitoring.yaml의 pg_extensions 메트릭이 스키마를 붙이지 않은 current_database()를 호출했고 target_databases: '*'로 모든 사용자 database에서 실행됐기 때문입니다. bootstrap.initdb가 만드는 기본 app role처럼 database를 소유한 비 superuser 계정이 있으면 조건을 채웁니다.
  • 스키마를 붙이지 않은 함수나 relation을 참조하는 custom metric 쿼리를 쓰는 배포. 고객이 metric 쿼리 본문을 넣거나 바꿀 수 있는 멀티테넌트 플랫폼을 advisory는 가장 위험한 경우로 봤습니다.

advisory는 스키마 누락이 가장 직접적인 형태일 뿐이라고 덧붙였습니다. scrape 세션에서 평가되는 식은 모두 superuser 권한으로 실행됐으므로, 사용자 정의 함수, operator, cast, 사용자 소유 테이블이나 view, 읽는 객체에 걸린 index 식과 RLS policy도 같은 범주에 들어갑니다. 쿼리에 pg_catalog.를 붙이는 것만으로는 원인이 없어지지 않는다는 뜻입니다.

PostgreSQL 14 이하는 조건이 더 넓다고 advisory가 따로 적었습니다. PostgreSQL 15부터 public 스키마의 CREATE 권한이 PUBLIC에서 빠졌기 때문입니다. 두 버전에서 아무 권한 없는 login role로 확인한 결과입니다.

버전has_schema_privilege(..., 'public', 'CREATE')public ACLpublic 소유자
14.19t{postgres=UC/postgres,=UC/postgres}postgres
18.4f{pg_database_owner=UC/pg_database_owner,=U/pg_database_owner}pg_database_owner

CloudNativePG 1.29와 1.30은 PostgreSQL 14부터 18까지 지원합니다. 14를 쓰는 클러스터라면 database 소유자가 아닌 계정도 대상에 넣어 판단해야 합니다.

수정 내용​

advisory 본문은 "세 개의 패치"라고 적었지만 2026-09-28에 조회한 Patches 절에는 두 항목만 있습니다. 두 패치 모두 1.29.1과 1.28.3에 들어갔습니다.

Patch 1: 기본 쿼리의 스키마 명시​

PR #10576은 default-monitoring.yaml과 문서 예제의 카탈로그 참조에 pg_catalog.를 붙였습니다. pg_extensions 쿼리의 변경은 이렇습니다.

- current_database() as datname,
+ pg_catalog.current_database() as datname,
...
- WHEN default_version = installed_version THEN 0
+ WHEN default_version OPERATOR(pg_catalog.=) installed_version THEN 0

함수와 함께 = 같은 operator도 OPERATOR(pg_catalog.=)로 바꿨습니다. v1.29.0과 v1.29.1의 파일을 받아 스키마 없는 호출을 세어 보면 count( 3곳, now( 1곳, current_database( 1곳이 사라지고 OPERATOR( 13곳이 생깁니다. 파일 길이는 둘 다 491줄입니다. target_databases에 '*'를 쓰는 쿼리는 두 버전 모두 pg_extensions 하나입니다.

이 패치는 오퍼레이터가 배포하는 설정만 고칩니다. default-monitoring.yaml을 복사해 만든 custom ConfigMap은 사용자가 직접 고쳐야 합니다.

Patch 2: 전용 cnpg_metrics_exporter role​

commit 04e8c19가 근본 원인을 없앤 변경입니다. 18개 파일을 바꿨고, 요지는 인증 단계의 사용자 교체입니다.

  • cnpg_metrics_exporter role을 LOGIN PASSWORD NULL로 만들고 pg_monitor만 부여
  • NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT LOGIN 속성과 pg_monitor 멤버십, 비밀번호 없음을 reconcile마다 primary에서 다시 맞춤
  • exporter 전용 connection pool이 user=cnpg_metrics_exporter application_name=cnpg_metrics_exporter로 Unix socket에 접속
  • pg_hba.conf에 local all cnpg_metrics_exporter peer map=cnpg_metrics_exporter, pg_ident.conf에 매핑 한 줄 추가
  • SET ROLE TO pg_monitor 제거, 대신 트랜잭션마다 search_path 앞에 pg_catalog를 붙임
  • target_databases: '*' 확장 시 has_database_privilege(datname, 'CONNECT')가 false인 database는 Debug 로그만 남기고 제외
  • bootstrap.initdb.import의 role 가져오기 대상에서 제외

수정 후 구조를 같은 PostgreSQL 18.4 컨테이너에서 재현했습니다. role을 commit과 같은 문장으로 만들고, 전용 계정으로 접속해 앞 절의 문장을 다시 실행한 결과입니다.

session_user | current_user
-----------------------+-----------------------
cnpg_metrics_exporter | cnpg_metrics_exporter

RESET -- 결과는 그대로 cnpg_metrics_exporter
ERROR: permission denied to set role "postgres"
ERROR: permission denied to set session authorization "postgres"
ERROR: permission denied for function pg_read_file
ERROR: permission denied to COPY to or from an external program
DETAIL: Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
ERROR: permission denied to COPY to a file
ERROR: permission denied for table pg_authid

pg_stat_activity와 pg_settings는 그대로 조회됩니다. session_user가 처음부터 비 superuser이므로 RESET ROLE이 돌아갈 곳도 비 superuser입니다.

peer 매핑도 확인했습니다. commit의 두 줄을 컨테이너 설정에 넣고({{.Username}} 자리는 컨테이너 OS 사용자 postgres) reload한 뒤 pg_hba_file_rules, pg_ident_file_mappings로 읽힌 규칙을 보고 접속해 봤습니다.

접속 시도결과
OS 사용자 postgres, socket, -U cnpg_metrics_exporter성공, session_user는 cnpg_metrics_exporter
OS 사용자 root, socket, -U cnpg_metrics_exporterPeer authentication failed
TCP 127.0.0.1, 비밀번호 입력password authentication failed

비밀번호가 NULL이라 TCP로는 접속할 수 없습니다. socket에서는 Pod 안의 postgres OS 사용자만 이 role로 접속합니다.

노트

수정 후 exporter 권한은 pg_monitor 범위로 줄었습니다. 사용자 소유 테이블이나 pg_authid, pg_subscription 같은 superuser 전용 카탈로그를 읽던 custom 쿼리는 실패합니다. 이런 쿼리를 계속 쓰려면 GRANT USAGE ON SCHEMA, GRANT SELECT를 cnpg_metrics_exporter에 따로 부여해야 합니다. PUBLIC의 CONNECT를 회수한 database도 GRANT CONNECT가 필요합니다. 공식 문서는 이 role에 pg_monitor와 필요한 테이블 권한 외의 멤버십을 주지 말라고 경고합니다.

후속 취약점 CVE-2026-55769​

1.30.0 릴리스 날인 2026-06-29에 GHSA-x8c2-3p4r-v9r6이 공개됐습니다. 이번에는 exporter 대신 instance manager가 superuser로 여는 connection에 search_path가 고정돼 있지 않았던 문제입니다. 역시 CWE-426이고 critical입니다. 수정은 PR #10774이며, 오퍼레이터가 여는 모든 pooled connection의 startup message에 search_path = pg_catalog, public, pg_temp를 넣었습니다.

라인CVE-2026-44477 수정CVE-2026-55769 수정
1.281.28.31.28.4
1.291.29.11.29.2
1.301.30.0부터 포함1.30.0부터 포함

advisory는 오퍼레이터 connection에 고정 search_path를 주거나 신뢰할 수 없는 사용자에게 database 소유권을 주지 않은 클러스터는 해당되지 않는다고 적었습니다. database 소유자 계정이 이번 두 건에서 공통 조건이라는 점은 운영 관점에서 기억해 둘 만합니다.

업그레이드​

지원 정책 문서 기준으로 1.28.x는 2026-06-30에 지원이 끝났고, 1.29.x는 2026-09-29가 EOL입니다. 2026-09-23에 나온 1.30.1과 1.29.3이 현재 최신입니다. 두 CVE를 함께 정리하려면 1.30.1로 올리는 것이 가장 단순합니다. 1.29에 남는다면 최소 1.29.2가 필요하지만 EOL이 바로 다음 날입니다.

공식 문서와 릴리스 노트에서 확인한 순서와 주의 사항입니다. CloudNativePG 업그레이드 자체는 이 글에서 실행해 보지 않았습니다.

  1. 현재 오퍼레이터 버전과 대상 클러스터의 PostgreSQL major 버전 확인
  2. custom monitoring ConfigMap, Secret에서 사용자 테이블이나 superuser 전용 카탈로그를 읽는 쿼리 목록 작성
  3. 그 쿼리에 필요한 GRANT를 cnpg_metrics_exporter 기준으로 준비 (role은 업그레이드 후 생김)
  4. replica cluster가 있으면 source primary cluster를 먼저 업그레이드. role은 source primary에서 만들어져 streaming replication으로 내려가므로, replica cluster를 먼저 올리면 role이 없는 상태로 scrape합니다
  5. 업그레이드 후 instance manager 로그에서 exporter의 권한 오류, role 누락 오류 확인
  6. role이 없으면 공식 문서의 수동 생성 문장을 writable primary에서 superuser로 실행
CREATE ROLE cnpg_metrics_exporter WITH LOGIN NOSUPERUSER NOCREATEDB
NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT;
GRANT pg_monitor TO cnpg_metrics_exporter;

바로 올릴 수 없을 때 advisory가 제시한 임시 조치는 네 가지입니다. custom 쿼리의 모든 카탈로그 참조에 pg_catalog.를 붙이기, 신뢰하는 role만 database를 소유하게 하기, target_databases: '*' 범위 줄이기, 신뢰할 수 없는 사용자에게 metric 쿼리 본문을 노출하지 않기입니다. advisory는 첫 번째가 부분 완화일 뿐이라고 명시합니다.

점검 항목​

아래 SQL은 PostgreSQL 18.4 컨테이너에서 실행해 결과 형태를 확인했습니다. CloudNativePG Pod 안에서는 실행해 보지 않았습니다. primary에 superuser로 접속해 실행하는 것을 전제로 합니다. superuser로 사용자 database에 접속할 때는 PGOPTIONS="-c search_path=pg_catalog"를 붙여 세션 search_path를 고정하십시오.

exporter가 어떤 계정으로 접속해 있는지 확인합니다. 수정 전 버전이면 usename이 postgres, rolsuper가 t로 나옵니다.

SELECT a.datname, a.usename, r.rolsuper, a.application_name
FROM pg_catalog.pg_stat_activity a
JOIN pg_catalog.pg_roles r ON r.oid = a.usesysid
WHERE a.application_name = 'cnpg_metrics_exporter';
datname | usename | rolsuper | application_name
----------+-----------------------+----------+-----------------------
app | cnpg_metrics_exporter | f | cnpg_metrics_exporter
postgres | postgres | t | cnpg_metrics_exporter

위 출력은 수정 전과 수정 후 연결을 컨테이너에 동시에 띄워 만든 예시입니다. 실제 클러스터에서는 한 종류만 나와야 합니다.

role 속성과 멤버십이 commit이 강제하는 상태와 같은지 봅니다. member_of에 pg_monitor 외의 role이 있으면 누가 왜 줬는지 확인해야 합니다.

SELECT r.rolname, r.rolsuper, r.rolinherit, r.rolcanlogin,
a.rolpassword IS NULL AS no_password,
ARRAY(SELECT b.rolname FROM pg_catalog.pg_auth_members m
JOIN pg_catalog.pg_roles b ON b.oid = m.roleid
WHERE m.member = r.oid) AS member_of
FROM pg_catalog.pg_roles r
JOIN pg_catalog.pg_authid a ON a.oid = r.oid
WHERE r.rolname = 'cnpg_metrics_exporter';
rolname | rolsuper | rolinherit | rolcanlogin | no_password | member_of
-----------------------+----------+------------+-------------+-------------+--------------
cnpg_metrics_exporter | f | t | t | t | {pg_monitor}

database 소유자가 누구인지 봅니다. 두 CVE 모두 database 소유자 계정이 조건에 들어가므로, 애플리케이션 계정이 소유자인지 확인해 둡니다.

SELECT d.datname, pg_catalog.pg_get_userbyid(d.datdba) AS owner, r.rolsuper AS owner_is_super
FROM pg_catalog.pg_database d
JOIN pg_catalog.pg_roles r ON r.oid = d.datdba
WHERE NOT d.datistemplate
ORDER BY 1;

database나 role 수준에서 search_path를 바꿔 둔 설정이 있는지 봅니다. 의도하지 않은 항목이 있으면 설정한 사람과 이유를 확인합니다.

SELECT d.datname, r.rolname, s.setconfig
FROM pg_catalog.pg_db_role_setting s
LEFT JOIN pg_catalog.pg_database d ON d.oid = s.setdatabase
LEFT JOIN pg_catalog.pg_roles r ON r.oid = s.setrole
WHERE pg_catalog.array_to_string(s.setconfig, ',') LIKE '%search_path%';

pg_catalog 밖에 pg_catalog 함수와 같은 이름의 함수, 또는 사용자 operator가 있는지 database마다 확인합니다. 확장 소속 객체는 pg_depend의 deptype = 'e'로 제외했습니다. 이 조건이 없으면 citext 하나만 설치해도 동명 함수 25개가 나오고, 조건을 넣으면 확장이 아닌 객체만 남습니다.

SELECT n.nspname AS schema, p.proname AS name, 'function' AS kind,
pg_catalog.pg_get_userbyid(p.proowner) AS owner
FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND EXISTS (SELECT 1 FROM pg_catalog.pg_proc c
WHERE c.pronamespace = 'pg_catalog'::pg_catalog.regnamespace
AND c.proname OPERATOR(pg_catalog.=) p.proname)
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass
AND d.objid = p.oid AND d.deptype = 'e')
UNION ALL
SELECT n.nspname, o.oprname, 'operator', pg_catalog.pg_get_userbyid(o.oprowner)
FROM pg_catalog.pg_operator o
JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_operator'::pg_catalog.regclass
AND d.objid = o.oid AND d.deptype = 'e')
ORDER BY 1, 2;

결과가 있다고 해서 공격 흔적이라는 뜻은 아닙니다. 애플리케이션이 일부러 만든 함수일 수도 있으므로 소유자와 생성 시점을 확인하는 출발점으로 쓰십시오.

custom 쿼리 본문은 Kubernetes 쪽에서 확인합니다. 아래 명령은 이 글에서 실행하지 않았고, 클러스터 spec의 monitoring.customQueriesConfigMap 경로를 따른 것입니다.

kubectl get cluster -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.monitoring.customQueriesConfigMap}{"\n"}{end}'
kubectl get configmap <name> -n <ns> -o yaml | grep -nE "target_databases|current_database\(|now\("

측정 환경과 한계​

  • macOS 호스트의 Colima VM (Docker 28.3.3, 2 CPU, 약 12GB, Linux 6.8, arm64), 다른 작업과 공유
  • postgres:18 (PostgreSQL 18.4), postgres:14 (PostgreSQL 14.19) 컨테이너
  • CloudNativePG 오퍼레이터와 Kubernetes 클러스터는 띄우지 않았습니다. CNPG 쪽 사실은 advisory, commit diff, 릴리스 노트, 지원 정책 문서, default-monitoring.yaml 원본으로만 확인했습니다
  • exploit 체인은 재현하지 않았고, PostgreSQL 동작을 하나씩 따로 확인했습니다

관련 글​

  • pg_maintain 권한 위임 정리: 유지보수 명령 중 search_path를 고정하는 PostgreSQL 17의 방어 장치를 다룹니다. 권한을 위임받은 세션이 무엇을 실행하게 되는지를 본다는 점에서 이 글과 같은 질문입니다
  • CloudNativePG 10.1 모니터링: exporter 구조, 9187 포트, custom 쿼리 ConfigMap 작성법

참고​

SET ROLE로 권한을 낮춘 연결은 처음 인증한 신원을 계속 기억해요. 모니터링처럼 주기적으로 도는 연결일수록 처음부터 낮은 권한으로 인증하는 편이 맞다는 것이 이번 수정에서 제가 얻은 기준이에요. 아직 1.29.1 이전이라면 1.30.1로 올리고 위 점검 쿼리를 한 번 실행해 보시길 권해요.

PgBouncer와 Patroni 시간대

· 약 5분

로그의 타임스탬프 절반은 맞고 절반은 UTC라는 신고가 들어오면 어디부터 볼까요. Percona Community에 올라온 사례의 답이 흥미로웠습니다. 원인이 PostgreSQL 안이 아니었습니다.

배경은 Oracle에서 Patroni로 관리하는 PostgreSQL 18 클러스터로 마이그레이션한 직후입니다. 개발자들이 "타임스탬프가 어떤 건 맞고 어떤 건 UTC로 나온다"고 알렸습니다.

계층이 넷이었다​

문제를 이해하려면 timezone 값이 어디에 저장되는지 짚어야 합니다. 이 환경에서는 네 곳이었습니다.

계층처음문제 상황최종
VM (OS)UTCUTCUTC
PostgreSQLUTC (기본값)Africa/LagosAfrica/Lagos
Patroni 설정없음재시작 후 UTCAfrica/Lagos
PgBouncerUTC (캐시)UTC (낡은 캐시)갱신됨

세 번째 행과 네 번째 행이 사건의 두 단계입니다.

1단계: 재시작 후 사라진 ALTER SYSTEM 값​

처음 시도한 방법이 이것이었습니다.

ALTER SYSTEM SET TIMEZONE = 'Africa/Lagos';
SELECT pg_reload_conf();

이 명령이 어디에 쓰이는지 컨테이너로 확인했습니다.

$ psql -Atc "show timezone"
Etc/UTC

$ psql -c "alter system set timezone = 'Asia/Seoul'"
$ psql -Atc "select pg_reload_conf()"
t

$ cat $PGDATA/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
timezone = 'Asia/Seoul'

새 세션에서는 바로 반영됩니다.

$ psql -Atc "show timezone; select now();"
Asia/Seoul
2026-08-24 19:02:52.265584+09

여기까지는 정상입니다. 문제는 값이 postgresql.auto.conf에 있다는 것입니다. 원문 사례에서는 리소스 조정을 위해 노드를 하나씩 재시작한 뒤 timezone이 UTC로 되돌아갔고, 저자는 patronictl edit-config로 넣었어야 했다고 정리합니다.

원문은 값이 사라진 정확한 메커니즘까지는 밝히지 않습니다. Patroni 문서에 따르면 postgresql.auto.conf는 postgresql.conf 다음에 읽혀 노드별 ALTER SYSTEM 값이 우선하므로, 재시작만으로 이 값이 지워진다고 일반화하기는 어렵습니다. 다만 ALTER SYSTEM은 해당 노드에만 적용되는 설정이라 클러스터 전체 설정 관리에서 벗어납니다.

Patroni 환경에서 올바른 방법은 Patroni 쪽에 넣는 것입니다.

patronictl edit-config

여기서 설정한 값은 노드 재시작에도 유지됩니다. Patroni를 쓰는 클러스터에서 ALTER SYSTEM을 쓰지 않는다는 원칙이 이 사례의 첫 교훈입니다.

2단계: PgBouncer가 기억하고 있었다​

Patroni 설정을 Africa/Lagos로 바로잡은 뒤에도 일부 세션이 UTC로 나왔습니다. 여기가 진짜 원인입니다.

PgBouncer는 세션 파라미터를 캐시합니다. 클라이언트가 연결할 때 넘기는 startup parameter를 기억해 두고, 풀에서 서버 연결을 꺼내 줄 때 그 값을 맞춰 줍니다. 그런데 Patroni 설정 변경으로 데이터베이스의 timezone이 바뀐 경우에는 이 내부 캐시를 제대로 무효화하지 않습니다. ALTER DATABASE로 바꿨을 때와 경로가 다릅니다.

그래서 낡은 UTC 기준으로 만들어진 세션들이 풀에서 계속 재활용됐습니다. 새로 만들어진 연결은 Africa/Lagos, 재활용된 연결은 UTC입니다. 로그에 두 값이 섞인 이유입니다.

startup parameter가 서버 설정을 이긴다​

캐시가 왜 이렇게 강한지 궁금해서 직접 확인했습니다. 서버 설정은 Asia/Seoul인 상태에서, 클라이언트가 연결 시점에 timezone을 지정해 봤습니다.

$ psql -Atc "show timezone"
Asia/Seoul

$ PGOPTIONS="-c timezone=UTC" psql -Atc "show timezone"
UTC

같은 서버, 같은 순간인데 값이 다릅니다. 클라이언트가 연결할 때 넘긴 값이 서버의 설정을 덮어씁니다. 이게 정상 동작입니다. 세션 단위 설정이 서버 기본값보다 우선하니까요.

Patroni와 PgBouncer 환경의 시간대 적용 우선순위

PgBouncer의 캐시가 문제가 되는 지점이 여기입니다. 서버 설정을 고쳐도 pooler가 예전 값을 세션에 계속 넣어 주면, 서버 쪽 변경은 그 세션에 닿지 않습니다. show timezone으로 확인해도 클라이언트가 보는 값은 pooler가 정한 값입니다.

어떻게 고쳤나​

PgBouncer 파드를 재시작해 캐시를 비웠습니다. 이후 새 연결은 모두 Africa/Lagos를 반영했습니다.

원문이 정리한 교훈 셋입니다. Patroni에서 timezone을 바꾼 뒤에는 PgBouncer를 재시작하거나 재연결시킵니다. ALTER SYSTEM SET TIMEZONE이 아니라 patronictl edit-config를 씁니다. 그리고 Kubernetes에서는 PgBouncer를 독립 파드가 아니라 sidecar로 배치합니다.

세 번째 항목에 이 사례의 구조적 원인이 들어 있습니다. PgBouncer가 독립 파드로 떠 있었기 때문에 Patroni 노드 재시작과 PgBouncer 재시작이 서로 무관했습니다. sidecar였다면 파드 재시작이 둘을 함께 갈아 줬을 것입니다. 연결 풀이 DB 인스턴스보다 오래 사는 배치에서는 이런 종류의 불일치가 반복됩니다.

진단할 때 볼 지점​

같은 증상을 만나면 확인 순서를 이렇게 잡을 만합니다.

-- 지금 이 세션이 보는 값과 그 출처
select name, setting, source, sourcefile
from pg_settings where name = 'TimeZone';

-- 접속 중인 세션과 연결 시각(설정 변경 시각과 비교)
select pid, application_name, backend_start
from pg_stat_activity where backend_type = 'client backend';

pg_settings의 source 컬럼이 핵심입니다. 값이 configuration file이면 서버 설정에서, client면 클라이언트가 startup parameter로 넘긴 값입니다. client로 나오면서 값이 기대와 다르면 pooler를 봐야 합니다.

pooler 쪽에서는 SHOW SERVERS와 SHOW POOLS로 서버 연결이 언제 만들어졌는지 확인합니다. 설정을 바꾼 시각보다 오래된 연결이 남아 있으면 그게 문제의 세션입니다. RECONNECT 명령이나 파드 재시작으로 정리합니다.

남는 생각​

이 사례가 좋은 이유는 원인이 PostgreSQL 밖에 있었다는 점입니다. show timezone을 아무리 확인해도, 그 값을 정한 주체가 pooler라면 서버 설정을 보는 것으로는 안 풀립니다.

Oracle에서 넘어온 직후라는 배경도 한몫했다고 봅니다. Oracle에는 연결 풀을 이런 식으로 앞에 두는 관례가 덜하고, 세션 파라미터가 계층별로 덮어써지는 구조에 익숙하지 않으면 의심 대상에 pooler가 안 들어옵니다. 저도 이 글을 읽기 전까지 PgBouncer가 startup parameter를 그렇게 오래 붙들고 있는 줄은 몰랐어요.

참고​

cgroup v2 입문

· 약 6분

docker run --memory=512m을 실행할 때, Kubernetes 매니페스트에 resources.limits.memory를 적을 때, systemd 유닛에 MemoryMax=를 넣을 때. 이 셋이 결국 같은 커널 기능 하나로 내려간다는 걸 알고 계셨나요. 컨테이너 시대의 자원 통제는 전부 cgroup 위에 서 있어요.

systemd 유닛 11종을 돌아본 글에서 cgroup을 여러 번 스쳐 지나갔는데, 정작 cgroup 자체를 따로 다룬 적은 없었습니다. 이번에 채웁니다.

cgroup은 무엇인가​

cgroup(control group)은 프로세스들을 무리(group)로 묶고, 그 무리 단위로 자원을 통제하고 계량하는 커널 기능입니다. 하는 일은 세 가지로 요약됩니다.

기능내용예
제한 (limit)무리가 쓸 수 있는 자원의 상한메모리 64MB까지, CPU 절반까지
계량 (accounting)무리가 지금까지 쓴 자원 측정누적 CPU 시간, 현재 메모리 사용량
통제 (control)무리 전체를 한 번에 조작일시 정지(freeze), 일괄 종료

핵심은 대상이 프로세스 하나가 아니라 무리라는 점입니다. ulimit 같은 전통 도구는 프로세스 단위라서, 프로세스가 fork하면 통제를 벗어납니다. cgroup은 자식 프로세스가 부모의 cgroup을 물려받으므로 fork로 도망칠 수 없습니다. 컨테이너가 "안에서 뭘 몇 개 띄우든 전체 512MB"라는 약속을 지킬 수 있는 이유입니다.

구현 형태는 파일시스템입니다. /sys/fs/cgroup 아래 디렉터리 하나가 cgroup 하나이고, 디렉터리를 만들면 cgroup이 생기고, 그 안의 파일에 값을 쓰면 제한이 걸립니다. 시스템 콜 API가 아니라 mkdir와 echo로 조작하는 커널 기능이라는 점이 cgroup의 성격을 잘 보여줍니다.

계보를 짧게 짚으면, 2006년 Google 엔지니어들이 "process containers"라는 이름으로 시작해 2008년 커널 2.6.24에 cgroup이라는 이름으로 머지됐습니다. 이후 LXC와 Docker가 이 위에 컨테이너를 지었고, systemd는 서비스 관리의 뼈대로 삼았습니다.

v1의 혼돈, v2의 단일 트리​

cgroup을 검색하면 v1과 v2 이야기가 반드시 나옵니다. 이 구분을 모르면 검색 결과의 절반이 내 시스템과 안 맞는 이유를 알 수 없습니다.

v1의 설계는 유연함이 지나쳤습니다. 컨트롤러(memory, cpu, blkio 등)마다 각자 독립된 트리를 가질 수 있었습니다. 한 프로세스가 memory 트리에서는 A 그룹, cpu 트리에서는 B 그룹에 속하는 것이 가능했다는 뜻입니다. 표현력은 높지만 대가가 컸습니다. 트리끼리 조합이 어긋나면 동작을 예측하기 어렵고, 컨트롤러마다 인터페이스 관례가 제각각이었으며, 권한 위임 규칙도 정리되어 있지 않았습니다.

v2는 2016년 커널 4.5에서 이 혼돈을 정리했습니다. 원칙은 하나입니다. 트리는 하나, 모든 컨트롤러는 그 트리 위에서 동작한다(unified hierarchy).

인터페이스도 통일됐습니다. 어느 cgroup 디렉터리든 같은 규칙의 파일들이 있습니다.

  • cgroup.procs: 이 무리에 속한 PID 목록. 여기에 PID를 쓰면 편입
  • cgroup.controllers: 이 지점에서 쓸 수 있는 컨트롤러
  • memory.max, cpu.max, io.max, pids.max: 컨트롤러별 상한
  • memory.events: OOM 발생 횟수 같은 사건 카운터

v2에만 있는 물건도 생겼습니다. PSI(Pressure Stall Information)입니다. memory.pressure, cpu.pressure, io.pressure 파일이 "이 무리의 프로세스들이 자원을 못 받아 지연된 시간의 비율"을 보여줍니다. 사용량이 아니라 부족으로 인한 고통을 직접 재는 지표라서, 사용률 그래프보다 정직할 때가 많습니다.

전환은 배포판 기본값으로 이미 끝난 이야기입니다. Fedora 31(2019)을 시작으로 Ubuntu 21.10, Debian 11, RHEL 9가 v2를 기본으로 켰고, Docker는 20.10부터, Kubernetes는 1.25에서 v2 지원을 GA로 올렸습니다. 지금 새로 만나는 시스템은 거의 v2라고 봐도 됩니다. 내 시스템 확인은 한 줄입니다.

stat -fc %T /sys/fs/cgroup/
# cgroup2fs 면 v2, tmpfs 면 v1

실험: 64MB 국경에 100MB를 밀어 넣으면​

개념은 여기까지 하고, 직접 확인해 보겠습니다. macOS라서 Docker 컨테이너(Linux VM) 안에서 실험했습니다. 컨테이너 자체가 cgroup으로 만들어지니 오히려 좋은 교보재입니다.

먼저 --memory=64m이 정말 cgroup 파일로 내려가는지 확인합니다.

$ docker run --rm --memory=64m alpine cat /sys/fs/cgroup/memory.max
67108864

67108864 바이트, 정확히 64MiB입니다. Docker 플래그는 커널에 도달하면 memory.max 파일의 숫자 하나가 됩니다. 이제 이 국경 안에서 100MB를 할당해 봅니다.

$ docker run --rm --memory=64m alpine sh -c 'head -c 100m /dev/zero | tail'
Killed
# 종료 코드 137 (128 + SIGKILL 9)

tail은 입력을 메모리에 쌓는 성질이 있어 간단한 메모리 포식자로 쓸 수 있습니다. 64MiB 상한을 넘는 순간 커널 OOM killer가 그 cgroup 안에서 희생자를 골라 SIGKILL을 보냅니다. 시그널 글에서 다룬 137이라는 종료 코드가 여기서 나옵니다.

죽인 흔적은 사건 카운터에 남습니다. 같은 컨테이너에서 OOM을 겪은 직후 memory.events를 읽으면 이렇습니다.

$ cat /sys/fs/cgroup/memory.events
low 0
high 0
max 72
oom 3
oom_kill 1
oom_group_kill 0

$ cat /sys/fs/cgroup/memory.peak
67108864

읽는 법이 흥미롭습니다. max 72는 사용량이 상한선에 부딪힌 횟수입니다. 부딪힐 때마다 커널은 먼저 page 회수(reclaim)로 버텨 봅니다. 그래도 안 되면 oom(3회)으로 넘어가고, 최종적으로 프로세스 하나를 죽였습니다(oom_kill 1). memory.peak가 정확히 67108864, 즉 상한과 같다는 것은 국경까지 꽉 채우고 넘지 못했다는 증거입니다. 상한 초과가 아니라 상한 도달 후 처형이라는 순서가 파일에 그대로 남습니다.

cgroup 메모리 상한 초과 처리 흐름

컨테이너 없이 맨 리눅스에서 같은 실험을 하려면 systemd가 지름길입니다.

# 임시 scope 유닛으로 감싸서 실행, 상한 64M
systemd-run --scope -p MemoryMax=64M some-command

# 서비스라면 유닛 파일에
[Service]
MemoryMax=64M

systemd 유닛은 각자 cgroup 하나씩을 차지하므로, MemoryMax=는 결국 그 cgroup의 memory.max에 값을 쓰는 것입니다. 트리 전체는 systemd-cgls로, 무리별 실시간 사용량은 systemd-cgtop으로 봅니다.

운영자가 기억할 세 가지​

첫째, 모든 상위 도구의 제한은 cgroup 파일로 환원됩니다. Docker 플래그든, Kubernetes limits든, systemd 지시자든, 디버깅의 종착지는 /sys/fs/cgroup 아래 파일입니다. 제한이 이상하게 동작하면 도구의 문서보다 그 파일의 실제 값을 먼저 확인하는 쪽이 빠릅니다.

둘째, cgroup의 메모리 계정에는 page cache가 포함됩니다. memory.max는 프로세스의 anonymous 메모리만 세는 것이 아니라, 그 무리가 일으킨 파일 캐시까지 셉니다. 파일을 많이 읽는 워크로드(백업, 대량 스캔, 그리고 데이터베이스)가 "실제 사용량은 얼마 안 되는데" OOM에 몰리는 전형적인 이유입니다. 캐시는 압박이 오면 회수되므로 보통은 문제가 없지만, 회수 속도가 할당 속도를 못 따라가는 순간이 있습니다. 컨테이너 위 PostgreSQL의 메모리 제한 설정은 이 지점이 본론이라, 각도를 잡아 후속 글에서 따로 다루겠습니다.

셋째, v2의 사건 카운터와 PSI를 감시 지표로 쓸 수 있습니다. memory.events의 max와 oom_kill, 그리고 memory.pressure는 "제한에 얼마나 자주 부딪히고 있는가"를 알려주는 조기 경보입니다. OOM으로 죽고 나서 아는 것과, max 카운터가 오르는 걸 보고 미리 아는 것의 차이입니다.

여담 하나로 마무리할게요. 이 글을 쓰게 된 계기는 Claude Code가 Bash 명령에 cgroup 메모리 제한을 거는 옵션을 넣었다는 소식이었어요. AI 에이전트가 실행하는 명령의 폭주를 막는 안전장치로 2008년의 커널 기능이 다시 불려 나오는 것, cgroup이 여전히 현역 국경 수비대라는 좋은 증거 아닐까요.

참고 자료​

쿠버네티스 유닉스 시그널

· 약 8분

오래된 주제와 새로운 무대​

유닉스 시그널은 운영체제 수업 첫 단원에서 가볍게 다루고 지나가는 주제예요. 그런데 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는 개발자들의 오랜 습관입니다 — 하지만 프로세스에게 정리할 기회를 주지 않습니다.

  • 커널이 프로세스를 즉시 종료
  • 프로세스는 시그널을 캐치하거나 무시하거나 핸들러를 등록할 수 없다 (불가능)
  • lock 파일이나 임시 파일 같은 애플리케이션 수준 정리가 누락되고, 상대편 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

쿠버네티스가 Pod를 정상 종료한 뒤 필요하면 강제 종료하는 흐름

여기서 3번 preStop과 4번 SIGTERM 사이의 순서가 중요합니다. 쿠버네티스는 preStop hook이 끝난 뒤에 컨테이너 PID 1로 SIGTERM을 보내며, preStop에 걸린 시간도 grace period에 포함됩니다. 동시에 진행되는 것은 endpoint 제거와 종료 시퀀스라서, 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로 박살납니다.

Docker shell form과 exec form의 PID 1 신호 전달 비교

긴 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이 필요 없는 날이 많아져요. 컨테이너 오케스트레이션 시대에 이 오래된 주제가 다시 중요해진 이유는 결국 이것이에요.

참고​