컨테이너 이미지를 레지스트리에 올리는 순간, 우리는 암묵적으로 “이 태그로 pull 한 이미지가 내가 빌드한 그 이미지일 것이다”라고 믿습니다. 하지만 태그는 언제든 다른 다이제스트를 가리키도록 덮어쓸 수 있고, 레지스트리 계정이 탈취되면 악의적인 이미지가 같은 이름으로 배포될 수 있습니다. 이 믿음을 검증 가능한 사실로 바꿔주는 것이 이미지 서명(signing)입니다.
과거의 이미지 서명은 별도의 키 관리 인프라와 무거운 프로토콜 때문에 도입이 쉽지 않았습니다. Sigstore와 그 도구 Cosign은 이 문턱을 크게 낮췄고, 개인 키를 로컬에 두지 않는 키리스(keyless) 서명은 CI 환경에서 서명을 자연스럽게 만듭니다. 이 글에서는 기본 서명·검증부터 키리스 서명의 원리, SBOM·증명(attestation) 첨부, admission 단계에서 검증을 강제하는 구성까지 정리합니다.
이미지 서명이 실제로 막아주는 것
서명은 두 가지를 증명합니다. 첫째는 무결성(integrity)으로, 서명된 다이제스트의 이미지 내용이 서명 이후 변조되지 않았다는 것입니다. 둘째는 출처(provenance)로, 특정 신원(개인 키 소유자 또는 특정 CI 워크플로)이 이 이미지를 서명했다는 것입니다.
반대로 서명은 이미지 내부가 안전하다는 보장이 아닙니다. 취약점이 가득한 이미지도 얼마든지 서명될 수 있습니다. “누가 만들었는지”와 “그 뒤로 안 바뀌었는지”만 말할 뿐, “만든 것이 좋은지”는 취약점 스캔·정책 검사가 담당합니다. 이 둘을 혼동하면 서명만 하고 안심하는 함정에 빠집니다.
- 보장하는 것: 다이제스트 무결성, 서명 신원, 첨부된 증명의 진위
- 보장하지 못하는 것: 이미지 내부의 취약점 부재, 빌드 소스의 안전성 그 자체
Cosign은 서명을 이미지 옆에 별도 아티팩트로 레지스트리에 저장합니다. 외부 저장소가 필요 없고 OCI 레지스트리 하나로 이미지와 서명이 함께 이동하므로, 기존 CI/CD 파이프라인에 끼워 넣기가 쉽습니다.
키 기반 서명: 가장 단순한 시작
가장 이해하기 쉬운 방식은 전통적인 키 쌍입니다. Cosign이 타원곡선 키 쌍을 생성하고, 개인 키로 서명하며 공개 키로 검증합니다. 폐쇄망에 좋습니다.
# 키 쌍 생성 — 개인 키는 암호로 보호된다
cosign generate-key-pair
# 결과: cosign.key(개인, 암호 보호), cosign.pub(공개)
IMG=registry.example.com/api:1.4.0
# 다이제스트로 고정해 서명하는 습관이 안전하다(태그는 움직일 수 있음)
DIGEST=$(cosign triangulate --type digest "$IMG" 2>/dev/null ||
crane digest "$IMG")
# 개인 키로 서명 — 서명은 레지스트리에 별도 아티팩트로 저장됨
cosign sign --key cosign.key "registry.example.com/api@${DIGEST}"
# 공개 키로 검증
cosign verify --key cosign.pub "registry.example.com/api@${DIGEST}"
여기서 핵심 습관은 태그가 아니라 다이제스트로 서명·검증하는 것입니다. api:1.4.0 같은 태그는 나중에 다른 이미지를 가리키도록 덮어쓸 수 있으므로, 서명 대상을 @sha256:... 형태로 고정하고 검증도 다이제스트 기준으로 하면 태그 재지정 공격을 원천 차단할 수 있습니다.
키 기반 방식의 약점은 명확합니다. 개인 키를 누군가 관리해야 하고, 유출되면 그 키로 서명된 모든 것을 신뢰할 수 없게 됩니다. 키 로테이션·폐기라는 오래된 숙제가 남는데, 이를 없애는 것이 다음의 키리스 서명입니다.
키리스 서명: 개인 키를 아예 없앤다
키리스 서명은 장기 개인 키를 보관하지 않습니다. 서명 시점에 OIDC 신원(예: CI 워크플로의 신원 토큰)을 증명하면, Sigstore의 인증 기관 Fulcio가 그 신원에 대해 아주 짧은 수명(약 10분)의 인증서를 발급합니다. 그 인증서로 서명하고 기록은 Rekor 투명성 로그에 남으며, 서명이 끝나면 인증서는 만료됩니다.
이 구조의 이점은 “훔칠 키가 없다”는 것입니다. 인증서가 10분 만에 만료되므로 유출된들 쓸모가 없고, 신원 자체가 인증서에 새겨집니다. 검증할 때는 공개 키가 아니라 어떤 신원이 서명했는지를 확인합니다.
# 키리스 서명 — 로컬 개인 키 없음. OIDC 신원으로 서명
# CI 환경에서는 대화형 없이 워크플로 토큰을 자동 사용
COSIGN_EXPERIMENTAL=1 cosign sign
--yes
"registry.example.com/api@${DIGEST}"
# 검증: 서명한 '신원'과 'OIDC 발급자'를 반드시 지정한다
cosign verify
--certificate-identity-regexp
"^https://github.com/acme/.+/.github/workflows/release.yml@refs/tags/.+$"
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
"registry.example.com/api@${DIGEST}"
여기서 가장 흔한 실수는 검증 시 신원 조건을 느슨하게 두는 것입니다. --certificate-identity-regexp를 .*로 두면 누구든 유효한 Sigstore 서명만 붙이면 통과해, “우리 워크플로가 서명했다”가 아니라 “누군가 서명했다”만 확인하는 셈이 됩니다. 반드시 발급자와 신원을 특정 리포지토리·워크플로·브랜치까지 좁혀서 지정해야 의미가 있습니다.
CI 파이프라인에서 자동 서명하기
키리스 서명은 CI에서 진가를 발휘합니다. 워크플로에 OIDC 토큰 발급 권한만 주면 별도 시크릿 없이 서명됩니다. 아래는 빌드·푸시 후 그 다이제스트를 서명하는 예시입니다.
# .github/workflows/release.yml
name: build-sign
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
id-token: write # OIDC 토큰 발급 — 키리스 서명의 핵심 권한
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & push (다이제스트 확보)
id: push
run: |
IMG=ghcr.io/acme/api
docker build -t "$IMG:${GITHUB_REF_NAME}" .
docker push "$IMG:${GITHUB_REF_NAME}"
# 태그가 아닌 다이제스트를 출력으로 넘긴다
DIGEST=$(crane digest "$IMG:${GITHUB_REF_NAME}")
echo "ref=${IMG}@${DIGEST}" >> "$GITHUB_OUTPUT"
- uses: sigstore/cosign-installer@v3
- name: Keyless sign
run: cosign sign --yes "${{ steps.push.outputs.ref }}"
주목할 부분은 id-token: write 권한과, 서명 대상으로 태그가 아닌 다이제스트를 넘긴다는 점입니다. 푸시 직후 다이제스트를 캡처해 이후 단계에서 그 다이제스트만 쓰면, 빌드와 서명 사이 태그가 다른 이미지를 가리키게 바뀌는 경쟁 조건을 피할 수 있습니다.
SBOM과 증명(attestation) 첨부
서명은 “이 다이제스트를 이 신원이 서명했다”만 말합니다. 여기에 무엇을 담았고 어떻게 빌드했는지를 검증 가능한 형태로 덧붙이는 것이 증명(attestation)입니다. 대표적으로 구성 명세 SBOM과 빌드 출처를 담은 SLSA provenance가 있습니다. Cosign은 임의의 서술(predicate)을 특정 타입으로 서명해 붙일 수 있습니다. 아래는 SBOM을 cyclonedx 증명으로 첨부하는 예시입니다.
# 1) SBOM 생성 (예: syft)
syft "registry.example.com/api@${DIGEST}"
-o cyclonedx-json=sbom.cdx.json
# 2) SBOM을 서명된 증명으로 첨부 (키리스)
cosign attest --yes
--predicate sbom.cdx.json
--type cyclonedx
"registry.example.com/api@${DIGEST}"
# 3) 증명 검증 — 신원 조건은 서명 검증과 동일하게 좁혀서
cosign verify-attestation
--type cyclonedx
--certificate-identity-regexp
"^https://github.com/acme/.+/release.yml@refs/tags/.+$"
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
"registry.example.com/api@${DIGEST}"
증명은 검증 단계에서 정책과 결합할 때 강력합니다. 예컨대 “SBOM 증명이 존재하고 빌드 출처가 우리 조직의 워크플로다”를 만족해야만 배포를 허용하는 식입니다. 다만 SBOM은 구성 목록일 뿐이며, “무엇이 들었는지”는 SBOM이, “그것이 취약한지”는 스캐너가 판단합니다.
클러스터 admission에서 검증 강제하기
서명과 증명을 잘 붙여도 실행 시점에 검증을 강제하지 않으면 의미가 반감됩니다. 서명되지 않은 이미지가 그냥 배포된다면 서명은 선택 사항일 뿐입니다. 그래서 Kubernetes admission 단계에서 검증을 강제하는 정책 컨트롤러(예: Sigstore Policy Controller, Kyverno)를 함께 씁니다. 아래는 Kyverno로 특정 레지스트리 이미지에 키리스 서명을 요구하는 예시입니다.
# Kyverno: 지정 신원의 서명이 없으면 파드 생성 거부
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce # 위반 시 거부(Audit 아님)
webhookTimeoutSeconds: 30
rules:
- name: verify-api-image
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "ghcr.io/acme/api*"
# 검증 후 다이제스트로 치환 — 태그 변조를 원천 차단
mutateDigest: true
required: true
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/acme/api/.github/workflows/release.yml@refs/tags/*"
rekor:
url: "https://rekor.sigstore.dev"
핵심 두 가지를 짚습니다. 첫째, validationFailureAction: Enforce로 두어야 실제로 거부가 일어납니다. 갑자기 강제로 켜면 서명 없는 기존 이미지들이 일제히 배포 실패하므로, 처음에는 Audit로 로그만 관찰하다가 기존 워크로드가 모두 서명을 갖춘 뒤 Enforce로 전환하는 순서가 안전합니다.
둘째, mutateDigest: true는 검증에 통과한 이미지 참조를 다이제스트로 치환해, 검증 시점과 실행 시점의 이미지를 동일한 다이제스트로 못 박습니다. 태그 재지정으로 둘이 어긋나는 시간차 공격을 막는 장치입니다.
투명성 로그와 정보 노출
키리스 서명이 “훔칠 키가 없다”는 이점을 갖는 배경에는 짧은 수명 인증서와 함께 투명성 로그(Rekor)가 있습니다. 모든 서명은 Rekor에 기록되고, 검증기는 서명이 인증서 유효 기간 안에 이루어졌는지를 이 로그로 확인합니다. 인증서가 만료된 뒤에도 서명이 유효했던 시점에 이루어졌다는 사실이 로그로 증명되므로 검증은 정상 동작합니다.
다만 공용 Sigstore에 서명하면 그 기록이 공개 로그에 영구히 남습니다. 서명 대상 다이제스트와 서명 신원(워크플로 경로, 서명자 이메일 등)이 공개되므로, 이 노출이 부담스럽다면 자체 호스팅한 Fulcio·Rekor로 사설 인스턴스를 운영하면 됩니다. 공개 로그는 신뢰의 원천이자 정보 노출 지점이라는 양면성을 갖습니다.
마무리
컨테이너 이미지 서명은 “내가 pull 한 이미지가 정말 내가 빌드한 것인가”에 검증 가능한 답을 줍니다. Cosign과 Sigstore는 이 과정을 실무에 얹을 만큼 가볍게 만들었고, 키리스 서명은 개인 키 관리라는 오랜 숙제를 없앱니다. 핵심 원칙은, 서명·검증은 다이제스트 기준으로, 검증 시에는 발급자와 신원을 좁게 지정하고, admission 단계에서 Audit부터 점진적으로 Enforce로 강제하며, SBOM·provenance 증명을 함께 다뤄 출처와 구성까지 검증하는 것입니다.
동시에 한계를 잊지 말아야 합니다. 서명은 출처와 무결성만 증명할 뿐 내부 안전성은 스캔·정책이 담당하고, 공개 투명성 로그는 신뢰의 근거이자 정보 노출 지점입니다. 서명을 “붙이는 것”보다 “검증을 강제하는 것”이 실제 방어선입니다. 검증을 강제하지 않는 서명은 보안이 아니라 형식입니다.
자주 묻는 질문
Q. 서명만 하면 취약점 관리는 안 해도 되나요?
A. 별개의 문제입니다. 서명은 출처와 무결성만 보장할 뿐 취약한 패키지 유무는 판단하지 않습니다. 스캔과 정책 검사를 병행하고, 이상적으로는 스캔 결과를 증명으로 첨부해 검증 단계에서 함께 강제하세요.