차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙 (#40)
* 차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙
두 가지다. (1) 이전 이미지에서 CVE 가 발견되면 재빌드가 자동으로 일어나게 한다.
(2) Dockerfile 을 OS·언어 축으로 일관되게 쓰도록 규칙을 정한다.
(1) 재빌드 자동화 — 트리거를 로컬 실측으로 고쳤다
------------------------------------------------
처음에는 트리거를 "핀이 뒤처졌는가" 로 잡았다. **배포 중인 이미지 8개를 실제로 스캔해 보니
그게 틀렸다** — 차단 CVE 가 있는 4개 중 핀 변경이 필요한 것은 하나도 없었고, 그 판정은
"0건" 을 냈다. 재빌드로 고쳐지는 경우가 셋이기 때문이다.
핀이 뒤처졌다 → 핀 올려서 빌드
핀은 맞는데 그 핀으로 아직 안 빌드됐다 → 그냥 빌드 ← 핀만 고친 PR 직후 상태
베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 ← 재빌드하면 zypper 가 최신을 깐다
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 하는
부수 작업이고 트리거가 아니다. 수정 버전 없는 차단(affected·will_not_fix)은 재빌드해도
그대로이므로 no-fix 로 따로 보고해 사람이 다른 레버를 판단하게 한다.
scripts/build/check-rebuild-needed.py 신규
카탈로그가 실제로 가리키는 이미지를 기준으로 잰다 — 재빌드해 보지 않고도 "지금 배포 중인
것이 규정을 벗어났는가" 를 답한다.
catalog.env → 카탈로그 values 의 ref → 그 ref 의 스캔 리포트 ↕ build.env 의 핀
판정 규칙은 게이트와 같은 출처를 쓴다(max(벤더,NVD)·승인 예외·만료 예외 무효) —
cve-gate.py 의 함수를 그대로 가져다 쓴다. 핀 산출은 suggest-go-upgrades.py 재사용.
--list-refs 로 "무엇을 스캔해야 하나" 도 스크립트가 낸다 — ref 해석 규칙이 워크플로로
새면 두 곳이 어긋난다.
suggest-go-upgrades.py 는 산출 로직이 main() 안에 있어 재사용할 수 없었다. collect_modules/
collect_stdlib/builder_candidates/effective_severity 로 빼고 main() 은 호출해 출력만 한다 —
CLI 출력은 리팩터 전후 동일하다(실측 대조).
build-image.yml 에 schedule(월요일 02:00 UTC)과 mode=drift 를 추가했다. 판정 → (핀이
뒤처졌으면) 브랜치 push → 빌드·verify.sh·게이트까지 자동이고 **레지스트리 push 와 카탈로그
반영은 하지 않는다.** 게시 여부 로직에서 catch-all 을 true → false 로 바꿨다 — 원래는
트리거를 추가하면 자동으로 push 가 켜지는 구조였고 schedule 을 넣는 순간 사고가 났다.
이제 push 는 workflow_dispatch + mode=image 에서만 켜진다.
(2) Dockerfile 작성 규칙 — 실제 8개에서 추출했다
----------------------------------------------
image-authoring.md 가 규칙 본문의 단일 출처이고 skill 은 "어느 절을 읽어야 하는가" 선택표만
갖는다(CLAUDE.md 의 "Skill 은 절차 본문을 복제하지 않는다").
두 축은 독립이다 — 같은 bci-micro 최종 위에 Go 빌더도 Node 빌더도 온다.
축 1 최종 베이스 bci-base / bci-micro / scratch+micro rootfs — 셋뿐이다.
네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다.
micro·scratch 는 nonroot 계정을 직접 만들어야 한다(실측 형태 첨부).
축 2 빌더 Go(BUILDPLATFORM/TARGETARCH, ldflags 에 SOURCE_COMMIT) /
Node(런타임은 OS 패키지) / JVM(재컴파일 없이 jar 만 OLD/NEW 쌍으로
교체 — OLD 를 받는 이유는 못 찾으면 실패시키기 위함) /
C·Lua(정적 링크 금지 — SLE_BCI 에 static glibc 없음)
"업스트림 런타임 계약은 보존한다" 절을 새로 넣었다(USER·ENTRYPOINT·파일 레이아웃).
cloudnative-pg 가 멀티아치 심볼릭 링크를 부수 장치로 오판해 지웠다가 리컨실이 실패한 것이
근거다. 스캐너는 이걸 전혀 보지 못한다.
로컬 검증 — 판정만이 아니라 빌드까지 돌렸다
-----------------------------------------
판정이 지목한 4건을 실제로 빌드해 전제를 확인했다. 문서에 단정만 해두고 넘어가지 않았다.
이미지 배포 중 재빌드 후 핀 변경
apisix-ingress-controller 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음
cloudnative-pg 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음
etcd 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음
cnpg-postgresql 차단 2 0/0 PASS · VERIFY-OK · cov=ok 없음
- cnpg-postgresql 이 "베이스 OS 패키지 갱신으로 해소" 사례다(perl·rpm-ndb). 핀을 하나도
안 바꾸고 재빌드만으로 0건이 됐다 — 검증 없이 단정했던 부분이라 이게 핵심 확인이다.
- 빌드된 etcd 바이너리가 go1.26.6 + pinned commit 을 보고한다 — 핀이 실제로 반영됐다.
- 4건 다 CoverageProbe=ok 이므로 0건이 "측정되지 않음" 이 아니다.
- 빌드 후에도 git status images/ 가 깨끗하다 = 트리거를 핀 기준으로 뒀다면 4건 전부
놓쳤을 것이라는 확인.
그 외: 예외 적용(keycloak CVE-2025-59250 → 0건), --fail-on-drift rc=1, 워크플로 셸 재현
(GITHUB_OUTPUT 4개 JSON·fromJson OK·핀 변경 0 → 브랜치 미생성 → 기본 ref 로 빌드),
push 정책 전 조합 확인.
CI 에서 미검증인 것은 환경 특성뿐이다 — trivy 설치 스텝, GITHUB_STEP_SUMMARY 출력,
matrix 팬아웃, contents:write 로 브랜치 push. 빌드 자체는 로컬과 같은 스크립트를 쓴다.
Refs #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* self-build-image SKILL 의 자동 재빌드 절을 실행 계약만 남긴다
같은 커밋에서 image-authoring.md 와 SKILL 양쪽에 자동 재빌드 절차를 거의 동일하게 썼다.
SKILL 이 "작성 규칙 본문은 image-authoring.md 가 단일 출처다 — 여기 복제하지 않는다" 고
두 번 선언한 파일에서 내가 그 규칙을 어겼다.
SKILL 에는 실행 계약(수동 실행 명령 · 로컬 판정 명령 · push 안 한다는 사실)만 남기고
트리거 조건과 그 근거는 image-authoring.md 를 가리킨다.
frontmatter 도 함께 고친다 — "7종" 에 argocd 가 빠져 있어 실제 images/ 8개와 어긋났다.
description 은 Skill 로딩 판단에 쓰이는 텍스트이므로 "목록은 images/ 가 단일 출처" 라는
회피 조항으로 덮을 수 없다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 레포 분리 시 갈라지는 이음선을 코드와 문서에 명시한다
커스텀 이미지가 별도 레포로 분리될 예정인데, check-rebuild-needed.py 는 카탈로그(무엇을
배포 중인가)와 build.env(어떻게 만드는가)를 **양쪽 다** 읽는다. 분리 시 이 파일이 갈라진다.
절단면은 이미 함수 경계에 있다. 6개월 뒤에 "이 함수가 왜 카탈로그를 읽지" 를 다시 파지
않도록 어느 함수가 어느 쪽인지 docstring 에 적었다.
A (카탈로그 레포) resolve_current_ref + blocking_cves → "차단됐는가"
B (이미지 레포) pin_changes + apply_changes → "핀을 올려야 하는가"
check_image 둘을 엮는 오케스트레이션 — 여기가 갈라진다
image-authoring.md 의 드리프트 절에도 같은 사실을 한 문단으로 적어 분리 작업 때 이 주석을
먼저 읽게 했다. 결합점 전체 목록은 경계 문서화 작업에서 표로 정리한다.
코드 동작은 바뀌지 않았다 — 주석과 문서만 추가했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,31 @@ trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다
|
||||
공식 언어 이미지(`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 저장소가 특정
|
||||
@@ -140,6 +165,101 @@ COPY --from=builder /rootfs/ /
|
||||
업스트림 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` 에
|
||||
@@ -175,6 +295,65 @@ python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports 디렉토
|
||||
"그 프로젝트의 의존성 그래프에 있는 모듈만" 골라 적용하는 헬퍼를 두면 된다. `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 이 어느 함수가 어느 쪽인지 적어둔다 — **탐지는 카탈로그 쪽에 남고, 핀 판단은 이미지
|
||||
쪽으로 간다.** 분리 작업 때 그 주석을 먼저 읽는다.
|
||||
|
||||
## 두 가지 유형 (둘 다 같은 스크립트를 쓴다)
|
||||
|
||||
| 유형 | Dockerfile 이 하는 일 | 셸 유무 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: self-build-image
|
||||
description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, cloudnative-pg, cnpg-postgresql, etcd, keycloak 7종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처).
|
||||
description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, argocd, cloudnative-pg, cnpg-postgresql, etcd, keycloak 8종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처).
|
||||
---
|
||||
|
||||
# 자체 빌드 하드닝 이미지
|
||||
@@ -31,6 +31,56 @@ IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/
|
||||
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` 이전(전역 스코프)에 선언한다.** 스테이지 내부에 선언하면
|
||||
@@ -42,6 +92,9 @@ CI는 `build-image.yml`이 `image` 입력으로 이미 파라미터화돼 있다
|
||||
스캐너에 그 배포판 데이터가 없다는 뜻이다(`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 로 확인한다 —
|
||||
|
||||
Reference in New Issue
Block a user