리소스 최적화: Requests/Limits와 언어별 런타임
검토: 2026-09-11. 예제 검증 환경은 Kubernetes 1.36 스키마, Java 21 / Spring Boot 4.1.1, Python 3.12 / Gunicorn 26.2.0, Node.js 24.21, Go 1.27.1, Rust 1.98 / Tokio 1.53.1입니다.
리소스 크기는 언어 이름이나 고정 비율만으로 정하지 않습니다. 대표 부하·시작·재시작·GC· 장애 상황에서 처리량과 지연 SLO를 측정하고 requests, limits, 복제본, 런타임 동시성을 함께 조정합니다. 예제의 메모리 비율과 임계값은 시험 시작점이며 성능 보장이 아닙니다.
1. Requests, limits와 QoS
| 항목 | Requests | Limits |
|---|---|---|
| CPU | 스케줄링 계산과 CPU 경합 시 상대적 가중치에 사용 | Linux cgroup CPU quota로 실행량 제한 가능 |
| 메모리 | 스케줄링 계산에 사용 | reclaim으로 해결하지 못하는 할당 압박에서 cgroup OOM 발생 가능 |
| 미설정 | admission 기본값과 상위 정책 확인 필요 | 해당 컨테이너 한도가 없을 수 있지만 노드·상위 cgroup 한도는 존재 |
request는 물리 CPU 고정이나 메모리의 선할당이 아니며 애플리케이션 성능을 절대 보장하지 않습니다. limit만 지정하면 다른 admission 기본값이 없는 경우 같은 값이 request로 복사됩니다. LimitRange 등을 거친 실제 Pod 사양을 확인합니다.
아래는 Pod-level resources를 사용하지 않는 컨테이너 수준 예제입니다. resource-demo 네임스페이스를 먼저 만들고, 기본 리소스를 주입하는 LimitRange가 없는 환경에서 QoS를 비교합니다. Guaranteed는 CPU·메모리의 request와 limit이 모두 양수이고 같아야 하며, 일반·init 컨테이너 등의 조건도 함께 충족해야 합니다.
# qos-pods.yaml
apiVersion: v1
kind: Pod
metadata:
name: guaranteed
namespace: resource-demo
spec:
containers:
- name: app
image: ghcr.io/stefanprodan/podinfo:6.15.0@sha256:ec73780a8425f59ea49f5bc8cdff0d598805a224fbaa1f86c67a244f250fa9da
resources:
requests:
cpu: 500m
memory: 256Mi
limits:
cpu: 500m
memory: 256Mi
---
apiVersion: v1
kind: Pod
metadata:
name: burstable
namespace: resource-demo
spec:
containers:
- name: app
image: ghcr.io/stefanprodan/podinfo:6.15.0@sha256:ec73780a8425f59ea49f5bc8cdff0d598805a224fbaa1f86c67a244f250fa9da
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
---
apiVersion: v1
kind: Pod
metadata:
name: besteffort
namespace: resource-demo
spec:
containers:
- name: app
image: ghcr.io/stefanprodan/podinfo:6.15.0@sha256:ec73780a8425f59ea49f5bc8cdff0d598805a224fbaa1f86c67a244f250fa9da
resources: {}BestEffort는 CPU·메모리 request/limit이 모두 없는 경우이며 나머지는 Burstable이 될 수 있습니다. Pod-level resources나 sidecar/init 자원 계산을 사용하는 경우 해당 버전의 규칙도 확인합니다.
QoS는 절대적인 eviction 순서가 아닙니다. 메모리 압박 시 kubelet은 request 초과 여부, Pod Priority, request 대비 사용량을 고려합니다. Guaranteed와 request 이내 Burstable이 나중에 고려되는 경향은 있지만 “Guaranteed는 어떤 상황에도 마지막”이라고 보장할 수 없습니다. DiskPressure의 ephemeral-storage 퇴거와 컨테이너 limit OOM도 구별합니다. OOMKilled는 보통 컨테이너의 종료 reason이며 Pod phase 이름이 아닙니다. 프로세스 일부가 종료되거나, PID 1 종료 후 restartPolicy에 따라 컨테이너가 재시작할 수 있습니다.
CPU quota와 메모리 한계
cgroup v1은 cpu.cfs_quota_us / cpu.cfs_period_us, v2는 cpu.max를 사용합니다. 100ms는 흔한 quota period이지 모든 환경의 고정값이 아닙니다. 500m, 100ms 예제의 실행 예산은 period당 총 50ms이며 여러 스레드가 이를 함께 소모합니다. Throttling은 지연뿐 아니라 처리량에도 영향을 줄 수 있습니다. throttled-period 비율은 “스로틀링이 있었던 period의 비율”이며 손실 CPU 시간 비율이 아닙니다.
메모리 limit은 힙 크기와 같지 않습니다. cgroup v1의 memory.limit_in_bytes와 v2의 memory.max 경로를 혼용하지 않습니다. 런타임의 전체 RSS, native 할당, thread stack, page cache와 memory-backed emptyDir 등을 함께 살펴봅니다. limit 접근 때 메모리 사용량을 검사하는 liveness probe로 먼저 재시작시키면 OOM 원인을 숨기고 재시작 루프를 만들 수 있으므로 일반적인 해결책으로 사용하지 않습니다.
CPU limit을 없애면 해당 컨테이너 quota로 인한 제한은 줄일 수 있지만 CPU 경합·상위 quota· 노드 한계까지 없어지지 않습니다. request, 우선순위, 격리, 정책과 SLO를 함께 검토합니다.
네임스페이스 기본값과 총량
LimitRange는 admission 기본값/개별 리소스 제약, ResourceQuota는 네임스페이스의 리소스 요청·한도·객체 수 등에 대한 admission 총량입니다. 실제 CPU 사용량이나 비용을 실시간으로 제한하는 기능은 아닙니다. 다음 정책은 production 네임스페이스가 있어야 적용됩니다.
# namespace-policy.yaml
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
namespace: production
spec:
limits:
- type: Container
default:
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
min:
memory: 16Mi
max:
memory: 4Gi
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: budget
namespace: production
spec:
hard:
requests.cpu: '8'
requests.memory: 16Gi
limits.memory: 32Gi
pods: '20'2. 측정과 VPA
평균만 보지 않고 대표 기간의 분포, 피크, 시작 비용과 메모리 증가를 관찰합니다. P70 request/P99 limit 같은 정책은 조직의 가설로 시험할 수 있지만 모든 워크로드의 정답은 아닙니다. GC·model load·JIT·암호화·sidecar·배치 입력 크기 때문에 같은 언어에서도 결과가 크게 다릅니다. 메모리 누수를 limit 증가만으로 덮거나, 유휴 상태의 재해 복구/대기 Pod를 낭비로 자동 분류하지 않습니다.
VPA 설치와 Goldilocks의 버전 고정 예제는 스케일링 장을 사용합니다. Goldilocks는 대상 네임스페이스/워크로드에 VPA를 생성·표시하며 설치만으로 모든 워크로드가 자동 최적화되는 것은 아닙니다. 다음 VPA는 뒤의 resource-java Deployment를 관찰합니다.
# vpa.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: resource-java
namespace: production
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: resource-java
updatePolicy:
updateMode: 'Off'
resourcePolicy:
containerPolicies:
- containerName: app
controlledValues: RequestsOnly
minAllowed:
cpu: 100m
memory: 256Mi
maxAllowed:
cpu: '4'
memory: 4GiOff는 추천만 제공합니다. target은 request 추천이며 lower/upper bound는 추천 범위입니다. upperBound를 그대로 메모리 limit으로 쓰거나 uncappedTarget을 실제 적용값으로 오해하지 않습니다. RequestsAndLimits는 기존 비율에 따라 limit도 조정할 수 있는 정책이지 upperBound를 limit으로 복사하는 기능이 아닙니다.
VPA 1.7.1에서 Auto는 deprecated인 Recreate 동작입니다. in-place 지원은 InPlaceOrRecreate/InPlace의 조건과 Kubernetes 기능 지원을 확인합니다. Initial도 새 Pod의 request를 바꾸므로 request 기반 HPA의 분모에 영향을 줍니다. “Initial이면 HPA와 무조건 충돌하지 않는다”는 설명은 부정확합니다.
Pod 한 개가 SLO를 지키며 200 RPS를 처리하고 목표가 1,000 RPS라면 정상 상태에는 5개가 필요합니다. Pod 한 개 손실 후에도 같은 처리량이 필요하면 최소 6개가 필요합니다. 이것은 3개 AZ 중 하나의 상실까지 보장하는 계산이 아닙니다. “20% 여유”가 추가 용량 20%인지, 가용 용량의 20%를 비워 두겠다는 뜻인지도 구분합니다. 후자의 계산은 ceil(1000 / (200 × 0.8)) = 7입니다.
3. JVM: 힙과 컨테이너 메모리
MaxRAMPercentage는 JVM이 감지한 메모리 기준의 힙 ergonomics 입력입니다. 항상 75%가 최적이거나 Pod limit 변경을 즉시 재추적하는 자동 크기 조절기는 아닙니다. -Xmx, -XX:MaxRAM, 작은 힙에 대한 ergonomics 등 다른 옵션도 영향을 줍니다. 힙 밖에는 metaspace, code cache, stack, direct buffer, JNI와 allocator 메모리가 필요합니다. 일정한 25%가 모든 애플리케이션의 non-heap을 충당한다는 보장은 없습니다.
JAVA_OPTS는 JVM이 자동으로 읽는 표준 환경 변수가 아닙니다. 이미지 entrypoint가 이를 명령줄에 추가해야 동작합니다. 아래는 JVM이 읽는 JAVA_TOOL_OPTIONS와 직접적인 java -jar 명령을 사용합니다. 60%는 검증 시작점이며 실제 profile로 조정합니다.
다음 Spring Boot 예제는 Java 21과 Boot 4.1.1로 빌드했습니다. 프로젝트 경로대로 파일을 저장합니다. 실행에 필요한 전체 Maven 설정과 애플리케이션입니다.
<!-- java/pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>example</groupId>
<artifactId>resource-api</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>// java/src/main/java/example/ResourceApi.java
package example;
import java.util.Map;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class ResourceApi {
private final Timer workTimer;
public ResourceApi(MeterRegistry registry) {
workTimer = Timer.builder("demo.work")
.description("Synthetic work duration")
.publishPercentileHistogram()
.register(registry);
}
@GetMapping("/work")
public Map<String, String> work() {
return workTimer.record(() -> Map.of("status", "ok"));
}
public static void main(String[] args) {
SpringApplication.run(ResourceApi.class, args);
}
}# java/src/main/resources/application.yaml
spring:
application:
name: resource-api
management:
endpoints:
web:
exposure:
include: health,prometheus
endpoint:
health:
show-details: never
probes:
enabled: true
prometheus:
metrics:
export:
enabled: true
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true
jvm.gc.pause: true
slo:
http.server.requests: 10ms,50ms,100ms,500ms,1smvn -f java/pom.xml package
java -jar java/target/resource-api-1.0.0.jar위 최소 프로젝트는 Maven wrapper 파일을 포함하지 않으므로 직접 만든 wrapper가 없으면 mvn package를 사용합니다. 이미지에는 빌드한 jar를 /app/resource-api.jar로 넣고 Java 21 호환 런타임을 포함해야 합니다. 아래 registry.example.com 이미지는 교체할 자리입니다. namespace, 레지스트리 접근과 이미지 빌드는 별도 준비 항목입니다. readiness/liveness는 실제 제공되는 Actuator endpoint와 연결했습니다.
# java-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: resource-java
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: resource-java
template:
metadata:
labels:
app: resource-java
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: registry.example.com/team/resource-java:1.0.0
command:
- java
- -jar
- /app/resource-api.jar
env:
- name: JAVA_TOOL_OPTIONS
value: -XX:+UseContainerSupport -XX:MaxRAMPercentage=60.0 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/diagnostics
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: '2'
memory: 2Gi
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 5
volumeMounts:
- name: diagnostics
mountPath: /diagnostics
volumes:
- name: diagnostics
emptyDir:
sizeLimit: 3Gi
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: resource-javaActuator의 Prometheus 설정은 management.prometheus.metrics.export 경로입니다. 메트릭과 health detail을 무조건 외부에 공개하지 않습니다. percentiles-histogram을 켠 metric만 _bucket 기반 쿼리를 사용할 수 있으며, 클라이언트 계산 percentile을 여러 Pod에서 단순 평균내어 전체 percentile로 만들 수 없습니다. Micrometer Timer 자체가 count/sum을 제공하므로 같은 의미의 Counter를 중복 등록할 필요는 없습니다.
HeapDumpOnOutOfMemoryError는 JVM의 Java OOME에 대한 옵션입니다. 커널 SIGKILL에서는 힙 덤프나 종료 hook 실행을 기대할 수 없습니다. emptyDir 진단 파일은 Pod 삭제 시 사라지고 동일 파일명 충돌·디스크 부족도 처리해야 합니다. 힙 덤프에는 민감한 애플리케이션 데이터가 포함될 수 있으므로 접근·보존을 제한합니다. ScheduleAnyway는 선호이며 엄격한 AZ 분산 보장이 아닙니다.
GC와 CPU
| 선택 | 확인할 조건 |
|---|---|
| G1 | 일반적인 시작점. pause target은 목표이지 최대 지연 보장이 아님 |
| ZGC | 사용하는 JDK 버전의 generational 모드와 CPU/메모리 예산 검증 |
| Shenandoah | 선택한 JDK 배포판·버전에서 실제 제공하는지 확인 |
| Parallel / Serial | 처리량·작은 힙 등 워크로드 조건으로 비교 |
Java 21에서 generational ZGC는 -XX:+UseZGC -XX:+ZGenerational로 사용할 수 있습니다. Java 23은 generational 기본값, Java 24는 non-generational 제거로 해당 선택 옵션이 obsolete입니다. Java 25에서도 obsolete 경고와 향후 만료를 구별해야 합니다. Java 17에 위 옵션을 그대로 넣지 않습니다. 현행 generational-only JDK에서는 -XX:+UseZGC를 사용하고 정확한 버전에서 시작을 검증합니다. 고정된 GC별 지연·처리량 숫자는 벤치마크 없이 보장하지 않습니다.
availableProcessors()와 GC worker 수가 항상 같지는 않습니다. quota·affinity·JDK 버전·collector ergonomics가 영향을 줍니다. 무조건 GC thread 수를 늘리면 낮은 CPU quota에서 더 빠르게 예산을 소모할 수 있습니다. 시작 시간이 긴 경우 startupProbe와 실제 JIT/GC/CPU 관측을 함께 사용합니다.
JMX와 JFR
JMX exporter 1.6.0 jar를 이미지에 포함하거나 공식 배포 파일과 체크섬을 확인해 준비합니다. 아래 설정은 실제 JVM의 메모리·thread·GC MBean에서 검증했습니다. Micrometer 메트릭 이름과 JMX 메트릭 이름은 별개이므로 대시보드에서 혼용하지 않습니다. GC CollectionTime은 ms에서 seconds로 변환했습니다.
# jmx-config.yaml
startDelaySeconds: 0
lowercaseOutputName: true
lowercaseOutputLabelNames: true
includeObjectNames:
- "java.lang:type=Memory"
- "java.lang:type=Threading"
- "java.lang:type=GarbageCollector,name=*"
rules:
- pattern: 'java.lang<type=Memory><HeapMemoryUsage>(used|committed|max)'
name: demo_jmx_heap_$1_bytes
type: GAUGE
- pattern: 'java.lang<type=Threading><>ThreadCount'
name: demo_jmx_threads
type: GAUGE
- pattern: 'java.lang<name=(.+), type=GarbageCollector><>CollectionCount'
name: demo_jmx_gc_collections_total
labels:
gc: "$1"
type: COUNTER
- pattern: 'java.lang<name=(.+), type=GarbageCollector><>CollectionTime'
name: demo_jmx_gc_collection_seconds_total
valueFactor: 0.001
labels:
gc: "$1"
type: COUNTERjava -javaagent:jmx_prometheus_javaagent-1.6.0.jar=127.0.0.1:9404:jmx-config.yaml -jar java/target/resource-api-1.0.0.jar이 명령은 로컬 진단용으로 loopback에 바인딩합니다. Prometheus가 다른 Pod에서 읽는 구성이라면 명시적 수집 포트·ServiceMonitor·접근 제어를 추가합니다. JMX exporter가 jar 경로에 없으면 JVM은 시작하지 않습니다. NMT는 JVM 시작 시 -XX:NativeMemoryTracking=summary를 지정해야 하며, JVM 밖의 모든 라이브러리 할당을 완전히 설명하는 RSS 회계는 아닙니다.
JFR을 실행 중에 수집할 때는 JDK 도구, 같은 사용자 권한, attach 허용과 쓰기 가능한 경로가 필요합니다. 아래 PID는 실제 Java PID로 교체합니다. PID 1이라고 가정하지 않습니다.
jcmd PID VM.native_memory summary
jcmd PID JFR.start name=profile settings=profile duration=60s filename=/diagnostics/profile.jfr
jfr summary /diagnostics/profile.jfr컨테이너에서 kubectl cp를 사용할 때는 선택한 container와 tar 설치 여부도 확인합니다. recording 종료 후 복사하며 heap/JFR 파일이 프로세스 종료나 Pod 교체까지 보존된다고 가정하지 않습니다.
4. Python: worker 수와 프로파일링
호스트 multiprocessing.cpu_count()에 2 × CPU + 1을 적용하면 컨테이너 quota보다 많은 프로세스를 만들 수 있습니다. CPU-bound/IO-bound 구분, GIL/확장 모듈, worker당 RSS와 요청 동시성으로 시험합니다. “CPU당 2–4개가 항상 정답”이라는 공식은 없습니다. 아래 WSGI Flask 예제는 WEB_CONCURRENCY를 실제로 읽으며 기본 1개 worker, 2개 thread에서 시작합니다.
# python/requirements.txt
Flask==3.1.3
gunicorn==26.2.0# python/app.py
from flask import Flask
def create_app():
app = Flask(__name__)
@app.get("/health/live")
@app.get("/health/ready")
def health():
# Process-only health for this minimal example.
return {"status": "ok"}
return app# python/gunicorn.conf.py
import os
def positive_int(name, default):
value = os.environ.get(name, str(default))
if not value.isdecimal() or int(value) < 1:
raise ValueError(f"{name} must be a positive integer")
return int(value)
bind = os.environ.get("HTTP_BIND", "0.0.0.0:8000")
workers = positive_int("WEB_CONCURRENCY", 1)
threads = positive_int("GUNICORN_THREADS", 2)
worker_class = "gthread" # WSGI Flask, not an ASGI worker.
control_socket_disable = True # No local administration socket in this example.
preload_app = False
timeout = 30
graceful_timeout = 25
keepalive = 5
max_requests = 1000
max_requests_jitter = 100
accesslog = "-"
errorlog = "-"
loglevel = "info"python -m venv .venv
.venv/bin/python -m pip install -r python/requirements.txt
WEB_CONCURRENCY=2 GUNICORN_THREADS=2 .venv/bin/python -m gunicorn --chdir python --config python/gunicorn.conf.py 'app:create_app()'이 health endpoint는 예제 프로세스만 확인합니다. 실제 의존 서비스 readiness는 애플리케이션에 맞게 구현합니다. Flask 3에는 제거된 before_first_request를 쓰지 않습니다. Gunicorn 26의 관리용 control socket은 이 최소 예제에서 껐습니다.
Flask WSGI와 FastAPI 등의 ASGI를 구별합니다. Gunicorn 26에는 ASGI worker도 있으며, Uvicorn을 선택할 경우 deprecated인 uvicorn.workers와 별도 uvicorn-worker 패키지를 구별합니다. 예제의 gthread 설정을 ASGI 앱에 그대로 적용하지 않습니다. preload_app은 copy-on-write 이점과 fork 이전에 생성한 thread/connection/client의 문제를 함께 검토합니다. worker 재시작 횟수 제한은 누수의 근본 해결책이 아닙니다.
tracemalloc은 Python이 추적하는 할당을 관찰하며 전체 native/RSS를 설명하지 않습니다. 다음은 명시적으로 실행하는 로컬 진단입니다. HTTP debug endpoint로 외부에 노출하지 않습니다.
# python/profile_allocations.py
import tracemalloc
def profile_allocation_delta(workload):
"""Run an explicit local diagnostic; this is not an HTTP debug endpoint."""
tracemalloc.start(10)
try:
before = tracemalloc.take_snapshot()
result = workload()
after = tracemalloc.take_snapshot()
for statistic in after.compare_to(before, "lineno")[:10]:
print(statistic)
return result
finally:
tracemalloc.stop()
if __name__ == "__main__":
profile_allocation_delta(lambda: [bytearray(1024) for _ in range(100)])5. Node.js: old space와 전체 메모리
--max-old-space-size 단위는 MiB이며 V8 old-space 설정입니다. 전체 RSS 상한이 아니고 young generation, external/Buffer, native 메모리 등이 별도로 필요합니다. 여러 worker 프로세스가 있으면 각 프로세스의 힙 예산도 합산해야 합니다. Node.js 20은 2026년 4월 EOL이므로 새 예제는 지원 중인 Node.js 24를 사용합니다.
다음은 단일 Node 프로세스에서 health endpoint, 메모리 관찰과 SIGTERM 종료를 구현한 예제입니다. 비율만 보고 global.gc()를 반복 호출하는 것을 일반적인 최적화로 제시하지 않습니다. --expose-gc는 검증한 Node 24에서 NODE_OPTIONS로 허용되지만, 사용 가능하다는 것과 운영에서 강제 GC가 효과적이라는 것은 별개입니다.
// node/server.cjs
const http = require('node:http');
const port = Number(process.env.PORT || 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer between 1 and 65535');
}
let draining = false;
const server = http.createServer((req, res) => {
if (!['/health/live', '/health/ready'].includes(req.url)) {
res.writeHead(404).end();
return;
}
const status = draining && req.url === '/health/ready' ? 503 : 200;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: status === 200 ? 'ok' : 'draining' }));
});
server.listen(port, process.env.HTTP_HOST || '0.0.0.0');
const monitor = setInterval(() => {
const { rss, heapUsed, heapTotal, external, arrayBuffers } = process.memoryUsage();
console.log(JSON.stringify({ rss, heapUsed, heapTotal, external, arrayBuffers }));
}, 30000);
monitor.unref();
function shutdown() {
if (draining) return;
draining = true;
clearInterval(monitor);
server.close(() => process.exit(0));
setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);NODE_OPTIONS="--max-old-space-size=512" node node/server.cjsUV_THREADPOOL_SIZE는 libuv thread pool을 사용하는 파일 I/O·일부 DNS·암호화 작업 등에 영향을 줍니다. 모든 네트워크 I/O가 이 pool에서 실행되는 것은 아닙니다. thread 수를 늘리면 stack·동시 작업 메모리도 늘 수 있습니다. 프로세스 시작 전에 설정하고 실제 부하로 평가합니다.
Node cluster를 쓴다면 isPrimary를 사용하고 worker 수를 명시적으로 제한합니다. os.cpus().length나 PM2 instances: max가 quota를 정확히 반영한다고 가정하지 않습니다. availableParallelism()도 모든 cgroup 제약을 정확히 환산하는 worker 공식으로 간주하지 않습니다. worker crash 재시작에는 backoff와 종료 중 재생성 방지가 필요하며, Kubernetes의 Pod 복제와 프로세스 복제를 함께 사용하면 전체 worker 수와 메모리를 곱해서 계산합니다.
6. Go와 Rust
Go
Go 1.25부터 기본 GOMAXPROCS가 Linux cgroup CPU quota를 고려합니다. go.mod 언어 버전과 GODEBUG 호환성 기본값도 영향을 주며 새 예제는 go 1.25.0 이상을 사용합니다. 현재 검증은 Go 1.27.1로 수행했습니다. 수동 GOMAXPROCS 환경 변수나 양수 runtime.GOMAXPROCS(n) 호출은 자동 갱신을 끕니다. 조회용 runtime.GOMAXPROCS(0)는 변경하지 않습니다.
현재 런타임은 quota를 올림하고 logical CPU/affinity 등도 고려합니다. 논리 CPU와 affinity가 충분한 경우 기본값 2 미만으로 내려가지 않는 조건도 있어 500m → 항상 1 같은 단순 공식은 맞지 않습니다. automaxprocs의 버전/반올림 정책과 Go 내장 기본값을 동일하게 취급하거나 둘을 무조건 중복 적용하지 않습니다. CPU request만으로 quota를 계산하지 않습니다.
// go/go.mod
module example.com/resource-probe
go 1.25.0// go/main.go
package main
import (
"encoding/json"
"os"
"runtime"
"runtime/debug"
)
func main() {
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
// A negative value queries the current setting without changing it.
limit := debug.SetMemoryLimit(-1)
result := map[string]any{
"gomaxprocs": runtime.GOMAXPROCS(0),
"goroutines": runtime.NumGoroutine(),
"go_managed_bytes": memory.Sys - memory.HeapReleased,
"go_soft_memory_limit": limit,
"runtime_version": runtime.Version(),
}
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
panic(err)
}
}go -C go build -o resource-probe .
GOMEMLIMIT=450MiB ./go/resource-probeGOMEMLIMIT은 Go runtime 관리 메모리의 soft limit입니다. 대략 MemStats.Sys - HeapReleased 범위이며 cgo·mmap·외부 라이브러리 등 전체 RSS 한도가 아닙니다. GC 비용 제한 때문에 목표를 초과할 수도 있습니다. 컨테이너 limit의 80–90%면 OOM을 방지한다는 보장은 없고, live heap보다 지나치게 작은 값은 GC 부담을 크게 만들 수 있습니다. 위 프로그램은 현재 값 조회용이며 cgroup을 직접 읽거나 바꾸지 않습니다.
Rust
GC가 없다고 메모리 사용이나 지연이 결정적인 것은 아닙니다. allocator, fragmentation, 동시 작업, buffer, blocking 작업과 OS 스케줄링을 관찰합니다. jemalloc 같은 allocator 교체는 프로파일 결과와 플랫폼 지원을 근거로 시험하며 모든 서비스에 더 빠르다는 전제를 두지 않습니다.
다음 Tokio 예제는 runtime 설정을 검증하는 작은 프로그램이며 HTTP 서버 예제는 아닙니다. worker_threads()를 직접 지정하면 환경 변수보다 우선하므로 여기서는 생략했습니다. worker thread 수는 spawn_blocking pool이나 별도 라이브러리 thread의 총량 제한이 아닙니다.
# rust/Cargo.toml
[package]
name = "resource-probe"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "=1.53.1", features = ["rt-multi-thread", "time"] }// rust/src/main.rs
use std::time::Duration;
use tokio::runtime::Builder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Without worker_threads(), Tokio can honor TOKIO_WORKER_THREADS.
// This example rejects invalid values before building the runtime.
if let Ok(value) = std::env::var("TOKIO_WORKER_THREADS") {
let workers: usize = value.parse()?;
if workers == 0 {
return Err("TOKIO_WORKER_THREADS must be positive".into());
}
}
let runtime = Builder::new_multi_thread()
.enable_time()
.build()?;
println!("worker_threads={}", runtime.metrics().num_workers());
runtime.block_on(async {
tokio::time::sleep(Duration::from_millis(10)).await;
});
Ok(())
}cargo build --manifest-path rust/Cargo.toml
TOKIO_WORKER_THREADS=2 ./rust/target/debug/resource-probe처음 만드는 프로젝트에는 lockfile이 없으므로 cargo build로 생성하고 검토·커밋합니다. 그 이후 재현 가능한 빌드는 --locked를 사용합니다. CPU 작업을 무제한 async task로 생성하는 대신 bounded queue·동시성 제한을 설계합니다. 언어별 “몇 MB, 몇 ms, 몇 배 빠름” 표는 실제 같은 조건의 벤치마크 없이는 사용하지 않습니다.
7. PromQL과 알림
다음 규칙은 kube-prometheus-stack의 job="kubelet", metrics_path="/metrics/cadvisor"와 aggregate cpu="total" 시계열을 전제로 합니다. per-CPU 수집이면 그 수집 방식에 맞게 집계합니다. kube-state-metrics도 필요합니다. 사용자 label 계약이 다르면 selector를 실제 데이터에 맞춥니다.
동일 관측값의 중복 scrape를 더하지 않도록 예제는 container 단위 max 집계를 사용합니다. 여러 런타임 ID가 같은 Pod/container 이름 아래 겹치는 재시작 구간은 별도 확인하고, 이 값들을 정밀한 CPU 사용량 청구 자료로 사용하지 않습니다. 다중 클러스터에는 실제 수집/remote-write 경로의 cluster 구분이 있어야 하며, 없는 cluster label을 PromQL이 만들어 주지는 않습니다.
규칙 순서대로 request/limit 분모를 맞추고 0을 제외합니다. 메모리 working set은 정확한 OOM 예측값이 아니며 page cache와 reclaim 동작도 확인합니다. 마지막 종료 reason은 gauge이므로 increase(reason)으로 OOM 횟수를 계산하지 않습니다. 아래 OOM 알림은 최근 재시작 + 마지막 기록된 OOM reason이며 기간 내 모든 OOM의 정확한 횟수는 아닙니다.
# resource-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: resource-review
namespace: observability
labels:
release: prometheus
spec:
groups:
- name: resource-review
interval: 1m
rules:
- record: resource:cpu_cores:rate5m
expr: max by (cluster, namespace, pod, container) (rate(container_cpu_usage_seconds_total{job="kubelet",metrics_path="/metrics/cadvisor",container!="",container!="POD",pod!="",cpu="total"}[5m]))
- record: resource:cpu_requests:cores
expr: max by (cluster, namespace, pod, container) (kube_pod_container_resource_requests{resource="cpu",unit="core"})
- record: resource:memory_working_set:bytes
expr: max by (cluster, namespace, pod, container) (container_memory_working_set_bytes{job="kubelet",metrics_path="/metrics/cadvisor",container!="",container!="POD",pod!=""})
- record: resource:memory_limit:bytes
expr: max by (cluster, namespace, pod, container) (kube_pod_container_resource_limits{resource="memory",unit="byte"})
- record: resource:cpu_request_ratio
expr: resource:cpu_cores:rate5m / (resource:cpu_requests:cores > 0)
- record: resource:memory_limit_ratio
expr: resource:memory_working_set:bytes / (resource:memory_limit:bytes > 0)
- record: resource:cfs_throttled:rate5m
expr: max by (cluster, namespace, pod, container) (rate(container_cpu_cfs_throttled_periods_total{job="kubelet",metrics_path="/metrics/cadvisor",container!="",container!="POD",pod!=""}[5m]))
- record: resource:cfs_periods:rate5m
expr: max by (cluster, namespace, pod, container) (rate(container_cpu_cfs_periods_total{job="kubelet",metrics_path="/metrics/cadvisor",container!="",container!="POD",pod!=""}[5m]))
- record: resource:cfs_throttled_period_ratio
expr: resource:cfs_throttled:rate5m / (resource:cfs_periods:rate5m > 0)
- record: resource:recent_restart_last_oom
expr: (max by (cluster, namespace, pod, container) (increase(kube_pod_container_status_restarts_total[5m]))
> 0) and on (cluster, namespace, pod, container) (max by (cluster, namespace,
pod, container) (kube_pod_container_status_last_terminated_reason{reason="OOMKilled"})
== 1)
- record: cluster:pending_pods:count
expr: sum by (cluster) (max by (cluster, namespace, pod) (kube_pod_status_phase{phase="Pending"}))
- record: node:pods:count
expr: count by (cluster, node) (max by (cluster, node, namespace, pod) (kube_pod_info{node!=""}))
- alert: HighCPUThrottledPeriodRatio
expr: resource:cfs_throttled_period_ratio > 0.25
for: 10m
labels:
severity: warning
annotations:
summary: HighCPUThrottledPeriodRatio
description: A high fraction of quota periods were throttled; correlate with
latency and throughput.
- alert: MemoryWorkingSetNearLimit
expr: resource:memory_limit_ratio > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: MemoryWorkingSetNearLimit
description: Working set is near the configured limit; this is not an exact
OOM prediction.
- alert: RecentRestartWithLastReasonOOM
expr: resource:recent_restart_last_oom > 0
for: 0m
labels:
severity: warning
annotations:
summary: RecentRestartWithLastReasonOOM
description: A recent restart has OOMKilled as its last recorded reason; inspect
termination state.release: prometheus와 observability 네임스페이스는 해당 Prometheus의 rule selector와 맞춰야 합니다. 임계값은 예시이며 HighCPUThrottledPeriodRatio가 곧바로 limit 증가를 요구하는 것은 아닙니다. 지연·처리량·동시성·노드 경합을 함께 확인합니다.
cluster:pending_pods:count는 0/1 phase gauge의 값을 합산합니다. count(kube_pod_status_phase{phase="Pending"})는 0인 값도 세므로 Pending Pod 수가 아닙니다. Pending에는 이미 스케줄된 뒤 이미지/스토리지를 기다리는 Pod도 포함되어 리소스 부족과 동일하지 않습니다. node:pods:count는 노드별 Pod 수이며 이를 전체 노드 수로 다시 나누지 않습니다.
Deployment별 분석은 Pod 이름 정규식으로 소유자를 추측하지 말고 kube-state-metrics의 Pod→ReplicaSet→Deployment owner 관계 또는 검증된 recording rule을 사용합니다. 장기간 과잉 예약 분석은 롤아웃·휴일·예비 용량과 SLO를 함께 평가하며 자동 eviction 기준으로 쓰지 않습니다.
JVM 쿼리와 대시보드 연결
다음 쿼리는 위 Micrometer 설정을 사용하는 JVM을 대상으로 합니다. Prometheus가 붙이는 instance로 프로세스를 구분하고, scrape에서 얻은 실제 metric 이름을 확인합니다.
sum by (instance, application) (jvm_memory_used_bytes{area="heap"})
/
sum by (instance, application) (jvm_memory_max_bytes{area="heap"} > 0)histogram_quantile(0.99,
sum by (le, instance, application) (rate(jvm_gc_pause_seconds_bucket[5m]))
)sum by (instance, application) (rate(jvm_gc_pause_seconds_sum[5m]))rate(jvm_classes_loaded_count_classes_total[5m])jvm_gc_pause_seconds_sum의 rate는 관찰한 pause 초/벽시계 초이며 process CPU로 나눈 “GC CPU 비율”이 아닙니다. 동시 GC 전체 비용을 모두 포함하지도 않습니다. jvm_classes_loaded_classes는 현재 로드된 class 수 gauge이고, 검증한 Micrometer의 누적 로드 counter는 jvm_classes_loaded_count_classes_total입니다. JDK/라이브러리 버전이 다르면 이름을 실제 노출값과 맞춥니다.
Grafana에는 앞 장의 명시적 datasource UID와 dashboard provisioning을 사용합니다. 위 rule의 ratio에는 percentunit 또는 ×100 후 percent를 선택해 단위를 맞춥니다. raw utilization gauge를 histogram bucket인 것처럼 heatmap에 넣지 않습니다. 부분 panel JSON을 완성된 import용 dashboard라고 안내하지 않습니다.
8. EKS Auto Mode와 여유 용량
Auto Mode도 Pod request, 실제 Node allocatable, DaemonSet/시스템 overhead, Pod overhead, 포트·볼륨·토폴로지·taint 등 배치 제약을 고려해야 합니다. 4 vCPU 인스턴스의 allocatable이 정확히 4 CPU라고 가정해 4 CPU request를 꽉 채우는 계산은 부정확합니다. 인스턴스가 클수록 언제나 효율적이거나 작을수록 항상 저렴한 것도 아닙니다. 장애 영향 범위·사용 가능한 타입·가격·fragmentation을 함께 평가합니다.
Auto Mode NodePool의 API는 karpenter.sh/v1이고 NodeClass의 group은 eks.amazonaws.com입니다. 아래는 이미 존재하는 Auto Mode default NodeClass를 참조합니다. NodeClass·서브넷·역할·해당 리전의 인스턴스 가용성은 별도로 확인합니다.
# auto-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: resource-demo
spec:
template:
spec:
nodeClassRef:
group: eks.amazonaws.com
kind: NodeClass
name: default
requirements:
- key: node.kubernetes.io/instance-type
operator: In
values:
- m7i.large
- m7i.xlarge
- m7i.2xlarge
- key: karpenter.sh/capacity-type
operator: In
values:
- on-demand
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 5m노드 통합은 request와 배치 가능성·가격·disruption 제약 등을 바탕으로 결정하며 “실측 CPU 30% 미만이면 반드시 통합”하는 규칙이 아닙니다. PDB가 모든 종료를 막거나 강제 종료까지 무중단을 보장하지 않습니다. limit이 매우 커도 request가 같다면 일반 스케줄러가 그 limit을 그대로 예약하는 것은 아닙니다.
다음 placeholder Pod는 높은 우선순위의 실제 작업이 사용할 여유 용량을 유도할 수 있습니다. Priority -1은 기본 0보다 낮지만 가능한 모든 Priority 중 최저값은 아닙니다. preemptionPolicy: Never는 이 Pod가 다른 Pod를 선점하지 않도록 하며, 자신이 선점되는 것을 막지 않습니다. EC2 Capacity Reservation이나 가용 용량 보장이 아니고, topology/taint/리소스 모양이 실제 작업과 맞아야 합니다.
# capacity-buffer.yaml
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: capacity-buffer
value: -1
preemptionPolicy: Never
globalDefault: false
description: Lower priority placeholder capacity; not a reservation guarantee.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: capacity-buffer
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: capacity-buffer
template:
metadata:
labels:
app: capacity-buffer
spec:
priorityClassName: capacity-buffer
containers:
- name: pause
image: registry.k8s.io/pause:3.10.2
resources:
requests:
cpu: '2'
memory: 4Gi적용 순서와 검증 한계
- 대표 부하에서 지연·처리량·CPU quota·전체 메모리와 재시작 원인을 측정합니다.
- VPA 추천·운영 관측으로 request 후보를 만들고 HPA와 런타임 동시성을 함께 검토합니다.
- 시작·피크·실패·재시작을 포함한 비운영 시험으로 limit과 여유 용량을 조정합니다.
- Canary에서 SLO·비용·노드 배치·eviction/중단 변화를 관찰하고 되돌릴 설정을 보관합니다.
검토에서는 Java/Spring/JMX의 실제 메트릭·JFR/NMT, Gunicorn/Node health와 SIGTERM, Go 런타임 값, Tokio worker 설정을 로컬에서 실행했습니다. PromQL 계산은 중복 scrape, 다중 클러스터, 0 분모, 과거 OOM, Pending 0/1 gauge 입력으로 시험했습니다. 이것은 실제 EKS 배포, cgroup quota 변경 시험, OOM 강제 발생, 성능 벤치마크를 수행했다는 뜻은 아닙니다.
공식 자료
- Kubernetes resources
- Node-pressure eviction
- Go container-aware GOMAXPROCS
- Go GC guide
- Java 21 options
- JEP 474
- JEP 490
- Spring Boot properties
- JMX exporter 1.6.0
- Gunicorn settings
- Flask changelog
- Node.js 24 CLI
- Tokio runtime builder
< 이전: Observability 스택 | 목차 | 다음: EKS 업그레이드 >