Multi-stage Dockerfile cho PHP và Node.js: giảm kích thước image

Hướng dẫn multi-stage Dockerfile cho PHP và Node.js: tách build/runtime, tối ưu Composer, npm, base image, cache, bảo mật và rollback image production.

NHT
· 9 phút đọc
Multi-stage Dockerfile cho PHP và Node.js: giảm kích thước image

Multi-stage Dockerfile là cách tổ chức một Dockerfile thành nhiều giai đoạn build để tách công cụ biên dịch khỏi môi trường chạy production. Với PHP và Node.js, kỹ thuật này giúp image cuối nhẹ hơn, ít package thừa hơn và dễ kiểm soát hơn, nhưng không phải cứ thêm nhiều lệnh FROM là tự động đạt chuẩn production.

Bài viết này đi từ nguyên lý, sau đó xây dựng hai mẫu thực tế: PHP dùng Composer và PHP-FPM, Node.js dùng npm hoặc pnpm để build frontend/backend. Bạn sẽ biết nên copy artifact nào, vì sao không nên mang cả node_modules hoặc compiler vào runtime, cách kiểm tra image sau build và các lỗi thường gặp khi tối ưu sai.

1. Multi-stage Dockerfile là gì?

Trong Dockerfile thông thường, các bước cài compiler, package manager, source code và dependency build thường tạo ra nhiều layer. Nếu image cuối dùng luôn stage đó, production phải mang theo những thứ chỉ cần trong lúc biên dịch. Multi-stage build giải quyết bằng cách dùng nhiều lệnh FROM; mỗi lệnh mở một stage và stage cuối chỉ nhận artifact cần thiết từ stage trước.

Hãy coi stage build là xưởng sản xuất, còn stage runtime là sản phẩm được đóng gói để chạy. Production không cần mang cả xưởng đi theo.Diễn giải thực hành dựa trên Docker Docs — Multi-stage builds.
# syntax=docker/dockerfile:1
FROM alpine:3.21 AS build
RUN printf "hello\n" > /tmp/output.txt

FROM alpine:3.21 AS runtime
COPY --from=build /tmp/output.txt /app/output.txt
CMD ["cat", "/app/output.txt"]

Docker Docs cho phép đặt tên stage bằng AS build rồi dùng COPY --from=build. Cách đặt tên này bền hơn việc dùng số 0, 1, 2 vì khi thêm hoặc đổi thứ tự stage, lệnh copy không bị trỏ nhầm.

2. Vì sao PHP và Node.js hưởng lợi rõ từ multi-stage?

PHP application thường cần Composer để tải dependency, có thể cần extension build như zip, intl hoặc các thư viện hệ thống. Khi chạy PHP-FPM, image runtime thường chỉ cần PHP-FPM, extension đã biên dịch, source và thư mục vendor. Nếu giữ lại Composer, gcc, make và header package trong runtime, image lớn hơn và bề mặt tấn công rộng hơn.

Node.js thường có bước npm ci, pnpm install hoặc build TypeScript/Vite/Next.js. Production có thể chỉ cần file build, dependency production và một runtime Node.js nhỏ. Tuy vậy, không thể copy máy móc thư mục build giữa các nền tảng khác nhau; native module phải được build cho đúng OS, libc và kiến trúc CPU của runtime.

Thành phầnBuild stageRuntime stage
Compiler và headerCó thể cài để biên dịch extension/native moduleKhông đưa vào nếu không cần lúc chạy
Package managerComposer, npm, pnpm hoặc yarnChỉ giữ công cụ cần khi runtime thực sự gọi
SourceCó thể chứa toàn bộ source và testChỉ copy file app/artifact cần thiết
Debug toolDùng ở stage debug/testKhông mặc định đưa vào production
SecretKhông bake secret vào layerInject lúc deploy qua secret/env an toàn

3. Mẫu multi-stage Dockerfile cho PHP và Composer

Mẫu sau dùng stage Composer để tải dependency rồi copy thư mục vendor sang runtime PHP-FPM. Hãy thay version image theo compatibility matrix của ứng dụng, khóa dependency bằng composer.lock và kiểm tra extension PHP trước khi build production.

# syntax=docker/dockerfile:1
FROM composer:2 AS php-deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --prefer-dist \
    --no-interaction \
    --no-progress \
    --optimize-autoloader

FROM php:8.3-fpm-bookworm AS runtime
WORKDIR /var/www/html
COPY --from=php-deps /app/vendor ./vendor
COPY . .

RUN chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
USER www-data
CMD ["php-fpm"]

Điểm quan trọng là runtime không kế thừa từ image Composer. Lệnh COPY --from=php-deps chỉ lấy vendor. Trong dự án Laravel, bạn có thể cần thêm bước cache config/route/view ở stage phù hợp, nhưng không nên chạy lệnh tạo cache trước khi các biến môi trường runtime và secret đã được thiết kế đúng.

Nếu PHP extension cần build từ source, hãy tạo một stage base có toolchain, sau đó copy thư viện đã build sang runtime tương thích. Không copy một binary được build trên Debian sang Alpine nếu chưa hiểu khác biệt glibc/musl. Khi nghi ngờ, hãy build và test trong cùng họ base image.

4. Mẫu multi-stage Dockerfile cho Node.js

Với Node.js, trước tiên xác định ứng dụng chạy theo kiểu nào. Ứng dụng server-side có thể cần source và production dependencies; ứng dụng frontend tĩnh có thể build bằng Node rồi copy thư mục dist sang Nginx. Hai mô hình này có runtime khác nhau và không nên gộp vào một Dockerfile thiếu mục tiêu rõ ràng.

# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Nếu dùng pnpm, hãy copy pnpm-lock.yaml, bật corepack hoặc cài phiên bản pnpm đã pin rồi chạy pnpm install --frozen-lockfile. Không nên copy cả thư mục node_modules từ máy developer vì có thể chứa dependency dev, binary sai nền tảng và file không cần thiết.

Với frontend tĩnh, runtime có thể là Nginx thay vì Node.js:

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:stable-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

5. Thứ tự COPY, cache và .dockerignore

Docker cache hoạt động theo từng instruction. Để không cài lại toàn bộ dependency mỗi khi sửa một file source, hãy copy manifest và lockfile trước, chạy install, rồi mới copy source. Với repository lớn, .dockerignore nên loại node_modules, vendor, .git, log, cache, file môi trường local và artifact build cũ.

# .dockerignore mẫu
.git
.gitignore
node_modules
vendor
npm-debug.log*
*.log
.env
.env.*
dist
coverage
Dockerfile*
docker-compose*.yml

Không đưa .env vào build context chỉ để ứng dụng “chạy được” trong lúc build. Secret trong build arg hoặc layer có thể bị lưu trong history/cache. Hãy tách cấu hình runtime khỏi image và dùng secret manager, environment injection hoặc cơ chế deploy mà nhà cung cấp hỗ trợ.

6. Chọn base image: slim, Alpine hay Debian?

Image nhỏ không phải tiêu chí duy nhất. Alpine có kích thước nhỏ nhưng dùng musl libc; một số native dependency, extension PHP hoặc module Node.js có thể cần kiểm thử thêm. Debian slim thường lớn hơn một chút nhưng tương thích quen thuộc hơn với nhiều package. Chọn base dựa trên dependency, khả năng debug, security update và năng lực đội vận hành.

Tiêu chíCâu hỏi cần trả lời
Tương thíchExtension PHP hoặc native module Node có build/chạy đúng không?
Bảo mậtBase image có nguồn tin cậy, lịch cập nhật và quy trình scan không?
DebugKhi lỗi production, có cách kiểm tra mà không cài tool tùy tiện không?
Tái lậpTag/digest và lockfile có được kiểm soát trong CI không?
Vận hànhĐội ngũ có biết package manager và đường dẫn log của base đó không?

Docker khuyến nghị chọn base image từ nguồn tin cậy, giữ image tối giản và rebuild định kỳ. Nếu pin digest, bạn có audit trail tốt hơn nhưng cần quy trình cập nhật digest để không bỏ lỡ bản vá.

7. Build target cho test, debug và production

Một Dockerfile có thể có stage test, debugproduction. CI có thể build target test để chạy unit test, còn deploy chỉ build target production. Cách này tránh việc image production mặc định chứa debug symbol, test data hoặc công cụ shell không cần thiết.

# Build target kiểm thử
DOCKER_BUILDKIT=1 docker build --target test -t app:test .
docker run --rm app:test

# Build target production
docker build --target runtime -t app:prod .
docker image inspect app:prod --format '{{.Size}}'

Nếu stage test không phụ thuộc stage production, BuildKit có thể tối ưu phần build theo dependency. Trong CI, nên lưu log build, image digest, kết quả test và scan để biết chính xác artifact nào đã được deploy.

8. Kiểm tra image sau khi build

Đừng đánh giá multi-stage chỉ bằng con số dung lượng. Cần kiểm tra process khởi động, endpoint, permission, user chạy container, dependency runtime, log và image history. Một image nhỏ nhưng thiếu certificate CA, timezone, extension hoặc user đúng vẫn là image lỗi.

docker build --pull -t example/app:ci .
docker image inspect example/app:ci
docker history example/app:ci

docker run --rm -d --name app-check -p 18080:8080 example/app:ci
curl --fail http://127.0.0.1:18080/health
docker exec app-check id
docker logs app-check
docker rm -f app-check

Kết hợp kiểm tra tĩnh Dockerfile, scan vulnerability, test integration và smoke test. Không copy secret ra output để “debug”. Khi cần shell điều tra, hãy dùng một debug image hoặc ephemeral container có kiểm soát thay vì sửa trực tiếp image production.

9. Lỗi phổ biến khi tối ưu PHP và Node.js

  • Copy cả source, cache, vendor dev hoặc node_modules từ máy local vào runtime.
  • Dùng tag base mơ hồ, không lock dependency và không lưu image digest của lần deploy.
  • Build native module ở stage dùng libc khác với runtime.
  • Chạy ứng dụng bằng root dù app không cần quyền đó.
  • Đưa secret vào ARG, ENV hoặc file được commit vào build context.
  • Dùng multi-stage nhưng vẫn cài compiler và package build trong stage cuối.
  • Không chạy smoke test sau khi chỉ copy một phần artifact.

Một bài Docker cơ bản cho người mới giúp nắm container, image và volume; còn bài CI/CD và best practices giúp đặt build image vào pipeline có kiểm thử thay vì build thủ công trên server.

10. Checklist production cho multi-stage Dockerfile

  1. Xác định rõ build artifact và runtime entrypoint trước khi viết Dockerfile.
  2. Tách build, test, debug và runtime thành stage có tên dễ đọc.
  3. Copy manifest/lockfile trước source để tận dụng cache.
  4. Dùng .dockerignore và không đưa secret vào build context.
  5. Chọn base image theo compatibility, security update và khả năng debug.
  6. Chạy container bằng user không phải root nếu ứng dụng cho phép.
  7. Build bằng --pull trong quy trình cập nhật base; cân nhắc pin digest.
  8. Kiểm tra image size, history, vulnerability, endpoint và process sau build.
  9. Ghi image digest và kết quả test vào artifact của CI/CD.
  10. Có kế hoạch rollback về image digest trước đó.

11. Kết luận

Multi-stage Dockerfile là một kỹ thuật tổ chức build, không phải lời hứa rằng image sẽ luôn nhỏ hoặc an toàn. Với PHP và Node.js, lợi ích lớn nhất đến từ việc tách dependency build khỏi runtime, giữ lockfile, chọn base phù hợp và kiểm tra artifact cuối như một sản phẩm có thể rollback.

Hãy bắt đầu bằng một Dockerfile có stage rõ ràng, đo image trước và sau, chạy test trong CI rồi mới tối ưu thêm. Khi image nhỏ đi nhưng khả năng restore, debug hoặc cập nhật bảo mật kém đi, đó không phải là tối ưu production.

Tài liệu tham khảo

  1. Docker Docs — Multi-stage builds.
  2. Docker Docs — Building best practices.
  3. Docker Docs — Dockerfile reference.