Понадобился свой Firecrawl — движок для скрейпинга и краулинга сайтов — как инструмент для локального ИИ-агента, конкретно Hermes. Официальный docker-compose.yaml предлагает прямо на сервере собрать четыре сервиса из исходников. Мне это не подошло: пересобирать Go, Rust и Node-тулчейн на каждое обновление ради сервиса, который снаружи просто дёргает HTTP, — это стрельба себе в ногу на каждый релиз. Хотелось оставить тот же стек, но брать уже готовые образы из GHCR. Заодно, раз уж полез в этот компоуз, закрыл сервис от интернета, оставив доступ только с самого сервера, где крутится агент.

Что здесь важно для проекта#

Firecrawl в этой задаче — не игрушка для парсинга сайтов, а отдельный сервис в контуре AI-инфраструктуры. Его нужно запускать так, чтобы он не открывал лишние порты наружу и не превращался в ещё одну точку риска.

Поэтому статья не только про команду docker compose up. Главный смысл — взять готовые образы, не собирать лишнее локально, держать порт на localhost и понимать, какой сервис к чему обращается.

Готовые образы существуют, но не все одинаковы#

В комментариях самого upstream-компоуза уже намекают на образы (# image: ghcr.io/firecrawl/firecrawl), но без тега — то есть по умолчанию это :latest, а :latest — не версия, это отдельный пункт в чек-листе безопасности (SEC-DOCKER-02). Смотрю, что реально публикуется:

docker manifest inspect ghcr.io/firecrawl/firecrawl:latest

У основного API-образа (ghcr.io/firecrawl/firecrawl) на удивление нормальные теги, с настоящими версиями: 2.11.64, 2.11.64-production, 2.11, 2, latest. А у двух его соседей, playwright-service и nuq-postgres, только latest плюс архитектурные теги. Странно видеть такой разброс внутри одного проекта, но жить можно: где есть нормальная версия — фиксирую её через переменную окружения; где версии нет — фиксирую хотя бы тег через .env, а для строгой воспроизводимости пиню digest.

services:
  api:
    image: ghcr.io/firecrawl/firecrawl:${FIRECRAWL_API_TAG:-2.11.64-production}
  playwright-service:
    image: ghcr.io/firecrawl/playwright-service:${PLAYWRIGHT_SERVICE_TAG:-latest}
  nuq-postgres:
    image: ghcr.io/firecrawl/nuq-postgres:${NUQ_POSTGRES_TAG:-latest}

Все три docker pull прошли без танцев с авторизацией — образы публичные. Дальше стек поднимается ровно как в оригинале: playwright-service, api, redis, rabbitmq, nuq-postgres — только без единого build: в файле.

Апстрим кое-что не докрутил#

Пока переносил сервисы на образы, вылезли две вещи, которые в оригинальном компоузе больше похожи на банальный недосмотр авторов, чем на осознанное решение.

Первая — у nuq-postgres, то есть очереди заданий на Postgres, нет тома для данных. Без него база живёт в слое контейнера, и docker compose down -v или простое пересоздание стирает всю очередь и историю — сотни выполненных джобов в помойку из-за одной забытой строчки в compose. Добавил хранение под /var/lib/postgresql/data, то же самое сделал для redis (--appendonly yes) и rabbitmq (/var/lib/rabbitmq) — но не именованными volume, а bind mount'ами в ./data/<сервис> рядом с самим compose-файлом. Так данные лежат на виду: их можно бэкапить обычным rsync или tar, а не выкапывать из /var/lib/docker/volumes/....

Вторая — api в depends_on ждёт redis, playwright-service и rabbitmq, но не ждёт nuq-postgres, хотя именно туда по умолчанию пишет очередь. Добавил health-check на Postgres (pg_isready) и condition: service_healthy в зависимостях api — теперь он не стартует раньше, чем реально готова база, в которую тут же полезет писать.

nuq-postgres:
  healthcheck:
    test:
      [
        "CMD-SHELL",
        "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}",
      ]
    interval: 5s
    timeout: 5s
    retries: 10
    start_period: 10s

api:
  depends_on:
    nuq-postgres:
      condition: service_healthy

Bind mount удобен, но права придётся чинить руками#

С именованным volume Docker сам разбирается с правами внутри своего хранилища. С bind mount эта работа переезжает к вам: каждый образ пишет в свою директорию от конкретного непривилегированного пользователя, а не от root, и на хосте у этой директории должны быть права именно на него — иначе будет либо EACCES, либо контейнер откатится на root-запись (см. SEC-DOCKER-10 в чек-листе: root в контейнере плюс bind mount — это запись на хост от root).

UID/GID я не угадывал. Просто поднял каждый образ и посмотрел, от какого пользователя реально работает процесс:

docker run -d --rm --name idcheck redis:7-alpine
docker exec idcheck ps aux   # PID 1 — от какого пользователя?
docker exec idcheck id redis
docker stop idcheck

Так по каждому образу нашлись три разных пары:

  • redis — UID 999, GID 1000
  • rabbitmq — UID 100, GID 101
  • nuq-postgres — UID 999, GID 999

У redis, что забавно, UID и GID не совпадают — это особенность конкретного alpine-образа, не опечатка. Перед первым запуском создаю директории и сразу отдаю их правильному владельцу:

mkdir -p data/redis data/rabbitmq data/nuq-postgres
chown 999:1000 data/redis
chown 100:101  data/rabbitmq
chown 999:999  data/nuq-postgres

Без этого шага redis/rabbitmq обычно просто промолчат и запишут, что смогут (Docker создаёт директорию от root, а процессы внутри контейнера всё равно пытаются писать под своим uid — тут поведение зависит от образа), а вот nuq-postgres жёстко откажется стартовать: Postgres при initdb требует 0700/0750 на директории данных и в этом смысле ведёт себя правильно: лучше не запуститься, чем поднять базу с неправильными правами.

На этом же шаге я словил баг, от которого реально плавился мозг: на Windows под Docker Desktop nuq-postgres с bind mount не поднимается вообще — уходит в бесконечный цикл fixing permissions... okFATAL: invalid permissions. Слой файлового шаринга Windows (Hyper-V/gRPC-FUSE) не умеет сохранять правильные unix-права на примонтированной директории, как ни chmod'и. На реальном Linux-сервере, куда всё это едет, такой прослойки нет: bind mount там ведёт себя как обычная директория с обычными правами, и chown перед первым запуском отрабатывает штатно.

Порт наружу — привычка, от которой стоит отвыкать#

api публикует порт на хост (3002:3002), и по умолчанию Docker биндит его на все интерфейсы, 0.0.0.0. На сервере с публичным IP это значит, что API торчит в интернет. Не «почти закрыт», не «ну там же Docker», а реально доступен снаружи. Тот же сценарий, что в чек-листе идёт под SEC-INFRA-02: сервис без публичного назначения не должен публиковать порт наружу.

Клиент у меня — ИИ-агент, который крутится на этом же сервере как обычный процесс, не в контейнере. Значит, самое надёжное — не биндить порт на весь мир, а слушать только loopback:

ports:
  - "${BIND_ADDRESS:-127.0.0.1}:${PORT:-3002}:${INTERNAL_PORT:-3002}"

Проверяется это не на словах:

docker port firecrawl-api-1
# 3002/tcp -> 127.0.0.1:3002

Слушателя на публичном интерфейсе просто нет. Это лучше, чем надеяться, что firewall всё поймает. curl http://127.0.0.1:3002 с самого сервера работает как обычно, агент подключается по адресу http://127.0.0.1:3002 без единой правки на своей стороне.

Если бы клиент был контейнером на этом же хосте, я бы не публиковал порт на хост вовсе, а подключил его к той же docker-сети и звал сервис по имени (http://api:3002). Но в моём случае клиент — обычный процесс, так что 127.0.0.1 достаточно и проще.

Исходящий трафик никто не трогал#

Закрытие входящего порта не имеет никакого отношения к исходящему трафику. Docker по умолчанию делает NAT для контейнеров на выход, так что api и playwright-service спокойно продолжают ходить в интернет — без этого Firecrawl был бы бесполезен, весь его смысл в том, чтобы скрейпить внешние сайты. Проверил тем же запросом, которым обычно проверяю, что стек живой:

curl -X POST http://127.0.0.1:3002/v1/scrape \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

Ответ пришёл с нормальным markdown — значит, вся цепочка api → nuq-postgres → rabbitmq → playwright-service → внешний сайт рабочая, и при этом снаружи в api до сих пор никто не попадёт.

Отдельный пункт того же чек-листа, SEC-AI-01, касается SSRF на сервисах, которые ходят по URL от имени ИИ-агента. Firecrawl для этого и существует: берёт URL, который прислал агент (а агенту его, в свою очередь, может подкинуть что угодно, хоть содержимое чужой веб-страницы), и идёт по нему. Если агент однажды попросит Firecrawl сходить на 169.254.169.254 или на внутренний адрес самого сервера, это уже не вопрос портов, а вопрос того, доверяет ли сам Firecrawl всем URL одинаково. Это вопрос к внутренней логике самого сервиса, а не к моему compose-файлу, и разбираться в ней отдельно я не стал, но держать её в голове стоит, если даёте агенту свободу подсовывать произвольные адреса — а вы, скорее всего, даёте.

Что проверить после копипасты#

  1. В образе API стоит нормальный версионный тег, а не голый latest — для playwright-service/nuq-postgres пока только latest, для строгой воспроизводимости пиньте по digest.
  2. У nuq-postgres, redis и rabbitmq есть постоянные bind mount в ./data/<сервис>, и перед первым запуском на них выставлен chown на UID/GID процесса внутри контейнера — иначе nuq-postgres откажется стартовать, а redis/rabbitmq тихо запишут не то, что нужно.
  3. api в depends_on ждёт именно ту очередь, в которую реально пишет (nuq-postgres по умолчанию, не только redis/rabbitmq).
  4. Порт api опубликован не на 0.0.0.0, а на конкретный адрес — 127.0.0.1, если клиент на этом же хосте, приватный IP, если клиент в LAN, или не опубликован вовсе, если клиент — сосед по той же docker-сети.
  5. docker port <container> показывает именно тот адрес, который вы ожидали, а не «наверное, сработало».

Если вашему проекту нужен парсинг, индексация страниц или отдельный scraping-контур, контейнер — только начало. Напишите мне в Telegram или на почту — помогу собрать Firecrawl или похожий сервис с понятными границами по сети, очередям, хранилищу, доступам и нагрузке, чтобы он был управляемым, а не очередной чёрной коробкой.