Zero-downtime деплой Rust сервисов: как перезагружать приложение без потери клиентов
Представьте ситуацию: вы выкатили новую версию своего Rust-сервиса, но в момент перезапуска 50 пользователей получили ошибку 502. Кто-то потерял данные, кто-то — деньги, а кто-то — доверие к вашему продукту. Знакомая боль? Сегодня мы разберем, как настроить zero-downtime деплой Rust сервисов, чтобы обновления проходили незаметно для клиентов.
Zero-downtime (или деплой без простоев) — это не магия, а набор технических приемов: graceful shutdown, правильное управление соединениями и балансировка трафика. Rust с его системой типов и асинхронным рантаймом (Tokio) дает нам инструменты для элегантного решения этой задачи. Поехали!
Проблема: почему обычный рестарт убивает продакшн?
Когда вы просто убиваете процесс (kill -9) или вызываете std::process::exit(), операционная система мгновенно закрывает все сокеты. Клиенты, которые в этот момент отправляли запросы, получают Connection reset или Timeout. Для HTTP-сервисов это катастрофа: браузеры показывают ошибку, мобильные приложения падают, а фоновые задачи теряют состояние.
В Rust мы можем это исправить. Нам нужно:
- Перестать принимать новые запросы (но старые — доделать).
- Дождаться завершения всех активных обработчиков.
- Только потом завершить процесс.
Это называется graceful shutdown — основа zero-downtime деплоя Rust сервисов.
Шаг 1. Graceful shutdown в Rust с Tokio
Tokio предоставляет встроенные механизмы для graceful shutdown. Рассмотрим пример HTTP-сервера на axum (популярный веб-фреймворк на Rust).
Базовый сервер без graceful shutdown
use axum::{Router, routing::get};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
Если нажать Ctrl+C, сервер просто упадет — все активные запросы оборвутся. Не годится.
Добавляем graceful shutdown
use axum::{Router, routing::get};
use std::net::SocketAddr;
use tokio::signal;
async fn shutdown_signal() {
// Ждем SIGTERM или SIGINT (Ctrl+C)
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
println!("Получен сигнал завершения, начинаем graceful shutdown...");
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
println!("Сервер завершил работу.");
}
Теперь при получении SIGTERM или Ctrl+C сервер:
- Перестает принимать новые соединения.
- Ждет завершения всех текущих запросов (с таймаутом по умолчанию).
- Завершает процесс.
Это первый шаг к zero-downtime деплою Rust сервисов. Но если у вас один сервер, то на время ожидания завершения запросов новые клиенты не смогут подключиться. Нужна балансировка.
Шаг 2. Балансировка: Nginx + несколько инстансов
Чтобы обновление было полностью незаметным, нужно запустить новую версию сервиса параллельно со старой, а затем переключить трафик. Это называется blue-green deployment или rolling update.
Схема работы
- У вас запущено 2 (или более) инстанса Rust-сервиса на разных портах (например, 3001 и 3002).
- Nginx балансирует трафик между ними через upstream.
- Вы останавливаете один инстанс с graceful shutdown.
- Запускаете новую версию на том же порту.
- Повторяете для второго инстанса.
Клиенты при этом не теряют соединение — Nginx перенаправляет запросы на живой инстанс.
Конфигурация Nginx
upstream rust_backend {
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 80;
location / {
proxy_pass http://rust_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Важно: таймауты для long-lived соединений
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
Обратите внимание на proxy_http_version 1.1 и пустой Connection — это включает keepalive, что ускоряет работу.
Шаг 3. Автоматизация деплоя: скрипт на Bash
Ручное переключение инстансов — это скучно и чревато ошибками. Напишем простой скрипт для rolling update Rust сервисов.
#!/bin/bash
set -e
SERVICE_NAME="my_rust_app"
PORTS=(3001 3002)
for PORT in "${PORTS[@]}"; do
echo "Обновляю инстанс на порту $PORT..."
# 1. Отправляем SIGTERM старому процессу
OLD_PID=$(lsof -ti :$PORT || true)
if [ -n "$OLD_PID" ]; then
kill -SIGTERM "$OLD_PID"
# Ждем graceful shutdown (макс 30 секунд)
timeout 30 tail --pid="$OLD_PID" -f /dev/null || true
fi
# 2. Запускаем новую версию
./target/release/$SERVICE_NAME --port $PORT &
# 3. Проверяем, что сервер ответил
sleep 2
curl -f http://127.0.0.1:$PORT/health || exit 1
echo "Инстанс на порту $PORT обновлен и работает."
done
echo