Я долго откладывал это. Запустить модель локально — не проблема, Ollama справляется за пять минут. Но когда понадобилось выставить её как нормальный API-эндпоинт с авторизацией и TLS, я обнаружил, что готовых разборов почти нет. Либо "запусти ollama run llama3", либо сразу enterprise Kubernetes. Середины — ноль.
Поэтому я потратил два вечера, наломал дров, и теперь расскажу как это сделать нормально.
Что мы строим и зачем
Цель простая: сервер с одной или несколькими моделями, доступный по HTTPS, с базовой защитой от случайных гостей. Ничего сложного — но дьявол, как обычно, в деталях.
Мой стек: Ubuntu 22.04, Docker Compose, Ollama как бэкенд, nginx как реверс-прокси. Модель — Mistral 7B, потому что она хорошо работает даже без GPU, если у тебя есть нормальная оперативка.
Первая моя ошибка была в том, что я пытался запустить Ollama прямо внутри Docker-контейнера с GPU-проброской. Час ушёл на nvidia-container-toolkit, половина — на выяснение почему nvidia-smi работает, а Ollama молчит. В итоге оказалось проще: Ollama на хосте, nginx в Docker, они разговаривают через сеть хоста. Не идеально архитектурно, зато работает.
Шаг первый: Ollama на хосте
Устанавливаю Ollama стандартным способом:
curl -fsSL https://ollama.com/install.sh | sh
По умолчанию Ollama слушает на 127.0.0.1:11434 — и это хорошо, пусть так и остаётся. Наружу её выставлять не надо, это задача nginx.
Скачиваю модель:
ollama pull mistral
Проверяю что всё живое:
curl http://localhost:11434/api/generate -d '{
"model": "mistral",
"prompt": "ping",
"stream": false
}'
Если в ответ пришёл JSON с полем response — отлично, едем дальше.
Один нюанс: по умолчанию Ollama запускается как systemd-сервис, но его конфиг надо подправить. Открываю:
sudo systemctl edit ollama
Добавляю в секцию [Service]:
[Service]
Environment="OLLAMA_HOST=127.0.0.1:11434"
Environment="OLLAMA_KEEP_ALIVE=5m"
KEEP_ALIVE=5m означает что модель выгрузится из памяти через 5 минут простоя. Без этого она висит вечно — что хорошо для производительности, но плохо если памяти мало.
Шаг второй: nginx как реверс-прокси
Вот где я потратил больше всего времени. Дело в том что Ollama — это не просто HTTP-сервис, там есть стриминг. Если неправильно настроить буферизацию, клиент будет ждать пока вся генерация не закончится, и только потом получит ответ разом. Это убивает весь смысл стриминга.
Мой docker-compose.yml:
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
certbot:
image: certbot/certbot:latest
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
extra_hosts: host.docker.internal:host-gateway — это ключевая строчка. Без неё nginx внутри контейнера не может достучаться до Ollama на хосте.
Конфиг для виртуального хоста nginx/conf.d/ollama.conf:
server {
listen 443 ssl;
server_name llm.example.com;
ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Базовая авторизация
auth_basic "LLM API";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
location /api/ {
proxy_pass http://host.docker.internal:11434;
proxy_http_version 1.1;
# Критично для стриминга
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name llm.example.com;
return 301 https://$host$request_uri;
}
proxy_buffering off — вот та строчка, из-за которой я час смотрел на зависший курсор. Без неё nginx накапливал весь ответ в буфер и отправлял разом.
Файл паролей создаю на хосте:
htpasswd -c ./nginx/conf.d/.htpasswd myuser
Шаг третий: TLS через Certbot
Перед тем как поднять nginx с SSL, нужно получить сертификат. Делаю это через временный standalone-режим:
docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
-d llm.example.com \
--email you@example.com \
--agree-tos
Первый раз я облажался — пытался получить сертификат пока nginx не был запущен. Webroot не работает без работающего веб-сервера. Правильная последовательность: сначала поднять nginx с конфигом только для порта 80 (без SSL-блока), получить сертификат, потом добавить SSL-конфиг и перезапустить.
Автообновление добавляю в cron хоста:
0 3 * * * docker compose -f /opt/llm-api/docker-compose.yml run --rm certbot renew --quiet && docker compose -f /opt/llm-api/docker-compose.yml exec nginx nginx -s reload
Шаг четвёртый: проверяем что получилось
curl https://llm.example.com/api/generate \
-u "myuser:mypassword" \
-d '{"model": "mistral", "prompt": "Hello", "stream": false}'
Если хочу стриминг — убираю "stream": false и смотрю как токены приходят по одному. Это приятный момент после двух вечеров возни.
Полезная проверка — убедиться что без пароля сервер возвращает 401, а не 200. Звучит очевидно, но я один раз настраивал авторизацию не в том блоке конфига и она тихо не работала.
curl -I https://llm.example.com/api/tags
# Должен вернуть: HTTP/2 401
Несколько вещей которые я бы сделал иначе
Когда поднимал в первый раз, пропустил лимиты запросов. Ollama честно начинает обрабатывать всё что к ней приходит, и если кто-то (или что-то) решит заспамить эндпоинт — модель просто встанет колом. В nginx лечится через limit_req_zone:
# В секции http{}
limit_req_zone $binary_remote_addr zone=llm:10m rate=5r/m;
# В location
limit_req zone=llm burst=3 nodelay;
5 запросов в минуту — это нормально для интерактивного использования, но можно подстроить под свои нужды.
Ещё момент: Ollama умеет работать с несколькими моделями одновременно, но по умолчанию загружает только одну. Если нужно больше — параметр OLLAMA_MAX_LOADED_MODELS. Мне пока хватает одной.
Логи Ollama по умолчанию идут в journald — смотреть через journalctl -u ollama -f. Первые пару дней я их мониторил, чтобы понять насколько быстро модель обрабатывает запросы и не падает ли.
Всё это занимает от силы час-полтора если делать по порядку. Большую часть моих двух вечеров съело то, что я не делал по порядку.
