IT/DevSecOps

Cosign + Kyverno로 컨테이너 이미지 서명 검증, 클러스터에 강제하기

gfrog 2026. 5. 7. 12:15
반응형

이미지 서명을 안 하는 팀은 이제 거의 없을 거다. 빌드 파이프라인에 cosign 한 줄 박는 건 어렵지도 않으니까. 문제는 검증 쪽이다. 누가 서명 안 된 이미지를 클러스터에 푸시해도 그냥 굴러간다. 결국 admission 단에서 막아야 하는데, 이걸 가장 깔끔하게 해주는 게 Kyverno의 ImageValidatingPolicy다.

올해 초 Kyverno에서 기존 ClusterPolicy의 verifyImages 규칙을 ImageValidatingPolicy(IVP) 라는 별도 타입으로 분리하면서 정책 작성이 좀 더 명시적으로 바뀌었다. 우리 팀에서도 4월 초에 ClusterPolicy 기반 검증을 IVP로 옮겼는데, 옮기면서 정리한 내용을 가이드 형태로 풀어본다.

사전 준비물

  • Kubernetes 1.28+ (가능하면 1.31+, IVP 안정성 이슈가 좀 줄었다)
  • Kyverno 1.13.x 이상
  • cosign 2.4+ (bundle format 기본 활성)
  • 이미지 레지스트리 — ECR, GHCR, Harbor 어느 쪽이든 OCI 1.1 지원하면 OK

cosign 2.x부터는 서명을 별도 .sig 태그가 아닌 OCI 1.1 referrers API로 첨부하는 방식이 기본이다. 레지스트리가 referrers를 지원 안 하면 fallback 모드로 동작하긴 하는데, 검증 시 --experimental 플래그를 안 줘도 되는 건 큰 차이다.

1단계: 빌드 파이프라인에서 keyless 서명

키 관리 안 하려고 keyless 쓰는 거라, GitHub Actions 기준으로 보면 이렇게 끝난다.

permissions:
  id-token: write
  packages: write
  contents: read

jobs:
  build-sign:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        id: build
        with:
          push: true
          tags: ghcr.io/myorg/api:${{ github.sha }}
      - name: Sign with cosign
        env:
          COSIGN_EXPERIMENTAL: "1"
        run: |
          cosign sign --yes \
            ghcr.io/myorg/api@${{ steps.build.outputs.digest }}

id-token: write가 핵심이다. 이게 빠지면 keyless OIDC 토큰을 못 받아서 그대로 멈춘다. 처음 도입할 때 권한 범위 문제로 한 번 헤맸다.

서명 후 Rekor 투명성 로그에 기록이 남는데, cosign verify로 식별자(주체+이슈어)를 매칭하면 검증 끝이다. 그래서 검증 정책에서는 누가, 어디서 서명했는지를 명시해야 한다. 이게 keyless의 핵심 발상.

2단계: Kyverno 설치 시 verify-images 옵션 켜기

Helm 차트로 설치할 때 image verification은 admission webhook 별도 설정이 들어간다. 1.13부터는 IVP 컨트롤러가 기본 활성이긴 한데, 초기 설치 시엔 확인하는 게 좋다.

helm upgrade --install kyverno kyverno/kyverno \
  -n kyverno --create-namespace \
  --set features.imageVerificationCache.enabled=true \
  --set features.imageVerificationCache.maxSize=1024 \
  --set features.imageVerificationCache.ttlDurationInSeconds=3600

imageVerificationCache는 검증 결과를 캐시한다. 이걸 안 켜놓으면 모든 Pod 생성마다 Rekor 조회가 발생해서 admission latency가 올라간다. 우리 팀에선 캐시 켜고 P95가 800ms → 90ms로 떨어졌다.

3단계: ImageValidatingPolicy 작성

본론이다. ghcr.io/myorg/* 이미지에 대해 GitHub Actions에서 만든 keyless 서명을 강제하는 정책이다.

apiVersion: policies.kyverno.io/v1alpha1
kind: ImageValidatingPolicy
metadata:
  name: verify-ghcr-keyless
spec:
  evaluation:
    background:
      enabled: false
  matchConstraints:
    resourceRules:
      - apiGroups:   [""]
        apiVersions: ["v1"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["pods"]
  matchImageReferences:
    - glob: "ghcr.io/myorg/*"
  attestors:
    - name: gha-keyless
      cosign:
        keyless:
          identities:
            - issuer: "https://token.actions.githubusercontent.com"
              subjectRegExp: "^https://github.com/myorg/.+/.github/workflows/.+@refs/heads/(main|release/.+)$"
  validations:
    - expression: >
        images.containers.all(image,
          verifyImageSignatures(image, [attestors.gha-keyless]) >= 1)
      message: "이미지 {{ image }} 서명이 검증되지 않음 (signer/issuer 불일치 또는 미서명)"

subjectRegExp가 보안의 마지막 보루다. main 또는 release/* 브랜치 워크플로에서만 서명을 인정한다. 만약 누가 자기 PR 브랜치에서 서명을 만들어 넣으려 해도 정규식에서 걸린다. 이거 넣을 때 한 번 더 깐깐하게 보는 게 좋다 — 너무 느슨하면 의미가 없고, 너무 빡세면 hotfix 브랜치 같은 게 막힌다.

background.enabled: false는 기존 Pod에 대해 백그라운드 검사를 안 하겠다는 뜻이다. 이미 떠 있는 Pod까지 막으면 마이그레이션 시점에 클러스터가 마비된다. 정책 도입 초기에 이걸로 한 번 사고 칠 뻔했다.

4단계: 단계적 롤아웃

처음부터 failurePolicy: Fail로 가면 안 된다. 다음 순서로 점진 도입을 권장한다.

먼저 validationActions: [Audit] 모드로 한 주 정도 돌린다. Kyverno PolicyReport에 "어떤 이미지가 검증 실패하고 있는지" 다 찍힌다. 의외로 third-party 이미지가 클러스터에 많이 깔려 있다는 걸 발견하게 된다 (cert-manager, external-dns, metric-server… 이런 거). 이 단계에서 matchImageReferences의 glob을 정확히 좁혀야 한다. 우리 팀은 처음에 *로 잡아놓고 audit 돌렸다가 PolicyReport가 폭발해서 다음날 모두 모여 정리했다.

validationActions:
  - Audit

Audit 결과 모든 이미지가 OK로 떨어지는 걸 확인한 뒤에야 Enforce로 바꾼다.

validationActions:
  - Enforce

그리고 third-party 이미지(cosign 서명이 없는 이미지)는 별도 정책으로 명시적 예외 처리 — matchImageReferences에 넣지 말고 따로 allowlist 정책을 운영하는 편이 관리가 깔끔하다.

검증 캐시 관련 함정

캐시 TTL이 너무 길면 키 로테이션이 적용되는 데 시간이 걸린다. 우리 팀은 처음에 24시간으로 잡아놨다가, 이미지 서명 키 한 번 갈았을 때 캐시 만료 전까지 옛 키 검증이 통과해서 좀 어색했다. 1시간(3600초)이 적당한 것 같다.

또 하나, IVP는 Pod만 매칭한다. Deployment/StatefulSet 같은 컨트롤러는 Pod이 만들어질 때 한 번 검증된다. 그래서 배포 단에서 "이미지가 막혔다"가 아니라 "Pod가 안 뜬다"로 증상이 나타난다. 이게 처음엔 좀 헷갈리니까 알람을 PolicyReport 기준으로 따로 잡아두는 게 좋다.

마치며

cosign + Kyverno 조합이 처음 도입할 때는 yaml이 좀 무서워 보이는데, 한 번 IVP 한 개 굴려보면 그다음부턴 attestor만 바꿔가면서 SBOM 검증, vulnerability attestation 같은 걸 줄줄이 붙일 수 있다. 다음 글에서는 SLSA provenance attestation을 IVP로 검증하는 부분을 다뤄볼까 한다.

혹시 다른 검증 도구(Connaisseur, ratify) 쓰시는 분들 있으면 비교 의견 댓글로 남겨주세요.

반응형