GKE Autopilot에서 월 $615 아끼기 — 실측 데이터로 컨테이너 최적화하기

seonwoo_jung·2026년 6월 7일

시작은 Dockerfile 한 줄이었다

주말에 배포 설정을 정리하다가 인증 서비스의 Dockerfile에서 FROM eclipse-temurin:21-jdk-alpine 한 줄이 눈에 걸렸다.

"빌드는 CI에서 끝나는데, 런타임 이미지가 왜 JDK지?"

이 작은 의문 하나를 파고들기 시작했는데, 결국 백엔드 9개 서비스 전체의 컨테이너 표준화와 월 $615 비용 절감으로 이어졌다. 이 글은 그 전체 여정의 기록이다.

  • JDK → JRE 전환과 Dockerfile의 숨은 버그들
  • JVM이 컨테이너 메모리의 25%만 쓰고 있던 이유
  • GKE Autopilot 과금 구조와 30일 실측 기반 CPU requests 조정
  • runAsNonRoot에서 만난 함정 (CreateContainerConfigError)
  • 30일간 트래픽 0이던 유령 리소스 발견

1. JDK 이미지로 운영 컨테이너를 띄울 이유가 없다

처음 상태는 이랬다.

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로 바꾸면:

  • 이미지 크기 절반 (JDK alpine ~330MB → JRE alpine ~170MB) — pull/push, 콜드 스타트 모두 이득
  • 공격 표면 축소

트레이드오프는 jmap, jstack 같은 진단 도구가 없다는 것 정도인데, k8s 환경에서는 APM이나 actuator로 대체되므로 문제가 안 됐다.

덤으로 발견한 버그: exec form에서는 환경변수가 확장되지 않는다

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 실행 시 익명 볼륨 쓰레기만 쌓는다.


2. JVM은 기본적으로 컨테이너 메모리의 25%만 힙으로 쓴다

파드에 memory limit 3Gi를 줬는데, JVM의 기본 MaxRAMPercentage25%다. 즉:

컨테이너 limit:  3Gi   ← 이만큼 쓸 수 있다고 선언
실제 최대 힙:   ~768Mi ← 기본값 25%

3Gi를 허용해놓고 힙은 768Mi에서 GC가 헐떡이는 구조였다. 한 줄로 해결:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"

왜 100%가 아니라 75%인가

JVM 프로세스는 힙 외에도 Metaspace, 스레드 스택(스레드당 ~1Mi), JIT 코드 캐시, direct buffer 등 limit에 비례하지 않는 고정 오버헤드(대략 250~500Mi)를 쓴다. 힙+힙외 합계가 limit을 넘으면 커널이 프로세스를 OOMKill(exit 137) 시키는데, 이건 스택트레이스도 힙덤프도 없는 죽음이라 디버깅이 끔찍하다.

그래서 적정 비율은 limit 크기에 따라 다르다:

컨테이너 limit적정 힙 비율이유
512Mi50~60%고정 오버헤드가 절반을 차지
1~2Gi65~75%표준 구간
3Gi+75~80%25%만 남겨도 oversized

실제로 메모리 1Gi짜리 Cloud Run 서비스에는 75% 대신 65%를 적용했다. 비율을 무지성으로 복붙하면 안 되는 이유다.


3. Autopilot은 requests 단위로 과금한다 — 그리고 우리는 과다 예약 중이었다

여기서 흥미로운 사실을 발견했다. 우리 클러스터 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 p95requests과다 배율
가장 바쁜 서비스19m500m26배
인증 서비스5m500m100배
가장 한가한 서비스3m500m167배

30일 최대 피크(260~335m)조차 전부 배포 시 JVM 부팅 버스트였고, 평상 트래픽의 CPU 수요는 사실상 0에 가까웠다. 걱정했던 "출근 시간 로그인 러시"는 데이터상 존재하지 않았다.

requests를 절반으로 — 성능은 안 느려지나?

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초로 역대 최속이었다. 부팅 시간의 분산은 설정보다 노드 상태가 지배한다.


4. non-root 전환, 그리고 runAsNonRoot의 함정

인증 정보를 다루는 서비스가 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은 이름을 믿지 않는다

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으로 옮기는 후속 작업과 함께 적용할 예정이다.


5. 유령 리소스 — 30일간 트래픽 0인 deployment

전사 롤아웃 중 이상한 걸 발견했다. 어떤 서비스가 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 베이스라인과 비교한 네트워크 실측이 근거다.


6. 비용 검증 — 추정 단가로 보고하지 말 것

처음에 "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) 계약 여부 — 정가 기준이므로 할인 계약이 있으면 실제 절감은 그만큼 줄어든다. 다음 달 청구서로 대조 검증할 예정이다.


정리 — 이번 여정의 교훈

  1. requests는 추측이 아니라 실측으로 정한다. 30일 p95 하나면 "혹시 모르니까 500m"라는 관성을 깰 수 있다.
  2. Autopilot에서 requests는 곧 청구서다. Standard의 감각으로 requests를 넉넉히 주면 그대로 돈이 샌다.
  3. JVM 기본값은 컨테이너를 모른다(정확히는 보수적이다). MaxRAMPercentage 명시는 컨테이너 JVM의 기본 소양. 단, limit 크기에 따라 비율을 다르게.
  4. runAsNonRoot에는 숫자 UID. 로컬에서 재현 안 되는 k8s 함정이 있다.
  5. 삭제의 근거는 네트워크 실측. probe 베이스라인(~120B/s/pod)과 비교하면 유령 리소스를 안전하게 판정할 수 있다.
  6. 비용 보고 전에 단가를 검증한다. Billing Catalog API로 5분이면 추정이 사실이 된다.

Dockerfile 한 줄에서 시작했지만, 결국 핵심은 하나였다 — 추측하지 말고 측정하자.

0개의 댓글