Bỏ qua tới nội dung

Hướng dẫn viết Dockerfile từ đầu — Docker Deep Dive [Phần 3]

Dockerfile giống như một "recipe" — mỗi bước (instruction) thêm một lớp nguyên liệu lên trên lớp trước. Docker cache từng layer, nên thứ tự lệnh quyết định tốc độ build.

10 phút đọc

Overview

Dockerfile là bản thiết kế (blueprint) để Docker build ra một Image. Mỗi lệnh trong Dockerfile tạo ra một layer trong image — hiểu đúng điều này là chìa khóa để viết Dockerfile hiệu quả.

Tư duy: Dockerfile giống như một "recipe" — mỗi bước (instruction) thêm một lớp nguyên liệu lên trên lớp trước. Docker cache từng layer, nên thứ tự lệnh quyết định tốc độ build.


Key Concepts — 6 lệnh cốt lõi

1. FROM — Chọn base image

FROM php:8.2-fpm-alpine
  • What: Khai báo image gốc làm nền tảng. Mọi Dockerfile bắt buộc bắt đầu bằng FROM.
  • Why Alpine?: alpine variant nhỏ (~5MB vs ~80MB debian), giảm attack surface, build nhanh hơn.
  • Trade-off: Alpine dùng musl libc thay vì glibc → một số PHP extension có thể cần thêm bước compile.
Base image Size Package manager Use case
php:8.2-fpm ~480MB apt Cần compatibility cao
php:8.2-fpm-alpine ~80MB apk Production, CI/CD
php:8.2-cli ~370MB apt Artisan commands, queue workers

2. WORKDIR — Set thư mục làm việc

WORKDIR /var/www/html
  • What: Đặt current directory cho các lệnh RUN, COPY, CMD phía sau.
  • First principle: Giống cd nhưng persistent — nếu folder chưa tồn tại, Docker tự tạo.
  • Best practice: Luôn set WORKDIR sớm, tránh dùng absolute path lặp lại trong các lệnh sau.

3. RUN — Chạy command lúc build

RUN apk add --no-cache \
    libpng-dev \
    libzip-dev \
    && docker-php-ext-install pdo_mysql zip gd
  • What: Thực thi shell command và commit kết quả thành một layer mới.
  • Key insight: Mỗi RUN = 1 layer. Gộp nhiều command bằng && để giảm số layer.
  • --no-cache: Không lưu package index → giảm image size.

4. COPY — Copy file từ host vào image

  • Giải thích thêm

    Đúng, COPY . . chỉ chịu trách nhiệm copy — không chạy lệnh, không install gì cả. Nó đơn thuần lấy file từ host bỏ vào image.

    Về câu hỏi .env:

    Có, mặc định COPY . . SẼ copy .env vào image — nếu file .env tồn tại trong build context và bạn chưa có .dockerignore.

    Đây là một lỗ hổng bảo mật phổ biến mà nhiều người mới bỏ qua:

    • .env chứa DB_PASSWORD, APP_KEY, API keys, Stripe secret...
    • Ai có image = có thể đọc được tất cả secrets đó
    • Kể cả khi bạn RUN rm .env ở layer sau, file vẫn tồn tại trong layer trước (vì mỗi layer là immutable)

    Cách ngăn chặn

    Tạo file .dockerignore ở cùng thư mục với Dockerfile:

    .env
    .env.*
    

    Khi có .dockerignore, flow hoạt động như sau:

    COPY . .
      ├── Docker đọc .dockerignore trước
      ├── Loại bỏ các file match pattern
      └── Copy phần còn lại vào image
    

    Vậy Laravel lấy .env từ đâu khi chạy container?

    Thay vì bake .env vào image, inject env ở runtime:

    • Docker Compose: dùng env_file hoặc environment trong docker-compose.yml
    • Docker run: dùng -env-file .env hoặc e DB_HOST=mysql
    • Production (AWS, K8s): dùng Secrets Manager, Parameter Store, ConfigMap
    # docker-compose.yml
    services:
      app:
        build: .
        env_file:
          - .env    # inject lúc runtime, KHÔNG bake vào image
    

    Tóm lại

    Câu hỏi Trả lời
    COPY . . chỉ copy? ✅ Đúng, không làm gì khác
    Có copy .env không? ✅ Có, nếu không có .dockerignore
    Nên copy .env vào image? ❌ Không — lỗ hổng bảo mật
    Giải pháp? .dockerignore • inject env lúc runtime

    Nguyên tắc chung: Image nên stateless và không chứa secrets. Config/secrets inject từ bên ngoài lúc chạy container.

COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist

COPY . .
  • What: Copy file/folder từ build context (host) vào image.
  • COPY vs ADD: COPY chỉ copy thuần. ADD có thể extract tar và download URL → luôn dùng COPY trừ khi cần tính năng đặc biệt của ADD.
  • Cache optimization pattern: Copy dependency files trước (composer.json) → install → rồi mới copy source code. Như vậy khi chỉ thay đổi code, layer composer install được cache lại.

5. CMD — Lệnh chạy khi container start

CMD ["php-fpm"]
  • What: Default command khi docker run container. Chỉ có một CMD duy nhất — nếu có nhiều, chỉ cái cuối cùng có hiệu lực.
  • Exec form vs Shell form:
Form Syntax PID 1 Signal handling
Exec (recommended) CMD ["php-fpm"] php-fpm trực tiếp Nhận SIGTERM đúng
Shell CMD php-fpm /bin/sh -c php-fpm Signal có thể bị nuốt
  • CMD vs ENTRYPOINT: CMD dễ override khi docker run. ENTRYPOINT cố định lệnh chính, CMD cung cấp default args.

6. EXPOSE — Khai báo port

EXPOSE 9000
  • What: Tài liệu hóa (documentation) rằng container lắng nghe port nào. EXPOSE không tự publish port ra host.
  • Thực tế: Bạn vẫn cần -p 9000:9000 khi docker run hoặc khai báo ports trong docker-compose.yml.
  • Tại sao vẫn cần: Giúp người đọc hiểu container design, và một số tool (như Docker Compose) sử dụng thông tin này.

How It Works — Layer Cache & Build Order

Cơ chế layer cache

Layer 1: FROM php:8.2-fpm-alpine          ← cached (không đổi)
Layer 2: RUN apk add ... && ext-install    ← cached (không đổi)
Layer 3: COPY composer.json composer.lock  ← cached (file không đổi)
Layer 4: RUN composer install              ← cached (dependencies không đổi)
Layer 5: COPY . .                          ← INVALIDATED (code thay đổi)
Layer 6: RUN php artisan optimize          ← phải rebuild (sau layer bị invalidate)

Nguyên tắc sắp xếp

  1. Ít thay đổi → lên trên (system packages, extensions)
  2. Dependencies → giữa (composer.json → install)
  3. Source code → cuối (thay đổi thường xuyên nhất)

Thực hành — Dockerfile cho Laravel + PHP-FPM

Bài tập: Viết Dockerfile từ scratch

# === Stage: Production PHP-FPM for Laravel ===

# 1. Base image — Alpine nhẹ, phù hợp production
FROM php:8.2-fpm-alpine

# 2. System dependencies cho PHP extensions
RUN apk add --no-cache \
    libpng-dev \
    libjpeg-turbo-dev \
    libzip-dev \
    oniguruma-dev \
    autoconf \
    gcc \
    g++ \
    make

# 3. Install PHP extensions cần cho Laravel
RUN docker-php-ext-configure gd --with-jpeg \
    && docker-php-ext-install \
        pdo_mysql \
        mbstring \
        zip \
        gd \
        bcmath \
        opcache

# 4. Install Redis extension qua PECL
RUN pecl install redis \
    && docker-php-ext-enable redis

# 5. Cleanup build dependencies (giảm image size)
RUN apk del autoconf gcc g++ make

# 6. Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# 7. Set working directory
WORKDIR /var/www/html

# 8. Copy dependency files TRƯỚC (cache optimization)
COPY composer.json composer.lock ./

# 9. Install dependencies (no-dev cho production)
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist

# 10. Copy toàn bộ source code
COPY . .

# 11. Generate optimized autoloader
RUN composer dump-autoload --optimize --no-dev

# 12. Laravel optimizations
RUN php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache

# 13. Set permissions
RUN chown -R www-data:www-data storage bootstrap/cache

# 14. Expose PHP-FPM port
EXPOSE 9000

# 15. Start PHP-FPM
CMD ["php-fpm"]

Phân tích Dockerfile trên

Bước Mục đích Tại sao đặt ở vị trí này
1-5 System deps + PHP ext Hiếm khi thay đổi → cache hiệu quả nhất
6 Copy Composer binary Multi-stage copy, không cần install Composer thủ công
8-9 Dependencies Chỉ rebuild khi composer.json/lock thay đổi
10-13 Source code + optimize Thay đổi thường xuyên → đặt cuối

Trade-offs & Lưu ý

✅ Ưu điểm của cách tiếp cận này

  • Image nhỏ (~150-200MB với Alpine)
  • Build nhanh nhờ layer cache đúng thứ tự
  • Production-ready (opcache, no-dev deps)

⚠️ Hạn chế cần biết

  • Alpine có thể gây issue với một số PHP extension cần glibc
  • php artisan config:cache trong Dockerfile có nghĩa env phải inject đúng lúc runtime
  • Không có Xdebug (chỉ dùng cho dev, không nên có trong production image)

🔄 So sánh: Single-stage vs Multi-stage

Tiêu chí Single-stage (bài này) Multi-stage (tuần sau)
Độ phức tạp Thấp Trung bình
Image size ~150-200MB ~80-120MB
Build tools in final image Có (gcc, make...) Không — chỉ runtime
Khi nào dùng Học, prototype Production

Câu hỏi kiểm tra hiểu biết

  • Q1: Tại sao COPY composer.json composer.lock ./ phải đặt trước COPY . .?
  • Q2: EXPOSE 9000 có mở port 9000 ra ngoài host không? Tại sao?
  • Q3: Sự khác biệt giữa CMD ["php-fpm"] và CMD php-fpm là gì? Cái nào nên dùng?
  • Q4: Nếu bạn thêm một PHP extension mới (ví dụ intl), những layer nào sẽ bị rebuild?
  • Q5: Tại sao nên gộp nhiều RUN command bằng && thay vì viết nhiều RUN riêng lẻ?

Bước tiếp theo

  1. Hands-on: Tạo folder mới, viết Dockerfile theo mẫu trên, build và chạy thử:
docker build -t my-laravel-fpm .
docker run -d --name test-fpm my-laravel-fpm
docker exec -it test-fpm php -m  # Kiểm tra extensions
docker images my-laravel-fpm     # Kiểm tra image size
  1. Thử nghiệm cache: Thay đổi một file PHP → rebuild → quan sát layer nào được cache.
  2. So sánh size: Build cùng Dockerfile nhưng thay alpine bằng php:8.2-fpm (debian) → so sánh.

References

Bình luận