fix: 하네스 제거 및 keycloak 문서 보강

This commit is contained in:
DongHyeonka
2026-07-25 12:53:13 +09:00
parent 6c53ded9cb
commit d71669eb59
2329 changed files with 138239 additions and 172816 deletions
@@ -1 +0,0 @@
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-container-runtime-contract.md
@@ -0,0 +1,444 @@
---
title: branch / feature-container-runtime-contract
source_type: branch-note
status: raw
branch: feature-container-runtime-contract
parent_branch:
related_projects: [ca-skeleton]
governing_docs: [wiki/projects/ca-tmpl/runtime-container-health-migration]
tags: [branch, ca-skeleton, container, runtime, jvm]
created: 2026-05-22
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-030
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-030
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CONTAINER-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
contract_packet_sha256: 4ea821a22a546fda0f4407358f63672198be276985d938779551dfde818ab5e8
---
# branch: feature-container-runtime-contract
> Layer: `raw/branch-notes/` — JVM application이 container 환경에서 예측 가능하게 동작하기 위한 runtime 기준을 정의합니다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (governing: [[wiki/projects/ca-tmpl/runtime-container-health-migration]] §Container 슬라이스) 의 결정/근거/금지 사항을 정제한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: non-root·memory·health container contract test가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CONTAINER-001@1` | Temurin JRE slim이 default이며 distroless는 debug/runbook 보강 후 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
같은 Spring application이라도 container memory, timezone, signal, filesystem, healthcheck 기준이 없으면 서버별로 다르게 실패합니다. 이 branch는 skeleton의 runtime contract를 고정합니다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- JVM memory/container limit 기준.
- timezone/locale 기준.
- graceful shutdown signal 기준.
- healthcheck command 기준.
- writable filesystem 최소화 기준.
- temp directory/resource exhaustion 기준.
### 제외 범위
- Kubernetes manifest 작성.
- Helm chart 작성.
- cloud-specific autoscaling.
- health probe **endpoint shape / group membership** (→ [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] owner; 본 branch 는 manifest-side probe **timing field** 만).
- app-side graceful shutdown **ordering invariant** (→ sibling D4 owner; 본 branch 는 manifest-side budget 값 owner).
## 근거 (필수, 최소 1개+)
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/container-distroless-google-github]] | 보안 surface 축소 vs in-container 디버깅 손실 |
| [[raw/official-docs/container-alpine-java-musl-tradeoffs]] | image 크기 작음 vs native lib/DNS resolver 호환성 risk |
| [[raw/official-docs/container-graalvm-native-image-spring-boot]] | cold start/메모리 우위 vs reflection 메타데이터 + 빌드 시간 + peak throughput 손실 |
| [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]] | 우아한형제들 Spring Native 도입기, hybrid 채택 결론 |
| [[raw/official-docs/k8s-application-security-checklist-readonly-fs]] | D2 — Kubernetes 공식 checklist 가 `readOnlyRootFilesystem: true` 를 "most applications" 에 적용되는 base security hardening 항목으로 명시 (K8S-ASC-C1, K8S-ASC-C2) |
| [[raw/official-docs/k8s-pod-security-standards-restricted]] | D2 — Restricted profile 이 emptyDir 을 허용 볼륨으로 명시 (K8S-PSS-C2); readOnlyRootFilesystem 이 현행 Restricted admission field 목록에 없음 확인 (K8S-PSS-C3) |
| [[raw/official-docs/redhat-openjdk-container-awareness-java17]] | D4 — `-XX:MaxRAMPercentage=75` rationale: MaxRAMPercentage 기본값 25%, cgroup v1/v2 지원 JDK 버전 경계, container limit → GC/heap/thread-pool ergonomics 영향 (RHAT-JCONT-C1~C4) |
| [[raw/official-docs/openjdk-jdk-8196595-container-support]] | D4 — `UseContainerSupport` 기본 활성(default true) + `-XX:{Initial,Max,Min}RAMPercentage` 플래그가 container/system 메모리 대비 비율로 Java heap 크기를 제어함을 Oracle 공식 JDK 문서로 증명 (JDK-8196595-C1~C5) |
| [[raw/official-docs/kubernetes-pod-lifecycle-termination]] | D5 — terminationGracePeriodSeconds(default 30s) + preStop → SIGTERM → grace 만료 시 SIGKILL 순서 (K8S-POD-LC-C1~C5) |
| [[raw/official-docs/spring-boot-graceful-shutdown-reference]] | D5 — Spring graceful shutdown 기본 활성 + 기존 요청 완료/신규 거부 + `spring.lifecycle.timeout-per-shutdown-phase` (SB-GS-C1~C5) |
| [[raw/official-docs/config-12-factor-app-config]] | D1 — config 는 deploy 마다 가변·code 는 불변(deploy 간 가변성 분리) + config 는 env vars 에 저장하는 12-factor Factor III *원칙* (TWELVE-FACTOR-CONFIG-C1, C2) |
| [[raw/official-docs/container-stdout-logging-12factor-official]] | D1 — 실행 환경(=deployment manifest)이 runtime 관심사를 소유하고 앱은 설정 불가하다는 12-factor Logs(XI) 책임 분리 *원칙* 보강 (LOG-12F-C4) |
| [[raw/company-tech-blogs/runtime-health-datadog-engineering-graceful-shutdown]] | D5 — preStop 5s + drain + grace 비율 권장치 (RH-DD-C1~C4, `needs-confirmation` 강도) |
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-D: Container Runtime)
본 branch의 Temurin JRE slim + `-XX:MaxRAMPercentage=75` + UTC/UTF-8 + graceful shutdown(app 20s + preStop 5s + grace 35s) 결정에 대한 외부 source 조사. 비교 분석은 (예정) `wiki/concepts/runtime-container-health-migration.md` 참조.
- **채택 결정 (Temurin JRE slim baseline)**:
- (Spring Boot 3 JVM 기본 정책 정합)
- **검토한 대안**:
- **대안 1: Distroless (Google)** — [[raw/official-docs/container-distroless-google-github]] (보안 surface 축소 vs in-container 디버깅 손실)
- **대안 2: Alpine + musl libc** — [[raw/official-docs/container-alpine-java-musl-tradeoffs]] (image 크기 작음 vs native lib/DNS resolver 호환성 risk)
- **대안 3: GraalVM Native Image + Spring Boot Native** — [[raw/official-docs/container-graalvm-native-image-spring-boot]] (cold start/메모리 우위 vs reflection 메타데이터 + 빌드 시간 + peak throughput 손실)
- **사례**: [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]] — 우아한형제들 Spring Native 도입기, hybrid 채택 결론
- **비교 핵심**: Temurin JRE slim은 운영 친숙도/디버깅 우선. Distroless는 보안↑/디버깅↓. GraalVM native-image는 startup·메모리 우위지만 reflection 비용 + peak throughput 손실 — 우아한형제들 사례도 hybrid 채택. ca-tmpl baseline은 skeleton 단계에 적합, native-image는 cold start 민감 service 진입점.
- **2026-06-14 보강 (D2/D4 자동조사 — `/branch-spec`)**:
- **D2 (read-only root fs)**: K8s 공식 Application Security Checklist + NSA/CISA Hardening Guide 가 read-only root fs + tmpfs/emptyDir writable mount 패턴을 권고. 단 PSS Restricted admission 은 `readOnlyRootFilesystem` 을 자동 강제하지 않음(K8S-PSS-C3) → 명시 securityContext 또는 별도 policy engine 필요. 대안: writable root fs(레거시 path 조사 임시), 완전 read-only no-mount(non-JVM static binary 한정 — JVM 은 startup write 로 broken).
- **D4 (container-aware JVM)**: `MaxRAMPercentage` 비율(cgroup limit 추적) vs 절대 `-Xmx`(고정·재조정 필요) vs JVM 기본 25%(Spring Boot 단일 프로세스 과소배정 — 금지). 75% 는 vendor 범위(Red Hat 50→80%) 안의 팀 관행. locale 은 `C.UTF-8` 권고(Debian slim 내장).
## TODO
> TODO drained — 결정은 error.category enum 표 / MDC Key Standard 표 / error.details JSON shape 참조
## 진행 중 메모
- 2026-06-14 (`/branch-spec`): D2(read-only root fs)·D4(container-aware JVM) 외부 근거 자동조사 + 아카이브(K8s checklist/PSS, OpenJDK, Red Hat). D1(12-factor)·D5(K8s pod lifecycle + Spring graceful shutdown) 기존 raw source wire. `## 구현 가이드`·`## 엣지·실패·의존`·`## Audit & Findings` 신설. ca-tmpl ground-truth 대조에서 발견한 drift(§Audit) 는 사용자 결정 영역이라 자동 rewrite 하지 않고 권고만.
- 2026-06-15 (ca-implementer): **구현 완료 (locally-verified)**`src/Dockerfile` (multi-stage, Temurin JRE jammy, non-root app user, JAVA_TOOL_OPTIONS 전체 셋, C.UTF-8, EXPOSE 8080/9001), `src/.dockerignore` (신규 생성), `docker-compose.yml` (read_only+tmpfs+mem_limit 512m+stop_grace_period 35s), `docker-compose.dev.yml`, `docker-compose.local.yml` 작성 완료. `OperationalError.JVM_OOM` (INTERNAL/500/false) 신규 추가 — `actually-implemented`. `ContainerRuntimeOomContractTest` (app-bootstrap) 신규 — 소프트 runbook 파일 체크 패턴. `./gradlew :shared-contract:test` + `./gradlew :app-bootstrap:test` PASS. **SHUTDOWN_BUDGET_DRIFT 주의**: compose `stop_grace_period=35s`, `APP_SERVER_SHUTDOWN_TIMEOUT=20s` 를 canonical 값으로 사용; src/.env 의 30s 값(env-driven-config 브랜치 소유)과 drift 존재 — compose 파일에 주석으로 명시. LOCALE_DRIFT 해소: C.UTF-8 로 구현. Dockerfile HEALTHCHECK 교차 기능 커플링(actuator 브랜치 소유) — 주석으로 명시.
- 2026-06-15 (`/verify`): docker 라이브 표면 검증 — 이미지 빌드 + JVM ergonomics(cgroup heap 추적) + non-root + C.UTF-8/UTC + read-only fs/tmpfs 모두 PASS(§Audit DOCKERFILE_IMPLEMENTED). 2건 보정: ① JVM_OOM 런타임 emit 은 앱이 하지 않고 observability 로 위임(문구 정합, §구현 가이드 §5 + §Audit OOM_LOG_LOSS), ② `$HOME` read-only fs write 위험 발견 → Dockerfile `ENV HOME=/tmp` 추가.
- 2026-06-15 (ca-quality-reviewer advisory fixes): **2건 보안·신뢰성 픽스 적용**. ① `docker-compose.local.yml``${POSTGRES_PASSWORD:-changeme}` 2곳(`db` 서비스 + `app` 서비스 datasource) 을 required-variable form `${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your local .env — no default provided}` 로 교체(no-default-ships) — AGENTS.md 하드코딩 비밀 금지 준수. ② `src/Dockerfile` 의존성 warm-up 라인 `./gradlew dependencies ... 2>/dev/null || true``2>/dev/null || true` 제거(fail-fast) — CI network-restricted 환경에서 dependency resolution 실패를 조용히 삼키지 않도록 수정. `./gradlew :shared-contract:test :app-bootstrap:test --tests '*ContainerRuntimeOomContractTest'` PASS 확인.
## Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
| --- | --- | --- |
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
## 결정 사항
- 2026-05-22: runtime 기준은 application code와 deployment manifest 사이의 계약으로 둠.
- 2026-05-22: prod container는 writable path를 최소화하고 temp directory를 명시해야 함.
- 2026-05-22: base image 기본값은 Temurin JRE slim. distroless는 debug/runbook 보강 후 허용.
- 2026-05-22: JVM 기본값은 `-XX:MaxRAMPercentage=75`, timezone UTC, locale `en_US.UTF-8`.
- 2026-05-22: deployment manifest sync는 이 branch가 owner이며 `terminationGracePeriodSeconds`, `preStop`, app shutdown timeout, health probes를 한 표로 관리.
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지.
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | runtime 기준은 application code 와 deployment manifest 사이의 계약 | runtime tunable(memory/timezone/shutdown/probe)이 환경마다 달라질 수 있으면 → 이미지가 아니라 manifest/env 로 외부화. 빌드 시 고정 + 환경 불변 값이면 → 이미지에 baked 허용(예외) | `raw/official-docs/config-12-factor-app-config.md#TWELVE-FACTOR-CONFIG-C1` (config 는 deploy 마다 가변, code 는 불변 — deploy 간 가변성 분리), `#TWELVE-FACTOR-CONFIG-C2` (config 를 env vars 에 저장); `raw/official-docs/container-stdout-logging-12factor-official.md#LOG-12F-C4` (실행 환경이 runtime 관심사를 완전 관리, 앱 설정 불가 — 동일 방법론 보강) | `official-reference` (12-factor Config/Logs 책임 분리 *원칙*) + `team-policy` (구체적 owner 분배) | 12-factor 는 config↔code, app↔실행환경 분리 *원칙* 만 지지 — "이 branch 가 manifest sync owner" 라는 구체적 책임 분배는 외부 표준 부재(team-policy) |
| D2 | prod container 는 writable path 최소화 + read-only root filesystem 의무화 + temp directory 명시 | prod K8s JVM 컨테이너 → read-only root fs + tmpfs/emptyDir writable mount. 레거시 앱이 다수 path 에 write 하고 path 매핑 미완료면 → writable root fs 임시(배포 전 path 조사 단계, prod 금지). non-JVM static binary 면 → no-mount 완전 read-only 가능(JVM 은 startup write 로 broken) | `raw/official-docs/k8s-application-security-checklist-readonly-fs.md#K8S-ASC-C1` (readOnlyRootFilesystem: true 명시적 권고), `#K8S-ASC-C2` (base security hardening — most applications), `raw/official-docs/k8s-pod-security-standards-restricted.md#K8S-PSS-C2` (emptyDir = Restricted 허용 볼륨) | `official-vendor-doc` | `K8S-PSS-C3`: readOnlyRootFilesystem 은 PSS Restricted admission 이 *자동 강제하지 않음* — securityContext 명시 또는 별도 policy engine 필요. ca-tmpl 의 실제 write-path 전부 emptyDir/tmpfs redirect 됨은 구현 검증 필요(Claims To Verify) |
| D3 | base image default = Temurin JRE slim, distroless 는 debug runbook 보강 후 허용 | 운영/디버깅 친숙도 우선 → Temurin JRE slim. 보안 surface 최소화 + 디버깅 runbook 보강 완료 → distroless. cold-start/메모리 민감 + reflection 적은 service → GraalVM native 검토 | `raw/official-docs/container-distroless-google-github.md#CDG-C1` (distroless = app + runtime only, no shell), `#CDG-C5` (`:debug` variant 는 busybox shell 제공) | `official-vendor-doc` (Google distroless 의 공식 trade-off) | Google 의 `CDG-C2` "best practice" 는 self-claim — industry-wide consensus 아님 |
| D4 | JVM 기본값 = `-XX:MaxRAMPercentage=75`, timezone UTC, locale en_US.UTF-8 | container memory limit 이 환경마다 다르거나 변동 → MaxRAMPercentage(비율, cgroup 추적). 메모리 프로파일 고정 + 절대값 고정 규정 → `-Xmx`. (무설정 기본 25% 는 Spring Boot 단일 프로세스 과소배정 → 금지) | `raw/official-docs/openjdk-jdk-8196595-container-support.md#JDK-8196595-C1` (UseContainerSupport 기본 활성), `#JDK-8196595-C3` (MaxRAMPercentage = heap 최대 % of memory, 기본 25%), `raw/official-docs/redhat-openjdk-container-awareness-java17.md#RHAT-JCONT-C4` (container limit → GC/heap/thread-pool ergonomics) | `official-vendor-doc` (C1+C3) + `team-convention` (75% 수치) | 75% 를 official best practice 로 표현 금지 — vendor 범위 70~80% 내 팀 관행. **locale `en_US.UTF-8` → `C.UTF-8` 수정 권고** (§Audit LOCALE_DRIFT) |
| D5 | deployment manifest sync owner = 본 branch (terminationGracePeriodSeconds, preStop, app shutdown timeout, health probe timing 일원화) | app-side(Spring graceful) 와 manifest-side(K8s grace/preStop) 가 양쪽에 걸칠 때 → 한 표로 일원화 owner 필요. 단일 비-K8s 배포면 manifest sync 표 불필요(예외) | `raw/official-docs/kubernetes-pod-lifecycle-termination.md#K8S-POD-LC-C1` (terminationGracePeriodSeconds default 30s + preStop→SIGTERM), `#K8S-POD-LC-C3` (kubelet→SIGTERM to PID1), `#K8S-POD-LC-C2` (grace 만료 시 SIGKILL), `raw/official-docs/spring-boot-graceful-shutdown-reference.md#SB-GS-C3` (기존 요청 완료/신규 거부), `#SB-GS-C4` (timeout-per-shutdown-phase) | `official-vendor-doc` (K8s + Spring 공식) | budget 수치(35s/20s/5s)는 Datadog 사례(`RH-DD-C2` `needs-confirmation`) 기반 권장치 — 실측 없음. **env-key `APP_SERVER_SHUTDOWN_TIMEOUT` default 30s 와 본 표 20s drift** (§Audit SHUTDOWN_BUDGET_DRIFT) |
> Note: Alpine + musl 대안의 risk 는 `raw/official-docs/container-alpine-java-musl-tradeoffs.md#CAJM-C1`~`CAJM-C5` 가 직접 지지하며, ca-tmpl Temurin JRE slim 채택의 negative-evidence 역할. Distroless 의 image size 이점 (`CDG-C4`) 은 `static-debian13` 기준이며 Java distroless variant 는 더 큼 — 본 branch 의 baseline 비교 시 주의. `container-woowahan-spring-native-tradeoffs` 는 `company-tech-blog` 카테고리이므로 GraalVM hybrid 결론은 `company-case-study` 강도만 가지며 official best practice 로 표현 금지.
## 구현 가이드
> *결정 (Decisions)* 이 "*무엇*" 이라면, 본 §는 "*어디에 어떻게*" 의 사전 명세. 3-rule(CLAUDE.md §15.5): R1 각 cell 은 Decision ID + Supporting Claim reference, R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, R3 본 branch 범위 밖 detail 은 위임(§Audit).
> **코드 상태 주의**: ca-tmpl `src/Dockerfile` 은 빈 파일 → 본 § 의 base image/JVM/securityContext detail 은 전부 `planned`. `server.shutdown`/`timeout-per-shutdown-phase` config 만 `actually-implemented`(env-key 배선).
### 1. Base image + Dockerfile (Trace: D3 · CDG-C1/C5 · CAJM-C1~C5)
> **Trace**: D3. Temurin JRE slim 채택 = distroless/alpine-musl/GraalVM 대안 검토 후 baseline.
>
> - **UNSUPPORTED_IMPL_DECISION**: multi-stage 구조, JRE 버전 핀(예: `eclipse-temurin:21-jre-jammy`), non-root UID 값은 무출처 팀 선택 — Dockerfile 미작성이라 전부 `planned`.
| 항목 | 명세 | 상태 | Anchor |
|---|---|---|---|
| base image | Temurin JRE slim (distroless = debug runbook 보강 후 허용) | `planned` (src/Dockerfile empty) | D3 / CDG-C1 |
| USER | non-root (K8S-ASC-C3: privileged:false + drop ALL caps 와 정합) | `planned` | K8S-ASC-C3 |
| forbidden | prod 에서 root full JDK image | — | D3 |
### 2. JVM ergonomics + locale (Trace: D4 · JDK-8196595-C1/C3 · RHAT-JCONT-C1/C4)
> **Trace**: D4. UseContainerSupport(default-on) + MaxRAMPercentage(cgroup 비율) 채택.
>
> - **UNSUPPORTED_IMPL_DECISION**: `75%` 수치는 vendor 범위(70~80%) 내 팀 관행 — non-heap(metaspace/code cache/thread stacks/direct buffer, RHAT-JCONT-C4)이 25% 안이라는 가정. `HeapDumpPath` naming `<pod>-<ts>` 패턴, `emptyDir.sizeLimit`(heap dump 누적 eviction 방지) 값 미정.
```
-XX:MaxRAMPercentage=75
-XX:+UseContainerSupport # JDK 10+ default (JDK-8196595-C1), 명시 권장
-XX:HeapDumpPath=/var/tmp/heap/<pod>-<ts>.hprof # emptyDir mount 의무 (§3)
-XX:+ExitOnOutOfMemoryError # → §5 OOM_LOG_LOSS 주의
TZ=UTC
LANG=C.UTF-8 # ⚠️ 현행 결정문은 en_US.UTF-8 — §Audit LOCALE_DRIFT, C.UTF-8 권고
```
- 컨테이너 memory limit **반드시 설정** — 미설정 시 MaxRAMPercentage 가 host RAM 기준(RHAT-JCONT-C4) → 과대/과소 할당.
### 3. Filesystem policy: read-only root fs + writable mounts (Trace: D2 · K8S-ASC-C1 · K8S-PSS-C2/C3)
> **Trace**: D2. read-only root fs + 필수 경로만 tmpfs/emptyDir.
>
> - **UNSUPPORTED_IMPL_DECISION**: 경로별 `emptyDir` vs `tmpfs(medium: Memory)` 선택은 trade-off(tmpfs=pod 메모리 소비/≈0 latency vs emptyDir=node disk I/O). `server.tomcat.basedir=/tmp` redirect 는 D2 도출 *필수 수반결정*(미설정 시 read-only root fs 에서 Tomcat work dir write fail → startup CrashLoop — D2 자동조사 finding).
> - **OUT_OF_BRANCH_SCOPE**: PSS Restricted admission *enforcement 설정* 자체(policy engine 배선)는 security baseline branch 영역 — 본 branch 는 securityContext 필드 값만.
| 항목 | 명세 | 상태 | Anchor |
|---|---|---|---|
| root fs | `securityContext.readOnlyRootFilesystem: true` | `planned` | K8S-ASC-C1 |
| heap dump path | `/var/tmp/heap` emptyDir mount | `planned` | K8S-PSS-C2 (emptyDir 허용) |
| temp/upload | `/tmp` tmpfs mount | `planned` | K8S-PSS-C2 |
| Tomcat work dir | `server.tomcat.basedir=/tmp` (또는 `java.io.tmpdir=/tmp`) | `planned` (수반결정) | D2 자동조사 finding |
| enforcement 주의 | readOnlyRootFilesystem 은 PSS Restricted 가 자동 강제 *안 함* → 명시 securityContext 필수 | — | K8S-PSS-C3 |
### 4. Deployment manifest sync (Trace: D5 · K8S-POD-LC-C1/C2/C3 · SB-GS-C3/C4)
> **Trace**: D5. app-side(Spring) ↔ manifest-side(K8s) timeout/probe 일원화. 본 branch = manifest-side 값 owner.
>
> - **UNSUPPORTED_IMPL_DECISION**: 35s/20s/5s/10s 조합은 Datadog 사례(`RH-DD-C2` `needs-confirmation`) 기반 ca-tmpl 운영 가정 — 실측 없음.
> - **OUT_OF_BRANCH_SCOPE**: shutdown *ordering invariant* (SIGTERM→readiness DOWN→drain→exit) 는 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D4(app-side) owner. `APP_SERVER_SHUTDOWN_TIMEOUT` env-key *값/validation* 은 [[raw/branch-notes/feature-env-driven-runtime-configuration]] owner — 본 branch 는 그 값을 consume.
| field | default | 위임/상태 | Anchor |
| --- | --- | --- | --- |
| app shutdown timeout | 20s (`server.shutdown=graceful` + `spring.lifecycle.timeout-per-shutdown-phase`) | config `actually-implemented`(env-key 배선); **값 drift** — env default 30s (§Audit) | SB-GS-C3/C4 / `app-bootstrap/.../application.yml:205,211` |
| `preStop` hook sleep | 5s | 본 branch owner | K8S-POD-LC-C1 |
| `terminationGracePeriodSeconds` | 35s | 본 branch owner | K8S-POD-LC-C1 (default 30s 를 override) |
| safety margin | 10s (drain late completion 흡수) | 본 branch — env default 30s 적용 시 0 으로 붕괴(§Audit) | — |
| readiness failure before drain | required | 위임 sibling(ordering) | K8S-POD-LC-C3 |
| startup probe | required when migration/startup validation enabled | 위임 sibling(endpoint shape) | — |
> **Runtime Defaults 요약표** (위 표의 정책 한 줄 view):
>
> | item | default | allowed | forbidden |
> | --- | --- | --- | --- |
> | base image | Temurin JRE slim | distroless with debug runbook | root full JDK image in prod |
> | JVM memory | `-XX:MaxRAMPercentage=75` | workload-specific override | container limit ignored |
> | timezone | UTC | none | server default timezone |
> | shutdown | SIGTERM -> readiness down -> drain -> exit | forced kill after grace | SIGKILL before app timeout |
> | manifest sync | one table for app timeout/probes/preStop | platform-specific overlay | app/manifest timeout mismatch |
### 5. OOM 분류 + JVM_OOM error code (Trace: D4 · registry error-codes.yaml · K8S-POD-LC-C2)
> **Trace**: D4(ExitOnOutOfMemoryError) + registry `JVM_OOM`. **`JVM_OOM` 은 본 branch 가 registry owner** — `docs/registries/error-codes.yaml` L213: `code: JVM_OOM`, `category: INTERNAL`, `http_status: 500`, `retryable: false`, `owner_branch: feature-container-runtime-contract`, `owner_layer: infrastructure`, `runbook_link: runbook://runtime/jvm-oom`, `required_test: contract-verification:container-runtime-oom`. **enum/registry 분류 = `actually-implemented`** (`OperationalError.JVM_OOM` = INTERNAL/500/false + parity test, 2026-06-15 GREEN). **런타임 구조화 emit(`error.code=JVM_OOM` 로그)은 미배선 — 설계상 위임** (아래 결정 + §Audit OOM_LOG_LOSS).
>
> - **결정 (2026-06-15)**: JVM_OOM 구조화 로그는 앱이 emit 하지 않음 — `-XX:+ExitOnOutOfMemoryError` 가 OOM 즉시 abort 하여 앱 핸들러(shutdown hook/UncaughtExceptionHandler)로 안정 emit 불가. 런타임 구분 신호 = JVM 네이티브 OOM stderr + exit 137 + heap dump. 구조화 `error.code=JVM_OOM` 로그-기반 alert 는 observability 브랜치로 위임. enum 은 분류 SSOT 로만 유지.
- container exit 137 (SIGKILL) → OOMKilled (container OOM, kubelet 결정, K8S-POD-LC-C2).
- JVM `OutOfMemoryError``-XX:+ExitOnOutOfMemoryError` 로 137 exit + `-XX:+HeapDumpOnOutOfMemoryError``/var/tmp/heap` 에 heap dump.
- 두 케이스 모두 exit 137 → **구분 신호 = heap dump 유무 + JVM 네이티브 OOM stderr ("Terminating due to java.lang.OutOfMemoryError")**. kubelet OOMKill 은 둘 다 없음.
- ⚠️ **OOM_LOG_LOSS (해소 — 위임 결정)**: 앱이 `error.code=JVM_OOM` 을 직접 emit 하지 않음(ExitOnOutOfMemoryError 가 핸들러보다 먼저 abort — 의도된 설계). 구조화 alert 는 observability log-pattern(네이티브 OOM msg + exit 137)으로 위임. enum 은 분류 코드로 유지. §Audit 참조.
### 6. (위임) Health probe endpoint standard — OUT_OF_BRANCH_SCOPE
> **endpoint shape / group membership 은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] §1 SSOT.** 아래는 manifest-side 참조용 mirror — 값 변경 시 sibling 이 authoritative. 본 branch 는 manifest 의 probe **timing field** 만 owns.
- liveness: `GET /actuator/health/liveness` (deadlock/메모리 한정 검사)
- readiness: `GET /actuator/health/readiness` (dependency status)
- startup: `GET /actuator/health/startup` (migration/validation 진행 중)
- single-probe timeout: liveness 1s / readiness 2s / startup 30s.
- startup probe total budget(failureThreshold × periodSeconds = 150s)는 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] SSOT.
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/타 계약 의존.
- **실패·엣지 경로**:
- **read-only root fs + unmounted write path**: `/tmp` mount 누락 시 Spring Boot embedded Tomcat startup write → `Permission denied` → startup probe failureThreshold → CrashLoopBackOff. 포착: dev/staging 에서 `readOnlyRootFilesystem: true` + smoke test (Claims To Verify). 사전 식별: `strace -e trace=open,openat,creat` 로 write syscall 추적.
- **shutdown budget > terminationGracePeriodSeconds**: grace 만료 시 SIGKILL → inflight 유실(K8S-POD-LC-C2). 본 표 app 20s + preStop 5s = 25s ≤ grace 35s 이나, **env default 30s 적용 시 30+5=35=grace → margin 0**(§Audit SHUTDOWN_BUDGET_DRIFT).
- **ExitOnOutOfMemoryError 즉시 exit → JVM_OOM log flush 손실** 가능(audit 2026-05-25 #4.28).
- **exit 137 모호성**: kubelet OOMKill(SIGKILL) vs JVM OOM(ExitOnOutOfMemoryError 137) 둘 다 137 → log `error.code=JVM_OOM` 유무로만 구분.
- **MaxRAMPercentage non-heap spike**: metaspace/direct buffer 급증 → 75% heap + 25% non-heap 가정 초과 → cgroup limit 초과 → OOMKill(RHAT-JCONT-C4).
- **container memory limit 미설정**: MaxRAMPercentage 가 host RAM 기준 → 과대/과소 할당.
- **heap dump 누적**: `/var/tmp/heap` emptyDir 이 node ephemeral storage quota 초과 → pod eviction. `emptyDir.sizeLimit` 미설정 risk.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] `D2``APP_SERVER_SHUTDOWN`(default graceful) / `APP_SERVER_SHUTDOWN_TIMEOUT`(default **30s**, validation `spring_duration_shorthand_le_termination_grace`) env-key consume. 본 branch 의 `terminationGracePeriodSeconds` 가 이 값 ≥ 여야 함.
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] `D4`/`§4` — graceful shutdown *ordering invariant*(app-side) owner; `§6` 에서 `TZ=UTC` 를 본 branch 로 위임. 본 branch = manifest-side 값 + container env owner.
- [[raw/branch-notes/feature-migration-startup-contract]] — startup probe budget(150s) 및 startup validation 은 그쪽 owner; 본 branch 는 manifest 의 startup probe *존재* 만.
- registry `error-codes.yaml` `JVM_OOM`**본 branch owner**(INTERNAL/500/retryable=false/owner_layer=infrastructure/required_test=contract-verification:container-runtime-oom).
- registry `metrics.yaml` `jvm.memory.used`/`jvm.gc.pause` (owner `feature-metrics-alerting-contract`) — OOM/heap alert 연계(`heap used/max > 0.85 for 10m`).
## 테스트 계약
- inflight request 처리 검사: `server.shutdown=graceful` + `spring.lifecycle.timeout-per-shutdown-phase` property가 명시되어 있어야 함. 측정 방법: `./gradlew bootRun``curl localhost:8080/long-running` 호출 + SIGTERM 보내고 응답 도착 timeout < 25s 이내 verify. property 누락 또는 25s 초과 시 fail. (값 drift 주의 — §Audit SHUTDOWN_BUDGET_DRIFT)
- timezone이 서버 default에 암묵 의존하면 실패.
- temp cleanup 검사: `APP_FILE_UPLOAD_ENABLED=true`이면 다음 3가지 cleanup 메커니즘이 모두 활성: (a) try-with-resources via `MultipartFile.transferTo` cleanup (b) startup sweeper bean (`OrphanTempFileSweeper`) 등록 — `/var/tmp/upload/*` 1시간 초과 파일 삭제 (c) JVM shutdown hook. 측정 방법: 1h-old file을 `/var/tmp/upload/`에 두고 application 재시작 → 5분 이내 파일 삭제 verify. (주의: `OrphanTempFileSweeper` 는 ca-tmpl `src/` 에 미존재 → `planned`)
- app shutdown timeout이 manifest termination grace보다 길면 실패.
- OOM 분류 검사: container exit code 137(SIGKILL) → `OOMKilled` (container OOM, kubelet); JVM `OutOfMemoryError``-XX:+ExitOnOutOfMemoryError`로 137 exit + `/var/tmp/heap` heap dump. 측정 방법: `-Xmx16m`로 강제 JVM OOM 트리거 → exit code 137 + heap dump 파일 생성 verify (앱은 `error.code=JVM_OOM` 을 직접 emit 하지 않음 — 구조화 alert 는 observability log-pattern). required_test `contract-verification:container-runtime-oom` 은 enum↔registry parity 를 검증(2026-06-15 GREEN).
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| Spring Boot graceful shutdown 이 20s 내 inflight request 처리 완료 | `server.shutdown=graceful` + `timeout-per-shutdown-phase` 의 실제 동작은 endpoint 로직에 따라 달라짐 | `./gradlew bootRun` + `curl /long-running` 호출 + SIGTERM → 응답 도착 timeout < 25s verify | `planned` |
| `-XX:MaxRAMPercentage=75` 가 container memory limit 을 정확히 인식 | JDK 10+ `UseContainerSupport` 기본값이 모든 cgroup 환경에서 정상 동작한다는 직접 보장 부재 (cgroup v2 는 11.0.16+/17.0.4+/21 — RHAT-JCONT-C3) | container memory limit 변화 시 `Runtime.getRuntime().maxMemory()` 가 75% 로 변화 verify; `-Xlog:os+container=trace` 로 cgroup 인식 확인 | `needs-confirmation` |
| JVM OOM → exit 137 + `/var/tmp/heap` heap dump 생성 (앱 구조화 emit 없음 — 설계상 위임) | ExitOnOutOfMemoryError 가 핸들러보다 먼저 abort → 앱 emit 불가; 구분은 heap dump + 네이티브 msg | `-Xmx16m` 강제 OOM → exit 137 + heap dump 파일 존재 verify (앱 부팅 필요 — 단독 미검증) | `planned` |
| 컨테이너에 `C.UTF-8` locale 존재 + JVM `file.encoding=UTF-8` | Temurin JRE slim(Debian) 에 C.UTF-8 내장 여부 + en_US.UTF-8 은 locales 패키지 필요 — minimal image 에서 미존재 가능 | `docker run <img> locale` + `java -XshowSettings:properties 2>&1 \| grep file.encoding` verify | `planned` |
| readiness failure → drain 순서가 SIGTERM 처리 시 자동 보장 | `preStop` sleep 5s + readiness probe cache delay 일치 보장 부재 | k8s 환경에서 SIGTERM 시 readiness false 전환 후 drain 시작 트레이스 verify | `planned` |
| temp file cleanup (3 메커니즘) 이 모두 활성 + 누락 없음 | try-with-resources / startup sweeper / shutdown hook 중 하나만 누락되어도 leak (`OrphanTempFileSweeper` 미구현) | 1h-old file 을 `/var/tmp/upload/` 에 두고 재시작 → 5분 이내 삭제 verify | `planned` |
| `$HOME`(/home/app) write 가 read-only root fs 에서 실패하지 않는다 | useradd --no-create-home + read-only fs → `java.util.prefs`(`~/.java/.userPrefs`) 등 `$HOME` write 라이브러리 실패 가능 (2026-06-15 docker 검증서 발견 → Dockerfile `ENV HOME=/tmp` 로 mitigate) | 앱 부팅 후 prefs/SDK 의 `$HOME`(=`/tmp` tmpfs) write 성공 + read-only-fs WARN 부재 verify | `planned` |
| container exit 137 (SIGKILL by kubelet) 와 JVM OOM (137 by ExitOnOutOfMemoryError) 가 구분 가능 | 두 케이스 모두 exit 137 → **heap dump 유무 + JVM 네이티브 OOM msg** 로 구별(앱 구조화 로그 아님) | cgroup limit 초과(OOMKill, dump 없음) vs JVM heap 한계(dump 생성) 각각 분류 verify | `needs-confirmation` |
| distroless 채택 시 in-container 진단 도구 부재 영향이 runbook 으로 완화 | `CDG-C5``:debug` variant 는 busybox shell 만, jcmd/jstack/heap dump 별도 | distroless prod pod 에서 ephemeral container/sidecar 로 heap dump 추출 PoC + runbook | `planned` |
| Alpine + musl 채택 시 Testcontainers / native lib (snappy, zstd-jni 등) 정상 동작 | `CAJM-C4` 공식 경고 — musl 호환성 risk | alpine + Temurin musl 이미지에서 ca-tmpl integration test suite + native lib 호출 verify | `planned` |
| read-only root fs 강제 시 모든 write-path 가 emptyDir/tmpfs 로 redirect | application 코드의 file write 가 누락된 path 에서 발생 가능; Tomcat basedir 미설정 위험 | k8s securityContext `readOnlyRootFilesystem: true` + smoke test 로 startup/runtime write 실패 catch | `planned` |
| startup probe total budget (150s) 이 migration/validation 시간을 모두 커버 | DB migration 사이즈에 따라 150s 초과 가능 (budget owner = sibling) | 대용량 migration scenario 에서 startup probe success verify; 초과 시 fail | `planned` |
## Audit & Findings (ca-tmpl ground-truth 대조 2026-06-14, `/branch-spec`)
> 코드/registry/governing doc/sibling 대조에서 발견한 drift. **사용자 작성 결정 영역은 자동 rewrite 하지 않고 정합 권고만**(CLAUDE.md §11).
- **SHUTDOWN_BUDGET_DRIFT** (✅ 해소 2026-06-15 — option (a) 채택): 본 노트 app shutdown timeout=**20s** 이나 registry env-key `APP_SERVER_SHUTDOWN_TIMEOUT` default=**30s** (owner [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2, 2026-06-05 — 본 노트 2026-05-22 이후 갱신). env default 30s 적용 시 preStop 5s + drain 30s = 35s = `terminationGracePeriodSeconds` → 본 노트의 10s safety margin 이 **0 으로 붕괴**. validation rule(`spring_duration_shorthand_le_termination_grace`)은 `≤` 만 강제하므로 통과하나 margin 의도 상실. 권고: (a) app budget 을 30s 로 정합하고 grace 를 40s 로 상향, 또는 (b) env default 를 20s 로 낮춤 — 둘 다 사용자/env-config branch 결정. **해소(2026-06-15, /ca-parallel 후속)**: 권고 **(a)** 채택 — env-keys.yaml 이 app shutdown **(30s)의 SSOT 이고 본 branch 는 *관계*(grace ≥ timeout+preStop+margin)의 owner 이므로, app drain 30s 를 보존한 채 `stop_grace_period`**35s→40s**(30s+preStop 5s+margin 5s) 로 상향. `docker-compose.yml`/`docker-compose.local.yml` 의 fallback `:-20s``:-30s` 정합 + sync-table 주석/`stop_grace_period` 갱신. `.env`/env-keys 는 불변(30s). 설계상 정합; docker 런타임 스모크는 미검증.
- **OOM_LOG_LOSS** (✅ 해소 — 위임 결정 2026-06-15): 검증 결과 앱 코드에 JVM_OOM emitter 없음(`grep` 확인 — enum/comment/test 만 참조). `-XX:+ExitOnOutOfMemoryError` 가 OOM 즉시 abort → 앱 핸들러로 구조화 emit 은 원천적으로 불안정. **결정: 앱은 emit 하지 않음.** 런타임 구분 = exit 137 + heap dump(`/var/tmp/heap`, HeapDumpOnOutOfMemoryError) + JVM 네이티브 OOM stderr. 구조화 `error.code=JVM_OOM` 로그-기반 alert 는 observability 브랜치(log-pattern)로 위임. enum/registry 는 분류 SSOT(parity test GREEN). §구현 가이드 §5 반영.
- **LOCALE_DRIFT** (🟡 Should-fix): 본 노트 결정문 `LANG=en_US.UTF-8`; governing doc(`runtime-container-health-migration` §Container) + sibling [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] §6 = `LANG=C.UTF-8`. D4 자동조사(RHAT) 결론: C.UTF-8 이 Debian slim 내장(locales 패키지 불필요) → minimal image 정석. 권고: `C.UTF-8` 로 정합(§구현 가이드 §2 는 이미 C.UTF-8 + 주석 표기). 결정문은 사용자 영역이라 미수정.
- **HEAP_DUMP_PATH_DRIFT** (⚪ Advisory): 본 노트 `-XX:HeapDumpPath=/var/tmp/heap/<pod>-<ts>.hprof`; runbook `internal-error-spike.md` L39 = `/var/tmp/heap/heapdump-<pid>.hprof`. 구현 시 단일 path 규약으로 정합 필요.
- **PROBE_OWNERSHIP_DELEGATION** (정합 OK, 위임 명시): health probe endpoint shape/group membership 은 `feature-runtime-health-lifecycle-contract` §1 owner. 본 branch 는 manifest-side probe timing field 만. §구현 가이드 §6 에 위임 표기 완료(R3).
- **DOCKERFILE_IMPLEMENTED** (사실 등급 — 2026-06-15 갱신): ca-tmpl `src/Dockerfile` 구현 완료 (`actually-implemented`). multi-stage(JDK builder → JRE slim runtime), non-root `app` user(uid 1000), `JAVA_TOOL_OPTIONS` 전체 셋(-XX:MaxRAMPercentage=75/-XX:+UseContainerSupport/-XX:+ExitOnOutOfMemoryError/-XX:+HeapDumpOnOutOfMemoryError/-XX:HeapDumpPath=/var/tmp/heap/-Dserver.tomcat.basedir=/tmp), `C.UTF-8` locale(LOCALE_DRIFT 해소), EXPOSE 8080/9001, `src/.dockerignore` 신규, compose 3개(base/dev/local) 모두 작성. Gradle test 검증 가능한 항목(JVM_OOM enum + parity test) locally-verified. **Docker build + 런타임 표면 검증 완료 (2026-06-15, docker 29.5.3, `/verify`)**: 이미지 빌드 성공(multi-stage, JRE-only 534MB); `--memory` 256/512/1024m 에서 heap 185/371/742M 로 cgroup 추적 확인(UseContainerSupport 실동작); non-root uid 1000; C.UTF-8 + file.encoding=UTF-8 + TZ=UTC; read-only root fs + tmpfs(`/tmp`·`/var/tmp/heap` writable, `/app`·`/` write 거부) 모두 PASS → `locally-verified`. **$HOME 수정**: useradd --no-create-home + read-only fs 에서 `$HOME(/home/app)` write 실패 발견 → Dockerfile `ENV HOME=/tmp` 추가(writable tmpfs redirect).
- **CONTRACT_OK**: registry `JVM_OOM` row 가 `owner_branch: feature-container-runtime-contract` 로 본 branch 를 명시 — 계약 정합 확인. `NO_GROUND_TRUTH` 아님(ca-tmpl 경로 존재).
- **WEAK_DEFAULT_PASSWORD_FIXED** (✅ 해소 — 2026-06-15 ca-quality-reviewer advisory): `docker-compose.local.yml``${POSTGRES_PASSWORD:-changeme}` 가 weak default password 를 bake-in 함. `${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your local .env — no default provided}` (required-variable form) 으로 교체 → .env 미설정 시 compose up 즉시 실패. `db` service `POSTGRES_PASSWORD` + `app` service `SPRING_DATASOURCE_PASSWORD` 2곳 모두 교체.
- **DOCKERFILE_DEPENDENCY_WARMUP_SWALLOWED_FIXED** (✅ 해소 — 2026-06-15 ca-quality-reviewer advisory): `src/Dockerfile``RUN ./gradlew dependencies --no-daemon --quiet 2>/dev/null || true` 가 dependency resolution 실패를 조용히 삼켜 CI 에서 빈 캐시 레이어 + 후속 빌드 실패를 유발할 수 있었음. `2>/dev/null || true` 제거 → fail-fast (resolution 실패 시 빌드 즉시 중단 + 정확한 에러 노출). `--continue` partial-resolution 허용이 불필요한 구조이므로 단순 제거로 충분.
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`: `wiki/projects/ca-tmpl/runtime-container-health-migration` §Container 슬라이스)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| Container base image + JVM ergonomics | covered-here | — | — | D3, D4 |
| Locale / timezone (UTC, UTF-8) | covered-here | — | — | D4 (§Audit LOCALE_DRIFT) |
| Writable filesystem 최소화 (read-only root fs) | covered-here | — | — | D2 |
| Graceful shutdown budget (manifest-side) | covered-here | — | — | D5 |
| Graceful shutdown ordering invariant (app-side) | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | OK | §구현 가이드 §4 위임 링크 |
| Health probe endpoint shape / group membership | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | OK | §구현 가이드 §6 위임 링크 |
| Migration / startup probe budget | delegated | [[raw/branch-notes/feature-migration-startup-contract]] | OK | §엣지·실패·의존 의존 링크 |
| OOM 분류 + JVM_OOM error code | covered-here | — | — | §구현 가이드 §5 + registry owner |
## 마주친 문제
- **JVM_OOM 테스트 TDD red**: `OperationalError.JVM_OOM` 미존재 → `compileTestJava` 컴파일 에러 → 의도한 RED 확인 후 enum 추가 → GREEN. 전형적 TDD red 확인 흐름.
- **Dockerfile 0-byte placeholder**: `src/Dockerfile` 이 0-byte 추적 파일 — `Write` 도구 첫 시도에서 "File has not been read yet" 에러. `Read` 먼저 한 뒤 `Write` 성공.
- **`internal_category_codes_are_retryable` 루프**: `JVM_OOM`(INTERNAL/retryable=false) 추가 시 기존 루프가 실패함 — 예상된 변경. `&& e != OperationalError.JVM_OOM` 제외 조건 + 별도 focused assertion 추가로 해소.
## 유지보수 로그
### 2026-07-05 — builder-stage 모듈 COPY 목록 stale 수정 (`develop`, k3s 배포 준비)
- **문제**: inbound/outbound 어댑터 재구조화 + 신규 어댑터(outbound `objectstorage`/`fileserver`/`persistence-mongo`, inbound `grpc`/`graphql`/`websocket`) 추가 후, `src/Dockerfile` builder 스테이지의 하드코딩 per-module `COPY <module>/build.gradle` + `gradle.lockfile` 목록이 **6개 모듈 누락** 상태로 방치됨. `verifyDependencyLocks`(`COPY . .` 이전 실행)는 settings.gradle 전체 leaf 모듈을 STRICT resolve하는데, `app-bootstrap`이 누락 모듈을 project 의존으로 참조 → 릴리스 이미지 빌드가 깨질 상태였음. "레거시"의 실체는 스타일이 아니라 **모듈 구조와의 drift**.
- **수정**: 28줄 하드코딩 COPY 블록 → `COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./` 1줄로 교체. `--parents`가 디렉토리 구조를 보존하므로 신규 모듈이 자동 포함 → 다시는 settings.gradle과 drift 안 남 (D8 락 캐싱 전략·runtime 스테이지 모두 무변경).
- **labs 프론트엔드 digest 고정**: `--parents`는 labs Dockerfile frontend 필요 → 최상단에 `# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894` 추가. 떠다니는 태그 대신 digest 고정으로 빌드-타임 공급망 표면 최소화 (이 리포는 Cosign/SLSA/Trivy 파이프라인).
- **검증 (3중, 마지막이 end-to-end 실증)**:
1. 호스트 `./gradlew verifyDependencyLocks` → BUILD SUCCESSFUL 9s, leaf 19개 모듈 전부 STRICT 락 통과.
2. 경량 throwaway 이미지(alpine + `COPY --parents` + `find`, Gradle 미실행) → glob이 build.gradle 20개(19 모듈 + 루트) + gradle.lockfile 19개를 구조 보존 스테이징 (누락 6개 포함).
3. **실제 `src/Dockerfile` 전체 빌드 성공 (exit 0)**: `docker build -f src/Dockerfile src/ --build-arg RELEASE_VERSION=0.0.1 ...``#15 COPY --parents` DONE 0.2s → `#16 verifyDependencyLocks` DONE **120.8s**(컨테이너 내 STRICT resolve) → `#20 :app-bootstrap:bootJar` **BUILD SUCCESSFUL 25s**`caskeleton:verify-local` 이미지 생성. **이전 노트의 "Docker build unverified (no docker in worktree)" 상태를 여기서 해소.**
- 이 시점 `develop` 워킹트리 clean, **커밋은 사용자가 직접 수행**.
### 2026-07-05 — sample-portfolio standalone 데모 이미지 추가 (`src/Dockerfile.sample`)
- **동기**: 프로덕션 bootstrap 이미지(`src/Dockerfile``CaSkeletonApplication`)는 기본값 없는 env 72개 + JWT issuer + prod 시크릿/DB 검증으로 "그냥 띄워 테스트"가 어려움. `sample-portfolio`(`SamplePortfolioApplication`)는 자체 `application.yml`이 모든 env에 기본값을 주고 `SamplePublicAccessSecurityConfig`로 열려 있어 데모/서버 테스트에 적합 → k3s 배포용으로 **별도 Dockerfile 신설**.
- **구조 = src/Dockerfile 트윈**: builder 스테이지(labs `# syntax` + `COPY --parents` glob + STRICT `verifyDependencyLocks` + `COPY . .`)와 런타임 하드닝(non-root, read-only-fs 쓰기 마운트, JVM ergonomics, EXPOSE 8080/9001, HEALTHCHECK)을 그대로 상속. **차이는 5가지뿐**: (1) ARG 기본값으로 argless 빌드, (2) `:sample-portfolio:bootJar` 타깃, (3) jar 경로, (4) LABEL `caskeleton-sample`, (5) 릴리스 메타데이터 hard-fail 가드 제거(데모라 불필요).
- **런타임 사실**: PG 드라이버 `org.postgresql:postgresql:42.7.8``adapter:outbound:persistence-jpa`(runtimeOnly, RDBMS base + PG 벤더 병합 모듈)를 통해 샘플 runtimeClasspath에 존재 → bootJar 실행 가능. 부팅엔 reachable PostgreSQL만 있으면 됨(자체 Flyway `db/sample-migration/V2,V6`).
- **함정 (해소)**: ARG `RELEASE_VERSION=0.0.0-sample`으로 최초 argless 빌드가 build.gradle SemVer 가드(`\d+\.\d+\.\d+`, L21)에 걸려 `verifyDependencyLocks` exit 1로 실패. `--quiet`가 원인 메시지를 가려 BuildKit 백그라운드 알림이 "exit 0" 오해를 줌(실제 REAL_EXIT=1). → `RELEASE_VERSION`은 순수 SemVer여야 하고 `-sample` 마커는 라벨 전용 `BUILD_VERSION`에만. `RELEASE_VERSION=0.0.0`으로 교정 후 재빌드 성공.
- **검증**: `docker build -f src/Dockerfile.sample src/ -t ca-sample:local`(argless) → REAL_EXIT=0, `:sample-portfolio:bootJar` BUILD SUCCESSFUL 40s, 이미지 `ca-sample:local`(581MB) 생성. glob 레이어는 `src/Dockerfile` 빌드와 CACHED 공유. **커밋은 사용자가 직접 수행.**
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]]
- [[raw/company-tech-blogs/runtime-health-datadog-engineering-graceful-shutdown]]
- [[raw/official-docs/container-alpine-java-musl-tradeoffs]]
- [[raw/official-docs/container-distroless-google-github]]
- [[raw/official-docs/container-graalvm-native-image-spring-boot]]
- [[raw/official-docs/k8s-application-security-checklist-readonly-fs]]
- [[raw/official-docs/k8s-pod-security-standards-restricted]]
- [[raw/official-docs/openjdk-jdk-8196595-container-support]]
- [[raw/official-docs/redhat-openjdk-container-awareness-java17]]
- [[raw/official-docs/runtime-health-k8s-probes-official]]
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]]
- [[raw/official-docs/supply-chain-slsa-provenance-framework]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/jvm-oom-vs-container-oomkill-exit-137-2026-07-02]]
<!-- GENERATED: blog-topics:end -->
### Sub-branches (세부 작업)
- (없음)
### 오류 기록 (본 feature 작업 중 발생)
- (없음 — 마주친 문제 섹션에서 인라인 처리. 별도 error 노트 분리 불필요)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- JVM `-XX:+ExitOnOutOfMemoryError` 와 kubelet OOMKill 은 모두 exit 137 — 어떻게 구별하는가?
- `-XX:MaxRAMPercentage=75` 가 의미 있으려면 컨테이너 memory limit 이 반드시 설정되어야 하는 이유는?
- `read_only: true` 컨테이너에서 Spring Boot Tomcat 이 CrashLoop 하는 원인과 해결책?
- Graceful shutdown budget: app drain 20s + preStop 5s + safety margin 10s → `stop_grace_period=35s`. `.env`의 30s 값과의 drift를 어떻게 처리했나?
- 왜 final stage에 JDK가 아닌 JRE만 포함하는가?
- 멀티모듈 Gradle 빌드에서 per-module `COPY build.gradle` 하드코딩 목록이 왜 stale 취약점인가? BuildKit `COPY --parents` glob으로 레이어 캐싱을 유지하면서 drift를 없애는 방법은? (일반 `COPY **/build.gradle`는 왜 안 되는가 — 경로 평탄화/충돌)
- Dockerfile `# syntax` frontend를 태그가 아닌 digest로 고정하는 공급망(supply-chain) 근거는? cache mount(`--mount=type=cache`) 전략이 GitHub Actions `type=gha` 캐시와 왜 안 맞는가?
- 하나의 멀티모듈 리포에서 "엄격한 릴리스 이미지(메타데이터 hard-fail·build-arg 필수)"와 "처분형 데모 이미지(argless·가드 없음)"를 별도 Dockerfile로 분리하는 기준은? builder 스테이지를 공유(동일 glob 레이어 → CACHED)하면서 무엇만 갈라내야 하는가?
- `RUN ./gradlew ... --quiet` 가 실패했는데 BuildKit 백그라운드 알림은 "exit 0"으로 보였다 — `--quiet`가 원인 로그를 가리는 함정, 그리고 `RELEASE_VERSION=0.0.0-sample` 이 SemVer 가드(`\d+\.\d+\.\d+`)에 걸린 근본 원인을 어떻게 특정했나?
### 블로그·채용공고 연계 글감
- "JVM OOM과 컨테이너 OOMKill은 왜 같은 exit 137인가 — 구별법과 error.code 전략"
- "Spring Boot 컨테이너 graceful shutdown budget 계산 — preStop/drain/grace margin 조합"
- "docker-compose read_only: true + Spring Boot — Tomcat basedir 를 /tmp 로 redirect 해야 하는 이유"
- "멀티모듈 Gradle Dockerfile의 per-module COPY 목록이 조용히 stale해지는 문제 — `COPY --parents` glob 한 줄로 drift 제거 + 레이어 캐싱 유지"
## 관련 일일 노트
- `[[raw/daily-notes/2026-06-15]]`
## 완료 후 wiki 추출 대상
- [[wiki/projects/ca-tmpl/runtime-container-health-migration]] 의 container runtime canonical section (§Container).
## 완료 후 정리
> 머지/종료 시점에 채움.
- PR 링크: (pending)
- 리뷰 메모: (pending)
- 머지 결과 / 배포 환경: 로컬 worktree (ca-tmpl-container-runtime) — Gradle test locally-verified; Docker build unverified (no docker in worktree)
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: OperationalError.JVM_OOM 추가, ContainerRuntimeOomContractTest, OperationalErrorTest JVM_OOM 테스트
- `locally-verified` 항목: src/Dockerfile, src/.dockerignore, docker-compose.yml, docker-compose.dev.yml, docker-compose.local.yml — 내용 locally-verified but docker runtime 미실행
- `prod-verified` 항목: (없음)
- **추출하지 않을 항목** (planned / documented-only / abandoned): Docker build 런타임 행동(locale, read-only-fs smoke, OOM exit 137) — docker-only-unverified