Lần deploy nhìn rất bình thường: đổi tên customers.name thành customers.full_name, sửa model, ship. Migration chạy trước, rolling update chạy sau, và trong bốn phút Kubernetes thay tám pod, pod cũ nào còn chạy cũng đang query một cột hổng còn tồn tại. Checkout trả 500 cho một phần ba user. Rollback code cũng không cứu được, vì code cũ cần cột cũ mà migration đã drop mất rồi.
Chọn chiến lược deploy không chỉ là chuyện traffic đi từ bản N qua N+1 ra sao. Nó là chuyện gì xảy ra trong lúc cả hai bản cùng chạy, chung database, chung queue, chung cache. Chiến lược nào cũng có khoảng thời gian đó. Khác nhau ở chỗ nó kéo dài bao lâu, bao nhiêu traffic thấy bản mới, và mình thoát ra nhanh cỡ nào.
Rolling update: lựa chọn mặc định
Rolling update thay pod từng ít một. Đây là mặc định của Kubernetes và không tốn thêm hạ tầng:
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-api
spec:
replicas: 8
minReadySeconds: 20 # a new pod must stay ready this long before it counts
progressDeadlineSeconds: 600
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # up to 2 extra pods during the rollout
maxUnavailable: 0 # never drop below 8 ready pods
maxUnavailable: 0 đi cùng maxSurge khác 0 giữ đủ capacity suốt quá trình rollout. minReadySeconds chặn trường hợp pod pass readiness rồi mười giây sau crash mà vẫn được tính là "available", thứ có thể để một bản hư lan ra cả fleet. Nếu rollout bị kẹt quá progressDeadlineSeconds, Deployment bị đánh dấu fail, và kubectl rollout undo deployment/shop-api đưa ReplicaSet trước đó quay lại.
Cái giá phải trả: trong lúc rollout, user nhảy qua lại giữa hai bản theo từng request. Code cũ và code mới phải tương thích với data, session và job trong queue của nhau. Rollback cũng là một lần rolling update, nên mất thời gian y như lúc deploy.
Blue-green: hai stack đầy đủ, một công tắc
Blue-green chạy bản mới (green) song song với bản cũ (blue) ở đủ kích thước, kiểm tra xong rồi chuyển traffic trong một bước:
# Both stacks run side by side; the Service selector decides who gets traffic.
kubectl apply -f shop-api-green.yaml
kubectl rollout status deployment/shop-api-green --timeout=5m
# Smoke test green through its own internal Service before the switch.
curl -fsS http://shop-api-green.internal/ready
# Switch: one atomic change to the selector.
kubectl patch service shop-api -p '{"spec":{"selector":{"app":"shop-api","color":"green"}}}'
# Rollback is the same command with color=blue. Keep blue running until you are sure.
Chuyển gần như tức thì và rollback cũng vậy, miễn blue vẫn còn chạy. Mình test được green với config production và dependency thật trước khi user nào thấy. Cái giá: gấp đôi capacity trong lúc deploy, và mấy connection sống lâu (WebSocket, response streaming) vẫn nằm trên blue tới khi đóng. Database vẫn xài chung, nên blue-green không làm một migration phá vỡ tương thích trở nên an toàn được. Nó chỉ làm khoảnh khắc chuyển đổi gọn hơn.
Canary: traffic thật, phạm vi ảnh hưởng nhỏ
Canary gửi một phần nhỏ traffic thật qua bản mới, so metrics với bản stable, rồi nới dần: 5%, 25%, 50%, 100%. Service mesh và mấy công cụ như Argo Rollouts hay Flagger tự động hóa chuyện này; còn ý tưởng cốt lõi thì gói gọn trong vài dòng nginx:
# nginx: send ~5% of clients to the canary, sticky per client address
split_clients "${remote_addr}" $shop_backend {
5% canary;
* stable;
}
upstream stable { server 10.0.1.10:8080; server 10.0.1.11:8080; }
upstream canary { server 10.0.2.10:8080; }
server {
listen 80;
location / { proxy_pass http://$shop_backend; }
}
Canary bắt được những thứ staging không bao giờ bắt được: hình dạng data thật, phiên bản client thật, tải thật. Nó cần hai điều kiện. Một là đủ traffic để metrics có ý nghĩa; 5% của một service 20 request mỗi phút thì mười phút cũng chẳng nói lên được gì. Hai là một quy tắc quyết định tự động, thống nhất từ trước. Người ngồi nhìn dashboard ở mức 5% lần nào cũng sẽ tự tìm lý do bỏ qua một đợt lỗi nhỏ.
Chọn chiến lược
Để ý là câu hỏi về database đứng đầu tiên. Không chiến lược traffic nào cứu được một thay đổi schema làm hư bản đang chạy.
Tương thích database: expand, migrate, contract
Quy tắc làm mọi chiến lược trở nên an toàn: mỗi release phải chạy được với schema của release trước và release sau nó. Nghĩa là thay đổi phá vỡ tương thích phải chia ra nhiều lần deploy:
- Expand: thêm cột hoặc bảng mới, cho phép null, rồi backfill. Code cũ không đụng tới nó.
- Ghi kép: ship code ghi cả
namelẫnfull_name, vẫn đọc cột cũ. - Chuyển phần đọc: ship code đọc
full_name. Rollback về bước 2 vẫn an toàn. - Contract: khi không còn gì đọc hay ghi
name, drop nó ở một release sau.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// Release 1 (expand): add the new column, nullable, no reads depend on it yet.
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table): void {
$table->string('full_name')->nullable()->after('name');
});
// Backfill in chunks so the migration never locks the table for long.
DB::table('customers')->whereNull('full_name')->chunkById(1000, function ($rows): void {
foreach ($rows as $row) {
DB::table('customers')->where('id', $row->id)->update(['full_name' => $row->name]);
}
});
}
};
Cách nghĩ này áp dụng cả ngoài SQL. Job trong queue do bản N serialize sẽ được bản N+1 xử lý, nên payload của job phải tương thích ngược ít nhất một release. Cache key đổi format thì nên có prefix mới chứ đừng ghi đè entry cũ. Response API mà app mobile đang xài sẽ sống lâu hơn mọi lần deploy của mình.
Feature flag: deploy không phải là release
Feature flag tách chuyện ship code ra khỏi chuyện bật tính năng. Với Laravel Pennant, checkout mới có thể deploy lên 100% pod mà chỉ nhân viên nội bộ và 5% user thấy:
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;
final class FeatureServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Code ships dark; the flag decides who sees it. Stable per user.
Feature::define('new-checkout', function (User $user): bool {
return $user->is_staff || $user->id % 100 < 5;
});
}
}
// In a controller:
// return Feature::active('new-checkout') ? $this->newCheckout($cart) : $this->legacyCheckout($cart);
Giờ phần rủi ro (hành vi mới) được điều khiển bằng một flag đổi trong vài giây, còn bản thân việc deploy trở nên nhàm chán. Flag cũng là cách rollback nhanh nhất mình có: tắt nó không cần build, không cần pipeline, không cần migration. Hash theo một key ổn định (ở đây là user id) rất quan trọng: user không được nhảy qua lại giữa checkout cũ và mới mỗi lần request.
Tự động hóa quyết định cho canary
Viết quy tắc quyết định thành code, thống nhất ngưỡng trước khi deploy, rồi để pipeline gọi nó sau mỗi bước:
namespace App\Deploy;
final class CanaryJudge
{
public function __construct(
private int $minRequests = 500,
private float $maxErrorDelta = 0.01,
private float $maxLatencyRatio = 1.2,
) {}
/** @param array{requests: int, errors: int, p95_ms: int} $baseline */
public function decide(array $baseline, array $canary): string
{
if ($canary['requests'] < $this->minRequests) {
return 'wait';
}
if ($this->errorRate($canary) - $this->errorRate($baseline) > $this->maxErrorDelta) {
return 'rollback';
}
if ($baseline['p95_ms'] > 0 && $canary['p95_ms'] > $baseline['p95_ms'] * $this->maxLatencyRatio) {
return 'rollback';
}
return 'promote';
}
private function errorRate(array $m): float
{
return $m['requests'] > 0 ? $m['errors'] / $m['requests'] : 0.0;
}
}
Ba kết quả chứ không phải hai. wait chặn pipeline promote dựa trên một mẫu quá nhỏ, không có ý nghĩa thống kê. So với baseline đang chạy thay vì một con số cố định giúp quy tắc công bằng khi có sự cố ảnh hưởng đều cả hai bản.
Kiểm thử cổng deploy
Cái judge quyết định code hư có tới tay mọi user hay không, nên nó xứng đáng có test chốt từng nhánh. Pest giữ cho test dễ đọc:
use App\Deploy\CanaryJudge;
$baseline = ['requests' => 20_000, 'errors' => 20, 'p95_ms' => 180];
it('waits until the canary has enough traffic', function () use ($baseline) {
$canary = ['requests' => 120, 'errors' => 0, 'p95_ms' => 150];
expect((new CanaryJudge())->decide($baseline, $canary))->toBe('wait');
});
it('rolls back when the error rate jumps', function () use ($baseline) {
$canary = ['requests' => 1_000, 'errors' => 40, 'p95_ms' => 170];
expect((new CanaryJudge())->decide($baseline, $canary))->toBe('rollback');
});
it('rolls back on a latency regression even with zero errors', function () use ($baseline) {
$canary = ['requests' => 1_000, 'errors' => 0, 'p95_ms' => 260];
expect((new CanaryJudge())->decide($baseline, $canary))->toBe('rollback');
});
it('promotes a healthy canary', function () use ($baseline) {
$canary = ['requests' => 1_000, 'errors' => 2, 'p95_ms' => 190];
expect((new CanaryJudge())->decide($baseline, $canary))->toBe('promote');
});
Ngoài unit test, tập dượt luôn bản thân việc rollback: trên staging, deploy, rollback, rồi kiểm tra bản trước vẫn chạy ổn với schema đã migrate. Một đường rollback chưa từng chạy thử thì chỉ là hy vọng, không phải kế hoạch.
Những lỗi hay gặp
Migration phá hủy nằm chung release với thay đổi code. Drop hay đổi tên cột trong lúc pod cũ còn chạy là nguyên nhân sập khi deploy phổ biến nhất. Expand trước, contract sau.
Readiness pass quá sớm. Nếu /ready trả 200 trước khi cache được warm hay config được load, rolling update đi tiếp và bản hư lan ra. Kết hợp readiness check thật với minReadySeconds.
Canary không tách session hay cache. Canary ghi cache theo format mới có thể làm hỏng cache của pod stable. Đánh version cho cache key và payload của session.
Rollback mà nghĩ database cũng rollback theo. Thường là không được. Data bản mới ghi ra phải đọc được bằng bản cũ, và đó chính là thứ expand/contract mang lại.
Flag không bao giờ được gỡ. Mỗi flag nhân đôi số nhánh code phải test. Tạo luôn ticket gỡ flag trong cùng pull request thêm flag.
Khi nào xài cái nào, khi nào không
Rolling hợp với đa số web service stateless có thay đổi tương thích ngược. Đừng xài khi hai bản tuyệt đối không được phục vụ traffic cùng lúc, ví dụ thay đổi protocol giữa client và server.
Blue-green hợp khi cần chuyển đổi tức thì, nguyên khối và rollback nhanh, và chịu được gấp đôi capacity trong lúc deploy. Nó không hợp với hệ thống stateful lớn, nơi nhân đôi stack rất tốn kém.
Canary đáng công cho thay đổi rủi ro cao trên service nhiều traffic, nơi traffic thật là bài test duy nhất có ý nghĩa. Bỏ qua nó với service ít traffic mà metrics không bao giờ đủ ý nghĩa thống kê; xài flag cùng một lần rolling update cẩn thận là đủ.
Chọn cái nào thì quy tắc database vẫn vậy: hai bản sẽ chạy chồng lên nhau, nên thiết kế mỗi release để sống chung được với release trước và sau nó.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết một cổng canary tự động. evaluateCanary(baseline, canary, minRequests = 500, maxErrorDelta = 0.01, maxLatencyRatio = 1.2) bên PHP và evaluate_canary(baseline, canary, min_requests=500, max_error_delta=0.01, max_latency_ratio=1.2) bên Python nhận hai mảng/dict metrics có các key requests, errors và p95_ms, rồi trả về một chuỗi. Trả 'wait' khi canary có ít request hơn mức tối thiểu (kiểm tra cái này đầu tiên). Error rate là errors / requests, hoặc 0.0 khi requests bằng 0. Trả 'rollback' khi error rate của canary trừ error rate của baseline lớn hơn hẳn mức delta cho phép, hoặc khi p95_ms của baseline lớn hơn 0 và p95_ms của canary lớn hơn hẳn p95_ms của baseline nhân với latency ratio. Còn lại trả 'promote'. Viết thêm nextWeight(current, steps) / next_weight(current, steps): với một danh sách phần trăm traffic tăng dần, trả về step đầu tiên lớn hơn hẳn current, hoặc 100 khi không có step nào lớn hơn.
Code khởi tạo
/**
* @param array{requests: int, errors: int, p95_ms: int} $baseline
* @param array{requests: int, errors: int, p95_ms: int} $canary
*/
function evaluateCanary(
array $baseline,
array $canary,
int $minRequests = 500,
float $maxErrorDelta = 0.01,
float $maxLatencyRatio = 1.2,
): string {
// TODO: 'wait' when the canary has fewer than $minRequests requests
// TODO: error rate = errors / requests (0.0 when requests is 0)
// TODO: 'rollback' when canary rate - baseline rate > $maxErrorDelta
// TODO: 'rollback' when baseline p95 > 0 and canary p95 > baseline p95 * $maxLatencyRatio
// TODO: otherwise 'promote'
throw new LogicException('TODO');
}
/** @param list<int> $steps ascending traffic percentages, e.g. [5, 25, 50, 100] */
function nextWeight(int $current, array $steps): int
{
// TODO: return the first step greater than $current, or 100 when there is none
throw new LogicException('TODO');
}
def evaluate_canary(
baseline: dict,
canary: dict,
min_requests: int = 500,
max_error_delta: float = 0.01,
max_latency_ratio: float = 1.2,
) -> str:
"""baseline / canary: {"requests": int, "errors": int, "p95_ms": int}"""
# TODO: "wait" when the canary has fewer than min_requests requests
# TODO: error rate = errors / requests (0.0 when requests is 0)
# TODO: "rollback" when canary rate - baseline rate > max_error_delta
# TODO: "rollback" when baseline p95 > 0 and canary p95 > baseline p95 * max_latency_ratio
# TODO: otherwise "promote"
raise NotImplementedError
def next_weight(current: int, steps: list[int]) -> int:
# TODO: return the first step greater than current, or 100 when there is none
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýKiểm tra cỡ mẫu trước mọi thứ: mẫu canary quá nhỏ thì phải trả 'wait' kể cả khi số liệu nhìn rất tệ
Chặn phép chia: bản nào có 0 request thì error rate là 0.0
Cả hai quy tắc rollback đều so sánh lớn hơn hẳn; bằng đúng ngưỡng thì vẫn promote
/**
* @param array{requests: int, errors: int, p95_ms: int} $baseline
* @param array{requests: int, errors: int, p95_ms: int} $canary
*/
function evaluateCanary(
array $baseline,
array $canary,
int $minRequests = 500,
float $maxErrorDelta = 0.01,
float $maxLatencyRatio = 1.2,
): string {
if ($canary['requests'] < $minRequests) {
return 'wait';
}
$rate = fn (array $m): float => $m['requests'] > 0 ? $m['errors'] / $m['requests'] : 0.0;
if ($rate($canary) - $rate($baseline) > $maxErrorDelta) {
return 'rollback';
}
if ($baseline['p95_ms'] > 0 && $canary['p95_ms'] > $baseline['p95_ms'] * $maxLatencyRatio) {
return 'rollback';
}
return 'promote';
}
/** @param list<int> $steps ascending traffic percentages, e.g. [5, 25, 50, 100] */
function nextWeight(int $current, array $steps): int
{
foreach ($steps as $step) {
if ($step > $current) {
return $step;
}
}
return 100;
}
def evaluate_canary(
baseline: dict,
canary: dict,
min_requests: int = 500,
max_error_delta: float = 0.01,
max_latency_ratio: float = 1.2,
) -> str:
"""baseline / canary: {"requests": int, "errors": int, "p95_ms": int}"""
if canary["requests"] < min_requests:
return "wait"
def rate(m: dict) -> float:
return m["errors"] / m["requests"] if m["requests"] > 0 else 0.0
if rate(canary) - rate(baseline) > max_error_delta:
return "rollback"
if baseline["p95_ms"] > 0 and canary["p95_ms"] > baseline["p95_ms"] * max_latency_ratio:
return "rollback"
return "promote"
def next_weight(current: int, steps: list[int]) -> int:
for step in steps:
if step > current:
return step
return 100

