Docker Registry v3 완벽 권한 제어
docker_auth + Casbin + JWT/JWK로 Enterprise급 인증/인가 구현
아키텍처 설계
sequenceDiagram
participant Client as 클라이언트
participant Proxy as Reverse Proxy
participant Registry as Registry
participant Auth as docker_auth
participant Script as htpasswd_auth.sh
participant Casbin as Casbin Policy
Client->>Proxy: podman pull image:tag
Proxy->>Registry: GET /v2/image/manifests/tag
Registry->>Client: 401 Unauthorized
WWW-Authenticate: Bearer realm=/auth Client->>Proxy: GET /auth?service=...&scope=... Proxy->>Auth: 토큰 요청 (Basic Auth) Auth->>Script: "username password" Script->>Script: htpasswd 검증 Script-->>Auth: exit 0 (성공) Auth->>Casbin: 인가 확인 (username, image, action) Casbin-->>Auth: allow Auth->>Auth: JWT 토큰 생성 및 서명 Auth-->>Client: {token: "eyJ..."} Client->>Proxy: GET /v2/image/manifests/tag
Authorization: Bearer eyJ... Proxy->>Registry: 요청 + 토큰 Registry->>Registry: JWK로 토큰 검증 Registry-->>Client: 200 OK + Manifest
WWW-Authenticate: Bearer realm=/auth Client->>Proxy: GET /auth?service=...&scope=... Proxy->>Auth: 토큰 요청 (Basic Auth) Auth->>Script: "username password" Script->>Script: htpasswd 검증 Script-->>Auth: exit 0 (성공) Auth->>Casbin: 인가 확인 (username, image, action) Casbin-->>Auth: allow Auth->>Auth: JWT 토큰 생성 및 서명 Auth-->>Client: {token: "eyJ..."} Client->>Proxy: GET /v2/image/manifests/tag
Authorization: Bearer eyJ... Proxy->>Registry: 요청 + 토큰 Registry->>Registry: JWK로 토큰 검증 Registry-->>Client: 200 OK + Manifest
디렉터리 구조
/srv/registry/ ├── data/ # Registry 이미지 저장소 ├── auth/ │ └── htpasswd # 기존 사용자 인증 파일 ├── config/ │ └── config.yml # Registry 설정 └── docker_auth/ ├── Dockerfile # apache2-utils 포함 커스텀 이미지 ├── config/ │ └── auth_config.yml # docker_auth 메인 설정 ├── scripts/ │ └── htpasswd_auth.sh # 외부 인증 스크립트 ├── casbin/ │ ├── model.conf # Casbin RBAC 모델 │ └── policy.csv # 권한 정책 └── ssl/ ├── server.pem # 공개키 (토큰 검증용) ├── server-key.pem # 비밀키 (토큰 서명용) └── jwks.json # JWK Set (Registry v3)
인증 스크립트 구현
htpasswd_auth.sh
#!/usr/bin/env sh
set -eu
HTPASSWD_FILE="${HTPASSWD_FILE:-/auth/htpasswd}"
HTPASSWD_BIN="${HTPASSWD_BIN:-/usr/bin/htpasswd}"
# 1. stdin에서 "user pass" 읽기 (docker_auth가 공백으로 구분)
LINE="$(cat)"
# 2. CR 제거 (Windows/Proxy 환경 대응)
LINE=$(printf '%s' "$LINE" | tr -d '\r')
# 3. 사용자명/비밀번호 분리
USER="$(printf '%s' "$LINE" | awk '{print $1}')"
PASS="${LINE#"$USER"}"
PASS="$(printf '%s' "$PASS" | sed -e 's/^[[:space:]]\+//')"
# 4. 검증
[ -n "$USER" ] || exit 1
[ -n "$PASS" ] || exit 1
[ -r "$HTPASSWD_FILE" ] || exit 1
# 5. htpasswd 검증 (-i: stdin으로 비밀번호 전달)
if printf '%s' "$PASS" | "$HTPASSWD_BIN" -v -i "$HTPASSWD_FILE" "$USER" >/dev/null 2>&1; then
exit 0
else
exit 1
fi
💡 핵심 포인트
- awk/sed 파싱: docker_auth의 ext_auth는 "username password" 형식으로 stdin 전달
- CR 제거: Windows 클라이언트나 프록시를 거칠 때 \r\n 개행 문제 방지
- -i 옵션: 비밀번호를 명령줄이 아닌 stdin으로 전달 (ps 명령어에 노출 방지)
Casbin 권한 제어
model.conf
[request_definition]
r = account, type, name, service, ip, action, labels
[policy_definition]
p = account, type, name, service, ip, action, labels
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.account == "admin" || (
g(r.account, p.account) &&
r.type == p.type &&
keyMatch2(r.name, p.name) &&
r.service == p.service &&
ipMatch(r.ip, p.ip) &&
(p.action == "*" || r.action == p.action)
)policy.csv
# format: p, account, type, name, service, ip, action, labels
# 1. admin: 모든 권한
p, admin, registry, catalog, registry.example.com, 0.0.0.0/0, *, {}
p, admin, repository, *, registry.example.com, 0.0.0.0/0, pull, {}
p, admin, repository, *, registry.example.com, 0.0.0.0/0, push, {}
# 2. 익명 사용자: 읽기 전용
p, "", repository, *, registry.example.com, 0.0.0.0/0, pull, {}
# 3. 개발자별 권한
p, developer, repository, app-backend, registry.example.com, 0.0.0.0/0, pull, {}
p, developer, repository, app-backend, registry.example.com, 0.0.0.0/0, push, {}ACL (동적 네임스페이스)
# auth_config.yml의 acl 섹션
acl:
# 1. 사용자는 자기 네임스페이스(username/*)에 모든 권한
- match: { account: "/.+/", name: "${account}/*" }
actions: ["*"]
# 2. 인증된 사용자는 catalog 조회 가능
- match: { account: "/.+/", type: "registry", name: "catalog" }
actions: ["*"]
# 3. 인증된 사용자는 모든 이미지 pull 가능
- match: { account: "/.+/" }
actions: ["pull"]
✅ ACL의 장점
${account} 동적 치환으로 사용자별 네임스페이스 자동 할당
예: alice 사용자는 alice/* 이미지에 자동으로 모든 권한 획득
→ 사전 정책 추가 불필요!
JWK 변환 (Registry v3 핵심)
⚠️ Registry v3 필수 요구사항
Registry v3는 JWT 토큰 검증을 위해 JWK(JSON Web Key) 형식을 권장합니다.
docker_auth의 RSA 공개키를 JWK Set으로 변환하여 Registry에 제공해야 합니다.
Step 1: 토큰에서 kid 추출
# 1. 테스트 토큰 발급
TOKEN=$(curl -s -u admin:password \
'https://registry.example.com/auth?service=registry.example.com&scope=registry:catalog:*' \
| jq -r '.token // .access_token')
# 2. JWT 헤더에서 kid(Key ID) 추출
KID=$(echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq -r .kid)
echo "Extracted KID: $KID"
# kid가 비어있으면 docker_auth 설정 문제
# → auth_config.yml에서 disable_legacy_key_id: true 확인Step 2: RSA 공개키 추출
# docker_auth의 비밀키에서 공개키 추출
sudo openssl rsa -in /srv/registry/docker_auth/ssl/server-key.pem -pubout \
-out /srv/registry/docker_auth/ssl/jwt-pub.pem
# 공개키 확인
cat /srv/registry/docker_auth/ssl/jwt-pub.pem
# 출력 예시:
# -----BEGIN PUBLIC KEY-----
# MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
# -----END PUBLIC KEY-----Step 3: PEM → JWK 변환
# 1. pem-jwk 도구 설치
sudo npm install -g pem-jwk
# 2. PEM을 JWK로 변환
pem-jwk /srv/registry/docker_auth/ssl/jwt-pub.pem \
> /srv/registry/docker_auth/ssl/jwk.json
# 3. kid 추가 + JWKS 형식으로 래핑
jq --arg kid "$KID" \
'(.kid=$kid) | .alg="RS256" | .use="sig" | {keys:[.]}' \
/srv/registry/docker_auth/ssl/jwk.json \
> /srv/registry/docker_auth/ssl/jwks.json
# 4. 최종 JWKS 확인
cat /srv/registry/docker_auth/ssl/jwks.json | jq .
💡 JWKS 형식
{
"keys": [
{
"kty": "RSA",
"n": "xGOr1...", // 공개키 모듈러스 (Base64)
"e": "AQAB", // 공개 지수
"kid": "ABC123", // 토큰 헤더의 kid와 매칭
"alg": "RS256", // 서명 알고리즘
"use": "sig" // 용도 (서명 검증)
}
]
}Step 4: Registry config.yml 설정
# /srv/registry/config/config.yml
version: 0.1
log:
level: info
http:
addr: :5000
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
auth:
token:
realm: https://registry.example.com/auth
service: registry.example.com
issuer: "Registry Auth"
rootcertbundle: /docker_auth_ssl/server.pem
jwks: /docker_auth_ssl/jwks.json # ← Registry v3 필수!Step 5: Registry 컨테이너 재시작
# 1. 기존 컨테이너 중지
sudo systemctl stop container-registry.service
sudo podman rm -f registry
# 2. docker_auth SSL 마운트 포함하여 재생성
sudo podman run -d --name registry \
-p 127.0.0.1:5000:5000 \
-v /srv/registry/data:/var/lib/registry \
-v /srv/registry/config:/etc/docker/registry \
-v /srv/registry/docker_auth/ssl:/docker_auth_ssl:ro \
docker.io/library/registry:3 \
serve /etc/docker/registry/config.yml
# 3. systemd 유닛 재생성
sudo podman generate systemd --name registry --files
sudo mv container-registry.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now container-registry.service
# 4. 로그 확인 (JWKS 로드 성공 메시지)
sudo podman logs registry --tail 20Step 6: JWK 검증
# 1. 새 토큰 발급
TOKEN=$(curl -s -u admin:password \
'https://registry.example.com/auth?service=registry.example.com&scope=registry:catalog:*' \
| jq -r '.token')
# 2. 토큰 페이로드 확인
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# 기대 결과:
{
"iss": "Registry Auth",
"sub": "admin",
"aud": "registry.example.com",
"exp": 1704067890,
"access": [
{
"type": "registry",
"name": "catalog",
"actions": ["*"]
}
]
}
# 3. Registry API 호출 테스트
curl -H "Authorization: Bearer $TOKEN" \
https://registry.example.com/v2/_catalog
# 성공 시: {"repositories":["app-backend","app-frontend"]}배포 및 검증
전체 검증 시나리오
1
익명 사용자 읽기
podman logout registry.example.com
podman pull registry.example.com/app-backend:latest
# → 성공 (익명 pull 허용)
2
익명 사용자 쓰기 차단
podman push registry.example.com/test:1.0
# → 403 Forbidden (익명은 읽기만 가능)
3
Admin 전체 권한
podman login registry.example.com -u admin
podman tag alpine:latest registry.example.com/production/app:1.0
podman push registry.example.com/production/app:1.0
# → 성공
4
일반 사용자 제한
podman login registry.example.com -u developer
podman push registry.example.com/production/app:1.0
# → 403 Forbidden (production 이미지 쓰기 권한 없음)
5
사용자 네임스페이스 (ACL)
podman login registry.example.com -u alice
podman tag alpine:latest registry.example.com/alice/myapp:1.0
podman push registry.example.com/alice/myapp:1.0
# → 성공 (ACL의 ${account} 동적 치환)
podman login registry.example.com -u bob
podman push registry.example.com/alice/myapp:2.0
# → 403 Forbidden (bob은 alice 네임스페이스 쓰기 불가)운영 표준 절차
정책 관리 워크플로우
| 상황 | 조치 | 정책 파일 |
|---|---|---|
| 개인 프로젝트 | username/project:tag 사용 | 정책 추가 불필요 (ACL 자동) |
| 조직 공용 이미지 | project:tag 사용 | policy.csv에 수동 추가 |
| 팀 네임스페이스 | team/project:tag 사용 | policy.csv에 팀 권한 추가 |
| 임시 권한 부여 | 특정 사용자+이미지 | policy.csv에 한시적 정책 |
새 이미지 등록 절차
# Case 1: 개인 네임스페이스 (정책 추가 불필요)
podman login registry.example.com -u dev1
podman tag myimage:1.0 registry.example.com/dev1/myproject:1.0
podman push registry.example.com/dev1/myproject:1.0
# → ACL이 자동 처리
# Case 2: 조직 공용 이미지 (정책 추가 필요)
# 1. policy.csv 편집
sudo vi /srv/registry/docker_auth/casbin/policy.csv
# 2. 정책 추가
p, dev1, repository, backend-api, registry.example.com, 0.0.0.0/0, push, {}
p, dev1, repository, backend-api, registry.example.com, 0.0.0.0/0, pull, {}
# 3. docker_auth 재시작
sudo systemctl restart container-docker-auth.service
# 4. 푸시
podman push registry.example.com/backend-api:1.0정책 적용 주의사항
⚠️ 토큰 캐싱 지연
정책 변경 후 즉시 반영되지 않을 수 있습니다:
- Hot-Reload 미지원: docker_auth 재시작 필수
- 토큰 만료 시간: 기본 15분 (expiration: 900)
- 클라이언트 캐싱: podman/docker가 토큰 재사용
즉시 반영 방법:
podman logout registry.example.com
podman login registry.example.com롤백 및 트러블슈팅
롤백 절차
# 1. docker_auth 중지
sudo systemctl stop container-docker-auth.service
sudo systemctl disable container-docker-auth.service
# 2. Registry 중지
sudo systemctl stop container-registry.service
sudo podman rm -f registry
# 3. 기존 설정 복원
sudo cp /srv/registry/config/config.yml.backup \
/srv/registry/config/config.yml
sudo cp /etc/caddy/Caddyfile.backup \
/etc/caddy/Caddyfile
# 4. Registry 기존 방식으로 재시작
sudo podman run -d --name registry \
-p 127.0.0.1:5000:5000 \
-v /srv/registry/data:/var/lib/registry \
-v /srv/registry/config:/etc/docker/registry \
docker.io/library/registry:3
# 5. Caddy 재시작
sudo systemctl restart caddy
# 6. 검증
curl -u admin:password https://registry.example.com/v2/_catalog트러블슈팅: kid 없음
증상
토큰 헤더에 kid가 없거나 null
# 원인: docker_auth 설정 누락
sudo vi /srv/registry/docker_auth/config/auth_config.yml
# 확인:
token:
disable_legacy_key_id: true # ← 이 줄 필수!
# 재시작
sudo systemctl restart container-docker-auth.service트러블슈팅: JWKS 로드 실패
# Registry 로그 확인
sudo podman logs registry | grep -i jwks
# 원인 1: 파일 권한
sudo chmod 644 /srv/registry/docker_auth/ssl/jwks.json
# 원인 2: JWK 형식 오류
cat /srv/registry/docker_auth/ssl/jwks.json | jq .
# keys 배열이 있어야 함:
{
"keys": [...] # ← 필수!
}
# 재생성
jq '{keys:[.]}' /srv/registry/docker_auth/ssl/jwk.json \
> /srv/registry/docker_auth/ssl/jwks.json트러블슈팅: 정책 미반영
# 1. docker_auth 재시작 (필수)
sudo systemctl restart container-docker-auth.service
# 2. 기존 토큰 무효화
podman logout registry.example.com
podman login registry.example.com
# 3. 토큰 만료 시간 단축 (선택)
sudo vi /srv/registry/docker_auth/config/auth_config.yml
token:
expiration: 300 # 5분 (기본 900초)
sudo systemctl restart container-docker-auth.service
✅ 구현 완료 체크리스트
- ✅ docker_auth 컨테이너 정상 실행
- ✅ htpasswd 인증 스크립트 동작
- ✅ Casbin 정책 로드 성공
- ✅ JWK Set 생성 및 kid 매칭
- ✅ Registry가 JWT 토큰 검증 성공
- ✅ 익명 읽기, 인증 쓰기 분리
- ✅ 사용자 네임스페이스 ACL 동작
- ✅ 정책 변경 워크플로우 확립
- ✅ 롤백 절차 준비
작성일: 2025년 1월
'docker' 카테고리의 다른 글
| Rootful vs Rootless Podman네트워크 격리 문제 (0) | 2026.01.13 |
|---|---|
| Docker Compose 대안 (0) | 2026.01.13 |
| Podman vs Docker 2026 (0) | 2026.01.13 |
| Podman과 Caddy로 구축하는 프라이빗 컨테이너 레지스트리: 완벽 가이드 (0) | 2025.12.09 |
| Error: looking up compose provider failed (podman compose 설치 방법) (0) | 2025.11.17 |