Thực hành: Deploy Laravel bằng GitLab CI/CD + Docker
Hướng dẫn thực hành, áp dụng chiến lược blue-green deployment (đã phân tích ở chien-luoc-deployment.md) cho ứng dụng Laravel, container hoá bằng Docker, deploy qua GitLab CI/CD lên 1 VPS.
Cách dùng branch/tag để đưa code qua các môi trường (test → staging → production) xem git-workflow.md.
Vì sao blue-green mà không phải rolling? Rolling deployment (thay từng instance một) cần orchestrator biết thay lần lượt. Docker Compose không làm được: nó đọc
deploy.replicasnhưng bỏ quadeploy.update_config(parallelism,order: start-first) — mấy field đó chỉ Docker Swarm mới hiểu.docker compose up -ddừng hết container cũ rồi mới dựng container mới → vẫn có khoảng 502. Trên 1 VPS + Compose, blue-green là cách duy nhất đạt zero-downtime thật mà không phải kéo cả Swarm/K8s vào. Chi tiết ở mục 3.4.
Kiến trúc tổng quan
flowchart TD
REPO["GitLab repo<br/>(push code)"] --> PIPE
subgraph PIPE["GitLab CI/CD pipeline"]
direction LR
B["build<br/>composer install<br/>npm build (Vite)"] --> TE["test<br/>phpunit/pest, lint"] --> PK["package<br/>build image → Registry"] --> DP["deploy<br/>SSH vào VPS → deploy.sh"]
end
DP --> PROXY
subgraph VPS["VPS (1 máy)"]
PROXY["proxy — Nginx :80/:443<br/>serve asset tĩnh + fastcgi_pass tới màu active"]
PROXY -->|"active"| BLUE["app_blue — php-fpm<br/>v1.4"]
PROXY -.->|"idle, chờ rollback"| GREEN["app_green — php-fpm<br/>v1.5"]
BLUE --> SHARED
GREEN --> SHARED
SHARED["DÙNG CHUNG, không nhân đôi:<br/>PostgreSQL · Redis<br/>volume storage · volume assets"]
WK["worker + scheduler<br/>chạy image của màu active"] --> SHARED
end
Chỉ container app nhân đôi. Database, Redis, file upload dùng chung → đây là lý do migration vẫn phải backward-compatible (mục 3.5).
1. Dockerfile — multi-stage build (build nhiều giai đoạn)
Multi-stage giúp image production không mang theo composer, node, dev dependencies — image nhỏ, ít bề mặt tấn công (attack surface) hơn.
# ---- Stage 1: build asset frontend (Vite) ----
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources ./resources
COPY vite.config.js ./
RUN npm run build
# ---- Stage 2: cài dependency PHP ----
FROM composer:2 AS vendor
WORKDIR /app
COPY database ./database
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --optimize-autoloader
# ---- Stage 3: runtime image ----
FROM php:8.3-fpm-alpine
RUN apk add --no-cache postgresql-dev \
&& docker-php-ext-install pdo pdo_pgsql opcache
WORKDIR /var/www/html
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
RUN php artisan config:cache \
&& mkdir -p /srv/assets/public \
&& chown -R www-data:www-data storage bootstrap/cache /srv/assets
USER www-data
EXPOSE 9000
# Chép asset của bản này vào volume dùng chung rồi mới chạy php-fpm.
# Nginx serve tĩnh từ volume đó, nên asset của các bản cũ vẫn còn (xem 6.5).
CMD ["sh", "-c", "cp -r public/. /srv/assets/public/ && exec php-fpm"]
--no-dev: không cài dev dependency (phpunit, debugbar...) vào image production — nhẹ hơn, giảm rủi ro leak thông tin debug.opcache: bắt buộc bật cho PHP production — cache bytecode đã compile, giảm đáng kể response time (không cần parse lại PHP mỗi request).chown /srv/assets: named volume mount vào chỗ này sẽ thừa kế quyền của thư mục trong image lúc tạo lần đầu →www-dataghi được, không cần chạy container bằng root.
2. .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
test:
stage: test
image: php:8.3-cli
script:
- composer install --no-interaction
- php artisan test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
build:
stage: build
image: docker:24
services:
- docker:24-dind # dind = docker-in-docker, để build image trong runner
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy:
stage: deploy
image: alpine:3.20
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh && ssh-keyscan -H $SERVER_HOST >> ~/.ssh/known_hosts
script:
- ssh $SERVER_USER@$SERVER_HOST "/srv/app/deploy.sh $IMAGE_TAG"
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # deploy production cần bấm tay, tránh push nhầm là live ngay
Toàn bộ logic switch nằm trong deploy.sh trên server (mục 3.3), không nhét
vào chuỗi SSH — dễ đọc, dễ chạy tay lúc sự cố, và set -e hoạt động đúng.
Biến bí mật (secrets) — khai báo ở GitLab: Settings → CI/CD → Variables, tick Protected + Masked, không hardcode trong file:
SSH_PRIVATE_KEY,SERVER_HOST,SERVER_USER- Credentials database/Redis (đưa qua
.envtrên server, không đưa vào image — image build ra không được chứa secret vì ai pull được image là đọc được).
3. Blue-green trên 1 VPS — cấu hình cụ thể
Ý tưởng: 2 service app chạy song song, Nginx chỉ trỏ tới một màu. Deploy = dựng màu đang rảnh (idle) bằng image mới, test nó, rồi đổi 1 dòng config Nginx và reload. Rollback = đổi ngược lại dòng đó.
3.1 docker-compose.yml
services:
proxy:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "127.0.0.1:8081:8081" # cổng test riêng app_green — chỉ nghe localhost
- "127.0.0.1:8082:8082" # cổng test riêng app_blue
volumes:
- ./nginx/app.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/active-upstream.conf:/etc/nginx/active-upstream.conf:ro
- assets:/srv/assets:ro
app_blue:
image: ${BLUE_TAG}
restart: unless-stopped
env_file: .env
volumes:
- assets:/srv/assets
- storage:/var/www/html/storage
app_green:
image: ${GREEN_TAG}
restart: unless-stopped
env_file: .env
volumes:
- assets:/srv/assets
- storage:/var/www/html/storage
worker:
image: ${ACTIVE_TAG} # luôn chạy code của màu đang active
restart: unless-stopped
env_file: .env
volumes: [storage:/var/www/html/storage]
command: php artisan queue:work --tries=3
stop_grace_period: 60s # đủ thời gian job đang chạy kết thúc êm
scheduler:
image: ${ACTIVE_TAG}
restart: unless-stopped
env_file: .env
volumes: [storage:/var/www/html/storage]
command: php artisan schedule:work # 1 instance duy nhất, tránh cron chạy trùng
volumes:
assets:
storage:
BLUE_TAG, GREEN_TAG, ACTIVE_TAG để luôn trong /srv/app/.env cùng chỗ
với secret — Compose tự đọc .env cho việc thay biến, không cần
--env-file. Mấy biến thừa này lọt vào container cũng vô hại.
Không service nào publish port ra ngoài trừ proxy — app chỉ nói chuyện qua
mạng nội bộ của Compose, gọi nhau bằng tên service (app_blue:9000).
3.2 Nginx
nginx/active-upstream.conf — file quyết định màu nào đang live, chỉ 1 dòng:
server app_blue:9000;
nginx/app.conf:
upstream app_fpm {
include /etc/nginx/active-upstream.conf; # deploy.sh ghi đè file này
}
server {
listen 80;
root /srv/assets/public; # asset từ volume dùng chung, có cả bản cũ
index index.php;
location /build/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control immutable;
}
location / {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app_fpm;
fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
include fastcgi_params;
}
}
# 2 cổng test cố định cho từng màu — dùng để smoke test màu idle
# TRƯỚC khi switch, đi đúng đường thật: nginx → php-fpm → DB.
server {
listen 8081; # app_green
location / {
fastcgi_pass app_green:9000;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
include fastcgi_params;
}
}
server {
listen 8082; # app_blue
location / {
fastcgi_pass app_blue:9000;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
include fastcgi_params;
}
}
Điểm quan trọng: proxy chỉ serve file tĩnh từ volume assets và chuyển PHP
sang php-fpm. Blue và green mỗi bản có public/build/app-<hash>.js khác nhau,
cả hai cùng nằm trong volume nên không đá nhau (hash khác tên file).
3.3 /srv/app/deploy.sh
#!/bin/sh
set -eu
cd /srv/app
NEW_TAG="$1"
ACTIVE=$(cat active-color) # "blue" hoặc "green"
if [ "$ACTIVE" = blue ]; then IDLE=green; PORT=8081; else IDLE=blue; PORT=8082; fi
IDLE_VAR=$(echo "$IDLE" | tr a-z A-Z)_TAG # BLUE_TAG / GREEN_TAG
echo "==> active=$ACTIVE, deploy $NEW_TAG lên $IDLE"
# 1. Dựng màu idle bằng image mới. Màu active không bị đụng tới.
sed -i "s|^$IDLE_VAR=.*|$IDLE_VAR=$NEW_TAG|" .env
docker compose pull "app_$IDLE"
docker compose up -d --no-deps "app_$IDLE"
# 2. Migration expand-only, chạy TRƯỚC khi switch.
# Lúc này cả 2 version code đều đang sống → schema phải cả 2 đọc được (3.5).
docker compose exec -T "app_$IDLE" php artisan migrate --force
# 3. Smoke test màu idle qua đúng đường thật. Không đạt thì dừng, không switch.
i=0
until curl -fs "http://127.0.0.1:$PORT/up" >/dev/null; do
i=$((i + 1))
[ "$i" -ge 15 ] && { echo "!! smoke test FAIL — giữ nguyên $ACTIVE"; exit 1; }
sleep 2
done
# 4. SWITCH — đổi 1 dòng rồi reload. Nginx reload là graceful:
# request đang chạy dở vẫn xong trên worker process cũ.
echo "server app_$IDLE:9000;" > nginx/active-upstream.conf
docker compose kill -s HUP proxy
echo "$IDLE" > active-color
# 5. Worker + scheduler chuyển sang code mới.
sed -i "s|^ACTIVE_TAG=.*|ACTIVE_TAG=$NEW_TAG|" .env
docker compose up -d --no-deps worker scheduler
echo "==> xong, đang chạy $IDLE ($NEW_TAG). Màu $ACTIVE vẫn sống để rollback."
Màu cũ cố ý không stop. Giữ nó ít nhất 30 phút; rollback trong thời gian đó
chỉ mất một lệnh (mục 4). Dọn sau: docker compose stop app_$ACTIVE.
Lần đầu setup: tạo active-color chứa blue, và trong .env thêm 3 dòng
BLUE_TAG=, GREEN_TAG=, ACTIVE_TAG= (cho tạm cùng 1 tag).
3.4 Tại sao cách này mới là zero-downtime thật
Compose "rolling" (replicas: 2) Blue-green
[v1][v1] [v1 active][v2 đang dựng]
[ ][ ] ← stop cả 2 → 502 [v1 active][v2 đã test xong]
[v2][v2] [v1 idle ][v2 active] ← đổi 1 dòng nginx
Ba điểm khác nhau, đều quan trọng:
- Không có khoảnh khắc nào không container nào phục vụ. Container mới dựng xong và test xong rồi mới nhận traffic.
- Test được trước khi mở cửa. Cổng 8081/8082 cho phép gọi thẳng vào màu
idle qua đúng đường nginx → php-fpm → DB.
--waitcủa Compose chỉ chờ healthcheck của container, không chứng minh đường đi thật chạy được. - Rollback tính bằng giây, không cần pull/build lại image. Container cũ vẫn đang chạy sẵn.
Đổi lại: tốn RAM cho 2 bản app cùng lúc. Ước lượng RAM app × 2 + DB + Redis + dư 20%; VPS đang chạy sát RAM thì lúc switch sẽ dính OOM killer (kernel giết process khi hết RAM).
3.5 Migration an toàn (2 version code cùng sống)
Blue-green vẫn không thoát khỏi ràng buộc này, vì database dùng chung. Có
2 quãng thời gian cả v1 và v2 cùng nói chuyện với 1 schema: giữa bước 2 và 4
của deploy.sh, và toàn bộ thời gian giữ màu cũ để rollback.
Áp dụng expand-contract pattern:
- Expand (deploy 1): thêm cột mới, KHÔNG xoá/đổi tên cột cũ. Code mới đọc/ghi được cả cột cũ và cột mới.
- Chạy
migrate --forcetrên màu idle, trước khi switch. - Contract (deploy sau, khi chắc chắn không rollback về bản cũ nữa): xoá cột/bảng cũ trong 1 migration riêng.
Chi tiết từng loại thay đổi schema (đổi tên cột, đổi kiểu, thêm constraint, thêm index không khoá bảng) ở migration-postgresql-zero-downtime.md.
Chạy migrate an toàn khi có nhiều runner/replica (--isolated, exit code, lock
kẹt) và chiến lược rollback ở migration-ci-cd.md.
3.6 Queue worker & scheduler
- Worker phải khớp màu active. Nếu để worker chạy code v1 trong khi web đã
là v2, job do v2 đẩy vào queue (payload mới) sẽ được v1 xử lý → lỗi
Unserializehoặc job chạy sai logic. Bước 5 củadeploy.shlo việc này. - Không chạy worker ở cả 2 màu. Hai worker cùng đọc 1 queue = job chạy 2
lần. Đây là lý do
workerlà service riêng dùng${ACTIVE_TAG}, không phải chạy kèm trongapp_blue/app_green. stop_grace_period: 60s: khi Compose thay worker, nó gửi SIGTERM; Laravel worker hoàn thành job hiện tại rồi mới thoát êm (graceful). Grace period ngắn hơn job dài nhất → job bị cắt giữa chừng.- Scheduler đúng 1 instance.
schedule:workchạy ở service riêng; chạy trong cả 2 màu là cron job chạy trùng.
4. Rollback
Màu cũ vẫn đang chạy sẵn, nên rollback là đổi ngược upstream — không pull, không build, không chờ container khởi động:
/srv/app/rollback.sh:
#!/bin/sh
set -eu
cd /srv/app
ACTIVE=$(cat active-color)
if [ "$ACTIVE" = blue ]; then PREV=green; else PREV=blue; fi
PREV_VAR=$(echo "$PREV" | tr a-z A-Z)_TAG
docker compose ps --status running --services | grep -qx "app_$PREV" \
|| { echo "!! app_$PREV đã bị stop, không rollback nhanh được"; exit 1; }
echo "server app_$PREV:9000;" > nginx/active-upstream.conf
docker compose kill -s HUP proxy
echo "$PREV" > active-color
# worker/scheduler quay về code cũ
sed -i "s|^ACTIVE_TAG=.*|ACTIVE_TAG=$(grep "^$PREV_VAR=" .env | cut -d= -f2-)|" .env
docker compose up -d --no-deps worker scheduler
echo "==> đã rollback về $PREV"
Cái rollback này KHÔNG lùi được: migration đã chạy. Đây chính là lý do migration phải expand-only (3.5) — code cũ vẫn chạy được trên schema mới.
Nếu màu cũ đã bị stop (deploy quá lâu rồi), rollback chậm hơn: chạy lại
deploy.sh với tag image cũ. Image được tag theo $CI_COMMIT_SHORT_SHA
(commit cụ thể) nên vẫn còn trong registry, không cần build lại — đây là lý do
nên tag theo commit SHA thay vì latest.
5. Những chỗ hay bị bỏ sót trong thực tế
- Đừng chạy
artisan config:cachetrong container đang nhận traffic. Dockerfile đãconfig:cachelúc build. Chạy lại lúc runtime là ghi đè file cache giữa lúc request khác đang đọc nó → lỗi ngẫu nhiên rất khó lần. - Proxy không được serve
public/bằng bind-mount từ source code. Nó phải đọc volumeassets— nơi cả 2 màu cùng đổ asset vào. Bind-mount 1 thư mục source là blue và green đá nhau. - OPcache không tự invalidate khi đổi container — không phải vấn đề với
cách deploy này (mỗi container có OPcache riêng, container mới = cache
trắng), nhưng là bug kinh điển nếu deploy kiểu ghi đè code lên container
đang chạy (
docker cp/rsync — không khuyến khích). - Storage/uploads không nên nằm trong container — container là ephemeral
(dùng xong huỷ). Ở compose trên,
storagelà volume dùng chung cho cả 2 màu; production thật nên đẩy hẳn ra S3-compatible storage. .envlà single point of failure. Cả blue lẫn green đọc chung 1 file. Sửa sai 1 dòng là hỏng cả hai màu, rollback upstream không cứu được. Backup file này, và đổi.envthì đổi ở 1 deploy riêng, không kèm release code.
6. Nếu app dùng Livewire
Livewire là stateful (có trạng thái giữ qua nhiều request): mỗi component
được render kèm một snapshot (JSON chứa toàn bộ public property), trình
duyệt gửi snapshot đó ngược lên /livewire/update mỗi lần user bấm nút.
Server hydrate (dựng lại object PHP từ snapshot) rồi chạy method.
Nghĩa là: request sau phải hiểu được snapshot do request trước sinh ra. Blue-green có 2 quãng 2 version cùng sống — lúc switch (request đang mở trên trình duyệt vẫn giữ snapshot của màu cũ) và suốt thời gian giữ màu cũ để rollback. Đúng chỗ vỡ.
Chi tiết vận hành (sticky routing, tách metrics theo version) nằm ở canary-laravel-livewire.md. Mục này chỉ liệt kê những gì cấu hình ở trên phải thoả.
6.1 APP_KEY — đừng đổi lúc deploy
Snapshot kèm một checksum ký bằng HMAC dựa trên APP_KEY. Sai checksum →
Livewire ném CorruptComponentPayloadException và chặn request.
Với kiến trúc ở trên (1 VPS, 1 file .env, blue và green cùng env_file) thì
APP_KEY giống nhau → checksum luôn hợp lệ, không cần làm gì. Chỉ vỡ khi
rotate (đổi) APP_KEY — và lúc đó blue-green không cứu được, vì mọi snapshot
đang mở trên trình duyệt user đều chết.
6.2 Expand-contract cho public property, không chỉ cho DB
Đây mới là lỗi hay gặp — và checksum không bắt được, vì snapshot không hề bị sửa, chỉ là code đọc nó đã đổi:
10:00 blue (v1) render → snapshot { filters: [], page: 1 } ✔ checksum đúng
10:05 switch sang green (v2): component bỏ $filters, đổi $page → $currentPage
10:07 user bấm nút → POST snapshot v1 → green hydrate → 500, trang chết
Áp đúng pattern expand-contract ở mục 3.5, nhưng cho property: deploy N thêm property mới và giữ property cũ; deploy N+2 mới xoá.
6.3 Session + cache bắt buộc lên Redis
SESSION_DRIVER=file → blue và green mỗi container một kho session riêng
(volume storage dùng chung có đỡ, nhưng cache config/route thì không). Lúc
switch, mỗi request Livewire đều kèm CSRF token — user không tìm thấy session
của mình → 419 Page Expired hàng loạt. Đặt SESSION_DRIVER=redis,
CACHE_STORE=redis trong .env trên server. Redis dùng chung nên switch
màu không ai bị đăng xuất.
6.4 File upload tạm (livewire-tmp)
Livewire upload 2 nhịp: nhịp 1 lưu file tạm vào storage/app/livewire-tmp/;
nhịp 2 (submit form) là request khác — nếu rơi đúng lúc switch màu thì sang
container khác. Volume storage dùng chung ở mục 3.1 xử lý được việc này;
production thật nên đặt LIVEWIRE_DISK=s3.
6.5 Asset build cũ bị 404
Trang Livewire thường mở rất lâu (dashboard, form dài). Trình duyệt vẫn giữ
HTML cũ trỏ tới /build/assets/app-abc123.js; nếu asset đó biến mất → 404 →
mọi tương tác chết im, không báo lỗi.
Cách ở mục 1 + 3.2 đã xử lý: mỗi container chép asset vào volume assets
lúc khởi động thay vì thay thế, Nginx serve từ volume đó → asset của các bản
cũ vẫn còn. Nhớ dọn định kỳ (vài ngày một lần, không phải mỗi deploy), hoặc
đẩy asset lên CDN/S3.
Checklist trước khi deploy app Livewire
-
SESSION_DRIVER+CACHE_STORE=redis, không phảifile -
APP_KEYkhông đổi trong lần deploy này - Không có public property nào bị xoá/đổi tên trong release này
- Migration trong release này là expand-only (không
DROP, khôngRENAME) - Volume
assetschưa bị dọn sạch từ lần deploy trước
7. Case thực tế có thể gặp và cách xử lý
Case 1 — Switch xong nhưng traffic vẫn vào màu cũ.
nginx -s reload chạy nhưng file upstream sửa không phải file Nginx đang
include, hoặc container proxy mount đường dẫn khác.
Xử lý ngay: docker compose exec proxy nginx -T | grep upstream để xem
cấu hình Nginx đang thật sự chạy, không tin file trên disk.
Ngăn lần sau: deploy.sh sau khi switch phải gọi /health qua đường public
và kiểm tra header X-Release trả về đúng màu mới; không đúng thì fail job.
Case 2 — Màu idle dựng xong, smoke test pass, switch xong thì 502.
Smoke test gọi thẳng container (localhost:9001) nên bỏ qua đúng chỗ hỏng:
proxy không resolve được tên service mới, hoặc PHP-FPM listen sai socket.
Xử lý ngay: switch ngược lại, xem docker compose logs proxy.
Ngăn lần sau: smoke test phải đi đúng đường thật proxy → php-fpm → DB
(mục 3.2 để sẵn 2 cổng test cho việc này), không gọi tắt.
Case 3 — Migration fail giữa chừng, DB ở trạng thái nửa vời.
PostgreSQL cho DDL trong transaction, nhưng một file migration Laravel có
nhiều lệnh và CREATE INDEX CONCURRENTLY không chạy trong transaction
được — fail là index còn lại ở trạng thái INVALID.
Xử lý ngay: không switch. \d+ bảng tìm index INVALID → DROP INDEX
rồi tạo lại. Màu active vẫn đang chạy schema cũ nên dịch vụ chưa sao.
Ngăn lần sau: mỗi migration một việc; CONCURRENTLY tách ra file riêng;
SET lock_timeout để nó fail nhanh thay vì treo. Chi tiết:
migration-postgresql-zero-downtime.md.
Case 4 — Deploy fail vì VPS hết disk.
Mỗi release để lại một image; volume assets giữ asset của các bản cũ.
Xử lý ngay: docker image prune -af --filter "until=168h", xoá thư mục asset
cũ hơn vài ngày.
Ngăn lần sau: cho cron dọn hàng tuần + cảnh báo khi disk > 80%. Đừng dọn
theo mỗi lần deploy — asset bản cũ còn cần cho tab đang mở (mục 6.5).
Case 5 — Web đã sang bản mới nhưng job vẫn chạy code cũ.
Quên restart worker, hoặc worker vẫn gắn với màu cũ.
Xử lý ngay: restart worker của màu mới, php artisan queue:restart để worker
đang chạy tự thoát sau job hiện tại.
Ngăn lần sau: worker/scheduler nằm trong cùng bước switch của deploy.sh
(mục 3.3 bước 4), không phải việc làm tay sau đó.
Case 6 — Đang deploy màu green thì màu blue (đang chạy) bị restart.
docker compose up -d mà không giới hạn service sẽ recreate mọi service có
cấu hình thay đổi — kể cả proxy và màu đang active.
Xử lý ngay: dịch vụ tự lên lại sau vài giây, nhưng đó là downtime thật.
Ngăn lần sau: luôn docker compose up -d --no-deps <service-màu-idle>. Chỉ
gọi tên service cần dựng.
Case 7 — Sau switch, user đang mở tab bị lỗi "checksum không khớp" (Livewire).
Snapshot render bởi màu cũ bị gửi tới màu mới có APP_KEY khác, hoặc public
property đã bị đổi/xoá trong release này.
Xử lý ngay: switch ngược về màu cũ; user tải lại trang là hết.
Ngăn lần sau: APP_KEY không đổi khi deploy; đổi public property theo
expand-contract như đổi schema (mục 6.2).
Case 8 — Cần rollback nhưng màu cũ đã bị tắt để tiết kiệm RAM. Rollback lẽ ra 1 giây thành 3 phút chờ container dựng lại. Xử lý: giữ màu cũ chạy ít nhất 30–60 phút sau switch. Đó chính là thứ mình trả tiền khi chọn blue-green — tắt sớm là vứt bỏ lợi ích duy nhất của nó.
Case 9 — VPS hết RAM lúc 2 màu cùng chạy, OOM killer giết container đang phục vụ.
Xử lý ngay: tắt màu idle, giảm pm.max_children của PHP-FPM.
Ngăn lần sau: tính RAM theo đỉnh lúc chồng lấn (2 màu + DB + Redis +
build), đặt mem_limit cho từng service để container idle không giết được
container active.
8. Câu hỏi phụ interviewer hay hỏi tiếp
"Blue-green trên 1 VPS có phải blue-green thật không?" Là blue-green ở tầng ứng dụng: 2 bộ container app, đổi upstream để switch, rollback tức thì. Không phải blue-green hạ tầng: DB, Redis, VPS dùng chung — VPS chết là chết cả hai, và migration vẫn phải backward-compatible. Nói rõ giới hạn này là điểm cộng khi phỏng vấn.
"Nginx reload có làm rớt request đang chạy không?"
Không. reload là graceful: Nginx dựng worker process mới đọc config mới,
worker cũ phục vụ nốt request đang mở rồi tự thoát. Cái làm rớt request là
restart, không phải reload.
"Sao không dùng docker compose up --scale cho rolling?"
Compose bỏ qua deploy.update_config — nó dừng hết container cũ rồi mới dựng
bản mới, tức là recreate chứ không phải rolling. Muốn rolling thật phải có
orchestrator (Swarm/K8s/ECS).
"Migration chạy ở bước nào, và vì sao?"
Trước khi switch, khi màu idle đã dựng xong và màu active vẫn đang phục vụ.
Lúc đó hai version code cùng sống, nên migration chỉ được ADD, không
DROP/RENAME. Chạy đúng một lần, từ một container, không chạy trong
entrypoint (nhiều container = chạy song song = race condition).
"Sao build image trong CI mà không build trên server?"
Ba lý do: build ăn CPU/RAM sẽ ảnh hưởng chính app đang phục vụ; build trên
server nghĩa là mỗi môi trường có một artifact khác nhau (thứ test không phải
thứ chạy); và cần image sẵn trong registry thì rollback mới nhanh được —
docker pull bản cũ nhanh hơn build lại rất nhiều.
"Secret quản lý thế nào?"
.env nằm trên server, ngoài repo, quyền 600. Giá trị nhạy cảm cho CI để ở
GitLab CI variables (Masked + Protected). Đổi .env là một lần deploy riêng,
không kèm release code — để khi hỏng còn biết nguyên nhân là gì.
"Zero-downtime cho queue worker thì sao?"
Worker không nhận HTTP nên không cần switch. Cách an toàn là queue:restart
(worker chạy nốt job hiện tại rồi thoát) + stop_grace_period dài hơn job lâu
nhất. Job phải idempotent, vì bị giết giữa chừng là nó sẽ chạy lại.
"Cần VPS bao nhiêu RAM?"
Tính theo lúc chồng lấn: RAM 1 màu × 2 + DB + Redis + Nginx + chỗ trống ~20%.
Ứng dụng Laravel cỡ vừa thường 4 GB là chật, 8 GB thoải mái. Nếu đang sát
trần, mem_limit cho từng service là bắt buộc.
"Khi nào thì nên bỏ cách này để lên Kubernetes?" Khi cần nhiều hơn 1 máy (HA thật, VPS chết vẫn sống), khi cần autoscale, hoặc khi có nhiều service phải deploy độc lập. Với 1 app + 1 VPS + team 3–4 người, K8s thêm rất nhiều việc vận hành mà không giải quyết vấn đề nào đang có.
Tóm tắt để trả lời phỏng vấn
"Trên 1 VPS chạy Docker Compose thì không có rolling thật, nên tôi làm blue-green ở tầng app: hai bộ container
app_blue/app_green, Nginx trỏ upstream vào màu đang active, deploy là dựng màu idle bằng image mới → migration expand-only → smoke test đi đúng đường thật → đổi một dòng upstream rồinginx -s reload(graceful nên không rớt request). Rollback là đổi ngược dòng đó, dưới một giây — với điều kiện màu cũ còn chạy, nên tôi giữ nó thêm 30–60 phút. Image build một lần trong CI và promote qua các môi trường, cấu hình đi bằng biến env lúc runtime. Ba chỗ tôi để ý nhất: migration phải backward-compatible vì có lúc hai version cùng dùng một DB, worker/scheduler phải chuyển màu cùng lúc và chỉ chạy một bản, và với Livewire thìAPP_KEYkhông được đổi còn public property phải theo expand-contract như schema."