VM에서 컨테이너로 전환하면서 가장 먼저 정리한 대상은 베이스 이미지였다. 표준 베이스라는 개념은 새롭지 않았다. VM 환경에서도 Packer로 골든 이미지를 만들어 JDK·Tomcat·APM·로그 수집기 같은 공통 구성을 한곳에서 관리하고 있었다. 컨테이너 베이스 이미지는 기존 골든 이미지의 역할을 이어받았다.
컨테이너 전환의 목표는 새로운 표준을 별도로 만드는 것이 아니라 골든 이미지에서 관리하던 공통 구성을 컨테이너 베이스 이미지로 옮기는 것이었다. VM의 Packer 골든 이미지에 대응하는 표준 컨테이너 베이스 이미지를 만들었고, 사내에서는 corebase라고 부른다.
왜 표준 베이스 이미지인가
목표는 단순했다. JDK·런타임·APM·보안 기본값을 한곳에서 관리하는 것이다.
- 서비스가 각자 Dockerfile에서 JDK를 설치하면 버전과 보안 패치 적용 시점이 달라질 수 있다.
- APM 에이전트나 사내 Root CA 같은 공통 요소는 모든 서비스에 똑같이 들어가야 하는데, 이걸 서비스마다 챙기다 보면 빠뜨리는 곳이 나온다.
- JDK 8에서 17, 21로 올리는 작업도 베이스가 흩어져 있으면 서비스 수만큼 반복해야 한다.
그래서 서비스는 자기 애플리케이션만 얹고 토대는 표준 이미지가 책임진다는 경계를 만들기로 했다.
변형 매트릭스로 설계했다
서비스마다 필요한 조합이 달랐다. 어떤 서비스는 JDK 8과 Tomcat이, 어떤 서비스는 JDK 17 단독이 필요했고, 일부는 Azure CLI까지 요구했다. 그래서 단일 이미지 대신 멀티스테이지 + 변형(variant) 매트릭스로 설계했다.
토대는 Alpine 3.19 위에 공통 레이어(corebase-alpine)를 깔고, 그 위에 JDK 버전과 부가 기능을 조합하는 구조다.
corebase-alpine # 공통 패키지 + APM 에이전트 + Root CA
├─ corebase-jre8 / jre11 / jre17 / jre21
│ ├─ -tomcat # Tomcat 내장
│ ├─ -az # Azure CLI 포함
│ └─ -geoip2 # GeoIP DB + 매일 갱신 크론
JDK는 8·11·17·21을 모두 지원하고, 각 버전마다 GC를 다르게 잡았다. JDK 8은 ParallelOldGC를, 17·21은 ZGC를 기본값으로 두었다. 런타임 특성에 맞는 기본값을 베이스에서 미리 정해 두면 서비스가 매번 고민할 필요가 없다.
공통 레이어에는 세 가지를 미리 넣었다.
- APM 에이전트 — 별도 스테이지에서 풀어
/app에 복사한다. 모든 서비스가 동일한 에이전트를 갖게 된다. - 사내 Root CA 인증서 — DB(TLS) 연결용으로 베이스에 내장하고, 읽기 전용(0400)으로 둔다.
- GeoIP — 필요한 변형에만 넣고, crontab으로 매일 새벽 mmdb를 자동 갱신한다.
-az 변형은 Azure CLI를 pip로 설치한 뒤 빌드 의존성(gcc·make 등)을 다시 제거해 이미지를 가볍게 유지했다. 컴파일에만 필요한 패키지가 최종 이미지에 남지 않도록 한 것이다.
멀티아키텍처(x64 + aarch64)
베이스 이미지는 linux/amd64와 linux/arm64를 모두 빌드한다. JRE 타르볼을 아키텍처별로 두고, 빌드할 때 TARGETPLATFORM에 따라 알맞은 것을 풀도록 했다.
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
tar -zxvf /tmp/${JDK_TAR_AMD64} -C /usr/lib/jvm; \
elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
tar -zxvf /tmp/${JDK_TAR_ARM64} -C /usr/lib/jvm; \
fi
로컬에서 수동으로 빌드할 때는 docker buildx build --platform linux/amd64,linux/arm64로 멀티아치 매니페스트를 한 번에 만들고, 플랫폼별 다이제스트를 추출해 태그를 붙인다. 어떤 변형을 어떤 태그로 낼지는 build-config.yaml에 매트릭스로 선언해 두고, 스크립트가 이를 읽어 처리한다.
빌드 파이프라인 — 빌드 → 서명 → 배포
표준 이미지를 제품처럼 다루기로 하면서 빌드 파이프라인도 Argo Workflows의 ClusterWorkflowTemplate으로 표준화했다. 각 변형은 동일한 3단계 DAG를 거친다.
build (Kaniko) → sign (Cosign) → send-webhook (ArgoCD sync)
- 빌드 — Kaniko.
--target으로 필요한 변형 스테이지만 빌드하고,--cache=true와--skip-unused-stages로 빌드 시간을 줄였다. Docker daemon 없이 클러스터의 격리된 빌드 Pod에서 이미지를 생성한다. - 서명 — Cosign. 빌드한 이미지에 서명을 붙인다. 키는 코드에 두지 않고 Azure Key Vault(KMS) 의 키를 참조한다. 이렇게 서명된 이미지여야 클러스터의 Kyverno 정책을 통과한다. 서명 안 된 이미지를 막는 정책이 있다 보니 베이스 이미지부터 서명 대상이 되는 게 자연스러웠다.
- 배포 — ArgoCD. 빌드 산출물로 나온 파라미터와 sync 옵션을 ArgoCD 애플리케이션에 PUT한 뒤 sync를 트리거한다. 환경별 values는 저장소의
argocdpayloads/에 환경 단위로 두어, 같은 이미지를 환경마다 같은 방식으로 운영한다.
이 빌드 매트릭스 덕분에 JDK 8·11·17·21 × 변형 조합이 한 워크플로에서 일괄로 빌드·서명·배포된다.
엔트리포인트의 사전 검증
이미지를 아무리 잘 만들어도 런타임에서 조용히 죽으면 의미가 없다. 그래서 실행 스크립트는 시작 단계에서 환경변수부터 검증하도록 했다. JAVA_OPTS, SERVERNAME, APM 관련 변수가 없으면 명확한 ERROR 로그를 남기고 즉시 종료한다. APM 설정 파일의 존재와 권한도 확인한 뒤 값을 주입하며, 종료할 때는 로그 tailing 프로세스를 정리한다. "왜 안 뜨는지 모르겠는" 상황을 줄이려는 장치다.
효과와 돌아보며
표준 베이스 이미지를 갖추고 나서 달라진 점은 이렇다.
- 서비스마다 반복하던 JDK·APM·인증서 설치가 사라졌다.
- JDK 8에서 21로 올리는 버전 전환을 베이스에서 중앙 일괄로 관리하게 됐다.
- corebase의 모든 변형 이미지가 서명돼 Kyverno 공급망 보안 정책의 검증 대상이 된다.
- x64와 aarch64를 함께 지원해 아키텍처 선택의 폭도 생겼다.
베이스 이미지는 일회성 산출물이 아니라 버전 관리·서명·배포 절차를 갖춘 공통 제품으로 운영해야 했다. 보안 패치가 발생하면 베이스 이미지를 다시 빌드하고 서비스별 반영 상태를 추적할 수 있다. 변형이 늘수록 빌드 매트릭스가 복잡해지는 문제는 남아 있지만, 공통 런타임과 서비스 코드의 책임 범위가 분리되면서 서비스별 Dockerfile은 단순해졌다.
참고