Graceful shutdown (SIGTERM) + HTTP /health endpoint

Раньше shutdown_signal вообще не существовал — весь механизм graceful
shutdown в network.rs был мёртвым кодом, ничего его не вызывало. main.rs
теперь spawn'ит net.run() и последовательно ждёт сигнал, затем drain
(НЕ tokio::select! — select! убил бы run()-future на середине drain'а в
момент, когда shutdown-future резолвится). /health на 127.0.0.1 отдаёт
активные соединения — для systemd/LB health-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:37:25 +07:00
parent d8363667e0
commit 10d357b8ec
5 changed files with 180 additions and 5 deletions
+4 -1
View File
@@ -5,10 +5,13 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/root/netr-core WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=8443 ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=8443 --health-port=9092
Restart=always Restart=always
RestartSec=3 RestartSec=3
LimitNOFILE=65535 LimitNOFILE=65535
# systemctl stop/restart шлёт SIGTERM — теперь сервер ловит его сам и
# дожидается отключения активных клиентов вместо мгновенного обрыва.
TimeoutStopSec=35
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+5 -1
View File
@@ -5,10 +5,14 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/root/netr-core WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 --health-port=9091
Restart=always Restart=always
RestartSec=3 RestartSec=3
LimitNOFILE=65535 LimitNOFILE=65535
# systemctl stop/restart шлёт SIGTERM — теперь сервер ловит его сам и
# дожидается отключения активных клиентов (см. server/src/main.rs,
# server/src/network.rs) вместо мгновенного обрыва по TimeoutStopSec.
TimeoutStopSec=35
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
//! Минимальный HTTP `/health` для supervisor'а (systemd/docker healthcheck) —
//! отдельный порт, не тот, где слушается замаскированный туннельный протокол
//! (смешивать их нельзя: health-эндпоинт — обычный читаемый HTTP, это выдало
//! бы DPI ровно то, что декой должен скрывать). По умолчанию биндится только
//! на 127.0.0.1 — наружу торчать не должен, это внутренняя проверка для
//! supervisor'а/docker healthcheck на этой же машине.
//!
//! Не использует HTTP-фреймворк (лишняя зависимость ради одного эндпоинта) —
//! отвечает одним и тем же 200+JSON на любое подключение, не разбирая запрос.
use netrunner_logger::{error, info, warn};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
/// Отдаёт реальное состояние — не просто "процесс жив", а сколько сейчас
/// физических соединений реально обслуживается (см. `Network::run`,
/// `active_connections`).
pub async fn run(
host: String,
port: u16,
active_connections: Arc<AtomicU64>,
token: CancellationToken,
) {
let addr = format!("{host}:{port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Не удалось поднять health-listener");
return;
}
};
info!("🩺 Health-эндпоинт слушает на {}", addr);
loop {
tokio::select! {
_ = token.cancelled() => {
info!("Health-listener остановлен по сигналу отмены.");
break;
}
res = listener.accept() => {
let Ok((mut stream, _)) = res else { continue };
let active = active_connections.load(Ordering::Relaxed);
tokio::spawn(async move {
let body = format!(
r#"{{"status":"ok","active_connections":{active}}}"#
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()).await {
warn!(error = %e, "Health-listener: ошибка записи ответа");
}
});
}
}
}
}
+59 -3
View File
@@ -12,16 +12,46 @@
mod backend_client; mod backend_client;
mod diagnostics; mod diagnostics;
mod health;
mod network; mod network;
use clap::Parser; use clap::Parser;
use netrunner_core::net::AuthValidator; use netrunner_core::net::AuthValidator;
use netrunner_logger::Logger; use netrunner_logger::{error, info, Logger};
use std::sync::Arc; use std::sync::Arc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::backend_client::BackendClient; use crate::backend_client::BackendClient;
use crate::network::Network; use crate::network::Network;
/// Ждёт SIGTERM (docker stop/systemctl stop) или SIGINT (Ctrl+C) и отменяет
/// токен — раньше этой функции не было вообще: сервер получал сигнал прямо
/// от ОС мимо CancellationToken'а, и вся drain-логика в `Network::run` была
/// мертва, ни разу не срабатывая на реальном шатдауне.
async fn shutdown_signal(token: CancellationToken) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Не удалось установить обработчик Ctrl+C");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Не удалось установить обработчик SIGTERM")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => info!("🛑 Получен SIGINT (Ctrl+C). Останавливаемся..."),
_ = terminate => info!("🛑 Получен SIGTERM. Останавливаемся..."),
}
token.cancel();
}
/// Аргументы командной строки сервера. /// Аргументы командной строки сервера.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about = "Netrunner Proxy Server")] #[command(author, version, about = "Netrunner Proxy Server")]
@@ -52,6 +82,12 @@ struct Args {
/// Обязателен, только если передан `--require-auth`. /// Обязателен, только если передан `--require-auth`.
#[arg(long)] #[arg(long)]
backend_url: Option<String>, backend_url: Option<String>,
/// Порт для внутреннего HTTP `/health` (биндится только на 127.0.0.1 —
/// не для публичного доступа, только supervisor/docker healthcheck на
/// этой же машине). Не задан — health-эндпоинт выключен.
#[arg(long)]
health_port: Option<u16>,
} }
fn main() { fn main() {
@@ -70,11 +106,31 @@ fn main() {
None None
}; };
let net = Network::new(args.host, args.port, args.decoy_host, auth); let net = Network::new(
args.host,
args.port,
args.decoy_host,
auth,
args.health_port,
);
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async { rt.block_on(async {
net.run(CancellationToken::new()).await; let token = CancellationToken::new();
let run_token = token.clone();
// net.run() спавним отдельной задачей и дожидаемся её ПОСЛЕ сигнала —
// tokio::select! между сигналом и run() тут не подходит: select
// дропает недовершившуюся ветку целиком, оборвав drain-фазу в
// Network::run в момент получения самого сигнала, вместо того чтобы
// дать ей отработать.
let run_handle = tokio::spawn(async move {
net.run(run_token).await;
});
shutdown_signal(token).await;
if let Err(e) = run_handle.await {
error!(error = ?e, "Задача сервера завершилась с паникой при остановке");
}
}); });
} }
+50
View File
@@ -11,11 +11,19 @@ use netrunner_core::net::{
TOPOLOGY_PRINT_INTERVAL, TOPOLOGY_PRINT_INTERVAL,
}; };
use netrunner_logger::{error, info, warn}; use netrunner_logger::{error, info, warn};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::diagnostics::{ClientDiagnosticsLogger, ServerDiagnosticsLogger}; use crate::diagnostics::{ClientDiagnosticsLogger, ServerDiagnosticsLogger};
use crate::health;
/// Сколько ждём при остановке, пока уже принятые соединения сами закроются,
/// прежде чем отпустить рантайм (который при Drop абортит все задачи разом,
/// без предупреждения клиентам).
const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
/// Параметры прослушивания сервера. /// Параметры прослушивания сервера.
pub struct Network { pub struct Network {
@@ -27,6 +35,9 @@ pub struct Network {
/// `None` — `--require-auth` не передан, авторизация и лимиты трафика /// `None` — `--require-auth` не передан, авторизация и лимиты трафика
/// выключены на этом инстансе целиком (поведение как до этой фичи). /// выключены на этом инстансе целиком (поведение как до этой фичи).
auth: Option<Arc<dyn AuthValidator>>, auth: Option<Arc<dyn AuthValidator>>,
/// `None` — health-эндпоинт выключен (по умолчанию для обратной
/// совместимости с уже развёрнутыми нодами без этого флага).
health_port: Option<u16>,
} }
impl Network { impl Network {
@@ -35,12 +46,14 @@ impl Network {
port: u16, port: u16,
decoy_host: impl Into<Arc<str>>, decoy_host: impl Into<Arc<str>>,
auth: Option<Arc<dyn AuthValidator>>, auth: Option<Arc<dyn AuthValidator>>,
health_port: Option<u16>,
) -> Self { ) -> Self {
Self { Self {
host, host,
port, port,
decoy_host: decoy_host.into(), decoy_host: decoy_host.into(),
auth, auth,
health_port,
} }
} }
@@ -125,6 +138,23 @@ impl Network {
info!("🌐 Netrunner Server: Listening on {}", addr); info!("🌐 Netrunner Server: Listening on {}", addr);
let listener = TcpListener::bind(&addr).await.expect("Server bind failed"); let listener = TcpListener::bind(&addr).await.expect("Server bind failed");
// Число реально обслуживаемых физических соединений прямо сейчас — не
// "процесс жив", а "сколько клиентов на нём висит". Отдаётся в /health
// и используется ниже, чтобы дождаться отключения клиентов при
// остановке вместо мгновенного разрыва при drop рантайма.
let active_connections = Arc::new(AtomicU64::new(0));
if let Some(health_port) = self.health_port {
let health_token = token.clone();
let health_connections = active_connections.clone();
tokio::spawn(health::run(
"127.0.0.1".to_string(),
health_port,
health_connections,
health_token,
));
}
loop { loop {
tokio::select! { tokio::select! {
_ = token.cancelled() => { _ = token.cancelled() => {
@@ -145,6 +175,8 @@ impl Network {
self.auth.clone(), self.auth.clone(),
); );
active_connections.fetch_add(1, Ordering::Relaxed);
let conn_counter = active_connections.clone();
tokio::spawn(async move { tokio::spawn(async move {
// "Входим" в этот Span. Все логи внутри handler.run() привяжутся к этому IP. // "Входим" в этот Span. Все логи внутри handler.run() привяжутся к этому IP.
let _enter = span.enter(); let _enter = span.enter();
@@ -153,10 +185,28 @@ impl Network {
if let Err(e) = handler.run().await { if let Err(e) = handler.run().await {
error!(error = %e, "⚠️ Server handler terminated with error"); error!(error = %e, "⚠️ Server handler terminated with error");
} }
conn_counter.fetch_sub(1, Ordering::Relaxed);
}); });
} }
} }
} }
} }
// Graceful drain: приём новых соединений уже остановлен (цикл выше
// прерван), но уже принятые клиенты продолжают жить как detached-задачи
// рантайма. Без этого ожидания следующий за `run()` выход из
// `rt.block_on` уронит рантайм и оборвёт их все разом без предупреждения.
let drain_start = tokio::time::Instant::now();
while active_connections.load(Ordering::Relaxed) > 0 {
if drain_start.elapsed() > SHUTDOWN_DRAIN_TIMEOUT {
warn!(
remaining = active_connections.load(Ordering::Relaxed),
"Drain timeout истёк, принудительно завершаем оставшиеся соединения"
);
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
info!("✅ Drain завершён, соединений осталось: {}", active_connections.load(Ordering::Relaxed));
} }
} }