배포 준비 연습 문서의 문제 3 풀이 코드다.
//! 50 배포 준비 연습 p3: /healthz(살아 있나), /readyz(요청을 받아도 되나), /version(무엇이 돌고 있나)과
//! 컨테이너 HEALTHCHECK가 부르는 `healthcheck` 하위 명령. oneshot과 127.0.0.1:0의 진짜 소켓으로 시험한다.
//! 의존성: axum = "0.8", tokio = { version = "1", features = ["full"] }, tower = { version = "0.5", features = ["util"] },
//! serde = { version = "1", features = ["derive"] }, serde_json = "1"
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/4.연습문제/50_release-readiness-practice_p3.rs"
//! 진짜 서버로 띄우려면 주소를 "0.0.0.0:8080"으로 바꾸고, 종료 신호를 SIGTERM으로 받는다(40번 안전한 종료).
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use axum::body::{Body, to_bytes};
use axum::extract::State;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use axum::{Json, Router};
use serde::Serialize;
use tower::ServiceExt;
/// 빌드할 때 GIT_HASH를 주면 그 값, 아니면 "unknown".
const GIT_HASH: &str = match option_env!("GIT_HASH") {
Some(h) => h,
None => "unknown",
};
/// 준비 상태 깃발. 시작 준비가 끝나면 켜고, 종료를 시작하면 끈다.
#[derive(Clone, Default)]
struct Health {
ready: Arc<AtomicBool>,
}
/// 살아 있나: 프로세스가 요청에 답할 수 있으면 200. 바깥(DB 등)은 보지 않는다.
async fn healthz() -> &'static str {
"ok"
}
/// 요청을 받아도 되나: 깃발이 켜져 있을 때만 200, 아니면 503.
async fn readyz(State(h): State<Health>) -> (StatusCode, &'static str) {
if h.ready.load(Ordering::Acquire) {
(StatusCode::OK, "ready")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "not ready")
}
}
#[derive(Serialize)]
struct VersionInfo {
name: &'static str,
version: &'static str,
git: &'static str,
}
async fn version() -> Json<VersionInfo> {
Json(VersionInfo {
name: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION"),
git: GIT_HASH,
})
}
fn app(h: Health) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/readyz", get(readyz))
.route("/version", get(version))
.with_state(h)
}
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Serve,
HealthCheck,
}
/// 진짜 main의 첫머리: `shortener healthcheck`이면 검사만 하고 그 종료 코드로 끝난다.
fn mode(args: &[&str]) -> Mode {
match args.get(1) {
Some(&"healthcheck") => Mode::HealthCheck,
_ => Mode::Serve,
}
}
/// 같은 컨테이너 안의 서버에 GET을 보내 상태 줄의 숫자를 돌려준다. curl이 없는 이미지에서도 돈다.
fn status_of(addr: SocketAddr, path: &str, timeout: Duration) -> std::io::Result<u16> {
let mut s = TcpStream::connect_timeout(&addr, timeout)?;
s.set_read_timeout(Some(timeout))?;
s.set_write_timeout(Some(timeout))?;
write!(
s,
"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
)?;
let mut buf = String::new();
s.read_to_string(&mut buf)?;
buf.split(' ')
.nth(1)
.and_then(|code| code.parse().ok())
.ok_or_else(|| std::io::Error::other("상태 줄을 읽지 못했다"))
}
/// HEALTHCHECK 종료 코드: 0 건강, 1 아픔. 2는 Docker가 예약해 두었으므로 쓰지 않는다.
fn healthcheck(addr: SocketAddr, timeout: Duration) -> u8 {
match status_of(addr, "/healthz", timeout) {
Ok(200) => 0,
_ => 1,
}
}
async fn oneshot(router: &Router, path: &str) -> (StatusCode, String) {
let req = Request::builder().uri(path).body(Body::empty()).unwrap();
let res = router.clone().oneshot(req).await.expect("infallible");
let status = res.status();
let bytes = to_bytes(res.into_body(), 1 << 16).await.unwrap();
(status, String::from_utf8(bytes.to_vec()).unwrap())
}
async fn blocking_status(addr: SocketAddr, path: &'static str) -> std::io::Result<u16> {
tokio::task::spawn_blocking(move || status_of(addr, path, Duration::from_secs(2)))
.await
.unwrap()
}
#[tokio::main]
async fn main() {
assert_eq!(mode(&["shortener"]), Mode::Serve);
assert_eq!(mode(&["shortener", "healthcheck"]), Mode::HealthCheck);
// 1. oneshot: 시작 직후에는 살아 있지만 준비되지 않았다.
let h = Health::default();
let router = app(h.clone());
assert_eq!(
oneshot(&router, "/healthz").await,
(StatusCode::OK, "ok".into())
);
assert_eq!(
oneshot(&router, "/readyz").await,
(StatusCode::SERVICE_UNAVAILABLE, "not ready".into())
);
h.ready.store(true, Ordering::Release);
assert_eq!(
oneshot(&router, "/readyz").await,
(StatusCode::OK, "ready".into())
);
let (status, body) = oneshot(&router, "/version").await;
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["name"], env!("CARGO_PKG_NAME"));
assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(v["git"], GIT_HASH);
println!("/version: {body}");
// 2. 진짜 소켓: 서버를 띄우고 healthcheck 하위 명령이 쓰는 함수로 묻는다.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async {
stop_rx.await.ok();
})
.await
.unwrap();
});
let code = tokio::task::spawn_blocking(move || healthcheck(addr, Duration::from_secs(2)))
.await
.unwrap();
assert_eq!(code, 0);
assert_eq!(blocking_status(addr, "/readyz").await.unwrap(), 200);
// 3. 종료를 시작한다: 준비 깃발만 끈다. 살아 있음은 그대로라 재시작되지 않고, 새 요청만 빠진다.
h.ready.store(false, Ordering::Release);
assert_eq!(blocking_status(addr, "/readyz").await.unwrap(), 503);
assert_eq!(blocking_status(addr, "/healthz").await.unwrap(), 200);
println!("종료 중: /readyz 503, /healthz 200");
stop_tx.send(()).unwrap();
server.await.unwrap();
// 4. 서버가 꺼진 뒤에는 연결이 안 되므로 1.
let code = tokio::task::spawn_blocking(move || healthcheck(addr, Duration::from_millis(500)))
.await
.unwrap();
assert_eq!(code, 1);
println!("healthcheck: 서버가 돌 때 0, 꺼진 뒤 1");
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unknown_path_is_404() {
let router = app(Health::default());
assert_eq!(oneshot(&router, "/nope").await.0, StatusCode::NOT_FOUND);
}
#[test]
fn other_args_serve() {
assert_eq!(mode(&["shortener", "--port", "1"]), Mode::Serve);
assert_eq!(mode(&[]), Mode::Serve);
}
}