자체 빌드 이미지 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
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# =============================================================================
# deploy-test-etcd.sh
# etcd 차트가 지정한 이미지로 실제 Kubernetes 배포 수준에서 정상 동작하는지
# 확인한다. CVE 게이트(scripts/pipeline/cve-gate.py)는 "취약점이 없는가"만
# 본다. 이 스크립트는 "0건이어도 실제로 뜨는가"를 본다 —
# .claude/deploy-test-procedure.md 의 원칙("Zero CVE 로는 부족하다. 동작하지
# 않는 이미지는 카탈로그에 올릴 수 없다").
#
# cnpg 배포 테스트(deploy-test-cnpg-cluster.sh)와 달리 etcd 차트는 오퍼레이터가
# 없는 단일 차트다 — 상시 컴포넌트 설치 단계가 없고, 매 실행 테스트 네임스페이스에
# 차트를 설치하고 끝나면 지운다. 정리는 성공/실패 무관 항상 수행한다(trap).
#
# 배포는 차트 **기본값(values.yaml)** 만 쓴다. custom-values.yaml 은 쓰지 않는다 —
# 유일한 override 는 검증 대상 이미지와 replica 수뿐이다(cnpg 원칙과 동일).
#
# 사용:
# IMAGE_NAME=quay.io/coreos/etcd:v3.7.1 bash scripts/deploy-test/deploy-test-etcd.sh <OUT_DIR>
#
# 환경변수:
# IMAGE_NAME (필수) 검증할 etcd 이미지 전체 경로:태그 (registry/repository:tag)
# INIT_IMAGE init 컨테이너 이미지 전체 경로:태그 기본 docker.io/busybox:1.38.0-uclibc
# CHART_DIR 기본 manifests/helm/etcd/1.1.12
# TEST_NAMESPACE 기본 etcd-test-build (매 실행 재생성)
# RELEASE 기본 etcd-build
# REPLICAS 기본 3 (쿼럼 확인을 위해 홀수 권장)
# WAIT_TIMEOUT 기본 300 (초) — StatefulSet 롤아웃 대기 한도
#
# 산출물 (OUT_DIR):
# deploy-test.log 전체 실행 로그
#
# 종료 코드: 0 = 기능 확인(quorum health + put/get) 통과, 그 외 = 실패.
# 정리는 종료 코드와 무관하게 항상 수행된다.
# =============================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
OUT_DIR="${1:?사용법: deploy-test-etcd.sh <OUT_DIR>}"
mkdir -p "$OUT_DIR"
LOG="$OUT_DIR/deploy-test.log"
: > "$LOG"
IMAGE_NAME="${IMAGE_NAME:?IMAGE_NAME 환경변수가 필요하다 (검증할 etcd 이미지 전체 경로:태그)}"
INIT_IMAGE="${INIT_IMAGE:-docker.io/busybox:1.38.0-uclibc}"
CHART_DIR="${CHART_DIR:-$REPO_ROOT/manifests/helm/etcd/1.1.12}"
TEST_NAMESPACE="${TEST_NAMESPACE:-etcd-test-build}"
RELEASE="${RELEASE:-etcd-build}"
REPLICAS="${REPLICAS:-3}"
WAIT_TIMEOUT="${WAIT_TIMEOUT:-300}"
log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$LOG"; }
# IMAGE_NAME="registry[/path]/repository:tag" → registry / repository / tag 분해.
# 차트가 imageName 단일 필드가 아니라 3분할 필드(image.registry/repository/tag)를
# 쓰기 때문에 필요하다.
split_image() {
local full="$1" tag repo_full registry repository
tag="${full##*:}"
repo_full="${full%:*}"
registry="${repo_full%/*}"
repository="${repo_full##*/}"
echo "$registry" "$repository" "$tag"
}
read -r IMAGE_REGISTRY IMAGE_REPOSITORY IMAGE_TAG <<< "$(split_image "$IMAGE_NAME")"
read -r INIT_REGISTRY INIT_REPOSITORY INIT_TAG <<< "$(split_image "$INIT_IMAGE")"
log "== 사전 확인 =="
for t in kubectl helm python3; do
command -v "$t" >/dev/null || { log "::error:: $t 없음"; exit 2; }
done
[ -d "$CHART_DIR" ] || { log "::error:: 차트 없음: $CHART_DIR"; exit 2; }
if ! kubectl get ns >/dev/null 2>>"$LOG"; then
log "::error:: 클러스터 연결 실패 — kubeconfig/네트워크 확인"
exit 2
fi
log " OK (kubectl context: $(kubectl config current-context 2>/dev/null))"
log " image=$IMAGE_REGISTRY/$IMAGE_REPOSITORY:$IMAGE_TAG"
log " initImage=$INIT_REGISTRY/$INIT_REPOSITORY:$INIT_TAG"
log " replicas=$REPLICAS"
# --- 테스트 네임스페이스 정리 (선-정리·후-정리 공용) --------------------------
cleanup_test_ns() {
kubectl get ns "$TEST_NAMESPACE" >/dev/null 2>&1 || return 0
log "== 정리: $TEST_NAMESPACE =="
if helm status "$RELEASE" -n "$TEST_NAMESPACE" >/dev/null 2>&1; then
helm uninstall "$RELEASE" -n "$TEST_NAMESPACE" >>"$LOG" 2>&1
fi
kubectl -n "$TEST_NAMESPACE" delete pvc --all --wait --timeout=120s >>"$LOG" 2>&1
# storageClass longhorn 은 reclaimPolicy Retain 이라 PVC 삭제만으로는 볼륨이 안
# 지워진다(.claude/pitfalls.md). 이 네임스페이스가 남긴 PV 만 골라 지운다.
local pv
for pv in $(kubectl get pv -o json 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
for p in d.get('items', []):
cr = p['spec'].get('claimRef') or {}
if cr.get('namespace') == '$TEST_NAMESPACE':
print(p['metadata']['name'])
" 2>/dev/null); do
kubectl delete pv "$pv" --ignore-not-found >>"$LOG" 2>&1 \
|| log "::warning:: PV $pv 삭제 실패 — 수동 확인 필요"
done
kubectl delete ns "$TEST_NAMESPACE" --wait --timeout=120s >>"$LOG" 2>&1
log " 완료"
}
RESULT=1
trap 'cleanup_test_ns' EXIT
cleanup_test_ns # 직전 실행이 비정상 종료했을 경우를 대비한 선-정리
log "== etcd: 설치 ($RELEASE/$TEST_NAMESPACE, 차트 기본값 + 이미지·replicas override) =="
kubectl create ns "$TEST_NAMESPACE" >>"$LOG" 2>&1
if ! helm install "$RELEASE" "$CHART_DIR" -n "$TEST_NAMESPACE" \
--set image.registry="$IMAGE_REGISTRY" \
--set image.repository="$IMAGE_REPOSITORY" \
--set image.tag="$IMAGE_TAG" \
--set initImage.registry="$INIT_REGISTRY" \
--set initImage.repository="$INIT_REPOSITORY" \
--set initImage.tag="$INIT_TAG" \
--set replicas="$REPLICAS" \
--wait --timeout "${WAIT_TIMEOUT}s" \
>>"$LOG" 2>&1; then
log "::error:: 차트 설치 실패 — $LOG 확인"
kubectl -n "$TEST_NAMESPACE" get pods >>"$LOG" 2>&1
exit 1
fi
STS_NAME="$(kubectl -n "$TEST_NAMESPACE" get statefulset -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)"
if [ -z "$STS_NAME" ]; then
log "::error:: StatefulSet 을 찾지 못함"
exit 1
fi
log "== StatefulSet 롤아웃 대기 (최대 ${WAIT_TIMEOUT}s): $STS_NAME =="
if ! kubectl -n "$TEST_NAMESPACE" rollout status "statefulset/$STS_NAME" --timeout="${WAIT_TIMEOUT}s" >>"$LOG" 2>&1; then
log "::error:: 타임아웃 — StatefulSet 이 준비되지 못함"
kubectl -n "$TEST_NAMESPACE" get pods -o wide >>"$LOG" 2>&1
kubectl -n "$TEST_NAMESPACE" describe pods >>"$LOG" 2>&1
exit 1
fi
pod0="${STS_NAME}-0"
log "== 컨테이너 실행 계정 확인 (non-root uid 999 기대) =="
# quay.io/coreos/etcd 는 etcd/etcdctl 바이너리만 든 최소 이미지라 id/cat/sh 조차
# 없다(실측: `id` exec 시 "executable file not found in \$PATH"). uid 는 직접 확인할
# 수 없으므로, 아래 quorum health·put/get 이 성공한다는 것 자체를 간접 증거로 삼는다
# — securityContext(runAsUser 999, RO rootfs)가 거부됐다면 kubelet 이 애초에
# 컨테이너를 못 띄웠거나 /data 쓰기가 실패했을 것이다.
if uid="$(kubectl -n "$TEST_NAMESPACE" exec "$pod0" -- id -u 2>>"$LOG")" && [ -n "$uid" ]; then
log " pod0 uid=$uid"
[ "$uid" = "999" ] || log "::warning:: 기대한 uid(999) 와 다름 — CUSTOM-README.md 의 securityContext 가정 재검토 필요"
else
log " id 바이너리 없음(최소 이미지) — 직접 확인 불가. 아래 quorum health/put-get 성공 여부로 간접 확인"
fi
log "== 기능 확인: quorum health =="
health_out="$(kubectl -n "$TEST_NAMESPACE" exec "$pod0" -- etcdctl endpoint health --cluster 2>>"$LOG")"
log " $health_out"
healthy_count="$(echo "$health_out" | grep -c "is healthy" || true)"
if [ "$healthy_count" != "$REPLICAS" ]; then
log "::error:: quorum health 이상 (healthy=$healthy_count / replicas=$REPLICAS)"
exit 1
fi
log "== 기능 확인: put/get 왕복 =="
if ! kubectl -n "$TEST_NAMESPACE" exec "$pod0" -- etcdctl put smoke-test ok >>"$LOG" 2>&1; then
log "::error:: etcdctl put 실패"
exit 1
fi
answer="$(kubectl -n "$TEST_NAMESPACE" exec "$pod0" -- etcdctl get smoke-test --print-value-only 2>>"$LOG")"
log " get smoke-test => '$answer'"
if [ "$answer" = "ok" ]; then
log "== 결과: PASS =="
RESULT=0
else
log "::error:: get 응답 이상 (got: '$answer')"
RESULT=1
fi
exit "$RESULT"