토요일

Kubernetes OOMKilled 에러 해결 (Exit Code 137) — Spring Boot/Java Pod 메모리 초과 완전 정리

🔍 검색 키워드: kubernetes oomkilled, exit code 137, pod oomkilled 해결, spring boot kubernetes memory, k8s OOMKilled java, 쿠버네티스 메모리 에러, kubectl oomkilled 원인

증상

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: OOMKilledCompleted 또는 Running
현재 메모리 사용량kubectl top podlimit의 70% 이하
JVM 힙 설정JAVA_OPTS 환경변수limit의 50~75%
Metaspace 제한-XX:MaxMetaspaceSize256m 이하 권장
requests ≤ limitsDeployment 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)768Milimit의 약 75%
Metaspace256Mi-XX:MaxMetaspaceSize
Stack, Direct Buffer 등~100MiOS/JVM 관리
총계~1124Milimit 초과 → OOMKilled 위험!
위 예시도 빡빡하다. limit를 1.5Gi 이상으로 올리거나 Metaspace를 줄여야 한다.

방법 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만 조정해도 알아서 따라온다.

Java 8 사용자: update 191 이상이면 UseContainerSupport 지원됨. 미만이면 -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 앱이라면 순서대로 확인:

  1. kubectl describe pod로 OOMKilled 확인
  2. kubectl top pod로 실사용량 파악
  3. JAVA_OPTS에서 -Xmx vs limit 비율 점검
  4. Java 11+이면 UseContainerSupport + MaxRAMPercentage로 교체
  5. 그래도 반복되면 힙 덤프 분석

limit를 무한정 올리는 게 해결책이 아니다. 적정 비율로 잡고, 모니터링으로 추세를 보면서 튜닝하는 게 맞다.

금요일

.env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결

🔍 검색 키워드: process.env undefined, dotenv not working, .env 파일 적용 안됨, python-dotenv not loading, 환경변수 undefined, docker 환경변수 안됨, next.js env not working

상황

.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 파일 경로가 다르다

기본적으로 dotenvprocess.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
⚠️ Dockerfile에서 COPY .env .를 하면 이미지에 시크릿이 박힌다. .dockerignore.env를 넣고, 런타임에 주입하는 방식을 써야 한다.
# .dockerignore
.env
.env.*

상황별 체크리스트

증상원인확인/해결
로컬만 됨, 서버 안 됨.env가 서버에 없음CI/CD 시크릿 주입 또는 서버에 .env 생성
dotenv.config() 했는데 undefinedimport 순서 문제진입점 최상단에서 dotenv 먼저 로드
특정 변수만 undefined변수명 오타, 공백.env 파일 문법 확인
Docker 안에서 undefinedenv_file 미설정env_file 또는 -e 옵션으로 주입
Next.js 클라이언트에서 undefinedNEXT_PUBLIC_ 접두사 누락접두사 추가 후 재빌드
Python에서 Noneload_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%다.

PostgreSQL "FATAL: password authentication failed" 에러 완전 해결 가이드

🔍 검색 키워드: postgresql fatal password authentication failed, psql role does not exist, postgresql connection refused, pg_hba.conf, postgresql 비밀번호 에러

상황

PostgreSQL에 접속하려는데 이런 에러가 뜬다.

FATAL: password authentication failed for user "myapp"

또는

FATAL: role "myapp" does not exist

또는

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL: Peer authentication failed for user "postgres"

전부 다르게 생겼지만 원인은 비슷한 경우가 많다. 하나씩 짚어보자.


원인 1: 비밀번호가 틀렸다 (가장 흔한 경우)

앱 설정 파일에 넣은 비밀번호와 실제 DB 비밀번호가 다른 거다. .env, application.yml, database.yml 등에서 비밀번호 오타나 환경별 혼용이 잦다.

확인 방법:

# postgres 유저로 직접 접속해서 비밀번호 재설정
sudo -u postgres psql

-- 현재 유저 목록 확인
\du

-- 비밀번호 재설정
ALTER USER myapp WITH PASSWORD 'newpassword';

원인 2: 유저 자체가 없다 (role does not exist)

DB는 있는데 유저를 만든 적이 없거나, 다른 환경에서 만든 유저가 이 환경에는 없는 경우다. 로컬에서 개발하다가 스테이징 DB로 붙으려 할 때 자주 발생한다.

해결:

-- postgres 슈퍼유저로 접속 후
CREATE USER myapp WITH PASSWORD 'yourpassword';

-- 데이터베이스 권한 부여
GRANT ALL PRIVILEGES ON DATABASE mydb TO myapp;

-- 스키마 권한도 줘야 하는 경우
GRANT ALL ON SCHEMA public TO myapp;

원인 3: pg_hba.conf 인증 방식 문제 (Peer auth failed)

Peer authentication failed 에러는 pg_hba.conf의 인증 방식 설정 문제다. 로컬 소켓 접속 시 OS 유저명과 DB 유저명이 같아야 하는 peer 방식으로 설정돼 있을 때 발생한다.

pg_hba.conf 위치 확인:

sudo -u postgres psql -c "SHOW hba_file;"
# 보통 /etc/postgresql/14/main/pg_hba.conf 또는 /var/lib/pgsql/data/pg_hba.conf

pg_hba.conf 수정:

sudo nano /etc/postgresql/14/main/pg_hba.conf

수정 전:

local   all   all   peer

수정 후 (비밀번호 인증으로 변경):

local   all   all   md5

또는 특정 유저만:

local   mydb   myapp   md5
host    mydb   myapp   127.0.0.1/32   md5

변경 후 재시작:

sudo systemctl restart postgresql

원인 4: Docker 환경에서 환경변수 미전달

Docker로 PostgreSQL 띄울 때 POSTGRES_PASSWORD 없이 컨테이너를 올리거나, 앱 컨테이너에 DB 접속 정보를 제대로 안 넘긴 경우다.

# docker-compose.yml 올바른 예시
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: mypassword
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    environment:
      DATABASE_URL: postgresql://myapp:mypassword@db:5432/mydb
    depends_on:
      db:
        condition: service_healthy
💡 depends_on은 컨테이너 시작 순서만 보장한다. PostgreSQL이 실제로 준비됐는지는 healthcheckcondition: service_healthy로 처리해야 한다.

원인 5: 접속 호스트/포트 오류

Connection refused는 PostgreSQL이 해당 주소에서 리슨하고 있지 않다는 뜻이다.

# PostgreSQL이 실제로 떠 있는지
sudo systemctl status postgresql

# 어느 포트에서 리슨 중인지
sudo ss -tlnp | grep 5432

# 외부 접속 허용 설정 확인
sudo grep listen_addresses /etc/postgresql/14/main/postgresql.conf

외부에서 접속하려면 postgresql.conf에서:

listen_addresses = '*'

그리고 pg_hba.conf에도 원격 접속 허용 라인 추가:

host    all   all   0.0.0.0/0   md5

상황별 체크리스트

증상확인할 것해결책
password authentication failed비밀번호 오타, 환경 혼용ALTER USER ... WITH PASSWORD
role does not exist유저 미생성CREATE USER + 권한 부여
Peer authentication failedpg_hba.conf 설정peer → md5 변경 후 재시작
Connection refusedPostgreSQL 미실행 또는 포트 불일치서비스 상태 확인, listen_addresses 설정
Docker에서만 발생컨테이너 간 네트워크, 환경변수db 호스트명 사용, healthcheck 추가

Node.js 접속 예시 (pg 라이브러리)

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
  connectionTimeoutMillis: 5000,
  idleTimeoutMillis: 30000,
  max: 10,
});

pool.query('SELECT NOW()', (err, res) => {
  if (err) {
    console.error('DB 연결 실패:', err.message);
  } else {
    console.log('DB 연결 성공:', res.rows[0].now);
  }
});

Python (psycopg2, SQLAlchemy)

import psycopg2
from psycopg2 import OperationalError
import os

def create_connection():
    try:
        conn = psycopg2.connect(
            host=os.getenv("DB_HOST", "localhost"),
            port=int(os.getenv("DB_PORT", 5432)),
            database=os.getenv("DB_NAME"),
            user=os.getenv("DB_USER"),
            password=os.getenv("DB_PASSWORD"),
        )
        print("PostgreSQL 연결 성공")
        return conn
    except OperationalError as e:
        print(f"연결 실패: {e}")
        raise

from sqlalchemy import create_engine
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)

마무리

PostgreSQL 접속 에러는 대부분 세 가지다: 비밀번호 틀림, 유저 없음, pg_hba.conf 설정 문제. 에러 메시지를 정확히 읽으면 원인이 나온다. FATAL: 뒤에 오는 텍스트가 전부다. Connection refused는 PostgreSQL 자체가 안 떠있거나 포트가 다른 거고, authentication failed는 자격증명 문제, role does not exist는 유저 생성을 안 한 거다.

Docker 환경이면 컨테이너 간 네트워크와 healthcheck까지 챙겨야 한다.