Docker Deev Dive #3: Dockerfile — Viết file build image

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"]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ài viết liên quan

Đang cập nhật...