Files
service-catalog/.claude/image-authoring.md
T
wbsong111 79555215a0 파이프라인을 두 축으로 갈라 소유 문서를 확정한다
"파이프라인이 chart CVE 조치와 커스텀 이미지 빌드 2개로 나뉘어 있는가" 를 확인하다가 실제
구성이 그 모델과 다른 것이 드러났다.

  1. 워크플로는 3개다. cve-edge-post.yml 이 CLAUDE.md 에서 디렉토리 트리와 괄호 안에만
     등장해 파이프라인으로 읽히지 않았다 — "2개" 인식의 근원이다.
  2. doc/sbom-pipeline.md 가 두 축을 한 파일에 담고 있었다(자체 빌드 절 43줄).
     커스텀 이미지가 별도 레포로 분리될 예정인데 이 상태로는 분리 때 파일을 찢어야 한다.
  3. 그 문서가 이미 뒤집힌 결정을 담고 있었다 — "schedule 트리거는 없다. 블라인드 정기
     재빌드는 제거했다" 인데 PR #40 이 schedule 을 추가했다.

축을 이렇게 갈랐다
-----------------
  차트 카탈로그 축   sbom.yml · cve-edge-post.yml   →  doc/sbom-pipeline.md
  자체 빌드 축       build-image.yml                →  .claude/image-authoring.md

doc/sbom-pipeline.md — 차트 축만 남긴다
  - 상단에 "이 문서가 다루는 축" 을 두고 자체 빌드는 링크로 넘긴다
  - 자체 빌드 절(43줄)을 image-authoring.md 로 이관
  - sbom.yml ↔ cve-edge-post.yml 비교 표 신설. **판정기가 두 벌**이라는 사실을 명시했다 —
    cve-edge-post.yml 은 집계를 워크플로 YAML 안의 인라인 python 으로 갖고 있어 승인 예외도
    실효 등급도 적용하지 않는다. 같은 스캔 데이터에서 다른 숫자가 나올 수 있다
  - PR 을 실제로 막는 게이트는 images/** PR 뿐이고 manifests/applicationset/** 는 아무
    워크플로도 보지 않는다는 사각지대를 적었다

.claude/image-authoring.md — 자체 빌드 축의 단일 출처가 된다
  - 이관받은 워크플로 서술 + "이 워크플로의 게이트는 강제다"(차트 축 warn-only 와 다르다는
    사실이 지금까지 한 곳에만 있었다)
  - schedule 결정 정정 — 지금 것은 블라인드가 아니라 CVE 트리거다. 수정 버전이 있는 차단
    CVE 가 있을 때만 빌드하고 없으면 아무것도 하지 않는다. 거부된 것과 조건이 다르다
  - "레포 분리 후 무엇이 끊기는가" 결합점 7개 표. 3번(탐지가 카탈로그를 읽는다)이 가장 크고,
    게이트 공유는 workflow_call 이 아니라 composite action 이어야 한다는 것도 적었다
    (workflow_call 은 별도 job 이라 $OUT_DIR 를 공유하지 못한다)
  - 파일 상단에 "레포 분리 시 images/·scripts/build/·build-image.yml 과 함께 이동한다"
  - #35 에서 실측한 매핑 함정 추가 — CHART_DIRS 에 없는 파일은 patch-catalog-tag.py 가
    검사조차 하지 않아 cnpg-cluster/1.1.0 이 조용히 빠졌다

CLAUDE.md — 지도만 남긴다
  두 축 비교 표(질문·워크플로·게이트 강도·소유 문서)로 바꾸고 메커니즘 서술을 걷어냈다.
  cve-edge-post.yml 을 파이프라인으로 처음 등재했다.

검증
----
  표의 사실 대조   각 워크플로의 cve-gate 호출·warn-only·활성 schedule 을 파일에서 확인
  축 분리          sbom-pipeline.md 에 남은 build-image.yml 언급은 전부 링크·대조·시크릿
                   공유 문장(의도된 것)
  뒤집힌 결정      "schedule 트리거는 없다" 잔존 0건
  링크             3개 문서의 로컬 링크 전부 실재

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:26:14 +09:00

32 KiB

자체 빌드 이미지 작업 규칙

자체 빌드 축의 단일 출처다. 새 자체 빌드 이미지를 추가하거나 기존 빌드 정의를 바꿀 때, 그리고 build-image.yml 이 무엇을 하는지 알아야 할 때 여기를 본다.

카탈로그의 CVE 파이프라인은 두 축이고 이 문서는 "이미지를 어떻게 만드는가" 를 갖는다. "우리가 배포하는 이미지에 무엇이 있는가"(sbom.yml·cve-edge-post.yml, 스캔·게이트 메커니즘)는 doc/sbom-pipeline.md 가 단일 출처다 — 여기 복제하지 않는다.

레포 분리 시 이 파일은 images/ · scripts/build/ · build-image.yml 과 함께 이동한다. 커스텀 이미지가 별도 레포로 나가는 것이 계획이고, 그때 끊기는 결합점은 아래 "레포 분리 후 무엇이 끊기는가" 에 정리돼 있다.

security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅했다. 자체 빌드 이미지는 images/<image>/ 에 있고(목록은 그 디렉토리가 단일 출처) docker.io/paasup 에 push 되어 카탈로그 values 가 그 태그를 참조한다. 아래는 프레임워크가 어떻게 동작하는지와, 이미지를 추가·변경할 때 지켜야 할 규칙이다.

원칙 1 — 오케스트레이션은 항상 하나, 이미지 종류는 몰라도 된다

scripts/build/build-hardened-image.sh 하나가 모든 자체 빌드 이미지를 빌드한다. 이미지가 OS 패키지를 재설치하는 것이든, 소스를 직접 컴파일하는 것이든 스크립트는 같다 — 차이는 전부 images/<image>/ 안에 있다.

새 오케스트레이션 스크립트를 만드는 것은 최후의 수단이다. "이 이미지는 성격이 다르다"는 이유만으로 새 스크립트를 만들지 않는다. 절차(빌드 → 기능검증 → SBOM → 스캔 → 게이트 → push)는 이미지 종류와 무관하게 동일하고, 차이는 Dockerfile 내부(무엇을 어떻게 설치·컴파일하는가)에만 있어야 한다.

build-hardened-image.sh 가 요구하는 계약

images/<image>/<variant>.build.env 가 다음을 선언하면 스크립트는 이미지 종류를 몰라도 된다:

의미
DOCKERFILE images/<image>/ 기준 상대 경로
TARGET docker build --target 에 넘길 스테이지명
BUILD_ARGS 공백 구분 변수명 목록. 여기 나열한 것만 --build-arg 로 전달된다
APP_VERSION 태그 프리픽스·verify.sh 전달용 범용 버전 문자열 (유일한 필수값)

그 외 이미지별 변수(예: PG_MAJOR, SOURCE_COMMIT)는 build.env 에 적기만 하면 자동으로 verify.sh 의 환경변수로 전달된다 — 스크립트가 무엇을 넘겨야 하는지 알 필요가 없다.

verify.sh 는 호스트에서 bash 로 실행된다

게스트 컨테이너에 stdin 으로 셸 스크립트를 주입하는 방식이 아니다 — env TAG=... PLATFORM=... <build.env 변수들> bash images/<image>/verify.sh 로 호출된다. 이유: 이미지에 셸이 없을 수 있다(distroless 계열 최종 이미지는 /bin/sh 가 없다). 호스트 스크립트는 셸이 있는 이미지엔 docker run --entrypoint sh ... <<'EOF' 로 게스트 스크립트를 쓸 수 있고, 셸이 없는 이미지엔 docker run --entrypoint <바이너리> 로 직접 실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 VERIFY-OK 를 출력해야 통과로 판정된다.

원칙 2 — 최종 런타임 베이스 OS 는 SUSE BCI

배포판은 결정됐다(2026-08-07, keycloak 이미지 추가 PR). 그전까지는 "security-catalog 의 SUSE BCI 단일화 결정을 이식하지 않았다" 는 미결 상태였다. keycloak 은 업스트림이 UBI9 기반이라 "업스트림과 최대한 동일하게" 와 정면으로 충돌했고, 카탈로그 내 일관성을 우선했다 — 먼저 들어온 이미지들이 전부 BCI 이고 trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다 (게이트의 CoverageProbedoc/sbom-pipeline.md).

  • 이 정책과 "업스트림과 최대한 동일하게" 가 충돌하면 무엇을 우선했는지와 왜인지를 해당 이미지 README 에 남긴다. 정책이 있다고 기록을 생략하지 않는다.
  • 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 — 공식 언어 이미지(golang 등)를 그대로 써도 된다. 정책이 적용되는 것은 스캔·배포 대상인 최종 스테이지뿐이다.

어느 BCI 변종을 쓸지는 "런타임이 무엇을 필요로 하는가" 로 정한다

카탈로그의 8개 이미지가 실제로 쓰는 조합은 셋뿐이다. 새 이미지는 이 중 하나를 고른다 — 네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다.

최종 베이스 고르는 조건 대가 쓰는 이미지
bci-base 런타임이 OS 패키지·셸을 쓴다 (zypper 로 앱을 설치, entrypoint 가 셸 스크립트) 표면적이 가장 크다 adc · apisix · argocd · cnpg-postgresql
bci-micro 정적 링크 바이너리 하나만 실행한다 패키지 매니저가 없다. sed·grep·find 도 없다. nonroot 계정을 직접 만들어야 한다 apisix-ingress-controller · cloudnative-pg · etcd
scratch + micro rootfs 런타임 구성을 builder 에서 통째로 조립한다 (JVM + 앱 트리) rootfs 조립을 직접 책임진다("씨앗" 방식 필수, 아래) keycloak

bci-micro·scratch 를 고르면 업스트림이 distroless :nonroot 태그로 공짜로 얻던 것을 직접 만들어야 한다. 실측된 형태는 이렇다.

# bci-micro 는 root 만 있다 — distroless 의 nonroot 변종에 해당하는 태그가 없다
RUN echo 'nonroot:x:65532:65532:nonroot:/home/nonroot:/bin/false' >> /etc/passwd; \
    echo 'nonroot:x:65532:' >> /etc/group; \
    mkdir -p /home/nonroot; chown 65532:65532 /home/nonroot
USER 65532:65532

bci-basegroupadd/useradd 가 있으므로 그것을 쓴다(images/adc 참고). uid·gid 는 업스트림 값을 그대로 쓴다 — 임의로 바꾸면 볼륨 권한이 깨진다.

어느 BCI 버전을 쓸지는 이미지마다 실측해서 정한다

"최신 BCI 를 쓴다" 는 규칙을 두지 않는다. 새 SLE 메이저의 SLE_BCI 저장소가 특정 패키지에서 구버전에 뒤처져 있을 수 있고, 그러면 최신 베이스가 오히려 CVE 를 남긴다.

실례 — keycloak 이미지에서 15.7 을 고른 근거(2026-08-07 실측):

BCI java-21-openjdk-headless
15.7 21.0.12.0-150600.3.29.1
16.0 21.0.11.0-160000.2.1

21.0.12 가 CVE-2026-41254·CVE-2026-47063 의 수정 버전이라 16.0 으로 갔으면 차단 CVE 2건이 그대로 남았다. 이 이미지가 실제로 필요로 하는 패키지의 버전을 후보 태그마다 직접 재고 결과를 build.env 주석에 남긴다.

docker run --rm registry.suse.com/bci/bci-base:<태그> \
  sh -c 'zypper -n refresh >/dev/null 2>&1; zypper -n info <패키지>'

새 버전으로 올릴 때는 trivy 의 해당 SLE 버전 커버리지도 다시 확인한다 — CoverageProbenone 이면 findings 0 이 진짜 0 이 아니다(게이트가 막는다).

CVE 수치만 재고 고르면 배포에서 죽는다. 베이스 OS 는 런타임이 요구하는 도구의 버전도 같이 바꾼다. 실측(2026-08-19, argocd): argo-cd 차트의 repo-server init 컨테이너가 cp --update=none 을 쓰는데 이 형식은 GNU coreutils 9.3+ 에서만 된다. BCI 15.7 로 빌드한 이미지가 게이트도 verify.sh 도 통과하고 배포 시점에 Init:CrashLoopBackOff 로 죽었다. 스캐너는 "설치된 패키지에 알려진 CVE 가 있는가" 만 보지 "이 이미지를 쓰는 차트가 무엇을 요구하는가" 는 전혀 보지 못한다.

차트가 이 이미지에 대고 실제로 실행하는 명령을 verify.sh 에서 그대로 재현한다. 베이스를 바꿀 때 빌드 단계에서 걸린다. images/argocd/verify.sh 의 "차트가 실제로 실행하는 명령" 절이 예다.

bci-micro 위에 패키지를 얹을 때 — rootfs 는 반드시 "씨앗" 방식으로

bci-micro 는 패키지 매니저가 없지만 rpmdb 는 갖고 있다 (/usr/lib/sysimage/rpm, 2026-08-07 실측). 빈 installroot 에 설치한 rootfs 를 micro 위에 그냥 덮으면 micro 의 rpmdb 가 가려져 micro 자체 패키지가 SBOM 에서 통째로 사라진다 — CVE 가 줄어드는 게 아니라 스캔 사각지대가 생기는 것이다.

micro 의 파일시스템을 씨앗으로 깔고 그 위에 설치한다:

FROM registry.suse.com/bci/bci-micro:15.7 AS micro
FROM registry.suse.com/bci/bci-base:15.7 AS builder
COPY --from=micro / /rootfs
RUN rpm --root /rootfs --import /usr/lib/rpm/gnupg/keys/*.asc && \
    zypper --non-interactive --installroot /rootfs --gpg-auto-import-keys refresh && \
    zypper --non-interactive --installroot /rootfs install -y --no-recommends <패키지들>
FROM scratch AS final
COPY --from=builder /rootfs/ /

rpm --import 를 빠뜨리면 설치되는 패키지마다 NOKEY 경고로 개별 서명 검증이 생략된다. 빌드 후 SBOM 의 OS 패키지 수도 반드시 확인한다 — 한 자릿수로 떨어졌으면 마스킹이 일어난 것이다. 실례는 images/keycloak/suse.Dockerfile.

bci-micro 에 없는 흔한 도구 (2026-08-07 실측)

sed·grep·find셋 다 없다. bash·coreutils·readlink·dirname· uname·locale 은 있다.

  • 최종 이미지의 앱이 이 도구들을 쓰면(예: Keycloak bin/kc.shsed·grep 을 쓴다) 런타임 패키지 목록에 명시적으로 넣어야 한다.
  • verify.sh게스트 스크립트는 이 도구들에 의존하지 말고 순수 셸 루프 (while IFS= read -r line; do ...; done)로 작성한다 — 이미지마다 설치 여부가 다르다.

SLE 패키지명이 RHEL/Debian 과 다른 것들 (실측)

다른 배포판 SLE_BCI 15.7
tzdata timezone
tzdata-java 없음 (JDK 내장 tzdb 사용)
glibc-langpack-en glibc-locale-base
coreutils-single coreutils

업스트림 Dockerfile 의 패키지 목록을 그대로 옮기면 No provider of '...' found 로 빌드가 실패한다. zypper -n search -t package '<패턴>' 로 먼저 확인한다.

언어별 빌더 규칙

베이스 OS(위 원칙 2)가 최종 스테이지를 정하고, 여기가 빌더 스테이지를 정한다. 두 축은 독립이다 — 같은 bci-micro 최종 위에 Go 빌더가 오기도 하고(etcd) Node 빌더가 오기도 한다.

빌더는 원칙 2의 대상이 아니다(최종 이미지에 남지 않으므로 공식 언어 이미지를 그대로 쓴다). 대신 버전을 값으로 빼서 build.env 에 두는 것이 모든 언어에 공통이다 — Dockerfile 에 박으면 CVE 조치마다 Dockerfile 을 고쳐야 한다.

공통 — 무엇을 build.env 로 빼는가

성격
빌더 이미지 태그 GO_BUILDER_TAG · NODE_BUILDER_TAG · BUILDER_BASE
업스트림 소스 지점 SOURCE_COMMIT(pinned commit) · APP_VERSION
취약 의존성 강제 버전 GO_MODULE_UPGRADES · <LIB>_FIX_VERSION · <LIB>_OLD/<LIB>_VERSION
패키지 목록 BUILDER_PACKAGES · RUNTIME_PACKAGES

BUILD_ARGS 에 나열하지 않은 변수는 --build-arg 로 전달되지 않는다 — 값을 추가하면 BUILD_ARGS 도 같이 고친다(빠뜨리면 조용히 기본값으로 빌드된다).

Go

ARG GO_BUILDER_TAG=1.26.6-trixie          # 전역 스코프 — FROM 에 쓰이므로
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH                             # 크로스 컴파일: 빌더는 호스트 아치, 산출물은 타깃
  • --platform=$BUILDPLATFORM + TARGETARCH 를 쓴다. 에뮬레이션으로 빌더를 돌리지 않는다.

  • 버전 문자열은 ldflags 로 주입한다. 업스트림은 보통 git rev-parse HEAD 로 얻지만 우리는 .git 없이 빌드하므로 SOURCE_COMMIT 을 직접 넣는다 — 넣지 않으면 verify.sh 의 버전 검사가 깨지고, 무엇을 빌드했는지 이미지가 스스로 증언하지 못한다.

  • 취약 모듈 강제 업그레이드는 세 형태가 있다. 아래 "Go 모듈 CVE" 절이 값 산출을 담당한다.

    형태 쓰는 경우
    GO_MODULE_UPGRADES + go-mod-upgrade.sh 한 이미지가 여러 Go 프로젝트를 빌드한다 argocd
    go.work 전역 replace 업스트림이 워크스페이스를 쓴다 etcd
    개별 <LIB>_FIX_VERSION 취약 모듈이 소수로 고정돼 있다 apisix-ingress-controller

    세 번째를 새로 만들지 않는다 — 모듈이 하나든 둘이든 GO_MODULE_UPGRADES 로 통일하는 쪽이 재빌드 판정(check-rebuild-needed.py)이 자동으로 다룰 수 있어 유리하다.

  • 업스트림이 이미 백포트했으면 모듈을 올리지 말고 그 커밋을 쓴다 — 차이가 작을수록 좋다 (cloudnative-pgrelease-1.30 HEAD 를 그대로 컴파일해 세 CVE 를 해소했다).

Node

FROM ${BUILDER_BASE} AS builder            # node:lts-* 또는 node:${NODE_BUILDER_TAG}
FROM ${RUNTIME_BASE} AS final              # bci-base
RUN zypper -n install -y ${NODE_PKG} && zypper -n clean --all
  • 런타임 Node 는 빌더 Node 와 별개다. 빌더는 공식 node 이미지, 런타임은 OS 패키지 (nodejs24)를 쓴다 — 런타임 쪽이 스캔 대상이므로 벤더가 패치하는 경로에 둔다.
  • 두 메이저가 어긋나지 않게 맞춘다. NODE_PKGbuild.env 값이다.
  • 번들 산출물(main.cjs 등) 하나만 복사한다 — node_modules 를 최종에 넣지 않는다.

JVM (jar 교체형)

업스트림이 배포하는 tarball 을 풀고 취약 jar 만 갈아끼운다. 재컴파일하지 않는다.

ARG NETTY_OLD                              # 지금 들어있는 버전
ARG NETTY_VERSION                          # 갈아끼울 버전
  • OLD/VERSION 쌍으로 받는다. OLD 를 명시하는 이유는 교체 대상을 못 찾았을 때 실패 시키기 위함이다 — 업스트림이 버전을 올리면 조용히 지나가는 대신 빌드가 깨져야 한다.
  • BOM 이 pin 한 jar 는 태그 교체·베이스 OS 교체로 안 고쳐진다 — 그래서 자체 빌드다.
  • tzdata-java 는 SLE_BCI 에 없다(JDK 내장 tzdb 를 쓴다). 패키지명 차이는 원칙 2의 표 참고.

C · Lua (소스 컴파일형)

  • 정적 링크를 시도하지 않는다. SLE_BCI 에 static glibc 가 없다(실측). c-builder 스테이지와 최종 스테이지의 베이스를 동일하게 두고 동적 링크한다(argocdtini·connect-proxy).
  • 컴포넌트 버전이 여러 개면 전부 개별 ARG 로 뺀다(apisix 는 10개가 넘는다).
  • SLE_BCI 에 없어 소스 빌드하는 도구는 왜 없는지와 무엇을 대신하는지를 주석에 남긴다 — 기능을 빼는 것과 구분되어야 한다.

업스트림 런타임 계약은 보존한다

CVE 를 없애려고 이미지를 바꾸는 것이지, 동작을 바꾸는 게 아니다. 스캐너는 이걸 전혀 보지 못하므로(원칙 2의 cp --update=none 실측) 아래는 사람이 지켜야 한다.

  • USER·ENTRYPOINT 는 업스트림과 같게 유지한다. uid 를 바꾸면 볼륨 권한이, entrypoint 를 바꾸면 차트의 args 가 깨진다.
  • 업스트림이 만들던 파일 레이아웃을 그대로 만든다 — 심볼릭 링크, 빈 디렉토리, 권한까지. 오퍼레이터·차트가 그것에 의존한다(cloudnative-pg 는 멀티아치 심볼릭 링크를 "부수 장치" 로 오판해 지웠다가 invalid architecture 로 리컨실이 실패했다).
  • 차트가 이 이미지에 대고 실행하는 명령을 verify.sh 에서 재현한다. 위 실측 참고.
  • Dockerfile 상단에 업스트림과의 대응 관계 표를 남긴다(무엇이 동일하고 무엇이 다른지). 기존 이미지들이 전부 이 형식을 갖고 있다.

Go 모듈 CVE — 업그레이드 버전을 손으로 찾지 않는다

Go 바이너리에 정적 링크된 모듈의 차단 CVE 는 build.envGO_MODULE_UPGRADES<module>@<version> 을 적어 해소한다. 그 버전은 게이트 리포트의 FixedVersion 에 이미 들어 있으므로 스크립트가 뽑는다 — 사람이 CVE 를 하나씩 훑어 최대값을 고르지 않는다.

python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports 디렉토리> [--image <필터>]

붙여넣을 수 있는 GO_MODULE_UPGRADES="..." 한 줄과, 모듈별 근거(설치된 버전 → 목표 버전, 관련 CVE 목록)를 주석으로 낸다. stdlib 은 모듈이 아니라 툴체인 문제이므로 따로 GO_BUILDER_TAG 후보를 계산해 알려준다.

빌드 시점에 최신을 당기지 않는 이유go get -u 로 매번 최신을 끌면 같은 소스로 빌드해도 이미지가 달라진다. 이 문서가 "롤링 태그를 쓰지 않는다" 고 정한 것과 같은 이유다. 버전 핀은 우리가 무엇을 검증했는지의 기록이고, git diff 에 무엇이 왜 올라갔는지 남는다. 스크립트는 값을 제안만 하고 채택은 사람이 커밋한다.

두 가지 실측 함정이 있다.

  • FixedVersion 의 여러 값은 "더 높은 버전"이 아니라 브랜치별 대안이다. stdlib 의 1.25.13, 1.26.6, 1.27.0-rc.3 은 세 브랜치 각각에서 고쳐진 지점이다. 전체 최대값을 고르면 프리릴리스를 정식 버전으로 오독한다(실제로 그 버그를 냈다). 스크립트가 이 규칙을 처리한다.
  • 제안값은 CVE 요건의 최소치다. 모듈 간 제약으로 더 올려야 할 수 있다 — 실측: go-git 5.19.2x/net 0.56.0x/crypto 0.53.0 을 요구해 제안값 0.52.0 으로는 빌드가 requires golang.org/x/crypto@v0.53.0, not v0.52.0 로 실패했다. 그 메시지가 가리키는 버전으로 올린다.

한 이미지가 여러 Go 프로젝트를 빌드하면(예: argocd 는 argocd·helm·kustomize·git-lfs 를 함께 빌드한다) 목록 하나를 전부에 재사용한다images/argocd/go-mod-upgrade.sh 처럼 "그 프로젝트의 의존성 그래프에 있는 모듈만" 골라 적용하는 헬퍼를 두면 된다. go get 은 의존성에 없는 모듈도 go.mod 에 추가해버리므로 그냥 넘기면 안 된다.

핀은 가만히 있어도 뒤처진다 — 드리프트는 주간 스캔이 잡는다

버전 핀을 고정하는 것의 대가는 소스를 안 바꿔도 새 CVE 가 공개되면 그 핀이 규정을 벗어난다는 것이다. 2026-08-19 에 실제로 났다: etcd·cloudnative-pgGO_BUILDER_TAG=1.26.5-trixie 에 묶여 새 stdlib 차단 CVE 8건에 걸렸는데, 문서 전용 PR 이 우연히 images/** 를 건드려 검증 빌드가 돌면서 발견됐다.

그래서 build-image.yml주간(월요일 02:00 UTC)으로 스스로 확인하고 재빌드까지 한다.

배포 중인 이미지 스캔 → check-rebuild-needed.py → (핀이 뒤처졌으면) 브랜치 push
                                              → 빌드·verify.sh·게이트

트리거는 "수정 버전이 있는 차단 CVE 가 있는가" 다 — 핀이 뒤처졌는가가 아니다. 처음에는 핀 기준으로 잡았는데 실측에서 틀렸다. 배포 중인 이미지 8개를 스캔했더니 차단이 있는 4개 중 핀 변경이 필요한 것은 하나도 없었다. 재빌드로 고쳐지는 경우가 셋이기 때문이다.

경우 재빌드가 고치는 이유
핀이 뒤처졌다 핀을 올려서 빌드한다
핀은 맞는데 그 핀으로 아직 안 빌드됐다 핀만 고친 PR 이 머지된 직후가 이 상태다
베이스 OS 패키지가 뒤처졌다 재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다
  • 수정 버전이 없는 차단은 트리거가 아니다. 재빌드해도 그대로다 — no-fix 로 따로 보고해 사람이 다른 레버(상위 태그·예외 승인)를 판단한다.
  • 승인 예외(doc/cve-exceptions.json)를 적용한다. 만료된 예외는 인정하지 않는다.
  • 레지스트리 push 와 카탈로그 반영은 하지 않는다. pushworkflow_dispatch + mode=image 에서만 켜진다.
  • 수동으로 같은 것을 돌릴 때는 mode=driftworkflow_dispatch 한다.

로컬에서는 이렇게 쓴다.

python3 scripts/build/check-rebuild-needed.py --list-refs               # 무엇을 스캔해야 하나
python3 scripts/build/check-rebuild-needed.py --reports <trivy-reports 디렉토리>
python3 scripts/build/check-rebuild-needed.py --reports ... --image etcd --apply

게이트가 "차단 8건" 까지 말하는 데서 한 걸음 더 가서 어느 build.env 의 어느 값을 무엇으로 바꾸면 되는지를 낸다. 기준은 카탈로그가 실제로 가리키는 이미지다 — 재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 를 답한다(catalog.env → 카탈로그 values → 그 ref 의 스캔 리포트 순으로 따라간다). 무엇을 스캔할지도 --list-refs 로 스크립트가 낸다 — ref 해석 규칙이 워크플로로 새면 두 곳이 어긋난다.

  • --apply 는 편집만 대신한다. 커밋·빌드·검증은 그대로 사람이 한다 — 핀은 "우리가 무엇을 검증했는지의 기록" 이므로 자동 커밋하지 않는다.
  • GO_MODULE_UPGRADES 를 쓰지 않는 이미지(예: etcdgo.work replace + XTEXT_FIX_VERSION 을 쓴다)에는 값을 넣어도 무효라 자동 적용하지 않고 "수동 확인" 으로 보고한다.
  • 판정 기준은 게이트와 같다(max(벤더, NVD), 기본 HIGH). 즉 드리프트 = 차단 CVE 를 만드는 뒤처짐이고, 게이트를 통과하는 낮은 등급은 보고하지 않는다.

이 판정은 카탈로그와 이미지 정의 양쪽을 읽는다 — 레포 분리 시 갈라지는 지점이다. 커스텀 이미지가 별도 레포로 나가면 "무엇을 배포 중인가"(카탈로그)와 "어떻게 만드는가"(이미지 정의)가 다른 레포에 놓인다. 절단면은 check-rebuild-needed.py 의 함수 경계에 이미 있고 그 docstring 이 어느 함수가 어느 쪽인지 적어둔다 — 탐지는 카탈로그 쪽에 남고, 핀 판단은 이미지 쪽으로 간다. 분리 작업 때 그 주석을 먼저 읽는다.

CI — build-image.yml

이 축의 워크플로다. 실행기는 scripts/build/build-hardened-image.sh 하나이고 빌드 → verify.sh → SBOM → scan-sbom.shcve-gate.py 를 순서대로 부른다 — 스캔·게이트는 차트 축의 스크립트를 그대로 재사용한다(doc/sbom-pipeline.md).

이 워크플로의 게이트는 강제다. 차트 축의 sbom.yml--warn-only 인 것과 다르다 — 게이트가 실패하면 push 도 카탈로그 반영도 일어나지 않는다.

트리거 대상 결정 빌드·검증·게이트 레지스트리 push 카탈로그 태그 갱신
workflow_dispatch image 입력(필수) · base_os(비우면 catalog.envDEFAULT_BASE_OS) · push(기본 true) push=true 일 때만 게이트 PASS + 태그 변경 시
pull_request (images/**) 변경된 images/<image>/ 를 diff 로 자동 탐지(복수면 매트릭스 병렬)
schedule (0 2 * * 1, 매주 월요일 02:00 UTC) · mode=drift 배포 중인 이미지를 스캔해 재빌드가 필요한 것만

PR 트리거가 push 하지 않는 것은 REGISTRY 를 넘기지 않기 때문이다 — 태그가 localhost/... 로 남아 push 를 시도할 수조차 없다(검증 전용 안전장치).

  • schedule 이 있지만 블라인드 정기 재빌드는 아니다. 예전에는 트리거가 없었고 그 이유가 "블라인드 재빌드는 정말 개선인지를 매번 되묻게 만든다" 였다. 지금 것은 수정 버전이 있는 차단 CVE 가 있을 때만 빌드하고 없으면 아무것도 하지 않는다 — 거부된 것과 조건이 다르다. 판정은 아래 "핀은 가만히 있어도 뒤처진다" 절이 갖는다.
  • sbom.yml 의 게이트가 이 워크플로를 자동 호출하지 않는다. 차트 축이 차단 CVE 를 찾아도 자체 빌드로 갈지는 사람이 판단한다 — 두 축 사이에 자동 연결은 없다. 이 워크플로의 schedule자기 이미지만 본다(배포 중인 자체 빌드 이미지).
  • 레지스트리는 REGISTRY_HOST: docker.io/paasup 고정. 인증은 DOCKERHUB_USER/DOCKERHUB_TOKEN.

카탈로그 반영 — PR 은 자동 생성되지 않는다

게이트 PASS 이고 새 태그가 현재 카탈로그 태그와 다르면, 워크플로가 catalog.envCHART_DIRS 아래 custom-values.yaml/dip-values.yamlscripts/build/patch-catalog-tag.py 로 갱신하고 build/<image>-<타임스탬프> 브랜치를 push 한다. 여기까지가 자동이다.

PR 오픈은 사람이 한다 — GITHUB_TOKEN 으로 PR 을 만드는 것 자체를 조직 정책이 막는다("Allow GitHub Actions to create and approve pull requests" 미허용, 리포 설정으로 변경 불가). 2026-08-04 실측으로 확인했고(run 30882785612, GraphQL: GitHub Actions is not permitted to create or approve pull requests), gh pr create 호출은 워크플로에서 제거했다. 대신 Job Summary 에 compare 링크가 남는다.

병합 전에 사람이 해야 하는 것:

  1. compare 링크로 PR 오픈.
  2. gh workflow run helm-catalog-sbom --ref <브랜치> 로 카탈로그 게이트 확인.
  3. 배포 검증 — 게이트 PASS 는 "동작한다"를 증명하지 않는다(스캐너는 런타임 요구사항을 보지 못한다). 절차: deploy-test-procedure.md.

매핑이 불완전하면 조용히 지나간다. patch-catalog-tag.py 는 "예상 패턴을 못 찾으면 실패" 하도록 만들어져 있지만 애초에 CHART_DIRS 에 없는 파일은 검사조차 하지 않는다. 실측 (2026-08-20): cnpg-postgresqlCHART_DIRScnpg-cluster/1.0.0 만 담고 있어 같은 이미지를 쓰는 1.1.0 이 낡은 태그로 남았고, 게이트만 계속 차단으로 잡았다. 카탈로그는 여러 버전을 동시에 보관하므로 이미지 하나가 여러 버전 디렉토리에 걸리는 것이 정상이다 — 새 버전 디렉토리를 만들 때 catalog.env 를 함께 본다.

레포 분리 후 무엇이 끊기는가

커스텀 이미지가 별도 레포로 나가면 이 축은 "어떻게 만드는가" 만 갖고, "무엇을 배포 중인가" 는 카탈로그 레포에 남는다. 지금 한 레포 안이라 보이지 않는 결합이 그때 드러난다.

# 결합점 분리 후 필요한 것
1 patch-catalog-tag.py 가 카탈로그 values 를 쓴다 카탈로그 쪽이 자기 파일을 쓰게 한다(반영 워크플로)
2 catalog.envCHART_DIRS·TAG_STYLE·TAG_BLOCK 카탈로그 레이아웃 정보다 — 카탈로그 쪽으로 옮긴다
3 check-rebuild-needed.py 가 카탈로그 values 를 읽어 배포 중 ref 를 해석 탐지는 카탈로그 쪽에 남는다 (그 파일 docstring 의 절단면 참고)
4 build-hardened-image.shscan-sbom.sh·cve-gate.py 를 부른다 게이트를 composite action 으로 공개해 양쪽이 uses:
5 doc/cve-exceptions.json 공유 카탈로그가 소유하고 action 입력으로 받는다(예외는 "무엇을 감수하는가")
6 build/<image>-<ts> 브랜치를 카탈로그 레포에 push 카탈로그 쪽이 자기 브랜치를 만든다
7 images/ 가 이미지 목록의 단일 출처 (여러 문서가 이 경로 참조) 참조 갱신

3번이 가장 크다 — 드리프트 탐지는 "카탈로그가 가리키는 이미지" 를 기준으로 재는 것이 설계의 핵심인데 이미지 레포는 카탈로그를 갖고 있지 않다. 그래서 탐지는 카탈로그, 빌드는 이미지 레포 로 갈라야 한다.

게이트를 공유할 때 workflow_call 은 쓸 수 없다 — 별도 job 으로 돌아 파일시스템을 공유하지 않는데 게이트는 스캔과 같은 job 에서 $OUT_DIR 를 읽어야 한다. composite action 이어야 한다. 참고로 SBOM_PIPELINE_IMAGE 는 도구만 담고 있어(DockerfileCOPY 가 없다) 그것만으로는 cve-gate.py 가 따라오지 않는다.

두 가지 유형 (둘 다 같은 스크립트를 쓴다)

유형 Dockerfile 이 하는 일 셸 유무
OS 패키지 재설치형 업스트림이 배포하는 산출물을 다른 배포판(zypper/apt 등)에 재설치 보통 있음 — 게스트 스크립트로 검증
소스 컴파일형 업스트림 pinned commit 을 go build 등으로 직접 컴파일 최종 베이스에 따라 다름

어느 유형이든 이 셋만 새로 쓰면 된다: <variant>.Dockerfile, <variant>.build.env, verify.sh(+ README.md).

신규 이미지 추가 체크리스트

  1. 상위 태그 교체 → 베이스 OS 교체 순으로 먼저 검토했는가. 그것으로 해소되면 자체 빌드로 가지 않는다. 특히 CVE 가 OS 패키지가 아니라 애플리케이션/바이너리 자체에 정적으로 포함된 것이면(예: Go 모듈, 정적 링크된 라이브러리) 베이스 OS 교체는 원천적으로 통하지 않는다 — 이 판단 근거를 남긴다(PR 설명 또는 MEMORY.md)
  2. 어느 유형인지 판단한다 ("업스트림 산출물을 다른 배포판에 재설치" vs "소스를 직접 컴파일"). 최종 베이스 OS 를 이때 정한다(원칙 2)
  3. images/<image>/<variant>.Dockerfile·<variant>.build.env·verify.sh·README.md 작성. 업스트림 Dockerfile 과의 대응 관계·차이를 파일 상단 주석으로 남긴다. FROM 에 쓰는 ARG 는 반드시 파일의 첫 FROM 이전(전역 스코프)에 선언한다 — 스테이지 내부(어떤 FROM 뒤)에 선언하면 그 스테이지 지역 변수가 되어 이후 FROM 의 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다
  4. 로컬 빌드:
    IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/out
    
    cve-gate.md 로 실효 C/H 0 확인. 커버리지 자가진단(CoverageProbe)이 ok 인지도 확인 — none 이면 findings 0건이 진짜 0건이 아니라 스캐너에 그 배포판 데이터가 없다는 뜻이므로 게이트가 차단한다(doc/sbom-pipeline.md 참고)
  5. 게이트 PASS 는 "동작한다" 를 증명하지 않는다. CVE 스캐너는 CVE 와 무관한 런타임 요구사항(예: 오퍼레이터가 자신의 파일 레이아웃에 의존하는 것)을 전혀 보지 못한다. 실제 배포 검증을 반드시 한다 — 절차와 스크립트는 deploy-test-procedure.md 에 있다(cnpg·etcd 는 전용 스크립트, 그 외는 수동 절차). 업스트림과 다르게 만든 부분은 전부 이유를 확인하고 남긴다.
  6. 카탈로그 values(custom-values.yaml/dip-values.yaml 등) 갱신, 조사·결정 근거를 PR 설명과 MEMORY.md에 기록. images/**+manifests/helm/** 는 PR 로.
  7. CI 자동화: build-image.yml 은 이미 image 입력으로 파라미터화돼 있다 — images/<image>/catalog.env 만 추가하면 별도 워크플로 수정 없이 태울 수 있다.

SBOM·스캔·게이트는 절대 다시 만들지 않는다

scripts/pipeline/scan-sbom.sh · scripts/pipeline/cve-gate.py 는 이미지 종류와 무관하게 동작하며 커버리지 자가진단(CoverageProbe)도 포함한다(doc/sbom-pipeline.md 참고). build-hardened-image.sh 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.