자체 빌드 이미지 프레임워크를 security-images 레포로 이관하고 카탈로그 쪽을 정리한다

images/·scripts/build/build-hardened-image.sh·suggest-go-upgrades.py·
build-image.yml·.claude/image-authoring.md·이미지 ADR(0001·0002·0004)을 삭제했다 —
전부 별도 public 레포 security-images 로 이미 이관됐다.

카탈로그 쪽에는 "무엇을 배포 중인가"를 아는 부분만 남긴다:
- catalog/image-map/<image>.env — 옛 catalog.env 의 카탈로그 레이아웃 정보만 뗀 것
- scripts/build/check-rebuild-needed.py — 드리프트 탐지(A 파트)만 남기고 핀 판단
  (B 파트: pin_changes/apply_changes/parse_module_specs)은 제거
- scripts/build/apply-published-tags.py(신규) — security-images 의 published.json
  을 읽어 카탈로그 values 를 패치
- .github/workflows/{self-build-drift-check,catalog-tag-update}.yml(신규) — 각각
  드리프트 스캔+트리거, 발행 태그 반영

effective_severity 를 cve-gate.py 로 옮겼다 — check-rebuild-needed.py 가 핀 도구를
거치지 않고 게이트를 직접 로드하게 하기 위한 선행 작업이다.

두 레포의 계약은 published.json 스키마 하나뿐이다 — security-images 는 이 카탈로그를
모른다(단방향 의존). 이관 배경·결합점 전체는
doc/migrations/self-build-images-to-security-images.md.

부수 수정: 자체 빌드 이미지를 참조하는 차트 values/README 의 죽은 링크(images/**,
doc/decisions/000{1,2,4}, .claude/image-authoring.md)를 security-images 레포를
가리키는 서술로 교체. deploy-test 스크립트·CUSTOM-README 의 개인 Docker Hub 계정
(docker.io/wbsong111) 을 docker.io/paasup 로 교체.

pitfalls.md 의 "스캐너 결과를 그대로 믿지 말 것" 절은 sbom-cve-gate skill 이 차트
축 설명에 실제로 참조하고 있어 남겼다 — "이미지 태그의 베이스 OS" 절만 제거했다
(다른 참조 없음, security-images 문서로 이관 완료).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-24 15:21:29 +09:00
parent e948436f53
commit 7746570ec0
87 changed files with 693 additions and 5554 deletions
+4 -4
View File
@@ -8,12 +8,12 @@ CVE 0건이어도 동작하지 않는 이미지는 카탈로그에 넣을 수
## CNPG (`cnpg-postgresql` 이미지) — 자동화됨
`build-image.yml` 이 새 이미지로 카탈로그 PR 을 열면, 병합 전에 로컬 kubeconfig 로
`scripts/deploy-test/deploy-test-cnpg-cluster.sh` 를 실행해 "실제로 뜨는가"만 빠르게 확인한다(PR 본문에도
안내됨).
`catalog-tag-update.yml` 이 새 이미지 태그로 카탈로그 브랜치를 push 하면, 병합 전에 로컬
kubeconfig 로 `scripts/deploy-test/deploy-test-cnpg-cluster.sh` 를 실행해 "실제로 뜨는가"만
빠르게 확인한다(브랜치의 Job Summary 에도 안내됨).
```sh
IMAGE_NAME=docker.io/wbsong111/cnpg-postgresql:<태그> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh /tmp/deploy-test-out
IMAGE_NAME=docker.io/paasup/cnpg-postgresql:<태그> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh /tmp/deploy-test-out
```
- **Operator(`cloudnative-pg`)는 상시 컴포넌트다** — release `cnpg`, namespace
-485
View File
@@ -1,485 +0,0 @@
# 자체 빌드 이미지 작업 규칙
**자체 빌드 축의 단일 출처다.** 새 자체 빌드 이미지를 추가하거나 기존 빌드 정의를 바꿀 때,
그리고 `build-image.yml` 이 무엇을 하는지 알아야 할 때 여기를 본다.
카탈로그의 CVE 파이프라인은 두 축이고 이 문서는 **"이미지를 어떻게 만드는가"** 를 갖는다.
**"우리가 배포하는 이미지에 무엇이 있는가"**(`sbom.yml`·`cve-edge-post.yml`, 스캔·게이트
메커니즘)는 [doc/sbom-pipeline.md](../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 커버리지가 양성 대조로 실측 확인돼 있다
(게이트의 `CoverageProbe` — [doc/sbom-pipeline.md](../doc/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` 태그로 공짜로 얻던 것을
직접 만들어야 한다.** 실측된 형태는 이렇다.
```dockerfile
# 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-base``groupadd`/`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` 주석에 남긴다.**
```sh
docker run --rm registry.suse.com/bci/bci-base:<태그> \
sh -c 'zypper -n refresh >/dev/null 2>&1; zypper -n info <패키지>'
```
새 버전으로 올릴 때는 trivy 의 해당 SLE 버전 커버리지도 다시 확인한다 —
`CoverageProbe``none` 이면 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 의 파일시스템을 씨앗으로 깔고 그 위에 설치한다:
```dockerfile
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.sh``sed`·`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
```dockerfile
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-pg``release-1.30` HEAD 를 그대로 컴파일해 세 CVE 를 해소했다).
### Node
```dockerfile
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_PKG``build.env` 값이다.
- 번들 산출물(`main.cjs` 등) 하나만 복사한다 — `node_modules` 를 최종에 넣지 않는다.
### JVM (jar 교체형)
업스트림이 배포하는 tarball 을 풀고 **취약 jar 만 갈아끼운다.** 재컴파일하지 않는다.
```dockerfile
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 스테이지와
최종 스테이지의 **베이스를 동일하게** 두고 동적 링크한다(`argocd``tini`·`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.env``GO_MODULE_UPGRADES`
`<module>@<version>` 을 적어 해소한다. 그 버전은 게이트 리포트의 `FixedVersion` 에 이미
들어 있으므로 스크립트가 뽑는다 — 사람이 CVE 를 하나씩 훑어 최대값을 고르지 않는다.
```sh
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.2``x/net 0.56.0``x/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-pg`
`GO_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 와 카탈로그 반영은 하지 않는다.** `push``workflow_dispatch` +
`mode=image` 에서만 켜진다.
- 수동으로 같은 것을 돌릴 때는 `mode=drift``workflow_dispatch` 한다.
로컬에서는 이렇게 쓴다.
```sh
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` 를 쓰지 않는 이미지**(예: `etcd``go.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.sh``cve-gate.py` 를 순서대로 부른다 — 스캔·게이트는 차트 축의 스크립트를
그대로 재사용한다([doc/sbom-pipeline.md](../doc/sbom-pipeline.md)).
**이 워크플로의 게이트는 강제다.** 차트 축의 `sbom.yml``--warn-only` 인 것과 다르다 —
게이트가 실패하면 push 도 카탈로그 반영도 일어나지 않는다.
| 트리거 | 대상 결정 | 빌드·검증·게이트 | 레지스트리 push | 카탈로그 태그 갱신 |
|--------|----------|------------------|-----------------|-------------------|
| `workflow_dispatch` | `image` 입력(필수) · `base_os`(비우면 `catalog.env``DEFAULT_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.env``CHART_DIRS` 아래
`custom-values.yaml`/`dip-values.yaml``scripts/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](deploy-test-procedure.md).
> **매핑이 불완전하면 조용히 지나간다.** `patch-catalog-tag.py` 는 "예상 패턴을 못 찾으면 실패"
> 하도록 만들어져 있지만 **애초에 `CHART_DIRS` 에 없는 파일은 검사조차 하지 않는다.** 실측
> (2026-08-20): `cnpg-postgresql` 의 `CHART_DIRS` 가 `cnpg-cluster/1.0.0` 만 담고 있어 같은
> 이미지를 쓰는 `1.1.0` 이 낡은 태그로 남았고, 게이트만 계속 차단으로 잡았다. 카탈로그는 여러
> 버전을 동시에 보관하므로 **이미지 하나가 여러 버전 디렉토리에 걸리는 것이 정상**이다 —
> 새 버전 디렉토리를 만들 때 `catalog.env` 를 함께 본다.
### 레포 분리 후 무엇이 끊기는가
커스텀 이미지가 별도 레포로 나가면 이 축은 **"어떻게 만드는가"** 만 갖고, **"무엇을 배포 중인가"**
는 카탈로그 레포에 남는다. 지금 한 레포 안이라 보이지 않는 결합이 그때 드러난다.
| # | 결합점 | 분리 후 필요한 것 |
|---|---|---|
| 1 | `patch-catalog-tag.py` 가 카탈로그 values 를 **쓴다** | 카탈로그 쪽이 자기 파일을 쓰게 한다(반영 워크플로) |
| 2 | `catalog.env``CHART_DIRS`·`TAG_STYLE`·`TAG_BLOCK` | **카탈로그 레이아웃 정보다** — 카탈로그 쪽으로 옮긴다 |
| 3 | `check-rebuild-needed.py` 가 카탈로그 values 를 **읽어** 배포 중 ref 를 해석 | **탐지는 카탈로그 쪽에 남는다** (그 파일 docstring 의 절단면 참고) |
| 4 | `build-hardened-image.sh``scan-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` 는 도구만 담고 있어(`Dockerfile``COPY` 가 없다) 그것만으로는
`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. 로컬 빌드:
```sh
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](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` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.
-6
View File
@@ -18,12 +18,6 @@ security-catalog 프로젝트에서 CNPG/etcd 자체 빌드·배포 테스트
> dip-catalog 의 `scan-sbom.sh` 는 두 번째 양상(데이터 커버리지 부재)을 구분하는 자가진단
> (`CoverageProbe`)을 이식했다(2026-08-03) — `doc/sbom-pipeline.md` 참고.
## 이미지 태그의 베이스 OS 를 확인할 것
같은 앱 버전이라도 태그에 따라 베이스 OS 가 다르고 EOL 이 임박한 것이 섞여 있다.
오퍼레이터가 배포판 수명 테이블을 갖고 있으면 기동 로그에 남는다
(CNPG: `internal/cmd/manager/instance/run/osdb.go`).
## `kubectl get cluster` 는 쓰면 안 된다
dev 클러스터에 `clusters` 단축명을 쓰는 CRD 가 3개 있다(CNPG 배포 테스트 대상 클러스터
+9 -5
View File
@@ -1,12 +1,13 @@
---
name: cve-remediation
description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)를 잡았을 때 태그 교체·베이스 OS 교체·자체 빌드·예외 승인 중 어느 레버를 쓸지 판단할 때 사용한다. "이 CVE 어떻게 없애", "차단 CVE 뭐부터 해야 해", "자체 빌드 가야 하나 예외 가야 하나", "게이트 실패 다음 스텝" 같은 요청이 해당한다. sbom-cve-gate·self-build-image 사이 결정 단계만 담당하며 둘의 절차는 복제하지 않는다.
description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)를 잡았을 때 태그 교체·베이스 OS 교체·자체 빌드·예외 승인 중 어느 레버를 쓸지 판단할 때 사용한다. "이 CVE 어떻게 없애", "차단 CVE 뭐부터 해야 해", "자체 빌드 가야 하나 예외 가야 하나", "게이트 실패 다음 스텝" 같은 요청이 해당한다. sbom-cve-gate 로 게이트를 해석하는 것과 security-images 레포에서 자체 빌드를 실행하는 것 사이 결정 단계만 담당하며 둘의 절차는 복제하지 않는다.
---
# 차단 CVE 대응 절차
이 skill 은 실행하지 않는다 — 레버를 판단해 실행 skill로 넘긴다. 게이트 실행/해석은
`sbom-cve-gate`, 자체 빌드는 `self-build-image`, 배포 검증은
`sbom-cve-gate`, 자체 빌드는 **별도 레포 `security-images`**(그 레포의
`docs/image-authoring.md`가 단일 출처), 배포 검증은
[deploy-test-procedure.md](../../deploy-test-procedure.md)가 단일 출처다 — 여기 반복 안 한다.
## 흐름
@@ -35,8 +36,11 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)
FixedVersion 이상인지, 같은 파일이 SBOM에서 컴포넌트 두 개로 잡히지 않는지(PkgPath
비교) 확인한다. 실례: keycloak `CVE-2025-59250`(mssql-jdbc jar 하나가 두 컴포넌트로
잡혀 잘린 쪽만 매칭된 오탐). 근거·만료일은 `doc/cve-exceptions.json`.
6. 자체 빌드는 `self-build-image` + [image-authoring.md](../../image-authoring.md)로,
태그/베이스 OS 교체는 카탈로그 values만 바꾸고 재게이트한다 — 별도 스크립트 없음.
6. 자체 빌드는 별도 레포 `security-images`에서 한다 — 그 레포의
`docs/image-authoring.md`가 절차 단일 출처다. 재빌드가 필요하면 그 레포의
`build-image.yml``workflow_dispatch`로 부른다(`scripts/build/check-rebuild-needed.py`
가 배포 중인 이미지를 스캔해 대상을 판단한다). 태그/베이스 OS 교체는 카탈로그
values만 바꾸고 재게이트한다 — 별도 스크립트 없음.
7. 수정 후 반드시 재게이트하고 PASS라도 배포 검증까지 끝나야 종료다 — 1회로 끝난다고
가정하지 않는다(keycloak은 1차 수정 후 재스캔에서 micrometer 2건이 새로 잡혔다).
8. 착지는 브랜치 push까지다 — 조직 정책상 `GITHUB_TOKEN`으로 PR을 못 연다. 사람이 PR을
@@ -44,5 +48,5 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)
## 참고
- 레버 실행: [sbom-cve-gate](../sbom-cve-gate/SKILL.md) · [self-build-image](../self-build-image/SKILL.md)
- 레버 실행: [sbom-cve-gate](../sbom-cve-gate/SKILL.md) · 자체 빌드는 별도 레포 `security-images`
- 함정: [pitfalls.md](../../pitfalls.md) · 미결 사항: [MEMORY.md](../../../MEMORY.md)
+2 -2
View File
@@ -55,8 +55,8 @@ gh run watch --repo <org>/dip-catalog
상위 태그 교체 → 베이스 OS 교체 → 자체 빌드 → 예외 승인
```
자체 빌드로 가야 한다면 `self-build-image` skill과
[.claude/image-authoring.md](../../image-authoring.md)를 따른다. 판정 로직 상세는
자체 빌드로 가야 한다면 별도 레포 `security-images`에서 한다 — 그 레포의
`docs/image-authoring.md`가 절차 단일 출처다. 판정 로직 상세는
`scripts/pipeline/cve-gate.py`의 모듈 docstring을 1차 출처로 본다.
## 참고
-108
View File
@@ -1,108 +0,0 @@
---
name: self-build-image
description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, argocd, cloudnative-pg, cnpg-postgresql, etcd, keycloak 8종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처).
---
# 자체 빌드 하드닝 이미지
CVE 게이트 대응 우선순위(상위 태그 교체 → 베이스 OS 교체 → **자체 빌드** → 예외 승인)에서
앞의 두 단계로 해소가 안 될 때만 온다.
## 원칙 — 오케스트레이션은 항상 하나다
**`scripts/build/build-hardened-image.sh` 하나가 모든 자체 빌드 이미지를 빌드한다.**
OS 패키지 재설치든 소스 컴파일이든 스크립트는 같고, 차이는 전부 `images/<image>/` 안에 있다.
**"이 이미지는 성격이 다르다"는 이유로 새 오케스트레이션 스크립트를 만들지 않는다** —
절차(빌드 → 기능검증 → SBOM → 스캔 → 게이트 → push)는 이미지 종류와 무관하게 동일하다.
SBOM·스캔·게이트도 다시 만들지 않는다 — `build-hardened-image.sh`가 이미
`scan-sbom.sh`/`cve-gate.py`를 호출한다.
## 실행
```sh
IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/out
```
`images/<image>/<variant>.build.env`가 계약(`DOCKERFILE`, `TARGET`, `BUILD_ARGS` 등)을
선언하면 스크립트는 이미지 종류를 몰라도 된다. 전체 계약표와 신규 이미지 추가 7단계 절차는
[.claude/image-authoring.md](../../image-authoring.md)에 있다 — 여기 복제하지 않는다.
CI는 `build-image.yml``image` 입력으로 이미 파라미터화돼 있다. `images/<image>/catalog.env`
추가하면 워크플로 수정 없이 태울 수 있다.
## 새 Dockerfile 은 두 축을 먼저 고른다
**작성 규칙 본문은 [.claude/image-authoring.md](../../image-authoring.md)가 단일 출처다** —
여기 복제하지 않는다. 이 표는 "어느 절을 읽어야 하는가" 만 정한다.
두 축은 **독립**이다. 같은 `bci-micro` 최종 위에 Go 빌더가 오기도 하고 Node 빌더가 오기도 한다.
**축 1 — 최종 런타임 베이스** (스캔·배포 대상. 원칙 2)
| 런타임이 필요로 하는 것 | 고른다 | 선례 |
|---|---|---|
| OS 패키지·셸 (zypper 설치, 셸 entrypoint) | `bci-base` | `adc` `apisix` `argocd` `cnpg-postgresql` |
| 정적 링크 바이너리 하나뿐 | `bci-micro` | `apisix-ingress-controller` `cloudnative-pg` `etcd` |
| 런타임 트리를 builder 에서 조립 (JVM 등) | `scratch` + micro rootfs 씨앗 | `keycloak` |
→ 세 조합 밖으로 나가려면 **왜 셋으로 안 되는지 먼저 적는다.** `micro`/`scratch`
nonroot 계정·`sed`/`grep` 부재를 직접 감당해야 한다(image-authoring.md "어느 BCI 변종").
**축 2 — 빌더 스테이지** (최종에 남지 않음. 원칙 2 대상 아님 → 공식 언어 이미지 그대로)
| 언어 | 빌더 | 핀 키 |
|---|---|---|
| Go | `golang:${GO_BUILDER_TAG}` + `$BUILDPLATFORM`/`TARGETARCH` | `GO_BUILDER_TAG` · `GO_MODULE_UPGRADES` |
| Node | `node:*` (런타임은 **OS 패키지** `nodejs24`) | `NODE_BUILDER_TAG` · `NODE_PKG` |
| JVM | BCI base — 재컴파일 없이 **취약 jar 만 교체** | `<LIB>_OLD` / `<LIB>_VERSION` 쌍 |
| C · Lua | BCI base — **정적 링크 금지**(static glibc 없음) | 컴포넌트별 버전 ARG |
→ 상세는 image-authoring.md "언어별 빌더 규칙". 세 가지가 언어와 무관하게 공통이다:
**버전은 Dockerfile 에 박지 말고 `build.env` 값으로**, `BUILD_ARGS`**반드시 등록**(빠뜨리면
조용히 기본값으로 빌드된다), 그리고 **업스트림 런타임 계약(`USER`·`ENTRYPOINT`·파일 레이아웃)은
보존**한다.
## 자동 재빌드 — 실행 계약만
`build-image.yml` 이 주간(월요일 02:00 UTC)으로 배포 중인 이미지를 스캔해 재빌드가 필요한
것을 찾아 빌드·검증·게이트까지 돈다. **트리거 조건과 왜 그 조건인지는
[.claude/image-authoring.md](../../image-authoring.md) "핀은 가만히 있어도 뒤처진다" 가
단일 출처다** — 여기 복제하지 않는다.
```sh
# 수동으로 같은 것을 돌린다
gh workflow run self-build-image --repo <org>/dip-catalog -f mode=drift
# 판정만 로컬에서 본다
python3 scripts/build/check-rebuild-needed.py --list-refs
python3 scripts/build/check-rebuild-needed.py --reports <trivy-reports> [--apply]
```
이 경로는 **레지스트리 push 와 카탈로그 반영을 하지 않는다** — push 는
`workflow_dispatch` + `mode=image` 에서만 켜진다.
## 실측된 함정
- **`FROM`에 쓰는 `ARG`는 첫 `FROM` 이전(전역 스코프)에 선언한다.** 스테이지 내부에 선언하면
그 스테이지 지역 변수가 되어 이후 `FROM`의 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다.
- **게이트 PASS는 "동작한다"를 증명하지 않는다.** CVE 스캐너는 런타임 요구사항(오퍼레이터가
자신의 파일 레이아웃에 의존하는 것 등)을 전혀 보지 못한다. 배포 검증을 반드시 한다 —
절차는 [.claude/deploy-test-procedure.md](../../deploy-test-procedure.md).
- **`CoverageProbe``ok`인지 확인한다.** `none`이면 findings 0건이 진짜 0건이 아니라
스캐너에 그 배포판 데이터가 없다는 뜻이다(`sbom-cve-gate` skill 참고).
- **롤링 태그를 쓰지 않는다.** 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 달라
태그에 빌드일을 포함한다(예: `1.30.0-security-hardened-20260804`).
- **핀을 고정한 대가로 핀이 뒤처진다.** 소스를 안 바꿔도 새 CVE 가 공개되면 어제 PASS 였던
핀이 오늘 FAIL 이 된다. 위 "자동 재빌드" 가 이것을 잡는다. 조치 전에 그 결과를 먼저 본다:
`python3 scripts/build/check-rebuild-needed.py --reports <trivy-reports> [--image <name>] [--apply]`
- **단, 같은 날 다시 빌드하면 그 태그가 겹친다.** 노드가 캐시한 옛 digest 가 그대로 쓰여
(`imagePullPolicy: IfNotPresent`) 고친 것이 반영되지 않은 채 "안 고쳐졌다" 로 보인다.
**검증 대상 워크로드에 `imagePullPolicy: Always` 를 수동으로 걸고 digest 로 확인한다**
절차는 [deploy-test-procedure.md](../../deploy-test-procedure.md) "같은 날 재빌드했다면"
이 단일 출처다. **카탈로그 values 에는 넣지 않는다**(같은 문서에 이유).
## 마무리
카탈로그 values(`custom-values.yaml`/`dip-values.yaml`) 태그 갱신은
`scripts/build/patch-catalog-tag.py`가 한다. 조사·결정 근거는 PR 설명과
[MEMORY.md](../../../MEMORY.md)에 남긴다. `images/**`+`manifests/helm/**` 변경은 PR로만 반영한다.