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.ymlbạ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:3306nế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ênapp(PHP-FPM) trên port 9000- Docker DNS (Bài 6) resolve
app→172.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-manualFix: Đả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=mysqltrong.env. -
Permission denied — Laravel storage/logs
Nguyên nhân: UID mismatch giữa host user và
www-datatrong 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 migratechạ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 runtương ứng bài nào. - Hiểu
docker-compose.ymltươ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.