Mac 도커에서 죽는 로컬 LLM을 살리는 엔지니어링 설정
오픈소스 AI 워크스페이스 코드를 내려받아 Mac 도커에 올리면 첫 프롬프트부터 벽에 부딪힙니다. 분명 M 시리즈 칩을 쓰고 있는데 팬만 요란하게 돌 뿐, 초당 10토큰 남짓한 처참한 속도가 찍힙니다. Docker Desktop의 가상화 계층이 Apple Silicon GPU(Metal)를 컨테이너 안으로 넘겨주지 못해 CPU 깡통 연산으로 떨어지기 때문입니다.
이 병목을 뚫지 않은 채 FastAPI 백엔드와 로컬 벡터 DB를 엮어 돌리면 메모리 누수와 커넥션 고갈로 컨테이너가 뻗어버립니다. 깃허브 스타 수만 보고 가져온 코드를 로컬 환경에서 상용 수준으로 굴리기 위해 뜯어고쳐야 할 네 가지 설정입니다.
CPU 폴백 원인과 하드웨어 가속 우회 경로
macOS의 Docker Desktop은 경량 Linux VM 위에서 돕니다. Linux 게스트 OS 컨테이너 내부로 호스트의 Metal GPU API를 직접 넘겨주는 기능이 없습니다. 컨테이너 내부의 Ollama나 llama.cpp는 VRAM 할당에 실패하고, 조용히 CPU 연산으로 전환합니다. M3 Max 기준 네이티브에서 40~80 t/s가 나오던 Llama 3 8B 모델이 도커 안으로 들어가는 순간 프롬프트 평가 21.45 t/s, 토큰 생성 12.17 t/s 수준으로 곤두박질치는 이유입니다.
[가상화 컨테이너 내 CPU 폴백 발생 메커니즘]
+-----------------------------------------------------------------------+
| Docker Container (Linux Guest OS) |
| +---------------------+ |
| | Local LLM Engine | --(VRAM Allocation Req)--> [VirtGPU Missing] |
| +---------------------+ | |
| | v |
| +<--(Fallback to CPU Execution)--- [Silent slog.Debug] |
+-------------|---------------------------------------------------------+
v
[Apple Silicon Host CPU (ARM Neon/DotProd)] -> 속도 저하 발생
기본 공유 메모리(shm) 설정도 문제입니다. 기본 할당량인 64MB로는 대규모 텐서 연산 시 즉각 Bus Error를 뱉습니다. ~/.docker/daemon.json을 열어 공유 메모리를 확장하고 자원 한도를 풀어줍니다.
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
},
"experimental": false,
"default-shm-size": "8g"
}
version: '3.8'
services:
llm-inference-engine:
image: ollama/ollama:latest
container_name: local_llm_engine
shm_size: '16gb'
ipc: host
deploy:
resources:
limits:
cpus: '8.0'
memory: 24G
reservations:
cpus: '4.0'
memory: 12G
ports:
- "11434:11434"
volumes:
- ollama_storage:/root/.ollama
volumes:
ollama_storage:
컨테이너 안에서 GPU 가속을 뽑아내려면 libkrun 가상 머신 모니터와 krunkit 드라이버를 쓰는 Podman으로 갈아타야 합니다. 컨테이너 내부의 Vulkan 연산 요청을 호스트 macOS의 Metal API로 넘겨주는 방식(Virtio-GPU Venus)입니다. 네이티브 Metal 대비 75% 선까지 처리 속도를 끌어올립니다.
- Homebrew로 krunkit과 Podman을 설치합니다.
brew tap slp/krunkit && brew install krunkit podman
- libkrun 프로바이더 기반으로 8코어, 32GB RAM 머신을 띄웁니다.
export CONTAINERS_MACHINE_PROVIDER="libkrun" && podman machine init --cpus 8 --memory 32768 && podman machine start
- VM 내부에 드라이버가 붙었는지 확인합니다.
podman machine ssh "ls -la /dev/dri"
FROM fedora:40
RUN dnf -y install dnf-plugins-core && \
dnf -y copr enable slp/mesa-krunkit fedora-40-aarch64 && \
dnf -y install mesa-vulkan-drivers vulkan-loader vulkan-tools && \
dnf -y downgrade mesa-vulkan-drivers.aarch64 --repo copr:copr.fedorainfracloud.org:slp:mesa-krunkit && \
dnf clean all
ENV GGML_VULKAN=1
인프라 규정상 CPU 연산만 써야 한다면 ARMv8.4-A DotProduct와 Neon 벡터 명령어에 맞춘 Q4_0_4_4 양자화 포맷을 사용합니다. 프롬프트 평가 속도를 50.63 t/s까지 방어해 기본 도커 CPU 실행 대비 처리 시간을 절반 이하로 줄입니다.
| 런타임 구성 방식 |
프롬프트 평가 (t/s) |
토큰 생성 (t/s) |
구축 난이도 |
특징 |
| Docker Desktop (기본 CPU) |
~21.45 |
~12.17 |
낮음 |
가상화 제약으로 CPU 폴백 |
| Docker Container (ARM Q4_0_4_4) |
~50.63 |
~14.01 |
보통 |
ARM Neon 벡터 명령어 활용 |
| Podman + libkrun (Vulkan Venus) |
Native 대비 ~75% |
Native 대비 ~75% |
높음 |
컨테이너 내부 Virtio-GPU 가속 |
| Host Native Metal + Container |
Native 100% (40~80) |
Native 100% |
보통 |
호스트 엔진 직접 호출 구조 |
FastAPI 메모리 누수 추적과 끊긴 스트림 회수
오픈소스 백엔드는 비동기 태스크 상태 관리나 CPython 가비지 컬렉터 처리가 엉성한 경우가 많습니다. 요청이 누적될수록 메모리가 불어나다 프로세스가 죽어버립니다. tracemalloc으로 메모리를 먹어 치우는 지점을 찍어냅니다.
import gc
import tracemalloc
tracemalloc.start()
def log_memory_snapshot():
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("[Memory Debug] Top 5 Allocations:")
for stat in top_stats[:5]:
print(stat)
gc.collect()
프로세스 관리 단계에서 메모리를 강제로 털어내야 합니다. Gunicorn 워커가 요청 1,000개를 처리할 때마다 알아서 메모리를 반환하고 새로 뜨도록 수명 주기를 겁니다.
gunicorn \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--max-requests 1000 \
--max-requests-jitter 100 \
--timeout 120 \
--keep-alive 5 \
--preload-app \
app.main:app
--max-requests-jitter 100을 붙여 4개 워커가 동시에 죽고 살아나며 요청이 씹히는 현상을 막고, 응답 없는 작업은 120초 타임아웃으로 잘라냅니다.
사용자가 답변 도중 브라우저를 닫았을 때 비동기 제너레이터가 루프를 계속 돌면 연산 자원이 그대로 낭비됩니다. request.is_disconnected()를 체크해 연결이 끊기면 즉시 루프를 탈출시킵니다.
import asyncio
import gc
import logging
from typing import AsyncGenerator
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
app = FastAPI(title="Production AI Workspace Backend")
logger = logging.getLogger("stream_logger")
async def robust_llm_token_stream(prompt: str, request: Request) -> AsyncGenerator[str, None]:
try:
for token_idx in range(2000):
if await request.is_disconnected():
logger.warning(f"[Stream Aborted] Client disconnected at step {token_idx}.")
break
await asyncio.sleep(0.01)
yield f"event: message\ndata: {{\"id\": {token_idx}, \"text\": \"chunk_{token_idx} \"}}\n\n"
except asyncio.CancelledError:
logger.info("[Stream Cancelled] Task cancelled by ASGI server.")
raise
except Exception as err:
logger.error(f"[Stream Error] Error during streaming: {str(err)}")
raise
finally:
logger.info("[Stream Cleanup] Releasing context and triggering GC.")
gc.collect()
@app.post("/api/v1/chat/stream")
async def chat_stream_endpoint(request: Request, body: dict):
prompt = body.get("prompt", "")
if not prompt:
raise HTTPException(status_code=400, detail="Prompt string is missing.")
return StreamingResponse(
robust_llm_token_stream(prompt, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
의존성 고정과 오프라인 빌드 파이프라인
업스트림의 마이너 업데이트 하나가 로컬 컨테이너 빌드를 깨뜨리는 일은 흔합니다. SHA-256 해시를 박아 넣은 requirements.txt로 패키지 변조와 버전 충돌을 막습니다.
fastapi==0.110.0 --hash=sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
uvicorn==0.28.0 --hash=sha256:2c6a81387e0037a34ca6a7f8087796030c79eb299f116515822ee483c6604e43
pydantic==2.6.4 --hash=sha256:d82e212f451f2d6c19f5a5e3a89369322e70e1781297587786411516279f64a5
sqlalchemy==2.0.28 --hash=sha256:a611116c2bb45f8f3077e6822ec3a37b384ff6b9a84d4dd88a0e8eb876b5cf12
사내 보안망이나 비행기 안 같은 오프라인 환경에서 도커를 재빌드할 때 pip 다운로드 실패가 나지 않도록 휠(Wheel) 바이너리를 로컬 디렉토리에 미리 받아둡니다.
pip wheel --wheel-dir=./wheels_repository -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY ./wheels_repository /app/wheels_repository
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/app/wheels_repository -r requirements.txt
COPY . .
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "gunicorn.conf.py", "app.main:app"]
업스트림 깃 저장소 변경점을 통째로 pull 받다 보면 기껏 튜닝한 로컬 설정이 날아갑니다. 최적화 브랜치를 분리하고 필요한 커밋만 체리픽으로 가져옵니다.
git remote add upstream https://github.com/opensource-ai-workspace/workspace.git
git fetch upstream
git checkout -b feature/local-mac-optimization
git cherry-pick <target-commit-hash>
DB 커넥션 병목 제어와 격리 네트워크 라우팅
멀티 에이전트가 임베딩 검색, 메모리 조회, 대화 로깅을 한 번에 때려버리면 PostgreSQL 커넥션 풀이 순식간에 마릅니다. 8코어 Apple Silicon 단일 디스크 환경 기준 커넥션 계산 공식(Nconn=CPU 코어×2+스핀들 수)에 맞춰 엔진 풀을 17개 안쪽으로 제한합니다.
from sqlalchemy.ext.asyncio import create_async_engine
DATABASE_URL = "postgresql+asyncpg://postgres:password@localhost:6432/ai_workspace"
engine = create_async_engine(
DATABASE_URL,
pool_size=15,
max_overflow=5,
pool_timeout=10,
pool_recycle=300,
pool_pre_ping=True
)
에이전트가 LLM 추론 결과를 기다리는 동안 DB 세션을 붙잡고 늘어지는 현상은 PgBouncer 트랜잭션 풀링으로 끊어냅니다.
[databases]
ai_workspace = host=postgres_db port=5432 dbname=ai_workspace auth_user=postgres
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = plain
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
idle_transaction_timeout = 60
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
+-------------------------------------------------------------------------------+
| Docker Internal Isolated Network (internal: true) |
| |
| +--------------------+ +--------------------+ +-----------------+ |
| | AI Workspace App | ---> | PgBouncer Proxy | ---> | PostgreSQL DB | |
| | (FastAPI Backend) | | (Port 6432) | | (Port 5432) | |
| +--------------------+ +--------------------+ +-----------------+ |
| | | |
| | [pool_mode = transaction] |
| | [idle_tx_timeout = 60s] |
| v |
| +--------------------+ |
| | Local Vector DB | (외부 인터넷 통신 차단) |
| | (Qdrant) | |
| +--------------------+ |
+-------------------------------------------------------------------------------+
데이터 유출 위험을 없애기 위해 컨테이너 네트워크에 internal: true를 걸어 외부 클라우드 통신을 막습니다. 호스트 머신에서 직접 구동 중인 고속 Metal Ollama 런타임과는 host.docker.internal 게이트웨이로만 연결합니다.
version: '3.8'
services:
backend-app:
build: .
environment:
- DB_HOST=pgbouncer
- DB_PORT=6432
- VECTOR_DB_HOST=vector-db
- OLLAMA_HOST=http://host.docker.internal:11434
networks:
- isolated_local_net
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8000:8000"
pgbouncer:
image: edoburu/pgbouncer:latest
environment:
- DB_HOST=postgres_db
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=secret
- POOL_MODE=transaction
networks:
- isolated_local_net
depends_on:
- postgres_db
postgres_db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=ai_workspace
- POSTGRES_PASSWORD=secret
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- isolated_local_net
vector-db:
image: qdrant/qdrant:v1.9.0
volumes:
- qdrant_data:/qdrant/storage
networks:
- isolated_local_net
networks:
isolated_local_net:
driver: bridge
internal: true
volumes:
pgdata:
qdrant_data:
GPU 가상화 병목을 Podman이나 호스트 브리지로 우회하고, 워커 재활용과 DB 프록시로 수명 주기를 제어하면 M 시리즈 칩이 달린 랩톱에서도 뻗지 않는 독립 개발 환경이 완성됩니다.