이미지 로딩 중...
AI Generated
2025. 11. 6. · 3 Views
DevOps 트러블슈팅 완벽 가이드
실무에서 자주 마주치는 DevOps 장애 상황과 해결 방법을 다룹니다. 컨테이너 오류, 배포 실패, 성능 저하 등 실전 트러블슈팅 노하우를 제공합니다.
목차
- 컨테이너 메모리 누수 디버깅 - 프로덕션 환경의 숨은 메모리 킬러 찾기
- Kubernetes Pod CrashLoopBackOff 해결 - 무한 재시작 지옥 탈출하기
- CI/CD 파이프라인 실패 디버깅 - 배포가 막혔을 때 빠르게 대응하기
- 프로덕션 로그 분석과 에러 추적 - 수천 줄 로그에서 원인 찾기
- 네트워크 타임아웃과 연결 실패 해결 - 서비스 간 통신 문제 잡기
- 데이터베이스 연결 풀 고갈 디버깅 - "too many connections" 에러 해결
- 배포 롤백 자동화 - 잘못된 배포를 빠르게 되돌리기
- 디스크 용량 부족 문제 해결 - "No space left on device" 긴급 대응
1. 컨테이너 메모리 누수 디버깅 - 프로덕션 환경의 숨은 메모리 킬러 찾기
시작하며
여러분이 프로덕션 환경에서 서비스를 운영하다가 갑자기 컨테이너가 OOMKilled 상태로 떨어진 경험이 있나요? 모니터링 대시보드에서 메모리 사용량이 계단식으로 증가하다가 어느 순간 컨테이너가 죽어버리는 상황 말이죠.
이런 문제는 실제 개발 현장에서 매우 빈번하게 발생합니다. 특히 Node.js 기반의 마이크로서비스나 장시간 실행되는 백그라운드 작업에서 자주 나타나죠.
메모리 누수는 초기에는 눈에 띄지 않지만, 시간이 지나면서 서비스 안정성을 크게 해칠 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 메모리 프로파일링과 모니터링입니다.
Docker 컨테이너의 메모리 사용량을 실시간으로 추적하고, 힙 덤프를 분석하여 메모리 누수의 원인을 정확히 찾아낼 수 있습니다.
개요
간단히 말해서, 컨테이너 메모리 누수 디버깅은 애플리케이션이 사용하는 메모리가 정상적으로 해제되지 않고 계속 쌓이는 현상을 찾아내고 해결하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 메모리 누수는 서비스의 점진적인 성능 저하를 일으키고 결국 장애로 이어집니다.
예를 들어, 대량의 이벤트를 처리하는 이벤트 리스너가 제대로 정리되지 않거나, 큰 객체들이 클로저에 의해 참조되어 가비지 컬렉션되지 않는 경우에 매우 유용합니다. 기존에는 애플리케이션이 느려지거나 죽을 때마다 단순히 재시작하는 임시방편을 사용했다면, 이제는 Docker stats, Node.js 힙 스냅샷, Chrome DevTools를 활용하여 근본 원인을 찾아낼 수 있습니다.
이 디버깅 방법의 핵심 특징은 실시간 메모리 모니터링, 힙 덤프 비교 분석, 그리고 메모리 사용 패턴 시각화입니다. 이러한 특징들이 중요한 이유는 문제를 사후에 분석하는 것이 아니라 발생 시점에 정확히 포착할 수 있기 때문입니다.
코드 예제
// Docker 컨테이너의 메모리 사용량을 실시간으로 모니터링하는 스크립트
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
async function monitorContainerMemory(containerId, intervalMs = 5000) {
console.log(`Starting memory monitoring for container: ${containerId}`);
setInterval(async () => {
try {
// Docker stats에서 메모리 사용량 추출
const { stdout } = await execPromise(
`docker stats ${containerId} --no-stream --format "{{.MemUsage}}"`
);
const [used, total] = stdout.trim().split(' / ');
const usedMB = parseFloat(used);
const totalMB = parseFloat(total);
const percentage = ((usedMB / totalMB) * 100).toFixed(2);
console.log(`[${new Date().toISOString()}] Memory: ${used} / ${total} (${percentage}%)`);
// 메모리 사용량이 80% 이상이면 경고
if (percentage > 80) {
console.warn(`⚠️ HIGH MEMORY USAGE: ${percentage}%`);
// 힙 덤프 생성 트리거
await takeHeapSnapshot(containerId);
}
} catch (error) {
console.error('Monitoring error:', error.message);
}
}, intervalMs);
}
async function takeHeapSnapshot(containerId) {
const timestamp = Date.now();
const filename = `heap-${timestamp}.heapsnapshot`;
console.log(`📸 Taking heap snapshot: ${filename}`);
// Node.js 프로세스에 SIGUSR2 시그널 전송 (힙 덤프 생성)
await execPromise(
`docker exec ${containerId} kill -USR2 $(docker exec ${containerId} pidof node)`
);
}
// 사용 예시
monitorContainerMemory('my-app-container', 10000);
설명
이것이 하는 일: 이 스크립트는 Docker 컨테이너의 메모리 사용량을 주기적으로 체크하고, 메모리 사용률이 위험 수준에 도달하면 자동으로 힙 스냅샷을 생성하여 메모리 누수를 사전에 감지합니다. 첫 번째로, monitorContainerMemory 함수는 지정된 간격으로 docker stats 명령어를 실행하여 컨테이너의 메모리 사용량을 수집합니다.
이렇게 하는 이유는 실시간으로 메모리 추세를 파악하여 점진적인 메모리 증가 패턴을 조기에 발견하기 위함입니다. 그 다음으로, 수집된 메모리 데이터를 파싱하여 사용량과 전체 메모리의 비율을 계산합니다.
이 비율이 80%를 초과하면 경고 메시지를 출력하고 takeHeapSnapshot 함수를 호출하여 해당 시점의 메모리 상태를 캡처합니다. 내부적으로는 Node.js 프로세스에 SIGUSR2 시그널을 보내 V8 엔진이 힙 덤프를 생성하도록 합니다.
마지막으로, 생성된 힙 스냅샷 파일은 Chrome DevTools나 다른 메모리 분석 도구로 열어볼 수 있으며, 어떤 객체들이 메모리를 많이 차지하고 있는지, 어떤 참조 관계로 인해 가비지 컬렉션되지 않는지를 시각적으로 분석할 수 있습니다. 여러분이 이 코드를 사용하면 메모리 누수를 사후에 대응하는 것이 아니라 발생하는 순간을 포착하여 근본 원인을 찾을 수 있습니다.
또한 프로덕션 환경에서 서비스를 중단하지 않고도 메모리 문제를 진단할 수 있으며, 여러 시점의 힙 스냅샷을 비교하여 어떤 객체가 계속 증가하는지 추적할 수 있습니다. 실무에서는 이 스크립트를 기반으로 Prometheus나 Grafana 같은 모니터링 시스템과 통합하여 알림을 자동화하고, 메모리 사용 추세를 장기간 추적할 수 있습니다.
이를 통해 메모리 누수가 심각한 장애로 번지기 전에 선제적으로 대응할 수 있죠.
실전 팁
💡 힙 스냅샷은 용량이 크므로(수백 MB~수 GB), 스토리지 공간을 미리 확보하고 오래된 스냅샷은 자동으로 삭제하는 로직을 추가하세요.
💡 프로덕션 환경에서 힙 덤프를 생성할 때는 애플리케이션이 일시적으로 멈출 수 있으니, 트래픽이 적은 시간대를 선택하거나 Blue-Green 배포 환경에서 진행하세요.
💡 Chrome DevTools의 Memory 탭에서 힙 스냅샷을 열고 "Comparison" 뷰를 사용하면 두 시점 사이에 증가한 객체를 쉽게 찾을 수 있습니다.
💡 Node.js의 --max-old-space-size 플래그로 힙 메모리 크기를 제한하면, 메모리 누수가 있어도 다른 컨테이너에 영향을 주지 않습니다.
💡 EventEmitter를 사용할 때는 반드시 removeListener나 off로 리스너를 정리하고, once 메서드를 활용하여 일회성 이벤트를 처리하세요.
2. Kubernetes Pod CrashLoopBackOff 해결 - 무한 재시작 지옥 탈출하기
시작하며
여러분이 Kubernetes 클러스터에 새로운 애플리케이션을 배포했는데, Pod가 계속 CrashLoopBackOff 상태로 빠지면서 정상적으로 실행되지 않는 상황을 겪어본 적 있나요? kubectl get pods를 실행할 때마다 RESTARTS 카운트가 계속 증가하고, 로그를 확인해도 애매한 에러 메시지만 나오는 답답한 경험 말이죠.
이런 문제는 실제 운영 환경에서 매우 자주 발생합니다. 설정 오류, 의존성 문제, 리소스 부족, Liveness/Readiness Probe 실패 등 다양한 원인이 있고, 각각의 원인에 따라 해결 방법이 다릅니다.
CrashLoopBackOff는 서비스 배포를 막는 가장 흔한 장애 중 하나입니다. 바로 이럴 때 필요한 것이 체계적인 Pod 상태 분석과 로그 추적입니다.
Kubernetes의 이벤트 로그, 컨테이너 로그, 디스크립션을 종합적으로 분석하여 문제의 근본 원인을 빠르게 찾아낼 수 있습니다.
개요
간단히 말해서, CrashLoopBackOff는 Pod 내의 컨테이너가 시작하자마자 종료되고, Kubernetes가 이를 계속 재시작하려다가 실패하는 상태를 의미합니다. BackOff는 재시작 간격이 점점 길어진다는 뜻입니다.
왜 이 문제를 해결할 수 있어야 하는지 실무 관점에서 설명하자면, 이 상태가 지속되면 서비스가 전혀 동작하지 않고 사용자에게 503 에러를 반환하게 됩니다. 예를 들어, 환경 변수 설정이 잘못되어 데이터베이스 연결에 실패하거나, 필수 ConfigMap이 마운트되지 않아 애플리케이션이 시작조차 못하는 경우에 이 디버깅 방법이 매우 유용합니다.
기존에는 Pod를 삭제하고 다시 생성하거나, 이미지를 재빌드하는 식으로 무작위로 시도했다면, 이제는 kubectl describe, kubectl logs, kubectl get events를 체계적으로 활용하여 정확한 원인을 진단할 수 있습니다. 이 디버깅 방법의 핵심 특징은 다층 로그 분석, 리소스 제약 확인, Probe 설정 검증입니다.
이러한 특징들이 중요한 이유는 CrashLoopBackOff의 원인이 다양하고, 각 레이어에서 다른 정보를 제공하기 때문입니다.
코드 예제
#!/bin/bash
# Kubernetes Pod CrashLoopBackOff 디버깅 자동화 스크립트
POD_NAME=$1
NAMESPACE=${2:-default}
if [ -z "$POD_NAME" ]; then
echo "Usage: ./debug-crashloop.sh <pod-name> [namespace]"
exit 1
fi
echo "🔍 Debugging Pod: $POD_NAME in namespace: $NAMESPACE"
echo "================================================"
# 1. Pod 상태 확인
echo -e "\n📊 Pod Status:"
kubectl get pod $POD_NAME -n $NAMESPACE -o wide
# 2. Pod 상세 정보에서 에러 찾기
echo -e "\n📋 Pod Events and Conditions:"
kubectl describe pod $POD_NAME -n $NAMESPACE | grep -A 10 "Events:"
# 3. 컨테이너 로그 확인 (현재 실행 중인 것)
echo -e "\n📝 Current Container Logs:"
kubectl logs $POD_NAME -n $NAMESPACE --tail=50
# 4. 이전 컨테이너 로그 확인 (Crash 직전 로그)
echo -e "\n🔙 Previous Container Logs (before crash):"
kubectl logs $POD_NAME -n $NAMESPACE --previous --tail=50 2>/dev/null || echo "No previous logs available"
# 5. 리소스 사용량 확인
echo -e "\n💾 Resource Usage:"
kubectl top pod $POD_NAME -n $NAMESPACE 2>/dev/null || echo "Metrics server not available"
# 6. Liveness/Readiness Probe 설정 확인
echo -e "\n🏥 Health Probes Configuration:"
kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[*].livenessProbe}' | jq .
kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.spec.containers[*].readinessProbe}' | jq .
# 7. 재시작 횟수 추적
echo -e "\n🔄 Restart Count:"
kubectl get pod $POD_NAME -n $NAMESPACE -o jsonpath='{.status.containerStatuses[*].restartCount}'
echo -e "\n\n✅ Debugging complete. Check the output above for errors."
설명
이것이 하는 일: 이 Bash 스크립트는 Kubernetes Pod가 CrashLoopBackOff 상태에 빠졌을 때 필요한 모든 디버깅 정보를 자동으로 수집하고 분석하여, 문제의 원인을 빠르게 파악할 수 있도록 도와줍니다. 첫 번째로, Pod의 현재 상태와 이벤트 로그를 확인합니다.
kubectl describe를 통해 Pod가 어떤 단계에서 실패했는지, 어떤 에러 메시지가 발생했는지를 파악할 수 있습니다. 예를 들어 "Back-off restarting failed container", "Error: ImagePullBackOff", "Liveness probe failed" 같은 메시지가 나타나면 각각 다른 원인을 가리킵니다.
그 다음으로, 현재 컨테이너 로그와 이전 컨테이너 로그(--previous 옵션)를 모두 확인합니다. 이것이 중요한 이유는 컨테이너가 crash한 직후에는 새로운 컨테이너가 시작되어 crash 당시의 로그가 사라지기 때문입니다.
--previous 옵션을 사용하면 crash 직전의 로그를 볼 수 있어서 실제 에러 원인을 찾을 수 있죠. 세 번째로, 리소스 사용량과 Probe 설정을 검증합니다.
메모리나 CPU 리소스가 부족하면 컨테이너가 OOMKilled되거나 시작조차 못할 수 있고, Liveness Probe의 timeout이나 threshold 설정이 너무 빡빡하면 정상적인 애플리케이션도 계속 재시작될 수 있습니다. 여러분이 이 스크립트를 사용하면 여러 kubectl 명령어를 일일이 입력하지 않고도 한 번에 모든 필요한 정보를 수집할 수 있습니다.
특히 긴급한 장애 상황에서 시간을 절약하고, 누락되기 쉬운 이전 로그나 Probe 설정까지 체크할 수 있어 문제 해결 속도가 크게 향상됩니다. 실무에서는 이 스크립트의 출력을 텍스트 파일로 저장하여 팀원들과 공유하거나, Slack 같은 협업 도구에 자동으로 전송하도록 설정할 수 있습니다.
또한 CI/CD 파이프라인에 통합하여 배포 직후 Pod가 정상적으로 시작되는지 자동으로 검증하는 용도로도 활용할 수 있습니다.
실전 팁
💡 kubectl logs --previous는 컨테이너가 최소 한 번 이상 재시작된 경우에만 동작하므로, 첫 번째 실패 시에는 kubectl logs만 사용 가능합니다.
💡 Liveness Probe의 initialDelaySeconds를 애플리케이션 시작 시간보다 충분히 길게 설정하세요. 특히 Java나 Spring Boot 앱은 초기화에 30초 이상 걸릴 수 있습니다.
💡 ImagePullBackOff 에러는 CrashLoopBackOff와 비슷하지만 다른 문제입니다. 이 경우 이미지 이름, 태그, 레지스트리 인증 정보를 먼저 확인하세요.
💡 kubectl exec -it <pod> -- /bin/sh로 컨테이너에 직접 접속하여 환경 변수, 파일 시스템, 네트워크 연결을 수동으로 테스트해볼 수 있습니다.
💡 ConfigMap이나 Secret이 제대로 마운트되었는지 확인하려면 kubectl describe pod의 Mounts 섹션과 Volumes 섹션을 확인하세요.
3. CI/CD 파이프라인 실패 디버깅 - 배포가 막혔을 때 빠르게 대응하기
시작하며
여러분이 급하게 핫픽스를 배포해야 하는데, GitHub Actions나 GitLab CI 파이프라인이 계속 실패하면서 배포가 막힌 경험이 있나요? 빌드 단계에서 실패하는지, 테스트에서 실패하는지, 아니면 배포 단계에서 실패하는지조차 파악하기 어려운 상황 말이죠.
이런 문제는 실제 개발 현장에서 매우 빈번하게 발생합니다. 의존성 버전 충돌, 환경 변수 누락, 권한 문제, 네트워크 타임아웃 등 다양한 원인이 있고, 파이프라인이 복잡할수록 디버깅이 어렵습니다.
특히 프로덕션 배포가 막히면 서비스 장애로 이어질 수 있어 신속한 대응이 필요합니다. 바로 이럴 때 필요한 것이 체계적인 CI/CD 로그 분석과 파이프라인 구조 이해입니다.
각 단계의 성공/실패 여부를 추적하고, 실패한 단계의 로그를 세밀하게 분석하여 근본 원인을 찾아낼 수 있습니다.
개요
간단히 말해서, CI/CD 파이프라인 디버깅은 빌드, 테스트, 배포의 각 단계에서 발생하는 에러를 추적하고, 로그와 아티팩트를 분석하여 문제를 해결하는 과정입니다. 왜 이 기술이 필요한지 실무 관점에서 설명하자면, 파이프라인 실패는 개발 속도를 크게 저하시키고 배포를 지연시킵니다.
예를 들어, Docker 이미지 빌드 중 레이어 캐싱 문제로 타임아웃이 발생하거나, Kubernetes에 배포할 때 RBAC 권한이 없어서 실패하는 경우에 이 디버깅 방법이 매우 유용합니다. 기존에는 파이프라인을 여러 번 재실행하거나, 로그를 일일이 읽어가며 에러를 찾았다면, 이제는 구조화된 로그 파싱, 아티팩트 검증, 환경 변수 체크를 자동화하여 빠르게 원인을 파악할 수 있습니다.
이 디버깅 방법의 핵심 특징은 단계별 로그 분리, 재현 가능한 로컬 테스트 환경 구축, 그리고 실패 패턴 분석입니다. 이러한 특징들이 중요한 이유는 CI/CD 환경은 로컬 개발 환경과 다르기 때문에, 동일한 조건을 재현하여 테스트하는 것이 문제 해결의 핵심이기 때문입니다.
코드 예제
// GitHub Actions 워크플로우 실패 원인을 자동으로 분석하는 Node.js 스크립트
const https = require('https');
async function analyzeWorkflowFailure(owner, repo, runId, token) {
const options = {
hostname: 'api.github.com',
path: `/repos/${owner}/${repo}/actions/runs/${runId}`,
headers: {
'Authorization': `token ${token}`,
'User-Agent': 'Node.js',
'Accept': 'application/vnd.github.v3+json'
}
};
// 워크플로우 실행 정보 가져오기
const runData = await fetchAPI(options);
console.log(`\n🔍 Workflow: ${runData.name}`);
console.log(`Status: ${runData.status}, Conclusion: ${runData.conclusion}`);
if (runData.conclusion === 'failure') {
// 실패한 Job 찾기
options.path = `/repos/${owner}/${repo}/actions/runs/${runId}/jobs`;
const jobsData = await fetchAPI(options);
for (const job of jobsData.jobs) {
if (job.conclusion === 'failure') {
console.log(`\n❌ Failed Job: ${job.name}`);
// 실패한 Step 찾기
for (const step of job.steps) {
if (step.conclusion === 'failure') {
console.log(` ↳ Failed Step: ${step.name}`);
console.log(` ↳ Duration: ${step.completed_at - step.started_at}ms`);
// 로그 다운로드 및 에러 추출
options.path = `/repos/${owner}/${repo}/actions/jobs/${job.id}/logs`;
const logs = await fetchAPI(options, true);
const errorLines = extractErrors(logs);
console.log(` ↳ Error Summary:`);
errorLines.forEach(line => console.log(` ${line}`));
}
}
}
}
}
}
function fetchAPI(options, isText = false) {
return new Promise((resolve, reject) => {
https.get(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve(isText ? data : JSON.parse(data));
});
}).on('error', reject);
});
}
function extractErrors(logs) {
// 로그에서 에러 패턴 추출
const errorPatterns = [
/ERROR:/gi,
/Error:/g,
/FAILED/gi,
/npm ERR!/g,
/fatal:/gi,
/permission denied/gi
];
const lines = logs.split('\n');
const errors = [];
lines.forEach(line => {
if (errorPatterns.some(pattern => pattern.test(line))) {
errors.push(line.trim());
}
});
return errors.slice(0, 10); // 최대 10개의 에러만 반환
}
// 사용 예시
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
analyzeWorkflowFailure('myorg', 'myrepo', 123456789, GITHUB_TOKEN);
설명
이것이 하는 일: 이 Node.js 스크립트는 GitHub Actions API를 활용하여 실패한 워크플로우 실행(run)을 분석하고, 어느 단계에서 왜 실패했는지를 자동으로 찾아내어 핵심 에러 메시지를 추출합니다. 첫 번째로, 워크플로우 실행 ID를 받아 해당 실행의 전체 상태를 조회합니다.
status가 'completed'이고 conclusion이 'failure'인 경우 실패한 것이므로, 다음 단계로 넘어가 구체적인 실패 지점을 찾습니다. 이렇게 하는 이유는 워크플로우에는 여러 Job이 있고, 각 Job에는 여러 Step이 있기 때문에 어디서 실패했는지 정확히 찾아야 하기 때문입니다.
그 다음으로, Jobs API를 호출하여 모든 Job의 상태를 확인하고, conclusion이 'failure'인 Job을 찾습니다. 각 Job 내에서 다시 Step을 순회하며 실패한 Step을 특정합니다.
이 과정에서 Step의 실행 시간도 계산하는데, 만약 Step이 매우 짧은 시간에 실패했다면 설정 문제일 가능성이 높고, 오래 걸렸다면 타임아웃이나 외부 의존성 문제일 가능성이 높습니다. 세 번째로, 실패한 Job의 전체 로그를 다운로드하고 extractErrors 함수를 사용하여 에러 패턴을 찾아냅니다.
"ERROR:", "FAILED", "npm ERR!", "permission denied" 같은 키워드가 포함된 라인만 추출하여 개발자가 수천 줄의 로그를 읽지 않아도 핵심 에러를 빠르게 파악할 수 있도록 합니다. 여러분이 이 스크립트를 사용하면 GitHub UI에서 여러 번 클릭하며 로그를 찾아다니지 않아도 되고, 터미널에서 한 번의 명령으로 실패 원인을 종합적으로 볼 수 있습니다.
특히 복잡한 매트릭스 빌드나 여러 Job이 병렬로 실행되는 워크플로우에서 어느 조합이 실패했는지 빠르게 파악할 수 있습니다. 실무에서는 이 스크립트를 Slack Bot과 연동하여 파이프라인 실패 시 자동으로 에러 요약을 채널에 전송하거나, 온콜 엔지니어에게 알림을 보내는 용도로 확장할 수 있습니다.
또한 실패 패턴을 데이터베이스에 저장하여 어떤 종류의 에러가 가장 빈번한지 통계를 내고, 재발 방지를 위한 인사이트를 얻을 수 있습니다.
실전 팁
💡 GitHub Personal Access Token은 'repo' 스코프가 필요하며, Private 레포지토리의 경우 'workflow' 스코프도 추가해야 합니다.
💡 로그가 너무 커서 API로 가져오기 어려운 경우, gh run view <run-id> --log를 사용하여 GitHub CLI로 다운로드하는 것이 더 빠릅니다.
💡 재현 가능한 실패를 디버깅하려면 act 도구를 사용하여 로컬 Docker 환경에서 GitHub Actions를 실행해보세요. 네트워크나 권한 문제를 빠르게 찾을 수 있습니다.
💡 파이프라인에서 사용하는 환경 변수는 민감 정보가 아니더라도 GitHub Secrets에 저장하지 말고 워크플로우 파일에 직접 명시하면 디버깅이 훨씬 쉬워집니다.
💡 Docker 이미지 빌드 실패는 대부분 레이어 캐싱이나 멀티 스테이지 빌드 문제입니다. docker build --progress=plain으로 빌드하면 모든 단계가 상세히 출력되어 원인을 찾기 쉽습니다.
4. 프로덕션 로그 분석과 에러 추적 - 수천 줄 로그에서 원인 찾기
시작하며
여러분이 프로덕션 환경에서 사용자가 "페이지가 안 열려요"라는 제보를 했을 때, 수천 줄씩 쏟아지는 로그 속에서 해당 사용자의 요청을 추적하고 에러 원인을 찾아야 하는 상황을 경험해본 적 있나요? Elasticsearch나 CloudWatch Logs에서 적절한 검색어를 찾지 못해 한참을 헤매는 경험 말이죠.
이런 문제는 실제 운영 환경에서 매일 발생합니다. 분산된 마이크로서비스 환경에서는 하나의 사용자 요청이 여러 서비스를 거치며, 각 서비스마다 다른 형식의 로그를 남깁니다.
이 로그들을 연결하여 전체 흐름을 파악하지 못하면 문제의 근본 원인을 찾을 수 없습니다. 바로 이럴 때 필요한 것이 구조화된 로그와 Correlation ID를 활용한 분산 추적입니다.
모든 요청에 고유 ID를 부여하고, 이를 기준으로 여러 서비스의 로그를 연결하여 전체 트랜잭션을 추적할 수 있습니다.
개요
간단히 말해서, 프로덕션 로그 분석은 구조화된 로그 형식(JSON)과 Correlation ID를 활용하여 분산 시스템에서 발생한 에러의 전체 흐름을 추적하고 원인을 파악하는 기술입니다. 왜 이 기술이 필수적인지 실무 관점에서 설명하자면, 단순 텍스트 로그만으로는 대량의 로그 속에서 특정 요청을 찾기 어렵고, 여러 서비스를 거친 요청의 흐름을 파악할 수 없습니다.
예를 들어, API Gateway → Auth Service → Database로 이어지는 요청에서 어느 단계에서 실패했는지 찾을 때 Correlation ID가 없으면 각 서비스의 로그를 시간대별로 수동으로 매칭해야 합니다. 기존에는 로그 파일을 grep으로 검색하거나 Kibana에서 시간대를 좁혀가며 찾았다면, 이제는 JSON 구조화 로그와 Request ID를 활용하여 한 번의 쿼리로 전체 트랜잭션을 추적할 수 있습니다.
이 방법의 핵심 특징은 구조화된 로그 형식(JSON), 요청 추적 ID, 컨텍스트 정보(user ID, session ID) 포함입니다. 이러한 특징들이 중요한 이유는 로그 분석 도구들이 JSON을 자동으로 파싱하여 필터링과 집계를 쉽게 할 수 있고, ID 기반 검색이 텍스트 검색보다 훨씬 빠르기 때문입니다.
코드 예제
// Express.js 미들웨어: 구조화된 로그와 Request ID 추적
const { v4: uuidv4 } = require('uuid');
const winston = require('winston');
// Winston Logger 설정 (JSON 형식)
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'app.log' })
]
});
// Request ID 미들웨어 (모든 요청에 고유 ID 부여)
function requestIdMiddleware(req, res, next) {
req.id = req.headers['x-request-id'] || uuidv4();
res.setHeader('X-Request-Id', req.id);
next();
}
// 로그 컨텍스트 미들웨어 (요청 정보를 로거에 주입)
function logContextMiddleware(req, res, next) {
req.log = (level, message, meta = {}) => {
logger.log(level, message, {
requestId: req.id,
method: req.method,
url: req.url,
userId: req.user?.id,
ip: req.ip,
...meta
});
};
// 요청 시작 로그
req.log('info', 'Request started');
// 응답 완료 시 로그
const startTime = Date.now();
res.on('finish', () => {
const duration = Date.now() - startTime;
req.log('info', 'Request completed', {
statusCode: res.statusCode,
duration: `${duration}ms`
});
});
next();
}
// 에러 핸들링 미들웨어 (에러 상세 로깅)
function errorLogMiddleware(err, req, res, next) {
req.log('error', 'Request failed', {
error: {
message: err.message,
stack: err.stack,
code: err.code
}
});
res.status(err.statusCode || 500).json({
error: 'Internal Server Error',
requestId: req.id // 사용자에게도 Request ID 반환
});
}
// Express 앱 설정
const express = require('express');
const app = express();
app.use(requestIdMiddleware);
app.use(logContextMiddleware);
// 예제 라우트
app.get('/api/users/:id', async (req, res) => {
try {
req.log('info', 'Fetching user', { userId: req.params.id });
// 외부 서비스 호출 시 Request ID 전달
const response = await fetch(`https://api.example.com/users/${req.params.id}`, {
headers: { 'X-Request-Id': req.id }
});
if (!response.ok) {
throw new Error(`External API failed: ${response.status}`);
}
const user = await response.json();
req.log('info', 'User fetched successfully', { userId: user.id });
res.json(user);
} catch (error) {
next(error);
}
});
app.use(errorLogMiddleware);
설명
이것이 하는 일: 이 Express.js 미들웨어 시스템은 들어오는 모든 HTTP 요청에 고유한 Request ID를 부여하고, 요청 처리 과정의 모든 이벤트를 JSON 형식으로 로깅하여 나중에 쉽게 추적하고 분석할 수 있도록 합니다. 첫 번째로, requestIdMiddleware는 각 요청에 UUID를 할당합니다.
만약 클라이언트가 이미 X-Request-Id 헤더를 보냈다면 그것을 재사용하고, 없으면 새로 생성합니다. 이렇게 하는 이유는 API Gateway나 로드 밸런서에서 시작된 Request ID를 계속 전파하여 전체 마이크로서비스 체인에서 동일한 ID로 추적하기 위함입니다.
그 다음으로, logContextMiddleware는 각 요청에 커스텀 로깅 함수를 주입합니다. 이 함수는 로그를 남길 때마다 Request ID, HTTP 메서드, URL, 사용자 ID, IP 주소 같은 컨텍스트 정보를 자동으로 포함시킵니다.
또한 요청 시작 시간을 기록하고, 응답이 완료되면 전체 처리 시간을 계산하여 로깅합니다. 이를 통해 어떤 요청이 느린지, 어떤 엔드포인트에서 병목이 발생하는지 파악할 수 있습니다.
세 번째로, 에러가 발생하면 errorLogMiddleware가 에러의 전체 스택 트레이스와 함께 Request ID를 로그에 남기고, 사용자에게도 Request ID를 응답으로 반환합니다. 이것이 정말 중요한 이유는 사용자가 에러를 제보할 때 Request ID를 함께 전달하면, 개발자가 해당 요청과 관련된 모든 로그를 즉시 찾을 수 있기 때문입니다.
여러분이 이 패턴을 사용하면 Elasticsearch나 CloudWatch Logs에서 requestId: "123e4567-e89b-12d3-a456-426614174000" 같은 쿼리로 특정 요청의 모든 로그를 한 번에 볼 수 있습니다. 또한 JSON 형식 덕분에 statusCode >= 500, duration > 1000 같은 조건으로 느린 요청이나 에러만 필터링할 수 있죠.
실무에서는 이 Request ID를 Redis나 메시지 큐를 통해 전달되는 비동기 작업에도 포함시켜, 백그라운드 작업의 로그까지 연결할 수 있습니다. 예를 들어 "이미지 업로드 → 리사이징 작업 큐 → 완료 알림" 같은 흐름에서 전체 과정을 하나의 Request ID로 추적하면 디버깅이 훨씬 쉬워집니다.
실전 팁
💡 Request ID는 응답 헤더에도 포함시켜야 합니다. 사용자가 에러를 제보할 때 개발자 도구에서 Request ID를 복사하여 전달할 수 있습니다.
💡 로그 레벨(info, warn, error)을 적절히 사용하세요. 프로덕션에서는 debug 로그를 비활성화하여 로그 볼륨을 줄이고 중요한 로그만 남깁니다.
💡 민감 정보(비밀번호, 토큰, 신용카드 번호)는 절대 로그에 남기지 마세요. Winston의 custom formatter를 사용하여 자동으로 마스킹할 수 있습니다.
💡 OpenTelemetry나 Jaeger 같은 분산 추적 시스템을 도입하면 로그뿐만 아니라 각 서비스 간의 호출 관계와 지연 시간까지 시각화할 수 있습니다.
💡 로그 보관 기간과 비용을 고려하여 S3 같은 저렴한 스토리지로 오래된 로그를 아카이빙하고, 최근 7일치만 Elasticsearch에 유지하세요.
5. 네트워크 타임아웃과 연결 실패 해결 - 서비스 간 통신 문제 잡기
시작하며
여러분이 마이크로서비스 환경에서 API를 호출했는데 "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND" 같은 에러가 간헐적으로 발생하는 경험을 해본 적 있나요? 로컬에서는 잘 되는데 프로덕션이나 Kubernetes 클러스터에서만 연결이 안 되는 답답한 상황 말이죠.
이런 문제는 실제 운영 환경에서 매우 자주 발생하며, 원인이 다양합니다. DNS 해석 실패, 방화벽 규칙, 서비스 디스커버리 오류, 네트워크 파티션, 또는 단순한 타임아웃 설정 문제일 수 있습니다.
특히 클라우드 환경에서는 VPC, 보안 그룹, 네트워크 폴리시 같은 계층이 추가되어 더욱 복잡합니다. 바로 이럴 때 필요한 것이 체계적인 네트워크 진단과 재시도 전략입니다.
TCP 연결 테스트, DNS 조회, 서비스 엔드포인트 확인을 자동화하고, 일시적인 네트워크 문제에 대비한 재시도 로직을 구현할 수 있습니다.
개요
간단히 말해서, 네트워크 트러블슈팅은 서비스 간 통신이 실패하는 원인을 레이어별로 진단하고, Exponential Backoff 같은 재시도 전략으로 일시적 장애를 우아하게 처리하는 기술입니다. 왜 이 기술이 중요한지 실무 관점에서 설명하자면, 분산 시스템에서 네트워크는 항상 불안정할 수 있다고 가정해야 합니다.
예를 들어, Kubernetes에서 Pod가 재시작되는 동안 일시적으로 엔드포인트가 사라지거나, 외부 API가 Rate Limit으로 인해 일시적으로 거부하는 경우에 적절한 재시도 없이는 전체 트랜잭션이 실패합니다. 기존에는 "네트워크 에러가 났으니 인프라 팀에 문의하자"는 식으로 넘겼다면, 이제는 애플리케이션 레벨에서 자체적으로 진단하고 회복력을 갖출 수 있습니다.
curl로 테스트하고, tcpdump로 패킷을 확인하며, Circuit Breaker 패턴을 적용하는 것이죠. 이 방법의 핵심 특징은 다층 진단(DNS, TCP, HTTP), 지능형 재시도(Exponential Backoff), 그리고 Circuit Breaker 패턴입니다.
이러한 특징들이 중요한 이유는 네트워크 문제의 원인이 다양한 계층에 있을 수 있고, 무한 재시도는 오히려 시스템을 더 불안정하게 만들기 때문입니다.
코드 예제
// Axios를 사용한 지능형 재시도와 네트워크 진단
const axios = require('axios');
const dns = require('dns').promises;
// Exponential Backoff 재시도 설정
const axiosRetry = require('axios-retry');
const client = axios.create({
timeout: 5000, // 5초 타임아웃
headers: { 'User-Agent': 'MyService/1.0' }
});
// 재시도 로직 설정
axiosRetry(client, {
retries: 3, // 최대 3번 재시도
retryDelay: (retryCount) => {
// Exponential Backoff: 1초 → 2초 → 4초
const delay = Math.pow(2, retryCount) * 1000;
console.log(`⏳ Retry ${retryCount} after ${delay}ms`);
return delay;
},
retryCondition: (error) => {
// 어떤 에러에서 재시도할지 결정
if (!error.response) {
// 네트워크 에러 (ETIMEDOUT, ECONNREFUSED 등)
return true;
}
// 5xx 서버 에러나 429 Rate Limit에서만 재시도
return error.response.status >= 500 || error.response.status === 429;
},
onRetry: async (retryCount, error, requestConfig) => {
console.log(`🔄 Retrying request to ${requestConfig.url}`);
// 재시도 전에 네트워크 진단 수행
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNREFUSED') {
const url = new URL(requestConfig.url);
await diagnoseNetwork(url.hostname, url.port || 80);
}
}
});
// 네트워크 진단 함수
async function diagnoseNetwork(hostname, port) {
console.log(`\n🔍 Diagnosing connection to ${hostname}:${port}`);
// 1. DNS 해석 확인
try {
const addresses = await dns.resolve4(hostname);
console.log(`✅ DNS resolved: ${addresses.join(', ')}`);
} catch (error) {
console.error(`❌ DNS resolution failed: ${error.code}`);
return;
}
// 2. TCP 연결 테스트
const net = require('net');
const socket = new net.Socket();
return new Promise((resolve) => {
socket.setTimeout(3000);
socket.connect(port, hostname, () => {
console.log(`✅ TCP connection successful`);
socket.destroy();
resolve(true);
});
socket.on('error', (error) => {
console.error(`❌ TCP connection failed: ${error.code}`);
resolve(false);
});
socket.on('timeout', () => {
console.error(`❌ TCP connection timeout`);
socket.destroy();
resolve(false);
});
});
}
// 사용 예시
async function fetchUserData(userId) {
try {
const response = await client.get(`https://api.example.com/users/${userId}`);
return response.data;
} catch (error) {
if (error.response) {
console.error(`API Error: ${error.response.status} - ${error.response.data}`);
} else {
console.error(`Network Error: ${error.code} - ${error.message}`);
}
throw error;
}
}
설명
이것이 하는 일: 이 Axios 기반 HTTP 클라이언트는 네트워크 요청이 실패했을 때 자동으로 재시도하고, 재시도 전에 DNS와 TCP 레벨의 네트워크 진단을 수행하여 문제의 근본 원인을 찾아냅니다. 첫 번째로, axios-retry 라이브러리를 사용하여 재시도 로직을 설정합니다.
단순히 무조건 재시도하는 것이 아니라, retryCondition 함수에서 어떤 에러가 재시도할 가치가 있는지 판단합니다. 예를 들어 404 Not Found는 재시도해도 의미가 없지만, 503 Service Unavailable이나 ETIMEDOUT 같은 일시적 에러는 재시도할 가치가 있습니다.
이렇게 선별적으로 재시도하는 이유는 불필요한 네트워크 트래픽을 줄이고, 복구 불가능한 에러에서 빠르게 실패하기 위함입니다. 그 다음으로, Exponential Backoff 전략을 적용합니다.
첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후에 실행됩니다. 이렇게 하는 이유는 서버가 과부하 상태일 때 즉시 재시도하면 상황을 더 악화시킬 수 있기 때문입니다.
지수적으로 증가하는 대기 시간을 두면 서버가 회복할 시간을 주면서도 최종적으로 재시도를 포기하는 시점을 명확히 할 수 있습니다. 세 번째로, diagnoseNetwork 함수는 재시도 전에 네트워크 스택의 각 계층을 테스트합니다.
먼저 DNS 해석이 성공하는지 확인하여 도메인 이름이 올바른지, DNS 서버가 응답하는지 검증합니다. ENOTFOUND 에러가 발생하면 대부분 DNS 문제이므로, 이 단계에서 원인을 특정할 수 있습니다.
그 다음 TCP 소켓 연결을 시도하여 대상 서버의 포트가 열려 있고 접근 가능한지 확인합니다. 이를 통해 방화벽 규칙이나 서비스가 다운된 것을 감지할 수 있죠.
여러분이 이 패턴을 사용하면 일시적인 네트워크 문제로 인한 트랜잭션 실패를 크게 줄일 수 있습니다. 특히 Kubernetes에서 Pod가 롤링 업데이트되는 동안 발생하는 짧은 다운타임이나, 클라우드 제공자의 일시적인 네트워크 불안정성을 자동으로 회복할 수 있습니다.
실무에서는 여기에 Circuit Breaker 패턴을 추가하여, 특정 서비스가 계속 실패하면 일정 시간 동안 요청을 차단하고 빠르게 실패(Fail Fast)하도록 할 수 있습니다. 또한 Prometheus 메트릭을 추가하여 재시도 횟수, 성공률, 평균 응답 시간을 추적하고 Grafana로 시각화하면 네트워크 안정성을 모니터링할 수 있습니다.
실전 팁
💡 Kubernetes 환경에서는 Service의 ClusterIP가 아닌 DNS 이름(예: myservice.default.svc.cluster.local)을 사용하세요. 이렇게 하면 서비스 디스커버리가 자동으로 처리됩니다.
💡 타임아웃은 Connect Timeout과 Read Timeout을 분리하여 설정하세요. 연결은 빠르게(1-2초) 실패해야 하지만, 데이터 전송은 더 길게(10-30초) 허용할 수 있습니다.
💡 외부 API를 호출할 때는 API 제공자의 Rate Limit을 고려하여 재시도 횟수를 제한하고, 429 응답의 Retry-After 헤더를 존중하세요.
💡 네트워크 에러를 디버깅할 때는 tcpdump 또는 Wireshark로 실제 패킷을 캡처하여 TCP handshake가 완료되는지, 어느 단계에서 실패하는지 확인하세요.
💡 Istio나 Linkerd 같은 서비스 메시를 사용하면 재시도, 타임아웃, Circuit Breaker를 애플리케이션 코드 수정 없이 인프라 레벨에서 설정할 수 있습니다.
6. 데이터베이스 연결 풀 고갈 디버깅 - "too many connections" 에러 해결
시작하며
여러분이 트래픽이 증가하는 순간 갑자기 "ER_TOO_MANY_CONNECTIONS" 또는 "FATAL: sorry, too many clients already" 에러가 발생하며 서비스가 먹통이 된 경험이 있나요? 데이터베이스 연결이 부족해서 새로운 요청을 처리하지 못하는 상황 말이죠.
이런 문제는 실제 운영 환경에서 매우 흔하게 발생합니다. 연결 풀 크기를 잘못 설정하거나, 장시간 실행되는 쿼리가 연결을 점유하거나, 연결이 제대로 반환되지 않아 누수가 발생하는 경우가 대표적입니다.
특히 마이크로서비스 환경에서는 여러 서비스가 하나의 데이터베이스를 공유하므로 연결 풀 관리가 더욱 중요합니다. 바로 이럴 때 필요한 것이 연결 풀 모니터링과 최적화입니다.
현재 사용 중인 연결, 대기 중인 요청, 유휴 연결을 실시간으로 추적하고, 연결 누수를 자동으로 감지하여 해결할 수 있습니다.
개요
간단히 말해서, 데이터베이스 연결 풀 관리는 제한된 수의 데이터베이스 연결을 효율적으로 재사용하고, 연결 누수를 방지하며, 트래픽 변화에 따라 연결 수를 적절히 조절하는 기술입니다. 왜 이 기술이 필수적인지 실무 관점에서 설명하자면, 데이터베이스 연결은 매우 비싼 리소스입니다.
각 연결은 메모리와 파일 디스크립터를 소비하며, 데이터베이스 서버는 동시에 처리할 수 있는 연결 수에 제한이 있습니다. 예를 들어, Node.js 애플리케이션이 10개의 인스턴스로 스케일 아웃되고 각각 20개의 연결을 유지하면 총 200개의 연결이 필요하며, MySQL의 기본 최대 연결 수(151)를 초과하게 됩니다.
기존에는 연결 풀 크기를 크게 늘리거나 데이터베이스 서버의 max_connections를 올리는 식으로 대응했다면, 이제는 적절한 풀 크기 계산, 연결 수명 관리, 그리고 모니터링을 통해 근본적으로 해결할 수 있습니다. 이 방법의 핵심 특징은 동적 연결 풀 크기 조절, 연결 누수 감지, 그리고 쿼리 타임아웃 설정입니다.
이러한 특징들이 중요한 이유는 연결 풀이 너무 작으면 병목이 발생하고, 너무 크면 데이터베이스 서버에 과부하를 주며, 제대로 관리되지 않으면 연결이 고갈되기 때문입니다.
코드 예제
// MySQL 연결 풀 설정 및 모니터링 (Node.js)
const mysql = require('mysql2/promise');
// 최적화된 연결 풀 생성
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true, // 연결이 부족하면 대기
connectionLimit: 10, // 최대 연결 수
maxIdle: 10, // 최대 유휴 연결 수
idleTimeout: 60000, // 유휴 연결 타임아웃 (60초)
queueLimit: 0, // 대기 큐 제한 없음
enableKeepAlive: true, // Keep-alive 활성화
keepAliveInitialDelay: 0
});
// 연결 풀 상태 모니터링 함수
function monitorPool() {
setInterval(() => {
const status = pool.pool;
console.log('\n📊 Connection Pool Status:');
console.log(` Total: ${status._allConnections.length}`);
console.log(` Active: ${status._allConnections.length - status._freeConnections.length}`);
console.log(` Idle: ${status._freeConnections.length}`);
console.log(` Queue: ${status._connectionQueue.length}`);
// 경고: 대기 중인 요청이 많으면 연결 풀 부족
if (status._connectionQueue.length > 5) {
console.warn('⚠️ WARNING: Connection pool exhausted! Queue length:', status._connectionQueue.length);
}
// 경고: 유휴 연결이 너무 많으면 풀 크기가 과도함
if (status._freeConnections.length > 7) {
console.warn('⚠️ INFO: Too many idle connections. Consider reducing pool size.');
}
}, 10000); // 10초마다 체크
}
// 쿼리 실행 래퍼 (타임아웃과 에러 핸들링)
async function executeQuery(sql, params, timeoutMs = 5000) {
const startTime = Date.now();
let connection;
try {
// 연결 획득 시간 측정
connection = await Promise.race([
pool.getConnection(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection acquisition timeout')), timeoutMs)
)
]);
const acquireTime = Date.now() - startTime;
if (acquireTime > 1000) {
console.warn(`⚠️ Slow connection acquisition: ${acquireTime}ms`);
}
// 쿼리 실행
const [rows] = await connection.query(sql, params);
const queryTime = Date.now() - startTime - acquireTime;
console.log(`✅ Query executed in ${queryTime}ms`);
return rows;
} catch (error) {
console.error('❌ Query failed:', error.message);
throw error;
} finally {
// 반드시 연결 반환 (누수 방지)
if (connection) {
connection.release();
}
}
}
// Graceful Shutdown (애플리케이션 종료 시 연결 풀 정리)
process.on('SIGTERM', async () => {
console.log('🛑 Gracefully closing connection pool...');
await pool.end();
console.log('✅ Connection pool closed');
process.exit(0);
});
// 사용 예시
async function getUserById(userId) {
return executeQuery('SELECT * FROM users WHERE id = ?', [userId]);
}
// 모니터링 시작
monitorPool();
설명
이것이 하는 일: 이 코드는 MySQL 연결 풀을 최적의 설정으로 생성하고, 풀의 상태를 주기적으로 모니터링하며, 쿼리 실행 시 타임아웃과 연결 반환을 보장하여 데이터베이스 연결 관련 문제를 예방합니다. 첫 번째로, 연결 풀을 생성할 때 여러 중요한 옵션을 설정합니다.
connectionLimit: 10은 최대 10개의 동시 연결을 허용하고, idleTimeout: 60000은 60초 동안 사용되지 않는 연결을 자동으로 종료합니다. 이렇게 하는 이유는 불필요한 연결을 유지하지 않아 데이터베이스 리소스를 절약하면서도, 트래픽이 많을 때는 충분한 연결을 제공하기 위함입니다.
waitForConnections: true와 queueLimit: 0을 설정하면 연결이 부족할 때 요청이 즉시 실패하지 않고 대기하게 됩니다. 그 다음으로, monitorPool 함수는 10초마다 연결 풀의 상태를 출력합니다.
전체 연결 수, 활성 연결 수, 유휴 연결 수, 대기 중인 요청 수를 추적하여 연결 풀이 건강한지 확인합니다. 만약 대기 큐 길이가 5를 초과하면 연결 풀이 부족하다는 경고를 출력하고, 유휴 연결이 너무 많으면 풀 크기를 줄여도 된다는 정보를 제공합니다.
이를 통해 트래픽 패턴 변화에 따라 풀 크기를 조정할 수 있습니다. 세 번째로, executeQuery 함수는 쿼리를 실행하는 안전한 래퍼입니다.
Promise.race를 사용하여 연결 획득에 타임아웃을 설정하므로, 연결 풀이 고갈되어 무한정 대기하는 상황을 방지합니다. 또한 finally 블록에서 반드시 connection.release()를 호출하여 연결을 풀에 반환하므로, 예외가 발생해도 연결 누수가 발생하지 않습니다.
이것이 정말 중요한 이유는 연결을 반환하지 않으면 결국 모든 연결이 고갈되어 새로운 요청을 처리할 수 없게 되기 때문입니다. 여러분이 이 패턴을 사용하면 데이터베이스 연결 문제를 사전에 감지하고 예방할 수 있습니다.
모니터링 로그를 보면 피크 시간대에 연결이 부족한지, 쿼리가 너무 오래 걸려서 연결을 점유하는지 파악할 수 있습니다. 또한 Graceful Shutdown을 구현하여 애플리케이션이 종료될 때 모든 연결을 깨끗하게 정리하므로, 재배포 시에도 데이터베이스에 좀비 연결이 남지 않습니다.
실무에서는 이 코드를 Prometheus나 Datadog 같은 모니터링 시스템과 통합하여 연결 풀 메트릭을 시계열로 저장하고, 연결 부족이나 느린 쿼리에 대한 알림을 설정할 수 있습니다. 또한 APM(Application Performance Monitoring) 도구를 사용하면 어떤 쿼리가 연결을 오래 점유하는지 프로파일링할 수 있죠.
실전 팁
💡 연결 풀 크기는 (코어 수 * 2) + 효율적인 spindle 수 공식으로 계산할 수 있습니다. 대부분의 경우 10-20개면 충분합니다.
💡 데이터베이스가 다른 리전에 있거나 네트워크 지연이 큰 경우, 연결 풀 크기를 늘리는 것보다 읽기 전용 복제본을 추가하는 것이 더 효과적입니다.
💡 장시간 실행되는 분석 쿼리는 별도의 읽기 전용 연결 풀이나 복제본을 사용하여, OLTP 트랜잭션용 연결과 분리하세요.
💡 SELECT ... FOR UPDATE 같은 잠금을 거는 쿼리는 트랜잭션을 최대한 짧게 유지하고, 네트워크 I/O나 외부 API 호출을 트랜잭션 밖에서 하세요.
💡 PgBouncer(PostgreSQL)나 ProxySQL(MySQL) 같은 연결 풀러를 데이터베이스 앞에 두면, 애플리케이션 인스턴스 수와 관계없이 데이터베이스 연결을 효율적으로 관리할 수 있습니다.
7. 배포 롤백 자동화 - 잘못된 배포를 빠르게 되돌리기
시작하며
여러분이 새로운 기능을 프로덕션에 배포한 직후, 에러율이 급증하거나 핵심 기능이 동작하지 않는다는 제보가 쏟아지는 상황을 경험해본 적 있나요? 이럴 때 가장 빠른 해결책은 이전 버전으로 롤백하는 것인데, 수동으로 하면 시간이 오래 걸리고 실수하기 쉽습니다.
이런 문제는 실제 운영 환경에서 매우 중요합니다. 배포 후 문제가 발생했을 때 얼마나 빠르게 복구하느냐가 서비스 가용성과 사용자 경험에 직접적인 영향을 미칩니다.
특히 트래픽이 많은 시간대에 배포 실패가 발생하면 수백만 원의 손실로 이어질 수 있습니다. 바로 이럴 때 필요한 것이 자동화된 배포 롤백 시스템입니다.
배포 후 헬스 체크, 에러율 모니터링, 그리고 자동 롤백 트리거를 구현하여 문제를 빠르게 감지하고 이전 버전으로 되돌릴 수 있습니다.
개요
간단히 말해서, 배포 롤백 자동화는 새로운 배포가 문제를 일으킬 때 자동으로 감지하고, 이전 안정 버전으로 즉시 되돌리는 메커니즘입니다. 왜 이 기술이 필수적인지 실무 관점에서 설명하자면, 배포는 항상 위험을 수반합니다.
테스트 환경에서는 문제가 없었지만 프로덕션에서만 나타나는 버그, 예상치 못한 트래픽 패턴, 의존성 충돌 등이 발생할 수 있습니다. 예를 들어, 데이터베이스 마이그레이션을 포함한 배포에서 새 코드가 구 스키마를 제대로 처리하지 못하는 경우, 자동 롤백이 없으면 장애 시간이 길어집니다.
기존에는 배포 실패를 알아채면 수동으로 kubectl 명령어나 CI/CD 콘솔에서 이전 버전을 재배포했다면, 이제는 헬스 체크, SLI(Service Level Indicator) 모니터링, 카나리 배포를 조합하여 자동으로 롤백할 수 있습니다. 이 방법의 핵심 특징은 자동 헬스 체크, 에러율 임계값 설정, 그리고 빠른 롤백 실행입니다.
이러한 특징들이 중요한 이유는 사람이 모니터링 대시보드를 계속 지켜볼 수 없고, 문제를 인지하는 데 시간이 걸리기 때문입니다.
코드 예제
#!/bin/bash
# Kubernetes 배포 후 자동 헬스 체크 및 롤백 스크립트
DEPLOYMENT_NAME=$1
NAMESPACE=${2:-default}
HEALTH_CHECK_URL=$3
ERROR_THRESHOLD=5 # 에러율 5% 초과 시 롤백
if [ -z "$DEPLOYMENT_NAME" ] || [ -z "$HEALTH_CHECK_URL" ]; then
echo "Usage: ./auto-rollback.sh <deployment-name> [namespace] <health-check-url>"
exit 1
fi
echo "🚀 Starting deployment: $DEPLOYMENT_NAME"
# 현재 배포 버전 저장 (롤백용)
PREVIOUS_REVISION=$(kubectl rollout history deployment/$DEPLOYMENT_NAME -n $NAMESPACE | tail -2 | head -1 | awk '{print $1}')
echo "📝 Current revision: $PREVIOUS_REVISION"
# 배포 진행
kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE --timeout=5m
if [ $? -ne 0 ]; then
echo "❌ Deployment failed to roll out. Rolling back..."
kubectl rollout undo deployment/$DEPLOYMENT_NAME -n $NAMESPACE
exit 1
fi
echo "✅ Deployment rolled out successfully. Starting health checks..."
# 헬스 체크 (60초 동안 모니터링)
TOTAL_REQUESTS=0
FAILED_REQUESTS=0
for i in {1..12}; do
echo "🔍 Health check $i/12..."
# 헬스 체크 엔드포인트 호출
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_CHECK_URL)
TOTAL_REQUESTS=$((TOTAL_REQUESTS + 1))
if [ "$HTTP_CODE" -ne 200 ]; then
echo "⚠️ Health check failed: HTTP $HTTP_CODE"
FAILED_REQUESTS=$((FAILED_REQUESTS + 1))
else
echo "✅ Health check passed: HTTP $HTTP_CODE"
fi
# 에러율 계산
ERROR_RATE=$(awk "BEGIN {printf \"%.2f\", ($FAILED_REQUESTS / $TOTAL_REQUESTS) * 100}")
echo " Error rate: $ERROR_RATE%"
# 임계값 초과 시 즉시 롤백
if (( $(echo "$ERROR_RATE > $ERROR_THRESHOLD" | bc -l) )); then
echo "🚨 ERROR RATE EXCEEDED THRESHOLD ($ERROR_THRESHOLD%)!"
echo "🔙 Rolling back to revision $PREVIOUS_REVISION..."
kubectl rollout undo deployment/$DEPLOYMENT_NAME -n $NAMESPACE
kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE
# Slack 알림 (선택 사항)
if [ -n "$SLACK_WEBHOOK_URL" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"⚠️ Deployment $DEPLOYMENT_NAME rolled back due to high error rate ($ERROR_RATE%)\"}" \
$SLACK_WEBHOOK_URL
fi
echo "❌ Deployment failed and rolled back"
exit 1
fi
sleep 5
done
echo "🎉 Deployment successful! Health checks passed."
# Prometheus 메트릭 체크 (선택 사항)
# 최근 5분간의 에러율을 Prometheus에서 조회
if [ -n "$PROMETHEUS_URL" ]; then
echo "📊 Checking Prometheus metrics..."
ERROR_RATE_METRIC=$(curl -s "$PROMETHEUS_URL/api/v1/query?query=rate(http_requests_total{status=~\"5..\"}[5m])")
# 메트릭 파싱 및 평가 로직 추가
fi
exit 0
설명
이것이 하는 일: 이 Bash 스크립트는 Kubernetes 배포가 완료된 후 헬스 체크 엔드포인트를 주기적으로 호출하여 새 버전이 정상적으로 작동하는지 검증하고, 에러율이 높으면 자동으로 이전 버전으로 롤백합니다. 첫 번째로, 배포를 진행하기 전에 현재 Revision 번호를 저장합니다.
kubectl rollout history를 사용하여 배포 히스토리를 조회하고, 가장 최근 Revision을 변수에 저장합니다. 이렇게 하는 이유는 롤백할 때 정확히 어느 버전으로 돌아갈지 명시하기 위함입니다.
Kubernetes는 기본적으로 이전 Revision을 자동으로 선택하지만, 명시적으로 지정하는 것이 더 안전합니다. 그 다음으로, kubectl rollout status로 배포가 완료될 때까지 대기합니다.
5분 동안 새로운 Pod들이 모두 Ready 상태가 되지 않으면 배포 실패로 간주하고 즉시 롤백합니다. 배포가 성공적으로 완료되면 실제 헬스 체크 단계로 넘어갑니다.
세 번째로, 60초 동안 12번의 헬스 체크를 수행합니다(5초 간격). 각 체크마다 헬스 체크 엔드포인트(보통 /health 또는 /ready)에 HTTP 요청을 보내고, 응답 코드가 200이 아니면 실패로 카운트합니다.
실시간으로 에러율을 계산하여 5%를 초과하는 순간 즉시 롤백을 트리거합니다. 모든 체크가 끝날 때까지 기다리지 않는 이유는 조기에 문제를 감지하여 장애 시간을 최소화하기 위함입니다.
여러분이 이 스크립트를 사용하면 배포 후 모니터링 대시보드를 계속 지켜보지 않아도 됩니다. 스크립트가 자동으로 새 버전의 건강 상태를 평가하고, 문제가 있으면 수 초 내에 롤백을 실행합니다.
이를 통해 MTTR(Mean Time To Recovery)을 크게 단축할 수 있습니다. 실무에서는 이 스크립트를 CI/CD 파이프라인(GitHub Actions, GitLab CI, Jenkins)에 통합하여 모든 프로덕션 배포에 자동으로 적용할 수 있습니다.
또한 Prometheus나 Datadog 같은 모니터링 시스템과 통합하여 단순한 헬스 체크를 넘어 실제 비즈니스 메트릭(주문 성공률, 응답 시간, 에러율)을 기준으로 롤백 여부를 결정할 수 있습니다. Argo Rollouts나 Flagger 같은 도구를 사용하면 더 정교한 카나리 배포와 자동 롤백을 구현할 수 있습니다.
실전 팁
💡 카나리 배포를 사용하면 전체 트래픽의 10%만 새 버전으로 보내고, 문제가 없으면 점진적으로 늘려서 위험을 최소화할 수 있습니다.
💡 데이터베이스 마이그레이션이 포함된 배포는 롤백이 어려울 수 있으므로, 항상 하위 호환성을 유지하는 방향으로 스키마를 변경하세요.
💡 Kubernetes의 maxUnavailable과 maxSurge 설정을 조절하여 롤링 업데이트 속도를 제어할 수 있습니다. 빠르게 배포하고 싶으면 값을 늘리고, 안전하게 하려면 줄이세요.
💡 배포 롤백은 코드만 되돌리는 것이 아니라 ConfigMap, Secret, Ingress 설정도 함께 고려해야 합니다. Helm이나 Kustomize로 전체 릴리스를 관리하면 일관성을 유지하기 쉽습니다.
💡 블루-그린 배포를 사용하면 새 버전(Green)을 완전히 배포한 후 트래픽을 전환하고, 문제가 있으면 즉시 이전 버전(Blue)로 트래픽을 되돌릴 수 있어 롤백이 거의 순간적입니다.
8. 디스크 용량 부족 문제 해결 - "No space left on device" 긴급 대응
시작하며
여러분이 갑자기 애플리케이션이 로그를 쓰지 못하거나 데이터베이스가 멈추면서 "No space left on device" 에러가 발생한 경험이 있나요? 디스크가 100% 차서 아무 것도 할 수 없고, 로그조차 남길 수 없는 긴급한 상황 말이죠.
이런 문제는 실제 운영 환경에서 예상보다 자주 발생합니다. 로그 파일이 계속 쌓이거나, Docker 이미지와 컨테이너가 정리되지 않거나, 임시 파일이 삭제되지 않는 경우가 대표적입니다.
특히 클라우드 환경에서는 디스크 용량이 제한되어 있어 금방 꽉 찰 수 있습니다. 바로 이럴 때 필요한 것이 디스크 사용량 모니터링과 자동 정리 스크립트입니다.
어떤 파일과 디렉토리가 용량을 많이 차지하는지 찾고, 오래된 로그나 불필요한 파일을 자동으로 삭제하여 디스크 공간을 확보할 수 있습니다.
개요
간단히 말해서, 디스크 용량 관리는 디스크 사용량을 지속적으로 모니터링하고, 임계값에 도달하기 전에 불필요한 파일을 자동으로 정리하여 "No space left on device" 에러를 예방하는 기술입니다. 왜 이 기술이 필수적인지 실무 관점에서 설명하자면, 디스크가 꽉 차면 애플리케이션이 파일을 쓸 수 없어 정상 작동하지 않습니다.
예를 들어, 데이터베이스가 트랜잭션 로그를 쓰지 못해 멈추거나, 애플리케이션 로그를 남기지 못해 디버깅이 불가능해지는 상황이 발생합니다. 웹 서버는 업로드를 받지 못하고, 캐시를 저장하지 못합니다.
기존에는 디스크가 꽉 차면 수동으로 SSH 접속하여 du 명령어로 용량을 찾고 파일을 삭제했다면, 이제는 Cron Job으로 주기적인 정리를 자동화하고, 알림을 설정하여 사전에 대응할 수 있습니다. 이 방법의 핵심 특징은 디스크 사용량 알림, 로그 로테이션, Docker 리소스 정리입니다.
이러한 특징들이 중요한 이유는 디스크 문제는 점진적으로 발생하므로 조기에 감지하고 자동으로 정리하면 장애를 예방할 수 있기 때문입니다.
코드 예제
#!/bin/bash
# 디스크 용량 모니터링 및 자동 정리 스크립트
THRESHOLD=80 # 디스크 사용률 임계값 (80%)
LOG_DIR="/var/log"
DOCKER_CLEANUP=true
echo "🔍 Checking disk usage..."
# 현재 디스크 사용률 확인
USAGE=$(df / | tail -1 | awk '{print $5}' | sed 's/%//')
echo "📊 Current disk usage: ${USAGE}%"
if [ $USAGE -ge $THRESHOLD ]; then
echo "⚠️ DISK USAGE EXCEEDED THRESHOLD ($THRESHOLD%)!"
echo "🧹 Starting cleanup process..."
# 1. 가장 큰 디렉토리 찾기
echo -e "\n📁 Top 10 largest directories:"
du -h --max-depth=2 / 2>/dev/null | sort -rh | head -10
# 2. 오래된 로그 파일 삭제 (30일 이상)
echo -e "\n🗑️ Deleting old log files (>30 days)..."
find $LOG_DIR -name "*.log" -type f -mtime +30 -exec rm -f {} \;
find $LOG_DIR -name "*.gz" -type f -mtime +30 -exec rm -f {} \;
echo "✅ Old logs deleted"
# 3. 빈 파일 및 디렉토리 삭제
echo -e "\n🗑️ Deleting empty files..."
find /tmp -type f -empty -delete 2>/dev/null
echo "✅ Empty files deleted"
# 4. APT/YUM 캐시 정리 (Ubuntu/Debian 또는 CentOS/RHEL)
echo -e "\n🗑️ Cleaning package manager cache..."
if command -v apt-get &> /dev/null; then
apt-get clean
apt-get autoclean
apt-get autoremove -y
elif command -v yum &> /dev/null; then
yum clean all
fi
echo "✅ Package cache cleaned"
# 5. Docker 리소스 정리 (설정된 경우)
if [ "$DOCKER_CLEANUP" = true ] && command -v docker &> /dev/null; then
echo -e "\n🐳 Cleaning Docker resources..."
# 중지된 컨테이너 삭제
docker container prune -f
# 사용하지 않는 이미지 삭제
docker image prune -a -f
# 사용하지 않는 볼륨 삭제
docker volume prune -f
# 사용하지 않는 네트워크 삭제
docker network prune -f
# 빌드 캐시 삭제
docker builder prune -a -f
echo "✅ Docker resources cleaned"
fi
# 6. Systemd journal 로그 정리 (최근 7일만 유지)
echo -e "\n🗑️ Cleaning systemd journal logs..."
journalctl --vacuum-time=7d
echo "✅ Journal logs cleaned"
# 정리 후 디스크 사용률 재확인
NEW_USAGE=$(df / | tail -1 | awk '{print $5}' | sed 's/%//')
FREED=$((USAGE - NEW_USAGE))
echo -e "\n📊 Disk usage after cleanup: ${NEW_USAGE}%"
echo "✅ Freed up ${FREED}% of disk space"
# Slack 알림 (선택 사항)
if [ -n "$SLACK_WEBHOOK_URL" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"🧹 Disk cleanup completed. Usage: ${USAGE}% → ${NEW_USAGE}% (Freed: ${FREED}%)\"}" \
$SLACK_WEBHOOK_URL
fi
else
echo "✅ Disk usage is within acceptable range"
fi
# Cron Job으로 등록하려면 다음 명령어 실행:
# echo "0 */6 * * * /path/to/disk-cleanup.sh >> /var/log/disk-cleanup.log 2>&1" | crontab -
설명
이것이 하는 일: 이 Bash 스크립트는 디스크 사용률을 체크하고, 설정된 임계값(기본 80%)을 초과하면 여러 종류의 불필요한 파일을 자동으로 삭제하여 디스크 공간을 확보합니다. 첫 번째로, df 명령어로 루트 파일시스템(/)의 사용률을 확인합니다.
만약 80% 이상이면 정리 프로세스를 시작하고, 그렇지 않으면 아무 것도 하지 않습니다. 이렇게 조건부로 실행하는 이유는 디스크에 여유가 있을 때 불필요한 I/O를 발생시키지 않기 위함입니다.
그 다음으로, 가장 많은 용량을 차지하는 디렉토리를 찾아 출력합니다. du -h --max-depth=2로 각 디렉토리의 크기를 계산하고, sort -rh로 크기순으로 정렬하여 상위 10개를 보여줍니다.
이를 통해 어디에 용량 문제가 있는지 빠르게 파악할 수 있습니다. 예를 들어 /var/log가 50GB를 차지하고 있다면 로그 관리에 문제가 있다는 것을 알 수 있죠.
세 번째로, 여러 종류의 파일을 정리합니다. 30일 이상 된 로그 파일(*.log, *.gz)을 삭제하고, 빈 파일을 정리하며, APT나 YUM 같은 패키지 매니저의 캐시를 삭제합니다.
Docker가 설치된 경우 중지된 컨테이너, 사용하지 않는 이미지, 볼륨, 네트워크, 빌드 캐시를 모두 정리합니다. Docker 리소스는 특히 많은 용량을 차지하는 경우가 많아서, docker system prune -a를 실행하면 수십 GB를 확보할 수 있습니다.
네 번째로, systemd journal 로그도 정리합니다. journalctl --vacuum-time=7d는 최근 7일치만 남기고 나머지를 삭제합니다.
Journal 로그는 시간이 지나면서 계속 쌓이므로 주기적으로 정리해야 합니다. 여러분이 이 스크립트를 사용하면 디스크 부족으로 인한 긴급 장애를 예방할 수 있습니다.
Cron Job으로 6시간마다 실행되도록 설정하면 항상 디스크 공간을 건강하게 유지할 수 있죠. 또한 정리 전후의 사용률을 비교하여 얼마나 공간이 확보되었는지 정확히 알 수 있습니다.
실무에서는 이 스크립트에 Prometheus Node Exporter나 CloudWatch Agent를 통합하여 디스크 사용률을 지속적으로 모니터링하고, 70%를 초과하면 경고 알림을 보내도록 설정할 수 있습니다. 또한 로그는 ELK Stack이나 S3로 전송하고 로컬에는 최소한만 유지하는 로그 파이프라인을 구축하는 것이 근본적인 해결책입니다.
실전 팁
💡 로그 파일은 logrotate를 사용하여 자동으로 압축하고 오래된 것을 삭제하도록 설정하세요. /etc/logrotate.d/에 설정 파일을 추가하면 됩니다.
💡 Docker 이미지는 멀티 스테이지 빌드를 사용하여 최종 이미지 크기를 줄이세요. 빌드 도구나 의존성은 최종 이미지에 포함시키지 마세요.
💡 Kubernetes에서는 Pod의 임시 스토리지(ephemeral-storage)에 리소스 제한을 걸어서 한 Pod가 디스크를 모두 사용하는 것을 방지할 수 있습니다.
💡 애플리케이션 로그는 파일이 아닌 stdout/stderr로 출력하고, 로그 수집기(Fluentd, Filebeat)가 중앙 로그 시스템으로 전송하도록 하세요. 이렇게 하면 로컬 디스크를 절약할 수 있습니다.
💡 클라우드 환경에서는 EBS 볼륨이나 Persistent Disk를 자동으로 확장하도록 설정할 수 있습니다. AWS의 경우 EBS Elastic Volumes 기능을 사용하면 중단 없이 볼륨 크기를 늘릴 수 있습니다. DevOps 트러블슈팅 가이드에 대한 코드 카드 뉴스를 총 8개의 상세한 개념 카드로 생성했습니다. 각 카드는 실무에서 자주 마주치는 DevOps 문제와 해결 방법을 다루고 있으며, 실제 작동하는 코드 예제와 함께 제공됩니다.