Canary deployments с Rust: без страха и упрёка
Представьте: вы написали новую фичу на Rust, прогнали тесты (они зелёные!), залили на продакшен... и тут 500 ошибки сыпятся, пользователи пишут в поддержку, а вы лихорадочно откатываете релиз. Знакомо? Если да — вы не одиноки. Деплой — это всегда стресс, особенно когда код работает с железом или на высоких нагрузках.
Но есть способ сделать развёртывание почти безболезненным: canary deployments (канареечные развёртывания). Идея проста: вы пускаете новый код не на всех пользователей сразу, а на небольшой процент (скажем, 5–10%). Если всё хорошо — постепенно увеличиваете долю. Если нет — откатываетесь, и об этом знает только горстка «канареек».
В этой статье мы разберём, как реализовать canary deployments для сервиса на Rust. Вы увидите примеры кода, настройку балансировщика и простой мониторинг. Поехали!
Почему Rust идеален для canary deployments?
Rust — это не просто очередной язык. Его фишки делают канареечные развёртывания особенно эффективными:
- Безопасность памяти — Rust гарантирует, что утечки памяти и гонки данных (data races) не пройдут в продакшен. Меньше багов = меньше шансов, что canary упадёт.
- Малый размер бинарника —
cargo build --releaseдаёт компактный исполняемый файл. Его можно быстро скопировать на несколько серверов или контейнеров. - Высокая производительность — Rust работает на уровне C/C++. Это значит, что ваш canary-сервер не «просядет» под нагрузкой, даже если на него направлено 10% трафика.
- Экосистема — крейты вроде
actix-web,tokio,towerдают готовые инструменты для HTTP, балансировки и метрик.
В итоге: Rust-сервис стабилен, быстр и легко поддаётся гранулярному управлению трафиком. Идеально для canary!
Архитектура canary deployment на Rust
Представим, что у нас есть веб-сервис на actix-web. Обычный REST API: принимает запросы, пишет в базу, отдаёт ответы. Мы хотим обновить версию с v1 на v2, но не сразу.
Базовая схема:
- Балансировщик (например, Nginx или HAProxy) распределяет трафик между версиями.
- Основная группа (stable) — серверы с v1, принимают 90% запросов.
- Canary-группа — один или два сервера с v2, принимают 10% запросов.
- Мониторинг — собираем метрики с обеих групп: ошибки, задержки, RPS.
Когда мы убеждаемся, что v2 работает стабильно, плавно увеличиваем вес canary-группы до 100%.
Шаг 1: Пишем Rust-сервер с метриками
Наш сервер будет отдавать две версии эндпоинта /api/hello. Для простоты версии различаются строкой ответа. В реальном проекте это может быть разная бизнес-логика.
// Cargo.toml
[dependencies]
actix-web = "4"
actix-rt = "2"
serde = { version = "1", features = ["derive"] }
prometheus = { version = "0.13", features = ["process"] }
log = "0.4"
env_logger = "0.10"
Теперь сам сервер. Мы добавим метрики Prometheus, чтобы следить за здоровьем canary:
use actix_web::{web, App, HttpServer, HttpResponse, middleware::Logger};
use prometheus::{Counter, HistogramVec, register_counter, register_histogram_vec};
use std::sync::OnceLock;
// Глобальные метрики
static REQUEST_COUNT: OnceLock<Counter> = OnceLock::new();
static REQUEST_DURATION: OnceLock<HistogramVec> = OnceLock::new();
fn init_metrics() {
REQUEST_COUNT.get_or_init(|| {
register_counter!(
"http_requests_total",
"Total number of HTTP requests"
).unwrap()
});
REQUEST_DURATION.get_or_init(|| {
register_histogram_vec!(
"http_request_duration_seconds",
"HTTP request duration in seconds",
&["handler", "version"]
).unwrap()
});
}
async fn hello_v1() -> HttpResponse {
let start = std::time::Instant::now();
REQUEST_COUNT.get().unwrap().inc();
// Симуляция работы
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let duration = start.elapsed().as_secs_f64();
REQUEST_DURATION.get().unwrap()
.with_label_values(&["hello", "v1"])
.observe(duration);
HttpResponse::Ok().body("Hello from v1 (stable)")
}
async fn hello_v2() -> HttpResponse {
let start = std::time::Instant::now();
REQUEST_COUNT.get().unwrap().inc();
// Новая версия может быть чуть быстрее или медленнее
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let duration = start.elapsed().as_secs_f64();
REQUEST_DURATION.get().unwrap()
.with_label_values(&["hello", "v2"])
.observe(duration);
HttpResponse::Ok().body("Hello from v2 (canary)")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
init_metrics();
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let version = std::env::var("VERSION").unwrap_or_else(|_| "v1".to_string());
log::info!("Starting server version {} on port {}", version, port);
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.route("/api/hello", web::get().to(match version.as_str() {
"v2" => hello_v2,
_ => hello_v1,
}))
.route("/metrics", web::get().to(|| async {
use prometheus::Encoder;
let encoder = prometheus::TextEncoder::new();
let mut buffer = Vec::new();
let metric_families = prometheus::gather();
encoder.encode(&metric_families, &mut buffer).unwrap();
HttpResponse::Ok()
.content_type("text/plain; charset=utf-8")
.body(buffer)
}))
})
.bind(format!("0.0.0.0:{}", port))?
.run()
.await
}
Обратите внимание: переменная окружения VERSION определяет, какую версию отдаёт сервер. Это позволяет запускать один и тот же бинарник с разными флагами — идеально для canary.
Шаг 2: Настраиваем балансировщик (Nginx)
Теперь нам нужно, чтобы Nginx направлял 10% трафика на canary-сервер, а 90% — на stable. Вот конфигурация: