13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] 인프라 엔지니어링

  • [AI] 로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    [AI] 로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    로컬 LLM 성능은 GPU 이름보다 측정 방식에서 갈립니다

    로컬 LLM 성능을 비교할 때 가장 위험한 문장은 ‘이 GPU면 충분히 빠르겠지’입니다. 실제 운영에 가까워질수록 병목은 GPU 코어 하나로 설명되지 않거든요. 프롬프트를 읽는 prefill, 토큰을 하나씩 생성하는 decode, KV cache가 차지하는 메모리, tokenizer와 HTTP 클라이언트, 동시 요청 스케줄링이 같이 움직입니다.

    저는 벤치마크를 볼 때 숫자 하나만 믿지 않습니다. 초당 토큰 수가 좋아도 첫 토큰이 늦으면 채팅 UX는 답답합니다. 반대로 TTFT가 괜찮아도 동시 요청을 올렸을 때 p95 지연 시간이 크게 튀면 내부 서비스로 쓰기 불안하더라고요.

    이 글은 vLLM을 띄우는 법을 소개하는 데서 멈추지 않습니다. 어떤 값을 고정하고, 무엇을 바꿔야 비교가 성립하는지, vLLM 옵션이 성능·비용·안정성에 어떤 영향을 주는지, 결과를 보고 운영선을 어떻게 잡는지까지 정리합니다. 특정 GPU의 성능 수치는 지어내지 않겠습니다. 대신 여러분 장비에서 재현할 수 있는 로컬 LLM 성능 측정 절차와 판단 기준을 남기겠습니다.

    로컬 LLM 성능 벤치마크 전체 아키텍처 다이어그램

    로컬 GPU 서버에서 vLLM API 서버를 띄우고, 벤치마크 클라이언트가 요청을 보내며, GPU/시스템 지표를 함께 수집하는 흐름입니다.

    vLLM 벤치마크 전에 성능 지표를 운영 관점으로 잡기

    vLLM은 OpenAI 호환 API 서버로 사용할 수 있는 LLM 추론 엔진입니다. 핵심은 여러 요청을 효율적으로 스케줄링하고, KV cache를 관리하며, 서버형 추론에서 처리량을 끌어올리는 데 있습니다. 여기서 성능 지표는 사전식 정의보다 ‘어떤 장애를 미리 알려주는가’로 보는 편이 훨씬 쓸모 있습니다.

    • TTFT(Time To First Token): 사용자가 요청한 뒤 첫 토큰이 나오기까지의 시간입니다. 길면 모델이 똑똑해도 느리게 느껴집니다. 긴 입력, prefill 부하, 큐 대기 시간이 주로 영향을 줍니다.
    • TPOT(Time Per Output Token): 첫 토큰 이후 출력 토큰 하나를 생성하는 평균 시간입니다. 답변이 길어질수록 체감 속도를 좌우합니다.
    • ITL(Inter Token Latency): 토큰 사이 간격입니다. 스트리밍 응답이 뚝뚝 끊기는지 확인할 때 봅니다.
    • E2E Latency: 요청부터 최종 응답 완료까지의 전체 시간입니다. 배치 요약, 문서 처리, 자동화 워크플로에서는 이 값이 더 중요할 수 있습니다.
    • Throughput: 초당 처리한 요청 수 또는 토큰 수입니다. 사용자 수를 늘릴 때 비용 효율을 판단하는 기준입니다.
    • Concurrency: 동시에 처리 중인 요청 수입니다. vLLM의 장점은 보통 단일 요청보다 이 구간에서 더 잘 드러납니다.
    • GPU Memory: 모델 가중치, KV cache, CUDA graph, 런타임 오버헤드가 함께 차지하는 메모리입니다. OOM은 대개 모델 크기 하나가 아니라 컨텍스트 길이와 동시성이 합쳐져 발생합니다.

    현장에서 가장 많이 본 착시는 ‘초당 토큰이 높으니 빠르다’는 판단입니다. 문서 요약처럼 입력이 긴 워크로드는 prefill 비용이 커서 TTFT가 먼저 무너질 수 있습니다. 반대로 짧은 채팅을 많이 받는 서비스는 decode보다 큐 대기와 스케줄링이 더 큰 문제가 되기도 합니다. 그래서 LLM 벤치마크는 항상 워크로드를 먼저 정하고 들어가야 합니다.

    LLM 벤치마크 환경은 README처럼 고정하세요

    LLM 벤치마크에서 비교가 깨지는 가장 흔한 이유는 실험 조건이 매번 조금씩 달라지는 겁니다. 모델만 같아도 토크나이저 버전, dtype, max model length, 요청 분포, temperature, 스트리밍 여부가 바뀌면 숫자는 달라집니다. 실험을 시작하기 전에 아래 항목을 먼저 적어두세요. 이거 진짜 편합니다. 나중에 결과가 이상할 때 기록이 없으면 원인 추적이 거의 감으로 변하거든요.

    항목 기록할 내용 왜 중요한가
    vLLM 버전 <code>vllm –version, 설치 방식, 컨테이너 태그 CLI 옵션과 스케줄러 동작은 버전에 따라 바뀔 수 있습니다.
    GPU/드라이버 nvidia-smi 출력, CUDA 런타임, GPU 개수 메모리 용량과 커널 지원 여부가 추론 안정성에 직접 영향을 줍니다.
    모델 Hugging Face ID, 로컬 경로, revision, 양자화 여부 가중치와 토크나이저가 다르면 같은 이름처럼 보여도 비교가 아닙니다.
    입력 길이 평균/최대 prompt token, 테스트 데이터셋 긴 입력은 prefill 비용과 KV cache 사용량을 키웁니다.
    출력 길이 max_tokens, 고정 출력 길이 사용 여부 decode 성능을 비교하려면 출력 길이를 통제해야 합니다.
    동시성 1, 2, 4, 8, 16처럼 단계별 증가 처리량이 늘다가 지연만 커지는 지점을 찾아야 합니다.
    서버 옵션 dtype, max-model-len, gpu-memory-utilization, max-num-seqs 메모리, 큐잉, 처리량, OOM 위험이 여기서 크게 갈립니다.
    요청 방식 chat/completions 여부, streaming 여부, timeout 클라이언트 측 지연과 서버 처리 지연을 분리해 봐야 합니다.

    ‘테스트 데이터셋’이라는 말에 너무 겁먹을 필요는 없습니다. 사내 문서 요약용이면 실제 문서에서 민감정보를 제거한 샘플 30~100개, 코딩 보조용이면 짧은 질문·중간 길이 수정 요청·긴 파일 기반 요청을 나눠 준비하면 됩니다. 핵심은 매번 같은 입력 분포를 쓰는 것입니다.

    vLLM 서버는 옵션 파일로 남기세요

    설치는 환경 의존성이 큽니다. CUDA, PyTorch, 드라이버 조합은 공식 설치 문서를 확인하는 편이 안전합니다. 아래 명령은 NVIDIA GPU가 있는 Linux 환경에서 시작점을 잡는 예시로 보세요. 벤치마크 재현성을 위해 서버 실행 옵션은 셸 히스토리에만 남기지 말고 YAML 파일로 고정하는 걸 권합니다.

    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install --upgrade pip
    pip install vllm
    vllm --version
    python - <<'PY'
    import torch
    print('cuda_available=', torch.cuda.is_available())
    print('gpu_count=', torch.cuda.device_count())
    PY

    아래는 단일 GPU에서 시작할 때의 보수적인 예시입니다. 모델 경로는 여러분 환경에 맞게 바꾸세요. vLLM의 --config는 YAML 설정 파일을 읽을 수 있으며, 같은 값이 CLI와 설정 파일에 동시에 있으면 CLI 인자가 우선합니다. 그래서 실제 운영에서는 중복을 줄이고, 실험에서는 어떤 값이 적용됐는지 로그로 한 번 더 확인하는 습관이 좋습니다.

    # vllm-serve.yaml
    model: /models/your-llm-model
    host: 0.0.0.0
    port: 8000
    dtype: auto
    max_model_len: 4096
    gpu_memory_utilization: 0.85
    max_num_seqs: 16
    enable_prefix_caching: true
    disable_log_requests: true
    vllm serve --config vllm-serve.yaml

    max_model_len은 서버가 받아들일 입력+출력의 최대 토큰 길이입니다. 무작정 크게 잡으면 긴 문서를 받을 수는 있지만 KV cache 여유가 줄어 동시성이 줄고 OOM 가능성이 커집니다. gpu_memory_utilization은 vLLM이 모델 실행에 사용할 GPU 메모리 비율입니다. 높이면 더 큰 KV cache를 확보할 수 있지만, 드라이버·라이브러리·다른 프로세스가 쓸 여유 공간은 줄어듭니다.

    max_num_seqs는 한 번의 스케줄링 반복에서 처리할 수 있는 시퀀스 수의 상한으로 이해하면 쉽습니다. 짧은 채팅을 많이 받는 서버라면 늘려볼 가치가 있고, 긴 문서 요약처럼 요청 하나가 큰 경우에는 과하게 늘릴수록 지연 시간과 메모리 압박이 커질 수 있습니다. enable_prefix_caching은 같은 시스템 프롬프트나 반복 prefix가 많은 워크로드에서 효과를 기대할 수 있지만, 요청 prefix가 매번 완전히 다르면 체감이 작을 수 있습니다.

    vLLM API 서버와 벤치마크 클라이언트 구성도

    vLLM 서버 실행 옵션과 벤치마크 클라이언트가 연결되는 구조를 한눈에 볼 수 있는 구성도입니다.

    추론 성능 측정은 워밍업, 단일 요청, 동시성 순서로 갑니다

    서버가 떠도 바로 측정값을 믿으면 안 됩니다. 첫 요청에는 모델 로딩 이후 남은 초기화, CUDA kernel 준비, 캐시 관련 비용이 섞일 수 있습니다. 저는 보통 헬스 체크, 워밍업, 단일 요청 확인, 동시성 테스트 순서로 갑니다. 이 순서를 지키면 서버가 느린 건지, 첫 요청만 느린 건지, 클라이언트가 병목인지 분리하기 쉽습니다.

    curl -s http://127.0.0.1:8000/v1/models | jq .
    
    for i in 1 2 3; do
      curl -s http://127.0.0.1:8000/v1/chat/completions \
        -H 'Content-Type: application/json' \
        -d '{
          "model": "/models/your-llm-model",
          "messages": [{"role": "user", "content": "warmup"}],
          "max_tokens": 16,
          "temperature": 0
        }' > /dev/null
      echo warmup-$i-done
    done

    간단한 smoke test에서는 응답이 오는지만 보지 말고 usage 필드도 확인하세요. 프롬프트 토큰과 completion 토큰이 예상보다 크게 다르면 테스트 입력이 통제되지 않은 것입니다. 특히 한국어와 영어는 토크나이저에서 토큰 수가 다르게 나올 수 있으니, 같은 글자 수가 곧 같은 부하라는 착각을 피해야 합니다.

    curl -s http://127.0.0.1:8000/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{
        "model": "/models/your-llm-model",
        "messages": [
          {"role": "system", "content": "간결하게 답하세요."},
          {"role": "user", "content": "로컬 LLM 성능 측정에서 TTFT가 왜 중요한지 설명해줘."}
        ],
        "max_tokens": 128,
        "temperature": 0
      }' | jq '{id, usage, finish_reason: .choices[0].finish_reason}'

    직접 만든 부하 스크립트도 유용합니다. 아래 코드는 스트리밍 응답에서 첫 chunk가 도착하는 시점과 전체 완료 시간을 분리해 기록합니다. 전문 도구를 대체하진 않지만, TTFT와 E2E가 왜 다르게 움직이는지 눈으로 확인하기 좋습니다.

    import asyncio
    import json
    import time
    import httpx
    
    URL = 'http://127.0.0.1:8000/v1/chat/completions'
    MODEL = '/models/your-llm-model'
    
    payload = {
        'model': MODEL,
        'messages': [
            {'role': 'user', 'content': 'vLLM으로 로컬 LLM 성능을 측정하는 기준을 설명해줘.'}
        ],
        'max_tokens': 128,
        'temperature': 0,
        'stream': True,
    }
    
    async def one_request(client, idx):
        start = time.perf_counter()
        first_chunk_at = None
        chunks = 0
        async with client.stream('POST', URL, json=payload, timeout=120) as response:
            response.raise_for_status()
            async for line in response.aiter_lines():
                if not line.startswith('data: '):
                    continue
                data = line.removeprefix('data: ').strip()
                if data == '[DONE]':
                    break
                chunks += 1
                if first_chunk_at is None:
                    first_chunk_at = time.perf_counter()
        end = time.perf_counter()
        ttft = None if first_chunk_at is None else first_chunk_at - start
        return {'request': idx, 'ttft_sec': ttft, 'e2e_sec': end - start, 'chunks': chunks}
    
    async def run(concurrency):
        limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency)
        async with httpx.AsyncClient(limits=limits) as client:
            results = await asyncio.gather(*(one_request(client, i) for i in range(concurrency)))
        for item in results:
            print(json.dumps(item, ensure_ascii=False))
    
    if __name__ == '__main__':
        asyncio.run(run(concurrency=4))

    이 스크립트를 concurrency=1, 2, 4, 8로 바꿔가며 돌려보면 패턴이 보입니다. TTFT만 급격히 늘면 큐 대기나 prefill 경쟁을 의심하고, TTFT는 괜찮은데 E2E가 길어지면 decode 구간의 경쟁이나 출력 길이 분포를 봅니다. 에러가 섞이면 평균값은 잠깐 내려놓고 실패율부터 봐야 합니다.

    vLLM 내장 벤치마크로 AI 성능 비교 기준선 만들기

    직접 만든 스크립트는 원인을 좁히는 데 좋고, 반복 가능한 비교에는 vLLM의 벤치마크 명령이 편합니다. 버전에 따라 세부 옵션은 달라질 수 있으니 vllm bench serve --help를 먼저 확인하세요. 핵심은 backend, endpoint, model, dataset 또는 synthetic 입력 조건, 동시성, 프롬프트 수를 명시하는 것입니다.

    vllm bench serve --help
    
    vllm bench serve \
      --backend openai-chat \
      --base-url http://127.0.0.1:8000 \
      --endpoint /v1/chat/completions \
      --model /models/your-llm-model \
      --num-prompts 100 \
      --max-concurrency 4 \
      --percentile-metrics ttft,tpot,itl,e2el

    여기서 주의할 점은 ‘벤치마크 클라이언트에서 측정한 지연 시간’이라는 사실입니다. 서버 내부 처리 시간, 네트워크, 클라이언트 이벤트 루프, JSON 파싱이 한 덩어리로 섞일 수 있습니다. 그래서 같은 서버에서 한 번, 다른 머신에서 한 번, 가능하면 클라이언트 CPU 사용률까지 같이 보는 편이 좋습니다.

    데이터셋 기반 테스트를 할 때는 입력 길이 분포를 꼭 기록하세요. 평균 토큰만 보면 안 됩니다. p95 입력 길이가 긴 데이터셋은 짧은 요청 다수보다 훨씬 거칠게 서버를 흔듭니다. 특히 문서 요약형 워크로드는 평균보다 꼬리가 문제입니다. 운영 장애는 보통 평균 요청이 아니라 긴 요청 몇 개가 큐를 막을 때 시작됩니다.

    로컬 LLM 성능 테스트에서 자주 보이는 실패 모드

    OOM과 느린 첫 응답은 표면 증상입니다. 같은 OOM이라도 원인은 모델이 너무 큰 경우, 컨텍스트를 과하게 연 경우, 동시성을 너무 높인 경우, logprobs 같은 옵션이 메모리를 더 쓰는 경우로 나뉩니다. 해결책도 다릅니다. 무조건 작은 모델로 내리면 문제는 사라지지만, 품질과 비용의 균형점을 찾을 기회를 잃습니다.

    watch -n 1 nvidia-smi
    
    nvidia-smi --query-gpu=timestamp,name,index,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw \
      --format=csv -l 1
    증상 가능성이 높은 근본 원인 먼저 볼 것 우선 조치
    첫 요청만 유독 느림 워밍업 부족, 커널 초기화, 캐시 미형성 워밍업 전후 TTFT 차이 측정 전 워밍업 요청을 제외하고 기록합니다.
    동시성 증가 시 TTFT가 먼저 튐 큐 대기, 긴 prefill 경쟁, max_num_seqs 과다 입력 토큰 분포, p95 TTFT 긴 입력과 짧은 입력을 분리 측정하고 동시성 상한을 낮춥니다.
    출력 중간부터 느려짐 decode 병목, 긴 출력 요청 혼재 max_tokens, 실제 completion token 출력 길이를 고정한 테스트와 실제 분포 테스트를 따로 돌립니다.
    GPU 메모리가 가득 찬 뒤 OOM KV cache 증가, 과한 max_model_len, 동시 요청 과다 memory.used 추이, 요청별 입력 길이 max_model_len, max_num_seqs, 동시성을 순서대로 줄입니다.
    GPU 사용률이 낮은데 응답이 느림 클라이언트 병목, tokenizer/CPU 병목, 연결 재사용 실패 클라이언트 CPU, HTTP keep-alive, 서버 로그 비동기 클라이언트, 연결 풀, 클라이언트 분리 실행을 확인합니다.
    평균은 좋은데 p95가 나쁨 긴 요청 꼬리, 큐 대기 증가 요청 길이 p50/p95, 실패 요청 로그 워크로드를 길이별로 나누고 운영 한계는 평균이 아니라 p95로 잡습니다.

    운영에서는 ‘성능을 더 뽑는 옵션’보다 ‘실패할 때 예측 가능한 옵션’이 더 가치 있을 때가 많습니다. 예를 들어 gpu_memory_utilization을 올리면 실험실 처리량은 좋아질 수 있지만, 같은 GPU에서 모니터링 에이전트나 다른 프로세스가 함께 돌면 여유 메모리가 부족해질 수 있습니다. 벤치마크가 목적이면 한계를 밀어보고, 서비스가 목적이면 일부러 여유를 남기는 선택이 맞습니다.

    검증과 결과 해석: 평균값보다 꺾이는 지점을 찾으세요

    추론 성능 측정에서 제가 가장 신뢰하는 그림은 동시성별 TTFT, TPOT, E2E latency, tokens/sec, 실패율을 한 표에 놓은 것입니다. 평균만 있으면 위험합니다. 최소한 p50과 p95를 같이 보세요. p50은 평소 체감, p95는 불만이 시작되는 구간에 가깝습니다.

    1. 단일 요청에서 TTFT가 제품 UX에 맞는가?
    2. 동시성을 올렸을 때 처리량이 증가하는 구간과 지연만 늘어나는 구간이 어디인가?
    3. 먼저 오는 한계가 GPU 메모리인지, decode 처리량인지, 클라이언트 병목인지 구분했는가?
    4. 실패율이 0에 가까운 구간과 단순히 평균이 좋은 구간이 같은가?

    예를 들어 내부 문서 요약 챗봇을 만든다고 해보겠습니다. 사용자는 한 번에 긴 문서를 넣고 답변을 기다립니다. 이 경우 짧은 프롬프트로 초당 토큰 수만 재면 거의 도움이 안 됩니다. 2천 토큰, 8천 토큰, 16천 토큰처럼 입력 길이를 나누고, 출력 길이는 고정한 뒤 TTFT와 E2E를 봐야 합니다. 반면 짧은 고객 응대 챗봇이면 입력 길이보다 동시 요청과 p95 TTFT가 더 중요합니다.

    vLLM 로컬 LLM 성능 벤치마크 결과 대시보드

    동시성 증가에 따른 응답 시간, 처리량, GPU 메모리 사용량을 함께 보여주는 결과 대시보드 이미지입니다.

    비교 목적 고정할 값 바꿀 값 판단 기준
    모델 후보 비교 프롬프트, max_tokens, 동시성, vLLM 옵션 모델 경로 또는 revision 품질 평가는 별도로 두고, 여기서는 지연·처리량·메모리만 비교합니다.
    동시성 한계 확인 모델, 입력 길이, 출력 길이 max-concurrency, 클라이언트 수 처리량 증가가 둔화되고 p95 지연이 급등하기 직전이 운영 후보입니다.
    긴 컨텍스트 영향 모델, 출력 길이, 동시성 입력 토큰 길이, max_model_len TTFT와 GPU 메모리가 함께 증가하는지 봅니다.
    서버 옵션 튜닝 모델, 테스트 데이터, 클라이언트 gpu_memory_utilization, max_num_seqs 평균 처리량보다 실패율과 p95 안정성을 우선합니다.
    prefix cache 효과 확인 시스템 프롬프트, 모델, 동시성 반복 prefix 여부, enable_prefix_caching 같은 prefix가 많은 워크로드에서만 의미 있게 비교합니다.

    설정값 선택 기준: 빠르게보다 맞게 고르는 게 먼저입니다

    vLLM 옵션은 레버처럼 움직입니다. 하나를 올리면 다른 쪽에서 비용을 냅니다. max_model_len을 키우면 긴 입력을 받을 수 있지만 KV cache 예산이 커지고, max_num_seqs를 키우면 동시 처리 가능성은 늘지만 요청 하나의 지연 시간이 튈 수 있습니다. 그래서 설정은 ‘최대 성능’이 아니라 ‘내 워크로드에 맞는 실패 방식’을 고르는 일에 가깝습니다.

    상황 추천 방향 피해야 할 선택
    개인 실험, 단일 사용자 작은 max_num_seqs, 필요한 만큼의 max_model_len, 단일 요청 TTFT 확인 운영도 안 할 긴 컨텍스트를 크게 열어 메모리를 낭비하는 것
    짧은 채팅을 여러 사용자가 사용 동시성 단계 테스트, p95 TTFT 기준 운영선 설정 평균 tokens/sec만 보고 동시성 상한을 높게 잡는 것
    긴 문서 요약 입력 길이 버킷별 측정, max_model_len 보수적 설정, 긴 요청 별도 큐 고려 짧은 프롬프트 벤치마크 결과를 긴 문서 처리에 적용하는 것
    동일 시스템 프롬프트 반복 enable_prefix_caching 효과를 별도 실험 prefix가 매번 다른데 캐시 효과를 기대하는 것
    한 GPU에 다른 프로세스도 실행 gpu_memory_utilization을 낮춰 여유 확보 벤치마크 순간 최대 처리량만 보고 메모리를 끝까지 쓰는 것

    제 기준의 기본값은 이렇습니다. 처음에는 max_model_len을 실제 요청 p95보다 약간 큰 수준으로 잡고, gpu_memory_utilization은 여유를 남긴 값에서 시작합니다. 그다음 동시성을 올리며 p95 TTFT와 실패율을 봅니다. 처리량이 조금 더 나오더라도 p95가 급격히 튀는 지점은 운영선으로 잡지 않습니다.

    자주 묻는 질문

    Q. vLLM만 쓰면 무조건 빨라지나요?

    아닙니다. vLLM은 서버형 추론, 특히 동시 요청 처리에서 강점이 큽니다. 하지만 단일 요청을 짧게 돌리는 실험에서는 차이가 작을 수 있고, 모델이 GPU 메모리에 빠듯하게 올라가는 환경에서는 설정을 잘못 잡으면 오히려 불안정해질 수 있습니다.

    Q. 로컬 LLM 성능 비교에서 가장 먼저 볼 지표는 뭔가요?

    채팅 UX라면 TTFT와 p95 TTFT를 먼저 봅니다. 배치 요약이나 내부 자동화라면 E2E latency와 처리량을 더 봅니다. 여러 사용자가 붙는 서비스라면 평균보다 실패율과 p95가 우선입니다.

    Q. 블로그에 있는 벤치마크 숫자를 믿어도 되나요?

    참고는 됩니다. 다만 그대로 의사결정에 쓰면 위험합니다. GPU, 드라이버, vLLM 버전, 모델 revision, 입력 길이, 출력 길이, 동시성, 스트리밍 여부가 다르면 결과가 바뀝니다. 같은 절차로 내 장비에서 다시 재는 것이 가장 확실합니다.

    Q. 양자화 모델은 항상 좋은 선택인가요?

    메모리 절감에는 유리할 수 있지만 품질, 지원 커널, decode 성능, 운영 안정성을 함께 봐야 합니다. ‘올라간다’와 ‘서비스하기 좋다’는 다릅니다. 양자화 여부를 바꿀 때는 모델 품질 평가와 추론 성능 평가를 분리하세요.

    Q. GPU 사용률이 낮으면 vLLM 설정 문제인가요?

    항상 그렇지는 않습니다. 클라이언트가 요청을 충분히 못 넣거나, CPU 토크나이징이 막히거나, 네트워크/HTTP 연결이 병목일 수 있습니다. 서버 옵션을 바꾸기 전에 클라이언트가 실제로 원하는 동시성을 만들고 있는지 확인하는 편이 빠릅니다.

    마지막 판단: 운영선은 가장 빠른 지점이 아니라 덜 흔들리는 지점입니다

    로컬 LLM 성능에서 중요한 질문은 ‘어떤 모델이 제일 빠른가’가 아니라 ‘내 서버가 어떤 요청 분포까지 예측 가능하게 버티는가’입니다. 저는 단일 최고 점수보다 반복 측정에서 비슷하게 나오는 구간을 더 신뢰합니다. 운영자는 평균값이 아니라 나쁜 날의 p95를 상대해야 하니까요.

    개인 실험용이면 작은 모델부터 시작해 TTFT와 GPU 메모리 여유를 확인하세요. 여러 사용자가 붙는 내부 서비스라면 vLLM 서버를 띄운 뒤 동시성별 p50/p95 TTFT, E2E latency, tokens/sec, 실패율을 함께 기록하세요. 긴 문서 요약처럼 입력이 긴 워크로드라면 짧은 채팅 벤치마크를 버리고 입력 길이 버킷을 따로 만들어야 합니다.

    제가 실제로 권하는 절차는 단순합니다. 워밍업을 제외하고, 같은 프롬프트 세트와 같은 출력 길이로, 동시성을 1에서 시작해 단계적으로 올립니다. 처리량이 더 늘지 않거나 p95 지연 시간이 갑자기 커지거나 실패가 섞이는 지점이 나오면, 그 직전 단계를 운영 후보로 잡습니다. 그다음 max_model_len, max_num_seqs, gpu_memory_utilization을 하나씩만 바꿔 다시 측정합니다.

    다음 글에서는 Prometheus와 Grafana를 붙여 vLLM 추론 서버의 요청 지표, GPU 메모리, 지연 시간 분포를 시각화하는 방법을 다룰 예정입니다. 관련 글로는 GPU 모니터링, 모델 서빙 아키텍처, LLM 비용 최적화 글을 함께 연결하면 독자가 다음 단계로 넘어가기 좋습니다. 벤치마크는 한 번 찍는 스크린샷이 아니라 운영 기준을 만드는 과정입니다.

    TTFT, 처리량, 동시성, GPU 메모리 기준으로 로컬 LLM 성능 비교 방법을 요약한 인포그래픽입니다.

    제 기준의 한 줄 권고는 이렇습니다. 빠른 숫자 하나를 찾지 말고, 같은 조건에서 동시성을 올리며 p95 지연 시간과 실패율이 꺾이는 지점을 찾으세요. 그 직전이 여러분 로컬 LLM 서버의 현실적인 운영선입니다.

  • [Linux] ext4 디스크 I/O 성능 저하 원인 분석 및 진단 방법

    [Linux] ext4 디스크 I/O 성능 저하 원인 분석 및 진단 방법

    [Linux] ext4 디스크 I/O 성능 저하 원인 분석 및 진단 방법

    안녕하세요, 13년차 서버실 지킴이입니다. 🤓

    어느 날 갑자기 서버가 느려졌는데, CPU나 메모리 사용량은 평온하고 네트워크 트래픽도 평소와 다를 바 없을 때, 문득 이런 생각이 들지 않으세요? “혹시 ext4의 디스크 I/O 문제인가?” 네, 정확한 추측일 수도 있어요. 리눅스 서버에서 가장 흔하게 사용되는 파일시스템인 ext4에서 성능 저하가 벌어지면 정말 당황스럽죠. 특히 서비스 운영 중이라면 심장이 덜컥 내려앉을 겁니다.

    저도 홈랩에서 이것저것 실험하다가 갑자기 디스크 읽기/쓰기 속도가 확 떨어져서 며칠 밤낮을 삽질했던 경험이 있거든요. 처음엔 설정 문제인가 싶었는데, 알고 보니 ext4 파일시스템 자체의 특성이나 잘못된 마운트 옵션, 혹은 숨겨진 병목 때문이었더라고요. 그래서 오늘은 ext4 디스크 I/O 성능 저하의 원인을 파헤치고, 어떻게 진단하고 해결할 수 있는지 제 경험을 바탕으로 솔직하게 풀어보려 합니다. 함께 리눅스 디스크 I/O 문제를 해결해 봅시다! 💡

    리눅스 서버의 ext4 파일시스템 I/O 흐름 및 잠재적 병목 지점 개요 다이어그램

    ext4 파일시스템과 디스크 I/O, 대체 뭐가 문제일까요? 🤔

    먼저, 기본적인 개념부터 짚고 넘어갈게요. 디스크 I/O(Input/Output)는 말 그대로 디스크에 데이터를 쓰고(write) 읽는(read) 작업을 말합니다. 서버 애플리케이션의 응답 속도는 이 디스크 I/O 성능에 크게 좌우되죠. 웹 서버의 로그 기록, 데이터베이스의 쿼리 처리, 캐시 파일 저장 등 거의 모든 작업이 디스크 I/O를 수반하거든요.

    ext4는 리눅스에서 가장 널리 사용되는 저널링 파일시스템(Journaling Filesystem)입니다. 저널링 덕분에 시스템 크래시(crash) 시 데이터 일관성(data consistency)을 보장하는 강력한 장점이 있지만, 이 저널링 과정 자체가 때로는 I/O 성능 오버헤드(overhead)로 작용할 수 있어요. 쉽게 말해, 데이터를 변경하기 전에 먼저 변경 내용을 저널(journal)이라는 특별한 영역에 기록하고 나서 실제 데이터 영역에 쓰는 방식이라, 안정성은 높지만 그만큼 추가적인 디스크 작업이 필요하다는 거죠.

    ext4의 성능에 영향을 미치는 주요 요소는 다음과 같습니다:

    • 저널링 모드 (Journaling Mode): data=journal, data=ordered, data=writeback 등 모드에 따라 데이터 안정성과 성능이 달라집니다.
    • 블록 사이즈 (Block Size): 파일시스템 생성 시 지정하는 데이터 처리 단위로, 작은 파일이 많으면 작은 블록 사이즈가, 큰 파일이 많으면 큰 블록 사이즈가 유리할 수 있습니다.
    • 마운트 옵션 (Mount Options): noatime, discard, barrier=0 등 다양한 옵션으로 성능을 튜닝할 수 있습니다.
    • 디스크 자체 성능: HDD냐 SSD냐, RAID 구성이냐 등 물리적인 디스크의 스펙도 중요하죠.
    • 파일 단편화 (Fragmentation): 파일이 디스크 여러 곳에 흩어져 저장되면 읽기/쓰기 성능이 저하됩니다.

    실전! ext4 디스크 I/O 성능 진단 도구 활용법 🛠️

    자, 이제 실제로 서버에서 디스크 I/O 병목을 어떻게 찾아내는지 알아볼 차례입니다. 리눅스에는 정말 유용한 도구들이 많아요. 제가 즐겨 쓰는 몇 가지를 소개합니다.

    1. iostat: 시스템 전체 디스크 I/O 통계

    iostat은 시스템 전체의 디스크 I/O 통계를 실시간으로 보여주는 강력한 도구예요. 특정 디스크의 사용률, 큐 길이, 응답 시간 등을 한눈에 파악할 수 있거든요.

    # iostat -x 1 5
    # -x: 확장 통계 정보 출력 (extended statistics)
    # 1: 1초 간격으로
    # 5: 5번 반복 출력
    
    Linux 5.15.0-78-generic (my-server) 	08/20/2023 	_x86_64_ 	(8 CPU)
    
    avg-cpu:  %user   %nice %system %iowait  %steal   %idle
               1.20    0.00    0.80    0.10    0.00   97.90
    
    Device             r/s   w/s   rkB/s   wkB/s  avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    sda               0.10  0.00    2.00    0.00     40.00     0.00    0.20    0.20    0.00   0.20   0.00
    sdb               0.00  0.00    0.00    0.00      0.00     0.00    0.00    0.00    0.00   0.00   0.00
    

    여기서 봐야 할 핵심 지표들은 다음과 같습니다:

    • %util: 디스크 사용률입니다. 100%에 가깝다면 디스크가 항상 바쁘다는 뜻인데, 성능 병목의 강력한 신호일 수 있어요. (⚠️ 단, 고성능 NVMe SSD의 경우 100%에 가까워도 실제 성능은 좋을 수 있으니 다른 지표와 함께 봐야 합니다!)
    • avgqu-sz (Average Queue Size): 디스크 I/O 요청 큐의 평균 길이입니다. 이 값이 지속적으로 높다면 디스크가 요청을 처리하지 못하고 대기 중인 작업이 많다는 뜻이에요. 보통 1 이상이면 주의 깊게 봐야 합니다.
    • await (Average Wait Time): I/O 요청이 디스크에서 처리되기까지 걸리는 평균 시간(밀리초)입니다. 큐에서 대기하는 시간까지 포함하므로, 이 값이 높으면 응답성이 떨어진다는 의미죠.
    • svctm (Average Service Time): I/O 요청이 디스크 컨트롤러에 의해 실제로 처리되는 평균 시간(밀리초)입니다. await과 svctm의 차이가 크다면, 큐에서 대기하는 시간이 길다는 의미로 해석할 수 있어요.

    2. sar -d: 상세 디스크 활동 통계

    sar는 시스템 활동 리포트(System Activity Reporter)의 약자로, CPU, 메모리, 네트워크 등 다양한 시스템 통계를 기록하고 분석할 수 있습니다. 디스크 I/O를 보려면 -d 옵션을 사용하면 되죠.

    # sar -d 1 5
    # -d: 디스크 활동 통계 출력
    # 1: 1초 간격으로
    # 5: 5번 반복 출력
    
    Linux 5.15.0-78-generic (my-server) 	08/20/2023 	_x86_64_ 	(8 CPU)
    
    02:30:01 PM   DEV       tps  rd_sec/s  wr_sec/s  avgrq-sz  avgqu-sz  await  svctm  %util
    02:30:02 PM   sda      0.10      2.00      0.00     40.00      0.00   0.20   0.20   0.00
    02:30:02 PM   sdb      0.00      0.00      0.00      0.00      0.00   0.00   0.00   0.00
    

    iostat과 비슷한 지표들을 제공하지만, sar는 기록된 데이터를 나중에 분석할 때 유용해요. 특히 tps (초당 전송 수), rd_sec/s (초당 읽기 섹터 수), wr_sec/s (초당 쓰기 섹터 수)를 통해 디스크의 처리량(throughput)을 가늠해볼 수 있습니다. sar는 sysstat 패키지에 포함되어 있으니, 설치되어 있지 않다면 sudo apt install sysstat (Debian/Ubuntu) 또는 sudo yum install sysstat (CentOS/RHEL)로 설치해 주세요.

    3. iotop: 프로세스별 디스크 I/O 사용량

    시스템 전체나 특정 디스크의 I/O 통계를 봤는데, 어떤 프로세스가 디스크를 많이 쓰고 있는지 궁금할 때가 있습니다. 이럴 땐 iotop이 아주 유용해요. 마치 top 명령어처럼 프로세스별 I/O 사용량을 실시간으로 보여주거든요.

    # iotop
    # -o: 현재 I/O를 사용하는 프로세스만 표시
    # -P: 프로세스만 표시 (스레드 제외)
    
    Total DISK READ :       0.00 B/s | Total DISK WRITE :       0.00 B/s
    Actual DISK READ:       0.00 B/s | Actual DISK WRITE:       0.00 B/s
      TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
        1 be/4 root        0.00 B/s    0.00 B/s  0.00 %  0.00 % init
        2 be/4 root        0.00 B/s    0.00 B/s  0.00 %  0.00 % [kthreadd]
      ...
    

    iotop을 실행하면 어떤 프로세스가 디스크를 가장 많이 쓰고 있는지 바로 알 수 있어요. IO> 컬럼을 통해 I/O 대기(wait) 상태를 파악할 수 있고, 이를 통해 특정 애플리케이션이나 서비스가 디스크 병목을 유발하는 주범인지 쉽게 찾아낼 수 있습니다. 💡

    iotop 명령어로 확인하는 프로세스별 디스크 I/O 사용량

    iotop 명령어를 통해 실시간 프로세스별 디스크 I/O 사용량을 확인하는 모습

    ⚠️ 삽질 경험담: ext4 마운트 옵션과 저널링의 함정

    제가 가장 많이 삽질했던 부분 중 하나가 바로 ext4의 마운트 옵션(Mount Options)과 저널링(Journaling)이었습니다. 단순히 디스크를 마운트할 때 아무 생각 없이 기본값으로 쓰다가 나중에 피를 본 적이 많거든요. 특히 작은 파일을 빈번하게 읽고 쓰는 웹 서버나 캐시 서버에서 이런 문제가 자주 발생하더라고요.

    1. atime 업데이트 오버헤드

    기본적으로 리눅스 파일시스템은 파일에 접근(access)할 때마다 atime(access time)을 업데이트합니다. 이게 참 좋은 기능이지만, 문제는 파일에 접근할 때마다 디스크 쓰기 작업이 발생한다는 거예요. 즉, 읽기 작업인데도 쓰기 I/O가 발생해 성능 저하를 일으킬 수 있어요. 그래서 저는 특별한 이유가 없다면 noatime 옵션을 즐겨 사용합니다.

    # /etc/fstab 파일 예시
    UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /data ext4 defaults,noatime,errors=remount-ro 0 1
    
    # 변경 후 적용
    sudo mount -o remount /data
    

    noatime은 atime 업데이트를 완전히 비활성화합니다. 만약 atime 정보가 필요하지만 I/O 오버헤드를 줄이고 싶다면 relatime 옵션을 사용할 수도 있어요. relatime은 마지막 접근 시간이 마지막 변경 시간(mtime)보다 오래되었을 때만 atime을 업데이트합니다. 대부분의 경우 relatime으로도 충분하고, 극단적인 성능이 필요하면 noatime을 사용하죠.

    2. 저널링 모드 선택

    앞서 설명했듯이, ext4는 저널링 모드에 따라 성능과 데이터 안정성이 달라집니다. 주요 모드는 세 가지입니다.

    • data=journal: 데이터와 메타데이터(metadata) 모두 저널에 기록합니다. 가장 안전하지만, 가장 느려요. 데이터 일관성이 최우선인 경우에만 고려해볼 만합니다.
    • data=ordered (기본값): 메타데이터만 저널에 기록하고, 데이터는 메타데이터가 커밋(commit)되기 전에 디스크에 쓰여지도록 보장합니다. 안정성과 성능의 균형이 좋아서 대부분의 경우에 적합하죠.
    • data=writeback: 메타데이터만 저널에 기록하고, 데이터는 저널링되지 않습니다. 가장 빠르지만, 시스템 크래시 시 데이터 손실 위험이 있어요. 캐시 디스크나 로그 디스크처럼 데이터 손실이 치명적이지 않은 경우에 고려할 수 있습니다.

    저널링 모드 변경은 파일시스템 생성 시 또는 tune2fs 명령으로 변경 가능하지만, 마운트 옵션으로도 지정할 수 있어요. 예를 들어, 성능을 최우선으로 한다면:

    # /etc/fstab 파일 예시
    UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /cache ext4 defaults,noatime,data=writeback 0 2
    
    # 변경 후 적용
    sudo mount -o remount /cache
    

    ⚠️ data=writeback은 데이터 손실 위험이 있으니 신중하게 사용하세요!

    3. 그 외 유용한 마운트 옵션

    몇 가지 더 유용한 옵션들을 표로 정리해봤습니다.

    옵션 설명 효과 주의사항
    noatime 파일 접근 시간(atime) 업데이트 비활성화 읽기 I/O 발생 시 쓰기 I/O 감소 atime 정보가 필요한 애플리케이션에 문제 발생 가능
    relatime mtime보다 오래된 atime만 업데이트 noatime과 atime 업데이트의 절충안 대부분의 경우 noatime 대신 사용 권장
    data=writeback 데이터 저널링 비활성화 가장 빠른 쓰기 성능 시스템 크래시 시 데이터 손실 위험
    barrier=0 디스크 쓰기 배리어(write barrier) 비활성화 일부 환경에서 쓰기 성능 향상 RAID 컨트롤러 또는 가상 환경에서 데이터 손실 위험 증가, 신중히 사용
    discard TRIM 명령 활성화 (SSD에 유용) SSD 성능 유지 및 수명 연장 지속적인 TRIM으로 인한 오버헤드 발생 가능 (주기적인 fstrim 권장)
    ext4 마운트 옵션별 성능 및 데이터 안정성 비교 인포그래픽

    ext4 마운트 옵션별 성능 및 데이터 안정성 비교

    ✅ 검증 및 결과 확인: 성능 개선을 눈으로 확인하기!

    마운트 옵션을 변경했거나, 문제가 되는 프로세스를 해결했다면, 이제 정말로 성능이 개선되었는지 확인해야겠죠? 저는 주로 iostat이나 sar로 다시 한번 지표를 확인해요. 특히 await, avgqu-sz, %util 값이 어떻게 변했는지 집중해서 봅니다.

    • await 값이 5ms 미만으로 꾸준히 유지된다면 양호한 수준입니다. 10ms 이상으로 지속된다면 여전히 I/O 병목이 있을 가능성이 높아요.
    • avgqu-sz 값이 1 이하로 떨어졌는지 확인해요. 이 값이 높으면 I/O 요청이 계속 밀리고 있다는 뜻이거든요.
    • %util은 무조건 낮다고 좋은 건 아니지만, 과도하게 높으면서 다른 지표들(await, avgqu-sz)도 높다면 문제가 있다는 신호예요.

    만약 특정 애플리케이션의 성능 저하가 문제였다면, 해당 애플리케이션의 응답 시간(response time)이나 처리량(throughput) 지표를 직접 확인하는 것이 가장 정확해요. 예를 들어, 데이터베이스 쿼리 속도가 빨라졌는지, 웹 페이지 로딩 시간이 단축되었는지 등을 측정해보는 거죠. 저는 Grafana와 Prometheus를 활용해서 I/O 지표를 시각화해서 보곤 하는데, 이렇게 하면 변화 추이를 한눈에 파악하기 정말 편하더라고요. 🎉

    Grafana 대시보드에서 디스크 I/O 성능 개선 추이를 시각적으로 확인하는 예시

    마무리: 나의 ext4는 어떤 길을 가야 할까? 🚀

    ext4 디스크 I/O 성능 문제는 정말 흔하고, 처음엔 막막하게 느껴질 수 있어요. 하지만 iostat, sar, iotop 같은 도구들을 활용해서 정확한 원인을 파악하고, 적절한 마운트 옵션 튜닝이나 저널링 모드 변경을 통해 충분히 해결할 수 있습니다.

    결론적으로, “내 ext4는 어떤 설정이 최적일까?”라는 질문에 대한 답은 “어떤 데이터를, 어떻게 사용하는지에 따라 다르다”는 거예요.

    • 만약 데이터 일관성과 안전성이 최우선이라면, data=ordered (기본값)를 유지하고, noatime보다는 relatime을 고려하는 것이 좋습니다.
    • 로그 파일이나 캐시처럼 데이터 손실에 비교적 관대한 환경에서 최고의 쓰기 성능을 원한다면, data=writeback과 noatime 조합을 신중하게 테스트해볼 수 있습니다. 하지만 이 경우 백업 전략을 더욱 철저히 해야겠죠.
    • SSD를 사용 중이라면, discard 옵션을 사용하거나 주기적으로 fstrim 명령을 실행하여 성능을 유지하는 것이 좋아요.

    이번 글이 여러분의 ext4 성능 문제 해결에 작은 도움이 되었기를 바랍니다. 다음번에는 파일 단편화(fragmentation) 문제를 깊이 다루거나, 더 나아가 ZFS나 Btrfs 같은 다른 파일시스템의 성능 튜닝에 대해서도 이야기해볼까 합니다. 궁금한 점이 있다면 언제든 댓글로 남겨주세요! 저의 삽질 경험이 또 다른 글이 될 수 있으니까요. 😉

  • [Linux] Linux namespace 활용 사례: 격리 환경 구축과 보안 강화 실전

    [Linux] Linux namespace 활용 사례: 격리 환경 구축과 보안 강화 실전

    Linux namespace 활용 사례: 격리 환경 구축과 보안 강화 실전

    리눅스 서버를 오래 만지다 보면, 운영 호스트는 그대로 둔 채 어떤 프로세스가 실제로 무엇을 건드리는지 빨리 확인해야 하는 순간이 꼭 옵니다. 패키지를 하나 설치했더니 예상 못 한 소켓을 열고, 에이전트를 하나 올렸더니 부팅 직후 파일을 여기저기 만들고, 테스트 스크립트가 외부망으로 나가 버리는 식이죠. 저도 홈랩이든 실무든 비슷했고요. 그때 가장 손에 익혀둘 가치가 있었던 게 Linux namespace 활용이었습니다. 흔히 컨테이너의 재료 정도로만 설명되지만, 직접 unshare, ip netns, nsenter를 만져보면 이건 단순 개념 설명으로 끝낼 기술이 아니더라고요.

    중요한 건 기대치를 정확히 잡는 겁니다. namespace는 마법 상자가 아닙니다. 커널은 공유하고, CPU·메모리 같은 자원 제한도 기본으로 걸어주지 않습니다. 대신 프로세스가 보는 시야를 분리합니다. 이 차이를 이해하면 “언제 namespace를 직접 쓰고, 언제 컨테이너 런타임이나 VM으로 넘어가야 하는지” 판단이 훨씬 빨라집니다. 오늘은 정의 위주보다, Linux namespace 활용이 실제로 어디에 먹히고 어디서 한계가 드러나는지에 초점을 맞춰 보겠습니다.

    Linux namespace 활용에서 격리 범위를 보여주는 아키텍처 다이어그램

    호스트와 각 namespace가 PID, Network, Mount, UTS 관점에서 어떻게 분리되는지 한눈에 보여주는 개요 이미지입니다.

    1. 왜 Linux namespace 활용이 실무에서 바로 먹히는가

    제가 현장에서 namespace를 꺼내는 순간은 대체로 두 가지입니다. 첫째, 운영 호스트에 패키지나 설정 찌꺼기를 남기고 싶지 않을 때입니다. 둘째, 테스트 대상 프로세스가 어디까지 보이고 어디로 나가는지 통제하고 싶을 때죠. 이건 Dockerfile을 만들 정도로 반복적인 작업은 아닌데, 그렇다고 그냥 호스트에서 돌리기엔 찜찜한 상황에서 특히 유용합니다.

    • 서비스 시작 스크립트가 어떤 파일과 소켓을 만드는지 확인할 때
    • 외부 통신을 막은 채 애플리케이션 기동 여부만 검증할 때
    • 방화벽 정책이나 라우팅 변경 전, 별도 네트워크 공간에서 먼저 재현할 때
    • 문제 프로세스를 호스트 전체와 분리해 관찰할 때

    여기서 VM과 컨테이너, namespace를 같은 선상에서만 비교하면 판단이 흐려집니다. 제가 체감한 기준은 이렇습니다. VM은 커널까지 분리하는 대신 무겁고, 컨테이너는 운영 편의성이 좋지만 준비물이 생기고, namespace 직접 사용은 가장 가볍지만 조립 책임이 사용자에게 있습니다. 즉, namespace는 “기능이 약한 컨테이너”라기보다 “필요한 격리 축만 직접 뽑아 쓰는 커널 도구”에 가깝습니다.

    실무 품질을 좌우하는 포인트도 분명합니다. namespace는 CPU나 메모리 폭주를 막아주지 않습니다. 파일 시스템 쓰기 범위도 자동으로 최소화해 주지 않고요. 그래서 저는 보통 이렇게 선을 그어 설명합니다. namespace는 분리, cgroup은 제한, seccomp는 호출 축소, LSM(AppArmor/SELinux)은 정책 강제. 이걸 구분하지 않고 “격리됐다”라고만 말하면 운영 단계에서 사고 납니다.

    2. Linux namespace 활용 전, 무엇이 분리되는지 먼저 보자

    리눅스 네임스페이스는 종류를 외우는 것보다, 각 종류가 장애 분석과 보안에 어떤 영향을 주는지 아는 편이 더 중요합니다. 아래 표는 제가 실제로 판단할 때 쓰는 관점으로 정리한 겁니다.

    Namespace 종류 분리 대상 실무에서 바로 체감되는 효과 자주 놓치는 포인트 주요 도구
    PID 프로세스 ID 공간 테스트 프로세스를 별도 PID 1처럼 실행 가능 /proc를 새로 준비하지 않으면 ps 결과가 헷갈릴 수 있음 unshare --pid --fork, nsenter --pid
    NET 인터페이스, 라우팅, 포트, 방화벽 외부 송신 차단, veth 기반 통신 테스트, 정책 검증 lo를 올리지 않으면 내부 앱이 예상 밖으로 깨질 수 있음 ip netns, ip link, ip route
    MNT 마운트 포인트와 파일시스템 시야 임시 루트, bind mount, 읽기 전용 뷰 구성 전파 속성(shared/private) 이해 없이 건드리면 의도치 않게 호스트에 반영될 수 있음 unshare --mount, mount, pivot_root
    UTS 호스트명, NIS 도메인명 로그와 프롬프트에서 실험 환경 식별이 쉬워짐 보안 경계 자체는 약하고 주로 식별성 개선용 unshare --uts, hostname
    USER UID/GID 매핑과 일부 권한 범위 비특권 사용자 기반 실험에 유리 배포판 정책, 커널 설정, /etc/subuid//etc/subgid 상태에 따라 동작 차이가 큼 unshare --user --map-root-user
    IPC System V IPC, POSIX 메시지 큐 다른 프로세스와 IPC 충돌 방지 눈에 바로 안 보여서 문제 원인을 놓치기 쉬움 unshare --ipc

    저는 처음부터 전부 묶지 않습니다. 대부분 NET + MNT + PID로 시작하고, 로그 식별이 필요하면 UTS, 비특권 실험이 필요하면 USER를 추가합니다. 이유가 있습니다. namespace는 많이 넣을수록 안전해지는 면도 있지만, 동시에 원인 분리가 어려워집니다. 실패했을 때 어느 층에서 막혔는지 빨리 찾는 편이 실무에선 더 중요하거든요.

    3. 실전 시나리오: 격리된 테스트 환경을 직접 만들어보겠습니다

    이번에는 재현 가능한 시나리오로 가보겠습니다. 상황은 이렇다고 치죠. 새 에이전트를 올리기 전에, 이 프로세스가 외부망으로 나가지 못하게 막은 상태에서 정상 기동하는지 확인하고 싶습니다. 또한 호스트의 프로세스 목록과 파일시스템을 그대로 노출하고 싶지 않습니다. 이럴 때 저는 보통 1단계는 네트워크 분리, 2단계는 PID와 마운트 분리 순으로 갑니다.

    1. 호스트와 분리된 네트워크 namespace를 만듭니다.
    2. veth 페어를 붙여 통신 경로를 의도적으로 만듭니다.
    3. namespace 내부에 서비스 하나를 띄워 실제로 어디에 바인딩되는지 확인합니다.
    4. 그다음 PID, UTS, Mount namespace를 붙여 프로세스와 파일시스템 시야까지 좁힙니다.

    이 순서를 추천하는 이유는 단순합니다. 네트워크 단절, 바인딩 실패, /proc 관찰 오류, 마운트 전파 실수는 각기 보는 포인트가 다릅니다. 한 번에 다 엮으면 증상은 하나인데 원인은 넷 다 될 수 있거든요.

    3-1. 네트워크 namespace 생성

    먼저 가장 자주 쓰는 형태부터 보겠습니다. 아래 예시는 호스트 쪽 veth-host와 namespace 내부 veth-lab를 연결하고, 기본 라우트까지 넣는 최소 구성입니다. 외부 인터넷이 꼭 필요 없다면 default route는 일부러 빼도 괜찮습니다.

    sudo ip netns add labns
    sudo ip link add veth-host type veth peer name veth-lab
    sudo ip link set veth-lab netns labns
    
    sudo ip addr add 10.200.1.1/24 dev veth-host
    sudo ip link set veth-host up
    
    sudo ip netns exec labns ip addr add 10.200.1.2/24 dev veth-lab
    sudo ip netns exec labns ip link set lo up
    sudo ip netns exec labns ip link set veth-lab up
    sudo ip netns exec labns ip route add default via 10.200.1.1
    
    sudo ip netns exec labns ip addr
    sudo ip netns exec labns ip route

    이 단계에서 제일 많이 놓치는 건 두 가지입니다. 하나는 lo를 올리지 않는 것, 다른 하나는 “기본 라우트가 있어야만 내부 테스트가 된다”고 오해하는 겁니다. 사실 동일 서브넷 내 호스트와 namespace 간 통신만 볼 거라면 default route가 없어도 됩니다. 반대로 외부로 내보낼 생각이 없다면 일부러 default route를 넣지 않는 편이 더 안전합니다. 이거 진짜 편하더라고요. 시작부터 길을 다 열어 두면, 통신이 되는 이유가 라우팅인지 NAT인지 애플리케이션 재시도 때문인지 해석이 흐려집니다.

    또 하나, ip netns add는 named network namespace를 /var/run/netns/ 관례 아래에 연결해 관리합니다. 배포판에 따라 실제 경로가 /run/netns/로 보일 수도 있는데, 보통 둘은 같은 런타임 경로 계열로 이해하면 됩니다. 그래서 정리 순서도 중요합니다. 네임스페이스 내부 프로세스가 남아 있으면 삭제가 깔끔하게 안 될 수 있습니다.

    sudo ip netns pids labns
    sudo ip netns exec labns pkill -f http.server || true
    sudo ip netns del labns
    sudo ip link del veth-host || true

    여기서 ip netns pids를 먼저 보는 이유가 있습니다. veth 삭제가 실패하거나 namespace 삭제가 남는 경우, 대개 내부에 살아 있는 프로세스가 원인입니다. 명령어는 간단한데 cleanup을 무시하면 실습이 반복될수록 환경이 꼬입니다.

    3-2. namespace 내부에서 서비스 실행

    이제 namespace 내부에 실제 서비스를 올려 보겠습니다. 예시는 단순하지만, 판단 기준은 실무 그대로 가져갈 수 있습니다. 핵심은 “서비스가 떴는가”가 아니라 어디에 바인딩됐고, 어느 네임스페이스 관점에서 보여야 정상인가를 확인하는 겁니다.

    sudo mkdir -p /srv/labns/www
    printf '%s\n' 'hello from lab namespace' | sudo tee /srv/labns/www/index.html > /dev/null
    
    sudo ip netns exec labns bash -lc 'cd /srv/labns/www && python3 -m http.server 8080 --bind 10.200.1.2'
    
    # 다른 터미널에서 확인
    curl http://10.200.1.2:8080/
    sudo ip netns exec labns ss -lntp
    sudo ip netns exec labns curl -I http://10.200.1.2:8080/

    제가 이 단계에서 항상 같이 보는 건 ss -lntp와 바인딩 주소입니다. 127.0.0.1에만 바인딩된 서비스는 namespace 외부에서 접근되지 않아도 정상일 수 있습니다. 반대로 0.0.0.0에 걸렸다면 namespace 내부에선 모든 인터페이스를 받는다는 뜻이라, 추후 포워딩을 붙이면 생각보다 넓게 열릴 수 있습니다. 즉, 서비스 가시성 문제를 네트워크 문제로 오진하지 않는 것이 중요합니다.

    호스트에서 안 열리는데 namespace 내부에서는 잘 뜨는 경우도 자주 봅니다. 이때는 실패가 아니라, 오히려 격리가 의도대로 작동한 신호일 수 있습니다. 확인 순서는 보통 이렇습니다.

    • ip netns exec labns ss -lntp로 프로세스가 어떤 IP와 포트에 bind 했는지 본다
    • curl을 namespace 내부에서 먼저 날린다
    • 그다음 호스트에서 같은 IP로 접근해 경로가 있는지 확인한다
    • 필요하면 NAT나 포워딩을 추가하되, 마지막 단계로 미룬다
    리눅스 네임스페이스 기반 격리 환경 구축 네트워크 구성도

    호스트의 veth와 namespace 내부 인터페이스가 어떻게 연결되고, 테스트 서비스가 어느 IP에 바인딩되는지 설명하는 구성도입니다.

    3-3. 외부 송신까지 통제하려면 어디를 더 봐야 하나

    네트워크 namespace를 만든 뒤 많은 분들이 “이제 외부 인터넷도 자동으로 막히겠지”라고 생각하시는데, 그건 아닙니다. 경로와 NAT를 어떻게 주느냐에 따라 얼마든지 나갈 수 있습니다. 그래서 보안 검증 목적이면 처음에는 default route를 아예 주지 않거나, 주더라도 포워딩과 NAT를 넣지 않은 상태에서 출발하는 편이 낫습니다.

    외부 통신이 필요한 테스트라면 호스트에 포워딩과 NAT를 붙일 수는 있습니다. 다만 이건 편의성보다 해석 난도가 올라갑니다. 정책 검증이 목적이라면 “닫힌 상태에서 하나씩 여는 방식”이 원인 파악에 훨씬 유리합니다. 최소 예시는 아래처럼 구성합니다. 최근 배포판은 nftables를 기본으로 쓰는 경우도 많으니, 환경에 따라 iptables 명령이 호환 계층인지도 같이 확인하는 편이 좋습니다.

    # 호스트에서 IPv4 forwarding 활성화
    sudo sysctl -w net.ipv4.ip_forward=1
    
    # 예시: labns 대역을 외부 인터페이스 eth0로 NAT
    sudo iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -o eth0 -j MASQUERADE
    sudo iptables -A FORWARD -i eth0 -o veth-host -m state --state RELATED,ESTABLISHED -j ACCEPT
    sudo iptables -A FORWARD -i veth-host -o eth0 -j ACCEPT

    다만 저는 일회성 분석 용도라면 여기까지는 잘 안 갑니다. NAT가 들어오면 이제 문제 원인이 애플리케이션인지, 라우팅인지, 포워딩 정책인지, 외부 DNS인지 한 층 더 늘어나기 때문입니다. 단순 기동 확인이라면 닫힌 내부망에서 끝내는 쪽이 훨씬 깔끔했습니다.

    3-4. PID, UTS, Mount까지 붙여 더 독립적인 실행 환경 만들기

    네트워크 분리만으로는 부족할 때가 많습니다. 프로세스 목록을 호스트와 분리하고, hostname을 바꾸고, 마운트 시야까지 따로 가져가면 디버깅 노이즈가 크게 줄어듭니다. 제가 자주 쓰는 형태는 아래와 같습니다.

    sudo unshare --fork --pid --mount --uts --ipc bash -lc '
      mount --make-rprivate /
      mount -t proc proc /proc
      hostname lab-host
      echo "[inside namespace] hostname: $(hostname)"
      echo "[inside namespace] process list:"
      ps -ef
      sleep 300
    '

    여기서 핵심은 두 줄입니다. mount --make-rprivate /와 mount -t proc proc /proc입니다. 첫 번째는 마운트 이벤트 전파를 끊어 의도치 않은 공유를 줄이는 역할을 하고, 두 번째는 현재 PID namespace 관점의 /proc을 다시 보여주기 위한 겁니다. 참고로 최근 unshare는 새 mount namespace에서 기본적으로 마운트를 private 쪽으로 돌리는 동작을 하기도 하지만, 명시적으로 적어두면 실습 문서나 운영 절차에서 덜 헷갈립니다.

    특히 PID namespace는 PID 1의 성격 때문에 생각보다 함정이 있습니다. 격리 환경 안에서 PID 1은 일반 프로세스보다 시그널 처리와 좀비 수거에 민감합니다. 실험처럼 짧게 돌릴 땐 괜찮지만, 조금이라도 오래 유지할 프로세스라면 tini 같은 init 래퍼나 별도 supervisor 역할을 고려하는 편이 안전합니다. 이 부분은 컨테이너에서도 똑같이 반복되죠.

    비특권 실험이 필요하면 USER namespace도 검토할 수 있습니다.

    unshare --user --map-root-user --mount --pid --fork bash -lc '
      id
      mount -t proc proc /proc
      ps -ef
      cat /proc/self/uid_map
      cat /proc/self/gid_map
    '

    다만 이건 “되면 좋고 아니면 말고” 식으로 접근하면 안 됩니다. 실제 서버에서는 /proc/sys/user/max_user_namespaces 값, 배포판 보안 정책, 그리고 일부 배포판에서만 제공되는 /proc/sys/kernel/unprivileged_userns_clone 같은 설정 때문에 막히는 경우가 적지 않습니다. 저는 이 단계에서 시간을 오래 쓰기보다, 운영 정책이 금지한 기능이라면 굳이 namespace 직접 조합에 집착하지 않는다는 쪽입니다. 홈랩과 운영 서버가 다르게 반응하는 대표적인 영역이 바로 여기거든요.

    4. Linux namespace 활용과 보안 강화, 어디까지 믿어도 되나

    Linux namespace 활용의 가장 큰 장점은 사고 범위를 줄이는 데 있습니다. 테스트 프로세스가 호스트 전체 프로세스 목록을 못 보고, 네트워크 인터페이스도 제한되고, 마운트 시야까지 좁아지면 “한 번 잘못 돌린 실험이 운영 전체를 어지럽히는 일”이 크게 줄어듭니다. 실수 방지 효과가 꽤 큽니다. 하지만 여기까지입니다. namespace만으로 완전한 샌드박스가 됐다고 보면 위험합니다.

    이유는 단순합니다. 커널은 여전히 공유합니다. 커널 취약점이 있거나 capability를 넓게 줬거나, 마운트와 디바이스 접근을 느슨하게 두면 격리 강도는 금방 낮아집니다. 제가 실무에서 namespace를 단독 보안 장치로 보지 않는 이유도 여기에 있습니다. 보통 아래 조합으로 봅니다.

    • namespace: 프로세스가 보는 세계 분리
    • cgroup: CPU, 메모리, pids 폭주 제한
    • seccomp: 허용 syscall 범위 축소
    • AppArmor 또는 SELinux: 파일·소켓·능력 사용 제약
    • capability drop: 필요 없는 권한 제거

    실무 판단을 더 직접적으로 말하면 이렇습니다. namespace는 “격리의 뼈대”이고, seccomp와 capability 조정이 “탈출 비용을 올리는 장치”이며, cgroup은 “망가질 때 피해 규모를 제한하는 장치”입니다. 하나만 넣고 안심하는 구조가 제일 위험했습니다.

    systemd를 쓰는 환경이라면, 별도 컨테이너 런타임 없이도 서비스 단에서 격리 옵션을 꽤 보강할 수 있습니다. 예를 들면 아래 같은 방향입니다.

    [Service]
    ExecStart=/usr/local/bin/my-agent
    NoNewPrivileges=yes
    PrivateTmp=yes
    ProtectSystem=strict
    ProtectHome=yes
    PrivateDevices=yes
    RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
    CapabilityBoundingSet=
    SystemCallFilter=@system-service

    이건 namespace의 대체재라기보다 보완재에 가깝습니다. 특히 “운영 서비스인데 컨테이너까지는 안 가고 싶다”는 구간에서 생각보다 강력합니다. 저도 네트워크 namespace로 송신 범위를 줄이고, systemd 옵션으로 파일시스템과 권한을 더 잠그는 조합을 자주 씁니다. 관련 옵션을 더 파고들고 싶다면 systemd 보안 설정 정리 글도 함께 보시면 흐름이 더 잘 잡힙니다.

    5. 트러블슈팅: 제가 실제로 자주 겪었던 문제들

    명령어보다 중요한 게 해석 기준입니다. namespace는 증상이 비슷해도 원인이 전혀 다른 경우가 많습니다. 아래는 제가 가장 자주 봤던 실패 패턴과, 그때 실제로 다시 확인하는 순서입니다.

    5-1. ping은 되는데 애플리케이션 통신은 안 되는 경우

    이건 라우팅보다 바인딩 주소 문제인 경우가 많았습니다. 127.0.0.1에만 바인딩된 서비스는 namespace 내부에서는 잘 떠 보이는데, 호스트나 다른 namespace에서는 닿지 않습니다. 반대로 0.0.0.0에 걸렸는데도 안 붙는다면 방화벽이나 경로를 보게 되죠. 저는 이럴 때 순서를 고정해 둡니다.

    • ss -lntp로 listening 주소를 먼저 본다
    • curl을 서비스가 실행 중인 namespace 내부에서 먼저 친다
    • 설정 파일의 listen, bind, host 값을 본다
    • 그다음에야 라우팅과 필터링 규칙을 본다

    이 순서를 추천하는 이유는, 네트워크 문제처럼 보이지만 실제론 프로세스 설정 문제인 경우가 정말 많기 때문입니다. bind 주소 확인 없이 iptables부터 보는 건 대개 시간 낭비였습니다.

    5-2. PID namespace에서 ps 결과가 이상한 경우

    거의 항상 /proc 문제였습니다. 새 PID namespace에 들어갔는데 현재 namespace에 맞는 proc 파일시스템을 보지 않으면, 프로세스 목록이 호스트 기준처럼 보여 혼란이 생깁니다.

    mount -t proc proc /proc
    ps -ef
    cat /proc/1/status
    readlink /proc/self/ns/pid

    /proc/1/status를 보면 현재 namespace 관점의 PID 1이 누구인지 알 수 있고, readlink /proc/self/ns/pid는 실제로 다른 namespace inode를 보고 있는지 확인하는 데 도움이 됩니다. 여기서 “ps가 이상하다”는 증상 자체가 문제의 본질이 아니라 관찰 대상이 잘못됐다는 신호인 경우가 많습니다.

    5-3. 사용자 namespace가 서버마다 다르게 동작하는 경우

    이건 커널 기능 차이보다 운영 정책 차이가 더 크게 체감됐습니다. 특히 비특권 사용자로 unshare --user --map-root-user를 시도할 때 서버마다 결과가 달라집니다.

    cat /proc/sys/user/max_user_namespaces
    [ -f /proc/sys/kernel/unprivileged_userns_clone ] && cat /proc/sys/kernel/unprivileged_userns_clone
    id
    uname -r
    • max_user_namespaces가 낮거나 0이면 생성이 막힐 수 있습니다.
    • unprivileged_userns_clone는 일부 배포판에서만 노출되는 설정이라, 파일이 없다고 이상한 건 아닙니다.
    • 보안 기준이 엄격한 서버는 정책상 의도적으로 비활성화한 경우가 많습니다.

    이럴 때 억지로 우회하는 건 보통 좋은 설계가 아닙니다. 제가 권하는 건 두 가지 중 하나입니다. 운영에서 반드시 필요하면 관리자 정책으로 정식 허용을 받거나, 아니면 컨테이너 런타임이나 별도 테스트 노드로 경로를 바꾸는 것. 운영 정책과 싸우는 형태는 오래 못 갑니다.

    5-4. 마운트는 분리한 줄 알았는데 호스트에 영향이 남는 경우

    이건 mount namespace를 처음 만질 때 특히 많이 겪습니다. 원인은 대개 마운트 전파 속성입니다. namespace를 나눴다고 해서 모든 마운트 이벤트가 자동으로 고립되는 건 아닙니다. 그래서 저는 mount 작업 전에 mount --make-rprivate /를 거의 습관처럼 넣습니다. 이 한 줄을 빼먹으면 bind mount나 임시 마운트가 예상 밖으로 공유돼 버릴 수 있습니다.

    증상은 보통 이렇습니다. 실험 환경 안에서만 붙인 줄 알았던 마운트가 호스트에서도 보이거나, 반대로 호스트 변경이 내부에 그대로 따라 들어옵니다. 이 경우에는 namespace 개념 자체보다 전파 모델을 다시 보는 편이 빠릅니다. 실무에서 mount namespace가 네트워크 namespace보다 까다롭게 느껴지는 이유가 여기 있습니다.

    Linux namespace 활용 점검과 트러블슈팅을 표현한 터미널 스타일 이미지

    ip netns exec, ss -lntp, mount -t proc proc /proc 같은 핵심 점검 포인트를 한 장으로 정리한 이미지입니다.

    6. 검증과 결과 확인: 무엇을 보고 성공이라 판단하나

    명령이 에러 없이 끝났다고 성공은 아닙니다. namespace는 “만들어졌다”와 “의도대로 격리됐다” 사이에 차이가 큽니다. 저는 아래 순서대로 확인합니다.

    1. 인터페이스가 의도한 namespace에 들어갔는지 본다
    2. 프로세스가 내부에서 어떤 PID와 hostname으로 보이는지 본다
    3. 서비스 포트가 기대한 주소에 bind 되었는지 확인한다
    4. 호스트와 namespace 사이 통신 범위를 점검한다
    5. 원치 않은 외부 송신 경로가 없는지 마지막에 확인한다
    ip netns list
    sudo ip netns exec labns ip addr
    sudo ip netns exec labns ip route
    sudo ip netns exec labns ps -ef
    sudo ip netns exec labns ss -lntp
    sudo ip netns exec labns curl -I http://10.200.1.2:8080/
    
    # 호스트에서 접근 확인
    curl -I http://10.200.1.2:8080/
    
    # namespace 내부에서 호스트 방향 확인
    sudo ip netns exec labns ping -c 2 10.200.1.1
    
    # 실제 네임스페이스 분리 여부 확인
    sudo ip netns exec labns readlink /proc/self/ns/net
    readlink /proc/self/ns/net

    결과 해석 기준도 미리 정해 두면 좋습니다.

    • ip netns list에 보이면 생성은 된 겁니다. 하지만 그걸로 끝은 아닙니다.
    • ip addr에 기대한 IP가 없으면 링크 이동이나 주소 할당 단계부터 다시 봐야 합니다.
    • ss -lntp에 프로세스가 없으면 서비스 시작 실패입니다. 이때는 로그보다 실행 명령과 bind 주소를 먼저 봅니다.
    • 호스트에서는 안 보이는데 내부에서는 보이면, 실패가 아니라 격리 성공일 수 있습니다.
    • 외부 통신이 되면 안 되는 시나리오인데 HTTP나 DNS가 나가면 default route, 포워딩, NAT부터 다시 봐야 합니다.

    제가 특히 강조하는 건 마지막 항목입니다. 격리 환경 검증에서 자주 빠지는 게 “열려 있지 않아야 할 경로 확인”입니다. 되는 것만 보면 절반만 본 겁니다. 막혀야 할 것이 실제로 막히는지 확인해야 보안 관점의 검증이 됩니다.

    7. 운영에 붙일 때의 현실적인 추천 조합

    실제로 써보면 모든 namespace를 한 번에 다 쓰는 방식은 종종 과합니다. 목적에 따라 조합을 나누는 편이 유지보수성이 더 좋았습니다. 아래 표는 제가 선택할 때 쓰는 기준입니다.

    상황 추천 조합 이유 굳이 피할 상황
    간단한 네트워크 테스트 NET 설정이 단순하고 효과가 바로 보입니다. 장기 운영 서비스처럼 재현성과 배포 표준화가 중요한 경우
    프로세스 독립 실행 검증 PID + UTS + MNT 프로세스, hostname, 파일시스템 시야를 함께 분리하기 좋습니다. 마운트 전파를 이해하지 못한 상태에서 급히 운영에 넣는 경우
    보안 민감한 임시 실행 USER + NET + MNT + cgroup + seccomp 권한, 시야, 자원, syscall 범위를 함께 줄일 수 있습니다. USER namespace 정책이 금지된 운영 서버
    반복 배포되는 서비스 직접 namespace보다 컨테이너 런타임 검토 이미지 관리, 재시작 정책, 로깅, 표준화 측면에서 유리합니다. 일회성 포렌식 재현이나 빠른 실험처럼 준비 비용이 아까운 경우

    여기서 제 추천은 꽤 명확합니다. 한 번성 검증이면 직접 namespace, 반복 운영이면 컨테이너 런타임이 대체로 맞습니다. 전자는 스패너처럼 빠르게 꺼내 쓰는 도구이고, 후자는 운영 체계에 가깝습니다. 둘 중 무엇이 더 우월하냐의 문제가 아니라, 조립 책임을 누가 지느냐의 차이로 보는 편이 현실적입니다.

    비용 관점에서도 비슷합니다. namespace 직접 조합은 초기 준비가 가볍지만 사람이 더 똑똑해야 합니다. 반면 컨테이너 런타임은 준비물이 늘어나지만 반복성이 좋아집니다. 그래서 팀 단위로 넘어가면 후자가 이기고, 개인 디버깅이나 빠른 재현 실험에선 전자가 여전히 강합니다. 관련해서 컨테이너와 VM 비교 글까지 같이 연결하면 내부 링크 흐름도 자연스럽게 만들 수 있습니다.

    Linux namespace 활용과 컨테이너 선택 기준을 비교한 인포그래픽

    일회성 테스트, 반복 운영, 보안 요구사항에 따라 어떤 선택이 적합한지 비교한 요약 이미지입니다.

    8. 자주 받는 질문과 마지막 권고

    Q1. namespace만 쓰면 컨테이너를 대체할 수 있나요?

    부분적으로는 가능합니다. 특히 빠른 재현, 네트워크 격리 실험, 프로세스 시야 분리 정도는 namespace만으로도 충분히 됩니다. 다만 이미지 관리, 배포 파이프라인, 재시작 정책, 로그 수집 표준화까지 생각하면 컨테이너 런타임이 훨씬 편한 구간이 분명히 있습니다. 저는 보통 “문제 재현과 분석 단계는 namespace”, “반복 서비스화 단계는 컨테이너”로 나눠 봅니다.

    Q2. 보안 강화 목적이라면 무엇부터 붙여야 하나요?

    제가 권하는 시작점은 NET namespace로 통신 범위를 먼저 줄이고, 그다음 MNT와 PID를 분리하는 방식입니다. 여기에 capability 최소화, seccomp, cgroup 제한을 얹는 쪽이 사고를 줄이기 좋았습니다. 처음부터 다 넣으면 강해 보이긴 하지만, 실패 시 원인 분리가 너무 어려워집니다.

    Q3. 홈랩에서도 해볼 가치가 있나요?

    충분합니다. 오히려 홈랩이 제일 좋습니다. 실패해도 운영 영향이 없고, namespace 특유의 함정인 bind 주소, loopback, /proc 준비, mount propagation을 몸으로 익히기 좋거든요. 저도 홈랩에서 먼저 손에 익힌 뒤에야 운영 서버에서 판단 속도가 빨라졌습니다.

    마지막으로 추천을 딱 잘라 말씀드리면 이렇습니다. 단발성 테스트, 포렌식성 재현, 서비스 기동 검증이 목적이면 Linux namespace 활용을 바로 써보는 편이 맞습니다. 반대로 팀 단위 반복 운영, 배포 자동화, 표준 이미지 관리가 목적이면 직접 namespace를 조립하기보다 컨테이너 런타임으로 넘어가는 쪽이 낫습니다. 보안 관점에서도 마찬가지입니다. namespace는 아주 좋은 출발점이지만 종착점은 아닙니다. 네트워크 분리만 믿지 말고, 권한과 syscall, 자원 제한까지 묶어야 실전에서 덜 흔들립니다.

  • [AI] AI 모델 클라우드 마이그레이션 체크리스트: 온프레미스 AI 전환 기준

    [AI] AI 모델 클라우드 마이그레이션 체크리스트: 온프레미스 AI 전환 기준

    AI 모델 클라우드 마이그레이션 체크리스트: 온프레미스 AI 전환 기준

    AI 모델 클라우드 마이그레이션을 검토할 때 많은 팀이 처음엔 GPU 인스턴스만 확보하면 된다고 생각합니다. 저도 예전엔 그렇게 봤는데, 실제 전환 프로젝트에 들어가면 GPU보다 먼저 터지는 문제는 데이터 경로, 모델 아티팩트 배포, 보안 경계, 준비 상태 검증이더라고요. 결국 이 작업은 서버 위치만 바꾸는 일이 아니라, 추론 요청이 들어와서 모델이 로드되고 결과가 저장되기까지의 흐름을 다시 설계하는 일에 가깝습니다.

    특히 AI 워크로드는 일반 웹 서비스보다 실패 양상이 더 복잡합니다. 애플리케이션 프로세스는 살아 있는데 모델 다운로드가 끝나지 않아 readiness가 실패할 수 있고, GPU는 한가한데 전처리 CPU가 막혀 지연이 튀는 경우도 흔합니다. 클라우드로 옮긴 뒤에도 입력 데이터가 온프레미스에 남아 있으면 왕복 지연과 전송 비용이 계속 쌓이죠. 그래서 이 글은 원론보다, 전환 회의와 전환 당일에 바로 써먹을 수 있는 체크 기준으로 정리해보겠습니다.

    AI 모델 클라우드 마이그레이션 전체 아키텍처 개요 이미지

    온프레미스 AI, 스토리지, 네트워크, 클라우드 추론 계층이 어떻게 연결되는지 한눈에 보여주는 개요 이미지입니다.

    1. 왜 AI 모델 클라우드 마이그레이션이 까다로운가

    일반 애플리케이션 이전은 보통 애플리케이션 바이너리와 데이터베이스를 중심으로 보면 됩니다. 그런데 AI 추론 서비스는 여기에 모델 파일, 토크나이저와 전처리 리소스, GPU 드라이버 호환성, 컨테이너 이미지 크기, 아티팩트 캐시, 배치와 실시간 트래픽의 공존이 더해집니다. 이 중 하나만 설계가 어긋나도 서비스는 떠 있지만 실제 요청은 느리거나 불안정해집니다.

    현장에서 자주 보는 실패는 크게 세 가지입니다. 첫째, 서비스는 클라우드에 올렸는데 입력 데이터와 피처 저장소는 사내망에 그대로 둬서 지연 시간 대부분을 네트워크 왕복에 써버리는 경우입니다. 둘째, 컨테이너 이미지는 잘 만들었는데 모델 아티팩트를 언제 어디서 받아오는지 기준이 없어 배포마다 시작 시간이 들쑥날쑥해지는 경우입니다. 셋째, 개발팀은 Kubernetes를 원하고 운영팀은 VM을 선호하는데 책임 경계를 정하지 않은 채 진행해서 장애가 나면 누구도 원인 구간을 단정하지 못하는 경우입니다.

    핵심은 기술 스택 이름이 아니라 실패 지점을 어디서 끊어 볼 수 있게 만들었느냐입니다. 모델 로딩 실패를 애플리케이션 로그에서만 보게 만들면 대응이 늦어집니다. 배포 시스템, 스토리지 접근, 네트워크 정책, 컨테이너 이벤트, readiness probe까지 각 단계에서 실패가 드러나야 운영이 됩니다. 제 경험상 마이그레이션이 잘된 팀은 최신 GPU를 쓰는 팀이 아니라, 실패를 빨리 국소화하는 팀이었습니다.

    2. AI 모델 클라우드 마이그레이션 전, 무엇을 옮기고 무엇을 남길까

    클라우드 AI 전환은 전부 이전하거나 전부 유지하는 식으로 가면 대개 비효율적입니다. 워크로드를 성격별로 나눠야 하거든요. 같은 모델이라도 실시간 API와 야간 배치의 최적 해법이 다를 수 있습니다.

    항목 온프레미스 유지가 유리한 경우 클라우드 이전이 유리한 경우 판단 포인트
    실시간 추론 API 내부 시스템 전용이고 입력 데이터가 사내망에서만 생성될 때 트래픽 변동이 크고 외부 서비스, CDN, API Gateway 연동이 많을 때 왕복 지연, 오토스케일링, 외부 노출 경로
    배치 추론 야간 고정 작업이고 유휴 GPU를 계획적으로 재활용할 수 있을 때 특정 기간에만 대량 실행이 몰리고 큐 길이가 자주 출렁일 때 실행 창 유연성, 큐 적체, 자원 점유 시간
    모델 학습 데이터 반출 제한이 강하고 장기 점유형 학습이 많을 때 짧은 기간 대규모 자원을 집중 투입해야 할 때 데이터 거버넌스, 체크포인트 저장 경로, 대역폭
    모델 저장소 폐쇄망 정책이 우선이고 외부 반출 심사가 까다로울 때 멀티 리전 배포, 버전 배포 자동화, 캐시 전략이 중요할 때 버전 전파 속도, 접근 통제, 캐시 일관성
    전처리/후처리 사내 DB, 파일서버, 내부 인증 체계와 강하게 결합돼 있을 때 API 기반으로 분리 가능하고 수평 확장이 자주 필요할 때 CPU 병목, 내부 연동 의존도, 서비스 분리 비용

    이 단계에서 꼭 문서화해두면 좋은 질문은 아래 다섯 가지입니다.

    • 모델 아티팩트는 이미지에 포함할지, 시작 시 다운로드할지, 볼륨으로 마운트할지
    • 입력 데이터는 클라우드에 복제할지, 전용 회선이나 프록시로 접근할지
    • GPU가 꼭 필요한 경로와 CPU로 충분한 경로를 분리했는지
    • 장애 시 온프레미스로 되돌리는 경로가 DNS 전환인지, 로드밸런서 가중치 조정인지, 배포 태그 롤백인지
    • 모델 버전 롤아웃과 인프라 롤아웃을 같은 절차로 묶을지, 분리할지

    실무적으로 정리하면 이렇습니다. 데이터가 사내망을 벗어나기 어렵고 지연 시간 요구가 빡빡하면 온프레미스나 하이브리드가 맞고, 트래픽 변동과 배포 빈도가 더 큰 문제라면 클라우드가 더 잘 맞습니다. 둘 다 애매하면 전체 이전보다 외부 노출 추론 API나 배치 작업부터 부분 이전이 안전합니다.

    3. AI 모델 클라우드 마이그레이션 사전 진단 체크리스트

    이 섹션은 실제 킥오프 회의에서 그대로 읽어도 됩니다. 저는 마이그레이션 전에 아래 항목이 하나라도 비어 있으면 일정을 바로 잡지 않습니다. 나중에 메꾸면 되겠지 싶어도, 보통은 막판에 가장 비싼 문제로 돌아오더라고요.

    1. 모델 목록 정리: 모델 이름, 버전, 프레임워크, 파일 크기, 로딩 경로, 토크나이저/전처리 리소스 위치, 의존 라이브러리 버전을 적습니다.
    2. 트래픽 패턴 확인: 실시간 API, 비동기 큐, 배치 실행을 분리해서 보고, 요청 급증 시점과 재시도 패턴이 있는지 확인합니다.
    3. 데이터 경로 파악: 요청 원천, 전처리 위치, 모델 호출 위치, 결과 저장소, 로그 전송 경로를 한 장의 흐름도로 그립니다.
    4. 보안 요구사항 정리: 네트워크 분리, TLS 종료 지점, Secret 주입 방식, 감사 로그 보존 위치를 정합니다.
    5. 운영 표준 확정: VM인지, 컨테이너인지, Kubernetes인지 결정하고, 배포 책임 팀을 명시합니다.
    6. 관측 지표 선정: p95 지연 시간, 오류율, 모델 로딩 시간, GPU 메모리 사용, Pod 재시작, 큐 적체, 스토리지 대기를 최소 기준으로 둡니다.
    7. 롤백 계획 수립: 어떤 이상 징후가 몇 분간 지속되면 되돌릴지, 누가 승인할지, 어떤 명령으로 되돌릴지 적습니다.
    8. 의존성 분리 검증: 코드 안에 절대 경로, 사내 DNS 이름, 로컬 마운트 경로, 특정 NIC 이름 같은 환경 종속값이 박혀 있지 않은지 확인합니다.

    제가 한 번 크게 헤맸던 사례도 딱 이 단계에서 걸렸어야 했습니다. 모델 파일은 컨테이너 시작 시 객체 저장소에서 내려받도록 바꿨는데, 토크나이저 사전 파일 경로는 예전 온프레미스 NFS 마운트 경로를 그대로 바라보고 있었거든요. 애플리케이션 로그에는 단순히 No such file or directory만 찍혀서 처음엔 이미지 문제로 봤고, 실제 원인은 설정 누락이었습니다. 모델 추론 실패는 모델 자체보다 주변 리소스 경로 누락에서 나는 경우가 생각보다 많습니다.

    사전 진단 단계에서 아래처럼 의존 파일을 한 번 훑어보면 이런 실수를 꽤 줄일 수 있습니다.

    find /opt/app -maxdepth 3 -type f \( -name "*.py" -o -name "*.yaml" -o -name "*.yml" -o -name "*.json" \) \
      -print0 | xargs -0 rg -n "/mnt/|/nfs/|/data/|\.internal|localhost|127\.0\.0\.1"

    이 명령은 코드와 설정 파일 안에 박혀 있는 환경 종속 경로, 내부 도메인, 로컬 참조를 빠르게 찾는 데 유용합니다. “이 정도는 나중에 고치면 되지” 하고 넘기면, 마이그레이션 막판에 가장 까다로운 형태로 돌아옵니다.

    4. 실전 구현 1: 현재 환경 계측과 병목 확인

    AI 모델 클라우드 마이그레이션 전에 먼저 해야 할 일은 현재 온프레미스 환경이 어디서 느린지 평균값이 아니라 병목 패턴으로 읽는 겁니다. 평균 CPU 사용률이 낮다고 안심하면 안 됩니다. 피크 시간에 CPU 전처리가 치솟는지, 디스크 대기가 늘어나는지, GPU 메모리 부족으로 컨테이너가 재시작하는지, 네트워크 인터페이스 하나에 트래픽이 몰리는지까지 봐야 합니다.

    초기에 많이 수집하는 명령은 아래 정도입니다.

    sar -u 1 5
    sar -n DEV 1 5
    iostat -x 1 5
    vmstat 1 5
    ss -ltnp
    df -h
    mount | rg -n "nfs|ceph|fuse|cifs"

    읽는 기준은 이렇게 가져가면 됩니다.

    • iostat -x에서 특정 디바이스의 await가 길고 대기열이 함께 늘면, 모델 파일 로딩이나 캐시 저장 과정에서 스토리지 병목이 있을 가능성이 큽니다. 다만 최신 SSD나 병렬 스토리지에선 %util만으로 포화 여부를 단정하긴 어렵습니다.
    • sar -n DEV에서 NIC 하나만 유난히 바쁘면 데이터 경로가 비대칭일 수 있습니다. 특히 모델 다운로드와 요청 처리 트래픽이 같은 인터페이스를 타면 전환 후 더 티가 납니다.
    • vmstat에서 swap 징후가 보이면, 모델 로딩 시 메모리 압박으로 초기화가 길어지거나 OOM 직전까지 가는 구조일 수 있습니다.
    • ss -ltnp로 실제 어떤 포트에 무엇이 바인딩되어 있는지 확인해두면, 클라우드 전환 후 readiness probe 경로와 포트 매핑 실수를 줄일 수 있습니다.
    • mount 결과에서 NFS나 원격 파일시스템 의존이 있으면, 그 경로를 클라우드에서 어떻게 대체할지 미리 정해야 합니다.

    GPU 워크로드라면 운영체제 지표만 보고 끝내면 아쉽습니다.

    nvidia-smi
    nvidia-smi dmon -s pucmem
    nvidia-smi --query-gpu=name,driver_version,memory.total,memory.used,utilization.gpu,utilization.memory --format=csv

    여기서 특히 봐야 할 건 GPU 사용률 하나가 아니라 GPU 사용률과 지연 시간의 관계입니다. GPU 사용률이 낮은데 응답이 느리면 대개 GPU가 핵심 원인은 아닙니다. 전처리 CPU, 네트워크 왕복, 스토리지 다운로드, 직렬화 구간을 먼저 의심하는 편이 맞습니다. 반대로 GPU 사용률과 메모리 사용률이 함께 치솟고 배포 직후 실패가 늘면, 컨테이너 메모리 제한이나 모델 샤딩 전략을 다시 봐야 합니다.

    현업에서 자주 놓치는 또 하나는 모델 시작 시간입니다. 서비스 지연만 재고 배포 시간을 안 재면, 오토스케일링이 필요한 순간에 새 Pod가 제때 준비되지 않습니다. 저는 아래처럼 컨테이너 이벤트와 readiness를 같이 봅니다.

    kubectl get pods -n ml -w
    kubectl describe pod -n ml <pod-name>
    kubectl logs -n ml <pod-name> --since=10m

    만약 이벤트에 이미지 풀, 볼륨 마운트, readiness probe 실패가 순서대로 찍히면 모델 자체보다 시작 절차가 병목인 경우가 많습니다. 초기화가 긴 서비스라면 readiness와 liveness만 둘 게 아니라 startupProbe까지 함께 검토하는 편이 더 안전합니다. 이거 하나로 불필요한 재시작 루프를 꽤 줄일 수 있거든요.

    AI 모델 클라우드 마이그레이션 전 온프레미스 AI 병목 점검 이미지

    마이그레이션 전 병목을 확인하는 장면을 시각화한 이미지입니다. GPU와 디스크, 네트워크 지표를 함께 보는 느낌이면 좋습니다.

    5. 실전 구현 2: 컨테이너와 설정 분리부터 정리하기

    온프레미스 AI 환경에서 흔히 보이는 상태가 “서버 한 대에 파이썬 가상환경, 모델 파일, 인증서, 임시 스크립트가 다 엉켜 있는 구조”입니다. 이 상태로 클라우드에 가면 재현성이 떨어지고, 장애가 나도 어느 레이어 문제인지 분리하기 어려워집니다. 그래서 저는 이럴 때 가장 먼저 이미지, 설정, 시크릿, 모델 아티팩트를 분리합니다.

    원칙은 단순합니다. 이미지에는 코드와 런타임만 넣고, 환경별 차이는 환경 변수와 Secret으로 분리하고, 모델 아티팩트는 별도 경로에서 가져오게 만듭니다. 모델을 이미지에 굽는 방식은 작은 모델이나 배포 빈도가 낮을 때는 편하지만, 이미지가 비대해지고 버전 교체가 잦아지면 배포 속도를 늦춥니다. 반대로 매번 시작 시 다운로드하게 하면 시작 시간이 길어질 수 있으니, 배포 빈도와 모델 크기를 같이 봐야 합니다.

    배포 방식 언제 유리한가 피해야 할 상황 주요 리스크
    모델을 이미지에 포함 모델 크기가 작고 배포 빈도가 낮을 때 버전 교체가 잦고 이미지 전파 시간이 길 때 이미지 비대화, 롤백 지연
    시작 시 객체 저장소에서 다운로드 버전 교체가 잦고 중앙 관리가 중요할 때 시작 시간이 엄격하거나 네트워크 변동이 큰 환경 Cold start 증가, 다운로드 실패
    공유 볼륨/PVC 마운트 같은 노드나 클러스터에서 여러 Pod가 재사용할 때 스토리지 성능이 불안정하거나 락 경합이 있을 때 I/O 병목, 마운트 실패

    Kubernetes 배포를 예로 들면, 최소한 아래 정도까지는 분리해두는 편이 좋습니다.

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: inference-api
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: inference-api
      template:
        metadata:
          labels:
            app: inference-api
        spec:
          containers:
            - name: api
              image: registry.example.com/inference-api:1.0.0
              imagePullPolicy: IfNotPresent
              ports:
                - containerPort: 8080
              env:
                - name: MODEL_PATH
                  value: /models/current
                - name: LOG_LEVEL
                  value: info
              envFrom:
                - secretRef:
                    name: inference-api-secret
              readinessProbe:
                httpGet:
                  path: /ready
                  port: 8080
                initialDelaySeconds: 10
                periodSeconds: 5
                failureThreshold: 6
              livenessProbe:
                httpGet:
                  path: /healthz
                  port: 8080
                initialDelaySeconds: 30
                periodSeconds: 10
              startupProbe:
                httpGet:
                  path: /healthz
                  port: 8080
                failureThreshold: 30
                periodSeconds: 10
              volumeMounts:
                - name: model-volume
                  mountPath: /models
          volumes:
            - name: model-volume
              persistentVolumeClaim:
                claimName: model-pvc

    이 설정에서 중요한 건 화려한 기능이 아니라 네 가지입니다. 모델 경로를 코드에 하드코딩하지 않는 것, readiness와 liveness를 분리하는 것, 초기화가 길면 startupProbe를 두는 것, Secret을 이미지 밖으로 빼는 것입니다. readiness는 “트래픽을 받을 준비가 됐는지”, liveness는 “프로세스가 비정상 상태인지”를 보는 기준입니다. 초기화가 오래 걸리는 서비스에서 startupProbe 없이 liveness를 너무 일찍 때리면 재시작 루프로 빠질 수 있습니다.

    배포 태그도 latest보다는 고정 버전을 쓰는 편이 낫습니다. 이건 취향 문제가 아니라 롤백 속도 문제에 가깝습니다.

    kubectl apply -f deployment.yaml
    kubectl rollout status deployment/inference-api
    kubectl get pods -l app=inference-api
    kubectl logs deploy/inference-api --tail=100
    kubectl rollout history deployment/inference-api

    검증할 때는 Pod가 뜨는지만 보면 부족합니다. 로그에서 모델 로딩 완료 메시지, 외부 저장소 접근 성공, 포트 바인딩, readiness 통과를 함께 확인해야 합니다. 특히 애플리케이션이 첫 요청 직전에 모델을 lazy load하도록 짜여 있으면, rollout status가 끝나도 실제 서비스 시점에만 장애가 날 수 있습니다. 이 경우엔 사전 검증 요청을 명시적으로 날려 워밍업까지 확인하는 편이 안전합니다.

    컨테이너 이미지, 환경 변수, 스토리지 볼륨, 서비스 경로가 어떻게 분리되는지 보여주는 구성 다이어그램입니다.

    6. 네트워크와 보안: 늦게 보면 일정이 무너집니다

    클라우드 AI 전환에서 일정이 가장 자주 밀리는 구간은 성능 튜닝보다 보안 승인입니다. 온프레미스에서는 그냥 되던 내부 호출이, 클라우드로 오면 Ingress, Egress, DNS, 인증서, 프록시, NAT, 감사 로그 요건을 통과해야 합니다. 이걸 배포 직전에 맞추려 하면 예외 규칙만 늘고, 운영 설명 가능성은 오히려 떨어집니다.

    • Ingress와 내부 서비스 경로를 분리합니다. 외부 공개 API와 내부 관리 API를 같은 진입점에 두지 않는 편이 낫습니다.
    • Secret은 이미지에 넣지 말고 Secret 관리 기능이나 외부 저장소에서 주입합니다.
    • Egress 허용 대상을 도메인, 포트, 목적별로 문서화합니다. 모델 다운로드, 인증, 로그 전송, 메트릭 전송을 분리해서 적어야 합니다.
    • 감사 로그는 누가 모델 버전을 배포했고, 누가 Secret을 변경했고, 누가 네트워크 정책을 열었는지 추적 가능해야 합니다.

    제가 실제로 겪었던 전형적인 사고는 이렇습니다. 개발 환경에서는 잘 되던 모델 API가 운영 클러스터에서만 외부 인증 토큰 발급에 실패했습니다. 앱 로그에는 단순한 timeout처럼 보여서 처음엔 코드 문제로 봤죠. 원인은 운영 네트워크 정책에서 인증 엔드포인트로 나가는 Egress가 막혀 있던 것이었습니다. 이런 문제는 “애플리케이션이 떠 있느냐”보다 의존 대상까지 실제로 연결되느냐를 따로 검증해야 잡힙니다.

    배포 전 연결성 테스트는 아래처럼 나눠서 보는 편이 좋습니다.

    curl -vk https://example.internal.health/ready
    curl -vk https://auth.example.com/
    nslookup auth.example.com
    dig auth.example.com +short
    openssl s_client -connect auth.example.com:443 -servername auth.example.com </dev/null

    이 조합이 좋은 이유는 실패 지점을 분리해주기 때문입니다. nslookup이나 dig에서 이름 해석이 안 되면 DNS 문제고, curl -vk에서 연결 자체가 안 되면 라우팅이나 방화벽 문제일 가능성이 큽니다. openssl s_client 단계에서 깨지면 인증서 체인이나 SNI 관련 문제를 의심할 수 있습니다. 보안팀이나 네트워크팀과 이야기할 때도 “안 돼요”보다 “DNS는 되고 TLS handshake에서 끊깁니다”가 훨씬 빠릅니다.

    추가로, AI 추론 서비스는 외부 모델 저장소나 내부 객체 저장소에서 큰 파일을 가져오는 경우가 많습니다. 그래서 Egress를 한 번 열어놓고 끝낼 게 아니라, 어떤 Pod가 어떤 목적지로 나가야 하는지를 좁혀 적는 편이 좋습니다. 운영팀 입장에서는 이게 정책 관리의 시작점입니다.

    7. AI 모델 클라우드 마이그레이션 전환 당일 체크리스트와 롤백 기준

    마이그레이션 전략은 기술보다 절차가 더 중요할 때가 많습니다. 전환 당일에는 “한 번에 바꾸고 보자”보다 단계적으로 바꾸는 편이 낫습니다. 제가 자주 쓰는 순서는 대체로 아래와 같습니다.

    1. 기존 온프레미스 서비스의 버전, 헬스 상태, 주요 로그 패턴을 기록합니다.
    2. 클라우드 환경에서 동일 입력으로 응답 형식과 로딩 시간을 사전 검증합니다.
    3. 읽기 전용 트래픽이나 일부 가중치만 새 환경으로 보냅니다.
    4. 오류 로그, 지연 시간, 모델 로딩 상태, 외부 연동 성공 여부를 집중 관찰합니다.
    5. 이상 징후가 없으면 트래픽 비율을 점진적으로 올립니다.
    6. 문제가 생기면 DNS, 로드밸런서, 서비스 라우팅 기준으로 즉시 롤백합니다.

    여기서 핵심은 “언제 되돌릴지”를 미리 적어두는 겁니다. 현장에서는 기술적 판단보다 심리적 지연이 더 무섭거든요. 그래서 애매한 표현보다 징후 중심 기준을 쓰는 편이 낫습니다.

    • readiness probe 실패가 연속으로 발생한다
    • 모델 로딩 실패 로그가 반복된다
    • 인증, 저장소 접근, 외부 API 연동 timeout이 지속된다
    • 온프레미스 대비 명확한 성능 저하가 보이는데 원인을 즉시 분리하지 못한다
    • Pod 재시작이나 노드 재스케줄링이 예상보다 잦다

    실무에서는 숫자 하나로 모든 걸 결정하기보다, 정상 로그와 비정상 로그의 차이를 미리 캡처해두는 편이 훨씬 도움이 됩니다. 예를 들어 정상일 때는 모델 로딩 완료, tokenizer 초기화 완료, readiness 성공이 순서대로 나오고, 비정상일 때는 다운로드 재시도나 mount 실패가 먼저 보인다면 전환 당일에 대시보드보다 로그 한 줄이 더 빨리 방향을 줍니다.

    가중치 기반 전환을 쓰는 환경이라면 전체 컷오버보다 부분 전환이 낫습니다. 이유는 단순합니다. AI 추론 서비스는 첫 요청 시 캐시가 비어 있는 경우가 많아서, 일부 트래픽으로 시작해야 cold path를 실제로 밟아볼 수 있기 때문입니다. 저는 이런 상황이면 전환 초반엔 새 환경을 정상 동작 확인용으로 보고, 안정화가 끝난 뒤에야 확장 수단으로 취급합니다.

    AI 모델 클라우드 마이그레이션 후 검증 대시보드 이미지

    전환 직후 검증 단계에서 운영자가 어떤 화면을 중점적으로 보는지 보여주는 대시보드형 이미지입니다.

    8. 검증과 결과 해석: 성공 여부는 이렇게 봅니다

    AI 모델 클라우드 마이그레이션이 끝났다고 판단하려면 서버가 켜져 있다는 사실만으로는 부족합니다. 적어도 아래 네 축이 같이 맞아야 합니다.

    • 기능 검증: 같은 입력에 대해 기대한 형식의 응답이 나오는가
    • 운영 검증: 로그 수집, 알림, 접근 제어, 배포 이력이 정상 동작하는가
    • 성능 검증: 병목이 GPU인지, CPU 전처리인지, 네트워크인지, 스토리지인지 구분 가능한가
    • 복구 검증: 재배포와 롤백이 문서대로 실제 수행되는가

    판단 기준도 조금 더 실무적으로 가져가야 합니다.

    • 응답은 정상이지만 시작 시간이 과하게 길다면 모델 아티팩트 다운로드 경로, 이미지 크기, PVC 마운트 지연을 먼저 봅니다.
    • GPU 사용률이 낮은데 지연이 높다면 CPU 전처리, 직렬화, 네트워크 왕복을 먼저 의심하는 편이 맞습니다.
    • Pod 재시작이 잦다면 메모리 제한, readiness 경로, 파일 마운트 실패, liveness 기준 과민 설정을 차례로 봅니다.
    • 특정 시간대에만 문제가 생기면 배치와 실시간 추론이 같은 노드 풀이나 같은 스토리지를 공유하는지 확인합니다.
    • 첫 요청만 느리고 이후는 괜찮다면 lazy load, 캐시 미스, DNS 캐시, 원격 다운로드를 먼저 의심합니다.

    여기서 중요한 건 수치 그 자체보다 설명 가능성입니다. 운영자가 “왜 느린지”를 두세 단계 안에 좁힐 수 있으면 성공에 가깝고, 장애가 나도 어느 레이어를 먼저 봐야 할지 모르면 아직 미완성입니다. 화려한 대시보드보다 원인 추적 경로가 짧은 구조가 더 값집니다.

    실제로 재현 가능한 시나리오를 하나 들면 이렇습니다. 평소엔 응답이 괜찮다가 배포 직후 몇 분 동안만 지연이 튀는 서비스가 있었습니다. GPU는 여유가 있었고 애플리케이션도 살아 있었습니다. 원인은 새 Pod가 올라올 때마다 모델 파일을 원격 저장소에서 다시 받아오고, 동시에 전처리 사전 파일도 초기화하느라 readiness 통과 직전까지 시간이 밀리던 구조였습니다. 이 경우 GPU 교체는 답이 아니고, 모델 캐시 전략과 readiness 기준을 손보는 게 답입니다.

    9. 자주 묻는 질문과 현업 권고

    온프레미스 AI를 전부 클라우드로 옮겨야 할까요?

    그럴 필요는 없습니다. 데이터 반출 제한이 강하거나 내부 시스템 전용이라면 일부는 남기는 게 맞습니다. 특히 학습 데이터, 민감 로그, 내부 전처리 파이프라인은 남기고, 외부 공개형 추론 API나 탄력성이 필요한 배치만 클라우드로 분리하는 구성이 현실적입니다.

    Kubernetes가 꼭 필요할까요?

    작은 팀이고 모델 수가 적고 변경 빈도가 낮다면 처음부터 복잡도를 올릴 필요는 없습니다. 다만 모델 버전이 자주 바뀌고, 환경이 늘고, 롤백 속도가 중요해지면 컨테이너 오케스트레이션이 운영 피로도를 줄여줍니다. 제 기준은 단순합니다. 서비스가 한두 개이고 담당자가 고정돼 있으면 VM도 가능하지만, 버전 수와 팀 수가 늘기 시작하면 Kubernetes 쪽이 결국 덜 아픕니다.

    비용보다 먼저 볼 것은 뭔가요?

    데이터 경로와 운영 절차입니다. 비용은 나중에 줄일 수 있어도, 데이터 왕복 구조와 롤백 체계가 꼬이면 되돌리기가 어렵습니다. 특히 모델은 클라우드에 있고 데이터는 온프레미스에 남아 있는 상태를 아무 생각 없이 만들면, 지연과 운영 복잡도가 같이 올라갑니다.

    이럴 땐 어떤 선택이 맞을까요?

    명확하게 정리하면 이렇습니다. 내부 데이터 의존이 강하고 지연에 민감하면 하이브리드가 맞고, 배포 속도와 탄력성이 더 중요하면 클라우드 이전이 더 잘 맞습니다. 작은 팀이 첫 전환을 한다면 전부 옮기기보다 배치 또는 외부 노출 API부터 시작하는 편이 안전합니다. 반대로 이미 모델 버전이 자주 바뀌고 롤백이 잦다면, 미루지 말고 컨테이너화와 설정 분리부터 정리한 뒤 클라우드로 가는 게 낫습니다.

    AI 모델 클라우드 마이그레이션 선택 기준 요약 이미지

    상황별 권장 전략을 빠르게 비교할 수 있는 요약 인포그래픽입니다.

    마무리: 이런 경우엔 이렇게 가시면 됩니다

    온프레미스 AI 환경이 이미 안정적이고 데이터가 사내망에 깊게 묶여 있다면, 무리해서 전부 옮기지 않는 편이 좋습니다. 이 경우엔 배치 추론이나 외부 공개 API부터 부분 이전이 잘 맞습니다. 반대로 트래픽 변동이 크고 모델 배포 주기가 빠르며, 운영팀이 버전 롤백과 오토스케일링에 자주 시달린다면 클라우드 쪽이 더 유리합니다.

    한 줄로 줄이면 이렇습니다. AI 모델 클라우드 마이그레이션은 GPU 이전 프로젝트가 아니라 운영 모델 재설계 프로젝트입니다. 데이터 경로가 복잡하면 하이브리드로 시작하고, 출시 속도가 우선이면 컨테이너와 설정 분리부터 끝내고 가는 편이 안전합니다. 무엇부터 할지 애매하다면 제일 먼저 계측과 의존성 탐색부터 해보세요. 이 순서가 생각보다 많이 살려줍니다.

    실무적으로는 이렇게 판단하면 됩니다. 이럴 땐 A: 사내 데이터 의존이 강하고 보안 경계가 복잡하면 하이브리드. 저럴 땐 B: 모델 버전 변경이 잦고 외부 트래픽 변동이 크면 클라우드 이전. 둘 다 애매하면 C: 전환보다 먼저 현재 병목과 숨은 의존성을 계측. 관련 글로 AI 인프라 구축 체크리스트나 모델 배포 전략 가이드를 함께 묶어두면 내부 링크 구조와 SEO 흐름도 더 좋아집니다.

  • [Nas] Btrfs Proxmox NAS 구축 사례: 성능보다 복구와 스냅샷 운영

    [Nas] Btrfs Proxmox NAS 구축 사례: 성능보다 복구와 스냅샷 운영

    Btrfs Proxmox NAS 구축 사례: 성능보다 복구와 스냅샷 운영

    홈랩에서 Btrfs Proxmox NAS 조합을 검토할 때 제일 먼저 갈리는 지점은 하나입니다. “저장소를 VM 안으로 넣어 역할을 분리할까, 아니면 호스트에 바로 붙여 단순하게 갈까.” 저도 둘 다 꽤 오래 굴려봤는데, 테스트와 롤백이 잦은 환경이라면 Proxmox 가상 환경 안에 NAS VM을 두고 그 안에서 Btrfs를 운영하는 방식이 생각보다 꽤 실용적이더라고요. 특히 장애를 몇 번 겪고 나니, 이 구조의 진짜 장점은 스냅샷 자체보다 복구 단위를 세밀하게 나눌 수 있다는 점에 있었습니다.

    물론 이 구성이 항상 정답은 아닙니다. VM 하나에 파일 공유, 미디어 보관, 백업 적재, VM 이미지 저장까지 다 몰아넣으면 Btrfs 장점보다 쓰기 패턴 충돌이 먼저 보이거든요. 반대로 NAS VM을 “공유 데이터와 백업의 운영 계층”으로 한정하고, 데이터베이스나 VM 디스크 이미지 같은 덮어쓰기 중심 워크로드를 분리하면 구조가 한결 안정적이었습니다. 이번 글은 제가 실제로 운영하면서 부딪힌 선택 기준, 실패 패턴, 명령어 수준의 운영 방법까지 한 번에 정리한 사례 기록입니다.

    Proxmox 호스트, Btrfs NAS 게스트, 클라이언트 PC와 백업 대상이 연결된 전체 아키텍처 예시입니다.

    왜 Btrfs Proxmox NAS 조합을 택했나

    제가 이 조합을 유지한 이유는 “최고 성능” 때문이 아니라 복구 흐름이 예측 가능했기 때문입니다. 홈랩에서는 속도보다도, 뭔가 잘못됐을 때 어디까지 되돌릴 수 있는지가 더 중요할 때가 많더라고요. Proxmox 스냅샷은 VM 단위 복구에 빠르고, Btrfs 스냅샷은 공유 폴더나 백업 디렉터리처럼 데이터 단위 복구에 유리합니다. 둘이 비슷해 보여도 실제 쓰임새는 꽤 다릅니다.

    • 역할 분리: 하이퍼바이저와 파일 서비스를 논리적으로 떼어내기 쉽습니다.
    • 복구 단위 분리: VM 전체 롤백과 특정 공유 복원을 따로 판단할 수 있습니다.
    • 서브볼륨 정책화: data, backup, media를 분리하면 보존 기간과 스냅샷 빈도를 다르게 가져가기 좋습니다.
    • 운영 실험성: 공유 구조를 바꿔도 호스트 스토리지 레이아웃까지 함께 건드릴 일이 적습니다.

    반대로 이 조합이 안 맞는 경우도 분명합니다. 대용량 순차 쓰기만 몰리는 저장소, 랜덤 덮어쓰기가 많은 DB 볼륨, VM 디스크 이미지를 NAS VM 내부 Btrfs에 다시 저장하는 중첩 구조는 추천하지 않습니다. 이런 환경에서는 유연성보다 CoW 부작용, 캐시 계층 중첩, 장애 분석 복잡도가 먼저 문제를 만듭니다.

    구성 방식 추천 상황 강점 피해야 할 상황
    Btrfs in VM 홈랩 NAS, 스냅샷 중심 복구, 역할 분리 서브볼륨 운영과 복구 지점 관리가 편함 VM 이미지와 DB 파일까지 한곳에 몰아넣는 경우
    ext4 in VM 설정 단순함, 익숙한 운영 우선 트러블슈팅 경로가 단순함 공유 단위 시점 복구가 자주 필요한 경우
    호스트 직접 NAS 가상화보다 저장소 일체형 운영 계층이 적어 성능 해석이 쉬움 서비스 역할을 자주 갈아끼우는 홈랩

    Btrfs Proxmox NAS에서 좋은 점과 아쉬운 점

    초보자 글에서는 Btrfs를 “스냅샷 되는 ext4 비슷한 파일시스템”처럼 설명하는 경우가 많은데, 실제 운영 감각으로 보면 그렇게 접근하면 거의 항상 꼬입니다. Btrfs는 파일시스템이면서 동시에 데이터 세트와 변경 이력을 같이 관리하는 계층에 가깝습니다. 그래서 공유 디렉터리를 정책 단위로 쪼개서 관리할 때는 정말 편한데, 덮어쓰기가 많은 단일 대용량 파일 위주 워크로드에는 장점이 약해집니다.

    서브볼륨과 스냅샷은 폴더 분리가 아니라 운영 단위 분리입니다

    data, backup, media를 나누는 이유는 보기 좋으라고가 아닙니다. 스냅샷 보존 기간, 복구 우선순위, 삭제 정책, 압축 적용 범위를 다르게 하기 위해서입니다. 예를 들어 backup은 매일 스냅샷을 남겨도 괜찮지만, 미디어 보관소인 media는 굳이 자주 남길 필요가 없을 때가 많습니다. 이 구분이 없으면 스냅샷 수만 늘고 복구 기준은 흐려집니다.

    Copy-on-Write는 만능이 아니라, 쓰기 패턴에 따라 약점이 분명합니다

    Btrfs의 CoW는 파일 변경 이력을 보존하고 스냅샷을 가볍게 만드는 핵심입니다. 다만 랜덤 덮어쓰기가 많은 파일에는 불리할 수 있습니다. 저는 아래처럼 나눠서 봅니다.

    • 문서, 사진, 설정 백업, 프로젝트 아카이브: Btrfs와 잘 맞습니다.
    • SQLite, VM 이미지, active DB dump 재작성 파일: 별도 볼륨으로 분리하거나 No_COW 적용을 미리 검토하는 편이 낫습니다.
    • 다운로드 중인 토런트 작업 디렉터리: 조각화와 메타데이터 증가를 빨리 유발해 분리하는 쪽이 보통 낫습니다.

    중요한 건 chattr +C를 만능 해법처럼 쓰지 않는 겁니다. 이 속성은 새 디렉터리나 빈 파일에 미리 적용해야 의미가 있고, 이미 기록된 데이터에는 결과를 장담하기 어렵습니다. 그래서 “문제 생기면 나중에 +C 붙이자” 식 접근은 대개 늦습니다.

    제가 실제로 잡은 홈랩 NAS 구조

    제가 선호한 구조는 단순합니다. Proxmox 호스트는 하이퍼바이저 역할만 맡고, NAS는 별도 리눅스 VM에서 처리합니다. 그리고 디스크는 가능하면 “큰 qcow2 파일 하나”보다 게스트가 블록 장치를 좀 더 직접적으로 인식하는 방식으로 붙입니다. 이유는 세 가지였습니다. 첫째, 패스스루나 HBA 경유라면 SMART와 I/O 에러 해석이 쉬워지는 경우가 많고, 둘째, 캐시 계층이 덜 꼬이며, 셋째, 장애가 났을 때 원인 추적 경로가 짧아집니다.

    1. Proxmox 호스트에 NAS 전용 VM 생성
    2. 디스크를 VirtIO SCSI 또는 개별 디스크 패스스루로 연결
    3. 게스트 리눅스에서 Btrfs 파일시스템 생성
    4. 서브볼륨 분리 후 UUID 기반 /etc/fstab 등록
    5. Samba 또는 NFS 설정
    6. scrub, balance, snapshot, 로그 점검 작업 예약

    여기서 많이 하는 실수가 하나 있습니다. Proxmox 스냅샷과 Btrfs 스냅샷을 같은 목적으로 쓰는 것입니다. 둘 다 “되돌리기”라서 비슷해 보여도 운영 목적이 다릅니다. 그리고 VM 스냅샷의 정합성이 중요하다면 QEMU guest agent와 파일시스템 freeze 여부도 같이 확인하는 편이 안전합니다.

    기능 좋은 용도 피해야 할 용도 제가 쓰는 기준
    Proxmox 스냅샷 패키지 업데이트 전, VM 설정 변경 전 장기 보관용 데이터 복구 짧게 잡고 빨리 정리
    Btrfs 스냅샷 공유 데이터 시점 복구, 삭제 사고 대응 오프사이트 백업 대체 서브볼륨별 보존 정책 적용
    Btrfs 서브볼륨 구조를 보여주는 Proxmox NAS 구성도

    게스트 내부에서 /srv/nas 아래 data, backup, media 서브볼륨을 나눈 예시 구조입니다.

    Btrfs Proxmox NAS 실전 구현 1: 파일시스템 생성과 마운트

    예시는 Debian 또는 Ubuntu 계열 게스트 기준입니다. 디스크가 /dev/sdb로 보인다고 가정하지만, 실제 작업 전에는 반드시 장치명을 다시 확인해야 합니다. 이 단계는 감으로 하면 안 됩니다. 홈랩에서 제일 복구하기 어려운 사고가 “잘못된 디스크 포맷”이거든요.

    apt update
    apt install -y btrfs-progs samba sysstat smartmontools
    
    lsblk -e7 -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,SERIAL
    blkid
    wipefs -n /dev/sdb
    
    mkfs.btrfs -L nasdata /dev/sdb
    
    mkdir -p /mnt/btrfs-root
    mount /dev/sdb /mnt/btrfs-root
    
    btrfs subvolume create /mnt/btrfs-root/@data
    btrfs subvolume create /mnt/btrfs-root/@backup
    btrfs subvolume create /mnt/btrfs-root/@media
    btrfs subvolume create /mnt/btrfs-root/@snapshots
    
    umount /mnt/btrfs-root
    
    mkdir -p /srv/nas/data /srv/nas/backup /srv/nas/media /srv/nas/.snapshots
    
    UUID=$(blkid -s UUID -o value /dev/sdb)
    
    mount -o noatime,compress=zstd:3,subvol=@data UUID=$UUID /srv/nas/data
    mount -o noatime,compress=zstd:3,subvol=@backup UUID=$UUID /srv/nas/backup
    mount -o noatime,compress=zstd:3,subvol=@media UUID=$UUID /srv/nas/media
    mount -o noatime,compress=zstd:3,subvol=@snapshots UUID=$UUID /srv/nas/.snapshots
    
    btrfs filesystem show
    btrfs subvolume list -t /srv/nas/data

    여기서 옵션 선택 이유를 짚어보면 이렇습니다.

    • compress=zstd:3: 텍스트, 문서, 설정 파일 비중이 있는 NAS에서 무난했습니다. 이미 압축된 미디어 위주라면 효과는 제한적입니다.
    • noatime: 접근 시간 갱신 쓰기를 줄여 자잘한 I/O를 줄입니다.
    • 최근 커널과 btrfs-progs에서는 free-space-tree가 기본인 경우가 많아서 space_cache=v2를 굳이 적지 않아도 되는 환경이 많습니다.

    제가 운영하면서 얻은 팁 하나는, 처음부터 스냅샷 저장 위치도 @snapshots처럼 별도 서브볼륨으로 분리해두는 겁니다. 스냅샷을 원본 서브볼륨 바로 아래에만 늘어놓으면 나중에 삭제 자동화할 때 경로 실수가 나기 쉽더라고요. 이거 진짜 한 번 꼬이면 꽤 귀찮습니다.

    다음은 /etc/fstab 예시입니다.

    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/data       btrfs  noatime,compress=zstd:3,subvol=@data       0 0
    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/backup     btrfs  noatime,compress=zstd:3,subvol=@backup     0 0
    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/media      btrfs  noatime,compress=zstd:3,subvol=@media      0 0
    UUID=11111111-2222-3333-4444-555555555555  /srv/nas/.snapshots btrfs  noatime,compress=zstd:3,subvol=@snapshots  0 0

    실제 적용 전에는 숫자를 그대로 붙여넣지 말고 반드시 blkid로 확인하세요. 그리고 수정 후에는 아래처럼 검증하는 편이 안전합니다.

    mount -a
    findmnt -t btrfs
    btrfs filesystem usage -T /srv/nas/data

    Btrfs Proxmox NAS 실전 구현 2: Samba 공유와 스냅샷 운영

    SMB 공유는 기능보다도 권한 해석이 일관적인지가 중요합니다. 홈랩에서는 성능보다 권한 꼬임 때문에 시간을 더 많이 쓰게 되더라고요. 아래 예시는 최소 구성인데, 저는 공유 목적별로 권한을 일부러 다르게 둡니다. 백업 공유는 쓰기 주체를 줄이고, 일반 데이터 공유는 팀이나 가족 계정에 맞춰 그룹 권한을 조금 더 넓게 둡니다.

    [global]
       workgroup = WORKGROUP
       server string = Btrfs NAS VM
       security = user
       map to guest = Bad User
       load printers = no
       printing = bsd
       disable spoolss = yes
       ea support = yes
       vfs objects = acl_xattr
       map acl inherit = yes
       store dos attributes = yes
    
    [data]
       path = /srv/nas/data
       browsable = yes
       read only = no
       valid users = nasuser
       force group = nas
       create mask = 0664
       directory mask = 0775
    
    [backup]
       path = /srv/nas/backup
       browsable = yes
       read only = no
       valid users = nasbackup
       force group = nasbackup
       create mask = 0660
       directory mask = 0770

    설정 후에는 서비스 재시작만 하지 말고, 문법과 실제 접근을 둘 다 확인해야 합니다. 이런 확인 절차를 한 번만 습관 들여도 시간을 꽤 아낄 수 있습니다.

    testparm -s
    systemctl restart smbd
    systemctl enable smbd
    smbclient -L localhost -U nasuser
    smbclient //localhost/data -U nasuser -c 'ls'

    스냅샷은 저는 공유 전체를 한 덩어리로 남기지 않고, 서브볼륨 단위로 따로 관리합니다. 그래야 복구할 때 선택이 단순합니다.

    mkdir -p /srv/nas/.snapshots
    
    btrfs subvolume snapshot -r /srv/nas/data /srv/nas/.snapshots/data-$(date +%F)
    btrfs subvolume snapshot -r /srv/nas/backup /srv/nas/.snapshots/backup-$(date +%F)
    
    btrfs subvolume list /srv/nas/.snapshots
    
    # 읽기 전용 스냅샷에서 특정 시점 복구
    btrfs subvolume snapshot /srv/nas/.snapshots/data-2026-08-24 /srv/nas/data-restore
    rsync -aHAX --info=progress2 /srv/nas/data-restore/ /srv/nas/data/

    복구할 때 바로 원본 위에 덮지 않고 data-restore로 한 번 펼쳐 확인하는 이유는, 실제 사고 상황에서는 “삭제 복구”보다 “원치 않는 오래된 상태로 되돌리는 실수”가 더 무섭기 때문입니다. 특히 여러 사용자가 동시에 접근하는 공유라면 더 그렇습니다.

    Btrfs 스냅샷과 Samba 설정을 점검하는 홈랩 NAS 운영 화면

    testparm, btrfs subvolume snapshot, smbclient로 구성 검증하는 흐름을 보여주는 이미지 위치입니다.

    ⚠️ 제가 실제로 겪은 문제와 해결 과정

    운영하면서 가장 헷갈렸던 건 용량 자체보다 공간이 어떻게 배치되어 있는지였습니다. Btrfs는 “남은 GB”만 봐서는 상태 판단이 잘 안 됩니다. 특히 작은 파일이 많고 스냅샷이 누적될수록, 데이터보다 메타데이터 청크 상태가 먼저 문제를 일으키는 경우가 있습니다.

    실패 모드 1: 데이터는 남아 보이는데 쓰기가 실패하는 경우

    제가 재현했던 상황은 이렇습니다. 작은 파일이 많은 프로젝트 백업 디렉터리를 여러 번 복사하고, 그 사이에 스냅샷을 반복 생성했습니다. 그다음 새 파일을 쓰려니 No space left on device가 나왔습니다. df로 보면 공간이 남아 있었는데도요. 근본 원인은 메타데이터 청크 사용률과 데이터 청크 분포가 비대칭적으로 꼬였기 때문이었습니다.

    btrfs filesystem usage /srv/nas/data
    btrfs filesystem df /srv/nas/data
    btrfs balance start -dusage=75 -musage=75 /srv/nas/data

    여기서 중요한 건 full balance를 습관적으로 돌리지 않는 겁니다. 운영 중 balance는 생각보다 오래 걸릴 수 있고, I/O 부하도 꽤 큽니다. 저는 보통 -dusage, -musage로 필요한 범위만 정리합니다. “왜 공간이 부족해졌나”를 보지 않고 무조건 balance부터 돌리면, 증상만 잠깐 눌러놓고 패턴은 그대로 남는 경우가 있더라고요.

    실패 모드 2: 성능이 들쑥날쑥한데 원인이 안 보이는 경우

    이건 Proxmox와 게스트 캐시가 겹칠 때 자주 헷갈립니다. 파일 복사 첫 번째는 빠르고 두 번째는 더 빠른데, 다른 디렉터리로 바꾸면 다시 느려지는 패턴이 대표적입니다. 이런 경우 단순 벤치마크 숫자보다 캐시가 아닌 워크로드를 섞어서 보는 것이 중요했습니다. 저는 같은 파일 하나만 반복 복사하는 테스트는 거의 신뢰하지 않습니다.

    • 큰 파일 1개 복사
    • 작은 파일 수천 개 복사
    • 스냅샷 생성 직후 복사
    • SMB 경유 복사와 게스트 내부 로컬 복사 비교

    이 네 가지를 섞어보면 병목 위치가 꽤 잘 드러납니다. SMB만 느리면 네트워크 또는 Samba 설정, 내부 로컬도 느리면 파일시스템 또는 디스크 경로를 의심하는 식입니다.

    실패 모드 3: 스냅샷은 많지 않은데 삭제 후에도 공간이 안 돌아오는 경우

    이건 Btrfs를 처음 쓸 때 가장 당황하기 쉬운 패턴입니다. 스냅샷, 공유 파일, 중복 블록 참조가 얽혀 있으면 “삭제했는데 왜 안 줄지?”가 자연스럽게 나옵니다. 이때는 du보다 btrfs filesystem usage와 qgroup 사용 여부, 스냅샷 참조 상태를 봐야 합니다. 단순히 휴지통을 비웠다고 공간이 즉시 선형적으로 줄어드는 구조는 아니거든요.

    scrub와 device stats는 루틴으로 보는 편이 낫습니다

    btrfs scrub start -Bd /srv/nas/data
    btrfs scrub status /srv/nas/data
    btrfs device stats /srv/nas/data
    smartctl -a /dev/sdb
    • scrub status에서 uncorrectable errors가 보이면 파일시스템만 볼 문제가 아닙니다. 실제 디스크 상태, 케이블, HBA, USB-SATA 브리지까지 함께 봐야 합니다.
    • btrfs device stats의 write_io_errs, read_io_errs, flush_io_errs, corruption_errs는 누적값입니다. 한 번의 숫자보다 증가 추세가 중요합니다.
    • smartctl은 패스스루나 장치 노출 방식에 따라 게스트에서 바로 안 보일 수 있습니다. 이 경우 호스트 측 SMART 정보와 함께 보는 편이 정확합니다.

    성능 해석은 벤치마크 숫자보다 병목 위치를 읽는 쪽이 낫습니다

    iostat -x 1
    vmstat 1
    journalctl -u smbd -f
    dmesg -Tw | egrep -i 'btrfs|blk|I/O|error|reset'
    • iostat -x 1에서 특정 디스크의 await가 계속 높고 %util이 바닥이 아니라면 저장장치 또는 연결 계층 병목을 먼저 봅니다.
    • vmstat 1에서 wa가 계속 높다면 CPU가 느린 게 아니라 I/O 대기일 가능성이 큽니다.
    • journalctl -u smbd -f에서 세션 재연결이 반복되면 파일시스템보다 네트워크, 인증, 잠금 설정 문제일 수 있습니다.
    • dmesg에 reset, timeout, I/O error가 섞이면 Btrfs 자체보다 하부 장치 문제를 먼저 해결해야 합니다.

    디스크 노출 방식은 성능보다 장애 해석성에서 갈립니다

    제 경험상 많은 글이 “패스스루가 빠르다” 수준에서 끝나는데, 실무적으로 더 중요한 건 문제가 났을 때 무엇이 잘못됐는지 빨리 읽히느냐입니다. 성능이 약간 아쉬운 구조보다, 에러 원인이 명확한 구조가 운영 비용을 줄입니다.

    디스크 연결 방식 장점 단점 제가 추천하는 상황
    VirtIO SCSI 가상 디스크 구성 단순, 백업/이동이 편함 하부 디스크 상태 해석과 SMART 확인이 제한될 수 있음 테스트랩, 소규모 공유
    개별 디스크 패스스루 장치 식별과 장애 분석이 명확함 설정이 다소 번거로움 장기 운영 NAS VM
    USB 외장 디스크 경유 추가 비용이 적음 브리지, 절전, 리셋 이슈가 많음 임시 백업 타깃 정도

    개인적으로 NAS를 오래 굴릴 생각이라면, 처음부터 “나중에 로그를 보고 원인을 읽기 쉬운가”를 기준으로 연결 방식을 고르시는 편이 좋습니다. 홈랩은 장애를 통해 배우는 환경인데, 원인 추적이 안 되면 배울 것도 줄어들더라고요.

    운영 자동화: 귀찮은 작업만 자동화해도 체감이 큽니다

    처음엔 수동으로 해도 되지만, scrub와 스냅샷은 결국 빼먹게 됩니다. 저는 최소한의 cron만 넣고 시작했습니다. systemd timer로 옮겨도 되지만, 홈랩에서는 유지 가능한 단순함도 꽤 중요하거든요.

    crontab -e
    
    # 매주 일요일 새벽 scrub
    0 3 * * 0 /usr/bin/btrfs scrub start -Bd /srv/nas/data
    
    # 매일 새벽 읽기 전용 스냅샷 생성
    10 2 * * * /usr/bin/btrfs subvolume snapshot -r /srv/nas/data /srv/nas/.snapshots/data-$(date +\%F)
    
    # 14일 지난 data 스냅샷 정리
    30 2 * * * /usr/bin/find /srv/nas/.snapshots -mindepth 1 -maxdepth 1 -type d -name 'data-*' -mtime +14 -exec btrfs subvolume delete {} \;

    여기서 제가 특히 강조하는 건 find 범위를 좁히는 겁니다. -mindepth 1, -maxdepth 1, 명확한 이름 패턴은 거의 필수에 가깝습니다. 경로가 느슨하면 삭제 자동화는 언젠가 사고를 냅니다.

    조금 더 안전하게 가고 싶다면 로그를 남기세요. 이건 나중에 정말 차이가 납니다.

    #!/usr/bin/env bash
    set -euo pipefail
    
    TARGET=/srv/nas/data
    SNAPROOT=/srv/nas/.snapshots
    STAMP=$(date +%F)
    
    btrfs subvolume snapshot -r "$TARGET" "$SNAPROOT/data-$STAMP"
    find "$SNAPROOT" -mindepth 1 -maxdepth 1 -type d -name 'data-*' -mtime +14 -print -exec btrfs subvolume delete {} \;

    이 정도만 해도 “언제 무엇이 삭제됐는지”를 추적하기가 훨씬 수월합니다. 홈랩이라고 해서 로그를 안 남기면, 나중에 본인이 제일 답답해집니다.

    Proxmox Btrfs NAS 운영 점검 대시보드 이미지

    Btrfs 상태 점검과 I/O 관찰을 한 화면에서 보는 운영용 대시보드 예시입니다.

    언제 이 구성을 쓰고, 언제 피해야 하나

    제가 실제로 운영한 기준으로 말씀드리면 선택은 꽤 명확합니다.

    • 쓰는 게 맞는 경우: 파일 공유, 사진 보관, 설정 백업, 프로젝트 아카이브, 컨테이너 볼륨 백업처럼 시점 복구 가치가 큰 데이터
    • 보류하는 게 맞는 경우: 고빈도 덮어쓰기 DB 파일, VM 디스크 이미지 저장소, 대용량 연속 쓰기만 몰리는 수집 저장소
    • 호스트 직결이 나은 경우: 단일 서비스, 최소 계층, 빠른 장애 복구보다 구조 단순화가 더 중요한 환경
    • ext4가 나은 경우: 파일시스템 기능보다 관리 익숙함과 예측 가능성이 우선인 경우

    짧게 말하면 이렇습니다. 데이터를 시점 단위로 되돌릴 일이 잦으면 Btrfs NAS VM이 잘 맞고, 파일을 그냥 안정적으로 쌓아두기만 하면 ext4나 호스트 직결이 더 낫습니다.

    검증 결과와 운영하면서 느낀 점

    제가 이 구조를 계속 쓰게 된 결정적인 이유는 두 번의 복구 경험 때문이었습니다. 한 번은 사용자가 디렉터리를 통째로 날렸을 때였고, 다른 한 번은 백업 작업이 덮어쓰기로 꼬였을 때였습니다. 두 경우 모두 VM 전체를 되돌릴 필요 없이 해당 서브볼륨의 스냅샷만 복원해서 문제를 끊을 수 있었습니다. 이 차이가 꽤 큽니다. VM 스냅샷만 있었다면 애플리케이션 상태까지 같이 과거로 돌아가야 했을 가능성이 높습니다.

    반면 불편한 지점도 분명했습니다. Btrfs는 “공간이 남았는지”보다 “공간이 어떻게 배치됐는지”를 봐야 하고, Proxmox 위에 올리면 디스크 계층을 하나 더 이해해야 합니다. 그래서 이 구성이 좋은 이유는 쉽기 때문이 아니라, 운영 목적이 분명할 때 얻는 이익이 번거로움을 넘어설 때가 많기 때문입니다.

    이런 상태면 안정적으로 굴러가는 중입니다

    • btrfs scrub status에 치명적 오류가 없습니다.
    • btrfs device stats 카운터가 시간 경과에 따라 늘지 않습니다.
    • 스냅샷 생성과 삭제 시간이 갑자기 길어지지 않습니다.
    • SMB 접근 시 재연결, 멈춤, 잠금 충돌이 반복되지 않습니다.
    • btrfs filesystem usage에서 메타데이터 사용률이 비정상적으로 치솟지 않습니다.

    자주 묻는 질문과 선택 가이드

    Q1. Proxmox Btrfs를 홈랩 NAS에 바로 추천하냐고요?

    조건부로 추천드립니다. 역할 분리와 스냅샷 기반 복구가 목적이면 만족도가 꽤 높습니다. 대신 “그냥 간단한 공유 폴더 하나”가 목표라면 ext4 기반 NAS가 더 편합니다. 기능이 많다고 항상 좋은 선택은 아니더라고요.

    Q2. Proxmox 스냅샷만 쓰면 안 되나요?

    가능은 하지만, 데이터 복구 단위가 너무 큽니다. VM 전체를 되돌리는 건 빠르지만, 특정 공유 폴더 한 시점만 복원하기에는 거칠게 느껴질 때가 많습니다. 저는 시스템 변경 보호는 Proxmox 스냅샷, 데이터 사고 복구는 Btrfs 스냅샷으로 나눠 쓰는 쪽이 훨씬 깔끔했습니다.

    Q3. 홈랩 NAS에서 꼭 기억할 한 줄은?

    스냅샷은 백업이 아닙니다. 같은 파일시스템 안에 있으면 하부 디스크 문제가 생길 때 같이 잃을 수 있습니다. 운영 복구와 재해 복구는 꼭 분리해서 보셔야 합니다.

    마무리: 제 추천은 꽤 분명합니다

    Btrfs Proxmox NAS 조합은 “가상화 환경 안에서도 데이터 복구 단위를 세밀하게 관리하고 싶다”는 분에게 잘 맞습니다. 제가 직접 굴려본 기준으로는, 홈랩에서 자주 생기는 삭제 사고, 잘못된 덮어쓰기, 테스트 후 롤백 같은 상황에 특히 강했습니다. 대신 이 구조를 고르셨다면 메타데이터 usage 확인, 정기 scrub, device stats 추적은 선택이 아니라 운영 기본값으로 가져가야 합니다.

    • 스냅샷 복구가 우선이다: Btrfs 기반 NAS VM이 맞습니다.
    • 구성이 단순해야 한다: ext4 기반 단순 NAS부터 시작하는 편이 낫습니다.
    • 디스크 장애 분석까지 직접 보고 싶다: 가상 디스크보다 패스스루 또는 장치 식별이 쉬운 구조가 유리합니다.
    • DB, VM 이미지, 랜덤 덮어쓰기가 많다: 같은 Btrfs NAS 안에 넣지 말고 별도 볼륨이나 다른 저장소로 분리하세요.

    제 판단을 한 문장으로 압축하면 이렇습니다. 복구 관점의 NAS를 만들 거라면 이 조합은 충분히 설득력이 있고, 단순 저장소가 목표라면 굳이 복잡도를 들일 이유는 많지 않습니다. 다음 단계로 넘어가신다면, 이 구조 위에 Restic 같은 외부 백업 경로를 붙여서 스냅샷과 재해 복구를 분리하는 쪽까지 함께 가져가시는 걸 권합니다. 관련 내부 글로는 Proxmox 백업 정책, Samba 권한 설계, Restic 오프사이트 백업 가이드를 이어서 묶어두면 SEO와 체류시간 측면에서도 도움이 됩니다.

    Btrfs Proxmox NAS 선택 기준과 운영 체크포인트 요약 이미지

    어떤 환경에서 Btrfs NAS 가상화가 맞는지 빠르게 판단할 수 있는 요약 이미지입니다.

  • [Cloud] Jenkins 장애 해결: CI/CD 파이프라인 디버깅 방법론

    [Cloud] Jenkins 장애 해결: CI/CD 파이프라인 디버깅 방법론

    Jenkins 장애 해결: CI/CD 파이프라인 장애 발생 시 디버깅 방법론

    Jenkins 장애 해결이 급한 순간은 늘 비슷하더라고요. 배포 직전인데 파이프라인이 멈추고, 로그는 길고, 팀 채팅방은 조용히 뜨거워집니다. 지속적 통합 환경에서는 작은 설정 하나가 전체 흐름을 막는 경우가 많거든요. 그래서 이번 글에서는 Jenkins CI/CD 파이프라인 장애가 났을 때 어디부터 보고, 무엇으로 판단할지 실무 순서대로 정리해보겠습니다.

    처음엔 저도 젠킨스 에러가 뜨면 Jenkins 자체 문제부터 의심했는데요. 실제로는 소스 저장소 인증, 에이전트 연결, 워크스페이스, 셸 환경 변수처럼 경계 지점에서 막히는 일이 훨씬 많았습니다. 중요한 포인트는 하나입니다. 증상만 보지 말고 실행 경로를 층별로 나눠서 확인해야 합니다.

    Jenkins 장애 해결을 위한 CI/CD 전체 아키텍처 다이어그램

    Jenkins 컨트롤러, 에이전트, Git 저장소, 빌드 도구, 배포 대상 시스템 사이의 장애 지점을 한눈에 보여주는 개요 이미지입니다.

    Jenkins 장애 해결은 왜 순서가 중요할까

    쉽게 말해 Jenkins는 혼자 일하지 않습니다. 컨트롤러가 잡(Job)을 받고, 에이전트가 실제 명령을 수행하고, Git 같은 외부 시스템에서 코드를 가져오고, Docker나 Maven, Gradle 같은 도구를 호출합니다. 여기서 하나라도 어긋나면 파이프라인 문제 해결이 꼬이기 시작하죠.

    실무에서 자주 보는 장애 구간은 대략 이렇습니다.

    • 시작도 못 하는 장애: 큐에만 쌓이고 실행되지 않음
    • 초반 실패: SCM checkout 실패, credential 문제, webhook 미동작
    • 중간 실패: 테스트, 빌드, 이미지 생성, 스크립트 문법 에러
    • 후반 실패: 아티팩트 업로드, 배포 권한, 대상 서버 연결 불가
    • 간헐 장애: 같은 커밋인데 어떤 때는 되고 어떤 때는 안 됨

    결국 Jenkins 장애 해결은 Jenkins 자체보다 연결된 구성 요소의 경계면을 보는 작업에 가깝습니다. 저도 예전엔 콘솔 출력만 붙잡고 오래 헤맨 적이 있었는데, 구조를 나눠서 보니까 훨씬 빨리 풀리더라고요.

    CI/CD 디버깅 기본 원칙: 한 번에 하나씩 잘라 보기

    팀에서 자주 맞추는 기준이 있습니다. 재현 가능한 최소 실패 지점(minimal failing step)을 먼저 만들자는 거예요. 로그가 2천 줄이어도 결국 실패는 한 단계에서 시작되거든요.

    1. 최근 변경이 어디인지 확인합니다. Jenkinsfile, credential, agent image, plugin, target server 중 무엇이 바뀌었는지 먼저 봅니다.
    2. 실패 지점을 단계 단위로 자릅니다. checkout, build, test, publish, deploy 순서로 어디서 처음 깨지는지 확인합니다.
    3. 컨트롤러와 에이전트를 분리해서 봅니다. UI에서 보이는 에러와 실제 실행 노드의 시스템 로그는 다를 수 있습니다.
    4. 같은 명령을 에이전트 셸에서 직접 실행합니다. Jenkins만 실패하는지, OS 레벨에서도 실패하는지 비교합니다.
    5. 마지막 성공 이력과 비교합니다. 같은 브랜치의 이전 성공 빌드가 가장 좋은 기준선입니다.

    여기서 중요한 포인트는 왜 안 되지?보다 어디까지는 됐지?라고 묻는 습관입니다. 이거 진짜 편하더라고요.

    Jenkins 장애 해결 1차 점검: 서비스 상태와 기본 로그

    Jenkins 장애 해결에서 제일 먼저 할 일은 화려한 분석이 아닙니다. 서비스가 살아 있는지, 최근 로그에 뻔한 실패가 있는지부터 확인해야 합니다. 의외로 Java 프로세스 메모리 문제, 디스크 공간 부족, 권한 문제 같은 기본기가 원인인 경우가 많거든요.

    1. systemd 서비스 상태 확인

    sudo systemctl status jenkins
    sudo journalctl -u jenkins -n 200 --no-pager
    sudo journalctl -u jenkins -f

    Linux 패키지 설치 환경에서는 Jenkins 공식 문서 기준으로 journalctl -u jenkins가 기본 로그 확인 방법입니다. 여기서 볼 건 단순합니다. 프로세스가 반복 재시작하는지, 플러그인 로딩 실패가 있는지, 포트 바인딩 실패, Permission denied 같은 메시지가 있는지 먼저 보세요. 마지막 한 줄만 보지 말고, 실패 직전 수십 줄을 같이 보는 게 훨씬 정확합니다.

    2. Jenkins 홈 디렉터리와 디스크 확인

    sudo du -sh /var/lib/jenkins
    sudo df -h
    sudo ls -ld /var/lib/jenkins

    일반적인 Linux 패키지 설치에서는 /var/lib/jenkins가 자주 쓰이는 Jenkins 홈 디렉터리입니다. 다만 로그 파일 경로는 설치 방식에 따라 다를 수 있어서, Linux에서는 먼저 journalctl을 우선으로 보는 편이 안전합니다. 디스크가 꽉 차면 빌드 중단, 워크스페이스 정리 실패, 플러그인 캐시 이상처럼 애매한 증상으로 보일 때가 많습니다.

    3. 웹 응답과 큐 상태 확인

    curl -I http://127.0.0.1:8080/login
    curl -s http://127.0.0.1:8080/queue/api/json
    curl -s http://127.0.0.1:8080/computer/api/json

    /queue/api/json은 작업이 왜 대기 중인지 볼 때 유용합니다. 대기 사유를 설명하는 값이 내려오는 경우가 있어서, 적절한 라벨의 에이전트가 없거나 모든 실행기(executor)가 점유된 상황을 빨리 찾을 수 있죠. /computer/api/json도 에이전트 상태를 한 번에 점검할 때 꽤 편합니다.

    Jenkins 장애 해결을 위한 서비스 상태 및 로그 점검 이미지

    systemctl, journalctl, Jenkins 로그를 보며 서비스 상태와 에러 메시지를 추적하는 터미널 중심의 점검 장면입니다.

    파이프라인 문제 해결의 핵심: 콘솔 로그를 단계별로 읽는 법

    콘솔 로그는 다들 보지만, 읽는 순서가 제각각인 경우가 많습니다. 저는 아래 순서대로 봅니다.

    1. 첫 실패 지점을 찾습니다. 마지막 실패가 아니라 첫 실패입니다.
    2. 실행한 실제 명령을 찾습니다. Jenkins가 감싼 메시지 말고 sh, bat, git, docker 같은 실제 명령을 봅니다.
    3. 반환 코드(exit code)를 확인합니다. 같은 문구라도 종료 코드가 다르면 해석이 달라집니다.
    4. 환경 변수와 작업 디렉터리를 의심합니다. Jenkins 안에서만 실패하면 경로, 사용자, 셸 차이일 가능성이 큽니다.

    예를 들어 이런 Declarative Pipeline 조각이 있다고 해보겠습니다.

    pipeline {
      agent any
      stages {
        stage('Checkout') {
          steps {
            checkout scm
          }
        }
        stage('Build') {
          steps {
            sh 'pwd'
            sh 'printenv | sort'
            sh './gradlew clean build'
          }
        }
      }
      post {
        always {
          archiveArtifacts artifacts: 'build/reports/**', allowEmptyArchive: true
        }
      }
    }

    여기서 pwd와 printenv | sort를 자주 넣는 이유가 있습니다. 처음엔 투박해 보여도, 로컬에서는 되는데 Jenkins에서만 실패하는 문제를 잡아낼 때 꽤 강력하거든요. 특히 PATH, HOME, WORKSPACE 차이를 확인할 때 도움이 큽니다.

    또 하나 중요한 점은 SCM checkout 실패와 빌드 도구 실패를 섞어서 보지 않는 겁니다. checkout scm 이전에 실패하면 Git 접근, credential, 네트워크 문제일 가능성이 높고, 그 이후에 실패하면 빌드 스크립트나 런타임 문제로 좁혀집니다.

    CI/CD 디버깅 실전: 에이전트와 셸 환경 검증

    실전에서 정말 자주 만나는 케이스가 하나 있습니다. 파이프라인에서는 docker: command not found가 뜨는데, 운영자는 분명 에이전트에 Docker를 설치했다고 말하는 상황이죠. 저도 이런 경우를 여러 번 봤는데, 알고 보면 Jenkins가 실행되는 사용자와 사람이 SSH로 접속했을 때의 사용자 환경이 다른 경우가 많았습니다.

    이럴 때는 Jenkinsfile 안에서 추측만 하지 말고, 에이전트 셸에서 같은 사용자 맥락을 확인하는 게 빠릅니다.

    whoami
    id
    pwd
    echo "$PATH"
    command -v git
    command -v docker
    command -v java
    ls -la

    판단 기준은 이렇습니다.

    • command -v docker가 비어 있으면 PATH 또는 설치 경로 문제를 먼저 봅니다.
    • whoami 결과가 예상과 다르면 서비스 계정이 다를 수 있습니다.
    • 현재 디렉터리가 워크스페이스인지 확인합니다. 상대 경로 스크립트는 여기서 자주 깨집니다.
    • SSH 로그인 셸에서는 되는데 Jenkins에서 안 되면 .bashrc, .profile 같은 로그인 셸 초기화에 의존했을 가능성이 큽니다.

    에이전트가 컨테이너 기반이라면 한 번 더 들어가야 합니다. 이미지 자체에 도구가 빠졌거나, 엔트리포인트가 달라 환경 초기화가 예상과 다를 수 있거든요. 이런 경우는 Jenkins 문제라기보다 실행 런타임 문제에 더 가깝습니다.

    Jenkins 장애 해결 중 에이전트 환경 변수와 PATH 분석 다이어그램

    컨트롤러와 에이전트 사이에서 사용자 계정, PATH, 워크스페이스, 컨테이너 런타임 차이를 추적하는 장면을 설명하는 이미지입니다.

    자주 만나는 젠킨스 에러와 첫 대응 기준

    Jenkins 장애 해결에서 시간을 아끼려면, 증상별 첫 대응을 미리 정해두는 게 좋습니다. 아래 표는 현장에서 자주 쓰는 분류입니다.

    증상 의심 구간 먼저 볼 것 초기 대응
    빌드가 큐에서 안 나감 에이전트/라벨/실행기 /queue/api/json, /computer/api/json label 매칭, offline agent, executor 점유 상태 확인
    SCM checkout 실패 Git 접근/credential/네트워크 콘솔 로그의 git 명령, credential ID, known_hosts 토큰 권한, SSH 키, 저장소 URL, DNS 확인
    script returned exit code 1 빌드 스크립트 자체 실패한 실제 셸 명령 에이전트 셸에서 동일 명령 재실행
    Permission denied 파일 퍼미션/실행 권한 whoami, ls -l, mount 옵션 실행 비트, 소유권, workspace 권한 확인
    No space left on device 디스크/캐시/로그 df -h, du -sh, build history 불필요한 워크스페이스와 오래된 아티팩트 정리
    Agent disconnected 노드 연결/SSH 또는 inbound agent agent 로그, controller 로그 네트워크 단절, Java 실행 환경, 인증 정보 재확인
    HTTP 403/401 토큰/권한/CSRF API 호출 방식, crumb 필요 여부 인증 토큰, 권한 매트릭스, 요청 헤더 검토

    핵심은 숫자를 외우는 게 아니라 증상과 레이어를 연결하는 감각입니다. 예를 들어 Permission denied가 보여도 Jenkins 권한 모델 문제일 수 있고, 리눅스 파일 퍼미션 문제일 수도 있습니다. 문맥을 같이 봐야 헛수고를 줄일 수 있습니다.

    실제 삽질 포인트: 플러그인, 워크스페이스, 그리고 숨은 상태값

    이 섹션은 현장에서 자주 걸리는 포인트를 모은 겁니다. Jenkins는 눈에 보이는 설정 외에도 상태값 때문에 사람을 헷갈리게 할 때가 있더라고요.

    1. 플러그인 업데이트 직후 파이프라인 이상 동작

    플러그인 업데이트 후 특정 단계만 갑자기 깨질 수 있습니다. 특히 Pipeline, Git, Credentials 관련 플러그인이 바뀌면 증상이 미묘합니다. 로그를 보면 클래스 로딩 문제나 메서드 시그니처 불일치처럼 드러나는 경우가 있어서, 업데이트 직전 시점을 기준선으로 잡는 게 중요합니다.

    2. 워크스페이스 오염

    같은 잡이 브랜치나 조건에 따라 다른 파일을 남겨두면 간헐 장애가 납니다. 특히 생성 파일이 다음 빌드에 영향을 주는 프로젝트에서 자주 보이죠. 이런 때는 Jenkins의 cleanWs()나 Workspace Cleanup 플러그인처럼 범위가 명확한 정리 방식을 우선 권장합니다.

    post {
      always {
        cleanWs()
      }
    }

    셸에서 직접 삭제가 꼭 필요하다면 현재 디렉터리와 변수 값을 먼저 출력한 뒤, 삭제 범위를 다시 확인하세요. 이 단계에서 서두르면 더 큰 사고로 이어지기 쉽습니다.

    3. 숨은 환경 변수 차이

    로컬 셸에서는 프록시, 인증서, locale, JAVA_HOME이 잡혀 있는데 Jenkins에서는 빠져 있는 경우가 있습니다. 이건 CI/CD 디버깅에서 정말 흔합니다. 랜덤 장애처럼 보여도 사실은 환경 차이인 경우가 많습니다.

    4. 타임아웃과 외부 의존성

    테스트가 멈춘 것처럼 보여도 실제로는 외부 API 응답 대기일 수 있습니다. 이럴 땐 Jenkins 화면만 보지 말고 애플리케이션 로그, 대상 시스템 로그, 네트워크 경로, DNS, 프록시 유무까지 같이 봐야 합니다. Jenkins는 멈춘 원인이라기보다 멈춘 결과가 보이는 관측 지점일 때가 많거든요.

    검증 단계: 장애가 풀렸는지 어떻게 확인할까

    장애 복구 후에 초록불만 보고 끝내면 다음 주에 같은 문제를 다시 만날 수 있습니다. 그래서 검증은 세 가지로 나눠 보는 편이 좋습니다.

    1. 같은 커밋 재실행: 같은 입력에서 재현이 사라졌는지 봅니다.
    2. 바로 이전 성공 경로 비교: stage 흐름과 로그 패턴이 정상 범위인지 확인합니다.
    3. 외부 연동까지 확인: artifact, image, deploy, webhook, notification이 끝까지 이어지는지 봅니다.

    예를 들어 빌드는 성공했는데 아티팩트 보관이 비어 있으면, 저는 완전한 성공으로 보지 않습니다. 배포 단계가 생략됐는데 파이프라인만 녹색이어도 마찬가지예요. 무엇이 성공인지를 stage 단위로 정의해두면 재발 방지에도 도움이 됩니다.

    Jenkins UI로 확인해도 되지만, API로 확인하는 습관도 좋습니다.

    curl -s http://127.0.0.1:8080/job/my-pipeline/lastBuild/api/json
    curl -s http://127.0.0.1:8080/job/my-pipeline/lastSuccessfulBuild/api/json

    여기서는 result, number, building 같은 기본 상태와 함께, 단계 흐름과 산출물이 기대한 대로 나왔는지 같이 보세요. 숫자보다 성공 상태의 구조적 일관성을 확인하는 게 더 중요합니다.

    Jenkins 장애 해결 결과를 검증하는 빌드 대시보드 이미지

    성공과 실패가 섞인 스테이지 뷰, 빌드 이력, 아티팩트 보관 여부를 비교해 검증하는 결과 화면 이미지입니다.

    운영하면서 효과 있었던 예방책

    장애를 줄이려면 사후 대응만큼 예방도 중요합니다. 실제로 운영하면서 체감이 컸던 건 아래 네 가지였습니다.

    • Jenkinsfile에 진단용 출력 최소 세트를 넣어둡니다. pwd, whoami, printenv | sort 같은 기본 정보가 생각보다 자주 문제를 풀어줍니다.
    • 에이전트 이미지를 표준화합니다. 팀마다 다른 도구 버전과 PATH 구성은 결국 장애 원인이 됩니다.
    • 워크스페이스 정리 정책을 둡니다. 오래된 빌드와 캐시가 간헐 장애를 만듭니다.
    • 변경 이력 분리가 중요합니다. Jenkinsfile 변경, credential 변경, plugin 변경을 한 번에 몰아서 하지 않는 편이 좋습니다.

    이전 글에서 다룬 로그 읽기나 리눅스 디스크 점검 방법과 함께 보면 더 도움이 됩니다. 관련 운영 글도 내부 링크로 묶어두면 검색 유입과 체류 시간 둘 다 챙기기 좋습니다.

    FAQ: Jenkins 장애 해결에서 자주 받는 질문

    Q1. Jenkins UI에 에러가 너무 짧게 보일 때는요?

    컨트롤러 로그와 에이전트 로그를 분리해서 보시면 됩니다. UI는 요약일 뿐이라 실제 원인은 시스템 로그에 남는 경우가 많습니다.

    Q2. 파이프라인이 가끔만 실패하면 어디부터 봐야 하나요?

    간헐 실패는 외부 의존성, 워크스페이스 오염, 동시성, 네트워크 지연을 먼저 의심합니다. 같은 커밋 재실행과 깨끗한 워크스페이스 실행을 비교해보면 방향이 꽤 빨리 잡힙니다.

    Q3. Jenkins 장애 해결에서 가장 먼저 버려야 할 습관은 뭔가요?

    마지막 에러 한 줄만 보고 원인을 단정하는 습관입니다. 항상 첫 실패 지점을 찾는 쪽이 훨씬 정확합니다.

    Jenkins 장애 해결 유형별 대응 요약 인포그래픽

    증상별 원인 구간, 확인 명령어, 권장 대응 순서를 한 장으로 정리한 요약 이미지입니다.

    현장에서 바로 쓰는 Jenkins 장애 해결 방식

    배포가 급한 상황이라면 먼저 서비스 상태 확인 → 콘솔 로그 첫 실패 지점 확인 → 에이전트 셸에서 동일 명령 재실행, 이 세 단계부터 밟아보세요. 대부분 여기서 방향이 나옵니다. 복잡한 추측보다 이 순서가 훨씬 빠릅니다.

    반대로 같은 장애가 반복된다면 접근을 바꿔야 합니다. 에이전트 표준화, 워크스페이스 정리, Jenkinsfile 진단 출력 추가, 변경 이력 분리 쪽으로 가야 재발 방지 효과가 큽니다.

    결국 Jenkins 장애 해결은 화려한 비법보다 관찰 순서가 더 중요합니다. 젠킨스 에러를 보면 당황하기 쉽지만, 컨트롤러, 에이전트, 외부 연동, 실행 명령을 층으로 나눠 보면 생각보다 빨리 풀리더라고요. 파이프라인 문제 해결이 막막할 때는 오늘 적은 체크 순서대로 한 번 따라가 보세요.

  • [AI] LLM 추론 성능 벤치마크: TensorRT vs ONNX Runtime

    [AI] LLM 추론 성능 벤치마크: TensorRT vs ONNX Runtime

    [AI 인프라] LLM 추론 성능 벤치마크: NVIDIA TensorRT vs ONNX Runtime

    LLM 추론 성능 벤치마크를 해보면, 병목은 생각보다 GPU 스펙 한 줄로 설명되지 않더라고요. 같은 GPU, 같은 모델이라도 어떤 런타임이 어떤 shape를 얼마나 안정적으로 처리하느냐, 그리고 초기화 비용과 토큰 생성 구간을 어떻게 분리해 봤느냐에 따라 체감 성능이 꽤 달라집니다. 저도 초반에는 TensorRT 쪽 숫자가 더 높게 나오면 무조건 이긴 줄 알았는데, 실제 운영에선 첫 요청 지연, 엔진 캐시, 입력 길이 분산, Python 생성 루프 오버헤드가 더 크게 문제 되는 경우가 많았습니다.

    이번 글은 단순 비교가 아니라, 같은 조건에서 TensorRT와 ONNX Runtime를 어떻게 공정하게 재는지, 그리고 측정값이 실제 서비스 판단으로 어떻게 이어지는지에 집중했습니다. 홈랩, 사내 검증 서버, PoC 환경에서 바로 써먹을 수 있게 명령어와 체크 포인트를 실무 기준으로 묶었습니다.

    로컬 LLM 추론 성능 벤치마크를 위한 TensorRT와 ONNX Runtime 아키텍처 이미지

    로컬 GPU 환경에서 모델 로딩, 토크나이저, 런타임, 추론 결과까지 이어지는 전체 흐름을 한눈에 보여주는 아키텍처 이미지입니다.

    1. 왜 TensorRT와 ONNX Runtime를 같이 보게 되나

    실무에서 둘을 같이 보는 이유는 단순합니다. TensorRT는 NVIDIA GPU 고정 환경에서 최대 성능을 노릴 때 강력한 선택지고, ONNX Runtime은 모델 비교, 환경 이동, 운영 단순성을 챙기기 좋은 기준선이기 때문입니다. 둘은 경쟁 관계이면서도, 실제론 비교용 baseline과 최적화용 target이라는 식으로 같이 쓰는 경우가 많습니다.

    • TensorRT: 엔진 빌드 비용과 shape 관리 부담을 감수하는 대신, 고정 워크로드에서는 높은 처리량과 낮은 지연을 기대할 수 있습니다.
    • ONNX Runtime: CUDA Execution Provider만 붙여도 baseline이 빨리 잡히고, 디버깅 난도가 비교적 낮습니다.
    • ONNX Runtime + TensorRT Execution Provider: 코드 경로는 유지하면서 일부 최적화를 노릴 수 있지만, 문제 원인 분리가 오히려 더 어려워지는 경우도 있습니다.

    여기서 제가 중요하게 보는 건 하나입니다. 런타임 성능만 빠른지, 전체 생성 경로가 빠른지를 분리해야 한다는 점입니다. TensorRT 엔진 레벨 숫자가 좋아도 실제 서비스 응답이 별 차이 없으면, 대개 병목은 런타임 바깥에 있습니다. 반대로 ONNX Runtime가 조금 느려 보여도 재현성과 운영 안정성이 높으면, 초기 서비스에는 그쪽이 더 값어치 있는 선택일 수 있습니다.

    2. LLM 추론 성능 벤치마크에서 꼭 봐야 할 지표

    LLM 벤치마크는 이미지 분류처럼 한 번 넣고 한 번 끝나는 추론과 다릅니다. 프롬프트 처리(prefill)와 토큰 생성(decode)이 분리되고, KV cache가 붙고, 입력 길이 편차가 커서 평균값 하나로 끝내면 판단을 그르치기 쉽습니다. 그래서 LLM 추론 성능 벤치마크에선 평균 속도보다 구간별 특성을 같이 봐야 합니다.

    2-1. 꼭 맞춰야 하는 비교 조건

    1. 같은 모델 가중치, 같은 토크나이저, 같은 전처리 조건을 씁니다.
    2. 가능하면 같은 ONNX 그래프를 기준으로 비교하고, TensorRT 11.x처럼 강타입 빌드가 필요한 경우에는 사전 precision 변환 단계까지 함께 기록합니다.
    3. 정밀도는 FP16, BF16, FP32, INT8 중 하나로 고정합니다. 섞어 재면 비교가 무너집니다.
    4. 프롬프트 길이와 생성 토큰 수를 고정하고, 가능하면 짧은 입력/긴 입력 두 구간으로 나눠 봅니다.
    5. 워밍업 횟수와 측정 구간을 분리합니다. 첫 요청을 평균에 섞으면 해석이 흐려집니다.
    6. 배치 크기와 동시성은 별도 축입니다. 둘을 한 번에 바꾸면 원인을 찾기 어렵습니다.
    7. 전원 상태, 클럭 상태, 백그라운드 프로세스를 고정합니다. 특히 데스크톱 환경에서는 이 영향이 꽤 큽니다.

    2-2. 해석할 지표

    • TTFT(Time To First Token): 사용자 체감에 가장 직접적입니다. 첫 토큰이 느리면 답답하게 느껴지거든요.
    • Tokens/sec: 긴 출력에서 중요합니다. decode 구간 효율이 여기서 드러납니다.
    • p95 latency: 운영 안정성을 보려면 평균보다 이 수치가 더 중요합니다.
    • GPU memory usage: 속도가 비슷하면 메모리 여유가 있는 쪽이 운영성이 좋습니다.
    • Engine build / session init overhead: 재시작이 잦은 환경, 다중 모델 환경에서는 이 비용이 실제 총비용으로 이어집니다.
    • CPU time: 토크나이저, 후처리, Python 루프가 병목이면 GPU 최적화 이득이 상쇄됩니다.

    제가 실제로 더 신뢰하는 건 최고 기록이 아니라 세 번 돌렸을 때 비슷하게 나오는지입니다. 단일 최고치만 잘 나온 벤치마크는 운영 판단 기준으로는 거의 쓸모가 없습니다.

    3. TensorRT 최적화와 ONNX Runtime 비교를 위한 기준선 만들기

    로컬 LLM 추론 성능 벤치마크에서 제일 먼저 해야 할 일은, 화려한 최적화가 아니라 분리 측정입니다. 저는 보통 아래 네 구간으로 나눕니다.

    1. 모델 준비 구간: ONNX export, shape 정의, 동적 축 확인
    2. 초기화 구간: TensorRT 엔진 빌드 또는 ONNX Runtime 세션 생성
    3. prefill 구간: 긴 프롬프트를 한 번에 넣는 첫 추론
    4. decode 구간: 토큰을 한 개씩 이어서 생성하는 반복 추론

    이 네 구간을 섞어서 평균 latency 하나로 덮으면 실무에서는 해석이 거의 틀어집니다. TensorRT는 엔진 빌드가 무겁고, ONNX Runtime는 세션 초기화와 provider 구성 영향이 큽니다. LLM은 여기에 prefill과 decode의 성격까지 달라서, 어느 구간에서 이겼는지가 곧 선택 기준이 됩니다.

    항목 TensorRT ONNX Runtime 실무 판단 포인트
    최대 성능 잠재력 높음 중간~높음 GPU와 워크로드가 고정이면 TensorRT 투자 가치가 커집니다.
    초기 실험 속도 느린 편 빠른 편 모델 export 직후 baseline을 잡을 때는 ONNX Runtime가 훨씬 편합니다.
    입력 길이 변동 대응 shape 전략에 민감 상대적으로 유연 프롬프트 길이 편차가 크면 TensorRT 최적화 이점이 줄어들 수 있습니다.
    장애 분석 난이도 높음 낮음 팀에 추론 엔진 디버깅 경험이 없으면 운영 비용이 성능 이득을 먹어버릴 수 있습니다.
    재기동 비용 민감도 엔진 캐시 전략 필요 세션 재생성 중심 짧은 수명 컨테이너 환경에서는 캐시 설계가 성능만큼 중요합니다.
    권장 시작점 최적화 단계 기준선 단계 바로 TensorRT로 들어가기보다 ONNX Runtime로 병목 위치를 먼저 확인하는 편이 낫습니다.

    4. 실전 구현 1: TensorRT 엔진 생성과 기본 측정

    TensorRT는 <code>trtexec로 첫 감을 잡는 게 가장 빠릅니다. 다만 LLM에서는 단순히 FP16 옵션 하나만 넣고 끝내면 부족합니다. 입력 shape 범위를 명시하지 않으면 엔진이 실제 프롬프트 분포와 어긋날 수 있고, 그 결과 기대 이하의 최적화가 나올 수 있습니다.

    아래 예시는 TensorRT 10.x 계열에서 많이 쓰는 방식입니다. TensorRT 11.x부터는 일부 precision 관련 플래그가 바뀌었거나 제거됐기 때문에, 실제 사용 전에는 trtexec --help로 로컬 버전을 꼭 확인해 두는 게 좋습니다. 이거 안 보고 그대로 복붙했다가 한 번씩 막히더라고요.

    trtexec \
      --onnx=model.onnx \
      --saveEngine=model_fp16.plan \
      --fp16 \
      --minShapes=input_ids:1x16,attention_mask:1x16 \
      --optShapes=input_ids:1x512,attention_mask:1x512 \
      --maxShapes=input_ids:1x2048,attention_mask:1x2048 \
      --warmUp=200 \
      --duration=60 \
      --verbose

    이 명령에서 실무적으로 중요한 건 아래입니다.

    • --minShapes, --optShapes, --maxShapes: 동적 입력 모델이라면 사실상 핵심입니다. opt shape는 가장 자주 들어오는 프롬프트 길이대로 잡는 편이 낫습니다.
    • --warmUp: 보통 워밍업 시간 기준으로 해석합니다. 반복 횟수라고 착각하면 결과 비교가 꼬일 수 있습니다.
    • --verbose: 성능보다 먼저 봐야 할 건 변환 실패 레이어, precision 강등, 지원되지 않는 연산 흔적입니다.

    LLM에서 자주 놓치는 건 shape 전략이 사실상 성능 전략이라는 점입니다. 짧은 프롬프트 위주 서비스인데 max shape를 과하게 크게 잡으면 메모리와 최적화 효율이 함께 손해를 봅니다. 반대로 입력 길이 분산이 큰데 지나치게 좁게 잡으면 실제 요청에서 비효율이 생길 수 있습니다.

    엔진 생성과 추론을 분리해서 보고 싶다면 저장된 엔진으로 다시 측정하는 편이 낫습니다.

    trtexec \
      --loadEngine=model_fp16.plan \
      --shapes=input_ids:1x512,attention_mask:1x512 \
      --warmUp=200 \
      --duration=60

    이렇게 해야 엔진 빌드 시간이 빠진 순수 추론 구간을 보기 좋습니다. 엔진 빌드 시간을 평균 latency에 섞는 실수는 생각보다 자주 나오고, 그 순간부터 비교표는 의미가 많이 줄어듭니다.

    TensorRT 최적화와 GPU 가속 흐름을 설명하는 LLM 추론 성능 벤치마크 이미지

    ONNX 모델이 TensorRT 엔진으로 변환되고, 워밍업과 프로파일링을 거쳐 성능 지표가 수집되는 흐름을 설명하는 이미지입니다.

    5. 실전 구현 2: ONNX Runtime baseline 측정

    ONNX Runtime의 강점은 baseline을 빠르게 만들 수 있다는 점입니다. 저는 여기서 단순 평균 latency보다 provider가 제대로 붙었는지, 세션 옵션이 적절한지, 실제 생성 루프와 분리해 엔진 수준 시간을 볼 수 있는지를 먼저 확인합니다.

    import time
    import numpy as np
    import onnxruntime as ort
    
    session_options = ort.SessionOptions()
    session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
    session_options.log_severity_level = 0
    session_options.log_verbosity_level = 1
    
    providers = [
        (
            "CUDAExecutionProvider",
            {
                "device_id": 0,
                "arena_extend_strategy": "kNextPowerOfTwo",
                "cudnn_conv_algo_search": "EXHAUSTIVE",
                "do_copy_in_default_stream": True,
            },
        ),
        "CPUExecutionProvider",
    ]
    
    session = ort.InferenceSession(
        "model.onnx",
        sess_options=session_options,
        providers=providers,
    )
    
    print("active providers:", session.get_providers())
    input_names = [x.name for x in session.get_inputs()]
    print("inputs:", input_names)
    
    feeds = {
        "input_ids": np.ones((1, 16), dtype=np.int64),
        "attention_mask": np.ones((1, 16), dtype=np.int64),
    }
    
    for _ in range(20):
        session.run(None, feeds)
    
    latencies_ms = []
    for _ in range(100):
        start = time.perf_counter()
        session.run(None, feeds)
        latencies_ms.append((time.perf_counter() - start) * 1000)
    
    latencies_ms.sort()
    p95 = latencies_ms[int(len(latencies_ms) * 0.95) - 1]
    print(f"avg latency: {sum(latencies_ms)/len(latencies_ms):.3f} ms")
    print(f"p95 latency: {p95:.3f} ms")

    이 코드는 baseline용입니다. 실제 LLM 생성 벤치마크로 가려면 past_key_values, position_ids, attention_mask shape, 그리고 prefill/decode 분리를 모델 구조에 맞게 넣어야 합니다. 중요한 건 엔진 호출 성능과 애플리케이션 생성 루프 성능을 따로 수집하는 습관입니다.

    ONNX Runtime 안에서 TensorRT Execution Provider를 시험해 보고 싶다면 아래처럼 provider를 분리해 보는 방법도 있습니다. 이 경우는 성능 수치보다 캐시 동작과 fallback 여부를 먼저 보는 편이 낫습니다.

    providers = [
        (
            "TensorrtExecutionProvider",
            {
                "trt_engine_cache_enable": True,
                "trt_engine_cache_path": "./trt_cache",
                "trt_fp16_enable": True,
            },
        ),
        "CUDAExecutionProvider",
        "CPUExecutionProvider",
    ]
    
    session = ort.InferenceSession("model.onnx", providers=providers)

    GPU 상태는 꼭 같이 봐야 합니다. 벤치마크 로그만 보면 빨라 보이는데 GPU util이 낮고 CPU 한 코어만 치솟는 경우가 꽤 많습니다.

    nvidia-smi dmon -s pucm -d 1

    여기서 저는 네 가지를 같이 봅니다. power는 클럭 유지 여부, util은 GPU 바쁨 정도, clock은 스로틀링 여부, memory는 컨텍스트 길이와 배치 변화의 영향을 읽는 데 도움이 됩니다.

    6. 주의사항과 트러블슈팅: 제가 자주 겪었던 문제들

    벤치마크는 숫자를 뽑는 작업이 아니라 왜 그 숫자가 나왔는지 설명하는 작업에 가깝습니다. 아래 문제들은 LLM 추론 비교에서 반복해서 나오는 실패 모드입니다. 실제로는 런타임보다 입력 경로에서 막히는 경우도 꽤 많더라고요.

    6-1. 같은 모델인데 결과가 안 맞는 경우

    • 토크나이저 옵션 차이로 입력 길이가 달라집니다. 성능 비교 전에 실제 token count부터 맞춰야 합니다.
    • padding 방향과 최대 길이 설정이 다르면 연산량이 달라집니다.
    • 배치 크기 자동 조정이나 동적 batching이 켜져 있으면 공정 비교가 깨집니다.
    • FP16과 FP32, 또는 BF16을 섞어 재면 런타임 차이보다 precision 차이가 더 크게 나옵니다.

    근본 원인은 대부분 같은 모델을 보고 있다고 착각하는 것입니다. 파일명이 같아도 export 옵션, 동적 축, 입력 schema가 달라지면 사실상 다른 워크로드입니다.

    6-2. TensorRT 엔진은 빠른데 실제 생성 속도는 애매한 경우

    이건 정말 자주 봅니다. 원인은 대체로 런타임 바깥에 있습니다.

    • 토크나이저가 CPU 병목입니다. 짧은 응답에서는 이 영향이 생각보다 큽니다.
    • Python 루프가 토큰마다 GPU 호출을 감싸면서 왕복 오버헤드를 만듭니다.
    • KV cache 레이아웃이나 메모리 복사가 비효율적입니다.
    • 입력 shape가 계속 바뀌어 TensorRT 최적화가 기대만큼 먹지 않습니다.
    • 엔진 캐시가 제대로 재사용되지 않아 재시작 후마다 준비 비용이 다시 듭니다.

    GPU util은 낮은데 TTFT와 p95가 큰 경우는 계산 문제가 아니라 상위 경로 병목일 가능성이 큽니다. 이때 GPU 커널을 더 최적화하는 건 우선순위가 아닙니다.

    6-3. ONNX Runtime가 예상보다 느린 경우

    • CUDAExecutionProvider가 실제로 활성화됐는지 먼저 확인합니다.
    • 지원되지 않는 연산이 CPU로 fallback 되는지 로그를 봅니다.
    • 세션 옵션의 그래프 최적화가 빠져 있지 않은지 확인합니다.
    • ONNX export에서 dynamic axis를 과하게 열어 둬 shape 추론과 최적화가 약해진 경우를 의심합니다.
    • 입력 텐서를 매 요청마다 새로 할당하면서 호스트 메모리 오버헤드를 키우는 경우도 꽤 있습니다.

    실무에서 제가 제일 경계하는 건 런타임이 느린 게 아니라 export 품질이 낮은 경우입니다. ONNX Runtime 성능 문제처럼 보여도, 실제로는 모델 export가 비효율적으로 된 사례가 적지 않습니다.

    ONNX Runtime 비교를 위한 CUDA Execution Provider 구성 이미지

    ONNX Runtime 세션 초기화, 그래프 최적화, CUDA Execution Provider 적용 구조를 이해하기 쉽게 풀어낸 이미지입니다.

    7. LLM 추론 성능 벤치마크 결과, 숫자보다 읽는 법이 먼저입니다

    측정이 끝났다면 이제 숫자를 읽어야 합니다. 저는 아래처럼 봅니다. 이 구간이 사실 제일 중요합니다. 숫자만 높다고 바로 정답은 아니거든요.

    1. TTFT가 길다: 엔진/세션 초기화, 긴 프롬프트 prefill, 토크나이저, 첫 decode 경로를 의심합니다.
    2. 평균 latency는 좋은데 p95가 나쁘다: 동시성 구간의 큐잉, 메모리 압박, shape 분산이 원인일 가능성이 큽니다.
    3. tokens/sec가 잘 안 오른다: decode 루프의 CPU 개입, KV cache 처리, 작은 배치가 문제일 수 있습니다.
    4. GPU util이 낮다: GPU가 한가한데 응답은 느리다면 애플리케이션 경로를 먼저 봐야 합니다.
    5. GPU memory가 높고 출렁인다: 최대 시퀀스 길이, 동시성, 정밀도 조합이 과한 신호일 수 있습니다.

    홈랩이나 PoC에서는 특히 재현성이 중요합니다. 저는 최소 3회 반복, 워밍업 제외, 첫 요청 별도 기록, prefill/decode 분리, GPU 모니터링 병행 이 다섯 가지를 기본으로 둡니다. 이렇게 해 두면 나중에 팀 내에서 숫자 해석으로 싸울 일이 확 줄어듭니다.

    관측 결과 자주 나오는 근본 원인 먼저 할 조치 TensorRT 쪽 힌트 ONNX Runtime 쪽 힌트
    첫 요청만 유독 느림 엔진 빌드, 세션 초기화, 캐시 미적용 초기화 구간을 분리 측정 엔진 캐시와 사전 빌드 여부 확인 세션 재사용과 provider 초기화 로그 확인
    긴 프롬프트에서 급격히 느려짐 prefill 비용 증가, shape 최적화 불일치 입력 길이 구간별로 분리 측정 opt shape를 실제 분포에 맞게 재설계 export dynamic axis와 입력 텐서 구성 점검
    GPU util은 낮은데 응답은 느림 토크나이저, Python 루프, CPU fallback CPU 사용률과 provider 로그 확인 엔진 바깥 호출 오버헤드 축소 fallback 연산과 메모리 할당 패턴 확인
    평균은 괜찮은데 p95가 나쁨 동시성, 큐잉, 메모리 압박 동시성 단계별로 별도 실행 shape 편차와 메모리 상한 재검토 세션 옵션, provider 설정, CPU 개입 확인

    결과 정리 시에는 숫자 순위표보다 어떤 조건에서 우세했는지를 남기는 편이 훨씬 유용합니다. TensorRT가 이긴 게 아니라, 고정 shape의 긴 decode 구간에서 TensorRT가 우세했다처럼 써야 다음 의사결정에 바로 써먹을 수 있습니다.

    LLM 추론 성능 벤치마크 결과와 GPU 사용률을 시각화한 이미지

    TTFT, tokens/sec, GPU utilization, memory usage를 한 화면에서 비교하는 대시보드 형태의 결과 이미지입니다.

    8. 실무 추천과 다음 단계

    제가 현업에서 내리는 기준은 꽤 단순합니다. 먼저 ONNX Runtime로 병목 위치를 밝히고, 그다음 TensorRT로 돈 되는 구간만 최적화하는 흐름이 가장 덜 아픕니다. 처음부터 TensorRT로 들어가면 성능은 빨리 보여도, 왜 빨라졌는지와 왜 안 빨라졌는지를 분리하기 어려운 경우가 많습니다.

    • 이럴 땐 ONNX Runtime: 모델을 자주 바꾼다, 비교 실험이 많다, 디버깅 시간을 아껴야 한다, NVIDIA 고정 배포가 아니다.
    • 이럴 땐 TensorRT: GPU가 고정돼 있다, 긴 기간 같은 워크로드를 운영한다, p95와 처리량을 끝까지 밀어야 한다, 엔진 관리까지 감당할 팀이 있다.
    • 이럴 땐 둘 다 본다: ONNX Runtime baseline은 안정적인데 비용이 아쉽고, 병목이 GPU 쪽이라는 근거가 이미 있다.

    조금 더 직설적으로 말씀드리면, PoC 단계에서 바로 TensorRT를 주력 경로로 잡는 건 대개 이릅니다. 반대로 이미 NVIDIA GPU 기반으로 워크로드가 굳어 있고, 하루 종일 같은 패턴의 요청을 받는다면 TensorRT 최적화는 충분히 투자할 만합니다. 이 경우에는 성능 향상 자체보다도 예측 가능한 지연과 재현성이 장점으로 돌아옵니다.

    1. 개발 속도, 실험 반복, 모델 교체 빈도가 중요하면 ONNX Runtime로 시작하는 편이 맞습니다.
    2. 배포 환경이 NVIDIA GPU로 고정이고, TTFT보다 지속 처리량과 p95가 더 중요하면 TensorRT 우선 검토가 맞습니다.
    3. 둘 중 하나로 바로 못 정하겠다면, 같은 ONNX 기준으로 prefill과 decode를 분리 측정한 뒤 TTFT, tokens/sec, p95, GPU memory 네 항목으로 결정하는 게 가장 안전합니다.

    다음 단계도 명확합니다. baseline이 흔들리면 런타임 최적화 전에 export와 입력 경로부터 정리하시고, baseline이 안정적인데 GPU 병목이 분명하면 그때 TensorRT로 들어가시면 됩니다. 이 순서를 지키면 삽질 시간이 확실히 줄어듭니다.

    관련해서 모델 경량화나 GPU 가속 운영 팁이 더 궁금하시면, 블로그의 다른 AI 인프라 글도 같이 읽어보시면 흐름이 훨씬 잘 잡힐 겁니다.

    TensorRT와 ONNX Runtime 선택 기준을 요약한 LLM 추론 성능 벤치마크 이미지

    어떤 상황에서 TensorRT를 고르고, 어떤 상황에서 ONNX Runtime를 고르면 되는지 한 장으로 정리한 요약 이미지입니다.

    FAQ

    Q. ONNX Runtime 안에서 TensorRT도 쓸 수 있나요?

    네, TensorRT Execution Provider로 구성할 수 있습니다. 다만 성능만 보지 말고, 엔진 캐시 재사용, fallback 경로, 디버깅 난이도를 같이 보셔야 합니다. 코드 경로는 단순해 보여도 장애 분석은 더 어려워질 수 있습니다.

    Q. LLM 추론 성능 벤치마크에서 숫자가 자꾸 달라집니다.

    워밍업, 프롬프트 길이, 출력 길이, 배치 크기, 전원/클럭 상태를 먼저 고정해 보세요. 거기에 백그라운드 프로세스와 첫 요청 분리 기록까지 넣으면 흔들림 원인을 상당히 빨리 좁힐 수 있습니다.

    Q. AI 모델 경량화가 꼭 필요할까요?

    메모리가 빠듯하거나 동시성을 올려야 한다면 우선순위가 높습니다. 다만 INT8이나 더 공격적인 최적화는 정확도 검증과 함께 가야 해서, baseline 없이 바로 들어가면 성능은 빨라져도 판단은 더 어려워질 수 있습니다.

  • [인프라] Sentry 비용 최적화, 1년 운영 회고로 정리한 현실 전략

    [인프라] Sentry 비용 최적화, 1년 운영 회고로 정리한 현실 전략

    [인프라] Sentry 비용 최적화, 1년 운영 회고로 정리한 현실 전략

    Sentry 비용 최적화 이야기는 막상 운영을 시작하고 나서야 절실해지더라고요. 처음엔 에러 모니터링만 잘 되면 된다고 생각했는데, 실제로 1년 정도 굴려보니 비용 구조와 운영 습관이 생각보다 강하게 연결돼 있었습니다. 저도 처음엔 “에러를 잘 모으는 게 무조건 좋은 거 아닌가?” 싶었는데요. 실제로 써보니까 많이 모으는 것보다 의미 있게 남기는 것이 훨씬 중요했습니다. 이번 글에서는 제가 겪은 Sentry 사용 후기와 함께, 예상치 못한 비용 포인트, 클라우드 비용 관리 관점에서의 판단 기준, 그리고 에러 모니터링 비용을 줄이면서도 운영 효율을 유지한 방법을 정리해보겠습니다.

    특히 팀에서 이미 Sentry를 쓰고 있는데 월말마다 이벤트가 급증하거나, 알림이 너무 많아서 정작 중요한 장애를 놓치고 있다면 이 글이 꽤 도움이 될 겁니다. 혹시 이런 경험 있으신가요? 대시보드는 꽉 차 있는데, 남는 건 피로감뿐인 상황이요. 저도 딱 그랬습니다 ㅎㅎ

    Sentry 비용 최적화 관점의 이벤트 수집 아키텍처 다이어그램

    Sentry 비용 최적화 관점에서 보면, 애플리케이션에서 발생한 이벤트가 SDK, 릴레이, 프로젝트, 알림, 대시보드로 이어지는 흐름 전체를 한 번에 보는 게 중요합니다.

    Sentry 비용 최적화가 중요한 이유

    쉽게 말해 Sentry는 문제를 빨리 찾게 해주는 도구입니다. 그런데 운영이 길어질수록 “문제를 빨리 찾는 비용”도 같이 봐야 하거든요. 에러 모니터링 비용은 단순히 청구서 숫자만의 문제가 아닙니다. 너무 많은 이벤트는 분석 시간을 늘리고, 너무 많은 알림은 팀의 집중력을 깎습니다. 그래서 비용 최적화는 돈 절약이 아니라 운영 신호 대 잡음비를 올리는 작업에 가깝습니다.

    제가 1년 동안 운영하면서 가장 크게 느낀 점은 이거였어요. 비용이 늘어나는 순간은 장애가 커질 때보다, 쓸데없는 이벤트가 조용히 쌓일 때가 더 많았습니다. 배포 직후 반복되는 프런트엔드 에러, 이미 알고 있는 봇 요청, 의미 없는 health check 실패 로그, 중복 알림. 이런 것들이 눈에 안 띄게 쌓이다가 어느 순간 “왜 이렇게 많이 쓰고 있지?”가 되더라고요.

    Sentry에서 비용에 직접 영향을 주는 요소

    • 이벤트 볼륨: 동일 원인의 반복 에러도 건수는 그대로 쌓입니다.
    • 환경 분리: production, staging, dev를 무분별하게 넣으면 분석 난이도와 노이즈가 같이 증가합니다.
    • 릴리스 태깅: 배포 단위 구분이 안 되면 원인 파악 시간이 길어집니다.
    • 알림 정책: 중요하지 않은 이벤트까지 모두 알리면 대응 체계가 무너집니다.
    • 샘플링: 전부 수집하는 습관은 초반엔 편하지만, 장기 운영에서는 비효율로 돌아오는 경우가 많습니다.

    Sentry 사용 후기: 1년 써보니 드러난 예상치 못한 비용

    제가 처음 놓쳤던 부분은 “에러 건수”와 “운영 가치”가 비례하지 않는다는 점이었습니다. 처음엔 많이 잡히면 좋은 줄 알았거든요. 근데 여기서 문제가 생깁니다. 예를 들어 이미 원인을 알고 있고 우선순위가 낮은 에러가 계속 들어오면, 그건 모니터링 강화가 아니라 비용과 피로도만 늘리는 요소가 돼버립니다.

    실제로 써보니까 예상치 못한 비용 포인트는 대체로 아래 네 가지였어요.

    1. 배포 직후 반복 에러: 특정 버전에서 같은 오류가 급증하면 이벤트가 짧은 시간에 몰립니다.
    2. 봇/크롤러 유입: 정상 사용자가 아닌 요청에서 발생한 예외도 그대로 쌓이더라고요.
    3. 개발/테스트 환경 혼입: staging이나 로컬 테스트 이벤트가 production 분석을 방해했습니다.
    4. 소유자 없는 알림: 누구도 처리하지 않는 알림이 계속 발행되면 시스템만 시끄러워집니다.

    여기서 중요한 포인트! Sentry 효율은 결국 “무엇을 버릴지 결정하는 능력”에서 많이 갈립니다. 모든 걸 저장하는 게 정답은 아니었어요.

    Sentry 비용 최적화를 위한 기본 원칙

    저는 중간에 운영 원칙을 아예 다시 세웠습니다. 기준이 생기니까 그다음부터는 훨씬 편하더라고요.

    항목 처음 운영할 때 실수 지금 적용하는 기준
    이벤트 수집 전부 수집 중요도 기준으로 샘플링
    환경 구분 production/staging/dev 혼합 production 중심, 나머지는 제한 수집
    알림 모든 에러 알림 사용자 영향 큰 이슈만 즉시 알림
    태그 관리 태그 난립 서비스, 릴리스, 환경, 고객군 중심 최소화
    리뷰 주기 문제 생길 때만 확인 주간 리뷰로 노이즈 제거

    쉽게 말해 기준은 단순합니다. 장애 대응에 도움 되는 데이터는 남기고, 나머지는 과감히 줄인다. 이 원칙 하나로 Sentry 비용 최적화 방향이 꽤 명확해졌습니다.

    실전 구현: 제가 적용한 Sentry 효율 개선 단계

    이제부터는 실제로 어떻게 손봤는지 정리해보겠습니다. 팀마다 스택은 다르겠지만, 접근 방식은 꽤 공통적입니다.

    1. 환경별 수집 정책 분리

    가장 먼저 한 일은 production과 staging을 완전히 다르게 다루는 것이었습니다. 운영 환경은 놓치면 안 되니까 보수적으로 가져가고, 테스트 환경은 필요한 범위만 남겼습니다.

    export SENTRY_ENVIRONMENT=production
    export SENTRY_RELEASE=webapp-2026-08-13
    export SENTRY_TRACES_SAMPLE_RATE=0.2

    이 정도만 해도 기준이 생깁니다. 릴리스와 환경을 분리해두면, 나중에 어떤 배포에서 에러가 급증했는지 확인하기 훨씬 쉬워집니다.

    2. 애플리케이션 레벨에서 불필요한 예외 제외

    여기서 삽질을 좀 했습니다. 처음엔 서버에서 발생한 예외를 거의 다 보내도록 해놨었는데요. 실제로 운영하면서 보니 이미 알고 있는 사용자 취소, 봇 요청, 연결 종료 같은 케이스까지 다 들어오고 있더라고요. 이건 운영 정보가 아니라 노이즈에 가깝습니다.

    import sentry_sdk
    
    IGNORED_EXCEPTIONS = {
        "ClientDisconnected",
        "HealthcheckTimeout",
        "BotRequestError",
    }
    
    def before_send(event, hint):
        exc_info = hint.get("exc_info")
        if exc_info:
            exc_type = exc_info[0]
            if exc_type and exc_type.__name__ in IGNORED_EXCEPTIONS:
                return None
    
        request = event.get("request", {})
        headers = request.get("headers", {})
        user_agent = str(headers.get("User-Agent", "")).lower()
        if "bot" in user_agent or "crawler" in user_agent:
            return None
    
        return event
    
    sentry_sdk.init(
        dsn="YOUR_DSN",
        environment="production",
        release="webapp-2026-08-13",
        before_send=before_send,
    )

    이런 식으로 before_send 단계에서 걸러주면 꽤 효과가 있습니다. 물론 너무 공격적으로 제외하면 진짜 문제를 놓칠 수 있으니, 처음에는 로그를 같이 보면서 한두 주 정도 검증하는 게 좋습니다.

    Sentry 비용 최적화를 위한 환경 분리와 필터링 설정 다이어그램

    프로덕션과 스테이징 분리, 봇 요청 제외, 예외 필터링을 하나의 흐름으로 시각화한 이미지가 있으면 팀 내 공유가 훨씬 쉬워집니다.

    3. 샘플링으로 이벤트 볼륨 제어

    샘플링은 처음엔 좀 불안했습니다. “혹시 중요한 걸 놓치면 어떡하지?” 하는 생각이 들거든요. 저도 그랬어요. 근데 실제로는 전부 수집해서 못 보는 것보다 중요한 걸 선별해서 제대로 보는 것이 훨씬 낫더라고요.

    sentry_sdk.init(
        dsn="YOUR_DSN",
        environment="production",
        release="webapp-2026-08-13",
        traces_sample_rate=0.2,
    )

    트래픽이 많은 서비스라면 일괄 비율 샘플링보다, 특정 엔드포인트나 특정 고객군만 더 촘촘히 보는 방식도 고려할 만합니다. 다만 여기서는 수치보다 원칙이 중요해요. 사용자 영향이 큰 경로는 더 자세히, 반복적이고 가치가 낮은 구간은 더 가볍게 가져가시면 됩니다.

    4. 알림 정책 다시 설계

    제가 체감상 가장 크게 개선된 부분은 알림 정책이었습니다. 에러가 생길 때마다 메신저로 다 쏘면 처음엔 든든해 보이는데요. 한 달만 지나면 아무도 안 봅니다. 이건 진짜입니다. 알림은 많을수록 안전한 게 아니라, 신뢰도가 높을수록 안전하더라고요.

    • 새로운 이슈 중 production만 즉시 알림
    • 같은 에러라도 일정 시간 내 반복 급증 시 우선 알림
    • 이미 확인된 낮은 우선순위 이슈는 일일 요약으로 전환
    • 담당 서비스 태그가 없는 이벤트는 우선 분류 작업부터 수행

    이렇게 바꾸고 나서부터는 Sentry 사용 후기가 팀 내에서도 확실히 좋아졌습니다. “시끄러운 도구”에서 “필요할 때 믿고 보는 도구”로 바뀐 느낌이었거든요.

    5. 소스맵과 릴리스 정리

    프런트엔드 운영하시는 분들은 이거 꼭 챙기셔야 합니다. 소스맵이 꼬이면 에러는 들어오는데 분석 시간이 확 늘어납니다. 비용 문제는 단지 이벤트 수만이 아니라, 사람의 조사 시간에서도 생기거든요.

    export SENTRY_RELEASE=webapp-2026-08-13
    sentry-cli releases new "$SENTRY_RELEASE"
    sentry-cli releases finalize "$SENTRY_RELEASE"

    릴리스 정리가 잘 되면 배포 이후 특정 에러가 어느 버전부터 발생했는지 빠르게 추적할 수 있어요. 이건 직접 해보니 진짜 편하더라고요.

    ⚠️ 트러블슈팅: 실제로 겪었던 문제와 해결법

    운영하면서 제일 많이 부딪힌 건 “줄였더니 놓치는 것 아닐까”에 대한 불안이었습니다. 그래서 저는 한 번에 확 줄이지 않고, 단계적으로 조정했습니다.

    1. 문제: 필터를 너무 넓게 잡아 중요한 이벤트가 빠질까 걱정됨
      해결: 먼저 로그와 함께 병행 관찰하고, 제외 규칙은 소규모부터 적용했습니다.
    2. 문제: staging 이벤트가 production 분석을 방해함
      해결: 환경 태그를 강제하고, production 외 환경은 수집량을 제한했습니다.
    3. 문제: 봇 요청이 에러를 과도하게 만듦
      해결: User-Agent 기반 1차 필터링과 라우트 단위 예외 처리를 병행했습니다.
    4. 문제: 알림이 너무 많아 아무도 보지 않음
      해결: 긴급 알림, 일일 요약, 리뷰 대상 이슈를 분리했습니다.

    특히 봇 요청은 진짜 골칫거리였습니다. 처음엔 애플리케이션 문제인 줄 알고 한참 들여다봤는데, 알고 보니 이상한 경로를 두드리는 외부 요청이더라고요. 그때 깨달았어요. 모든 에러가 제품 문제는 아니다. 운영에서는 이 구분이 정말 중요합니다.

    검증과 결과: Sentry 효율이 좋아졌는지 어떻게 확인했나

    최적화는 느낌으로 하면 안 됩니다. 저는 아래 네 가지를 기준으로 봤어요.

    1. 주간 신규 이슈 수가 줄었는가
    2. 반복성 높은 동일 에러 비중이 줄었는가
    3. 알림 확인 후 실제 대응으로 이어지는 비율이 높아졌는가
    4. 배포 후 원인 파악 시간이 짧아졌는가

    정확한 수치를 여기서 단정적으로 적진 않겠습니다. 서비스마다 너무 다르더라고요. 다만 제가 직접 해보니, 이벤트 총량이 줄어든 것보다 분석 시간이 줄어든 효과가 더 크게 체감됐습니다. 월말 비용 체감도 덜했고, 무엇보다 팀이 대시보드를 다시 신뢰하기 시작했어요. 이건 숫자 이상으로 중요하더라고요.

    Sentry 비용 최적화 전후 대시보드 비교 이미지

    최적화 전후 지표를 대시보드로 비교하면 Sentry 효율 개선이 비용 절감뿐 아니라 대응 속도 향상으로 이어졌는지 한눈에 확인할 수 있습니다.

    검증 항목 최적화 전 상태 최적화 후 기대 상태
    이벤트 품질 중복/잡음 많음 핵심 이슈 중심
    알림 신뢰도 무시되는 알림 많음 실제 대응 비율 상승
    원인 분석 속도 버전 추적 어려움 릴리스 기준 추적 가능
    클라우드 비용 관리 월말 체감 증가 예측 가능성 향상

    운영 체크리스트: Sentry 비용 최적화할 때 꼭 보는 것

    • production 외 환경이 과도하게 들어오지 않는가
    • 반복성 높은 저가치 예외를 제외했는가
    • 릴리스와 환경 태그가 일관되게 붙는가
    • 알림 정책이 실제 대응 체계와 맞는가
    • 주간 단위로 노이즈 이벤트를 리뷰하는가

    이 체크리스트만 꾸준히 돌려도 에러 모니터링 비용은 꽤 안정됩니다. 그리고 클라우드 비용 관리 관점에서도 “나중에 한꺼번에 정리”보다 “조금씩 자주 정리”가 훨씬 덜 힘듭니다.

    자주 묻는 질문

    Sentry 비용 최적화는 결국 샘플링만 하면 되나요?

    아닙니다. 샘플링은 한 축일 뿐입니다. 환경 분리, 예외 제외, 릴리스 정리, 알림 정책 재설계가 같이 가야 효과가 나요.

    Sentry 사용 후기에서 가장 체감 큰 개선은 무엇이었나요?

    저는 알림 정책 재설계가 가장 컸습니다. 이벤트 수가 조금 줄어드는 것보다, 정말 중요한 알림만 보이게 만든 효과가 훨씬 크더라고요.

    에러 모니터링 비용을 줄이면 장애 대응력이 떨어지지 않나요?

    무조건 줄이면 그럴 수 있습니다. 그래서 핵심은 삭제가 아니라 선별입니다. 사용자 영향이 큰 이벤트는 더 잘 보고, 가치가 낮은 반복 이벤트는 과감히 줄이는 쪽이 맞아요.

    Sentry 비용 최적화 핵심 체크리스트 인포그래픽

    운영 원칙, 검증 지표, 알림 설계 기준을 한 장으로 정리한 요약 이미지는 팀 온보딩 자료로도 꽤 유용합니다.

    마무리: Sentry 효율은 도구 설정이 아니라 운영 습관에서 결정됩니다

    1년 정도 운영해보니 결론은 분명했어요. Sentry 비용 최적화는 옵션 몇 개 바꾼다고 끝나는 일이 아니었습니다. 이벤트를 어떻게 정의할지, 어떤 알림만 살아남게 할지, 어떤 데이터가 실제로 팀에 도움이 되는지 계속 다듬는 과정이더라고요.

    저도 처음엔 “많이 수집하면 안전하다”고 생각했었는데, 실제로 써보니까 잘 버리는 팀이 결국 더 빨리 대응했습니다. 이거 꽤 역설적이죠. 하지만 운영은 원래 그렇더라고요. 많이 쌓는 것보다, 필요한 걸 바로 찾는 게 더 중요합니다.

    다음 글에서는 이번 내용과 이어서 Sentry 알림 정책 설계를 조금 더 깊게 다뤄볼 예정입니다. 어떤 이슈를 즉시 알림으로 보내고, 어떤 건 요약으로 돌릴지 기준을 잡는 방법이요. 이전 글에서 다룬 로그 집계와 메트릭 분리 전략도 함께 보시면 전체 운영 그림을 잡는 데 도움이 될 겁니다.

    혹시 지금 Sentry를 쓰고 있는데 비용은 늘고 효율은 애매하다고 느끼신다면, 오늘 바로 해볼 수 있는 건 하나입니다. 최근 7일 이슈 중 반복 건수만 많고 조치 가치가 낮은 이벤트를 골라 제외 후보 목록부터 만들어보세요. 여기서부터 진짜 최적화가 시작됩니다. 드디어 감이 잡히실 겁니다 🎉

  • [Cloud] Sentry 메모리 누수 해결: 실전 디버깅 사례 연구

    [Cloud] Sentry 메모리 누수 해결: 실전 디버깅 사례 연구

    Sentry 메모리 누수 해결: 실전 디버깅 사례 연구

    Sentry 메모리 누수 문제는 생각보다 늦게 티가 납니다. 처음에는 응답이 조금씩 느려지는 정도라서 그냥 트래픽이 늘었나 싶거든요. 그런데 어느 순간부터 컨테이너가 재시작되고, OOMKilled(메모리 부족으로 인한 강제 종료)가 찍히고, 에러는 많은데 원인은 안 보이는 상황이 옵니다. 저도 프로덕션 환경에서 딱 그 구간을 겪어봤습니다. 처음엔 애플리케이션 코드보다 인프라 쪽 문제라고 의심했었는데, 실제로 파고 들어가 보니 요청별로 쌓이는 객체 참조가 해제되지 않는 전형적인 메모리 누수였습니다. 이번 글은 Sentry 메모리 누수를 어떻게 추적했고, 어떤 식으로 좁혀 갔는지, 그리고 최종적으로 어떤 기준으로 해결 여부를 검증했는지 정리해 봤습니다.

    특히 Sentry 디버깅, 클라우드 에러 모니터링, 메모리 누수 해결, 성능 최적화가 한 번에 엮여 있는 사례라서, 운영 중인 Python API 서버나 컨테이너 환경을 다루는 분들께 꽤 현실적인 참고가 될 거 같습니다.

    Sentry 메모리 누수 추적을 위한 프로덕션 아키텍처 다이어그램

    프로덕션 API 서버, Sentry, 컨테이너 메모리 사용량 흐름을 한눈에 보여주는 개요 이미지입니다.

    Sentry 메모리 누수, 왜 잡기 어려운가

    쉽게 말해 메모리 누수(memory leak, 더 이상 필요 없는 메모리가 해제되지 않고 계속 남는 현상)는 에러 한 번으로 끝나지 않습니다. 요청은 성공하는데 프로세스 메모리만 조금씩 올라갈 수도 있고, 특정 조건에서만 객체가 쌓일 수도 있죠. 그래서 로그(log, 텍스트 기록)만 봐서는 감이 잘 안 옵니다.

    여기서 주목할 포인트가 있습니다. Sentry는 기본적으로 에러 모니터링(error monitoring)과 이벤트 추적(event tracking)에 강점이 있고, 메모리 누수는 증상과 맥락을 묶어 보는 용도로 정말 유용했습니다. 저는 아래 네 가지를 먼저 봤어요.

    • 언제 메모리 사용량이 급격히 올라가는지
    • 어떤 릴리스(release, 배포 버전) 이후부터 재현되는지
    • 어떤 엔드포인트(endpoint, API 경로)에서 현상이 두드러지는지
    • 재시작 직전 남기는 경고 이벤트와 예외 스택이 무엇인지

    혹시 이런 경험 있으신가요? CPU는 멀쩡한데 메모리만 톱니처럼 올라가고, 재배포하면 잠깐 괜찮아졌다가 다시 터지는 상황이요. 이럴 때는 감으로 고치면 거의 다시 터집니다. 저도 처음엔 캐시(cache) 설정만 바꾸면 되겠지 했는데, 삽질 좀 했습니다 ㅎㅎ

    Sentry 디버깅 관점에서 본 핵심 개념

    제가 실제로 써보니, 메모리 누수는 도구를 나눠서 보는 게 훨씬 편했어요. Sentry는 현상의 타이밍과 요청 맥락을 모으고, Python 내장 도구는 어떤 객체가 쌓이는지를 확인하는 식이었습니다.

    도구 역할 이번 사례에서 한 일
    Sentry 이벤트 추적, 릴리스 비교, 환경 분리 특정 배포 이후 경고 이벤트 증가와 엔드포인트 상관관계 확인
    tracemalloc 메모리 할당 추적 어느 코드 경로에서 객체가 계속 쌓이는지 비교
    gc 가비지 컬렉션 상태 확인 참조가 남아 해제되지 않는 객체 패턴 점검
    ps/top/kubectl 운영 레벨 리소스 확인 프로세스 RSS 증가, 재시작 시점, OOM 패턴 확인

    쉽게 말해, Sentry 메모리 누수 대응은 Sentry 하나만으로 끝내는 작업이 아니라, 관측(observability, 시스템 상태를 관찰하는 능력)의 중심축으로 Sentry를 두고 주변 도구를 붙이는 작업입니다.

    제가 잡았던 실제 패턴

    문제의 원인은 요청 처리 중 생성한 큰 응답 데이터를 전역 리스트(global list)에 디버깅 목적으로 붙여 놓은 코드였습니다. 개발 단계에서는 편했는데, 프로덕션에서 요청이 누적되면서 리스트가 비워지지 않았죠. 더 골치 아픈 건 예외가 바로 나지 않았다는 점입니다. 그래서 클라우드 에러 모니터링만 보고 있으면 늦게 알아차리기 쉬워요.

    실전 구현 1: Sentry에 운영 맥락 심기

    처음 한 일은 Sentry 이벤트에 운영 맥락을 더 많이 넣는 것이었습니다. 그냥 SDK만 붙여 두면 예외는 잘 모이는데, Sentry 메모리 누수처럼 느린 장애는 맥락이 부족할 때가 많거든요.

    1. 릴리스(release)와 환경(environment)을 분리합니다.
    2. 의심되는 엔드포인트에 breadcrumb(브레드크럼, 이벤트 전 단계 기록)를 남깁니다.
    3. 메모리 사용량이 임계치에 가까워지면 warning 이벤트를 직접 보냅니다.
    4. 컨테이너 재시작 전후 로그와 Sentry 타임라인을 맞춰 봅니다.
    import os
    import time
    import resource
    import sentry_sdk
    from sentry_sdk import capture_message, set_tag, add_breadcrumb
    from sentry_sdk.integrations.logging import LoggingIntegration
    
    logging_integration = LoggingIntegration(
        level=None,
        event_level=None,
    )
    
    sentry_sdk.init(
        dsn=os.environ.get("SENTRY_DSN"),
        environment=os.environ.get("APP_ENV", "production"),
        release=os.environ.get("APP_RELEASE", "unknown"),
        integrations=[logging_integration],
        traces_sample_rate=0.1,
    )
    
    MEMORY_WARN_MB = 700
    
    def get_rss_mb():
        rss_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
        if os.uname().sysname == "Darwin":
            return rss_kb / 1024 / 1024
        return rss_kb / 1024
    
    def observe_request(path: str, request_id: str):
        set_tag("endpoint", path)
        set_tag("request_id", request_id)
        add_breadcrumb(
            category="request",
            message=f"handling {path}",
            level="info",
            data={"request_id": request_id, "ts": int(time.time())},
        )
    
        rss_mb = get_rss_mb()
        if rss_mb >= MEMORY_WARN_MB:
            capture_message(
                f"high memory usage detected: {rss_mb:.1f}MB",
                level="warning",
            )

    여기서 중요한 포인트! Sentry에 경고 이벤트를 직접 남기면, 예외가 나기 전 단계의 흐름이 보입니다. 저는 이걸 넣고 나서야 특정 API 요청이 몰린 뒤 10~20분 정도 지나면 경고가 늘어난다는 걸 확인했어요.

    Sentry 메모리 누수 분석에 필요한 release와 environment 태그 구성 이미지

    Sentry에서 release, environment, endpoint 태그를 어떻게 나눠서 보는지 설명하는 구성 이미지입니다.

    실전 구현 2: 재현 스크립트와 누수 코드 분리

    운영에서만 보이는 문제라도, 결국 로컬이나 스테이징(staging, 사전 검증 환경)에서 비슷하게 재현할 수 있어야 고칠 수 있죠. 저는 홈랩에서도 비슷하게 많이 해보는데, 재현이 되는 순간부터 속도가 확 올라갑니다.

    문제를 단순화한 예시는 이런 식이었습니다.

    from fastapi import FastAPI
    
    app = FastAPI()
    DEBUG_CACHE = []
    
    @app.get("/items")
    def items():
        payload = {"rows": ["x" * 10000 for _ in range(500)]}
    
        # 문제 코드: 요청마다 큰 객체를 전역 리스트에 보관
        DEBUG_CACHE.append(payload)
    
        return {"count": len(payload["rows"]), "cache_size": len(DEBUG_CACHE)}

    이런 코드는 테스트 몇 번 할 때는 티가 안 나요. 근데 프로덕션에서 요청이 계속 들어오면 얘기가 달라집니다. 그래서 부하를 살짝 줘서 반복 요청을 만들었습니다.

    for i in $(seq 1 300); do
      curl -s http://127.0.0.1:8000/items > /dev/null
    done
    
    ps -o pid,rss,command -p $(pgrep -f uvicorn)

    이후 tracemalloc으로 전후 차이를 찍었습니다.

    import tracemalloc
    import linecache
    
    tracemalloc.start(25)
    
    # 부하 실행 전 snapshot
    before = tracemalloc.take_snapshot()
    
    # 여기서 반복 호출 수행
    
    after = tracemalloc.take_snapshot()
    stats = after.compare_to(before, "lineno")
    
    for stat in stats[:10]:
        frame = stat.traceback[0]
        print(f"{frame.filename}:{frame.lineno} -> {stat.size_diff / 1024:.1f} KiB")
        print(linecache.getline(frame.filename, frame.lineno).strip())

    실제로 써보니까 이 방식이 좋았던 이유는 명확합니다. Sentry에서 어느 요청 흐름이 이상한지를 찾고, tracemalloc으로 어느 코드 줄이 증가하는지를 고정할 수 있었거든요. 두 도구가 따로 노는 게 아니라 딱 이어졌습니다.

    실전 구현 3: 수정 방향과 배포 전략

    수정 자체는 의외로 단순했습니다. 전역에 보관하던 디버그 데이터를 없애고, 꼭 필요하면 크기를 제한한 ring buffer(링 버퍼, 고정 길이 순환 버퍼)로 바꿨어요. 사실 운영 서버에서는 요청 본문이나 큰 응답 객체를 오래 들고 있는 패턴 자체를 피하는 게 맞습니다.

    from collections import deque
    from fastapi import FastAPI
    
    app = FastAPI()
    DEBUG_CACHE = deque(maxlen=20)
    
    @app.get("/items")
    def items():
        payload = {"rows": ["x" * 10000 for _ in range(500)]}
    
        # 필요한 메타데이터만 제한적으로 저장
        DEBUG_CACHE.append({
            "row_count": len(payload["rows"]),
        })
    
        return {"count": len(payload["rows"]), "cache_size": len(DEBUG_CACHE)}

    배포할 때도 한 번에 전체 트래픽을 넘기지 않았습니다. 제가 운영할 때는 이런 문제는 꼭 점진 배포(rolling deployment, 순차 배포)로 갑니다. 왜냐하면 메모리 누수는 수정했다고 끝이 아니라, 정말 증가 추세가 꺾였는지를 봐야 하거든요.

    1. 수정 버전을 새 릴리스로 배포합니다.
    2. Sentry에서 이전 릴리스와 새 릴리스를 분리해 봅니다.
    3. 동일 엔드포인트 기준 warning 이벤트 발생 빈도를 비교합니다.
    4. 컨테이너 재시작 횟수와 RSS 증가 그래프를 같이 봅니다.

    재현 스크립트, 문제 코드, 수정 후 구조를 비교해서 보여주는 디버깅 흐름 이미지입니다.

    ⚠️ 주의사항: 제가 실제로 겪은 삽질 포인트

    여기서부터가 진짜 중요합니다. 메모리 누수는 원인을 하나만 보면 놓치는 경우가 많아요.

    1. ru_maxrss를 실시간 메모리로 착각

    ru_maxrss는 최대 RSS 값이라서 순간 메모리 스냅샷처럼 쓰면 해석이 꼬일 수 있습니다. 저도 처음엔 현재 메모리라고 생각하고 봤다가 그래프 해석을 잘못했었어요. 그래서 운영에서는 ps, 컨테이너 메트릭, 노드 메모리 지표를 같이 봐야 합니다.

    2. 예외가 없으니 애플리케이션 문제를 늦게 의심

    OOMKilled는 쿠버네티스(Kubernetes)나 런타임 차원의 이벤트로 보이는 경우가 많아서, 애플리케이션 버그와 분리해서 생각하기 쉬워요. 근데 여기서 중요한 건, 예외가 없다고 누수가 없는 게 아니다라는 점입니다.

    3. 디버그 로그가 문제를 키움

    디버깅하려고 요청 본문, 응답 객체, ORM 결과를 통째로 메모리에 들고 있으면 오히려 누수를 악화시킬 수 있습니다. 특히 전역 변수, 캐시, 클로저(closure, 외부 변수를 참조하는 함수), 콜백 등록 구조는 한 번 더 의심해 보셔야 합니다.

    4. Sentry를 만능 분석기로 기대

    Sentry는 정말 강력한 도구지만, heap dump 분석기나 프로파일러(profiler)를 완전히 대체하지는 않습니다. 저는 Sentry를 상황판으로 쓰고, 세부 원인 분석은 tracemalloc과 코드 리뷰로 마무리했어요. 이 조합이 제일 현실적이었습니다.

    • ✅ Sentry: 릴리스, 환경, 요청 흐름, 경고 이벤트 연계
    • ✅ 시스템 도구: RSS, 재시작, 컨테이너 상태 확인
    • ✅ 언어 도구: 객체 증가 경로 추적
    • ⚠️ 단일 지표만 보고 결론 내리기 금지

    검증: 해결됐다고 판단한 기준

    드디어 됐다! 라고 말하기 전에, 저는 꼭 기준을 숫자가 아니라 패턴으로 봅니다. 구체적인 절대 수치는 환경마다 다르니까요. 대신 아래 기준은 어느 환경에서도 꽤 유효합니다.

    1. 동일 트래픽 조건에서 프로세스 메모리가 계속 우상향하지 않는지
    2. 문제 엔드포인트 호출 후에도 일정 시간 뒤 메모리 증가 추세가 평탄해지는지
    3. Sentry warning 이벤트 빈도가 줄었는지
    4. OOM 재시작이 사라졌는지
    5. 새 릴리스 이후 동일 이슈 그룹이 다시 생기지 않는지

    저는 수정 전후를 이런 식으로 운영 점검했습니다.

    kubectl get pods -n prod
    kubectl top pods -n prod
    kubectl describe pod api-server-xxxxx -n prod
    
    # 애플리케이션 로그와 Sentry 이벤트 시점을 함께 대조
    kubectl logs deployment/api-server -n prod --since=30m

    결과적으로 수정 후에는 특정 API 호출이 몰려도 메모리 그래프가 계속 계단식으로 올라가지 않았고, 재시작도 멈췄습니다. Sentry에서도 문제 릴리스 기준으로 쌓이던 경고 이벤트가 눈에 띄게 줄었어요. 이런 순간이 있죠. 아, 이건 증상만 눌러 놓은 게 아니라 원인을 제대로 건드렸구나 하는 느낌이요. 이거 진짜 편하더라고요.

    Sentry 메모리 누수 해결 전후 결과를 보여주는 대시보드 이미지

    수정 전후 메모리 사용량 추세와 Sentry 이벤트 변화를 비교하는 결과 대시보드 이미지입니다.

    정리: Sentry 메모리 누수 대응 체크리스트

    마지막으로, 제가 다시 같은 상황을 만나면 이 순서대로 갑니다. 독자분들도 북마크해 두시면 꽤 쓸 만하실 거 같습니다.

    단계 체크 포인트 목적
    1 Sentry에 release, environment, endpoint 태그 확인 문제 범위 좁히기
    2 경고 이벤트 수동 전송 추가 예외 전 단계 포착
    3 부하 재현과 tracemalloc 비교 코드 라인 단위 확인
    4 전역 객체, 캐시, 콜백 참조 점검 누수 패턴 제거
    5 점진 배포 후 Sentry와 메트릭 비교 해결 여부 검증

    정리해 보면, Sentry 메모리 누수 대응의 핵심은 Sentry를 단순 에러 수집기로 쓰지 않는 데 있습니다. 릴리스 비교, 경고 이벤트, 요청 태그를 잘 심어 두면 메모리 누수처럼 느리고 애매한 장애도 꽤 빨리 좁혀 갈 수 있거든요. 저도 처음엔 헷갈렸는데, 결국 답은 비슷했습니다. 증상을 구조화해서 보고, 재현하고, 작은 단서부터 묶어 가는 것. 운영은 늘 그렇게 풀리더라고요.

    다음 글에서는 OpenTelemetry(오픈텔레메트리, 분산 추적 표준)와 Sentry를 함께 붙여서 요청 지연과 에러를 한 번에 보는 흐름도 다뤄볼 예정입니다. 이전 글에서 다뤘던 로그 집계 파이프라인과 같이 보시면 더 이해가 잘 되실 거 같습니다.

    자주 묻는 질문

    Sentry만으로 메모리 누수를 찾을 수 있나요?

    완전히 단독으로 찾기보다는, 어느 흐름에서 문제가 커지는지를 좁히는 데 강합니다. 실제 누수 지점은 tracemalloc, gc, 코드 리뷰 같은 보조 수단이 필요할 때가 많아요.

    메모리 누수 해결 후 무엇을 가장 먼저 봐야 하나요?

    같은 트래픽에서 메모리 사용량이 계속 증가하는지, 컨테이너 재시작이 멈췄는지, 그리고 Sentry 디버깅 기준으로 동일 릴리스 이슈가 줄었는지를 먼저 보시면 됩니다.

    클라우드 에러 모니터링만 잘 되어 있으면 충분한가요?

    충분하지 않을 때가 많습니다. 메모리 누수는 인프라 이벤트처럼 보이지만 실제 원인은 코드 참조 구조인 경우가 많아서, 애플리케이션 계층과 시스템 계층을 같이 보셔야 해요.

    Sentry 메모리 누수 대응 체크리스트 인포그래픽

    Sentry, tracemalloc, 시스템 메트릭을 조합한 대응 순서를 한 장으로 요약한 이미지입니다.

  • [k8s] Karpenter 오토스케일링, 성능 벤치마크: 다양한 워크로드 환경 테스트

    [k8s] Karpenter 오토스케일링, 성능 벤치마크: 다양한 워크로드 환경 테스트

    [Kubernetes] Karpenter 성능 벤치마크로 보는 오토스케일링 테스트

    Karpenter 성능 벤치마크 이야기를 해보려고 합니다. Kubernetes(쿠버네티스) 운영을 조금만 오래 해보면, 결국 병목은 애플리케이션이 아니라 노드가 언제, 얼마나 빠르게 붙느냐에서 터지는 경우가 많거든요. 저도 홈랩과 실서비스에 가까운 테스트 환경에서 이것저것 만져보면서 느낀 게, 오토스케일링은 단순히 “늘어난다”가 아니라 얼마나 빨리 반응하고, 얼마나 낭비 없이 붙고, 붙은 뒤 얼마나 안정적으로 정리되느냐까지 같이 봐야 한다는 점이었습니다.

    특히 이번 글은 Karpenter 성능 벤치마크라는 키워드에 맞춰서, 단순 사용기가 아니라 benchmark(벤치마크) 관점으로 정리했습니다. 수치만 던지는 글이 아니라, 어떤 워크로드에서 무엇을 관찰해야 하는지, 그리고 Kubernetes 오토스케일링 성능을 볼 때 어디서 실수하기 쉬운지까지 같이 묶어보겠습니다. 혹시 노드 확장 속도 테스트를 해보려고 하는데 어디서부터 봐야 할지 막막하셨다면, 이 글이 꽤 도움이 될 겁니다.

    Karpenter 성능 벤치마크를 위한 쿠버네티스 오토스케일링 아키텍처 개요 이미지

    Karpenter, 스케줄러, 워크로드, 신규 노드 프로비저닝 흐름을 한눈에 보여주는 아키텍처 개요 이미지입니다.

    Karpenter란 무엇이고, 벤치마크에서 왜 중요할까요?

    쉽게 말해 Karpenter는 Pending Pod(스케줄되지 못하고 대기 중인 파드)를 보고, 거기에 맞는 노드를 빠르게 띄우는 방식의 노드 프로비저너입니다. 예전에는 HPA(Horizontal Pod Autoscaler, 파드 수 자동 조절)와 Cluster Autoscaler(클러스터 오토스케일러, 노드 수 자동 조절)를 묶어서 보는 경우가 많았는데요. Karpenter는 여기서 조금 더 직접적으로 “어떤 노드가 필요한지”를 계산해서 띄우는 쪽에 가깝습니다.

    제가 처음 이걸 봤을 때는 “결국 노드 늘리는 거 아닌가?” 싶었는데, 실제로 써보니까 차이가 꽤 크더라고요. 특히 아래 같은 부분이 벤치마크 포인트였습니다.

    • 프로비저닝 결정 속도: Pending Pod가 생긴 뒤 새 노드가 뜨기까지의 흐름
    • bin packing(빈 패킹, 자원 밀집 배치) 효율: CPU와 메모리 요청량에 맞춰 얼마나 낭비 없이 배치되는지
    • 워크로드 적합성: 짧게 치고 빠지는 배치성 작업과, 지속적으로 부하가 유지되는 서비스형 워크로드에서 반응이 다른지
    • 정리 속도: 부하가 빠졌을 때 불필요한 노드를 얼마나 깔끔하게 줄이는지

    여기서 중요한 포인트! Karpenter 효율성 검증은 “몇 초 빨랐다”보다, 스케줄링 실패 원인이 어디서 사라졌는지를 보는 게 더 중요합니다. 이미지 풀링(Image Pulling), CNI(Container Network Interface) 초기화, 리소스 요청값 과대설정 같은 변수가 너무 많거든요.

    벤치마크 설계: 어떤 워크로드로 테스트해야 하나요?

    벤치마크는 결국 설계가 반입니다. 제가 직접 해보니, 하나의 워크로드만 보면 결론이 자꾸 왜곡되더라고요. 그래서 최소한 아래 3종류는 나눠서 보는 걸 추천합니다.

    워크로드 유형 특징 관찰 포인트
    Burst API 짧은 시간에 요청 급증 노드 확장 속도 테스트, Pending Pod 해소 시점
    Batch Job 대량 Job이 한 번에 생성 bin packing 효율, 스케줄링 밀집도
    Steady Service 지속적인 중간 부하 과잉 프로비저닝 여부, 축소 안정성

    이렇게 나누는 이유가 있습니다. Burst API는 순간 반응 속도를 보기 좋고, Batch Job은 Karpenter가 노드를 얼마나 촘촘하게 맞춰 띄우는지 보기 좋습니다. Steady Service는 오히려 쓸데없이 많이 띄우지 않는지를 확인하기 좋습니다. 사실 운영에서는 이 마지막이 비용에 제일 크게 영향을 주더라고요.

    테스트 환경 준비: 관측 지표부터 맞춰야 합니다

    벤치마크 전에 먼저 해야 할 건 화려한 부하툴이 아니라 관측 기준 통일입니다. Prometheus(프로메테우스, 메트릭 수집), Grafana(그라파나, 시각화), 그리고 kubectl 이벤트 확인 정도는 꼭 준비해두세요. 저는 처음에 부하부터 때렸다가, 나중에 보니 노드는 떴는데 이미지 풀 때문에 파드가 늦게 뜬 거라 삽질 좀 했습니다 ㅎㅎ

    1. Kubernetes 클러스터 준비
    2. Karpenter 설치 및 기본 프로비저닝 정책 정의
    3. 메트릭 수집 도구 연결
    4. 부하 생성 도구 배치
    5. 이벤트 타임라인 기록
    helm repo add karpenter https://charts.karpenter.sh
    helm repo update
    
    kubectl create namespace karpenter
    helm upgrade --install karpenter karpenter/karpenter \
      --namespace karpenter \
      --set settings.clusterName=my-cluster

    설치 방식은 환경마다 조금 다를 수 있지만, 핵심은 설치 자체보다 Provisioner/NodePool 성격의 정책과 워크로드 리소스 요청값을 맞추는 겁니다.

    apiVersion: karpenter.sh/v1beta1
    kind: NodePool
    metadata:
      name: default
    spec:
      template:
        spec:
          requirements:
            - key: kubernetes.io/arch
              operator: In
              values: ["amd64"]
          expireAfter: 720h
      disruption:
        consolidationPolicy: WhenUnderutilized

    위 예시는 개념 예시입니다. 실제 필드나 리소스 종류는 사용 중인 Karpenter 버전에 따라 다를 수 있으니, 운영 반영 전에는 현재 문서를 꼭 맞춰보셔야 합니다. 버전 차이 무시하고 복붙했다가 안 되는 경우가 진짜 많거든요.

    Karpenter 성능 벤치마크에서 NodePool과 리소스 요청값 구성을 설명하는 이미지

    NodePool 정책, 리소스 요청/제한, 스케줄링 조건이 어떻게 연결되는지 보여주는 구성 다이어그램입니다.

    실전 구현: 다양한 워크로드로 Karpenter 성능 벤치마크 진행하기

    1. Burst API 워크로드

    짧은 시간에 Deployment(디플로이먼트) 복제 수를 확 올리거나, HPA와 부하 생성기를 함께 써서 순간 부하를 만드는 방식입니다.

    kubectl scale deployment sample-api --replicas=50
    kubectl get pods -w

    여기서 보는 건 단순히 파드가 Running 되는 순간이 아닙니다.

    • 처음 Pending이 발생한 시점
    • Karpenter가 노드 생성 결정을 내린 시점
    • 노드 Ready 시점
    • 파드가 실제 서비스 가능 상태가 된 시점

    제가 직접 해보니 이 네 구간을 나눠서 봐야 병목이 보였습니다. 노드 생성은 빨랐는데 DaemonSet 초기화 때문에 실제 서비스 반영이 늦는 경우도 있더라고요.

    2. Batch Job 워크로드

    Job을 여러 개 한 번에 던져보면 bin packing 효율을 확인하기 좋습니다.

    apiVersion: batch/v1
    kind: Job
    metadata:
      name: cpu-batch-sample
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: worker
              image: busybox
              command: ["sh", "-c", "sleep 120"]
              resources:
                requests:
                  cpu: "500m"
                  memory: "256Mi"
    for i in $(seq 1 30); do
      kubectl create -f job.yaml
    done

    이 테스트는 생각보다 중요합니다. 왜냐하면 실제 운영에서는 API 서버보다 배치 잡이 더 난폭하게 클러스터를 흔드는 경우가 많거든요. 여기서 Karpenter가 과도하게 큰 노드를 띄우는지, 아니면 필요한 만큼만 맞춰 띄우는지를 체크하면 Karpenter 효율성 검증에 큰 도움이 됩니다.

    3. Steady Service 워크로드

    이건 부하를 오래 유지하면서 축소 동작까지 보는 테스트입니다.

    kubectl top nodes
    kubectl top pods -A
    kubectl get events -A --sort-by=.lastTimestamp

    중요한 건 “늘어나는 속도”만 보지 말고, 줄어들 때 서비스에 영향 없이 정리되는지도 같이 보셔야 합니다. 노드가 줄면서 PodDisruptionBudget(PDB, 파드 중단 예산)이나 스케줄링 제약과 충돌하면 오히려 운영 피로도가 확 올라갑니다.

    ⚠️ 실제로 많이 겪는 문제와 트러블슈팅

    이 섹션은 꼭 넣고 싶었습니다. 벤치마크가 이상하게 나오면 대부분 Karpenter 자체 문제라기보다 주변 설정 때문이더라고요.

    • 리소스 요청값이 비현실적일 때: requests가 과하게 크면 노드가 과잉 생성됩니다.
    • 이미지 풀 지연: 새 노드가 떠도 컨테이너 이미지 다운로드 때문에 체감은 느립니다.
    • DaemonSet 오버헤드 누락: CNI, 로깅 에이전트, 모니터링 에이전트 자원이 계산에서 빠지면 예상보다 적게 들어갑니다.
    • 스케줄링 제약 과다: nodeSelector, affinity, taint/toleration이 많으면 Karpenter가 선택할 수 있는 노드 풀이 좁아집니다.

    저도 처음엔 “왜 노드를 띄웠는데 Pending이 안 없어지지?” 싶었는데, 알고 보니 topology spread constraints와 리소스 요청이 같이 꼬였던 적이 있었습니다. 이럴 때는 아래 순서로 보면 빨리 풀립니다.

    1. Pending Pod describe로 실패 이유 확인
    2. Karpenter 로그에서 provisioning decision 확인
    3. 노드 Ready 이후 CNI/DaemonSet 상태 확인
    4. 이미지 풀 시간과 애플리케이션 startup probe 확인
    kubectl describe pod <pending-pod-name>
    kubectl logs -n karpenter deploy/karpenter
    kubectl get daemonsets -A
    kubectl describe node <new-node-name>

    💡 팁 하나 드리면, 노드 확장 속도 테스트를 할 때는 테스트용 이미지 크기를 최대한 줄이세요. 이미지가 크면 오토스케일링 성능이 아니라 레지스트리/네트워크 성능을 재게 됩니다.

    검증과 결과 해석: 숫자보다 흐름을 보세요

    이제 결과를 봐야죠. 다만 여기서 조심할 게 있습니다. 제가 일부러 구체 벤치마크 수치를 박지 않는 이유는, 인스턴스 타입, 네트워크, 이미지 캐시, CNI 구성에 따라 결과가 너무 달라지기 때문입니다. 대신 재현 가능한 관찰 포맷을 드리는 게 더 낫습니다.

    측정 항목 어떻게 확인하나 좋게 봐야 할 신호
    Pending 해소 시간 pod 상태 변화, 이벤트 타임라인 증가 구간이 짧고 일관적임
    노드 준비 시간 node Ready 시점 워크로드 증가 시 급격한 편차가 적음
    자원 밀집도 node/pod 사용률 비교 유휴 자원이 과도하게 남지 않음
    축소 안정성 scale-in 이후 재스케줄 상태 서비스 영향 없이 정리됨

    실제로 써보니까, Kubernetes 오토스케일링 성능은 “최대 속도”보다 “예상 가능한 속도”가 더 중요했습니다. 갑자기 한 번 엄청 빠른 결과가 나오는 것보다, 비슷한 조건에서 비슷하게 반응하는 쪽이 운영에는 훨씬 유리하거든요.

    Karpenter 성능 벤치마크 결과로 워크로드별 Pending Pod와 노드 준비 시간을 비교한 이미지

    Burst, Batch, Steady 워크로드별로 Pending Pod 해소 흐름과 노드 Ready 전환을 비교한 대시보드 이미지입니다.

    비교 정리: 어떤 환경에서 Karpenter가 특히 잘 맞을까요?

    제 기준으로는 아래 환경에서 특히 체감이 좋았습니다.

    • 트래픽 변동폭이 큰 서비스
    • 짧은 배치 작업이 자주 몰리는 플랫폼
    • 팀이 노드 타입과 비용 최적화까지 같이 보고 싶은 환경

    반대로, 부하가 거의 고정이고 노드 풀도 단순한 환경이라면 체감 이점이 크지 않을 수도 있습니다. 그래서 벤치마크는 꼭 “남들도 좋다더라”가 아니라 내 워크로드 기준으로 봐야 합니다. 여기서 중요한 포인트, 다시 한 번 말씀드리면 Karpenter 성능 벤치마크는 제품 홍보 자료처럼 보면 안 됩니다. 운영 제약, 워크로드 패턴, 팀의 관측 역량이 같이 들어가야 의미가 생깁니다.

    Karpenter 성능 벤치마크와 기존 오토스케일링 방식을 비교한 요약 이미지

    프로비저닝 방식, 반응성, 자원 활용 관점에서 Karpenter와 기존 접근을 비교한 요약 인포그래픽입니다.

    자주 묻는 질문

    Karpenter만 있으면 HPA는 필요 없나요?

    아닙니다. HPA는 파드 수를 늘리고, Karpenter는 그 파드가 올라갈 노드를 준비하는 역할에 가깝습니다. 둘은 대체 관계라기보다 연동 관계로 보는 게 맞습니다.

    벤치마크에서 가장 먼저 봐야 할 로그는 뭔가요?

    Pending Pod 이벤트와 Karpenter 컨트롤러 로그입니다. 여기서 프로비저닝 판단이 있었는지 먼저 확인해야 합니다.

    비용 절감 효과도 바로 검증할 수 있나요?

    가능은 하지만, 하루 이틀 테스트로 단정하긴 어렵습니다. 최소한 여러 워크로드 패턴을 반복해보고, 축소 정책까지 함께 봐야 의미가 있습니다.

    마무리: 빠른 확장보다 중요한 건 예측 가능한 확장입니다

    이번 글에서는 Karpenter 성능 벤치마크를 워크로드 유형별로 어떻게 봐야 하는지 정리해봤습니다. 제가 직접 해보니, 제일 중요한 건 화려한 벤치마크 숫자가 아니라 Pending 발생 → 노드 생성 결정 → 노드 Ready → 서비스 가능 이 흐름을 끊김 없이 읽어내는 것이었습니다. 드디어 됐다! 싶은 순간도 있었지만, 사실 그 전에 로그 보면서 한참 헤맨 적도 많았거든요.

    정리하자면 이렇습니다.

    • ✅ Burst, Batch, Steady 워크로드를 나눠서 봐야 합니다.
    • ✅ 노드 생성 속도만 보지 말고 이미지 풀과 DaemonSet 초기화도 같이 봐야 합니다.
    • ✅ Karpenter 효율성 검증은 절대 수치보다 일관성과 자원 활용 흐름이 중요합니다.

    다음 글에서는 HPA와 함께 붙였을 때의 상호작용, 그리고 실제 운영에서 자주 쓰는 관측 대시보드 구성도 따로 다뤄볼 예정입니다. 이전 글에서 다뤘던 클러스터 리소스 요청값 설계 내용도 같이 보시면 훨씬 이해가 쉬우실 겁니다.