빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.
## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)
모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.
- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*
setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.
## 컨테이너 릴리스 신설
이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.
Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.
## 게이트 정리
- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
`release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
`OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
`:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
(태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
(기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
3,072 → 2,977줄
## 검사가 검사를 못 하고 있던 것들
- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
"examples beside it are the tracked contract, never a real one" 이라고 적어둔
규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
로 읽히지 않게
## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)
`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.
## 남은 문제
- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
`.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
건드리지 않았다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
199 lines
11 KiB
Docker
199 lines
11 KiB
Docker
# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894
|
|
# =============================================================================
|
|
# feature-container-runtime-contract — multi-stage image build
|
|
#
|
|
# Build requirements:
|
|
# docker build -f src/Dockerfile src/ -t caskeleton:local \
|
|
# --build-arg RELEASE_VERSION=1.2.3 \
|
|
# --build-arg BUILD_VERSION=1.2.3+a1b2c3d4e5f6 \
|
|
# --build-arg GIT_SHA=a1b2c3d4e5f6 \
|
|
# --build-arg SOURCE_URL=https://github.com/your-org/your-repo
|
|
#
|
|
# Design decisions (feature-container-runtime-contract D2/D3/D4):
|
|
# D2 — read-only root filesystem: writable mounts must be declared explicitly.
|
|
# D3 — non-root user, JRE-only final stage (no full JDK).
|
|
# D4 — JVM ergonomics via JAVA_TOOL_OPTIONS and ExitOnOutOfMemoryError.
|
|
# =============================================================================
|
|
|
|
ARG RELEASE_VERSION
|
|
ARG BUILD_VERSION
|
|
ARG GIT_SHA
|
|
ARG SOURCE_URL
|
|
|
|
# ---- Stage 1: builder -------------------------------------------------------
|
|
# Uses the full JDK only in the build stage, never in the final image.
|
|
FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder
|
|
|
|
ARG RELEASE_VERSION
|
|
ARG GIT_SHA
|
|
|
|
WORKDIR /build/src
|
|
|
|
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
|
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
|
# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a
|
|
# single structure-preserving glob replaces the former per-module COPY list: new modules are
|
|
# picked up automatically and this stage never drifts out of sync with settings.gradle again.
|
|
# STRICT lock mode still rejects missing/drifted state at verifyDependencyLocks below.
|
|
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
|
COPY gradlew ./
|
|
COPY gradle/ gradle/
|
|
COPY config/ ./config/
|
|
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
|
# The convention plugins, whole. The glob above copies files named build.gradle and gradle.lockfile,
|
|
# which picks up build-logic's own build script and misses the precompiled script plugins beside it —
|
|
# so settings.gradle's `includeBuild('build-logic')` resolved against a build that declared no
|
|
# plugins and every leaf failed on an unknown plugin id, in the dependency-resolution stage below.
|
|
COPY build-logic/ ./build-logic/
|
|
|
|
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
|
# custom task fails on drift; Gradle's diagnostic `dependencies` report can print FAILED entries
|
|
# while still returning exit code 0 and therefore is not a release gate.
|
|
RUN test -n "${RELEASE_VERSION}" \
|
|
&& test -n "${GIT_SHA}" \
|
|
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
|
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
|
|
|
# Copy full source and stage the executable JAR at Gradle's declared Docker output path.
|
|
COPY . .
|
|
RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \
|
|
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
|
|
|
# ---- Layer extraction (D8) --------------------------------------------------
|
|
# Split the uber JAR into Spring Boot's four layers before it reaches the runtime stage.
|
|
#
|
|
# Without this the whole fat JAR — every dependency and the application classes in one file — is a
|
|
# single image layer, so a release that changes one line of application code re-pushes and re-pulls
|
|
# every dependency in the graph. The layers are ordered least- to most-frequently-changed
|
|
# (dependencies, loader, snapshot dependencies, application), which is what makes the expensive
|
|
# layer cacheable across releases.
|
|
#
|
|
# `extract` WITHOUT `--launcher` is the layout Spring Boot 4 documents: a thin application JAR whose
|
|
# manifest Class-Path points at the extracted lib/ directory, rather than a nested-JAR uber JAR the
|
|
# loader has to open and index on every start. It is also the AOT-cache/CDS-friendly layout, which
|
|
# is the layout any later startup-time work would need.
|
|
#
|
|
# Absolute paths on both sides, and no WORKDIR change: DeveloperExperienceContractTest asserts that
|
|
# every Dockerfile names the exact Gradle-staged artifact path rather than selecting a JAR, and the
|
|
# input here is that same fixed path Gradle wrote. Nothing in this stage may pick a JAR by pattern.
|
|
RUN java -Djarmode=tools \
|
|
-jar /build/src/app-bootstrap/build/docker/application.jar \
|
|
extract --layers --destination /build/src/app-bootstrap/build/docker/extracted
|
|
|
|
# ---- Stage 2: runtime image -------------------------------------------------
|
|
# JRE-only slim image (D3: no full JDK in production image).
|
|
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
|
|
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
|
|
|
ARG BUILD_VERSION
|
|
ARG GIT_SHA
|
|
ARG SOURCE_URL
|
|
|
|
# OCI image labels (build-arg placeholders — supply at docker build time).
|
|
LABEL org.opencontainers.image.title="caskeleton" \
|
|
org.opencontainers.image.source="${SOURCE_URL}" \
|
|
org.opencontainers.image.revision="${GIT_SHA}" \
|
|
org.opencontainers.image.version="${BUILD_VERSION}"
|
|
|
|
# A release image without source/version metadata is not an artifact this contract permits.
|
|
RUN test -n "${BUILD_VERSION}" && test -n "${GIT_SHA}" && test -n "${SOURCE_URL}"
|
|
|
|
# ---- Locale / timezone (D4) -------------------------------------------------
|
|
# C.UTF-8 is available in eclipse-temurin:21-jre-jammy without installing extra packages.
|
|
# Do NOT use en_US.UTF-8 — it requires the locales package and may not exist in a slim image.
|
|
ENV TZ=UTC \
|
|
LANG=C.UTF-8 \
|
|
LC_ALL=C.UTF-8
|
|
|
|
# ---- Writable HOME under read-only root fs (D2) -----------------------------
|
|
# The app user is created with --no-create-home and the root filesystem is
|
|
# read-only at runtime. Point $HOME at the writable /tmp tmpfs so libraries that
|
|
# write under $HOME (e.g. java.util.prefs -> ~/.java/.userPrefs, some SDK caches)
|
|
# do not fail with a read-only-filesystem error.
|
|
ENV HOME=/tmp
|
|
|
|
# ---- JVM ergonomics (D4) ----------------------------------------------------
|
|
# -XX:MaxRAMPercentage=75 — use up to 75% of the container memory limit for heap.
|
|
# -XX:+UseContainerSupport — respect cgroup memory limits (default on JDK 10+, explicit here).
|
|
# -XX:+ExitOnOutOfMemoryError — terminate immediately on OOM so the orchestrator can restart.
|
|
# -XX:+HeapDumpOnOutOfMemoryError / -XX:HeapDumpPath — write a heap dump to the writable
|
|
# /var/tmp/heap mount (see tmpfs mounts in compose files, D2).
|
|
# -Dserver.tomcat.basedir=/tmp — redirect Tomcat temp files to /tmp (D2: read-only root fs).
|
|
ENV JAVA_TOOL_OPTIONS="\
|
|
-XX:MaxRAMPercentage=75 \
|
|
-XX:+UseContainerSupport \
|
|
-XX:+ExitOnOutOfMemoryError \
|
|
-XX:+HeapDumpOnOutOfMemoryError \
|
|
-XX:HeapDumpPath=/var/tmp/heap \
|
|
-Dserver.tomcat.basedir=/tmp"
|
|
|
|
# ---- Filesystem layout (D2: read-only root filesystem) ----------------------
|
|
# /var/tmp/heap — heap dump landing zone; must be a writable mount at runtime.
|
|
# /tmp — Tomcat working directory (see JAVA_TOOL_OPTIONS above).
|
|
# Both directories are declared here so tooling is aware of them; at runtime they
|
|
# MUST be mounted as tmpfs (or host volumes) by the orchestrator (see compose files).
|
|
RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
|
|
|
# ---- Non-root user (D3) -----------------------------------------------------
|
|
RUN groupadd --system --gid 1000 app \
|
|
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
|
|
|
# ---- Fileserver storage root ------------------------------------------------
|
|
# Created in the image with the runtime user's ownership and 0750, so a fresh named volume
|
|
# mounted here inherits both. Without it the Fileserver platform's default root does not exist
|
|
# on a read-only root filesystem, and the capability fails on its first upload rather than at
|
|
# startup. This directory is a mount point, not a place to keep data in the image: an unmounted
|
|
# container writes into the container layer and loses everything on replacement.
|
|
RUN mkdir -p /var/lib/backend/files \
|
|
&& chown app:app /var/lib/backend/files \
|
|
&& chmod 0750 /var/lib/backend/files
|
|
VOLUME ["/var/lib/backend/files"]
|
|
|
|
WORKDIR /app
|
|
|
|
# ---- Application layers (D8) ------------------------------------------------
|
|
# One COPY per Spring Boot layer, ordered least- to most-frequently-changed. Each COPY is its own
|
|
# image layer, so a release that only changes application code re-pushes and re-pulls the last one
|
|
# instead of the whole dependency graph. This replaced a single `COPY application.jar app.jar`,
|
|
# under which every release shipped every dependency again because they lived in the same file as
|
|
# the code that changed.
|
|
#
|
|
# All four land in /app: the extracted application.jar is a thin JAR whose manifest Class-Path
|
|
# points at ./lib, which is what the dependencies layer unpacks to.
|
|
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/dependencies/ ./
|
|
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/spring-boot-loader/ ./
|
|
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/snapshot-dependencies/ ./
|
|
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/application/ ./
|
|
|
|
USER app
|
|
|
|
# ---- Ports ------------------------------------------------------------------
|
|
# 8080 — application HTTP port
|
|
# 9001 — management / actuator port, from management.server.port in config/observability.yml
|
|
EXPOSE 8080 9001
|
|
|
|
# ---- Health check -----------------------------------------------------------
|
|
# Targets the actuator readiness probe on the management port (9001).
|
|
#
|
|
# The endpoint is real: config/observability.yml sets management.server.port to 9001 and
|
|
# management.endpoint.health.probes.enabled to true, which is what publishes
|
|
# /actuator/health/readiness. (This block used to carry a note saying the path might 404 because
|
|
# the actuator work lived on an unmerged branch. It has been merged for some time, and a stale
|
|
# warning about a healthcheck is the kind of comment that gets a real red container ignored.)
|
|
#
|
|
# A readiness failure here is a correct UNHEALTHY, not a broken probe: the readiness group includes
|
|
# the datasource, so a container started with no reachable PostgreSQL is genuinely not ready.
|
|
# Kubernetes ignores HEALTHCHECK and uses its own probes against the same path; this exists for
|
|
# docker and Compose.
|
|
#
|
|
# wget is present in eclipse-temurin:21-jre-jammy, so nothing is installed for it.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
|
CMD wget --no-verbose --tries=1 --spider \
|
|
http://localhost:9001/actuator/health/readiness || exit 1
|
|
|
|
# ---- Entrypoint -------------------------------------------------------------
|
|
# The extracted thin JAR, not the uber JAR the builder produced. It carries only application classes
|
|
# and a Class-Path pointing at ./lib, so the loader does not open and index a nested-JAR archive on
|
|
# every start, and the layout stays AOT-cache/CDS friendly for any later startup work.
|
|
ENTRYPOINT ["java", "-jar", "application.jar"]
|