앞 글에서는 사람과 ESO 가 OpenBao 에 들어오는 문(인증)과 들어와서 닿는 범위(정책)를 봤다. 팀 책임님과 공통 인증 게이트웨이와 공통 프레임워크를 설계하면서 가장 오래 붙잡은 질문은 따로 있었다. "서비스가 서비스를 부를 때 무엇으로 자기를 증명하나." 사람 로그인은 Keycloak JWT 를 게이트웨이에서 검증하면 됐지만, 서비스 간 호출에는 별도의 신원이 필요했다. 우리는 그 신원 JWT 를 OpenBao 가 발급하게 하고, 받는 쪽은 공통 프레임워크의 Resource Server 설정으로 검증하게 했다. 그 JWT 한 장이 만들어지기까지 AppRole, Identity, Agent 가 차례로 등장한다. 이 글은 함께 설계한 그 길을 다시 따라가며, 적용하고 나서 보인 빈틈까지 적는다.
함께 읽을 글
- 시크릿을 KV 에 넣었다고 끝이 아니었다: OpenBao 의 경계는 인증과 정책이 정한다
- 앱은 살아 있는데 서비스 간 호출만 401: OpenBao 가 발급하는 서비스 신원 JWT 를 따라가 보니 ← 이 글
시크릿을 KV 에 넣었다고 끝이 아니었다: OpenBao 의 경계는 인증과 정책이 정한다
기존 프로젝트에서 OpenBao 는 나한테 "KV v2 에 값을 넣고 읽는 곳" 이었다. 토큰 하나를 설정 파일에 두고 HTTP 로 부르면 끝이었다. 팀을 옮기고 팀 내 책임님과 함께 여러 제품이 같이 쓰는 공통 인
ramos-log.tistory.com
Intro
- OpenBao 토큰은 OpenBao 안에서만 통한다. 다른 서비스에 "나는 누구다" 를 증명하려면
identity/oidc로 표준 JWT 를 받아 Bearer 로 보내고, 받는 쪽은 JWKS 로 서명을 검증한다. - AppRole 은 기계용 아이디(role_id)와 비밀번호(secret_id)다. 구조는 단순하고, 설계의 전부는 secret_id 를 누가 어떻게 전달하느냐다.
- 신원의 원천은 로그인 방식이 아니라 entity 다. alias 를 미리 만들어 두면 로그인 토큰이 그 entity 에 붙고, entity metadata 가 JWT claim 이 된다.
identity/oidc/config의 issuer 는 인스턴스 전체에 하나다. 공용 OpenBao 에서 가장 조심해야 할 설정이었다.- Agent 를 컨테이너 안에 백그라운드로 띄우는 방식은 Agent 가 죽어도 아무도 다시 띄우지 않는다. 앱은 멀쩡해 보이는데 15분 뒤 서비스 간 호출이 401 로 무너진다.
- K8s 안이라면 네이티브 사이드카나 Projected ServiceAccount token 이 더 가벼운 답일 수 있다.
1. KV 말고 OpenBao 에 맡긴 일
첫 적용 대상은 한 제품 스위트(이하 <suite>)의 control-plane 서비스였다. 이 서비스는 KV 를 직접 읽지 않는다. OpenBao 에 기대하는 건 신원 JWT 하나뿐이다. 책임님과 정리한 흐름은 이렇다.

등장인물이 셋이다. AppRole 로 로그인하고, Identity 가 "이 로그인은 control-plane 이다" 를 기억하고, Agent 가 이 과정을 앱 대신 계속 돌린다. 하나씩 봤다.
2. AppRole: 두 조각의 비밀번호
AppRole 은 role 마다 고정된 role_id 와, role 아래에 여러 개 발급할 수 있는 secret_id 로 로그인한다.
| 조각 | 성격 | 비유 |
|---|---|---|
role_id |
role 마다 고정 UUID | 사용자 이름 |
secret_id |
여러 개 발급 가능, 각각 TTL · 사용 횟수 · CIDR 제약 | 기간제 비밀번호 |
문서가 강조하는 건 두 조각을 서로 다른 경로로 전달하라는 것이다. role_id 는 이미지나 설정에, secret_id 는 배포 시점에 믿을 수 있는 주체가 넣는다. 한쪽이 새도 반쪽만 샌다.
문제는 secret_id 를 넣어 주는 "믿을 수 있는 주체" 에게도 OpenBao 에 들어갈 증거가 필요하다는 점이다. 흔히 secret zero 문제라고 부른다. 전달 방식을 세 가지로 나눠 봤다.

정석은 B 다. 내가 읽은 환경은 C 였다. bootstrap Job 이 AppRole 을 만들고 secret_id 를 발급해서 KV 에 쓰고, ESO 가 그걸 K8s Secret 으로 만들어 Pod 에 마운트한다. secret zero 를 "ESO 의 Kubernetes auth" 로 넘긴 셈이다. 편하다. 대신 secret_id 가 KV 와 etcd 두 곳에 평문으로 영구히 산다. 앞 글 5.2 의 kv/* 정책이 위험했던 이유가 여기서 이어진다.
무기한 secret_id
role 설정을 읽다가 또 멈칫했다.
{
"token_policies": ["<suite>-workload-jwt"],
"token_ttl": "20m",
"token_max_ttl": "1h",
"secret_id_num_uses": 0,
"secret_id_ttl": 0
}
0 은 "제한 없음" 이다. 로그인 토큰은 1시간이면 죽지만 그 토큰을 만드는 secret_id 는 영원히 산다. bootstrap 스크립트 주석에는 "살아 있는 자재는 회전하지 않는다. 회전하면 도는 Pod 의 Agent 가 로그인에 실패한다" 고 적혀 있었다. 이유는 이해가 됐다. 그래도 새면 수명이 무한이라는 건 운영 빚이다.
회전은 한 role 에 secret_id 를 여러 개 둘 수 있다는 점을 쓰면 끊김 없이 할 수 있다.

R=auth/approle/role/<suite>-workload-control-plane
NEW=$(bao write -f -format=json "$R/secret-id")
ROLE_ID=$(bao read -field=role_id "$R/role-id")
jq -r .data.secret_id <<<"$NEW" | bao kv patch -mount=kv <suite>/openbao/workload/control-plane secret_id=- role_id="$ROLE_ID"
jq -r .data.secret_id_accessor <<<"$NEW" # 새 accessor 만 기록한다
unset NEW ROLE_ID
secret_id 값이 터미널에 찍히지 않게, 발급 결과를 곧바로 KV 로 넘기는 것이 요점이다.
3. Identity: 신원은 로그인 방식이 아니라 entity 에 있다
AppRole 로 로그인했다고 OpenBao 가 "control-plane 이 들어왔다" 를 아는 건 아니다. 그걸 아는 건 Identity store 다.

- entity: 하나의 주체. 이름, metadata, 정책을 갖는다.
- alias: "이 mount 에서 이 이름으로 로그인한 것은 이 entity 다" 라는 연결.
(mount accessor, 이름)쌍이 유일하다.
alias 가 없으면 첫 로그인 때 이름 없는 entity 가 저절로 생기고 metadata 는 비어 있다. bootstrap 스크립트는 이걸 막으려고 entity 를 먼저 만들고, AppRole 의 alias 이름이 role_id 라는 규칙을 이용해 role_id 로 alias 를 걸어 뒀다. 그래서 로그인 토큰이 미리 만든 entity 에 붙고, entity metadata 의 workload_principal 을 JWT 에 넣을 수 있다.
이 구조의 장점은 로그인 방식을 바꿔도 신원이 그대로라는 점이다. 나중에 AppRole 을 버리고 Kubernetes auth 로 바꾸더라도 같은 entity 에 alias 만 하나 더 붙이면 JWT 계약은 바뀌지 않는다.
Kubernetes auth 쪽에는 함정이 하나 있다. alias 이름의 기본값이 ServiceAccount 의 uid 다. SA 를 지웠다 다시 만들면 uid 가 바뀌어 새 entity 가 생기고 metadata 가 사라진다. 서비스 신원에 쓸 거면 alias_name_source 를 serviceaccount_name(ns/sa)으로 바꿔 둔다.
4. OpenBao 를 OIDC 토큰 발급기로 쓰기
JWT 를 만드는 건 identity/oidc 다. 설정 객체가 셋이다.
| 객체 | 이 환경의 값 | 뜻 |
|---|---|---|
identity/oidc/config |
issuer = https://openbao.<site> |
인스턴스 전체에 하나 |
identity/oidc/key/workload-key |
RS256, 24시간마다 교체, 옛 키 48시간 유지 | 서명 키 |
identity/oidc/role/workload |
client_id <suite>-internal, ttl 15m, claim 템플릿 |
토큰 모양 |
role 의 claim 템플릿은 한 줄이다.
{"workload_principal": {{identity.entity.metadata.workload_principal}}}
발급 권한도 한 줄짜리 정책이다. KV 는 하나도 못 읽는다.
path "identity/oidc/token/workload" {
capabilities = ["read"]
}
앞 글에서 본 넓은 정책들과 비교하면 이건 모범적으로 좁다. JWT 에 들어가는 principal 은 토큰 주인의 entity metadata 에서 오므로, 이 정책을 가진 토큰은 자기 신원의 JWT 만 받을 수 있다.
발급된 JWT 는 대략 이렇게 생겼다.
{
"iss": "https://openbao.<site>/v1/identity/oidc",
"sub": "<entity id>",
"aud": "<suite>-internal",
"exp": 1790487112,
"workload_principal": "<suite>:service:control-plane"
}
iss 는 설정한 값과 다르다
iss 를 보고 한 번 헷갈렸다. config 에는 https://openbao.<site> 를 넣었는데 실제 토큰의 iss 는 뒤에 /v1/identity/oidc 가 붙는다. discovery 문서를 열어 보면 바로 확인된다.
curl -s https://openbao.<site>/v1/identity/oidc/.well-known/openid-configuration | jq .issuer
받는 쪽 설정에는 뒤의 값을 넣어야 한다. 앞의 값을 넣으면 issuer 불일치로 전부 거부된다.
issuer 가 하나뿐이라는 것
bootstrap 스크립트에서 가장 방어적으로 짜인 부분이 issuer 설정이었다. 이미 값이 있고 기대와 다르면 덮지 않고 멈춘다. 주석은 "덮으면 이 인스턴스를 쓰는 다른 릴리스의 토큰 검증이 한꺼번에 깨진다" 였다.
공용 OpenBao 에 여러 제품이 각자 자기 workload issuer 를 세우고 싶어도 issuer 는 하나다. 제품끼리는 role 의 client_id(= aud)와 서명 키로만 구분할 수 있다. 받는 쪽이 aud 검사를 끄면 이 구분이 사라진다.
받는 쪽이 검증하는 것
받는 서비스의 판정은 세 단계였다.
- 서명 ·
iss·aud·exp검증 (Spring Resource Server 가 해 준다). - 그 issuer 가 서비스용 issuer 로 등록된 것인지 확인한다. 사람 로그인 JWT(Keycloak)와 용도를 나눈다.
- principal 을 꺼내 엔드포인트별 허용 목록과 비교한다. principal 은
workload_principalclaim 에서 꺼내고, 없으면 K8s Projected ServiceAccount token 의sub(system:serviceaccount:<ns>:<sa>)에서 꺼낸다.
3 번이 흥미로웠다. 받는 쪽은 이미 OpenBao JWT 와 K8s SA 토큰을 같은 코드로 받을 준비가 되어 있다. 이 얘기는 6장에서 다시 한다.
수신 설정에서 하나 더 챙길 것. issuer-uri 만 주면 기동할 때 discovery 를 호출한다. OpenBao 가 잠깐 죽어 있으면 받는 서비스가 기동에 실패한다. jwk-set-uri 를 명시해 두면 JWKS 는 필요할 때 가져간다. 키가 24시간마다 돌아도 옛 키가 48시간 남아 있어서, 교체 직후에도 이미 발급된 15분짜리 토큰은 검증된다.
5. Agent: 앱 대신 로그인하고 갱신하는 데몬
이제 이 과정을 누가 계속 돌리나. bao agent 다. 역할은 셋이다.
- auto_auth: 로그인하고, TTL 이 지나기 전에 갱신하고, 상한(max_ttl)에 닿으면 다시 로그인한다.
- sink: Agent 가 가진 OpenBao 토큰을 파일로 쓴다.
- template: 비밀을 원하는 모양으로 렌더해 파일로 쓴다.
설정은 이랬다.
auto_auth {
method "approle" {
mount_path = "auth/approle"
config = {
role_id_file_path = "/run/<suite>/openbao/role-id"
secret_id_file_path = "/run/<suite>/openbao/secret-id"
remove_secret_id_file_after_reading = false
}
}
sink "file" {
config = { path = "/run/<suite>/workload/agent-token", mode = 0400 }
}
}
template {
contents = "{{ with secret \"identity/oidc/token/workload\" }}{{ .Data.token }}{{ end }}"
destination = "/run/<suite>/workload/token"
perms = "0400"
}
remove_secret_id_file_after_reading = false 에는 이유가 있다. 기본값은 true, 한 번 읽고 지운다. 그런데 role 의 token_max_ttl 이 1시간이다. 1시간마다 다시 로그인해야 하는데 파일을 지워 버리면 1시간 뒤 Agent 는 영원히 로그인하지 못한다.
JWT 는 lease 가 없는 응답이라, template 은 정해진 주기(기본 5분으로 알고 있다)마다 다시 받아 파일을 바꾼다. JWT 수명이 15분이니 파일 속 JWT 는 늘 10분 이상 남은 채로 교체된다.
sink 는 이 환경에서 쓰는 곳이 보이지 않았다. 앱은 JWT 파일만 읽는다. 쓰지 않는 sink 는 디스크 위 비밀이 하나 더 있는 셈이라, 나라면 뺀다.
컨테이너 안에 Agent 를 띄우는 방식
여기서 가장 오래 멈춰 있었다. Agent 는 사이드카가 아니라 앱 컨테이너 안에서 돌고 있었다. entrypoint 스크립트가 이렇게 한다.

기동 게이트는 꼼꼼하다. 90초 안에 JWT 파일이 안 생기거나 Agent 가 먼저 죽으면 컨테이너가 실패한다. Compose 와 K8s 에서 같은 이미지, 같은 설정으로 돈다는 장점도 크다.
빈틈은 기동 이후다. exec java 이후 Agent 는 그냥 자식 프로세스로 남는다. Agent 가 OOM 이나 오류로 죽어도 다시 띄우는 주체가 없다. 그러면 이렇게 된다.
- JWT 파일이 더 이상 바뀌지 않는다.
- 앱은 멀쩡히 떠 있고 readiness · liveness 도 통과한다.
- 마지막으로 받은 JWT 가 만료되는 순간(최대 15분 뒤)부터 다른 서비스 호출이 전부 401 이 된다.
아무 경보 없이 15분 뒤에 터지는 장애다. 로그를 보면 401 만 쌓여 있어서 원인을 받는 쪽에서 찾게 된다.
추가로 걸리는 것들도 있었다.
- Agent 설정 ConfigMap 을
subPath로 마운트해서, ConfigMap 을 고쳐도 재시작 전엔 반영되지 않는다. - JVM 이
MaxRAMPercentage=75로 메모리를 잡는데, 같은 컨테이너의 Agent 몫은 모른다. - 빌드 설정상 Agent 바이너리가 서버보다 한 단계 새 버전이었다. 보통은 돌지만 Agent 가 서버보다 새 버전인 조합은 권장 방향이 아니다.
다시 한다면: 네이티브 사이드카
클러스터가 1.29 이상이면 initContainers 에 restartPolicy: Always 를 준 네이티브 사이드카를 쓸 수 있다. 앱보다 먼저 뜨고, 죽으면 kubelet 이 다시 띄우고, 앱보다 늦게 내려간다. 위의 빈틈이 정확히 메워진다.
initContainers:
- name: openbao-agent
image: <registry>/openbao:<server-version>
restartPolicy: Always
args: ["agent", "-config=/agent/workload-agent.hcl"]
startupProbe:
exec: { command: ["sh", "-c", "test -s /run/<suite>/workload/token"] }
periodSeconds: 1
failureThreshold: 90
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { memory: 64Mi }
사이드카로 바꾸지 않더라도 앱 쪽에서 막을 수 있는 게 있다. JWT 파일을 요청할 때마다 다시 읽고, 읽은 JWT 의 exp 가 임계값 아래로 내려가면 readiness 를 실패시키고 남은 시간을 메트릭으로 내보낸다. 그러면 15분 뒤의 401 이 아니라 지금 당장의 알람이 된다.
공용 클러스터에는 Agent Injector 도 설치돼 있었지만 쓰는 Pod 가 하나도 없었다. 기본 auth path 는 auth/kubernetes 였는데 실제로 ESO 가 쓰는 mount 는 k8s-common 이었고, Agent 이미지를 외부 레지스트리에서 받게 되어 있었다. 처음 쓰려는 사람은 두 번 막힐 것이다.
6. 그래서 무엇을 고를까
정리하고 나서 선택지를 다시 놓아 봤다.
| 방식 | 좋은 점 | 대가 |
|---|---|---|
| AppRole + 컨테이너 내장 Agent (현행) | Compose 와 K8s 에서 이미지 · 설정이 같다 | secret_id 라는 비밀이 하나 더 생긴다. Agent 재시작 주체가 없다 |
| Kubernetes auth + 네이티브 사이드카 | secret_id 가 사라진다. kubelet 이 Agent 를 지킨다 | K8s 전용. Compose 쪽은 따로 가져가야 한다 |
| Projected ServiceAccount token 직접 사용 | OpenBao 와 Agent 없이 kubelet 이 알아서 갱신한다 | 클러스터마다 issuer 가 다르고 principal 이 system:serviceaccount:ns:sa 로 고정된다 |
| ESO 로 JWT 를 Secret 에 동기화 | 앱 변경 없음 | 15분짜리 토큰을 1분마다 Secret 에 쓰는 꼴이라 맞지 않는다 |
K8s 안에서만 본다면 세 번째가 가장 가볍다. 받는 쪽 코드가 이미 SA 토큰의 sub 를 principal 로 받을 수 있으니 수신 측 변경도 작다. 다만 Compose 와 VM 배포가 남아 있는 한 OpenBao JWT 경로를 버릴 수는 없다. 그렇다면 K8s 쪽은 Kubernetes auth + 사이드카로 secret_id 를 없애고, Compose 쪽에만 AppRole 을 남기는 게 현실적인 절충이라고 봤다. 같은 entity 에 alias 를 두 개 걸면 JWT 계약은 그대로다.
마치며
두 글을 쓰면서 OpenBao 를 보는 눈이 "값을 두는 곳" 에서 "신원을 확인하고 신원을 발급하는 곳" 으로 바뀌었다. 예전 프로젝트에서 정적 토큰 하나로 KV 를 부르던 코드가 얼마나 많은 것을 건너뛰고 있었는지도 보였다.
두 편을 관통하는 질문은 결국 하나였다. "이 비밀(토큰, secret_id, JWT)이 새면 무엇이 되는가, 그리고 새는 걸 누가 언제 알아채는가." 넓은 정책은 앞의 질문에, 컨테이너 안의 Agent 는 뒤의 질문에 걸렸다. 직접 설계한 시스템이라도 적용해 놓고 이 두 질문을 들고 한 줄씩 다시 읽으면 멈칫할 곳이 꼭 나온다.
'Platform > 인증 · 보안' 카테고리의 다른 글
| 시크릿을 KV 에 넣었다고 끝이 아니었다: OpenBao 의 경계는 인증과 정책이 정한다 (0) | 2026.09.27 |
|---|---|
| 엔진은 Gitea OIDC, 포탈은 Keycloak - 두 인증을 IdP Broker로 묶고, 멱등 부트스트랩까지 도달한 기록 (0) | 2026.04.26 |
| 암호화와 TLS 개념 (+ SSL Offloading) (0) | 2025.08.13 |