Docker Deev Dive #7: Bài tập tổng hợp — Chạy Laravel bằng tay (không docker-compose)

9 phút đọc

Overview

Đây là bài tổng hợp kiến thức Tuần 1 + Tuần 2. Bạn sẽ chạy một Laravel app hoàn chỉnh bằng thuần docker run — không dùng Docker Compose — để hiểu chính xác Docker Compose làm gì đằng sau.

Kiến thức cần áp dụng:

  • Bài 1-2: Container, Image, Layer
  • Bài 3: Dockerfile — build custom PHP-FPM image
  • Bài 4: Container Lifecycle — docker run, exec, logs
  • Bài 5: Volume — Named volume cho MySQL, Bind mount cho source code
  • Bài 6: Network — Custom bridge, DNS resolution, port mapping

💡 Mục tiêu: Sau bài này, khi nhìn vào docker-compose.yml bạn sẽ hiểu từng dòng tương ứng với lệnh Docker nào.


Kiến trúc mục tiêu

┌──────────────────── Network: laravel-manual ──────────────────────┐
│                                                                   │
│  ┌──────────┐    ┌──────────────┐    ┌──────────┐                 │
│  │  Nginx   │───▶│   PHP-FPM    │───▶│  MySQL   │                 │
│  │  :80     │    │   (Laravel)  │    │  :3306   │                 │
│  │          │    │   :9000      │    │          │                 │
│  └──────────┘    └──────────────┘    └──────────┘                 │
│       │                │                   │                      │
│  -p 8000:80     Bind mount:           Named volume:               │
│  (host access)  ./src:/var/www/html   mysql_data:/var/lib/mysql   │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

3 containers, 1 network, 2 volume types:

Container Image Port Volume Vai trò
nginx nginx:alpine 8000:80 Bind mount: source + config Web server, reverse proxy → PHP-FPM
app Custom (php:8.2-fpm-alpine + extensions) Không expose Bind mount: source code Laravel app, xử lý PHP
mysql mysql:8.0 Không expose * Named volume: mysql_manual_data Database
  • Thêm -p 3306:3306 nếu muốn kết nối từ host (TablePlus, Sequel Pro).

Bước 0: Chuẩn bị project

Tạo cấu trúc folder

mkdir -p ~/docker-lab/laravel-manual
cd ~/docker-lab/laravel-manual

# Tạo các file config
mkdir -p docker/nginx

Tạo Laravel project (nếu chưa có)

Dùng Composer container để tạo Laravel project mà không cần cài PHP/Composer trên host:

# Dùng Composer container để tạo project
docker run --rm -v $(pwd):/app composer create-project laravel/laravel src

Sau khi chạy xong, cấu trúc folder:

~/docker-lab/laravel-manual/
├── docker/
│   └── nginx/
│       └── default.conf    (sẽ tạo ở Bước 2)
├── Dockerfile              (sẽ tạo ở Bước 1)
└── src/                    (Laravel project)
    ├── app/
    ├── bootstrap/
    ├── config/
    ├── public/
    ├── routes/
    ├── .env
    └── ...

Cập nhật .env của Laravel

# Sửa file src/.env
# Thay đổi các dòng sau:

DB_CONNECTION=mysql
DB_HOST=mysql              # ← service name (Bài 6: Docker DNS)
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=secret

APP_URL=http://localhost:8000

Bước 1: Build custom PHP-FPM image

Laravel cần PHP extensions (pdo_mysql, zip, gd, v.v.) mà image php:8.2-fpm-alpine gốc không có. Tạo Dockerfile để build custom image (kiến thức Bài 3).

Tạo Dockerfile

cat > Dockerfile << 'EOF'
FROM php:8.2-fpm-alpine

# System dependencies
RUN apk add --no-cache \
    libpng-dev \
    libjpeg-turbo-dev \
    libzip-dev \
    oniguruma-dev

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

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

# Working directory
WORKDIR /var/www/html

# Permissions cho Laravel storage & cache
RUN chown -R www-data:www-data /var/www/html

EXPOSE 9000
CMD ["php-fpm"]
EOF

Build image

docker build -t laravel-fpm:manual .

# Verify
docker images laravel-fpm:manual
# REPOSITORY      TAG      SIZE
# laravel-fpm     manual   ~150MB

# Kiểm tra extensions
docker run --rm laravel-fpm:manual php -m | grep -E 'pdo_mysql|mbstring|zip|gd|bcmath'
# → bcmath, gd, mbstring, pdo_mysql, zip

Bước 2: Tạo Nginx config

Nginx hoạt động như reverse proxy: nhận HTTP request → forward sang PHP-FPM qua FastCGI protocol (Bài 6: gọi bằng service name app:9000).

cat > docker/nginx/default.conf << 'EOF'
server {
    listen 80;
    server_name localhost;
    root /var/www/html/public;
    index index.php index.html;

    # Ghi log ra stdout/stderr (Docker best practice - Bài 4)
    access_log /dev/stdout;
    error_log /dev/stderr;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;     # ← Docker DNS resolve "app" → IP PHP-FPM container
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
EOF

Giải thích dòng quan trọng nhất:

  • fastcgi_pass app:9000 → Nginx gọi container tên app (PHP-FPM) trên port 9000
  • Docker DNS (Bài 6) resolve app172.x.x.x → forward FastCGI request
  • root /var/www/html/public → Laravel public directory (entry point)

Bước 3: Tạo Network

Tạo custom bridge network để tất cả containers giao tiếp bằng tên (Bài 6: custom bridge có DNS, default bridge thì không).

docker network create laravel-manual

# Verify
docker network ls | grep laravel-manual
# → laravel-manual   bridge   local

Bước 4: Tạo Volume cho MySQL

Tạo named volume để MySQL data persist qua container restart/recreate (Bài 5).

docker volume create mysql_manual_data

# Verify
docker volume ls | grep mysql_manual
# → local   mysql_manual_data

Bước 5: Khởi chạy từng Container

Thứ tự quan trọng: MySQL → PHP-FPM → Nginx (dependency order).

5.1 MySQL Container

docker run -d \
  --name mysql \
  --network laravel-manual \
  -e MYSQL_ROOT_PASSWORD=secret \
  -e MYSQL_DATABASE=laravel \
  -v mysql_manual_data:/var/lib/mysql \
  mysql:8.0

Phân tích từng flag:

Flag Bài đã học Ý nghĩa
-d Bài 4 Detached mode — chạy background
--name mysql Bài 4 Đặt tên container (cũng là DNS name trong network)
--network laravel-manual Bài 6 Kết nối vào custom bridge network
-e MYSQL_ROOT_PASSWORD=secret Bài 4 Environment variable — MySQL yêu cầu bắt buộc
-e MYSQL_DATABASE=laravel Tự tạo database laravel khi init
-v mysql_manual_data:/var/lib/mysql Bài 5 Named volume — data persist khi container bị xóa
# Đợi MySQL khởi tạo (lần đầu ~10-15s)
docker logs -f mysql
# Đợi đến dòng: "ready for connections"
# Ctrl+C để thoát logs

# Verify: MySQL đang chạy
docker exec mysql mysqladmin ping -uroot -psecret
# → mysqld is alive

5.2 PHP-FPM (Laravel) Container

docker run -d \
  --name app \
  --network laravel-manual \
  -v $(pwd)/src:/var/www/html \
  laravel-fpm:manual

Phân tích:

Flag Bài đã học Ý nghĩa
--name app Bài 6 DNS name = app → Nginx config fastcgi_pass app:9000
--network laravel-manual Bài 6 Cùng network với MySQL → gọi mysql:3306 được
-v $(pwd)/src:/var/www/html Bài 5 Bind mount — sửa code trên host → container thấy ngay
laravel-fpm:manual Bài 3 Custom image đã build ở Bước 1
# Verify: PHP-FPM đang chạy
docker logs app
# → [NOTICE] ready to handle connections

# Verify: Laravel source code accessible
docker exec app ls /var/www/html/artisan
# → /var/www/html/artisan

# Verify: có thể resolve MySQL
docker exec app ping -c 2 mysql
# → 64 bytes from 172.x.x.x

5.3 Nginx Container

docker run -d \
  --name nginx \
  --network laravel-manual \
  -p 8000:80 \
  -v $(pwd)/src:/var/www/html \
  -v $(pwd)/docker/nginx/default.conf:/etc/nginx/conf.d/default.conf \
  nginx:alpine

Phân tích:

Flag Bài đã học Ý nghĩa
-p 8000:80 Bài 6 Port mapping — host:8000 → container:80
-v .../src:/var/www/html Bài 5 Bind mount source — Nginx cần serve static files (CSS, JS, images)
-v .../default.conf:... Bài 5 Bind mount config — override Nginx config mặc định
# Verify: Nginx đang chạy
docker logs nginx

# Verify: Nginx resolve được app
docker exec nginx ping -c 2 app
# → 64 bytes from 172.x.x.x

Bước 6: Setup Laravel

Install dependencies (nếu chưa có vendor/)

# Chạy composer install BÊN TRONG container (vì Composer đã có trong image)
docker exec app composer install

Generate app key

docker exec app php artisan key:generate
# → Application key set successfully.

Chạy migration

docker exec app php artisan migrate
# → Migration table created successfully.
# → Running migrations...
# → 2014_10_12_000000_create_users_table ............... DONE
# → 2014_10_12_100000_create_password_reset_tokens_table DONE
# → 2019_08_19_000000_create_failed_jobs_table ......... DONE
# → 2019_12_14_000001_create_personal_access_tokens_table DONE

Set permissions

docker exec app chown -R www-data:www-data /var/www/html/storage
docker exec app chown -R www-data:www-data /var/www/html/bootstrap/cache
docker exec app chmod -R 775 /var/www/html/storage
docker exec app chmod -R 775 /var/www/html/bootstrap/cache

Bước 7: Test

Truy cập từ browser

# Mở browser hoặc curl
curl -I http://localhost:8000
# → HTTP/1.1 200 OK
# → Server: nginx/1.x.x

# Hoặc mở trình duyệt: http://localhost:8000
# → Thấy trang Laravel Welcome

Verify full stack hoạt động

# 1. Nginx → nhận request
docker logs nginx
# → 172.x.x.x - - "GET / HTTP/1.1" 200

# 2. PHP-FPM → xử lý PHP
docker logs app
# → 172.x.x.x -  "GET /index.php" 200

# 3. MySQL → database accessible
docker exec app php artisan tinker --execute="echo DB::connection()->getDatabaseName();"
# → laravel

# 4. Test tạo data qua tinker
docker exec -it app php artisan tinker
# >>> \App\Models\User::factory()->create();
# >>> \App\Models\User::count();
# → 1

Bước 8: Hiểu request flow

Khi bạn truy cập http://localhost:8000/ trong browser, đây là toàn bộ flow:

1. Browser gửi HTTP GET đến localhost:8000
                │
2. Docker iptables NAT: host:8000 → nginx:80
                │
3. Nginx nhận request, check location rules:
   - URI = "/" → try_files → /index.php
   - Match location ~ \.php$
                │
4. Nginx forward FastCGI request đến app:9000
   - Docker DNS resolve "app" → 172.18.0.3
   - TCP connection đến 172.18.0.3:9000
                │
5. PHP-FPM nhận FastCGI request
   - Chạy /var/www/html/public/index.php
   - Laravel bootstrap: load config, .env
   - Route: / → welcome view
                │
6. (Nếu cần DB) PHP gọi mysql:3306
   - Docker DNS resolve "mysql" → 172.18.0.2
   - TCP connection, chạy SQL query
   - Trả kết quả về PHP
                │
7. PHP-FPM trả HTML response về Nginx
                │
8. Nginx trả HTTP response về browser
                │
9. Browser render trang Laravel Welcome

Bước 9: Quản lý — Stop, Start, Cleanup

Dừng tất cả containers

# Stop theo thứ tự ngược (Nginx → App → MySQL)
docker stop nginx app mysql

# Verify
docker ps
# → Không còn container nào running

Khởi động lại

# Start theo dependency order
docker start mysql
docker start app
docker start nginx

# Test: http://localhost:8000 vẫn hoạt động
# MySQL data vẫn còn (nhờ named volume)
docker exec app php artisan tinker --execute="echo \App\Models\User::count();"
# → 1 (data persist!)

Cleanup hoàn toàn

# Xóa containers
docker rm -f nginx app mysql

# Xóa network
docker network rm laravel-manual

# Xóa volume (CHÚ Ý: mất data MySQL!)
docker volume rm mysql_manual_data

# Xóa custom image
docker rmi laravel-fpm:manual

So sánh: Lệnh thủ công vs Docker Compose

Đây là bảng mapping 1:1 giữa những gì bạn vừa làm bằng tay và docker-compo**se.yml tương đương:**

Bạn gõ bằng tay Docker Compose tương đương
docker network create laravel-manual Compose tự tạo network <project>_default
docker volume create mysql_manual_data volumes: section ở cuối file
docker build -t laravel-fpm:manual . build: . trong service definition
docker run -d --name mysql --network ... -e ... -v ... mysql:8.0 Service mysql: trong docker-compose.yml
docker run -d --name app --network ... -v ... laravel-fpm:manual Service app: trong docker-compose.yml
docker run -d --name nginx --network ... -p ... -v ... nginx:alpine Service nginx: trong docker-compose.yml
docker stop nginx app mysql docker-compose down
docker start mysql && docker start app && docker start nginx docker-compose up -d

docker-compose.yml tương đương

Đây chính xác là file docker-compose.yml thay thế toàn bộ các lệnh bạn vừa gõ:

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
      - "8000:80"                                          # -p 8000:80
    volumes:
      - ./src:/var/www/html                                # -v $(pwd)/src:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app

  app:
    build: .                                               # docker build -t ... .
    volumes:
      - ./src:/var/www/html                                # -v $(pwd)/src:/var/www/html
    depends_on:
      - mysql

  mysql:
    image: mysql:8.0
    environment:                                           # -e flags
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: laravel
    volumes:
      - mysql_data:/var/lib/mysql                          # -v mysql_data:...

volumes:                                                   # docker volume create
  mysql_data:

# network: Compose tự tạo "<folder-name>_default" (custom bridge)
# DNS: mỗi service name = DNS hostname trong network

Thay vì ~10 lệnh docker, bạn chỉ cần:

docker-compose up -d      # = tạo network + volume + build + run tất cả
docker-compose down        # = stop + rm tất cả containers + network

Troubleshooting

Lỗi phổ biến và cách fix

  • 502 Bad Gateway từ Nginx

    Nguyên nhân: Nginx không kết nối được PHP-FPM.

    # Kiểm tra app container đang chạy
    docker ps | grep app
    
    # Kiểm tra logs PHP-FPM
    docker logs app
    
    # Kiểm tra DNS resolution từ nginx
    docker exec nginx ping -c 2 app
    
    # Kiểm tra cả 2 container cùng network
    docker network inspect laravel-manual
    

    Fix: Đảm bảo cả 2 container dùng --network laravel-manual.

  • SQLSTATE[HY000] [2002] Connection refused — PHP không kết nối MySQL

    Nguyên nhân: MySQL chưa ready hoặc container không cùng network.

    # Kiểm tra MySQL ready
    docker exec mysql mysqladmin ping -uroot -psecret
    
    # Kiểm tra .env
    docker exec app cat /var/www/html/.env | grep DB_HOST
    # → DB_HOST=mysql (KHÔNG phải 127.0.0.1)
    
    # Test connection trực tiếp
    docker exec app php -r "new PDO('mysql:host=mysql;dbname=laravel', 'root', 'secret'); echo 'OK';"
    

    Fix: Đợi MySQL ready (~15s lần đầu). Kiểm tra DB_HOST=mysql trong .env.

  • Permission denied — Laravel storage/logs

    Nguyên nhân: UID mismatch giữa host user và www-data trong container (Bài 5).

    # Fix permissions
    docker exec app chown -R www-data:www-data /var/www/html/storage
    docker exec app chmod -R 775 /var/www/html/storage
    docker exec app chmod -R 775 /var/www/html/bootstrap/cache
    
  • File not found — Nginx trả lỗi 404

    Nguyên nhân: Source code chưa mount đúng, hoặc root path sai.

    # Kiểm tra source có trong container
    docker exec nginx ls /var/www/html/public/index.php
    docker exec app ls /var/www/html/public/index.php
    
    # Kiểm tra nginx config root
    docker exec nginx cat /etc/nginx/conf.d/default.conf | grep root
    # → root /var/www/html/public;
    

    Fix: Đảm bảo bind mount path chính xác, nginx root trỏ đến /var/www/html/public.


Checklist kiểm tra hoàn thành

  • Build được custom PHP-FPM image với extensions cần thiết.
  • Tạo được custom bridge network.
  • Chạy được 3 containers (MySQL, PHP-FPM, Nginx) kết nối cùng network.
  • Laravel accessible tại http://localhost:8000.
  • php artisan migrate chạy thành công (PHP → MySQL qua Docker network).
  • Sửa code trên host → browser phản ánh ngay (Bind mount hoạt động).
  • Stop + rm containers → start lại → MySQL data vẫn còn (Named volume hoạt động).
  • Giải thích được từng flag trong lệnh docker run tương ứng bài nào.
  • Hiểu docker-compose.yml tương đương thay thế những lệnh nào.

Kết nối bài tiếp theo

Bạn vừa trải qua sự phức tạp khi quản lý multi-container bằng tay: phải tạo network, volume, gõ từng lệnh dài, nhớ thứ tự start. Ở Bài 8: Docker Compose, bạn sẽ học cách gói tất cả vào 1 file YAML — và hiểu tại sao Docker Compose tồn tại.

Bài viết liên quan

Đang cập nhật...