Контейнер в Symfony: как устроен внутри и что происходит при сборке
Почему контейнер Symfony не читает конфиги на каждый запрос, как он превращается в один PHP-класс и что случается, когда вы забываете про lazy-сервисы под нагрузкой.

На проде под нагрузкой контейнер Symfony не парсит YAML и не разбирает атрибуты — он давно скомпилирован в один PHP-класс, который лежит в var/cache/prod/. Понимание того, что именно происходит между вашим services.yaml и вызовом $container->get(), отвечает на практические вопросы: почему первый запрос после деплоя медленный, почему сервис с тяжёлым конструктором тормозит весь пул воркеров и зачем нужны lazy-сервисы. Разбираем на Symfony 6.4 / 7.x — в этих версиях механизм компиляции стабилен.
Что такое контейнер и почему он компилируется
Контейнер (Symfony\Component\DependencyInjection\ContainerInterface) — это реестр сервисов и правил их создания. В режиме разработки он собирается заново при изменении конфигурации, в проде — один раз и кешируется. Ключевое слово — компиляция. Контейнер не хранит определения сервисов как данные, которые читаются в рантайме. Он превращается в сгенерированный PHP-класс, где под каждый сервис есть свой метод вида getSomeServiceService().
Посмотреть результат можно прямо на диске:
# путь зависит от переменной APP_ENV
ls var/cache/prod/
# ищите файл вида ContainerXXXXXXX.php и его подпапку
cat var/cache/prod/App_KernelProdContainer.phpВнутри — обычный PHP-класс без единого обращения к YAML-парсеру. Именно поэтому в проде вы не платите за чтение конфигов на каждый HTTP-запрос: вся работа сделана на этапе сборки.
Две фазы жизни контейнера
- Compile-time — сбор определений (
Definition), прогон compiler passes, дамп в PHP-файл. Происходит при прогреве кеша. - Runtime — загрузка готового класса, ленивое создание сервисов по мере обращения. Здесь уже нет ни
Definition, ни compiler passes.
Как определения превращаются в код
На этапе сборки каждый сервис существует как объект Definition: класс, аргументы, теги, флаги public/lazy/shared. Загрузчики (YamlFileLoader, XmlFileLoader, PhpFileLoader и загрузчик атрибутов) наполняют ContainerBuilder этими определениями.
Дальше вступают compiler passes1 — классы, реализующие CompilerPassInterface. Они трансформируют дерево определений до того, как оно превратится в код. Именно на этой фазе:
- резолвятся алиасы и параметры (
%env(DATABASE_URL)%подставляется там, где может, остальное откладывается на рантайм); - обрабатываются теги — например,
AddEventListenersPassсобирает все сервисы с тегомkernel.event_listener; - удаляются неиспользуемые private-сервисы (
RemoveUnusedDefinitionsPass); - инлайнятся сервисы, у которых один потребитель (
InlineServiceDefinitionsPass).
Скомпилированный контейнер — это не кеш ваших конфигов, а другой артефакт целиком. Ваш YAML на проде не читается никогда; читается сгенерированный по нему PHP-класс.
Порядок проходов имеет значение
Проходы выполняются в порядке приоритета внутри фиксированных этапов: beforeOptimization, optimization, beforeRemoving, removing, afterRemoving. Свой проход регистрируют в build() бандла или в Kernel:
// src/Kernel.php (Symfony 6.4 / 7.x)
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
protected function build(ContainerBuilder $container): void
{
// проход добавляется на этап optimization с приоритетом 0
$container->addCompilerPass(new CollectHandlersPass(), PassConfig::TYPE_OPTIMIZATION);
}Если ваш проход зависит от того, что уже собраны теги другого прохода, — следите за этапом и приоритетом. Неверный порядок даёт не ошибку компиляции, а тихо неполный результат: коллекция сервисов окажется пустой.
Что происходит при обращении к сервису
В рантайме $container->get('App\Service\Mailer') вызывает сгенерированный метод. Три момента, которые определяют поведение под нагрузкой:
- Shared по умолчанию. Сервис создаётся один раз на процесс-воркер и переиспользуется. Если у вас классический PHP-FPM, «процесс» живёт один запрос, но при использовании Roadrunner или FrankenPHP воркер держит сервисы между запросами — и тут утечки состояния становятся видны.
- Private по умолчанию. Начиная с Symfony 4 сервисы приватные: их нельзя достать из контейнера по имени, только через инъекцию. Публичным сервис делают явно, и таких должно быть минимум.
- Ленивое создание. Метод сервиса выполняется только при первом обращении. Но если сервис A инъектится в конструктор B, а B создаётся сразу — A создастся вместе с ним, даже если ни разу не понадобится.
Тяжёлый конструктор и lazy-сервисы
Сервис, который в конструкторе открывает соединение, читает файл или строит большую структуру, тянет это время на каждое своё создание. Если он инъектится в контроллер, вы платите за инициализацию на каждый запрос — даже в ветках, где сервис не используется.
Решение — ленивый сервис через прокси. В Symfony 6.4+ ленивость включается атрибутом или флагом, а под капотом с PHP 8.4 может использоваться нативная поддержка ленивых объектов (в более ранних PHP — ProxyManager). Пометка сервиса ленивым:
# config/services.yaml
services:
App\Service\HeavyReportBuilder:
lazy: trueПосле этого в код инъектится прокси-объект, а реальный конструктор отработает только при первом вызове метода. Проверить, что сервис действительно ленивый, можно командой:
php bin/console debug:container App\Service\HeavyReportBuilder --show-argumentsВ выводе будет строка о том, что сервис lazy.
Замеры: что и как мерить
Абсолютных цифр здесь давать нельзя — они зависят от числа бандлов, объёма конфигурации и железа. Но мерить нужно две вещи по отдельности, иначе выводы будут неверными.
| Что измеряем | Как | Когда болит |
|---|---|---|
| Время прогрева кеша (компиляция) | time php bin/console cache:warmup --env=prod | Долгий деплой, первый запрос после релиза |
| Время инициализации контейнера в рантайме | Профайлер (Blackfire) или Stopwatch в загрузке ядра | Ровный оверхед на каждом запросе |
| Число создаваемых сервисов на запрос | Symfony Profiler, вкладка запроса | Много ненужных сервисов инстанцируется |
Компиляция происходит один раз на прогрев — её оптимизируют, чтобы деплой не буксовал. Инициализация в рантайме бьёт по каждому запросу — здесь помогают lazy-сервисы и уменьшение графа обязательных зависимостей контроллера.
Где механизм ломается
Граничные случаи, о которых вспоминают уже после инцидента:
- Изменили конфиг, не прогрели кеш. В проде старый скомпилированный контейнер продолжает работать со старыми определениями. Симптом: правки в YAML «не применяются». Лечится
cache:clearв пайплайне деплоя. - Состояние в shared-сервисе под воркерным рантаймом. На FrankenPHP/Roadrunner сервис живёт между запросами. Поле, накопившее данные предыдущего запроса, утечёт в следующий. На PHP-FPM того же кода не воспроизвести — процесс умирает после запроса.
- Ленивость требует прокси. Финальный класс (
final) без интерфейса нельзя обернуть классическим прокси. С нативными ленивыми объектами PHP 8.4 ограничение мягче, но проверяйте поведение на своей версии PHP. - Публичный сервис ради тестов. Соблазн сделать сервис публичным, чтобы достать его в тесте, ломает оптимизацию удаления неиспользуемых. Правильнее — тестовый контейнер, где нужные сервисы публичны только в тестовом окружении.
Что смотреть дальше
Разберитесь с debug:container и debug:autowiring — это ваши основные инструменты для чтения собранного графа без гадания. Затем посмотрите, как ваш собственный бандл может добавить compiler pass для сбора сервисов по тегу — типовой паттерн для плагинных архитектур. Инвалидация кеша между деплоями и работа контейнера в CLI-воркерах — отдельные темы, здесь мы разбирали только устройство сборки и рантайма одного процесса.
1 Compiler pass — класс, который на этапе компиляции контейнера получает ContainerBuilder и модифицирует дерево определений сервисов до того, как оно превратится в PHP-код.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Symfony — Compiling the Container / Service Container, Symfony — Lazy Services
Частые вопросы
У меня Symfony 5.4, а не 6.4 — механизм тот же?
Правки в services.yaml на проде не применяются — что не так?
Почему первый запрос после деплоя медленный?
Сделать все сервисы lazy, чтобы ускорить запросы?
Не сработало: мой compiler pass не видит нужные сервисы с тегом.
Как быстро посмотреть, как собран конкретный сервис?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.