Triển khai Laravel Octane & FrankenPHP: Kiến trúc Production-Ready với Docker và Traefik
Tăng tốc ứng dụng Laravel lên gấp 10 lần với kiến trúc FrankenPHP và Octane. Hướng dẫn chi tiết cách xây dựng hệ thống container hóa với Docker Compose, tự động hóa triển khai bằng script và quản lý traffic với Traefik để đạt hiệu năng tối đa.
Laravel là một framework rất mạnh mẽ, nhưng kiến trúc tiêu chuẩn của nó có một điểm yếu lớn bắt nguồn từ cách PHP hoạt động: Nó phải khởi động lại (rebuild) toàn bộ framework cho mỗi request.
Ngay cả với các biện pháp tối ưu hóa, quá trình này vẫn mất từ 40 đến 60 mili giây trên máy của tôi với PHP 8.4. May mắn thay, trong nhiều năm qua, thế giới PHP và Laravel đã có một giải pháp giúp giảm đáng kể thời gian tải này: Laravel Octane và FrankenPHP. Thời gian khởi động cho framework Laravel có thể giảm xuống chỉ còn 4 đến 6 mili giây mỗi request. Thật đáng kinh ngạc phải không?
Nếu bạn mới làm quen với Laravel Octane hoặc FrankenPHP, bạn có thể tự hỏi làm sao điều này có thể xảy ra. Câu trả lời đơn giản là framework được giữ trong bộ nhớ (RAM). Sau khi FrankenPHP khởi động, Laravel luôn sẵn sàng phục vụ các request mà không cần khởi động lại từ đầu. Lời giải thích thực sự phức tạp hơn và nằm ngoài phạm vi bài viết này. Nếu tò mò, bạn có thể đọc tài liệu chính thức của Laravel Octane và FrankenPHP để tìm hiểu sâu hơn.
Trước khi tiếp tục, tôi nên đề cập rằng FrankenPHP không phải là application server duy nhất cho Laravel Octane. Tuy nhiên, đó là cái tôi đã thử, và tôi hài lòng với tính năng cũng như hiệu suất của nó đến mức không cảm thấy cần phải thử các giải pháp khác (như Swoole hay RoadRunner).
Thách thức khi triển khai: Từ thủ công đến tự động hóa
Tuyệt vời, nhưng làm thế nào để tôi chạy cái này trên server của mình?
Đó là một câu hỏi hay. Đầu tiên, Laravel Octane cung cấp một lệnh rất tiện lợi: php artisan octane:start. Vì đây là một tiến trình chạy lâu dài (long-lived process), chúng ta không thể chỉ khởi động nó thủ công rồi bỏ đi. Đây là lúc một trình quản lý tiến trình, như Supervisor, phát huy tác dụng. Bạn ra lệnh cho trình quản lý khởi động tiến trình, nó sẽ chạy ngầm và tự động khởi động lại nếu tiến trình bị crash hoặc hệ thống khởi động lại.
Nhưng việc sử dụng một hệ thống như Supervisor có một nhược điểm rất lớn theo quan điểm của tôi: làm "bẩn" hệ thống (system pollution). Đó là lý do tại sao tôi chọn một giải pháp khác: Docker Compose.
Tại sao Docker Compose là sự lựa chọn hoàn hảo
Docker Compose có những ưu điểm to lớn cho phép tôi tận dụng tối đa Laravel Octane và các công cụ liên quan.
Sự cô lập (Isolation)
Bằng cách xây dựng một Docker image tùy chỉnh, tôi có thể đưa vào chỉ những yêu cầu tối thiểu để chạy từng tiến trình. Điều này cho phép tôi tách biệt việc biên dịch phần mềm/asset (tôi đang nhìn vào thư mục node_modules khổng lồ của bạn đấy) khỏi ứng dụng chạy cuối cùng.
Ví dụ, image web server của tôi (tôi gọi là app) chỉ có các yêu cầu của FrankenPHP. Nó thậm chí không bao gồm Composer, vì thư mục vendor được sao chép từ một giai đoạn build riêng biệt. Mặt khác, image worker của tôi chỉ bao gồm PHP CLI mà không có FrankenPHP, vì nó không cần thiết.
Quản lý tiến trình
Bằng cách tách biệt mọi tiến trình cần chạy (horizon, pulse, scheduler, redis, db, web) vào container riêng của nó, chúng ta đảm bảo rằng vấn đề với một tiến trình không ảnh hưởng trực tiếp đến tiến trình khác. Tất nhiên, nếu database chết, app cũng chết, nhưng nếu scheduler chết, app vẫn có thể tiếp tục hoạt động, có thể với chức năng bị giảm bớt.
Thêm vào đó, nếu một container chết, Docker sẽ tự động khởi động lại nó mà không cần sự can thiệp của con người.
Tích hợp Traefik dễ dàng
Có lẽ điều này đã rõ ràng qua blog của tôi, nhưng tôi rất thích Traefik. Với nó, tôi có thể chạy 30 ứng dụng web bao gồm gần 80 container với sự can thiệp tối thiểu. Sự chậm trễ duy nhất là khi tôi build một Docker image mới trực tiếp trên server, việc này có thể chiếm dụng tài nguyên CPU. Với Traefik, tôi chỉ cần đặt một số nhãn (labels) lên container trong file docker-compose.yml, và Traefik sẽ tự động expose nó ra web trên cổng 80 và 443.
Cấu trúc Stack (The Stack)
Bây giờ tôi đã giải thích cái gì và tại sao, hãy cùng xem xét hạ tầng tôi đã triển khai để chạy trang web của mình.
Stack này bao gồm ba phần chính.
1. Dockerfile đa giai đoạn (Multi-Stage Dockerfile)
Một Dockerfile đa giai đoạn với nhiều target hỗ trợ xây dựng các image tinh gọn, chuyên biệt cho web server frankenphp và worker chạy dòng lệnh.
# Stage 1: Vendor (dùng chung cho tất cả)
FROM composer:latest AS vendor
WORKDIR /app
# Cài đặt PHP extensions (cần thiết cho composer)
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions gd bcmath intl pcntl redis pdo_mysql
ARG SPARK_USERNAME
ARG SPARK_API_TOKEN
ENV SPARK_USERNAME=${SPARK_USERNAME}
ENV SPARK_API_TOKEN=${SPARK_API_TOKEN}
COPY composer.json composer.lock ./
# Xác thực Spark tạm thời cho các private packages
RUN set -eux; \
composer config --global http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN"; \
composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress; \
composer config --global --unset http-basic.spark.laravel.com; \
rm -f /root/.composer/auth.json || true; \
rm -f /app/.composer/auth.json || true; \
rm -f /tmp/* /var/tmp/* || true
# Stage 2: Assets (build frontend với Node 22 + Yarn)
FROM node:22-alpine AS assets
WORKDIR /app
# Sao chép manifest dependency và cài đặt bằng Yarn (qua Corepack)
COPY package.json yarn.lock ./
RUN corepack enable \
&& corepack prepare yarn@1.22.22 --activate \
&& yarn install --frozen-lockfile
# Chỉ sao chép những gì cần thiết để build assets
COPY vite.config.js ./
COPY tailwind.config.js ./
COPY postcss.config.js ./
COPY resources ./resources
COPY public ./public
ENV NODE_ENV=production
RUN yarn build
# Stage 3: Worker image (CLI)
FROM php:8.4-cli-alpine AS worker
COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis
ARG APP_ENV=production
WORKDIR /app
COPY . /app
COPY ".env.${APP_ENV:-production}" .env
COPY --from=vendor /app/vendor /app/vendor
RUN mkdir -p storage bootstrap/cache;
RUN chown -R www-data:www-data storage bootstrap/cache;
RUN chmod -R 775 storage bootstrap/cache;
USER www-data
CMD ["php", "artisan", "queue:work", "--tries=3", "--sleep=1"]
# Stage 4: FrankenPHP image (Web)
FROM dunglas/frankenphp:latest AS frankenphp
WORKDIR /app
ARG APP_ENV=production
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis
COPY . /app
COPY ".env.${APP_ENV:-production}" .env
COPY --from=vendor /app/vendor /app/vendor
# Sao chép frontend assets đã biên dịch mà không cần cài đặt Node/Yarn ở stage này
COPY --from=assets /app/public/build /app/public/build
COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/install-php-extensions
COPY --from=vendor /usr/bin/composer /usr/bin/composer
RUN mkdir -p storage bootstrap/cache;
RUN chown -R www-data:www-data storage bootstrap/cache;
RUN chmod -R 775 storage bootstrap/cache;
#EXPOSE 80
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80"]
Như bạn thấy, tôi sử dụng stage vendor để tải xuống tất cả các gói Composer, bao gồm cả các gói private. Sau đó, tôi xóa thông tin xác thực khỏi image để giảm rủi ro bảo mật. Hai stage PHP còn lại chỉ cần sao chép thư mục vendor mà không cần chạy composer install lại.
Một stage assets tương tự xử lý frontend. Tôi cài đặt mọi thứ với Node 22 và Yarn, build các assets production, và sau đó chỉ sao chép các file đã biên dịch vào image web cuối cùng. Điều này giúp giữ cho thư mục node_modules khổng lồ và chính Node nằm ngoài container production, đây là một điểm cộng lớn trong sách của tôi.
2. File compose.yml
File này điều phối tất cả các dịch vụ, liên kết chúng lại với nhau và cấu hình cho môi trường production với Traefik.
# Docker Compose setup for local and production (Traefik) with FrankenPHP
# - Build a single image and reuse it for web, worker, and scheduler services
# - For production behind Traefik, set labels and SERVER_NAME appropriately
x-env: &default-env
env_file:
- .env
x-volumes: &laravel-volumes
volumes:
- ./.storage/logs/:/app/storage/logs
- ./.storage/app/:/app/storage/app
- ./.storage/framework/:/app/storage/framework
# helper map to merge env and volumes in one << per service
x-common: &common
<<: [*default-env, *laravel-volumes]
name: coz_jp_${APP_ENV}
services:
app:
container_name: coz_jp_web_${APP_ENV}
image: coz_jp:frankenphp
pull_policy: never
build:
context: ${CONTEXT_LOCATION:-.}
dockerfile: docker/Dockerfile
target: frankenphp
args:
APP_ENV: ${APP_ENV:-production}
SPARK_USERNAME: ${SPARK_USERNAME}
SPARK_API_TOKEN: ${SPARK_API_TOKEN}
<<: *laravel-volumes
#
labels:
- traefik.enable=true
- traefik.http.routers.coz_jp_${APP_ENV}-https.rule=Host(`${APP_DOMAIN}`)
- traefik.http.routers.coz_jp_${APP_ENV}-https.tls=true
- traefik.http.services.coz_jp_${APP_ENV}-https.loadbalancer.server.port=80
- traefik.http.routers.coz_jp_${APP_ENV}-https.tls.certresolver=cloudflare
- traefik.http.routers.coz_jp_${APP_ENV}-https.entrypoints=websecure
environment:
SERVER_NAME: ${APP_DOMAIN:-:80}
SERVER_ROOT: /app/public
depends_on:
- redis
- db
networks:
- internal
- traefik # uncomment in production
restart: unless-stopped
command: ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80", "--workers=8", "--log-level=info"]
healthcheck:
test: [
"CMD-SHELL",
"curl -fsS http://127.0.0.1:80/up || { rc=$$?; echo \"[healthcheck] GET /up failed with code $$rc\" >&2; exit 1; }"
]
interval: 15s
timeout: 5s
retries: 20
start_period: 10s
worker:
container_name: coz_jp_worker_${APP_ENV}
image: coz_jp:worker
pull_policy: never
build:
context: ${CONTEXT_LOCATION:-.}
dockerfile: docker/Dockerfile
target: worker
args:
APP_ENV: ${APP_ENV:-production}
SPARK_USERNAME: ${SPARK_USERNAME}
SPARK_API_TOKEN: ${SPARK_API_TOKEN}
<<: *common
command: ["php", "artisan", "horizon"]
depends_on:
- redis
- db
networks:
- internal
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 2s
retries: 10
scheduler:
container_name: coz_jp_scheduler_${APP_ENV}
image: coz_jp:worker
<<: *laravel-volumes
command: ["php", "artisan", "schedule:work"]
depends_on:
- redis
- db
networks:
- internal
restart: unless-stopped
pulse_check:
container_name: coz_jp_pulse_check_${APP_ENV}
image: coz_jp:worker
<<: *laravel-volumes
command: ["php", "artisan", "pulse:check"]
depends_on:
- redis
- db
networks:
- internal
restart: unless-stopped
pulse_work:
container_name: coz_jp_pulse_work_${APP_ENV}
image: coz_jp:worker
<<: *laravel-volumes
command: ["php", "artisan", "pulse:work"]
depends_on:
- redis
- db
networks:
- internal
restart: unless-stopped
db:
image: mysql:8.2
container_name: coz_jp_db_${APP_ENV}
<<: *default-env
environment:
MYSQL_DATABASE: ${DB_DATABASE:-laravel}
MYSQL_USER: ${DB_USERNAME:-laravel}
MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret}
ports:
- "${DB_EXPOSE_PORT:-13306}:3306" # thay đổi hoặc xóa trong production
volumes:
- ./.mysql-db/:/var/lib/mysql
networks:
- internal
restart: always
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
start_period: 30s
redis:
image: redis:alpine
container_name: coz_jp_redis_${APP_ENV}
volumes:
- .redis/redis:/data
networks:
- internal
healthcheck:
test: ["CMD", "redis-cli", "ping"]
restart: unless-stopped
networks:
internal:
traefik:
external: true
name: traefik
Trong file compose.yml, bạn có thể thấy cách tôi tách từng phần logic của ứng dụng (web, worker, scheduler, pulse) thành service riêng của nó. Điều này phù hợp với những lý do tôi đã đề cập trước đó.
3. Script triển khai run.sh
Cuối cùng, để chạy và triển khai quy trình này, tôi sử dụng một script .sh đơn giản để tự động hóa tất cả các bước. Bây giờ tôi chỉ cần gõ bash run.sh, và mã nguồn sẽ được pull về, build và deploy tự động.
#!/bin/bash
# Định nghĩa thư mục
GITFOLDER="../coz_jp"
LOCALFOLDER=$(pwd)
# Tải biến môi trường từ .env
source "$LOCALFOLDER/.env"
echo "***Pulling repo.";
# Chuyển đến thư mục GIT, thoát nếu thất bại
cd "$GITFOLDER" || exit 1
# Pull thay đổi mới nhất
git pull
# Quay lại thư mục local
cd "$LOCALFOLDER" || exit 1
echo "***Copying files";
# Copy compose.yml từ git, ghi đè local
cp -f "${GITFOLDER}/compose.yml" "$LOCALFOLDER/compose.yml"
cp -f "${LOCALFOLDER}/.env" "${GITFOLDER}/.env.${APP_ENV}"
# Đảm bảo các thư mục thuộc quyền sở hữu của user 82
sudo chown -R 82:82 .storage
sudo chown -R 999:999 .redis .mysql-db
echo "***Builidng docker";
# Khởi chạy các container Docker với rebuild
docker compose build && \
docker compose up -d --force-recreate && \
# Thay đổi quyền sở hữu của /app bên trong container dưới quyền root
docker compose exec -u root coz_jp_web_${APP_ENV} chown -R www-data: /app && \
# Chạy lệnh tối ưu hóa Laravel bên trong container
docker compose exec coz_jp_web_${APP_ENV} php artisan optimize
# Phát tiếng chuông terminal
echo -en "\007"
Cấu trúc Thư mục
Tất cả các file này được thiết kế và sắp xếp theo cách cho phép thực thi trong bất kỳ môi trường nào, từ local đến staging và production. Để đạt được điều này, tôi đã bố trí cấu trúc file như sau:
Cấu trúc này có nghĩa là tôi chỉ cần cd vào thư mục của một môi trường và chạy script để triển khai toàn bộ dự án một cách dễ dàng.
- project root
- - Git source folder (coz.jp trong trường hợp của tôi)
- - Stage env directory
- - - .env
- - - run.sh
- - - .mysql-db
- - - .redis
- - - .storage
- - Prod env directory
- - - .env
- - - run.sh
- - - .mysql-db
- - - .redis
- - - .storage
Sử dụng cấu hình và cấu trúc này giúp việc triển khai dự án trở nên dễ dàng chỉ bằng cách đi đến thư mục môi trường và thực thi lệnh đã đề cập trước đó.
Phản Ứng Của Bạn Là Gì?
Thích
0
Không Thích
0
Yêu
0
Hài hước
0
Giận dữ
0
Buồn
0
Wow
0