주말에 배포 설정을 정리하다가 인증 서비스의 Dockerfile에서 FROM eclipse-temurin:21-jdk-alpine 한 줄이 눈에 걸렸다.
"빌드는 CI에서 끝나는데, 런타임 이미지가 왜 JDK지?"
이 작은 의문 하나를 파고들기 시작했는데, 결국 백엔드 9개 서비스 전체의 컨테이너 표준화와 월 $615 비용 절감으로 이어졌다. 이 글은 그 전체 여정의 기록이다.
runAsNonRoot에서 만난 함정 (CreateContainerConfigError)처음 상태는 이랬다.
FROM eclipse-temurin:21-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=libs/*.jar
COPY ${JAR_FILE} app.jar
ENV TZ=Asia/Seoul
EXPOSE 8080
ENTRYPOINT ["java","-Duser.timezone=${TZ}","-jar","/app.jar"]
빌드는 CI에서 끝나고 fat jar만 COPY하는 구조인데 런타임 이미지가 JDK였다. javac, jar 같은 컴파일 도구는 운영 컨테이너에서 쓸 일이 없고, 공격자에게 도구만 쥐여주는 셈이다. JRE로 바꾸면:
트레이드오프는 jmap, jstack 같은 진단 도구가 없다는 것 정도인데, k8s 환경에서는 APM이나 actuator로 대체되므로 문제가 안 됐다.
ENTRYPOINT ["java","-Duser.timezone=${TZ}","-jar","/app.jar"]
ENTRYPOINT가 exec form(JSON 배열)이면 셸을 거치지 않으므로 ${TZ}가 문자열 그대로 Java에 전달된다. 즉 user.timezone=${TZ}라는 잘못된 값이 들어가고 있었다. 같은 패턴의 버그가 다른 서비스 3곳에서도 발견됐다. 값을 명시해서 해결:
ENTRYPOINT ["java","-Duser.timezone=Asia/Seoul","-jar","/app.jar"]
VOLUME /tmp도 지웠다. 2014년경 Spring Boot 가이드의 잔재인데, k8s는 Dockerfile의 VOLUME을 무시하고, 로컬 docker 실행 시 익명 볼륨 쓰레기만 쌓는다.
파드에 memory limit 3Gi를 줬는데, JVM의 기본 MaxRAMPercentage는 25%다. 즉:
컨테이너 limit: 3Gi ← 이만큼 쓸 수 있다고 선언
실제 최대 힙: ~768Mi ← 기본값 25%
3Gi를 허용해놓고 힙은 768Mi에서 GC가 헐떡이는 구조였다. 한 줄로 해결:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"
JVM 프로세스는 힙 외에도 Metaspace, 스레드 스택(스레드당 ~1Mi), JIT 코드 캐시, direct buffer 등 limit에 비례하지 않는 고정 오버헤드(대략 250~500Mi)를 쓴다. 힙+힙외 합계가 limit을 넘으면 커널이 프로세스를 OOMKill(exit 137) 시키는데, 이건 스택트레이스도 힙덤프도 없는 죽음이라 디버깅이 끔찍하다.
그래서 적정 비율은 limit 크기에 따라 다르다:
| 컨테이너 limit | 적정 힙 비율 | 이유 |
|---|---|---|
| 512Mi | 50~60% | 고정 오버헤드가 절반을 차지 |
| 1~2Gi | 65~75% | 표준 구간 |
| 3Gi+ | 75~80% | 25%만 남겨도 oversized |
실제로 메모리 1Gi짜리 Cloud Run 서비스에는 75% 대신 65%를 적용했다. 비율을 무지성으로 복붙하면 안 되는 이유다.
여기서 흥미로운 사실을 발견했다. 우리 클러스터 3개(dev/stg/prod)가 모두 GKE Autopilot이었다. Standard GKE는 노드 단위로 과금하지만, Autopilot은 파드의 requests(vCPU·GiB) × 시간으로 직접 과금한다.
yaml의
requests숫자를 줄이는 순간 청구액이 줄어든다.
그럼 requests가 적정한가? 추측 대신 Cloud Monitoring API로 30일치 실사용량을 뽑았다.
curl -s -G "https://monitoring.googleapis.com/v3/projects/$PROJECT/timeSeries" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
--data-urlencode 'filter=metric.type="kubernetes.io/container/cpu/core_usage_time" AND ...' \
--data-urlencode 'aggregation.perSeriesAligner=ALIGN_RATE' \
--data-urlencode 'aggregation.crossSeriesReducer=REDUCE_MAX' ...
결과는 충격적이었다. 운영 클러스터 기준, 모든 서비스의 CPU p95가 requests의 1/26 ~ 1/167 수준:
| 서비스 (운영, 30일) | CPU p95 | requests | 과다 배율 |
|---|---|---|---|
| 가장 바쁜 서비스 | 19m | 500m | 26배 |
| 인증 서비스 | 5m | 500m | 100배 |
| 가장 한가한 서비스 | 3m | 500m | 167배 |
30일 최대 피크(260~335m)조차 전부 배포 시 JVM 부팅 버스트였고, 평상 트래픽의 CPU 수요는 사실상 0에 가까웠다. 걱정했던 "출근 시간 로그인 러시"는 데이터상 존재하지 않았다.
CPU requests는 속도 제한이 아니다. 성능 상한은 limit이 정하고(유지했다), requests는 노드 경합 시 보장받는 최소 몫일 뿐이다.
| 시나리오 | 영향 |
|---|---|
| 평시 요청 처리 (p95 5m) | 없음 — 250m도 50배 여유 |
| 트래픽 스파이크 | 없음 — limit까지 버스트 |
| 배포 시 JVM 부팅 | 노드 경합 시 최악 ~3배 느려질 수 있음 (여기만 영향) |
부팅 버스트는 ~750m × 25초 ≈ 20 CPU·초의 작업량이다. 최악의 경우(보장분 250m로만 부팅) 100초인데, startupProbe 예산을 150초 → 200초(40회 × 5초)로 늘려 보험을 들었다. maxUnavailable: 0 전략이라 부팅이 느려져도 사용자 영향은 없다.
실제로 배포 후 부팅 시간을 측정해 보니: 새 설정의 스테이징 부팅이 36.5초로 역대 최속이었다. 부팅 시간의 분산은 설정보다 노드 상태가 지배한다.
인증 정보를 다루는 서비스가 root로 돌고 있었다. RCE가 터지면 공격자가 root를 얻는 구조다. Dockerfile에서 non-root로 전환하고:
RUN addgroup -S app && adduser -S app -G app
USER app
yaml에서 정책으로 강제했다:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
로컬에서 docker run + id로 non-root 실행까지 확인하고 배포했는데… 배포가 실패했다.
Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root
kubelet의 runAsNonRoot 검증은 컨테이너를 실행하기 전에 이미지 메타데이터의 USER 필드를 정적으로 검사한다. app이라는 이름은 컨테이너 내부 /etc/passwd에서 UID 0에 매핑될 수도 있으므로, kubelet은 "증명할 수 없으면 거부"를 택한다. 숫자 UID만 통과한다.
# UID를 고정하고 숫자로 지정해야 runAsNonRoot 검증을 통과한다
RUN addgroup -g 10001 -S app && adduser -u 10001 -S app -G app
USER 10001:10001
로컬 docker에는 runAsNonRoot 개념 자체가 없어서 로컬에서는 재현이 불가능한 문제였다. 다행히 maxUnavailable: 0 덕에 기존 파드가 계속 서비스 중이라 장애는 없었다.
또 하나의 함정: 이 securityContext가 적용된 yaml은 반드시 non-root 이미지와 같은 배포로 나가야 한다. yaml만 먼저 적용되면 기존 root 이미지 파드가 CreateContainerConfigError로 차단된다.
부수 케이스 — 80 포트(1024 미만)를 쓰는 서비스 하나는 non-root로 바인딩이 불가능해서 제외했다. 포트를 8080으로 옮기는 후속 작업과 함께 적용할 예정이다.
전사 롤아웃 중 이상한 걸 발견했다. 어떤 서비스가 Cloud Run으로 이전 완료됐는데, 운영 GKE 클러스터에 같은 이름의 deployment(3 파드 × 500m/1Gi)와 내부 LoadBalancer가 그대로 돌고 있었다.
"진짜 안 쓰는 게 맞나?"를 증명하기 위해 파드의 네트워크 수신량을 30일치 측정했다:
수신 트래픽(3파드 합산): median=361B/s, p95=379B/s, max=826B/s
파드당 ~120B/s — 정확히 liveness/readiness probe 트래픽 수준이다. 30일간 단 한 건의 실제 요청도 없었다. 이전 실험 후 방치된 유령이었고, 이것만 지워도 월 ~$94가 절약된다.
교훈: "안 쓰는 것 같은데?"는 삭제 근거가 못 된다. probe 베이스라인과 비교한 네트워크 실측이 근거다.
처음에 "vCPU당 시간당 $0.05 안팎"이라고 기억으로 추정했는데, 보고 전에 GCP Cloud Billing Catalog API로 실제 단가를 확인했다:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://cloudbilling.googleapis.com/v1/services/CCD8-9BF1-090E/skus?pageSize=5000" \
| jq '... Autopilot Pod mCPU Requests ...'
| SKU (asia-northeast3) | 확인된 단가 |
|---|---|
| Autopilot Pod mCPU Requests | $0.0571/vCPU·h |
| Autopilot Pod Memory Requests | $0.00632/GiB·h |
최종 절감 내역 (9개 서비스, 38개 파드):
| 항목 | 절감 | 월 금액 |
|---|---|---|
| CPU requests 절반 (dev/stg) | 5.5 vCPU | $229 |
| CPU requests 절반 (운영) | 7.0 vCPU | $292 |
| 유령 리소스 삭제 | 1.5 vCPU + 3Gi + LB | $94 |
| 합계 | ~$615/월 (연 ~$7,400) |
절감 리소스 양은 git에 커밋된 확정값, 단가는 공식 카탈로그 실측이다. 유일한 변수는 약정 할인(CUD) 계약 여부 — 정가 기준이므로 할인 계약이 있으면 실제 절감은 그만큼 줄어든다. 다음 달 청구서로 대조 검증할 예정이다.
MaxRAMPercentage 명시는 컨테이너 JVM의 기본 소양. 단, limit 크기에 따라 비율을 다르게.Dockerfile 한 줄에서 시작했지만, 결국 핵심은 하나였다 — 추측하지 말고 측정하자.