Zero-downtime деплой Rust сервисов: перезагрузка без простоев

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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.

Схема работы

  1. У вас запущено 2 (или более) инстанса Rust-сервиса на разных портах (например, 3001 и 3002).
  2. Nginx балансирует трафик между ними через upstream.
  3. Вы останавливаете один инстанс с graceful shutdown.
  4. Запускаете новую версию на том же порту.
  5. Повторяете для второго инстанса.

Клиенты при этом не теряют соединение — 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                

Блоги

Рекомендаций по книгам