Когда у тебя один проект и один сервер — всё просто: авторизовался на сервере по SSH, поставил nginx, PHP, настроил конфиги, готово. Но как только проектов становится больше — это превращается в рутину, которая пожирает время и плодит ошибки.
Я ловил себя на том, что каждый раз делаю одно и то же:
Устанавливаю nginx из официального репозитория (а не из дистрибутива, чтобы иметь последнюю версию)
Создаю PHP-FPM пул для каждого сайта с отдельным Unix-сокетом
Настраиваю OPcache, лимиты памяти, время выполнения...
Настраиваю виртуальные хосты nginx с правильными заголовками безопасности
Устанавливаю Composer, Node.js, Yarn
Создаю структуру директорий для Laravel
Настраиваю cron для
artisan schedule:run
И каждый раз — вручную. Каждый раз — с риском что-то забыть или сделать немного не так, как на соседнем сервере.
Почему Ansible, а не Docker?
Хороший вопрос. Docker отлично подходит для изоляции и переносимости приложений, но у многих небольших проектов и заказчиков есть требования или ограничения, при которых контейнеры избыточны или неудобны: VDS с ограниченными ресурсами, хостинг без поддержки Docker, проекты где нужен прямой доступ к "голому" серверу. Кроме того, поддерживать legacy-серверы без Docker — реальность для многих разработчиков.
Ansible же работает с любым Linux-сервером через SSH. Никакого агента, никакой инфраструктуры — только Python на целевом хосте.
Что в итоге получилось
Роль nginx_laravel разворачивает полноценную Laravel-среду одной командой:
ansible-playbook site.ymlЧто происходит под капотом:
nginx устанавливается из официального репозитория nginx.org (не из apt/dnf дистрибутива). Версия автоматически определяется со страницы загрузок — берётся последняя стабильная ветка (чётный минорный номер: 1.26.x, 1.28.x и т.д.).
PHP 8.4 (или любая версия >= 8.2) ставится из репозитория ondrej/php на Debian/Ubuntu или Remi на CentOS/AlmaLinux. Для каждого сайта создаётся отдельный PHP-FPM пул с изолированным Unix-сокетом.
Несколько сайтов на одном сервере — в конфиге просто список, остальное делает роль.
Сайт по умолчанию (catch-all) всегда присутствует: показывает информацию о сервере, версии ПО, IP клиента. Полезно для проверки того, что сервер вообще жив.
Безопасность из коробки: заголовки X-Frame-Options, X-Content-Type-Options, Referrer-Policy, server_tokens off, закрытый доступ к .env, .git, vendor.
GitLab CI/CD: в репозитории уже есть пайплайн — yaml-lint, ansible-lint, syntax-check, деплой и HTTP smoke-тест каждого сайта после деплоя.
Один из принципов, который я хотел заложить с самого начала: любое изменение в репозитории должно быть проверено автоматически, а деплой на сервер — только если все проверки прошли.
Пайплайн состоит из трёх стадий: validate → deploy → verify.
validate ──────────────────────────────────────────────────────┐
yaml-lint ──┐ │
lint ├── все три параллельно │
syntax-check ─┘ │
▼
deploy ────────────────────────────────────────────────────────┐
deploy:production (только если validate прошёл) │
▼
verify ────────────────────────────────────────────────────────┘
verify:sites (только если deploy прошёл)
Стадия Validate (Предпроверка)
Запускается на каждый пуш в main и на каждый merge request. Три задачи выполняются параллельно.
yaml-lint — проверяет синтаксис всех .yml и .yaml файлов в репозитории. Это первая линия обороны: опечатка в отступе или забытое двоеточие не уйдёт дальше этой проверки.
yamllint --config-file .yamllint.yml --format colored $(find . -name '*.yml' -o -name '*.yaml')ansible-lint — статический анализатор специально для Ansible. Он знает об идиомах и антипаттернах: использование shell там, где можно обойтись command, опасные pipe-команды без pipefail, шаблоны Jinja2 не в конце имени задачи, захардкоженные пароли и прочее. Профиль production — самый строгий, он требует соблюдения всех правил.
Несколько примеров того, что он поймал у меня в процессе разработки роли:
Имя задачи
Install PHP {{ php_version }} extensions— шаблон посередине строки, нарушает правилоname[template]curl ... | bash -безset -o pipefail— правилоrisky-shell-pipeХардкод
www-dataв задачах вместо переменной — логическая ошибка, которую lint не поймает, но code review — да
syntax-check — прогоняет Ansible через разбор плейбука без реального подключения к серверу:
ansible-playbook site.yml --syntax-check --inventory inventory/hosts.ymlЗдесь выявляются ошибки в структуре плейбука, несуществующие переменные в определённых контекстах, некорректные модули.
Стадия Deploy (Публикация)
Запускается только при пуше в main — не на MR. Это принципиально: merge request'ы проходят только валидацию, реальный деплой происходит только когда код принят в основную ветку.
resource_group: productionresource_group гарантирует, что одновременно может быть только один активный деплой на production. Если два пуша попали в main почти одновременно — второй деплой дождётся завершения первого.
Что делает джоб:
1. SSH-ключ. GitLab хранит приватный ключ в переменной SSH_PRIVATE_KEY. Джоб запускает ssh-agent, загружает ключ и добавляет хосты в known_hosts через ssh-keyscan. Ключ нигде не пишется на диск в открытом виде — только в памяти агента.
2. ansible-vault (опционально). Если в репозитории есть зашифрованные секреты, переменная ANSIBLE_VAULT_PASSWORD позволяет их расшифровать. Пароль пишется во временный файл, удаляется в after_script — даже если плейбук упадёт.
3. Плейбук. Запускается с флагом --diff — в логах CI видно, что именно изменилось на сервере: какие строки добавились в конфиги, какие файлы созданы. Это важно при разборе инцидентов.
ansible-playbook site.yml --inventory inventory/hosts.yml --diffСтадия Verify (Пост-проверка)
Самая интересная стадия. Запускается после успешного деплоя и проверяет, что сайты реально отвечают.
Написана на чистом Python без внешних зависимостей (только pyyaml). Скрипт читает inventory/hosts.yml, достаёт список хостов и сайтов — и для каждого делает HTTP-запрос.
Два типа проверок:
Сайт по умолчанию — запрос к IP-адресу сервера без заголовка Host. Должен вернуть 2xx/3xx. Это проверяет, что nginx запущен и дефолтный catch-all сайт живой (у нас он отдаёт PHP-страницу с инфо о сервере).
Именованные сайты — запрос к IP-адресу сервера, но с заголовком Host: домен. Это имитирует реальный запрос от браузера через DNS, без необходимости иметь настроенный DNS в CI-окружении. Для HTTPS-сайтов — запрос через https:// с отключённой проверкой сертификата (поддержка self-signed).
def probe(url, host_header=None, ssl_ctx=None):
headers = {"Host": host_header} if host_header else {}
req = urllib.request.Request(url, headers=headers)
...
Если хотя бы один сайт вернул 4xx/5xx или не ответил — джоб падает, пайплайн красный. В логах видно конкретно какой сайт и какой код вернул.
Почему это важно
До пайплайна типичная история выглядела так: изменил шаблон nginx, запушил, через 10 минут проверяешь и оказывается что сайт лежит. Это тормозит работу. С пайплайном — если изменение сломало nginx-конфиг, syntax-check поймает это ещё до деплоя. Если деплой прошёл, но что-то не так запустилось — verify:sites покажет это сразу, пока разработчик ещё смотрит на пайплайн.
Весь цикл от пуша до подтверждения что сайты живы — около 3–4 минут.
Что мне кажется важным в дизайне
Конфигурация как данные, а не код. Вся конфигурация сайтов — это просто YAML в инвентаре:
laravel_sites:
- domain: "myapp.ru www.myapp.ru"
root: "/var/www/myapp"
ssl_enabled: true
ssl_email: "admin@myapp.ru"
env: productionДобавляешь сайт — добавляешь элемент в список. Никаких дополнительных файлов, никакой магии.
Идемпотентность. Можно запускать роль сколько угодно раз — на уже настроенном сервере она только проверит, что всё в порядке, и ничего не тронет. Это важно для CI/CD: деплой выглядит одинаково что в первый раз, что в сотый.
Несколько доменов для одного сайта. domain: "site.ru www.site.ru" — первый домен используется для сокета, логов, сертификата; nginx обслуживает все.
Кроссплатформенность. Изначально роль была только для Debian/Ubuntu. Потом появились серверы на AlmaLinux — и пришлось добавить поддержку RedHat-семейства. Рефакторинг свёлся к тому, чтобы вынести OS-специфичные переменные (пути к сокетам, имена сервисов, пакеты) в отдельные файлы vars/Debian.yml и vars/RedHat.yml. Остальной код остался тем же.
Что ещё не сделано
HTTPS через Let's Encrypt (certbot) — в конфиге уже есть переменные, шаблон nginx готов, но автоматическое получение сертификата через certbot пока нужно делать вручную
Поддержка PostgreSQL/MySQL как опции при настройке сервера
Мониторинг через php-fpm status page
автоматическая публикация кода сайта из репозитория, при указании ссылки на репозиторий. Возможно реализую через webhook после успешного развертывания сервера и сайта.
Такая роль — это не серебряная пуля и не замена пониманию того, как работает nginx и PHP-FPM. Это инструмент, который убирает рутину и делает настройку воспроизводимой. Если у тебя несколько Laravel-проектов на VDS и ты каждый раз настраиваешь сервер вручную — попробуй. В худшем случае прочитаешь код и найдёшь что-то полезное для себя.
Исходники: GitOps / ansible-web-server · GitLab




Станьте первым, кто прокомментирует эту запись