almdev Технический блог

Одна локальная среда на двенадцать проектов
Каталог .local как контракт: одинаковая раскладка, одинаковые имена, одинаковые команды.

Почему рабочая конфигурация Docker вынесена из корня проекта, что лежит в .local, зачем псевдонимы сети вместо имён контейнеров и как перенести весь набор на новый проект за пятнадцать минут.

Подкатегория: Docker

Чтение
5 мин
Технологии / версии
Docker · Compose · Окружение · Symfony
6 янв 2026 · 5 мин · 1 просмотр · Ops
Центральный механизм соединяет двенадцать локальных проектов общей сетью
Docker 01/2026

Двенадцать проектов, у каждого свой docker-compose.yml в корне. Один поднимается через docker compose up, другой требует флага с файлом переопределения, третий хочет переменные из .env, которого нет в репозитории. Вернувшись к проекту через два месяца, я каждый раз тратил двадцать минут на то, чтобы его просто запустить.

Сейчас во всех двенадцати одинаковый каталог и одинаковые три команды. Это не рефакторинг ради красоты — это экономия примерно часа в неделю.

Почему конфигурация вынесена из корня

В корне лежит compose.yaml, который создал Symfony Flex при установке пакетов. Он полезен как черновик и неудобен как рабочая конфигурация: рецепты пакетов дописывают в него сервисы, которые мне не нужны. Обычная установка пакета не затирает мои изменения, а recipes:update применяет патч и оставляет конфликты на ручное разрешение. Но смешивать автоматически предложенную конфигурацию с моей рабочей средой я всё равно не хочу.

Рабочая конфигурация живёт в отдельном каталоге:

.local/
├── docker-compose.yml
├── Makefile
├── .env
├── .env.example
├── .docker/
│   ├── php/
│   │   ├── Dockerfile
│   │   └── php.ini
│   ├── nginx/conf.d/nginx.conf
│   ├── supervisor/worker.conf
│   ├── mercure/Caddyfile
│   └── crontab
└── logs/
    ├── nginx/
    └── php/

Отделение решает и вторую задачу: понятно, что является источником истины. Вопрос «а какой из двух compose тут настоящий» отпадает, потому что настоящий тот, что в .local, и это записано в первой строке инструкции.

Имя проекта из переменной

Ключевая строка во всём файле:

name: ${PROJECT_NAME}

services:
  db:
    image: postgres:alpine

PROJECT_NAME задаётся в .local/.env и определяет имя проекта в Docker и префикс томов. Явное container_name — отдельная настройка: переменная лишь делает такое имя уникальным.

Без фиксированного имени проекта два checkout с одинаковым именем корневого каталога могут попасть в один namespace Compose. С ним teamspace-app и lms-app живут рядом, у каждого свои контейнеры и тома.

Внутри Compose я обращаюсь к контейнерам по именам сервисов: docker compose exec webserver не требует container_name. Явные имена оставил там, где к контейнеру обращается внешний обратный прокси. Это осознанный компромисс: имя стабильно, но такой сервис уже нельзя масштабировать несколькими репликами через Compose.

Одинаковые имена сервисов

Во всех двенадцати проектах сервисы называются одинаково: db, redis, webserver, nginx, при необходимости mercure, worker, scheduler.

Это соглашение важнее, чем кажется. Оно означает, что любая команда работает в любом проекте без изменений:

docker compose --env-file .local/.env -f .local/docker-compose.yml exec -T webserver php bin/console

Один и тот же вызов, один и тот же скрипт развёртывания, одна и та же строка в документации. Когда сервис PHP в одном проекте называется app, в другом php, а в третьем php-fpm, каждый скрипт приходится писать заново.

Именно webserver для PHP-FPM — исторически неудачное имя, веб-сервером тут работает nginx. Менять я не стал: цена переименования в двенадцати проектах выше, чем польза от правильного слова.

Псевдонимы сети

Nginx должен знать, куда проксировать запросы к PHP. Прямая ссылка на контейнер завязывает конфигурацию на имя проекта:

fastcgi_pass teamspace-app_webserver:9000;   # так плохо

Конфигурация nginx перестаёт быть переносимой. Решается псевдонимом в сети:

  webserver:
    networks:
      internal:
        aliases:
          - php-upstream
fastcgi_pass php-upstream:9000;   # так хорошо

Теперь файл nginx одинаков во всех проектах и копируется без правок. То же с Mercure: хаб получает псевдоним mercure-upstream, и секция проксирования потока событий тоже переносится как есть.

Три сети

Сетей у проекта три, и разделение осмысленное.

internal — внутренняя, создаётся самим проектом с флагом internal: true. В ней живут база, кеш, PHP и nginx. Docker не даёт контейнерам этой сети прямого внешнего доступа; наружу приложение выходит только через сервисы, подключённые к другим сетям.

proxy-dev — общая внешняя, в неё смотрит только nginx. Через неё до проекта достаёт обратный прокси.

mail-dev — общая внешняя, в неё смотрит только PHP. Через неё письма уходят в общий перехватчик почты.

  webserver:
    networks:
      internal:
        aliases: [php-upstream]
      mail-dev: {}

  nginx:
    networks:
      internal: {}
      proxy-dev: {}

networks:
  internal:
    internal: true
  proxy-dev:
    external: true
  mail-dev:
    external: true

База в общие сети не смотрит вообще. Это не паранойя: при десятке одновременно поднятых проектов в одной сети хватает одной опечатки в строке подключения, чтобы приложение пошло в чужую базу. Оно даже подключится, если пароли совпадают.

Логи наружу

Каталоги логов монтируются в проект:

    volumes:
      - ./logs/nginx:/var/log/nginx
      - ./logs/php:/var/log/php

Читать текущие логи через docker compose logs можно и фильтровать обычными инструментами, но долговременного архива это не даёт: после удаления старого контейнера его локальная история исчезает.

Смонтированный каталог даёт обычные файлы, которые открываются любимым инструментом и переживают пересборку.

Что в репозитории, а что нет

В репозиторий едут: docker-compose.yml, Makefile, весь каталог .docker/, .env.example.

Не едут: .env с паролями, содержимое logs/, тома.

Каталог logs/ при этом должен существовать, иначе Docker создаст его владельцем root и nginx не сможет писать. Решается пустым файлом-заглушкой:

.local/logs/nginx/.gitkeep
.local/logs/php/.gitkeep

Мелочь, на которой я спотыкался трижды, пока не добавил её в шаблон.

Перенос на новый проект

У меня перенос занимает около пятнадцати минут:

  1. копирую каталог .local из ближайшего по стеку проекта;
  2. правлю PROJECT_NAME и пароли в .env;
  3. проверяю список сервисов: нужен ли Mercure и нужен ли воркер;
  4. правлю корень в конфигурации nginx, если структура каталогов отличается;
  5. добавляю запись в динамическую конфигурацию обратного прокси.

Всё. Ни Dockerfile, ни php.ini, ни конфигурация nginx правок не требуют — они одинаковы благодаря псевдонимам сети.

Чего не хватает

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

И версия PHP в Dockerfile разъезжается. В четырёх проектах 8.4, в остальных 8.3, и обновляю я их по мере того, как до них доходят руки. Это единственное место, где однообразие пока не выдержало.

#docker #compose #okruzhenie #symfony