자체 빌드 이미지 3종(cloudnative-pg/cnpg-postgresql/etcd) + 대응 헬름 차트 도입

security-catalog 프로젝트에서 첫 실사용 자체 빌드 이미지 3종을 포팅한다 — 전부
상위 태그·베이스 OS 교체로 해소 안 되는 CVE(Go 모듈 정적 링크 또는 미수정 CRITICAL/
HIGH)를 자체 빌드(소스 컴파일 또는 SUSE BCI 재설치)로 대응한다:

- images/cloudnative-pg: CNPG operator, release-1.30 소스 컴파일 + bci-micro
- images/cnpg-postgresql: PostgreSQL 18.4, bci-base + zypper 재설치
- images/etcd: etcd v3.7.1, 소스 컴파일(x/text 강제 업그레이드) + bci-micro

함께 추가:
- manifests/helm/{cloudnative-pg,cnpg-cluster,etcd} — 위 이미지를 참조하는 카탈로그 차트
- scripts/deploy-test/*.sh, .claude/deploy-test-procedure.md — CVE 0건과 별개로
  "실제로 뜨는가"를 검증하는 배포 스모크 테스트
- .claude/pitfalls.md — 자체 빌드/배포 테스트 중 실측한 함정 모음

검토 중 발견해 반영한 수정:
- cloudnative-pg 차트의 image 블록을 etcd와 동일한 registry/repository/tag 3필드+
  따옴표 포맷으로 통일 — 기존 포맷(repository에 registry+repo 결합, 따옴표 없음)은
  patch-catalog-tag.py 의 split 패처가 tag만 갱신하고 repository는 그대로 남기는
  조용한 부분 치환을 일으켜, 향후 레지스트리 마이그레이션 시 깨진 참조를 만들 수 있었다
- cnpg-cluster 차트의 SLES 커버리지 코멘트를 최신 실측(trivy가 SLES 15.7을 정상
  커버함, 2026-07-29 재측정)에 맞게 정정 — 폐기된 "측정 불가/OVAL 우회 필요" 결론이
  남아있었다
- CLAUDE.md/MEMORY.md 의 "images/ 디렉토리 없음" 서술을 갱신하고, 레지스트리
  마이그레이션(docker.io/wbsong111 → docker.io/paasup)·decisions/analysis 문서 이관·
  리소스 프로파일 추가를 다음 작업으로 기록

이 3개 이미지는 아직 dip-catalog 자체 CI(build-image.yml)로 빌드·게이트·push 를
실행해본 적이 없다 — 현재 참조 태그는 security-catalog 쪽에서 이미 검증된 것이다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-03 12:03:37 +09:00
parent a168a3c7e6
commit 6878b61efa
89 changed files with 36608 additions and 11 deletions
+93
View File
@@ -0,0 +1,93 @@
# cloudnative-pg — CloudNativePG 오퍼레이터 자체 빌드
CNPG 오퍼레이터(컨트롤러) 이미지를 업스트림 소스에서 직접 컴파일한다.
`manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml`·`dip-values.yaml`
`image.repository`/`image.tag` 가 이 산출물을 가리킨다.
security-catalog 프로젝트에서 포팅했다. 목표 정의·CVE 조사 실측·이미지 채택 결정
근거(각각 `doc/cve-zero-pipeline.md`, `doc/analysis/cloudnative-pg-operator-cve.md`,
`doc/decisions/0005-cloudnative-pg-operator-self-build.md`)는 security-catalog 프로젝트에
있고 dip-catalog 에는 아직 이관되지 않았다 — 신규 자체 빌드 이미지 추가 절차 전반은
[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나 — 그리고 왜 `cnpg-postgresql` 과 다른 방법인가
업스트림 `ghcr.io/cloudnative-pg/cloudnative-pg:1.30.0` 은 실효 HIGH 3건
(`CVE-2026-39822` stdlib, `CVE-2026-56852` `golang.org/x/text`, `GHSA-hrxh-6v49-42gf`
`google.golang.org/grpc`)으로 차단된다. 상위 태그가 없다(2026-07-30 확인, 최신 릴리스가
여전히 `v1.30.0`). 업스트림 배포 이미지 베이스가 `gcr.io/distroless/static-debian13`
(OS 패키지 사실상 0개)라 **베이스 OS 를 바꾸는 것만으로는 고쳐지지 않는다** — CVE 는
바이너리에 정적 링크된 Go 모듈 버전이 원인이다. 자체 빌드(소스 컴파일)만 유효한 대응이다.
`cnpg-postgresql`(OS 베이스 교체 + zypper 패치)과 성격이 다르지만, **오케스트레이션은
동일한 [scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh)
하나를 공유한다.** 이 이미지가 그 스크립트의 계약(`build.env``DOCKERFILE`·`TARGET`·
`BUILD_ARGS`·`APP_VERSION` 선언, `verify.sh``VERIFY-OK` 로 종료)만 지키면, 이미지
종류(OS 패키지 설치형 vs 소스 컴파일형)는 스크립트가 몰라도 된다 — 근거는
[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고.
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| 소스 | `https://github.com/cloudnative-pg/cloudnative-pg.git` |
| pinned commit | `source.build.env``SOURCE_COMMIT` (release-1.30 브랜치) |
| 빌더 | 공식 `golang` 이미지 (`source.build.env``GO_BUILDER_TAG`, go.mod 요구 버전과 일치) |
| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` — security-catalog 는 SUSE BCI 하나만 쓰기로 결정했다(ADR 미이관); 업스트림의 distroless 대신 여기 적용 |
빌더 스테이지(Go 컴파일)는 공식 `golang` 이미지를 그대로 쓴다 — 최종 이미지에 남는 것이
아니라 컴파일 산출물만 최종 스테이지로 넘어오므로 스캔·정책 대상이 아니다. `bci-micro`
는 SUSE BCI 중 가장 가벼운 변종이지만 `bci-base` 와 달리 `nonroot`(uid 65532) 계정이
미리 없어 `source.Dockerfile` 이 직접 만든다(`/etc/passwd`·`/etc/group` 에 추가).
`SOURCE_COMMIT`**자동 추적하지 않는다.** `cnpg-postgresql` 의 PGDG 버전처럼 사람이
업스트림 `release-1.30` 브랜치(또는 그다음 패치 릴리스가 나오면 그 태그)를 보고
`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는
업스트림이 `v1.30.1`(혹은 그 이상) 을 릴리스했을 때 — 릴리스가 나오면 그쪽으로 갈아타는
것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=cloudnative-pg BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다)
IMAGE=cloudnative-pg BASE_OS=source REGISTRY=docker.io/wbsong111 \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
dip-catalog 의 `scan-sbom.sh` 는 커버리지 자가진단(`CoverageProbe`)이 없다는 점에
유의한다 — `doc/sbom-pipeline.md` 참고. `verify.sh``manager version` 출력에 pinned
commit 이 실제로 반영됐는지(ldflags 주입 확인), 이미지 `Config.User`
`65532:65532`(nonroot)인지, `--help` 가 정상 종료하는지를 확인한다 — 실제 컨트롤러
기동(k8s API 필요)은 이 스모크 테스트 범위 밖이며, dev 클러스터 배포 테스트
(`.claude/deploy-test-procedure.md`)가 담당한다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) |
| `source.build.env` | pinned commit·버전·빌더 이미지 태그. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 직접 `docker run --entrypoint /manager` 를 호출한다(게스트 스크립트 주입 방식보다 상위 호환 — 최종 이미지에 셸이 없는 경우에도 그대로 동작한다) |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다 — `cnpg-postgresql` 처럼
변종이 늘면 `<variant>.Dockerfile`/`<variant>.build.env` 로 분기한다.
### 태그
```
docker.io/wbsong111/cloudnative-pg:1.30.0-security-hardened-20260730
└ app ─┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다.
dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고.
+11
View File
@@ -0,0 +1,11 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
CHART_DIRS="manifests/helm/cloudnative-pg/0.29.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
TAG_BLOCK=image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
+80
View File
@@ -0,0 +1,80 @@
# CloudNativePG 오퍼레이터 — 업스트림 소스를 pinned commit 으로 직접 컴파일한다.
#
# 업스트림(https://github.com/cloudnative-pg/cloudnative-pg)의 Dockerfile 은 이미 goreleaser
# 로 빌드된 바이너리(`dist/manager/manager_<arch>`)를 COPY 만 한다 — 컴파일 자체는 이 Dockerfile
# 밖(Makefile `docker-build` → goreleaser)에서 일어난다. 우리는 그 컴파일 단계를 Dockerfile
# 안으로 가져와 `go build` 로 직접 재현한다. `cnpg-postgresql/suse.Dockerfile` 이 "업스트림
# Dockerfile 을 다른 배포판으로 이식"한 것이라면, 이건 "업스트림이 Dockerfile 밖에서 하던
# 빌드를 Dockerfile 안으로 흡수"한 것이다.
#
# 왜 자체 빌드인가 — cloudnative-pg:1.30.0 이 게이트에서 차단하는 CVE 3건(stdlib·x/text·grpc)
# 은 OS 패키지가 아니라 바이너리에 정적 링크된 Go 모듈 버전이 원인이다. 배포 이미지 베이스가
# distroless(OS 패키지 사실상 0개)라 베이스 OS 교체로는 고칠 수 없다 — 소스를 다시 컴파일해야
# 한다. 세 CVE 모두 업스트림 release-1.30 브랜치에 이미 백포트돼 있다(SOURCE_COMMIT 이 그
# 커밋을 가리킨다) — 근거: doc/analysis/cloudnative-pg-operator-cve.md
#
# 업스트림과의 대응 관계 (release-1.30 브랜치 Dockerfile 기준)
# go build (Makefile build-manager) → 동일 (ldflags 까지 그대로)
# goreleaser 멀티아치(manager_amd64/arm64) → 단일 아키텍처(linux/amd64)만 직접 COPY.
# 심볼릭 링크 대신 같은 바이너리를 두 경로에 COPY (아래 "실측으로 드러난 필수 조건" 참고)
# distroless base(gcr.io/distroless/static-debian13:nonroot) → SUSE BCI(bci-micro)로 교체.
# 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001) — 최종 런타임 이미지에도 동일하게 적용한다.
# builder 스테이지(Go 컴파일)는 공식 golang 이미지를 그대로 쓴다 — 컴파일 결과물에는
# 영향이 없고, 최종 이미지에만 남는 게 무엇인지가 스캔·정책 대상이다
# syntax=docker/dockerfile:1
ARG GO_BUILDER_TAG=1.26.5-trixie
# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — 스테이지 안에서
# 선언하면(예: 이전엔 builder 스테이지 RUN 다음에 둠) 그 스테이지 지역 변수가 되어 다음
# FROM 의 이미지명 해석에 쓰이지 않는다(실측: "FROM argument 'RUNTIME_BASE' is not
# declared" 경고와 함께 빈 이미지명 에러 발생).
ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# 호스트 네이티브 아키텍처로 빌더를 띄운다. Go 크로스컴파일은 에뮬레이션이 필요 없으므로
# --platform=$BUILDPLATFORM 로 고정해 에뮬레이션 오버헤드를 피한다(로컬 arm64 Docker 데스크톱
# 에서 linux/amd64 결과물을 만들 때 특히 중요).
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH
ARG SOURCE_COMMIT
ARG APP_VERSION
WORKDIR /src
# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다.
ADD https://github.com/cloudnative-pg/cloudnative-pg.git#${SOURCE_COMMIT} /src
# 업스트림 Makefile 의 LDFLAGS·.goreleaser.yml 의 build 설정을 그대로 재현한다.
# (release-1.30 기준 go.mod: go 1.26.5, google.golang.org/grpc v1.82.1, golang.org/x/text v0.39.0)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath \
-ldflags "-s -w \
-X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildVersion=${APP_VERSION} \
-X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildCommit=${SOURCE_COMMIT} \
-X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildDate=$(date -u +%Y-%m-%d)" \
-o /out/manager ./cmd/manager
FROM ${RUNTIME_BASE} AS final
WORKDIR /
# bci-micro 는 root 만 있고 65532 사용자가 없다(distroless 의 nonroot 변종과 달리 nonroot
# 계정을 미리 만들어두지 않는다) — 직접 만든다. bci-micro 에 bash·coreutils 는 있다
# (zypper·rpm 은 없음 — "micro" 는 패키지 매니저가 빠진 것이지 셸까지 없는 건 아니다).
RUN set -eux; \
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
# 실측으로 드러난 필수 조건: 오퍼레이터는 `operator/manager_<GOARCH>` 를 런타임에 glob 해서
# "가용 아키텍처" 목록을 만든다(pkg/utils/discovery.go DetectAvailableArchitectures) — 이
# 목록이 비어 있으면 Cluster 리컨실이 "invalid architecture: amd64" 로 실패한다(배포
# 테스트 2026-07-30 에서 재현). 업스트림은 멀티아치 심볼릭 링크로 이걸 만들지만, 우리는
# 단일 아키텍처만 다루므로 같은 바이너리를 두 경로에 COPY 해 같은 효과를 낸다.
COPY --from=builder /out/manager /manager
COPY --from=builder /out/manager /operator/manager_amd64
COPY --from=builder /src/licenses /licenses
COPY --from=builder /src/LICENSE /licenses/LICENSE
USER 65532:65532
ENTRYPOINT ["/manager"]
+32
View File
@@ -0,0 +1,32 @@
# CloudNativePG 오퍼레이터 — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=cloudnative-pg BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가·CVE 근거 → doc/analysis/cloudnative-pg-operator-cve.md
# 결정 → doc/decisions/0004-cloudnative-pg-operator-self-build.md
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 스톡 1.30.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=1.30.0
# release-1.30 브랜치 HEAD, 2026-07-30 확인 — CVE-2026-39822(stdlib)·CVE-2026-56852(x/text)·
# GHSA-hrxh-6v49-42gf(grpc) 가 모두 이 커밋에 백포트돼 있다. 갱신할 때는 release-1.30 의
# 최신 커밋으로 사람이 다시 고른다(자동 추적하지 않음 — cnpg-postgresql 의 PGDG 버전처럼
# 사람이 트리거해야 하는 갱신 축).
SOURCE_COMMIT=4463551204bc5cdb5af05b2c60a2d6b58ce9ff6a
# go.mod 의 `go 1.26.5` 요구를 만족하는 공식 golang 이미지 태그. 이 이미지는 builder
# 스테이지에서만 쓰이고 최종 이미지에는 남지 않는다.
GO_BUILDER_TAG=1.26.5-trixie
# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001). bci-base 대신
# 가장 가벼운 bci-micro 를 쓴다(패키지 매니저 없음, bash·coreutils 는 있음).
RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
BUILD_ARGS="SOURCE_COMMIT GO_BUILDER_TAG APP_VERSION RUNTIME_BASE"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# cloudnative-pg 오퍼레이터 이미지 기능 검증 — 호스트에서 bash 로 실행된다
# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh`
# 형태로 호출한다).
#
# cnpg-postgresql/verify.sh 와 달리 게스트 셸을 쓰지 않는다 — 최종 이미지 베이스가
# gcr.io/distroless/static-debian13:nonroot 라 셸이 아예 없다(`/bin/sh` 없음, 업스트림
# Dockerfile 도 이 사실을 자기 빌더 스테이지 주석에 명시한다). 대신 `--entrypoint /manager`
# 로 바이너리를 직접 실행해 검증한다 — 실제 컨트롤러 기동(k8s API 필요)은 이 스모크
# 테스트 범위 밖이며, dev 클러스터 배포 테스트가 담당한다.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
SHORT_COMMIT="${SOURCE_COMMIT:0:8}"
echo "== manager version =="
OUT="$(docker run --rm --platform "$PLATFORM" --entrypoint /manager "$TAG" version)"
echo " $OUT"
case "$OUT" in
*"$SHORT_COMMIT"*) ;;
*) echo "FAIL: version 출력에 pinned commit($SHORT_COMMIT) 이 없다 — ldflags 주입 확인 필요"; exit 1 ;;
esac
echo "== 실행 사용자 (nonroot, 이미지 메타데이터) =="
# 셸이 없어 컨테이너 안에서 id 를 실행할 수 없다 — 이미지가 선언한 기본 User 를 확인한다.
# 위 version 실행 자체가 이 기본 사용자로 이미 성공했다(권한 문제였다면 여기까지 오지 못한다).
USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")"
[ "$USER_CFG" = "65532:65532" ] || { echo "FAIL: 이미지 Config.User 가 65532:65532 가 아니다 (실제: $USER_CFG)"; exit 1; }
echo " Config.User=$USER_CFG"
echo "== --help 스모크 (k8s API 없이 도는 유일한 확인 범위) =="
docker run --rm --platform "$PLATFORM" --entrypoint /manager "$TAG" --help >/dev/null
echo " /manager --help 종료 코드 0"
echo "VERIFY-OK"
+132
View File
@@ -0,0 +1,132 @@
# cnpg-postgresql — CloudNativePG PostgreSQL 자체 빌드
CNPG 오퍼레이터가 관리하는 PostgreSQL 인스턴스 이미지를 직접 빌드한다.
`manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml``imageName` 이 이 산출물을 가리킨다.
security-catalog 프로젝트에서 포팅했다. 목표 정의·이미지 선택 규칙·후보 비교 실측·이미지
채택 결정·빌드 자동화(CI) 문서(`doc/cve-zero-pipeline.md`, `doc/image-selection.md`,
`doc/analysis/cnpg-image-vuln-comparison.md`, `doc/decisions/0001-cnpg-postgresql-image.md`,
`doc/architecture/build-pipeline.md`)는 security-catalog 프로젝트에 있고 dip-catalog 에는
아직 이관되지 않았다.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
---
## 왜 자체 빌드하나
업스트림 `ghcr.io/cloudnative-pg/postgresql:18.4-standard-trixie` 는 실효 CRITICAL/HIGH
23건을 갖고 있고, **전부 수정 버전이 없다**(Debian `unimportant` 9 / `no-dsa` 7 /
`postponed` 3 / 미분류 4). SUSE 는 같은 CVE 들을 이미 백포트했다 — 베이스 OS 를 바꾸면
벤더 판정이 달라진다.
**단, 수치가 낮아진 것이 실제 개선인지 벤더 미평가인지 반드시 구분해야 한다.**
`cve-gate.py` 의 교차 검증(`--crossref`)이 이를 돕는다. security-catalog 의
`scan-sbom.sh` 가 제공하던 커버리지 자가진단(`CoverageProbe`)은 dip-catalog 의
`scan-sbom.sh` 에는 아직 없다(`doc/sbom-pipeline.md` 참고).
## 베이스 OS
이 이미지는 **SUSE BCI 15.7** 로 빌드됐다(security-catalog 프로젝트 ADR, 미이관).
| 파일 | 베이스 | 패키지 관리자 | 측정 가능성 |
| --- | --- | --- | --- |
| `suse.Dockerfile` | `registry.suse.com/bci/bci-base:15.7` | zypper + PGDG rpm | ✅ trivy 로 완전 측정 |
`ubuntu:24.04` 기반 빌드(후보 B)는 security-catalog 프로젝트에서 검토 후 제거됐다 —
카탈로그가 채택하지 않아 재빌드되지 않은 채 낡아가고 있었다(빌드 하루 만에 findings
38 → 46). 관련 실측은 이관되지 않았다.
### 업스트림과의 차이
**원칙: 업스트림 Dockerfile 과의 차이를 최소로 유지한다.** 패키지 구성·uid·확장 목록을
임의로 줄이면 오퍼레이터가 이미지에 기대하는 조건이 깨진다.
`suse.Dockerfile` 은 업스트림 CNPG(Debian/apt/PGDG-deb)를 SUSE(zypper/PGDG-rpm)로
이식한 것이라 저장소 설정·패키지명·경로가 전부 다르다. 대응 관계는 파일 상단 주석 참고.
`patched` 단계의 `zypper update -y` 가 실제로 기여한 몫: 베이스 이미지는 주기적으로만
재빌드되므로 배포판 아카이브보다 뒤처져 있다. 이 단계가 없으면 그 시점의 미패치 취약점이
그대로 남는다 — 자동 재빌드가 필요한 이유가 이것이다.
### 업스트림 빌드 레시피 변경 점검 (수동, 자동화 대상 아님)
`cloudnative-pg/postgres-containers` 저장소가 자체 Dockerfile 구조를 바꾸면(새 확장,
새 하드닝 단계 등) 이 포트도 따라가야 한다. **CI 로 자동 감지하지 않는다** — 배포판이
달라(Debian vs SUSE) 의미 있는 diff 가 안 나온다.
**권장 점검 주기: CNPG 마이너 릴리스마다.** 업스트림 Dockerfile 을 훑어보고 구조가
바뀌었으면 `suse.Dockerfile` 을 손으로 다시 이식한다.
---
## 빌드
```sh
# 카탈로그가 쓰는 베이스 OS (기본값)
IMAGE=cnpg-postgresql BASE_OS=suse bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다)
IMAGE=cnpg-postgresql BASE_OS=suse REGISTRY=docker.io/wbsong111 \
bash scripts/build/build-hardened-image.sh /tmp/out
# 교차 검증을 함께 (사각지대 후보를 뽑는다. CI 에는 아직 연결되지 않았다)
CROSSREF=/tmp/ref/standard-trixie.json IMAGE=cnpg-postgresql BASE_OS=suse \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다.
CVE 0건이어도 동작하지 않는 이미지는 카탈로그에 넣을 수 없다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `<base-os>.Dockerfile` | 빌드 정의 |
| `<base-os>.build.env` | 베이스·앱 버전·확장 목록. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. **베이스 OS 공통** — CNPG 요구사항은 베이스와 무관하다 |
베이스 OS 가 늘어도 이 구조는 그대로다 — `build-hardened-image.sh``BASE_OS` 환경변수로
`<base-os>.build.env` 를 고른다.
### 태그에 빌드일을 넣는다
```
docker.io/wbsong111/cnpg-postgresql:18.4-bci15.7-hardened-20260729
└ 앱 ─┘└ 베이스 ─┘└ 하드닝 ┘└ 빌드일 ┘
```
같은 앱 버전이라도 `zypper update` 결과가 시점마다 다르다. 롤링 태그를 피하고
자체 이미지에도 같은 실수를 하지 않는다(빌드일 포함 고정 태그).
이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다.
dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고.
---
## 실측 (security-catalog, 2026-07-29 / trivy 0.72.0 기준)
| | `bci15.7-hardened-20260728` |
| --- | --- |
| 커버리지 자가진단 | ✅ `ok` |
| 전 심각도 findings | 0 |
| 실효 고유 C/H | **0 / 0** |
| 게이트 | **PASS** |
### 채택 당시 비교 (후보 A/B/C, security-catalog 기준)
| | 업스트림 `standard-trixie` (A) | Ubuntu 24.04 자체빌드 (B, 제거됨) | **SUSE BCI 15.7 (C, 채택)** |
| --- | --- | --- | --- |
| 패키지 수 | 148 | 147 | 182 |
| trivy findings (전 심각도) | 316 | 46 | 0 |
| 실효 고유 C/H | 23 | 0 | 0 |
| 미조사 사각지대 | 0 | 18건 | 18건 |
| 서명·attestation | ✅ | ❌ | ❌ |
| 배포 검증 | — | ✅ failover 2초 | ✅ failover 3초 |
| gid | `postgres` | ⚠️ `tape` | `postgres` |
이 수치는 dip-catalog 에서 재측정한 것이 아니라 security-catalog 프로젝트에서 이관한
기록이다 — dip-catalog 자체 게이트로 재확인은 아직 하지 않았다(`MEMORY.md` 참고).
+11
View File
@@ -0,0 +1,11 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
# 이 이미지의 태그를 참조하는 차트 버전 디렉토리 (공백 구분, 여러 개 가능)
CHART_DIRS="manifests/helm/cnpg-cluster/1.0.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=imageName
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=suse
+83
View File
@@ -0,0 +1,83 @@
# CloudNativePG PostgreSQL — SUSE BCI 기반 자체 빌드
#
# 업스트림 CNPG Dockerfile(Debian/apt/PGDG-deb)을 SUSE(zypper/PGDG-rpm)로 이식한 것이다.
# ARG BASE 만 바꾸는 수준이 아니라 패키지 관리자·저장소 설정·패키지명·경로가 모두 다르다.
#
# 업스트림과의 대응 관계
# postgresql-common + apt.postgresql.org.sh → rpm --import + zypper addrepo (PGDG zypp)
# postgresql-18 → postgresql18-server
# postgresql-18-pgaudit / -pgvector → pgaudit_18 / pgvector_18
# locales-all → glibc-locale
# usermod -u 26 postgres → 불필요 (PGDG RPM 이 uid 26 으로 생성)
# /usr/lib/postgresql/18/bin → /usr/pgsql-18/bin
#
# CNPG 호환성 근거 (실측)
# - uid 26: PGDG RPM 이 postgres 를 uid/gid 26 으로 만든다 (RPM 계열 관례)
# - 바이너리 탐색: CNPG 는 경로를 하드코딩하지 않고 PATH 로 찾는다.
# cloudnative-pg 소스에서 "usr/lib/postgresql" 는 테스트 픽스처에만 등장한다.
# 그래도 하드코딩 경로를 쓰는 코드가 생길 경우를 대비해 심볼릭 링크를 함께 둔다.
#
# 측정 가능성 — trivy 는 SLES 15.7 을 정상 커버한다.
# 2026-07-29 이전에는 "trivy 의 SUSE 데이터가 15.7 을 커버하지 않는다" 로 판단했으나
# 양성 대조(SBOM 사본에 취약 버전 주입 후 재스캔)로 재측정한 결과 오판이었다 — trivy 는
# 13건을 잡았다(SUSE-SU ID 포함). 0건은 이 이미지가 실제로 최신이라서다.
# scan-sbom.sh 의 커버리지 자가진단이 매 스캔마다 이를 확인한다.
# 경위: doc/analysis/sles-oval-measurement.md
ARG BASE=registry.suse.com/bci/bci-base:15.7
FROM $BASE AS minimal
ARG PG_MAJOR=18
ARG PG_VERSION=18.4-4200001PGDG.sles15.7
ARG SLE_REPO=sles-15.7-x86_64
ARG PGDG_KEY=PGDG-RPM-GPG-KEY-SLES15
ENV PATH=$PATH:/usr/pgsql-${PG_MAJOR}/bin
# PGDG 저장소는 GPG 키를 미리 import 해야 한다. addrepo 만 하면
# "Signature verification failed for repomd.xml" 로 저장소가 스킵된다.
RUN set -eux; \
rpm --import "https://download.postgresql.org/pub/repos/zypp/keys/${PGDG_KEY}"; \
zypper --non-interactive addrepo --refresh \
"https://download.postgresql.org/pub/repos/zypp/${PG_MAJOR}/suse/${SLE_REPO}/" pgdg; \
zypper --non-interactive refresh; \
zypper --non-interactive install -y --no-recommends \
"postgresql${PG_MAJOR}-server=${PG_VERSION}"; \
zypper clean --all; \
rm -rf /var/log/zypp /var/cache/zypp
# CNPG 는 PATH 로 바이너리를 찾지만, Debian 레이아웃을 가정하는 코드가 생길 경우를 대비한다.
RUN set -eux; \
mkdir -p /usr/lib/postgresql; \
ln -sfn "/usr/pgsql-${PG_MAJOR}" "/usr/lib/postgresql/${PG_MAJOR}"; \
id postgres | grep -q 'uid=26(' || { echo "postgres uid 가 26 이 아니다"; exit 1; }
USER 26
FROM minimal AS standard
ARG PG_MAJOR=18
# 업스트림 standard 와 동등한 구성: pgaudit, pgvector, contrib(pg_stat_statements), 전 로케일.
# pg-failover-slots 는 PGDG zypp 저장소에 없어 제외한다 (업스트림 Debian standard 와의 차이).
ARG EXTENSIONS="pgaudit_${PG_MAJOR} pgvector_${PG_MAJOR}"
USER 0
RUN set -eux; \
zypper --non-interactive install -y --no-recommends \
glibc-locale \
"postgresql${PG_MAJOR}-contrib" \
${EXTENSIONS}; \
zypper clean --all; \
rm -rf /var/log/zypp /var/cache/zypp
USER 26
# 베이스 이미지에 남은 구버전 패키지를 보안 업데이트로 올린다.
# Debian 판의 `apt-get upgrade` 단계에 대응한다 (그쪽에서 NVD 기준 HIGH 5건을 해소했다).
FROM standard AS patched
USER 0
RUN set -eux; \
zypper --non-interactive refresh; \
zypper --non-interactive update -y --no-recommends; \
zypper clean --all; \
rm -rf /var/log/zypp /var/cache/zypp
USER 26
+28
View File
@@ -0,0 +1,28 @@
# CNPG PostgreSQL — SUSE BCI / zypper 기반 자체 빌드 (카탈로그 채택 베이스 OS)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# BASE_OS 기본값이 이 파일(suse)이다 — 카탈로그의 imageName 이 실제로 이 정의로 빌드된다.
#
# 측정 가능성 — trivy 는 SLES 15.7 을 정상 커버한다 (2026-07-29 재측정, 이전 판단 정정).
# 상세: doc/analysis/sles-oval-measurement.md
DOCKERFILE=suse.Dockerfile
TARGET=patched
TAG_SLUG=bci15.7
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# PG_VERSION(아래, PGDG 패키지의 정확한 EVR)에서 메이저.마이너만 뽑은 값과 같다 —
# 예전엔 스크립트가 PG_VERSION 을 파싱해 유도했지만, 이제는 이미지가 명시적으로 선언한다.
APP_VERSION=18.4
BASE=registry.suse.com/bci/bci-base:15.7
PG_MAJOR=18
# PGDG zypp 저장소의 정확한 EVR. apt 쪽과 형식이 다르다.
PG_VERSION=18.4-4200001PGDG.sles15.7
SLE_REPO=sles-15.7-x86_64
PGDG_KEY=PGDG-RPM-GPG-KEY-SLES15
# pg-failover-slots 는 PGDG zypp 저장소에 없어 제외한다 (apt 변종과의 차이).
EXTENSIONS="pgaudit_18 pgvector_18"
BUILD_ARGS="BASE PG_MAJOR PG_VERSION SLE_REPO PGDG_KEY EXTENSIONS"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# CNPG PostgreSQL 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... PG_MAJOR=... bash verify.sh` 형태로 호출한다). 이 이미지는 셸
# (`/bin/sh`)이 있으므로, 실제 점검은 컨테이너 안에서 게스트 셸 스크립트로 수행한다.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
#
# 왜 이미지 디렉토리에 두나: 검증 항목은 이미지마다 다르다. 빌드 스크립트에 하드코딩하면
# 이미지가 둘 이상이 될 때 깨진다. 실제로 apt·suse 두 변종이 생겨 분리했다.
#
# CNPG 가 이미지에 요구하는 것(오퍼레이터 소스 기준)
# - uid 26 — 다르면 오퍼레이터가 관리하는 볼륨 권한이 깨진다
# - PATH 에서 initdb/pg_ctl/postgres 를 찾을 수 있어야 한다 (경로 하드코딩 아님)
# - 차트가 선언한 확장이 실제로 생성되어야 한다
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
PG_MAJOR="${PG_MAJOR:?PG_MAJOR 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
docker run --rm -i --platform "$PLATFORM" -e PG_MAJOR="$PG_MAJOR" --entrypoint sh "$TAG" <<'GUEST'
set -e
export PGDATA=/tmp/_verify
export PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:/usr/pgsql-${PG_MAJOR}/bin:$PATH"
echo "== 실행 사용자 =="
uid=$(id -u)
gid=$(id -g)
gname=$(id -gn 2>/dev/null || echo '?')
echo " uid=$uid gid=$gid($gname)"
[ "$uid" = "26" ] || { echo "FAIL: uid=$uid — CNPG 는 26 을 요구한다"; exit 1; }
# gid 는 실패시키지 않고 경고만 한다. 실측 사례: Ubuntu 베이스에서 gid 26 이 `tape`
# 그룹에 선점되어 있어 postgres 가 tape 그룹으로 동작했다. 기능은 동작하지만 위생 문제다.
if [ "$gname" != "postgres" ]; then
echo " WARN: gid $gid 의 그룹명이 '$gname' 이다 (postgres 가 아님)"
fi
echo "== 바이너리 탐색 (PATH 기준) =="
for b in initdb pg_ctl postgres psql; do
p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; }
echo " $b → $p"
done
echo "== initdb + 기동 =="
initdb -D "$PGDATA" -A trust >/dev/null
pg_ctl -D "$PGDATA" -w start >/dev/null \
-o "-c shared_preload_libraries=pgaudit,pg_stat_statements -c unix_socket_directories=/tmp"
psql -h /tmp -U postgres -Atc "SELECT version();" | sed 's/^/ /'
echo "== 확장 생성 =="
# 차트(cnpg-cluster)가 Database CRD 로 선언하는 것과 같은 목록이다.
psql -h /tmp -U postgres -q -c \
"CREATE EXTENSION pgaudit; CREATE EXTENSION vector; CREATE EXTENSION pg_stat_statements;"
psql -h /tmp -U postgres -Atc \
"SELECT extname||' '||extversion FROM pg_extension ORDER BY 1;" | sed 's/^/ /'
echo "== libxml2 링크 (XML 함수) =="
psql -h /tmp -U postgres -Atc \
"SELECT xml_is_well_formed_document('<a>x</a>');" | sed 's/^/ /'
echo "== 로케일 =="
# 업스트림 standard 는 전 로케일을 포함한다. 줄어들면 정렬·비교 동작이 달라진다.
n=$(psql -h /tmp -U postgres -Atc "SELECT count(*) FROM pg_collation;")
echo " pg_collation: ${n}개"
[ "$n" -gt 100 ] || echo " WARN: collation 이 ${n}개다 — 로케일 패키지 누락 의심"
pg_ctl -D "$PGDATA" -w stop >/dev/null
echo "VERIFY-OK"
GUEST
+95
View File
@@ -0,0 +1,95 @@
# etcd — 자체 빌드
etcd 서버/`etcdctl`/`etcdutl` 바이너리를 업스트림 소스에서 직접 컴파일한다.
`manifests/helm/etcd/1.1.12/custom-values.yaml``image.repository`/`image.tag`
이 산출물을 가리킨다.
security-catalog 프로젝트에서 포팅했다. 목표 정의·CVE 조사 실측·이미지 채택 결정
근거(각각 `doc/cve-zero-pipeline.md`, `doc/analysis/etcd-cve.md`,
`doc/decisions/0007-etcd-image-self-build.md`)는 security-catalog 프로젝트에 있고
dip-catalog 에는 아직 이관되지 않았다 — 신규 자체 빌드 이미지 추가 절차 전반은
[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나 — 그리고 왜 `cloudnative-pg` 와 같은 방법인가
업스트림 `quay.io/coreos/etcd:v3.7.1` 은 실효 HIGH 1건(`CVE-2026-56852`,
`golang.org/x/text` — 깨진 UTF-8 입력에 대한 `norm.Iter` 무한루프 DoS)으로 차단된다.
상위 태그가 없다(2026-07-31 확인, 최신 릴리스가 여전히 `v3.7.1`이고 `release-3.7`
브랜치도 아직 이 CVE 를 백포트하지 않았다). 업스트림 배포 이미지 베이스가
`gcr.io/distroless/static-debian12`(OS 패키지 사실상 0개)라 **베이스 OS 를 바꾸는 것만
으로는 고쳐지지 않는다** — CVE 는 바이너리에 정적 링크된 Go 모듈 버전이 원인이다.
자체 빌드(소스 컴파일)만 유효한 대응이다.
이는 `cloudnative-pg` 오퍼레이터 자체 빌드와 **완전히 같은 유형의 문제**(같은
CVE-2026-56852)이고 대응도 같다 — **오케스트레이션은 동일한
[scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh)
하나를 공유한다.**
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| 소스 | `https://github.com/etcd-io/etcd.git` |
| pinned commit | `source.build.env``SOURCE_COMMIT``v3.7.1` 태그가 가리키는 실제 커밋 |
| 빌더 | 공식 `golang` 이미지 (`source.build.env``GO_BUILDER_TAG`, go.mod 요구 버전과 일치) |
| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` — security-catalog 는 SUSE BCI 하나만 쓰기로 결정했다(ADR 미이관); 업스트림의 distroless 대신 여기 적용 |
빌더 스테이지(Go 컴파일)는 공식 `golang` 이미지를 그대로 쓴다 — 최종 이미지에 남는 것이
아니라 컴파일 산출물만 최종 스테이지로 넘어오므로 스캔·정책 대상이 아니다.
**업스트림과 다른 부분은 딱 하나다**`golang.org/x/text` 를 워크스페이스 전역
`go.work` `replace` 한 줄로 `0.39.0` 이상으로 강제 업그레이드한다(각 서브모듈의
`go.mod` 를 개별로 고치지 않는다). 그 외 빌드 절차(`server`/`etcdutl`/`etcdctl`
바이너리를 각각 컴파일, `CGO_ENABLED=0` 정적 링크)는 업스트림
`scripts/build_lib.sh``etcd_build()` 를 그대로 재현한다.
`SOURCE_COMMIT`**자동 추적하지 않는다.** `cloudnative-pg` 와 동일하게 사람이
업스트림 `release-3.7` 브랜치(또는 그다음 패치 릴리스가 나오면 그 태그)를 보고
`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는
업스트림이 `v3.7.2`(혹은 그 이상, `x/text` 백포트 포함)를 릴리스했을 때 — 릴리스가
나오면 그쪽으로 갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상
우선이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=etcd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다)
IMAGE=etcd BASE_OS=source REGISTRY=docker.io/wbsong111 \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
`verify.sh``etcd`/`etcdctl`/`etcdutl` 이 PATH 에 있는지, `etcd --version` 출력에
pinned commit 이 실제로 반영됐는지(ldflags 주입 확인), 단일 노드로 기동해 `etcdctl`
put/get 왕복이 실제로 동작하는지를 확인한다 — 다중 노드 쿼럼·TLS 등은 이 스모크 테스트
범위 밖이며, dev 클러스터 배포 테스트(`.claude/deploy-test-procedure.md`,
`scripts/deploy-test/deploy-test-etcd.sh`)가 담당한다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) |
| `source.build.env` | pinned commit·버전·빌더 이미지 태그. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 `docker run --entrypoint sh` 로 게스트 셸 스크립트를 주입한다(`bci-micro` 는 bash·coreutils 가 있다 — `cnpg-postgresql/verify.sh` 와 같은 패턴, `cloudnative-pg` 의 셸 없는 distroless 와는 다르다) |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다.
### 태그
```
docker.io/wbsong111/etcd:3.7.1-security-hardened-20260731
└ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다.
dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고.
+11
View File
@@ -0,0 +1,11 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
CHART_DIRS="manifests/helm/etcd/1.1.12"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
TAG_BLOCK=image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
+83
View File
@@ -0,0 +1,83 @@
# etcd — 업스트림 소스를 pinned commit 으로 직접 컴파일한다.
#
# 업스트림(https://github.com/etcd-io/etcd) 의 루트 Dockerfile 은 이미 빌드된 바이너리
# (`etcd`/`etcdctl`/`etcdutl`)를 ADD 만 한다 — 컴파일 자체는 `scripts/build.sh` →
# `scripts/build_lib.sh` 의 `etcd_build()` 가 Dockerfile 밖에서 수행한다. 우리는 그 빌드
# 단계를 Dockerfile 안으로 가져와 `go build` 로 직접 재현한다(cloudnative-pg 자체 빌드와
# 동일한 패턴 — doc/decisions/0005 참고).
#
# 왜 자체 빌드인가 — quay.io/coreos/etcd:v3.7.1 이 게이트에서 차단하는 HIGH 1건
# (CVE-2026-56852, golang.org/x/text)은 OS 패키지가 아니라 바이너리에 정적 링크된 Go
# 모듈 버전이 원인이다. etcd v3.7.1/release-3.7 브랜치는 go.mod 에 x/text v0.37.0(취약)을
# 고정하고 있고 상위 패치 릴리스가 아직 없다(2026-07-31 확인) — 베이스 OS 교체로는
# 고쳐지지 않으므로 자체 빌드가 유일한 대응이다. 근거: doc/analysis/etcd-cve.md
#
# 업스트림과의 대응 관계 (v3.7.1 태그, scripts/build_lib.sh 의 etcd_build() 기준)
# cd server && go build ... → 동일 (ldflags 는 GitSHA 대신 pinned commit 을 직접 주입 — 아래 참고)
# cd etcdutl && go build ... → 동일
# cd etcdctl && go build ... → 동일
# distroless/static-debian12 → SUSE BCI(bci-micro)로 교체. 카탈로그는 SUSE BCI 하나만
# 쓴다(decisions/0001) — builder 스테이지(Go 컴파일)는 공식 golang 이미지를 그대로 쓴다
#
# 업스트림과 다르게 하는 부분 — x/text 강제 업그레이드
# etcd 는 Go 워크스페이스(go.work)로 서버/etcdctl/etcdutl 모듈을 함께 관리한다.
# 각 go.mod 를 개별로 고치는 대신 go.work 에 워크스페이스 전역 replace 한 줄만 추가해
# golang.org/x/text 를 강제 업그레이드한다 — 업스트림과의 차이를 최소로 유지하기 위함
# (doc/image-selection.md 7절 "빌드 원칙: 업스트림과의 차이를 최소로 유지한다").
#
# ldflags — GitSHA 대신 pinned commit 을 직접 주입하는 이유
# 업스트림은 `git rev-parse --short HEAD` 로 얻은 값을 버전 문자열에 심는다. 우리는
# BuildKit 의 git context(`ADD ...#${SOURCE_COMMIT}`)로 체크아웃하므로 커밋을 이미 알고
# 있다 — 호스트의 verify.sh 가 굳이 컨테이너 안에서 git 을 실행하지 않고도 버전 문자열에
# pinned commit 이 반영됐는지 확인할 수 있도록, 알고 있는 전체 커밋 해시를 그대로 심는다
# (cloudnative-pg/source.Dockerfile 과 동일한 이유).
# syntax=docker/dockerfile:1
ARG GO_BUILDER_TAG=1.26.5-trixie
# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — .claude/image-authoring.md
# 3번 항목, cloudnative-pg/source.Dockerfile 에서 실제로 겪은 버그.
ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# 호스트 네이티브 아키텍처로 빌더를 띄운다. Go 크로스컴파일은 에뮬레이션이 필요 없다.
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH
ARG SOURCE_COMMIT
ARG APP_VERSION
ARG XTEXT_FIX_VERSION
WORKDIR /src
# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다.
ADD https://github.com/etcd-io/etcd.git#${SOURCE_COMMIT} /src
# CVE-2026-56852 대응: golang.org/x/text 를 워크스페이스 전역으로 강제 업그레이드한다.
# `go work sync` 가 이 replace 를 각 모듈(server/etcdctl/etcdutl 등)의 go.sum 에 반영한다.
RUN echo "replace golang.org/x/text => golang.org/x/text v${XTEXT_FIX_VERSION}" >> go.work \
&& go work sync
# 업스트림 scripts/build_lib.sh 의 etcd_build() 를 그대로 재현한다.
# (GOARCH 는 TARGETARCH 로 대체, GitSHA 대신 pinned commit 전체 해시를 심는다 — 위 설명 참고)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
mkdir -p /out; \
LDFLAGS="-X=go.etcd.io/etcd/api/v3/version.GitSHA=${SOURCE_COMMIT}"; \
( cd server && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcd . ); \
( cd etcdutl && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcdutl . ); \
( cd etcdctl && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcdctl . )
FROM ${RUNTIME_BASE} AS final
# 업스트림 루트 Dockerfile 과 동일한 경로·구조 — chart(groundhog2k/etcd) 의 startup/liveness/
# readiness probe 가 `/usr/local/bin/etcdctl` 을 하드코딩해 호출하므로 경로를 바꾸면 안 된다.
COPY --from=builder /out/etcd /usr/local/bin/etcd
COPY --from=builder /out/etcdctl /usr/local/bin/etcdctl
COPY --from=builder /out/etcdutl /usr/local/bin/etcdutl
COPY --from=builder /src/LICENSE /licenses/LICENSE
WORKDIR /var/etcd/
WORKDIR /var/lib/etcd/
EXPOSE 2379 2380
CMD ["/usr/local/bin/etcd"]
+37
View File
@@ -0,0 +1,37 @@
# etcd — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=etcd BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가·CVE 근거 → doc/analysis/etcd-cve.md
# 결정 → doc/decisions/0007-etcd-image-self-build.md
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 업스트림 stock 이미지(quay.io/coreos/etcd:v3.7.1)와는 태그 슬러그(TAG_SLUG=security)로
# 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=3.7.1
# v3.7.1 태그가 가리키는 실제 커밋(태그 객체가 아니라 peeled commit), 2026-07-31 확인:
# git ls-remote --tags https://github.com/etcd-io/etcd.git v3.7.1 v3.7.1^{}
SOURCE_COMMIT=5e7fd0de9a57db03ecc11794dc40403a734c07bb
# go.mod 의 `go 1.26`/`toolchain go1.26.5` 요구를 만족하는 공식 golang 이미지 태그.
# 이 이미지는 builder 스테이지에서만 쓰이고 최종 이미지에는 남지 않는다.
GO_BUILDER_TAG=1.26.5-trixie
# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001). 정적 링크
# 바이너리(CGO_ENABLED=0)라 패키지 매니저가 없는 가장 가벼운 bci-micro 로 충분하다.
RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# CVE-2026-56852 대응 — golang.org/x/text 를 이 버전 이상으로 강제 업그레이드한다.
# v3.7.1/release-3.7 브랜치는 아직 이 CVE 를 백포트하지 않았다(go.mod 실측 x/text
# v0.37.0). etcd 메인테이너가 release-3.7 브랜치에 패치를 백포트하면(그리고 v3.7.2 가
# 나오면) 이 자체 빌드는 걷어내고 상위 태그로 돌아간다(대응 우선순위 a 가 c 보다 우선).
XTEXT_FIX_VERSION=0.39.0
BUILD_ARGS="SOURCE_COMMIT GO_BUILDER_TAG APP_VERSION RUNTIME_BASE XTEXT_FIX_VERSION"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# etcd 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... SOURCE_COMMIT=... XTEXT_FIX_VERSION=... bash verify.sh`
# 형태로 호출한다). 최종 베이스가 bci-micro 라 bash/coreutils 가 있으므로(패키지
# 매니저만 없음 — .claude/image-authoring.md), cnpg-postgresql/verify.sh 와 같은
# 게스트 셸 패턴을 쓴다(cloudnative-pg 처럼 셸 없는 distroless 가 아니다).
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
#
# 이 이미지에 요구하는 것
# - /usr/local/bin/etcd·etcdctl·etcdutl 이 PATH 에 있어야 한다 (chart 의 startup/
# liveness/readiness probe 가 /usr/local/bin/etcdctl 을 하드코딩해 호출한다)
# - 단일 노드로 기동해 put/get 왕복이 실제로 동작해야 한다 (게이트 0건이어도 못 쓰는
# 이미지는 무의미하다 — .claude/image-authoring.md 5번)
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
docker run --rm -i --platform "$PLATFORM" -e SOURCE_COMMIT="$SOURCE_COMMIT" --entrypoint sh "$TAG" <<'GUEST'
set -e
echo "== 바이너리 탐색 (PATH 기준) =="
for b in etcd etcdctl etcdutl; do
p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; }
echo " $b -> $p"
done
echo "== 버전 (pinned commit 반영 확인) =="
# sed 가 없다(bci-micro 는 bash·coreutils 는 있지만 sed 는 별도 패키지라 빠져 있다 —
# 2026-07-31 CI 에서 실측: "sh: sed: command not found"). 순수 셸 루프로 들여쓴다.
OUT="$(etcd --version)"
while IFS= read -r line; do echo " $line"; done <<EOF
$OUT
EOF
case "$OUT" in
*"$SOURCE_COMMIT"*) ;;
*) echo "FAIL: 버전 출력에 pinned commit($SOURCE_COMMIT) 이 없다 — ldflags 주입 확인 필요"; exit 1 ;;
esac
echo "== 단일 노드 기동 =="
export ETCD_DATA_DIR=/tmp/verify-data
export ETCD_LISTEN_CLIENT_URLS=http://127.0.0.1:2379
export ETCD_ADVERTISE_CLIENT_URLS=http://127.0.0.1:2379
export ETCD_LISTEN_PEER_URLS=http://127.0.0.1:12380
export ETCD_INITIAL_ADVERTISE_PEER_URLS=http://127.0.0.1:12380
export ETCD_INITIAL_CLUSTER=default=http://127.0.0.1:12380
export ETCD_NAME=default
etcd >/tmp/etcd-verify.log 2>&1 &
ETCD_PID=$!
trap 'kill $ETCD_PID 2>/dev/null || true' EXIT
echo "== health 대기 (최대 30s) =="
ok=0
for i in $(seq 1 30); do
if etcdctl endpoint health >/dev/null 2>&1; then ok=1; break; fi
sleep 1
done
if [ "$ok" != "1" ]; then
echo "FAIL: etcd 가 30초 내에 healthy 상태가 되지 못했다"
cat /tmp/etcd-verify.log
exit 1
fi
HEALTH="$(etcdctl endpoint health)"
while IFS= read -r line; do echo " $line"; done <<EOF
$HEALTH
EOF
echo "== put/get 왕복 =="
etcdctl put smoke-test ok >/dev/null
answer="$(etcdctl get smoke-test --print-value-only)"
[ "$answer" = "ok" ] || { echo "FAIL: get 응답 이상 (got: '$answer')"; exit 1; }
echo " get smoke-test => '$answer'"
kill "$ETCD_PID" 2>/dev/null || true
trap - EXIT
echo "VERIFY-OK"
GUEST