Студия разработки сайтов и приложений

Netspark.ru

Платформа для ботов в Telegram

Ботопотамы

Виртуализация локальной разработки, docker-compose и traefik

Когда-то давно описывал довольно простой способ виртуализации проектов, над которыми работаю, чтобы было удобно переключаться и они друг другу не мешали. Прошло уже 7 лет и способ конечно эволюционировал. Когда-то использовать отдельные контейнеры, запускаемые по docker run было быстрее и удобнее и поэтому я докерфайлы и docker-compose-файлы не писал. Но прогресс конечно добрался до меня в этом вопросе и победил. Теперь я для локальной разработки тоже использую docker compose.

Инструментами со всякими пресетами типа lando или ddev я, тем не менее, стараюсь не пользоваться. В основном потому что когда с ними сталкивался, они нормально работали — до тех пор пока не надо было что-то в них поменять или сделать нестандартно. После чего начинался вынос мозга, иногда такой, что проще было самому пересобрать сразу. Кроме того, если собирать «чистые» окружения, есть шанс лучше понять, как это всё работает, а если пользоваться lando, forge, ddev и прочими — наоборот.

Итак, чем docker compose лучше.

Во-первых, конечно, все настройки на виду, в файликах. Это значит, можно передать файлик кому-нибудь, и он легко пересоберет это окружение у себя. Файлы нагляднее — всегда видно что и где ты меняешь. Файлы можно, в конце концов, в git отправить.

Во-вторых, поддерживаемость. Отдельные образы, которыми я пользовался, были по-своему прекрасны — но только до тех пор пока не оказывалось, что образ собирался когда-то под Debian 9 и теперь на него нужно поставить PHP 8.5. Не то чтобы это суперсложно, но время занимает и, даже если делать редко, надоедает вусмерть. Ну а с файловым подходом достаточно версию PHP и пакетов в настройках поменять, и оно само пересоберется.

В-третьих, с развитием AI-инструментов стало очевидно, что просить нейроколлегу поработать с docker-compose.yml и Dockerfile гораздо проще, чем просить его писать инструкции по апгрейду старого контейнера из консоли. И в целом Claude и товарищи хорошо помогают разобраться, если с докер-окружением не всё понятно.

А чем старый способ был изначально лучше?

В первую очередь тем, что он каждый мой новый dev-контейнер вешал на отдельный IP. 172.17.0.2, 172.17.0.3 и т.д. И контейнеры поэтому между собой не конфликтовали, достаточно было помнить (в моменте), что на каком IP сейчас висит, и все работало. В то время как с новыми контейнерами на nginx c локальными доменами хочется обращаться к dev-сайтам как-то, например, site1.local, site2.local, netspark.local и так далее — и не запоминать уже айпишники. Но тогда nginx-ы разных проектов начинают конфликтовать между собой и не работать параллельно.

А желание поднимать несколько проектов параллельно, опять же, в связи с развитием нейросетей, есть всё чаще: в одном над задачей думает машина, в другом — ты. В одном вносишь правку, в другом test suite 20 минут запускается. Поэтому какое-то время я совмещал подходы. Старые проекты традиционно вёл на одиночных контейнерах ручной работы, а новые уже поднимал на docker compose. Вот пример docker-compose.yml для нового проекта на Laravel:

  • nginx,
  • PHP 8.3,
  • MySQL,
  • Redis для кэша, очередей и дедубликации,
  • Supervisor чтобы horizon и reverb бегали,
  • Horizon для управления очередями,
  • Reverb для вебсокетов,
  • Mailhog для тестовой ловли почты
  • и самоподписанные сертификаты, чтобы работало по https.
services:
  app:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site
    restart: unless-stopped
    volumes:
      - .:/var/www/html
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - MAIL_HOST=mailhog
      - TZ=Europe/Moscow
      - MYSQL_DISABLE_SSL_VERIFY=true
      - REVERB_HOST=reverb
      - REVERB_PORT=8080
      - REVERB_SCHEME=http
    depends_on:
      - mysql
      - redis
      - mailhog
    networks:
      - laravel_site_network

  webserver:
    build:
      context: .
      dockerfile: ./build/nginx/Dockerfile.local
    container_name: laravel_site_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - .:/var/www/html
      - ./build/nginx/conf.d/app.local.conf:/etc/nginx/conf.d/app.conf:ro
    environment:
      - TZ=Europe/Moscow
    depends_on:
      - app
      - reverb
    networks:
      - laravel_site_network

  mysql:
    image: mysql:8.0
    container_name: laravel_site_mysql
    restart: unless-stopped
    command: --require_secure_transport=OFF
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      TZ: Europe/Moscow
    volumes:
      - mysql_laravel_site_data:/var/lib/mysql
    ports:
      - "3306:3306"
    networks:
      - laravel_site_network

  redis:
    image: redis:alpine
    container_name: laravel_site_redis
    restart: unless-stopped
    environment:
      - TZ=Europe/Moscow
    networks:
      - laravel_site_network

  mailhog:
    image: mailhog/mailhog
    container_name: laravel_site_mailhog
    restart: unless-stopped
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI
    networks:
      - laravel_site_network

  reverb:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site_reverb
    restart: unless-stopped
    command: php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080 --debug # чтобы в лог отладочные сообщения падали
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - TZ=Europe/Moscow
      - REVERB_SERVER_HOST=0.0.0.0
      - REVERB_SERVER_PORT=8080
    volumes:
      - .:/var/www/html
    depends_on:
      - mysql
      - redis
    networks:
      - laravel_site_network

  supervisor:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site_supervisor
    restart: unless-stopped
    command: supervisord -n -c /etc/supervisor/supervisord.conf
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - TZ=Europe/Moscow
      - MYSQL_DISABLE_SSL_VERIFY=true
      - REVERB_HOST=reverb
      - REVERB_PORT=8080
      - REVERB_SCHEME=http
    volumes:
      - .:/var/www/html
      - ./build/supervisor:/etc/supervisor/conf.d
    depends_on:
      - app
      - mysql
      - redis
      - reverb
    networks:
      - laravel_site_network

  cron:
    build:
      context: .
      dockerfile: ./build/app/Dockerfile
    container_name: laravel_site_cron
    restart: unless-stopped
    command: cron-start
    volumes:
      - .:/var/www/html
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - TZ=Europe/Moscow
      - MYSQL_DISABLE_SSL_VERIFY=true
    depends_on:
      - app
      - mysql
      - redis
    networks:
      - laravel_site_network

volumes:
  mysql_laravel_site_data:

networks:
  laravel_site_network:
    driver: bridge

А вот докерфайл и настройки nginx:

FROM php:8.3-fpm

RUN apt-get update && apt-get install -y \
    git \
    cron \
    curl \
    libcurl4-openssl-dev \
    libpng-dev \
    libzip-dev \
    libonig-dev \
    libxml2-dev \
    libssl-dev \
    unzip \
    supervisor \
    mariadb-client \
    zlib1g-dev

RUN pecl install -o -f redis \
    && rm -rf /tmp/pear \
    && docker-php-ext-enable redis

RUN docker-php-ext-configure zip --with-zip \
    && docker-php-ext-install -j$(nproc) \
    pdo_mysql \
    pcntl \
    zip \
    gd \
    bcmath \
    curl \
    mbstring

RUN docker-php-ext-enable opcache

# Кастомные PHP-настройки
COPY ./build/app/php-local.ini /usr/local/etc/php/conf.d/php-local.ini

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Копируем crontab
COPY ./build/app/entrypoints/cron-start.sh /usr/local/bin/cron-start
RUN chmod +x /usr/local/bin/cron-start

# Entrypoint для app-контейнера: права на storage + запуск php-fpm
COPY ./build/app/entrypoints/app-start.sh /usr/local/bin/app-start
RUN chmod +x /usr/local/bin/app-start

WORKDIR /var/www/html

CMD ["app-start"]

В /build/php-local.ini можно класть частичные оверрайды для параметров PHP, например memory_limit или max_upload_size.

Конфиг /build/nginx/conf.d/app.local.conf:

server {
    listen 80;
    server_name laravel_site.local;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl default_server;

    server_name laravel_site.local;

    ssl_certificate     /etc/ssl/local/cert.pem;
    ssl_certificate_key /etc/ssl/local/key.pem;

    root /var/www/html/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;

    # WebSocket proxy for Laravel Reverb (/app/{key} — pusher protocol)
    location /app/ {
        proxy_pass http://reverb:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

/build/nginx/Dockerfile.local для генерации самоподписанных сертификатов (запускается при старте контейнера):

FROM nginx:alpine

RUN apk add --no-cache openssl && \
    mkdir -p /etc/ssl/local && \
    openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
      -keyout /etc/ssl/local/key.pem \
      -out /etc/ssl/local/cert.pem \
      -subj "/C=RU/ST=Local/L=Local/O=Dev/CN=laravel_site.local"

а вот /build/entrypoints/app-start.sh

#!/bin/bash
set -e

chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

exec php-fpm

Но проблема в том, что два проекта с такой конфигурацией одновременно не заведутся: nginx первого перетащит одеяло на себя и второму не даст работать. Можно конечно вытащить nginx за пределы отдельных проектов и сделать для всех общий конфиг. Но на первый взгляд это выглядит слишком ad hoc и интуитивно так делать не хочется. Хочется какого-то plug'n'play, чтобы можно было для очередного проекта прописать docker-compose.yml по неким правилам, и он бы сам встал рядом с остальными. В общем, собрался с мыслями и завел для этих локальных проектов

Один общий контейнер на traefik.

traefik действует как обратный прокси. Запускается и распределяет входящий трафик между включенными локальными контейнерами. Причем для добавления новых проектов не нужно вносить изменения в конфиг traefik, достаточно указать определенные теги в docker-compose.yml проекта. Ну и локальный домен в /etc/hosts прописать, чтобы резолвился.

docker-compose.yml для traefik:

services:
  traefik:
    image: traefik:v3
    container_name: traefik
    restart: unless-stopped
    command:
      - --api.insecure=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=traefik_proxy
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      # Глобальный редирект HTTP → HTTPS
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      # Игнорировать самоподписанные сертификаты на бэкендах
      - --serversTransport.insecureSkipVerify=true
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"   # Traefik dashboard
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - traefik_proxy

networks:
  traefik_proxy:
    external: true

скрипт запуска сети и контейнера traefik одной командой:

#!/usr/bin/env bash
set -e

docker network create traefik_proxy 2>/dev/null || echo "Network traefik_proxy already exists"
docker compose up -d
echo "Traefik is running. Dashboard: http://localhost:8080"

Прикольная софтинка, и в продакшне я уже сталкивался тоже, когда полсотни проектов на хосте крутятся в своих контейнерах, по несколько штук на каждый, а traefik их проксирует наружу. У него даже своя веб-панелька прикольная есть, на скриншоте сверху можно посмотреть.

Изменений в docker-compose.yml проекта, который я приводил выше, нужно минимальное количество:

# секция expose заменяет ports в webserver
expose:  
     - "443"
# добавляем сеть traefik_proxy и теги для traefik
networks:
      - laravel_site_network
      - traefik_proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.laravel_site-secure.rule=Host(`laravel_site.local`)"
      - "traefik.http.routers.laravel_site-secure.entrypoints=websecure"
      - "traefik.http.routers.laravel_site-secure.tls=true"
      - "traefik.http.services.laravel_site.loadbalancer.server.port=443"
      - "traefik.http.services.laravel_site.loadbalancer.server.scheme=https"
      - "traefik.docker.network=traefik_proxy"

Теперь я могу включать одни проекты через docker compose up -d, выключать другие с docker compose down, а traefik автоматически видит роуты и распределяет запросы. При этом сохранена гибкость реконфигурации проекта, можно перенастраивать, можно добавлять в проект контейнеры и т.д.

Жить стало немножко удобнее.

Обсуждение

Чтобы обсудить заметку, написать комментарий, или просто связаться, заходите в Телеграм-канал. У нас весело и всем рады!

Также меня можно найти в Хвиттере, VC.ru, Дзене, или Тенчате. А если вы на парковке, присоединяйтесь к каналу в Max!