Thực hành: Canary deployment cho Laravel + Livewire
Tài liệu này hướng dẫn dựng CI/CD theo canary deployment (đưa bản mới ra cho một phần nhỏ traffic thật trước, theo dõi, rồi tăng dần) cho ứng dụng Laravel dùng Livewire, chỉ bằng tool open-source. Phần cuối là các vấn đề gặp khi vận hành thật — phần này quan trọng hơn phần config.
Nền tảng chiến lược xem chien-luoc-deployment.md. Pipeline cơ bản (Dockerfile, GitLab CI, rollback theo image tag) xem laravel-gitlab-cicd.md — doc này chỉ nói phần thêm vào để làm canary.
0. Đọc trước khi làm: canary có đáng không?
Canary đắt hơn rolling rất nhiều — không phải đắt tiền máy, mà đắt công vận hành: phải có metrics tách theo version, phải có sticky routing, phải giữ 2 version code sống chung nhiều giờ.
Với Laravel + Livewire, canary còn khó hơn app REST bình thường vì Livewire là stateful giữa các request (giải thích ở mục 2).
Rẻ hơn mà được 80% lợi ích:
| Cách | Chi phí | Được gì |
|---|---|---|
Rolling + /up healthcheck |
Thấp | Zero-downtime, không giới hạn blast radius |
| Laravel Pennant (feature flag — bật/tắt tính năng cho % user ngay trong code) | Thấp | Giới hạn blast radius ở mức tính năng, rollback = tắt cờ, không đụng hạ tầng |
| Canary deployment | Cao | Giới hạn blast radius ở mức toàn bộ bản build (bắt được cả lỗi hạ tầng, lỗi thư viện, lỗi migration) |
→ Nếu chỉ cần giảm rủi ro cho một tính năng mới, dùng Pennant, đừng dựng canary. Canary đáng làm khi: traffic đủ lớn (≥ vài nghìn request/phút vào giờ cao điểm), đã có sẵn Prometheus + Grafana, và mỗi lần release lỗi là mất tiền thật.
Phần còn lại của doc giả định bạn đã quyết định làm canary.
Từ điển nhanh
| Từ | Nghĩa |
|---|---|
| stable | Bản đang chạy, phục vụ đa số traffic |
| canary | Bản mới, nhận % traffic nhỏ (tên gọi từ "chim hoàng yến trong hầm mỏ") |
| weight | % traffic chia cho canary |
| sticky routing | Ghim một user vào đúng một bản trong suốt phiên làm việc |
| promote | Đẩy canary lên 100%, biến nó thành stable mới |
| abort | Cắt canary về 0%, quay lại stable |
| blast radius | Phạm vi ảnh hưởng khi bản mới có bug |
| snapshot (Livewire) | Trạng thái component được serialize thành JSON, gửi kèm mỗi request |
| hydrate (Livewire) | Dựng lại object PHP từ snapshot ở request kế tiếp |
1. Kiến trúc
flowchart TD
CI["GitLab CI/CD<br/>build → test → push image (tag = commit SHA)"]
CI --> NG
subgraph VPS["VPS"]
NG["Nginx edge<br/>chia traffic + ghim sticky"]
NG -->|"95%"| S["app-stable<br/>(image cũ)"]
NG -->|"5%"| C["app-canary<br/>(image mới)"]
W["worker-stable"]
SCH["scheduler<br/>(CHỈ chạy ở stable)"]
S --> DB[("PostgreSQL / MySQL")]
C --> DB
W --> DB
S --> R[("Redis<br/>session + cache")]
C --> R
LOG["access.log<br/>(có nhãn pool)"] --> EXP["nginxlog-exporter"] --> PROM["Prometheus"] --> GRAF["Grafana + Alertmanager"]
NG -.-> LOG
end
Tool open-source dùng trong bài:
- GitLab CI/CD (hoặc Gitea Actions / Woodpecker CI) — chạy pipeline.
- Docker Compose — chạy 2 pool song song trên 1 VPS.
- Nginx — chia traffic theo %, ghim sticky, ghi log có nhãn pool.
- Prometheus + Grafana + Alertmanager — đo và cảnh báo.
- prometheus-nginxlog-exporter — đọc access log của Nginx, xuất metrics
theo nhãn
pool. (Nginx bản open-source không có metrics per-upstream sẵn, đây là cách rẻ nhất để có.) - GlitchTip (bản open-source tương thích Sentry) — gom exception, gắn tag
releaseđể tách lỗi canary và stable.
Nếu chưa gắn chặt với Nginx: Traefik cho sẵn cả 3 thứ (weighted service, sticky cookie, Prometheus metrics theo service) — ít config hơn hẳn. Bài này dùng Nginx vì đó là stack đang có.
2. Phần khó nhất: Livewire không chịu được request nhảy version
Đây là điểm mà 90% hướng dẫn canary trên mạng bỏ qua, vì họ giả định app là REST stateless (mỗi request độc lập).
Livewire hoạt động thế nào: lần load trang đầu tiên, server render
component và nhúng vào HTML một snapshot — JSON chứa toàn bộ property của
component, kèm một checksum ký bằng HMAC dựa trên APP_KEY. Mỗi lần user
bấm nút, trình duyệt POST snapshot đó về endpoint update
(/livewire/update; Livewire 4 đổi thành /livewire-{hash}/…, hash sinh từ
APP_KEY). Server hydrate (dựng lại object từ snapshot), chạy method, trả
snapshot mới.
Nghĩa là: request thứ 2 bắt buộc phải hiểu được snapshot do request thứ 1 tạo ra.
sequenceDiagram
participant U as Trình duyệt
participant N as Nginx (chia 5%)
participant S as app-stable (v1)
participant C as app-canary (v2)
U->>N: GET /orders
N->>S: 95% rơi vào stable
S-->>U: HTML + snapshot v1 (có property $filters)
Note over U: user bấm nút Lọc
U->>N: POST /livewire/update (snapshot v1)
N->>C: 5% rơi vào canary ❌
C-->>U: 500 CorruptComponentPayloadException<br/>hoặc property không khớp
Ba kiểu vỡ cụ thể:
- Checksum sai — nếu 2 pool khác
APP_KEY. → luôn dùng chungAPP_KEY. - Property lệch — v2 xoá/đổi tên một public property, snapshot v1 gửi lên không khớp → lỗi hydrate.
- Component không tồn tại — v2 đổi tên/xoá component, hoặc ngược lại v1 chưa biết component mới.
→ Bắt buộc phải sticky. Chia traffic ngẫu nhiên từng request (kiểu round robin) là hỏng ngay với Livewire.
Ghim sticky đúng cách: header do chính Livewire gửi
Cookie là mức tối thiểu, nhưng cookie có hạn dùng. Trường hợp thật hay gặp: user mở tab từ tối hôm trước, sáng nay bấm nút — cookie đã hết hạn, request rơi sang pool khác, snapshot cũ gặp code mới → lỗi.
Cách chắc chắn: nhúng version vào chính trang đó, rồi cho Livewire gửi kèm mỗi request. Tab cũ mang theo "giấy thông hành" của bản đã render ra nó.
{{-- resources/views/components/layouts/app.blade.php, trước </body> --}}
<script>
document.addEventListener('livewire:init', () => {
Livewire.hook('request', ({ options }) => {
options.headers['X-Release'] = @json(config('app.release'));
});
});
</script>
// config/app.php
'release' => env('APP_RELEASE', 'stable'), // container canary set APP_RELEASE=canary
Thứ tự ưu tiên khi định tuyến: header → cookie → chia % ngẫu nhiên.
3. Cấu hình Nginx edge
/etc/nginx/conf.d/canary.conf — file này do CI sinh ra, chỉ chứa weight:
# weight = 5
split_clients "${remote_addr}${http_user_agent}" $canary_bucket {
5% "canary";
* "stable";
}
map $host $canary_enabled { default "1"; } # 0 = kill switch, kéo hết về stable
Khi abort, CI ghi đè file này thành:
map $host $canary_bucket { default "stable"; }
map $host $canary_enabled { default "0"; }
/etc/nginx/conf.d/app.conf — phần logic, không đổi:
upstream app_stable { server app-stable:8000; keepalive 32; }
upstream app_canary { server app-canary:8000; keepalive 32; }
# ---- ưu tiên 1: header do Livewire gửi ----
map $http_x_release $rel_header {
"canary" "canary";
"stable" "stable";
default "";
}
# ---- ưu tiên 2: cookie ----
map $cookie_release $rel_cookie {
"canary" "canary";
"stable" "stable";
default "";
}
map $rel_header $rel_1 {
"" $rel_cookie; # map value chứa biến — cần Nginx ≥ 1.11
default $rel_header;
}
# ---- ưu tiên 3: chia % ----
map $rel_1 $release {
"" $canary_bucket;
default $rel_1;
}
# ---- kill switch đè lên tất cả ----
map "$canary_enabled$release" $pool {
"1canary" "app_canary";
default "app_stable";
}
log_format canary '$remote_addr $status $request_time "$request" '
'pool=$pool release=$release ua="$http_user_agent"';
server {
listen 80;
server_name app.example.com;
access_log /var/log/nginx/canary.log canary;
# asset build dùng chung cho cả 2 bản — xem mục 6
location /build/ {
alias /srv/assets/build/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
add_header Set-Cookie "release=$release; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax" always;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://$pool;
}
}
Vài điểm đáng nói:
split_clientsbăm theoremote_addr + user_agent, không theo thời gian → cùng một user luôn ra cùng kết quả, và khi tăng weight 5% → 25% thì nhóm 5% cũ vẫn nằm trong canary (nó chia theo dải hash, dải chỉ nới rộng ra). Không có chuyện user bị đá qua đá lại.proxy_pass http://$pool;dùng biến — Nginx tra tên trong các blockupstreamtrước, nên không cầnresolver.- Cookie 24h, dài hơn hẳn thời gian chạy canary, để tab mở lâu không bị lạc.
4. Docker Compose: 2 pool song song
services:
edge:
image: nginx:1.27-alpine
ports: ["80:80"]
volumes:
- ./nginx:/etc/nginx/conf.d:ro
- assets:/srv/assets:ro
- nginxlog:/var/log/nginx
depends_on: [app-stable]
app-stable:
image: ${STABLE_TAG}
env_file: .env
environment:
APP_RELEASE: stable
volumes: [assets:/srv/assets]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/up"]
interval: 5s
retries: 6
deploy: { replicas: 2 }
app-canary:
image: ${CANARY_TAG:-${STABLE_TAG}}
env_file: .env
environment:
APP_RELEASE: canary
volumes: [assets:/srv/assets]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/up"]
interval: 5s
retries: 6
profiles: ["canary"] # chỉ chạy khi bật profile → bình thường không tốn RAM
worker-stable:
image: ${STABLE_TAG}
command: php artisan queue:work --max-time=3600
env_file: .env
scheduler: # CHỈ MỘT, và chỉ ở stable — xem mục 7.6
image: ${STABLE_TAG}
command: php artisan schedule:work
env_file: .env
volumes:
assets:
nginxlog:
Entrypoint của image app copy asset ra volume dùng chung (tên file Vite có hash nên 2 bản không đè nhau):
# docker-entrypoint.sh
cp -rn /var/www/html/public/build/. /srv/assets/build/ || true
exec "$@"
Container app phục vụ HTTP thẳng ở port 8000 (FrankenPHP hoặc Octane/RoadRunner,
hoặc nginx+php-fpm bên trong image) — để edge chỉ cần proxy_pass, không phải
biết đường dẫn code của từng version.
5. Pipeline GitLab CI
Hai script nhỏ đặt sẵn trên server, CI chỉ gọi qua SSH.
/srv/app/bin/canary-weight.sh — đổi weight:
#!/usr/bin/env bash
set -euo pipefail
w=${1:?usage: canary-weight.sh <0-100>}
f=/srv/app/nginx/canary.conf
if [ "$w" -eq 0 ]; then
cat > "$f" <<'EOF'
map $host $canary_bucket { default "stable"; }
map $host $canary_enabled { default "0"; }
EOF
else
cat > "$f" <<EOF
split_clients "\${remote_addr}\${http_user_agent}" \$canary_bucket {
${w}% "canary";
* "stable";
}
map \$host \$canary_enabled { default "1"; }
EOF
fi
docker compose exec -T edge nginx -t
docker compose exec -T edge nginx -s reload # reload là graceful, không rớt kết nối
echo "canary weight = ${w}%"
/srv/app/bin/canary-check.sh — cổng kiểm tra tự động:
#!/usr/bin/env bash
# exit 0 = ổn, cho đi tiếp | 1 = xấu, abort | 2 = chưa đủ dữ liệu, đợi thêm
set -euo pipefail
PROM=${PROM_URL:-http://prometheus:9090}
WINDOW=${WINDOW:-5m}
MIN_REQ=${MIN_REQ:-500}
q() { curl -sfG "$PROM/api/v1/query" --data-urlencode "query=$1" \
| jq -r '.data.result[0].value[1] // "0"'; }
# LƯU Ý: tên metric phụ thuộc cấu hình prometheus-nginxlog-exporter của bạn.
req=$(q "sum(increase(nginx_http_response_count_total{pool=\"app_canary\"}[$WINDOW]))")
[ "${req%.*}" -lt "$MIN_REQ" ] && { echo "mới có $req request, chưa đủ mẫu"; exit 2; }
err_c=$(q "sum(rate(nginx_http_response_count_total{pool=\"app_canary\",status=~\"5..\"}[$WINDOW]))
/ clamp_min(sum(rate(nginx_http_response_count_total{pool=\"app_canary\"}[$WINDOW])), 0.001)")
err_s=$(q "sum(rate(nginx_http_response_count_total{pool=\"app_stable\",status=~\"5..\"}[$WINDOW]))
/ clamp_min(sum(rate(nginx_http_response_count_total{pool=\"app_stable\"}[$WINDOW])), 0.001)")
# riêng cho Livewire: lỗi ở endpoint update là tín hiệu snapshot/hydrate vỡ
lw=$(q "sum(increase(nginx_http_response_count_total{pool=\"app_canary\",path=~\"/livewire.*\",status=~\"5..|419\"}[$WINDOW]))")
echo "canary err=$err_c | stable err=$err_s | livewire 5xx/419=$lw | req=$req"
awk -v c="$err_c" -v s="$err_s" 'BEGIN { exit !(c > 0.01 || c > s * 2 + 0.002) }' && {
echo "ERROR RATE XẤU → abort"; exit 1; }
awk -v l="$lw" 'BEGIN { exit !(l > 5) }' && { echo "LIVEWIRE VỠ → abort"; exit 1; }
exit 0
.gitlab-ci.yml (chỉ phần canary; build/test giữ như doc cũ):
stages: [test, build, migrate, canary, promote]
.ssh: &ssh
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
# 1. Migration chạy TRƯỚC khi có traffic vào canary, và chỉ chạy 1 lần
migrate:
<<: *ssh
stage: migrate
script:
- ssh $SERVER_USER@$SERVER_HOST "cd /srv/app &&
CANARY_TAG=$IMAGE_TAG docker compose run --rm app-canary php artisan migrate --force"
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }]
# 2. Bật canary ở 5%
canary:start:
<<: *ssh
stage: canary
script:
- ssh $SERVER_USER@$SERVER_HOST "cd /srv/app &&
CANARY_TAG=$IMAGE_TAG docker compose --profile canary up -d --wait app-canary &&
sleep 120 && # warm-up: OPcache + cache Laravel còn nguội
bin/canary-weight.sh 5"
environment: { name: production/canary, on_stop: canary:abort }
# 3. Cổng tự động: chạy lại nhiều lần, exit 2 = đợi thêm
canary:check:
<<: *ssh
stage: canary
needs: [canary:start]
retry: { max: 2, when: script_failure }
script:
- ssh $SERVER_USER@$SERVER_HOST "cd /srv/app && sleep 300 && bin/canary-check.sh"
# 4. Tăng dần — bấm tay, có con người nhìn Grafana
canary:25:
<<: *ssh
stage: canary
when: manual
needs: [canary:check]
script: [ 'ssh $SERVER_USER@$SERVER_HOST "cd /srv/app && bin/canary-weight.sh 25 && sleep 300 && bin/canary-check.sh"' ]
canary:50:
<<: *ssh
stage: canary
when: manual
needs: [canary:25]
script: [ 'ssh $SERVER_USER@$SERVER_HOST "cd /srv/app && bin/canary-weight.sh 50 && sleep 300 && bin/canary-check.sh"' ]
# 5. Promote: nâng stable lên image mới TRƯỚC, rồi mới tắt canary
promote:
<<: *ssh
stage: promote
when: manual
script:
- ssh $SERVER_USER@$SERVER_HOST "cd /srv/app &&
sed -i 's|^STABLE_TAG=.*|STABLE_TAG=$IMAGE_TAG|' .env &&
docker compose up -d --wait app-stable worker-stable scheduler &&
bin/canary-weight.sh 0 &&
sleep 900 && # drain: chờ user đang dính cookie canary rời đi
docker compose --profile canary down"
# 6. Abort — luôn chạy được, kể cả khi pipeline đã fail
canary:abort:
<<: *ssh
stage: canary
when: manual
script:
- ssh $SERVER_USER@$SERVER_HOST "cd /srv/app && bin/canary-weight.sh 0 && sleep 600 &&
docker compose --profile canary down"
environment: { name: production/canary, action: stop }
Thứ tự promote là chỗ hay làm sai: phải nâng app-stable lên image mới
trước, rồi mới kéo weight về 0. Nếu tắt canary trước khi stable lên bản mới,
những user đang dính cookie release=canary sẽ bị đẩy về code cũ giữa phiên —
đúng cái lỗi snapshot ở mục 2. Sau khi cả 2 pool cùng image thì cookie không
còn ý nghĩa, muốn tắt canary lúc nào cũng được.
6. Đo cái gì
Prometheus scrape prometheus-nginxlog-exporter, exporter này đọc
/var/log/nginx/canary.log và biến pool= trong log thành label.
Bốn chỉ số, luôn so canary vs stable trong cùng khung thời gian (không so với "hôm qua" — traffic khác nhau):
| Chỉ số | Ngưỡng gợi ý | Vì sao |
|---|---|---|
| Tỉ lệ 5xx | canary > 1% hoặc > 2× stable | Tín hiệu rõ nhất |
5xx + 419 trên /livewire/* |
> 5 lần trong 5 phút | Riêng của Livewire: snapshot vỡ, session hết hạn |
| p95 latency | canary > 1.5× stable, sau warm-up | Query mới thiếu index, N+1 |
Số exception theo release (GlitchTip) |
có exception mới chưa từng thấy | Bug logic không tạo ra 5xx |
Gắn nhãn release vào log và exception:
// app/Providers/AppServiceProvider.php — boot()
Log::withContext(['release' => config('app.release')]);
Dashboard Grafana tối thiểu: 4 chỉ số trên, mỗi cái 2 đường (canary/stable) chồng lên nhau. Đừng làm dashboard 30 panel — lúc release căng thẳng không ai đọc nổi.
7. Vấn đề khi vận hành thật
Đây là phần tốn máu nhất. Sắp theo mức độ hay gặp.
7.1 Mẫu quá nhỏ → con số vô nghĩa
5% của 200 request/phút = 10 request/phút. Sau 5 phút có 50 request; 1 request lỗi = "error rate 2%" → abort oan. Ngược lại bug chỉ xảy ra với 1/1000 user thì 50 request không bao giờ bắt được.
Cách xử lý: đặt MIN_REQ (số request tối thiểu) làm điều kiện cứng trước
khi đọc bất kỳ tỉ lệ nào — script ở trên trả exit 2 = "chưa đủ dữ liệu, đợi
tiếp", khác hẳn với exit 1 = "xấu". Và deploy vào giờ có traffic. Canary
lúc 2h sáng chỉ tốn thời gian: không đủ traffic để học được gì, mà lỡ hỏng thì
không có ai trực.
7.2 Container mới luôn "chậm" trong vài phút đầu
OPcache nguội, connection pool chưa mở, cache Laravel (config/route/view) chưa được nạp, cache dữ liệu trong Redis chưa có entry của version mới. p95 của canary cao gấp đôi trong 2–3 phút đầu là bình thường, không phải bug.
Cách xử lý: ngủ 120s sau khi container --wait xong rồi mới bắt đầu tính
metrics (đã có trong canary:start). Bật opcache.validate_timestamps=0 và
preload để container ổn định nhanh hơn.
7.3 Cache dùng chung giữa 2 version
Nếu v2 đổi cấu trúc dữ liệu được cache (ví dụ cache một object đã serialize, hoặc đổi shape của array), stable đọc phải entry do canary ghi → lỗi khó hiểu, mà lại xảy ra ở stable (95% user), tức là canary làm hỏng bản đang lành.
Cách xử lý: tách CACHE_PREFIX theo release. Nhưng phải hiểu cái giá:
canary khởi động với cache trắng → chậm hơn thật → trộn lẫn với vấn đề 7.2.
Và tuyệt đối không tách prefix của session — session phải dùng chung, nếu
không user bị đăng xuất khi nhảy pool.
Quy tắc thực dụng: session + cache đều để trên Redis dùng chung; nếu release
nào đổi cấu trúc dữ liệu cache thì đổi key của riêng dữ liệu đó (thêm hậu
tố :v2), không đổi prefix toàn cục.
7.4 Migration: cửa sổ 2 version dài hơn rolling rất nhiều
Rolling: 2 version sống chung vài phút. Canary: vài giờ, thậm chí qua đêm. Mọi thay đổi schema phải backward-compatible trong suốt cửa sổ đó.
Bắt buộc theo expand-contract:
- Release N: chỉ thêm cột/bảng, có default hoặc nullable. Code mới ghi cả cột cũ lẫn cột mới.
- Release N+1 (sau khi N đã 100% và chắc không rollback): mới xoá cột cũ.
Và: rollback code không rollback được migration. Nếu canary chạy migration
rồi bị abort, schema mới vẫn còn — nên migration phải an toàn với cả code cũ.
Đó là lý do job migrate chạy trước, tách riêng, và không có bước "rollback
migration" trong pipeline.
Migration nặng (thêm index vào bảng 50 triệu dòng) phải làm ngoài pipeline, có kế hoạch riêng — không nhét vào một job CI 10 phút.
7.5 Queue: job của canary rơi vào worker stable
Canary dispatch một job class mới hoặc job có thêm property → worker stable
(code cũ) nhận → Class not found hoặc thiếu property → job fail, retry, vào
failed_jobs.
Hai cách:
- Cách rẻ (khuyên dùng): deploy phía tiêu thụ trước. Release N thêm job class + handler nhưng chưa dispatch. Release N+1 mới dispatch. Nghe thì chậm nhưng đây là cách duy nhất không cần thêm hạ tầng.
- Cách tách: canary đẩy job vào queue riêng (
QUEUE=canary), có worker canary riêng. Nhược điểm: khi abort mà queue canary còn job (nhất là job delay 1 tiếng), tắt worker canary là job kẹt luôn. Phải drain trước khi tắt.
7.6 Scheduler chạy 2 lần
Nếu container canary cũng chạy schedule:work, mọi cron job chạy 2 lần:
gửi email 2 lần, tính hoá đơn 2 lần, gọi API đối tác 2 lần. Đây là lỗi tốn tiền
thật, và im lặng — không ai thấy 5xx nào cả.
Cách xử lý: scheduler là một service riêng, chỉ một instance, luôn chạy
image stable (như compose ở trên). Thêm lớp bảo hiểm bằng
->withoutOverlapping() và ->onOneServer() (cần cache driver Redis).
7.7 Upload file của Livewire
Livewire lưu file tạm vào thư mục livewire-tmp trên đĩa của container xử lý
request upload. Request "hoàn tất form" sau đó có thể rơi vào container khác
→ không thấy file.
Đây là vấn đề đã có sẵn khi chạy nhiều replica, canary chỉ làm nó dễ lộ hơn.
Cách xử lý: cấu hình Livewire upload thẳng lên S3-compatible storage
(MinIO là bản open-source chạy được trên VPS), hoặc gắn một volume dùng chung
cho storage/app/livewire-tmp giữa tất cả container.
7.8 Asset 404 → trang trắng
HTML render từ stable trỏ tới /build/assets/app-AAA.js, nhưng file đó chỉ có
trong image stable. Nếu request asset rơi sang canary → 404 → trang trắng,
không có lỗi 5xx nào để cảnh báo.
Cách xử lý (đã có trong config trên): edge Nginx phục vụ /build/ từ một
volume dùng chung, mỗi container copy asset của mình vào đó lúc khởi động. Tên
file Vite có hash nên không đè nhau. Đừng xoá asset cũ ngay sau promote — giữ
ít nhất 2–3 release, vì tab của user vẫn còn tham chiếu tới bản cũ.
7.9 Sticky làm mẫu bị lệch
Băm theo IP nghĩa là: một công ty lớn dùng chung một IP NAT → cả trăm nhân viên rơi vào cùng một pool. Nếu đúng khách hàng đó vào canary, metrics canary phản ánh hành vi của một khách chứ không phải 5% dân số.
Cách xử lý: băm theo session id / user id thay vì IP nếu có thể. Hoặc chấp nhận và bù bằng cách chạy canary lâu hơn, qua nhiều khung giờ.
7.10 Abort không miễn phí
Lúc kéo weight về 0, user đang ở canary bị đẩy về stable. Click tiếp theo của họ mang snapshot của v2 sang cho v1 xử lý → một lần lỗi, F5 là hết. Chấp nhận được (đang abort mà), nhưng phải biết trước để không hoảng khi thấy một nhúm 500 ngay sau khi abort.
Giảm đau: để canary ở weight 0 nhưng container vẫn sống thêm 10–15
phút (sleep 600 trong job abort) — user có header X-Release: canary
vẫn được phục vụ đúng bản cho tới khi họ rời trang. Chỉ khi bug là nghiêm
trọng (mất dữ liệu, lộ thông tin) mới tắt ngay lập tức không drain.
7.11 Bug đã xảy ra rồi thì rollback không xoá được hậu quả
5% user trong 30 phút vẫn là hàng nghìn đơn hàng sai, email đã gửi, webhook đã bắn sang đối tác. Canary giới hạn phạm vi, không giới hạn tính vĩnh viễn.
→ Với luồng đụng tiền hoặc gửi ra ngoài, cân nhắc feature flag (Pennant) lồng bên trong canary: canary chạy code mới nhưng cờ tắt, chỉ bật sau khi metrics hạ tầng đã sạch.
7.12 Bot và healthcheck làm bẩn số liệu
Uptime checker gọi /up mỗi 10s, crawler quét sitemap — những request này
không đại diện cho user thật, mà lại chiếm tỉ trọng lớn khi canary chỉ có ít
traffic. Loại chúng ra trong cấu hình exporter (bỏ /up, lọc user agent bot).
7.13 Livewire wire:poll làm lệch số đếm
Component có wire:poll bắn request nền liên tục. Mặt tốt: tín hiệu đến nhanh.
Mặt xấu: request count phồng lên, tỉ lệ lỗi bị pha loãng — 100 request poll
thành công che mất 5 request click thất bại. Nếu app dùng nhiều wire:poll,
nên tách metrics theo loại request (poll vs action) bằng header đánh dấu.
8. Nếu đã chạy Kubernetes
Đừng tự viết lại những thứ trên. Dùng Argo Rollouts (hoặc Flagger):
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
canaryService: app-canary
stableService: app-stable
steps:
- setWeight: 5
- pause: { duration: 10m }
- analysis:
templates: [{ templateName: success-rate }]
- setWeight: 25
- pause: { duration: 10m }
- setWeight: 50
- pause: { duration: 10m }
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata: { name: success-rate }
spec:
metrics:
- name: success-rate
interval: 5m
successCondition: result[0] >= 0.99
failureLimit: 2 # 2 lần đo xấu liên tiếp → tự động abort, weight về 0
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
sum(rate(http_requests_total{service="app-canary",status!~"5.."}[5m]))
/ sum(rate(http_requests_total{service="app-canary"}[5m]))
Argo Rollouts lo phần chia weight, đo, và abort tự động. Nhưng nó không lo
phần sticky cho Livewire — vẫn phải tự làm: dùng annotation
nginx.ingress.kubernetes.io/canary-by-header: X-Release (chính cái header ở
mục 2) kết hợp affinity: cookie. Bài toán Livewire không biến mất khi lên
k8s, chỉ đổi chỗ config.
9. Checklist trước khi bật canary lần đầu
-
APP_KEYgiống nhau ở cả 2 pool - Session + cache trên Redis dùng chung, không dùng driver
file - Header
X-Releaseđã được Livewire gửi kèm (mở DevTools kiểm tra) - Nginx định tuyến đúng thứ tự header → cookie → %
- Asset
/build/phục vụ từ volume dùng chung - Livewire temp upload đã ra S3/MinIO hoặc volume chung
- Scheduler chỉ chạy ở stable, đúng 1 instance
- Migration của release này là expand-only (không xoá/đổi tên cột)
- Job class mới đã có ở worker stable từ release trước
- Dashboard Grafana có 2 đường canary/stable cho 4 chỉ số ở mục 6
-
bin/canary-weight.sh 0đã được tập chạy thử ít nhất 1 lần — kill switch chưa từng test là kill switch không tồn tại - Deploy vào giờ có traffic, có người trực nhìn dashboard
10. Câu hỏi phụ interviewer hay hỏi tiếp
"Canary khác blue-green ở đâu? Nghe cũng là chạy 2 bản." Blue-green chuyển 100% traffic một phát — mục tiêu là rollback nhanh. Canary chuyển từng phần — mục tiêu là phát hiện sớm với ít user bị ảnh hưởng. Blue-green trả lời "quay lại có kịp không", canary trả lời "có nên đi tiếp không". Canary cũng khác A/B testing: A/B so hành vi user giữa hai phiên bản tính năng (chạy nhiều ngày), canary so sức khoẻ kỹ thuật của một bản build (chạy vài chục phút).
"Sao không chia traffic bằng split_clients là xong?"
Với Laravel thuần thì đúng là xong. Với Livewire thì không: mỗi request
/livewire/update mang theo snapshot (JSON trạng thái component) do một
version render ra, kèm checksum ký bằng APP_KEY. Snapshot của v1 rơi vào v2
có public property khác là hỏng ngay. split_clients chia theo IP+UA nên vẫn
có thể ném cùng một user sang pool khác khi đổi weight — nên phải ghim theo
header/cookie trước, rồi mới tới chia %.
"Cookie sticky là đủ rồi, cần header làm gì?"
Cookie set ở request đầu, nhưng user đang mở sẵn tab từ trước khi bật canary
thì không có cookie, mà trong tab đó vẫn còn snapshot của bản cũ. Header
X-Release đi ra từ chính trang đã render nên luôn đúng version đã sinh ra
snapshot — nó là nguồn sự thật, cookie chỉ là dự phòng.
"5% traffic thì bao lâu mới đủ dữ liệu để kết luận?" Tính ngược từ error rate: muốn phát hiện lỗi làm 1% request fail, cần cỡ vài trăm request ở nhánh canary mới thấy rõ hơn nhiễu. Site 1000 req/phút → 5% là 50 req/phút → cần khoảng 10 phút. Site 60 req/phút → 5% là 3 req/phút, chờ cả tiếng vẫn không kết luận được: đó là dấu hiệu không nên dùng canary, hãy dùng blue-green + feature flag.
"Abort tự động hay để người bấm?" Tự động cho tín hiệu rõ và nguy hiểm (5xx tăng vọt, healthcheck fail) — cắt weight về 0 ngay. Bấm tay cho tín hiệu mờ (p95 nhích, throughput lệch) — vì container mới luôn chậm vài phút đầu, tự động ở đây chỉ tạo abort giả. Quan trọng hơn cả: kill switch phải đã được tập chạy thử.
"Canary thì migration có khác gì rolling không?" Khác ở độ dài cửa sổ. Rolling có 2 version cùng sống vài phút; canary có thể vài giờ, thậm chí qua đêm. Nên quy tắc expand-only phải giữ nghiêm hơn, và phải nhớ: migration chạy một lần trước khi canary nhận traffic, không chạy lại lúc promote.
"Bug chỉ xảy ra với một khách hàng lớn thì canary 5% ngẫu nhiên có bắt được không?"
Không. Chia % ngẫu nhiên bỏ sót lỗi phụ thuộc dữ liệu. Khi đó nên định tuyến
canary theo tenant/nhóm user (route theo header X-Tenant hoặc user id) —
cho đúng nhóm rủi ro cao vào canary trước, hoặc cho nhóm nội bộ vào trước
(dogfooding).
"Canary tốn thêm gì?" Tốn hạ tầng (thêm pool), tốn observability (metrics phải tách theo version, không tách thì canary vô nghĩa), và tốn người — phải có người đọc dashboard trong lúc canary chạy. Với team nhỏ, chi phí người thường lớn hơn lợi ích.
"Vậy khi nào bạn khuyên KHÔNG dùng canary?" Traffic thấp (không đủ mẫu), chưa có metrics tách theo version, không có người trực, hoặc rủi ro nằm ở một tính năng chứ không phải cả bản build — trường hợp cuối dùng feature flag (Laravel Pennant) rẻ hơn nhiều lần.
Tóm tắt để trả lời phỏng vấn
"Canary với Laravel thuần là bài toán chia traffic. Canary với Livewire là bài toán session affinity, vì Livewire gửi snapshot state qua lại mỗi request — snapshot render bởi v1 mà rơi vào v2 là vỡ ngay. Nên phần khó không nằm ở Nginx
split_clients, mà ở chỗ ghim user vào đúng một bản: tôi nhúng version vào trang, cho Livewire gửi kèm header mỗi request, Nginx định tuyến theo header trước rồi mới tới cookie và chia %. Ngoài ra là ba cái bẫy im lặng: scheduler chạy 2 lần, job của bản mới rơi vào worker bản cũ, và asset Vite 404 vì hash file khác nhau. Còn về khi nào nên dùng — nếu chỉ cần giảm rủi ro cho một tính năng thì Pennant rẻ hơn nhiều; canary chỉ đáng khi traffic đủ lớn để 5% cho ra số liệu có ý nghĩa thống kê."