장애 분석 봇 만들기 2편: CPU 서버에서 4B 로컬 LLM 돌리기
장애 분석 봇 만들기 시리즈
- 왜 만들었고, 어떻게 설계했나
- CPU 서버에서 4B 로컬 LLM 돌리기 (이 글)
- 판단은 코드, 정리는 LLM
- Jira 승인으로 움직이고, 최소 권한으로 가두기
- 봇이 내 Jira 토큰을 쓰고 있었다: 서비스 계정으로 바꾸기
- 한계와 다음 과제
들어가며
1편의 조건은 “외부 AI 금지, GPU 없이 4코어/8GB”였다. 이 편은 그 조건에서 LLM을 띄우고, 속도를 재고, 기본 설정대로 두면 메모리가 7GB까지 불어나는 함정을 찾은 이야기다.
1. 무엇을 띄우나
| 부품 | 설명 | 고른 이유 |
|---|---|---|
| llama.cpp 서버 | LLM을 CPU로 돌리는 오픈소스 추론 엔진(MIT). 공식 컨테이너 이미지가 있고 OpenAI 호환 API를 준다 | CPU 추론이 가볍고, 이미지를 직접 빌드할 필요가 없다 |
| Qwen3-4B, Q4_K_M | 파라미터 40억 개짜리 작은 모델을 4비트로 양자화한 파일(약 2.5GB, Apache 2.0) | 4코어/8Gi에서 안정적으로 돈다. 우리가 시킬 일은 “추리”가 아니라 “정리”라 충분하다 |
용어 세 가지
| 용어 | 쉬운 설명 |
|---|---|
| GGUF | llama.cpp가 읽는 모델 파일 형식. 파일 하나에 모델과 설정이 다 들어 있다 |
| 양자화 (Q4_K_M) | 숫자 정밀도를 낮춰 모델 크기를 줄이는 기술. 원래 16비트(파라미터당 2바이트)면 4B 모델이 8GB인데, 4비트 계열이면 약 2.5GB가 된다 |
| 토큰 | LLM이 글을 읽는 단위. 한국어·영어가 섞인 글은 대략 2글자 = 1토큰이다 |
2. 쿠버네티스에 올리기
모델 파일은 이미지에 넣지 않았다. 파드가 뜰 때 initContainer가 Hugging Face에서 받아 노드 디스크에 둔다. 한 번 받으면 다시 받지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
containers:
- name: llm
image: ghcr.io/ggml-org/llama.cpp:server
args:
- -m
- /models/Qwen3-4B-Q4_K_M.gguf
- --ctx-size
- "8192" # 한 번에 다룰 수 있는 토큰 수
- --threads
- "4" # CPU 4코어
- --parallel
- "1" # 한 번에 한 요청 (4코어에서 동시 처리하면 둘 다 느려진다)
- --jinja # 모델에 맞는 대화 형식 적용
- --cache-ram
- "1024" # ← 이 글의 핵심. 4장
- --api-key
- $(LLM_API_KEY)
resources:
requests: { cpu: "2", memory: 5Gi }
limits: { cpu: "4", memory: 8Gi } # 목표 VM 사양과 같게
자원 상한을 4코어/8Gi로 건 이유: 원래 계획은 이 사양의 VM이었다. 같은 상한을 걸어 두면 PoC 측정값을 그대로 “VM에서도 되는가”의 근거로 쓸 수 있다.
API 키를 건 이유: 키 없이 부르면 401이 나야 정상이다. 같은 클러스터의 아무 파드나 LLM을 쓰지 못하게 한다(네트워크 정책도 따로 건다, 4편).
2-1. 받은 모델 파일이 진짜인지
인터넷에서 받은 2.5GB 파일이 변조되지 않았는지 확인하려면 SHA256 지문을 비교한다. 매니페스트에 “정답 지문”을 적어 두고, initContainer가 받은 파일의 지문과 같을 때만 쓰게 했다.
정답 지문을 구하는 게 의외로 번거로웠다. 2.5GB를 받아서 계산하는 대신, Hugging Face가 응답 헤더에 지문을 넣어 준다는 걸 이용했다.
1
2
curl -sI https://huggingface.co/Qwen/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q4_K_M.gguf \
| grep -i x-linked-etag | grep -oiE '[0-9a-f]{64}' | head -1
curl -sI: 본문(2.5GB)은 받지 않고 헤더만 받는다.x-linked-etag: 큰 파일(LFS)의 SHA256이 들어 있다.head -1: 여러 줄이 잡히면 이후sed치환이unterminated s로 깨진다. 한 줄만 남긴다.
또 하나의 안전장치로, 매니페스트에 자리표시자가 그대로 남아 있으면 initContainer가 바로 멈추게 했다. 지문 검사를 건너뛰고 아무 파일이나 쓰는 일을 막기 위해서다.
3. 속도 재기
장애 로그 요약을 시키고, 응답에 들어 있는 시간 정보(timings)를 봤다.
| 측정 | 값 | 의미 |
|---|---|---|
| 읽는 속도 (입력 처리, prompt eval) | 초당 약 25~28토큰 | 1,500토큰(약 3,000자)을 읽는 데 약 1분 |
| 쓰는 속도 (생성) | 초당 약 5.5~10토큰 | 300토큰 답을 쓰는 데 30초~1분 |
| 건당 처리 시간 | 약 20초~1분 30초 | 시나리오와 증거 길이에 따라 다르다 |
알게 된 것: 시간 대부분이 “읽는” 데 든다. CPU 추론은 입력이 길수록 느려진다. 그래서 이후 개선은 “더 좋은 모델”이 아니라 “LLM에게 보내는 글을 줄이는 것”으로 갔다. 증거를 4,500자에서 3,000자로 줄이고, 필요 없는 정보(Normal 이벤트 등)를 빼서 평균 99초를 약 50초로 줄였다(3편).
한 가지 더. LLM을 재시작한 직후 첫 요청은 10~25초 더 걸렸다. 매번 같은 시스템 프롬프트(지시문)를 읽는 결과가 캐시에 없어서다. 두 번째 요청부터는 캐시를 재사용한다. 이 캐시가 다음 장의 주인공이다.
4. 함정: 메모리 7GB
LLM을 띄워 놓고 몇 시간 동안 분석을 수십 번 돌린 뒤, 자원 사용량을 보다가 멈칫했다.
1
2
NAME CPU(cores) MEMORY(bytes)
ai-diag-llm-... 1329m 7098Mi
7098Mi. 상한 8Gi의 87%다. 예상은 4Gi 남짓이었다.
4-1. 추리
| 생각 | 내용 |
|---|---|
| 예상 계산 | 모델 2.5GB + 대화 기억 공간(KV 캐시, 8K 토큰) 약 1.2GB + 계산 버퍼 약 0.5GB ≈ 4.2GB |
| 차이 | 3GB가 더 있다 |
| 의심 | 최근 llama.cpp에는 이전 요청 내용을 메모리에 쌓아 두는 캐시가 있다. 오늘 분석을 수십 번 했다 |
| 확인 | 서버 도움말에서 캐시 옵션을 찾는다 |
서버 도움말(llama-server --help)에서 --cache-ram 옵션을 찾았다. 캐시 상한 기본값이 8192MiB(8GB)였다.
이 값은 미리 잡아 두는 크기가 아니라 “여기까지 쌓을 수 있다”는 상한이다. 그래서 띄운 직후에는 문제가 없지만, 요청이 쌓일수록 캐시가 불어난다. 모델(2.5GB)에 캐시 상한(8GB)을 더하면 파드 상한 8Gi를 넉넉히 넘을 수 있는 구조다. 실제로 수십 회 분석 만에 7098Mi까지 찼다. 아직 재시작은 0회였지만 시간문제였다. 상한에 닿으면 LLM 파드가 OOMKilled로 죽는다. 우리 도구가 시험용 시나리오(S1)로 맞히던 바로 그 장애를 LLM 자신이 겪게 되는 셈이다.
원래 계획대로 8GB VM에 기본 설정으로 올렸다면, 운영 중에 메모리 부족으로 죽었을 것이다. 짧게 띄워 보고 “잘 되네”로 끝냈다면 보이지 않았다. 몇 시간 돌려 봤기 때문에 보인 문제다.
4-2. 조치: 캐시 상한 1024MiB
완전히 끄지(0) 않은 이유: 이 캐시가 같은 시스템 프롬프트를 다시 읽지 않게 해 줘서, 적당히 남겨 두면 빨라진다. 시스템 프롬프트 몇 개면 1GB로 충분하다.
1
2
- --cache-ram
- "1024"
| 시점 | LLM 메모리 |
|---|---|
| 조치 전 (수십 회 분석 후) | 7098Mi |
| 조치 후 시작 직후 | 2902Mi |
| 조치 후 분석 3회 | 3468Mi |
| (참고) 봇 파드 | 19Mi |
4코어/8GB에 메모리 여유가 4GB 넘게 남는다. 단, --cache-ram 설정이 필수다.
5. 운영 메모
| 항목 | 내용 | 이유 |
|---|---|---|
--parallel 1 | 한 번에 한 요청. 봇도 이슈를 하나씩 순서대로 처리한다 | 4코어에서 두 건을 동시에 돌리면 둘 다 느려진다 |
/no_think | Qwen3의 “생각 과정” 출력을 끈다 | 답이 짧아지고 빨라진다. 정리 작업에는 생각 과정이 필요 없다 |
| temperature 0.2, 출력 300토큰 | 일관된 답, 짧은 답 | 같은 증거에 매번 다른 말을 하면 믿기 어렵다 |
| 모델은 노드 디스크(hostPath) | 재시작 때 다시 받지 않는다 | 이그레스가 막혀 있어도 재시작된다. 대신 노드가 바뀌면 다시 받아야 한다(6편 한계) |
:server 이미지 태그 | 아직 digest로 고정하지 않았다 | 재시작 때 다른 버전을 받을 수 있다. 과제로 남겼다 |
2편 정리
| 배운 것 | 근거 |
|---|---|
| 4B 모델은 CPU 4코어로도 “정리” 용도에는 충분하다 | 건당 20초~1분 30초, 메모리 3.5Gi |
| CPU 추론은 읽는 시간이 대부분이다. 보내는 글을 줄인다 | 입력 25~28 tok/s |
| 기본값을 믿지 말고 오래 돌려 보고 자원을 본다 | --cache-ram 기본 8GB → 7098Mi |
| 받은 모델 파일은 지문으로 검증하고, 검증값이 비면 멈춘다 | x-linked-etag, initContainer |
다음 편은 이 작은 모델이 틀린 해석을 하고, 금지해도 값을 지어낸 이야기다.