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:

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ể:

  1. Checksum sai — nếu 2 pool khác APP_KEY. → luôn dùng chung APP_KEY.
  2. 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.
  3. 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.conffile 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:


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:

  1. 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.
  2. 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:

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()->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


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ê."