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.

Hướng dẫn Symfony Docker 2026: môi trường phát triển và triển khai production

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 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 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.

public/index.phpphp
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 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.

Sẵn sàng chinh phục phỏng vấn Symfony?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

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 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 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.

src/Controller/HealthController.phpphp
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 Symfonyhướng dẫn tính năng Symfony 8 và PHP 8.4 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

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#symfony
#docker
#frankenphp
#php
#deployment
#devops

Chia sẻ

Bài viết liên quan