# Symfony và Docker năm 2026: Môi trường phát triển và triển khai Production > Quy trình Symfony Docker hoàn chỉnh cho năm 2026: môi trường phát triển FrankenPHP, image production nhiều tầng, chế độ worker, secret mã hóa, tối ưu image và triển khai không gián đoạn kèm migration. - Published: 2026-06-25 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, docker, frankenphp, php, deployment, devops - Reading time: 10 min --- Hướng dẫn Symfony Docker này dựng một quy trình container hoàn chỉnh: một môi trường phát triển cục bộ có thể tái lập và một image production đã được gia cố, cả hai đều xoay quanh FrankenPHP và Symfony 7.4 LTS. Việc chạy Symfony trong container xóa bỏ khoảng cách kinh điển về lệch lạc môi trường giữa máy lập trình viên và máy chủ, đồng thời biến mỗi lần triển khai thành việc gửi đi một artifact bất biến duy nhất thay vì thực thi một chuỗi lệnh thủ công mong manh trên server. > **Bộ công nghệ Symfony Docker năm 2026** > > Template Symfony Docker chính thức hiện tại dùng [FrankenPHP](https://frankenphp.dev/) làm runtime, thay thế cho cặp Nginx cộng PHP-FPM truyền thống. Một binary duy nhất phục vụ HTTP/2 và HTTP/3, đồng thời chạy Symfony ở chế độ worker bền bỉ bằng cách giữ kernel luôn được khởi động sẵn giữa các request thay vì dựng lại nó ở mỗi lượt gọi. ## Môi trường phát triển với Docker Compose cho Symfony Một cấu hình phát triển tốt mang lại cho mọi thành viên cùng một phiên bản PHP, cùng các extension và cùng một cơ sở dữ liệu mà không phải động chạm gì đến máy host. Docker Compose mô tả bộ công nghệ đó theo lối khai báo. Ví dụ bên dưới ghép một container FrankenPHP dựng từ `Dockerfile` cục bộ với một service PostgreSQL 17, được nối với nhau trên mạng Compose mặc định. ```yaml # compose.yaml services: php: build: context: . target: frankenphp_dev ports: - "80:80" - "443:443" volumes: - ./:/app # bind mount: edits on the host appear instantly - caddy_data:/data # persist TLS certificates across restarts environment: DATABASE_URL: "postgresql://app:app@database:5432/app?serverVersion=17" depends_on: - database database: image: postgres:17-alpine environment: POSTGRES_DB: app POSTGRES_USER: app POSTGRES_PASSWORD: app volumes: - db_data:/var/lib/postgresql/data volumes: caddy_data: db_data: ``` Bind mount trên service `php` ánh xạ thư mục gốc của dự án vào trong container, nhờ đó các thay đổi tệp có hiệu lực ngay mà không cần dựng lại image. FrankenPHP tạo ra một chứng chỉ TLS cục bộ ngay lần khởi động đầu tiên, đó là lý do cổng 443 được mở và `caddy_data` là một named volume: chứng chỉ sẽ sống sót qua lệnh `docker compose down`. Gợi ý `serverVersion=17` trong DSN cho phép Doctrine bỏ qua một truy vấn dò phiên bản ở mỗi lần kết nối. Một khi bộ công nghệ đã chạy, mọi lệnh Symfony đều thực thi bên trong container `php` thay vì trên host, điều này bảo đảm đúng phiên bản PHP và đúng các extension. Việc tạo cơ sở dữ liệu, chạy migration hay mở một shell đều tuân theo cùng một khuôn mẫu thông qua `docker compose exec`. > **Chạy lệnh Console trong Container** > > Hãy đặt tiền tố `docker compose exec php` trước các lệnh Symfony và Composer để chạy chúng trên runtime PHP của container, ví dụ `docker compose exec php bin/console make:entity` hoặc `docker compose exec php composer require symfony/uid`. Máy host không bao giờ cần cài đặt PHP. ## Dockerfile nhiều tầng cho Symfony trong Production Một [Dockerfile nhiều tầng](https://docs.docker.com/build/building/multi-stage/) chia sẻ chung một base rồi tách thành một target phát triển và một target production. Tầng production chỉ cài các phụ thuộc runtime, loại bỏ các gói dev của Composer, và làm nóng cache ngay tại thời điểm build để container khi chạy khởi động tức thì. ```dockerfile # Dockerfile FROM dunglas/frankenphp:1-php8.4 AS base WORKDIR /app # Install the PHP extensions Symfony relies on RUN install-php-extensions \ intl \ opcache \ pdo_pgsql \ zip COPY --from=composer/composer:2-bin /composer /usr/bin/composer # --- Development target --- FROM base AS frankenphp_dev ENV APP_ENV=dev RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" # --- Production target --- FROM base AS frankenphp_prod ENV APP_ENV=prod ENV FRANKENPHP_CONFIG="worker ./public/index.php" ENV APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" # Layer caching: copy manifests first, install, then copy the source COPY composer.json composer.lock symfony.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist COPY . . RUN composer dump-autoload --no-dev --classmap-authoritative \ && composer dump-env prod \ && bin/console cache:warmup ``` Việc sao chép `composer.json` cùng các tệp lock trước phần còn lại của mã nguồn chính là tối ưu hóa mấu chốt: Docker lưu cache tầng cài đặt phụ thuộc và chỉ chạy lại nó khi các manifest thay đổi, chứ không phải ở mỗi lần chỉnh sửa mã. Cờ `--classmap-authoritative` báo cho Composer biết classmap đã đầy đủ, nên autoloader không bao giờ phải quay về tra cứu hệ thống tệp ở lúc chạy. Việc làm nóng cache trong quá trình build đồng nghĩa request production đầu tiên sẽ gặp một container đã được biên dịch trọn vẹn. ## Chế độ Worker của FrankenPHP và Symfony Runtime PHP-FPM truyền thống khởi động kernel Symfony, xử lý một request, rồi vứt bỏ toàn bộ. Chế độ worker giữ kernel và container dependency-injection sống xuyên suốt các request, đây chính là nơi phần lớn độ trễ được tiết kiệm. Gói `runtime/frankenphp-symfony` làm điều này khả thi thông qua component Symfony Runtime và bootstrap `public/index.php` chuẩn. ```php // public/index.php use App\Kernel; require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; return function (array $context) { return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); }; ``` Dòng `FRANKENPHP_CONFIG="worker ./public/index.php"` đặt trong Dockerfile kích hoạt vòng lặp này. Vì container được tái sử dụng, bất kỳ service nào giữ trạng thái riêng của từng request đều phải tự đặt lại giữa các request. Symfony xử lý các service của framework một cách tự động, nhưng các service có trạng thái do người dùng tự viết nên hiện thực `Symfony\Contracts\Service\ResetInterface` và được gắn tag `kernel.reset` để trạng thái tích lũy không rò rỉ sang request kế tiếp. Đây cũng chính là kỷ luật mà các [Messenger worker](/blog/symfony/symfony-messenger-queues-workers-async-architecture) chạy dài hạn đòi hỏi, và phần thưởng là thông lượng request cao gấp vài lần PHP-FPM trên cùng phần cứng. Với phát triển cục bộ, chạy FrankenPHP kèm cờ `--watch` sẽ khởi động lại worker một cách tự động khi tệp thay đổi, nhờ đó chế độ worker không cản trở vòng lặp chỉnh sửa và làm mới. ## FrankenPHP so với PHP-FPM trong Symfony Lựa chọn giữa FrankenPHP và bộ Nginx cộng PHP-FPM cổ điển định hình cả Dockerfile lẫn hành vi khi chạy. Bảng dưới đây tóm tắt những khác biệt thực tế cho một cấu hình Symfony production. | Khía cạnh | Chế độ worker FrankenPHP | Nginx + PHP-FPM | |--------|------------------------|-----------------| | Số container | Một | Hai (web server + PHP) | | Khởi động kernel | Một lần mỗi worker | Một lần mỗi request | | HTTP/3 | Tích hợp sẵn | Cần cấu hình thêm | | TLS | Tự động (Caddy) | Thiết lập chứng chỉ thủ công | | Xử lý trạng thái | Phải đặt lại giữa các request | Cô lập theo từng request | | Chi phí khởi động lạnh | Trả một lần lúc khởi động | Trả ở mỗi request | Chế độ worker thắng về thông lượng vì nó phân bổ bước khởi động kernel và biên dịch container tốn kém ra hàng nghìn request. PHP-FPM vẫn còn giá trị khi một ứng dụng phụ thuộc vào biến toàn cục hoặc các thư viện bên thứ ba giả định mỗi request có một tiến trình mới tinh, vì những thứ đó sẽ hỏng dưới một worker được tái sử dụng. > **Rò rỉ trạng thái trong chế độ Worker** > > Một service lưu dữ liệu request vào một thuộc tính riêng sẽ phục vụ dữ liệu của request này cho request kế tiếp trừ khi nó tự đặt lại. Hãy rà soát các singleton, event subscriber và bất cứ thứ gì giữ security token hoặc request hiện tại trước khi chuyển sang chế độ worker trong production. ## Quản lý biến môi trường và secret trong Production Symfony đọc cấu hình từ các biến môi trường, và có hai cách vững chắc để cung cấp chúng trong production. Cách thứ nhất là tiêm các biến thuần qua orchestrator hoặc tệp Compose. Cách thứ hai là kho secret mã hóa của Symfony, lưu các giá trị nhạy cảm trong repository dưới dạng ciphertext và giải mã chúng lúc chạy bằng một khóa riêng duy nhất. ```bash # Generate the keypair; commit the public key, keep the private key out of the image php bin/console secrets:generate-keys # Encrypt a value into config/secrets/prod/ php bin/console secrets:set DATABASE_URL # In production, expose only the decryption key to the container export SYMFONY_DECRYPTION_SECRET="$(cat config/secrets/prod/prod.decrypt.private.php)" ``` Bước `composer dump-env prod` trong Dockerfile biên dịch các tệp `.env` thành một tệp `.env.local.php` duy nhất đã được tối ưu, nhờ đó loại bỏ chi phí phân tích các tệp dotenv lúc khởi động. Bất kỳ biến nào được đặt trong môi trường container thật vẫn ghi đè lên các giá trị mặc định đã biên dịch, nên các secret do orchestrator cung cấp luôn thắng. [Hướng dẫn triển khai Symfony](https://symfony.com/doc/current/deployment.html) ghi rõ toàn bộ thứ tự ưu tiên, nhưng quy tắc thực dụng thì đơn giản: không bao giờ nướng `APP_SECRET` hay thông tin đăng nhập cơ sở dữ liệu vào image, mà hãy tiêm chúng lúc container khởi động. ## Tối ưu image production của Symfony Hai thiết lập OPcache chiếm phần lớn khoảng chênh lệch hiệu năng ở production: tắt kiểm tra timestamp và bật preloading. Vì một image container là bất biến, mã nguồn không bao giờ thay đổi lúc chạy, nên OPcache không nên stat các tệp để kiểm tra chỉnh sửa. Preloading tiến xa hơn bằng cách nạp các lớp của Symfony và của ứng dụng vào bộ nhớ dùng chung một lần duy nhất khi server khởi động. ```ini ; docker/php/prod.ini opcache.enable=1 opcache.preload=/app/var/cache/prod/App_KernelProdContainer.preload.php opcache.preload_user=root opcache.memory_consumption=256 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; immutable image: never re-check the filesystem realpath_cache_size=4096K realpath_cache_ttl=600 ``` Symfony sinh ra tệp `preload.php` liệt kê ở trên trong quá trình `cache:warmup`, nên tệp này đã tồn tại sẵn trong image sau khi build. [OPcache preloading](https://www.php.net/manual/en/opcache.preloading.php) liên kết các lớp này lúc khởi động và bỏ qua bước biên dịch ở request đầu tiên cần đến chúng. Đặt `validate_timestamps=0` chỉ an toàn cho các lần triển khai bất biến, nơi một bản phát hành mới đồng nghĩa một image mới; trên một host có thể thay đổi thì nó sẽ phục vụ mã đã cũ. Định kích thước `max_accelerated_files` cao hơn số lượng tệp thực tế giữ toàn bộ framework nằm trong cache mà không bị đẩy ra. ## Giảm kích thước image Symfony bằng .dockerignore Việc thiếu một tệp `.dockerignore` âm thầm làm phình bản build. Không có nó, bước `COPY . .` sẽ đưa thẳng thư mục `vendor` cục bộ, `var/cache` từ môi trường phát triển, đầu ra build của node và lịch sử `.git` vào cả image lẫn build context. Loại trừ chúng thu nhỏ image, tăng tốc quá trình tải build context lên daemon, và ngăn các artifact phát triển ghi đè lên các phụ thuộc production vừa được cài mới. ```gitignore # .dockerignore /.git/ /vendor/ /node_modules/ /var/ /.env.local /.env.*.local /tests/ /docker/ compose*.yaml Dockerfile ``` Bỏ qua `/vendor/` là quan trọng nhất: tầng production chạy `composer install --no-dev`, nên việc chuyển đi thư mục vendor mang hương vị phát triển của host sẽ phá hỏng hoàn toàn bước đó. Loại trừ `/var/` giữ cache và log của môi trường phát triển ra khỏi image, để bước `cache:warmup` lúc build tạo ra một cache production sạch. Kết hợp với build nhiều tầng và các extension dựa trên Alpine, một image Symfony gọn nhẹ thường nằm dưới mức 150 MB một cách thoải mái. ## Triển khai container Symfony và chạy migration Tệp Compose production tham chiếu đến một image dựng sẵn từ registry thay vì build cục bộ, và nó định nghĩa một health check để orchestrator chỉ định tuyến lưu lượng tới một container có phản hồi. Các secret đến dưới dạng biến môi trường, không bao giờ là các tầng của image. ```yaml # compose.prod.yaml services: php: image: registry.example.com/app:${TAG} environment: APP_ENV: prod APP_SECRET: ${APP_SECRET} DATABASE_URL: ${DATABASE_URL} healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost/health"] interval: 10s timeout: 3s retries: 3 restart: unless-stopped ``` Health check thực hiện curl tới một route `/health`, nên ứng dụng buộc phải phơi bày một route như vậy. Một controller tối giản trả về 200 mà không động đến cơ sở dữ liệu giúp kiểm tra diễn ra nhanh và tránh đánh dấu container là không khỏe mạnh trong một sự cố cơ sở dữ liệu thoáng qua. Khi độ sẵn sàng của cơ sở dữ liệu là quan trọng, một probe sâu hơn thứ hai có thể chạy một truy vấn nhẹ, nhưng endpoint liveness vẫn giữ mức tối giản. ```php // src/Controller/HealthController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; final class HealthController extends AbstractController { #[Route('/health', name: 'health', methods: ['GET'])] public function __invoke(): JsonResponse { return new JsonResponse(['status' => 'ok']); } } ``` Migration cơ sở dữ liệu nên chạy như một bước riêng biệt trước khi các container mới nhận lưu lượng, chứ không phải bên trong quá trình boot của ứng dụng. Chạy chúng trong một container dùng một lần bảo đảm schema được cập nhật đúng một lần cho mỗi bản phát hành, ngay cả khi nhiều bản sao ứng dụng khởi động song song. ```bash # deploy.sh set -euo pipefail docker compose -f compose.prod.yaml pull # Apply migrations once, in an isolated container docker compose -f compose.prod.yaml run --rm php \ bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration # Start replicas and wait for the health check to pass docker compose -f compose.prod.yaml up -d --wait ``` Cờ `--wait` chặn cho đến khi mọi service báo cáo khỏe mạnh, mang lại cho script triển khai một tín hiệu thành công thực sự thay vì kiểu khởi động rồi mặc kệ. Ghép điều này với một bản cập nhật cuốn chiếu trong một orchestrator như Kubernetes hoặc Docker Swarm cho ra các bản phát hành không gián đoạn: các container cũ vẫn tiếp tục phục vụ cho đến khi các container mới vượt qua health check. Để có góc nhìn rộng hơn về các phiên bản framework mà quy trình này nhắm tới, [tổng quan nền tảng Symfony](/technologies/symfony) và [hướng dẫn tính năng Symfony 8 và PHP 8.4](/blog/symfony/symfony-8-new-features-php-84-lazy-objects) trình bày những gì đã thay đổi trong dòng phát hành hiện tại. ## Kết luận - Dùng một Dockerfile nhiều tầng để image phát triển và image production chia sẻ chung một base, trong khi target production được gửi đi mà không kèm các phụ thuộc dev của Composer - Sao chép `composer.json` cùng các tệp lock trước mã nguồn để giữ tầng cài đặt phụ thuộc trong cache xuyên suốt các thay đổi mã - Chạy FrankenPHP ở chế độ worker để tái sử dụng kernel đã khởi động qua các request, và đặt lại các service có trạng thái bằng `ResetInterface` để ngăn rò rỉ trạng thái - Làm nóng cache Symfony và biên dịch `.env` với `dump-env prod` lúc build để container khi chạy khởi động trong trạng thái đã khởi tạo trọn vẹn - Bật OPcache preloading và đặt `validate_timestamps=0` trong production, điều an toàn chính vì image là bất biến - Giữ `APP_SECRET`, thông tin đăng nhập cơ sở dữ liệu và khóa giải mã kho secret ra khỏi các tầng của image; tiêm chúng dưới dạng biến môi trường lúc container khởi động - Áp dụng migration Doctrine trong một container dùng một lần trước khi các bản sao mới nhận lưu lượng, và ràng buộc quá trình triển khai vào health check bằng `up -d --wait` --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/vi/blog/symfony/symfony-docker-development-production