Понадобился свой 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 1000rabbitmq— UID 100, GID 101nuq-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... ok → FATAL: 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-файлу, и разбираться в ней отдельно я не стал, но держать её в голове стоит, если даёте агенту свободу подсовывать произвольные адреса — а вы, скорее всего, даёте.
Что проверить после копипасты#
- В образе API стоит нормальный версионный тег, а не голый
latest— дляplaywright-service/nuq-postgresпока толькоlatest, для строгой воспроизводимости пиньте по digest. - У
nuq-postgres,redisиrabbitmqесть постоянные bind mount в./data/<сервис>, и перед первым запуском на них выставленchownна UID/GID процесса внутри контейнера — иначеnuq-postgresоткажется стартовать, аredis/rabbitmqтихо запишут не то, что нужно. apiвdepends_onждёт именно ту очередь, в которую реально пишет (nuq-postgresпо умолчанию, не толькоredis/rabbitmq).- Порт
apiопубликован не на0.0.0.0, а на конкретный адрес —127.0.0.1, если клиент на этом же хосте, приватный IP, если клиент в LAN, или не опубликован вовсе, если клиент — сосед по той же docker-сети. docker port <container>показывает именно тот адрес, который вы ожидали, а не «наверное, сработало».
Если вашему проекту нужен парсинг, индексация страниц или отдельный scraping-контур, контейнер — только начало. Напишите мне в Telegram или на почту — помогу собрать Firecrawl или похожий сервис с понятными границами по сети, очередям, хранилищу, доступам и нагрузке, чтобы он был управляемым, а не очередной чёрной коробкой.



