내멋스레 사는 이야기
개발 트러블슈팅 완전 정복 | 웹개발·백엔드·데브옵스 실무 에러 해결법. Docker, GitHub Actions, TypeScript, MySQL, Redis 등 실전 경험 기반의 개발 블로그
토요일
Kubernetes OOMKilled 에러 해결 (Exit Code 137) — Spring Boot/Java Pod 메모리 초과 완전 정리
ì¦ì
Pod를 describe íëë ì´ë° ê² ëì¨ë¤.
State: Running
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: ...
Finished: ...
kubectl get podsìì STATUSê° OOMKilled ëë Errorë¡ ë¨ê³ , Restarts ì¹´ì´í°ê° ê³ì ì¬ë¼ê°ë¤.
ìì¸
컨í
ì´ëê° ì¤ì ë resources.limits.memory를 ì´ê³¼í´ì 커ëì´ ê°ì ë¡ íë¡ì¸ì¤ë¥¼ ì£½ì¸ ê²ì´ë¤. Exit Code 137ì SIGKILL(128 + 9)ì ì미íë¤.
Java ê¸°ë° Spring Boot ì±ìì í¹í ì주 ë°ìíë ì´ì ê° ìë¤:
- JVM í í¬ê¸°ë 컨í ì´ë limitì ë³ê°ë¡ ëì
- JVMì 기본ì ì¼ë¡ í¸ì¤í¸ ì ì²´ ë©ëª¨ë¦¬ë¥¼ 기ì¤ì¼ë¡ íì ì¡ì¼ë ¤ í¨
- Off-heap ë©ëª¨ë¦¬(Metaspace, Stack, Direct Buffer ë±)ê° í ì¸ì ì¶ê°ë¡ ìë¹ë¨
ê²°ê³¼ì ì¼ë¡ -Xmx512m ì¤ì ì limit를 512Mië¡ ì£¼ë©´ ë°ëì OOMKilled ëë¤.
ë¹ ë¥¸ ì§ë¨
1. íì¬ ë©ëª¨ë¦¬ ì¬ì©ë íì¸
# Pod ë©ëª¨ë¦¬ íí©
kubectl top pod <pod-name> -n <namespace>
# ìì¸ ìí íì¸
kubectl describe pod <pod-name> -n <namespace> | grep -A 10 "Last State"
# ìµê·¼ ì¢
ë£ë 컨í
ì´ë ë¡ê·¸
kubectl logs <pod-name> --previous -n <namespace>
2. 리ìì¤ ì¤ì íì¸
kubectl get pod <pod-name> -o jsonpath='{.spec.containers[*].resources}' -n <namespace>
{
"limits": { "memory": "512Mi" },
"requests": { "memory": "256Mi" }
}
3. ë ¸ë ì ì²´ ë©ëª¨ë¦¬ íí©
kubectl top nodes
kubectl describe node <node-name> | grep -A 5 "Allocated resources"
ì²´í¬ë¦¬ì¤í¸
| í목 | íì¸ ë°©ë² | ì ì ê¸°ì¤ |
|---|---|---|
| Pod OOMKilled ì¬ë¶ | kubectl describe pod â Reason: OOMKilled | Completed ëë Running |
| íì¬ ë©ëª¨ë¦¬ ì¬ì©ë | kubectl top pod | limitì 70% ì´í |
| JVM í ì¤ì | JAVA_OPTS íê²½ë³ì | limitì 50~75% |
| Metaspace ì í | -XX:MaxMetaspaceSize | 256m ì´í ê¶ì¥ |
| requests ⤠limits | Deployment YAML | íì requests ⤠limits |
| OOM Heap Dump | -XX:+HeapDumpOnOutOfMemoryError | íì¼ ìì± íì¸ |
í´ê²° ë°©ë²
ë°©ë² 1: JVM íì 컨í ì´ë limitì ë§ê² ëª ì ì¤ì
ê°ì¥ íí ì¤ì. limit ëë¹ JVM í ë¹ì¨ì ë°ëì ë§ì¶°ì¼ íë¤.
# deployment.yaml
containers:
- name: my-spring-app
image: my-app:latest
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
env:
- name: JAVA_OPTS
value: "-Xms256m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseContainerSupport"
ë©ëª¨ë¦¬ ë°°ë¶ ê³ì° ìì (limit: 1Gi = 1024Mi)
| ìì | í¬ê¸° | ë¹ê³ |
|---|---|---|
| JVM Heap (-Xmx) | 768Mi | limitì ì½ 75% |
| Metaspace | 256Mi | -XX:MaxMetaspaceSize |
| Stack, Direct Buffer ë± | ~100Mi | OS/JVM ê´ë¦¬ |
| ì´ê³ | ~1124Mi | limit ì´ê³¼ â OOMKilled ìí! |
ë°©ë² 2: -XX:+UseContainerSupport íì© (Java 11+)
Java 11 ì´ìììë JVMì´ ì»¨í ì´ë limit를 ìë ì¸ìíë¤.
env:
- name: JAVA_OPTS
value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m"
-XX:MaxRAMPercentage=75.0ì 컨í
ì´ë limitì 75%를 ìëì¼ë¡ íì í ë¹íë¤. -Xmx íëì½ë© ìì´ limitë§ ì¡°ì í´ë ììì ë°ë¼ì¨ë¤.
-Xmx를 ì§ì ê³ì°í´ì ì¤ì í´ì¼ íë¤.
ë°©ë² 3: ì¤ì ë©ëª¨ë¦¬ ëìì¸ ê²½ì° â í ë¤í ë¶ì
limit를 ì¬ë ¤ë ê³ì OOMKilled ëë¤ë©´ ì¤ì ëì를 ìì¬í´ì¼ íë¤.
env:
- name: JAVA_OPTS
value: >-
-Xmx768m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-XX:+ExitOnOutOfMemoryError
ë¤í íì¼ ë¡ì»¬ë¡ ë³µì¬:
kubectl cp <namespace>/<pod-name>:/tmp/heapdump.hprof ./heapdump.hprof
Eclipse MAT ëë VisualVMì¼ë¡ ì´ì´ì Leak Suspects Report ë린ë¤.
ë°©ë² 4: Spring Boot Actuatorë¡ ë©ëª¨ë¦¬ ì¤ìê° ëª¨ëí°ë§
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yaml
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
# JVM ë©ëª¨ë¦¬ ì¬ì©ë íì¸
curl http://localhost:8080/actuator/metrics/jvm.memory.used
curl http://localhost:8080/actuator/metrics/jvm.memory.max
Prometheus + Grafanaë¡ ìê³ì´ ìì§íë©´ OOMKilled ë°ì í¨í´ì ì¡ê¸° ì½ë¤.
ë°©ë² 5: Node.js / Python Podë¼ë©´
Node.js:
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=768"
resources:
limits:
memory: "1Gi"
Python (Gunicorn):
command: ["gunicorn", "--workers=2", "--threads=2", "app:app"]
resources:
limits:
memory: "512Mi"
HPAì VPA ì°ê³
ë¨ê¸° í´ê²°ì limit ì¦ê°ì§ë§, ì¥ê¸°ì ì¼ë¡ë ìë ì¤ì¼ì¼ë§ì´ íìíë¤.
# HPA - ë©ëª¨ë¦¬ ê¸°ì¤ ì¤ì¼ì¼ ìì
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-spring-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-spring-app
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70
ì 리
OOMKilledë ëë¶ë¶ limit ì¤ì ë¶ì¡± ìëë©´ JVM íê³¼ limit ë¶ì¼ì¹ìì ì¨ë¤. Spring Boot ì±ì´ë¼ë©´ ììëë¡ íì¸:
kubectl describe podë¡ OOMKilled íì¸kubectl top podë¡ ì¤ì¬ì©ë íìJAVA_OPTSìì-Xmxvs limit ë¹ì¨ ì ê²- Java 11+ì´ë©´
UseContainerSupport + MaxRAMPercentageë¡ êµì²´ - ê·¸ëë ë°ë³µëë©´ í ë¤í ë¶ì
limit를 무íì ì¬ë¦¬ë ê² í´ê²°ì± ì´ ìëë¤. ì ì ë¹ì¨ë¡ ì¡ê³ , 모ëí°ë§ì¼ë¡ ì¶ì¸ë¥¼ ë³´ë©´ì íëíë ê² ë§ë¤.
금요일
.env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결
상황
.env 파일을 분명히 만들었는데 코드에서 undefined가 찍힌다.
# .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecretkey
console.log(process.env.DATABASE_URL); // undefined ???
또는 Python에서:
import os
print(os.getenv("SECRET_KEY")) # None ???
배포 환경에서만 안 된다거나, Docker 안에서만 안 된다거나, 로컬은 되는데 서버에선 안 된다거나. 패턴은 다양하지만 원인은 몇 가지로 좁혀진다.
Node.js — dotenv 관련
원인 1: dotenv를 아예 안 불렀거나 너무 늦게 불렀다
// 잘못된 예 — DB 모듈보다 나중에 dotenv 로드
const db = require('./database'); // 이미 process.env 읽음
require('dotenv').config(); // 너무 늦었다
// 올바른 예 — 진입점 파일 최상단에서 먼저
require('dotenv').config();
const db = require('./database');
const app = require('./app');
TypeScript / ES Modules:
import 'dotenv/config'; // 이 방법이 제일 깔끔하다
// 또는
import dotenv from 'dotenv';
dotenv.config();
import { createConnection } from './db';
원인 2: .env 파일 경로가 다르다
기본적으로 dotenv는 process.cwd() 기준으로 .env를 찾는다. 실행 위치가 프로젝트 루트가 아니면 못 찾는다.
import path from 'path';
import dotenv from 'dotenv';
dotenv.config({
path: path.resolve(__dirname, '../.env'),
});
원인 3: .env 파일이 .gitignore에 있고 서버에 없다
로컬에서만 됐던 이유가 이거다. .env는 보통 .gitignore에 들어가 있어서 서버에 배포가 안 된다.
# GitHub Actions — 시크릿 주입
steps:
- name: Create .env file
run: |
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env
원인 4: 변수명에 공백이나 따옴표 문제
# 잘못된 .env
DATABASE_URL = postgresql://localhost/mydb # = 주변 공백 금지
# 올바른 .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecret
APP_NAME="My App" # 값에 공백이 필요하면 따옴표 사용
Python — python-dotenv
from dotenv import load_dotenv
import os
# .env 파일 로드 (현재 디렉토리 기준)
load_dotenv()
# 또는 경로 명시
load_dotenv(dotenv_path='/path/to/.env')
# 이미 설정된 환경변수를 덮어쓰려면
load_dotenv(override=True)
print(os.getenv("DATABASE_URL"))
load_dotenv()는 이미 시스템에 설정된 환경변수는 덮어쓰지 않는다. 테스트 환경에서 .env 값으로 강제하려면 override=True를 써야 한다.
# Django settings.py
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('DB_NAME'),
'USER': os.getenv('DB_USER'),
'PASSWORD': os.getenv('DB_PASSWORD'),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
Next.js / React — 프레임워크별 규칙
Next.js는 dotenv를 직접 쓰지 않고 자체 env 로딩 시스템을 쓴다.
- 서버에서만 쓰는 변수:
DATABASE_URL=value - 클라이언트(브라우저)에서도 쓰는 변수:
NEXT_PUBLIC_API_URL=value(반드시NEXT_PUBLIC_접두사)
// 서버 컴포넌트 / API Route에서만 접근 가능
process.env.DATABASE_URL // ✅
// 클라이언트에서 접근하려면 NEXT_PUBLIC_ 접두사 필수
process.env.NEXT_PUBLIC_API_URL // ✅
process.env.API_URL // 클라이언트에서는 undefined ❌
환경 파일 우선순위:
.env.local > .env.development.local > .env.development > .env
NEXT_PUBLIC_ 변수는 빌드 시 정적으로 교체된다. 빌드 후 값을 바꿔도 소용없다. 반드시 빌드 전에 설정해야 한다.
Docker / Docker Compose
# docker-compose.yml
services:
app:
build: .
# 방법 1: env_file 지정 (권장)
env_file:
- .env
# 방법 2: 직접 명시
environment:
- DATABASE_URL=postgresql://db:5432/mydb
- SECRET_KEY=${SECRET_KEY} # 호스트 환경변수에서
# Docker run
docker run --env-file .env myapp
docker run -e DATABASE_URL=xxx -e SECRET_KEY=yyy myapp
COPY .env .를 하면 이미지에 시크릿이 박힌다. .dockerignore에 .env를 넣고, 런타임에 주입하는 방식을 써야 한다.
# .dockerignore
.env
.env.*
상황별 체크리스트
| 증상 | 원인 | 확인/해결 |
|---|---|---|
| 로컬만 됨, 서버 안 됨 | .env가 서버에 없음 | CI/CD 시크릿 주입 또는 서버에 .env 생성 |
| dotenv.config() 했는데 undefined | import 순서 문제 | 진입점 최상단에서 dotenv 먼저 로드 |
| 특정 변수만 undefined | 변수명 오타, 공백 | .env 파일 문법 확인 |
| Docker 안에서 undefined | env_file 미설정 | env_file 또는 -e 옵션으로 주입 |
| Next.js 클라이언트에서 undefined | NEXT_PUBLIC_ 접두사 누락 | 접두사 추가 후 재빌드 |
| Python에서 None | load_dotenv() 미호출 | import 후 load_dotenv() 호출 |
| 기존 시스템 변수가 우선 | override 미설정 | load_dotenv(override=True) |
디버깅 팁
// 로드된 환경변수 키 목록 확인
console.log('ENV keys:', Object.keys(process.env).filter(k => !k.startsWith('npm_')));
// .env 경로 확인
const path = require('path');
console.log('Looking for .env at:', path.resolve(process.cwd(), '.env'));
# .env 파일에서 읽은 값만 확인 (시스템 변수 제외)
from dotenv import dotenv_values
config = dotenv_values(".env")
print(config)
마무리
환경변수 에러는 대부분 세 가지 중 하나다: .env 파일이 없거나, dotenv 로드를 너무 늦게 했거나, 프레임워크별 규칙을 무시했거나.
서버에 배포했을 때 갑자기 안 된다면 .env가 서버에 실제로 존재하는지부터 확인하고, Docker면 --env-file이나 env_file로 주입됐는지 확인한다. Next.js면 클라이언트에서 쓰는 변수에 NEXT_PUBLIC_ 붙이고 재빌드. 이 세 가지가 90%다.