Ecdysis: как мы обновляем сервисы на Rust без остановки и потери данных

линька | ˈekdəsəs |

существительное

процесс сбрасывания старой кожи (у рептилий) или сбрасывания внешнего кутикулярного покрова (у насекомых и других членистоногих).

Как обновить сетевой сервис, обрабатывающий миллионы запросов в секунду по всему миру, не разорвав при этом ни одного соединения?

Одним из наших решений в Cloudflare для этой масштабной задачи долгое время была библиотека ecdysis, написанная на Rust, которая реализует плановые перезапуски процессов без потерь: ни одно активное соединение не разрывается, и новые соединения не отвергаются.

В прошлом месяце мы открыли исходный код ecdysis, и теперь любой может её использовать. После пяти лет эксплуатации в продакшене Cloudflare, ecdysis доказала свою эффективность, обеспечивая обновления с нулевым временем простоя в нашей критически важной инфраструктуре на Rust, сохраняя миллионы запросов при каждом перезапуске в глобальной сети Cloudflare.

Трудно переоценить важность корректного выполнения таких обновлений, особенно в масштабах сети Cloudflare. Многие наши сервисы выполняют критически важные задачи, такие как маршрутизация трафика, управление жизненным циклом TLS-сертификатов или применение правил брандмауэра, и должны работать непрерывно. Если один из таких сервисов отключится, даже на мгновение, каскадный эффект может быть катастрофическим. Разорванные соединения и неудачные запросы быстро приводят к ухудшению производительности для клиентов и бизнес-потерям.

Когда этим сервисам требуются обновления, патчи безопасности не могут ждать. Нужно развертывать исправления ошибок и выпускать новые функции.

Наивный подход предполагает ожидание остановки старого процесса перед запуском нового, но это создаёт промежуток времени, в течение которого соединения отвергаются, а запросы теряются. Для сервиса, обрабатывающего тысячи запросов в секунду в одном месте, умножьте это на сотни центров обработки данных, и кратковременный перезапуск обернётся миллионами неудачных запросов по всему миру.

Давайте углубимся в проблему и в то, как ecdysis стала решением для нас — и, возможно, станет для вас.

Ссылки: GitHub | crates.io | docs.rs

Почему плановые перезапуски — это сложно

Наивный подход к перезапуску сервиса, как мы уже упоминали, заключается в остановке старого процесса и запуске нового. Это приемлемо для простых сервисов, не обрабатывающих запросы в реальном времени, но для сетевых сервисов, работающих с живыми соединениями, у этого подхода есть критические ограничения.

Во-первых, наивный подход создаёт окно, в течение которого ни один процесс не слушает входящие соединения. Когда старый процесс останавливается, он закрывает свои слушающие сокеты, что заставляет ОС немедленно отклонять новые соединения с ошибкой ECONNREFUSED. Даже если новый процесс запустится сразу, всегда будет промежуток, когда ничто не принимает соединения, будь то миллисекунды или секунды. Для сервиса, обрабатывающего тысячи запросов в секунду, даже промежуток в 100 мс означает сотни потерянных соединений.

Во-вторых, остановка старого процесса убивает все уже установленные соединения. Клиент, загружающий большой файл или транслирующий видео, внезапно разрывает соединение. Долгоживущие соединения, такие как WebSockets или gRPC-потоки, обрываются в середине операции. С точки зрения клиента, сервис просто исчезает.

Привязка нового процесса до остановки старого, казалось бы, решает эту проблему, но также создаёт дополнительные сложности. Ядро обычно позволяет только одному процессу привязываться к комбинации адрес:порт, но опция сокета SO_REUSEPORT разрешает множественную привязку. Однако это создаёт проблему во время перехода между процессами, которая делает этот метод непригодным для плановых перезапусков.

При использовании SO_REUSEPORT ядро создаёт отдельные слушающие сокеты для каждого процесса и балансирует нагрузку новых соединений между этими сокетами. Когда получен начальный пакет SYN для соединения, ядро назначает его одному из слушающих процессов. После завершения начального квитирования соединение попадает в очередь accept() процесса, пока тот его не примет. Если процесс завершится до принятия этого соединения, оно становится "осиротевшим" и завершается ядром. Инженерная команда GitHub подробно задокументировала эту проблему при создании своего балансировщика нагрузки GLB Director.

Как работает ecdysis

Когда мы приступили к проектированию и созданию ecdysis, мы определили четыре ключевые цели для библиотеки:

  1. Старый код можно полностью выключить после обновления.

  2. У нового процесса есть период "grace period" для инициализации.

  3. Сбой нового кода во время инициализации допустим и не должен влиять на работу сервиса.

  4. Только одно обновление выполняется параллельно, чтобы избежать каскадных сбоев.

ecdysis удовлетворяет этим требованиям, следуя подходу, впервые применённому в NGINX, который поддерживает плановые обновления с самых ранних дней. Подход прост:

  1. Родительский процесс создаёт новый дочерний процесс с помощью fork().

  2. Дочерний процесс заменяет себя новой версией кода с помощью execve().

  3. Дочерний процесс наследует файловые дескрипторы сокетов через именованный канал, общий с родительским процессом.

  4. Родительский процесс ждёт сигнала от дочернего процесса о готовности перед завершением работы.

Shedding old code with ecdysis: graceful restarts for Rust services at Cloudflare

Ключевой момент: сокет остаётся открытым на протяжении всего перехода. Дочерний процесс наследует слушающий сокет от родительского в виде файлового дескриптора, переданного через именованный канал. Во время инициализации дочернего процесса оба процесса используют одну и ту же базовую структуру данных ядра, что позволяет родительскому процессу продолжать принимать и обрабатывать новые и существующие соединения. Как только дочерний процесс завершает инициализацию, он уведомляет родительский и начинает принимать соединения. Получив это уведомление о готовности, родительский процесс немедленно закрывает свою копию слушающего сокета и продолжает обрабатывать только существующие соединения.

Этот процесс устраняет "провалы" в обслуживании, одновременно предоставляя дочернему процессу безопасное окно для инициализации. Существует краткий промежуток времени, когда и родительский, и дочерний процессы могут принимать соединения одновременно. Это сделано намеренно; любые соединения, принятые родительским процессом, просто обрабатываются до завершения как часть процесса "дренажа" (draining).

Эта модель также обеспечивает необходимую отказоустойчивость. Если дочерний процесс завершится сбоем во время инициализации (например, из-за ошибки конфигурации), он просто завершит работу. Поскольку родительский процесс никогда не прекращал слушать, ни одно соединение не разрывается, и обновление можно повторить после устранения проблемы.

ecdysis реализует модель форкинга с поддержкой асинхронного программирования первого класса через Tokio и интеграцию с systemd:

  • Интеграция с Tokio: Нативные асинхронные обёртки потоков для Tokio. Унаследованные сокеты становятся слушателями без дополнительного кода. Для синхронных сервисов ecdysis поддерживает работу без требований к асинхронной среде выполнения.

  • Поддержка systemd-notify: Когда включена функция systemd_notify, ecdysis автоматически интегрируется с уведомлениями systemd о жизненном цикле процесса. Установка Type=notify-reload в файле юнита вашего сервиса позволяет systemd корректно отслеживать обновления.

  • Именованные сокеты systemd: Функция systemd_sockets позволяет ecdysis управлять сокетами, активируемыми через systemd. Ваш сервис может быть активирован через сокеты и одновременно поддерживать плановые перезапуски.

Примечание о платформе: ecdysis полагается на специфичные для Unix системные вызовы для наследования сокетов и управления процессами. Она не работает на Windows. Это фундаментальное ограничение подхода, основанного на форкинге.

Вопросы безопасности

Плановые перезапуски влекут за собой вопросы безопасности. Модель форкинга создаёт краткое окно, в котором сосуществуют два поколения процессов, оба с доступом к одним и тем же слушающим сокетам и потенциально чувствительным файловым дескрипторам.

ecdysis решает эти проблемы за счёт своей конструкции:

Fork-then-exec: ecdysis следует традиционной модели Unix: fork(), за которым немедленно следует execve(). Это гарантирует, что дочерний процесс начинается с чистого листа: новое адресное пространство, свежий код и без унаследованной памяти. Только явно переданные файловые дескрипторы пересекают границу.

Явное наследование: Наследуются только слушающие сокеты и каналы связи. Остальные файловые дескрипторы закрываются с помощью флагов CLOEXEC. Это предотвращает случайную утечку чувствительных дескрипторов.

Совместимость с seccomp: Сервисы, использующие фильтры seccomp, должны разрешать fork() и execve(). Это компромисс: для плановых перезапусков требуются эти системные вызовы, поэтому их нельзя блокировать.

Для большинства сетевых сервисов эти компромиссы приемлемы. Безопасность модели fork-exec хорошо изучена и проверена в бою на протяжении десятилетий в таком программном обеспечении, как NGINX и Apache.

Пример кода

Рассмотрим практический пример. Вот упрощённый TCP-эхо сервер, поддерживающий плавные перезапуски:

use ecdysis::tokio_ecdysis::{SignalKind, StopOnShutdown, TokioEcdysisBuilder};
use tokio::{net::TcpStream, task::JoinSet};
use futures::StreamExt;
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    // Создаём конструктор ecdysis
    let mut ecdysis_builder = TokioEcdysisBuilder::new(
        SignalKind::hangup()  // Запуск обновления/перезагрузки по SIGHUP
    ).unwrap();

    // Остановка по SIGUSR1
    ecdysis_builder
        .stop_on_signal(SignalKind::user_defined1())
        .unwrap();

    // Создаём слушающий сокет - он будет унаследован дочерними процессами
    let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
    let stream = ecdysis_builder
        .build_listen_tcp(StopOnShutdown::Yes, addr, |builder, addr| {
            builder.set_reuse_address(true)?;
            builder.bind(&addr.into())?;
            builder.listen(128)?;
            Ok(builder.into())
        })
        .unwrap();

    // Запускаем задачу для обработки подключений
    let server_handle = tokio::spawn(async move {
        let mut stream = stream;
        let mut set = JoinSet::new();
        while let Some(Ok(socket)) = stream.next().await {
            set.spawn(handle_connection(socket));
        }
        set.join_all().await;
    });

    // Сигнализируем о готовности и ожидаем завершения работы
    let (_ecdysis, shutdown_fut) = ecdysis_builder.ready().unwrap();
    let shutdown_reason = shutdown_fut.await;

    log::info!("Завершение работы: {:?}", shutdown_reason);

    // Плавно завершаем подключения
    server_handle.await.unwrap();
}

async fn handle_connection(mut socket: TcpStream) {
    // Логика эхо-подключения здесь
}

Ключевые моменты:

  1. build_listen_tcp создаёт слушатель, который будет унаследован дочерними процессами.

  2. ready() сигнализирует родительскому процессу, что инициализация завершена и он может безопасно завершиться.

  3. shutdown_fut.await блокируется до тех пор, пока не будет запрошено обновление или остановка. Этот фьючер возвращает результат только тогда, когда процесс должен быть завершён — либо после успешного выполнения обновления/перезагрузки, либо после получения сигнала остановки.

Когда вы отправляете процессу сигнал SIGHUP, вот что делает ecdysis…

…в родительском процессе:

  • Выполняет fork и exec нового экземпляра вашего бинарного файла.

  • Передаёт слушающий сокет дочернему процессу.

  • Ожидает, пока дочерний процесс вызовет ready().

  • Завершает существующие подключения, затем завершает работу.

…в дочернем процессе:

  • Инициализирует себя, следуя тому же потоку выполнения, что и родитель, за исключением того, что любые сокеты, принадлежащие ecdysis, наследуются и не создаются заново дочерним процессом.

  • Сигнализирует родителю о готовности, вызывая ready().

  • Блокируется в ожидании сигнала завершения работы или обновления.

Использование в продакшене в масштабе

ecdysis работает в продакшене в Cloudflare с 2021 года. На нём работают критически важные инфраструктурные сервисы на Rust, развёрнутые в более чем 330+ дата-центрах в 120+ странах. Эти сервисы обрабатывают миллиарды запросов в день и требуют частых обновлений для исправлений безопасности, выпусков новых функций и изменений конфигурации.

Каждый перезапуск с использованием ecdysis сохраняет сотни тысяч запросов, которые в противном случае были бы потеряны при простом цикле остановки/запуска. В масштабах нашей глобальной инфраструктуры это означает сохранение миллионов подключений и повышение надёжности для клиентов.

ecdysis vs альтернативы

Библиотеки для плавного перезапуска существуют для нескольких экосистем. Понимание, когда использовать ecdysis, а когда альтернативы, критически важно для выбора правильного инструмента.

tableflip — это наша библиотека для Go, которая вдохновила создание ecdysis. Она реализует ту же модель fork-and-inherit для сервисов на Go. Если вам нужен Go, tableflip — отличный вариант!

shellflip — это другая библиотека Cloudflare для плавного перезапуска на Rust, созданная специально для Oxy, нашего прокси на Rust. shellflip более жёстко задаёт условия: она предполагает использование systemd и Tokio и сосредоточена на передаче произвольного состояния приложения между родителем и потомком. Это делает её отличным выбором для сложных stateful-сервисов или сервисов, которые хотят применять столь агрессивное изолирование, что они не могут даже открывать собственные сокеты, но это добавляет накладные расходы в более простых случаях.

Начните разработку

ecdysis привносит в экосистему Rust пятилетний опыт отлаженных в продакшене возможностей плавного перезапуска. Это та же технология, которая защищает миллионы подключений в глобальной сети Cloudflare, а теперь она стала open-source и доступна для всех!

Полная документация доступна на docs.rs/ecdysis, включая справочник по API, примеры для распространённых сценариев использования и шаги по интеграции с systemd.

Директория examples в репозитории содержит рабочий код, демонстрирующий использование TCP-слушателей, слушателей Unix-сокетов и интеграцию с systemd.

Библиотека активно поддерживается командой Argo Smart Routing & Orpheus при участии команд со всего Cloudflare. Мы приветствуем вклад, сообщения об ошибках и запросы функций на GitHub.

Создаёте ли вы высокопроизводительный прокси, долгоживущий API-сервер или любой сетевой сервис, где важна бесперебойная работа, ecdysis может стать основой для эксплуатации с нулевым временем простоя.