Инженерные настройки для спасения локальных LLM, умирающих в Docker на Mac
Когда вы скачиваете код открытого ИИ-воркспейса и запускаете его в Docker на Mac, вы сразу же сталкиваетесь с проблемой. У вас явно чип серии M, но кулеры просто шумно крутятся, а скорость составляет ужасные 10 токенов в секунду. Это происходит потому, что уровень виртуализации Docker Desktop не может передать Apple Silicon GPU (Metal) внутрь контейнера, из-за чего всё скатывается к чистым вычислениям на CPU.
Если не устранить этот узкое место и запустить бэкенд на FastAPI вместе с локальной векторной БД, контейнер упадет из-за утечки памяти и исчерпания соединений. Вот четыре настройки, которые нужно переписать, чтобы превратить код, скачанный ради звезд на GitHub, в надежное решение для локального использования.
Причины отката на CPU и обходные пути аппаратного ускорения
Docker Desktop на macOS работает поверх легковесной виртуальной машины Linux. В нем нет функции прямой передачи API хостового Metal GPU внутрь контейнера с гостевой ОС Linux. Ollama или llama.cpp внутри контейнера не могут выделить VRAM и тихо переключаются на вычисления силами CPU. Именно поэтому модель Llama 3 8B, выдававшая на М3 Max в нативном режиме 40–80 т/с, при попадании в Docker падает до 21.45 т/с при оценке промпта и 12.17 т/с при генерации токенов.
`
[Механизм отката на 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) по умолчанию тоже создают проблемы. Стандартный лимит в 64 МБ мгновенно вызывает ошибку шины (Bus Error) при масштабных тензорных вычислениях. Откройте файл ~/.docker/daemon.json, чтобы увеличить объем общей памяти и снять ограничения по ресурсам.
`json
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
},
"experimental": false,
"default-shm-size": "8g"
}
`
`yaml
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 внутри контейнера, нужно перейти на Podman, использующий монитор виртуальных машин libkrun и драйвер krunkit. Этот подход пробрасывает запросы Vulkan-вычислений из контейнера в Metal API хоста macOS (Virtio-GPU Venus). Это позволяет поднять скорость обработки до 75% от нативной Metal.
- Установите krunkit и Podman через Homebrew.
brew tap slp/krunkit && brew install krunkit podman
- Запустите машину на базе провайдера libkrun с 8 ядрами и 32 ГБ ОЗУ.
export CONTAINERS_MACHINE_PROVIDER="libkrun" && podman machine init --cpus 8 --memory 32768 && podman machine start
- Убедитесь, что драйверы подключены внутри ВМ.
podman machine ssh "ls -la /dev/dri"
`dockerfile
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, применяйте формат квантования Q4_0_4_4, оптимизированный под векторные инструкции ARMv8.4-A DotProduct и Neon. Это удерживает скорость оценки промпта на уровне 50.63 т/с, сокращая время обработки более чем вдвое по сравнению со стандартным запуском CPU в Docker.
| Конфигурация рантайма |
Оценка промпта (т/с) |
Генерация токенов (т/с) |
Сложность настройки |
Особенности |
| 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) |
~75% от нативной |
~75% от нативной |
Высокая |
Ускорение Virtio-GPU внутри контейнера |
| Host Native Metal + Container |
Нативные 100% (40–80) |
Нативные 100% |
Средняя |
Прямой вызов хостового движка |
Отслеживание утечек памяти в FastAPI и восстановление оборванных стримов
Бэкенды с открытым исходным кодом часто страдают небрежным управлением состоянием асинхронных задач или некорректной работой сборщика мусора CPython. По мере накопления запросов память раздувается, пока процесс не завершится аварийно. Для поиска точек утечки памяти используется tracemalloc.
`python
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 так, чтобы они автоматически освобождали память и перезапускались после обработки каждых 1000 запросов.
`bash
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() позволяет немедленно выйти из цикла при разрыве соединения.
`python
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"
}
)
`
Закрепление зависимостей и автономный конвейер сборки
Одно минорное обновление в апстриме часто ломает сборку локального контейнера. Использование файла requirements.txt с жестко прописанными SHA-256 хэшами защищает от подмены пакетов и конфликтов версий.
`plaintext
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
`
Чтобы при пересборке Docker в закрытом контуре безопасности или офлайн-среде (например, в самолете) не возникали сбои загрузки через pip, колечные (Wheel) бинарники заранее сохраняются в локальную директорию.
`bash
pip wheel --wheel-dir=./wheels_repository -r requirements.txt
`
`dockerfile
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) изменений из репозитория апстрима целиком часто приводит к затиранию с таким трудом настроенной локальной конфигурации. Для этого выделяют отдельную ветку оптимизации и переносят нужные коммиты точечно через cherry-pick.
`bash
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
`
Управление узкими местами соединений с БД и изоляция сетевой маршрутизации
Если мультиагентная система одновременно запускает поиск по эмбеддингам, обращение к памяти и логирование диалогов, пул соединений PostgreSQL исчерпывается мгновенно. Исходя из формулы расчета пула для однодисковой среды Apple Silicon с 8 ядрами (Nextconn=extCPUядраimes2+extколичествошпинделей), размер пула движка ограничивается до 17 соединений.
`python
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, пресекают с помощью пулинга транзакций PgBouncer.
`ini
[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.
`yaml
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 или хостового моста (bridge), а также управление жизненным циклом через переиспользование воркеров и проксирование БД позволяют создать стабильную изолированную среду разработки, которая не падает даже на ноутбуках с чипами серии M.