From 278f3187a8dc97c87eac21e23e5fc31affb9f4c8 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 05:44:18 +0000 Subject: [PATCH 01/27] chore: point lambda_http at SnapStart-enabled runtime branch --- Cargo.lock | 20 ++++++++------------ Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed6302ce..97d2fd87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,9 +262,8 @@ dependencies = [ [[package]] name = "aws_lambda_events" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "087b1b9233c7fc56623d72bb2f0b1fe915b19a0606aa3c09bcd1b902d9803e6c" +version = "1.2.0" +source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" dependencies = [ "base64", "bytes", @@ -1245,9 +1244,8 @@ dependencies = [ [[package]] name = "lambda_http" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f0300091919bd7c3d953bd0fb6a7a170d24f333486e4129421c0f6aa1164ac" +version = "1.2.1" +source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" dependencies = [ "aws_lambda_events", "bytes", @@ -1270,9 +1268,8 @@ dependencies = [ [[package]] name = "lambda_runtime" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23583c918bee8de7bc005ba2ad8ebd84529894bfd9a863cf24226bb6c7787690" +version = "1.2.1" +source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" dependencies = [ "async-stream", "base64", @@ -1295,9 +1292,8 @@ dependencies = [ [[package]] name = "lambda_runtime_api_client" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4873061514cb57ffb6a599b77c46c65d6d783efe9bad8fd56b7cba7f0459ef" +version = "1.1.0" +source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" dependencies = [ "bytes", "futures-channel", diff --git a/Cargo.toml b/Cargo.toml index c9a1ff49..7995e4ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ http-body = "1.0.1" http-body-util = "0.1.0" hyper = { version = "1.5.2", features = ["client"] } hyper-util = "0.1.10" -lambda_http = { version = "1.1.1", default-features = false, features = [ +lambda_http = { git = "https://github.com/bnusunny/aws-lambda-rust-runtime.git", branch = "feat/snapstart-resource-trait", default-features = false, features = [ "apigw_http", "apigw_rest", "alb", From dd08ffd37d81536f068b698078032ff2847bc2da Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 05:46:21 +0000 Subject: [PATCH 02/27] feat: add SnapStart hook-path environment variables --- src/lib.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index d3eec116..a43fde09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,6 +78,8 @@ const ENV_ENABLE_COMPRESSION: &str = "AWS_LWA_ENABLE_COMPRESSION"; const ENV_INVOKE_MODE: &str = "AWS_LWA_INVOKE_MODE"; const ENV_AUTHORIZATION_SOURCE: &str = "AWS_LWA_AUTHORIZATION_SOURCE"; const ENV_ERROR_STATUS_CODES: &str = "AWS_LWA_ERROR_STATUS_CODES"; +const ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH: &str = "AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH"; +const ENV_SNAPSTART_AFTER_RESTORE_PATH: &str = "AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH"; const ENV_LAMBDA_RUNTIME_API_PROXY: &str = "AWS_LWA_LAMBDA_RUNTIME_API_PROXY"; // Deprecated environment variable names (without prefix) @@ -349,6 +351,16 @@ pub struct AdapterOptions { /// the adapter will return an error to Lambda instead of the response. /// This can be useful for triggering Lambda retry behavior. pub error_status_codes: Option>, + + /// Inner-app path POSTed before the SnapStart snapshot is taken. + /// When set, the adapter notifies the app so it can drain resources. + /// Default: `None` (phase skipped). + pub snapstart_before_checkpoint_path: Option, + + /// Inner-app path POSTed after the SnapStart restore completes. + /// When set, the adapter notifies the app so it can reconnect / reseed. + /// Default: `None` (phase skipped). + pub snapstart_after_restore_path: Option, } /// Helper to get env var with deprecation warning for old name @@ -435,6 +447,8 @@ impl Default for AdapterOptions { error_status_codes: env::var(ENV_ERROR_STATUS_CODES) .ok() .map(|codes| parse_status_codes(&codes)), + snapstart_before_checkpoint_path: env::var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH).ok(), + snapstart_after_restore_path: env::var(ENV_SNAPSTART_AFTER_RESTORE_PATH).ok(), } } } @@ -1078,6 +1092,34 @@ mod tests { assert_eq!(parse_status_codes(""), Vec::::new()); } + #[test] + fn test_snapstart_paths_default_unset() { + temp_env_unset(&[ + "AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH", + "AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH", + ]); + let options = AdapterOptions::default(); + assert_eq!(options.snapstart_before_checkpoint_path, None); + assert_eq!(options.snapstart_after_restore_path, None); + } + + #[test] + fn test_snapstart_paths_parsed_when_set() { + std::env::set_var("AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH", "/snapstart/before"); + std::env::set_var("AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH", "/snapstart/after"); + let options = AdapterOptions::default(); + assert_eq!(options.snapstart_before_checkpoint_path.as_deref(), Some("/snapstart/before")); + assert_eq!(options.snapstart_after_restore_path.as_deref(), Some("/snapstart/after")); + std::env::remove_var("AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH"); + std::env::remove_var("AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH"); + } + + fn temp_env_unset(keys: &[&str]) { + for k in keys { + std::env::remove_var(k); + } + } + #[tokio::test] async fn test_status_200_is_ok() { // Start app server From f2f997cf0734b21daba77db559be19d6d2c5fe42 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 05:48:44 +0000 Subject: [PATCH 03/27] test: merge SnapStart env-var tests to avoid parallel race --- src/lib.rs | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a43fde09..ae420c47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1092,32 +1092,33 @@ mod tests { assert_eq!(parse_status_codes(""), Vec::::new()); } + // Both cases live in one test because they mutate the same process-global env + // vars; splitting them lets Rust's parallel test runner interleave the + // set/remove calls and clobber each other's state. #[test] - fn test_snapstart_paths_default_unset() { - temp_env_unset(&[ - "AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH", - "AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH", - ]); + fn test_snapstart_paths() { + // Default case: unset env vars -> both None. + std::env::remove_var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH); + std::env::remove_var(ENV_SNAPSTART_AFTER_RESTORE_PATH); let options = AdapterOptions::default(); assert_eq!(options.snapstart_before_checkpoint_path, None); assert_eq!(options.snapstart_after_restore_path, None); - } - #[test] - fn test_snapstart_paths_parsed_when_set() { - std::env::set_var("AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH", "/snapstart/before"); - std::env::set_var("AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH", "/snapstart/after"); + // Set case: env vars present -> parsed into Some(..). + std::env::set_var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH, "/snapstart/before"); + std::env::set_var(ENV_SNAPSTART_AFTER_RESTORE_PATH, "/snapstart/after"); let options = AdapterOptions::default(); - assert_eq!(options.snapstart_before_checkpoint_path.as_deref(), Some("/snapstart/before")); - assert_eq!(options.snapstart_after_restore_path.as_deref(), Some("/snapstart/after")); - std::env::remove_var("AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH"); - std::env::remove_var("AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH"); - } + assert_eq!( + options.snapstart_before_checkpoint_path.as_deref(), + Some("/snapstart/before") + ); + assert_eq!( + options.snapstart_after_restore_path.as_deref(), + Some("/snapstart/after") + ); - fn temp_env_unset(keys: &[&str]) { - for k in keys { - std::env::remove_var(k); - } + std::env::remove_var(ENV_SNAPSTART_BEFORE_CHECKPOINT_PATH); + std::env::remove_var(ENV_SNAPSTART_AFTER_RESTORE_PATH); } #[tokio::test] From 30bac8a89887c0be948146ca46e972a20551bf67 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 05:50:11 +0000 Subject: [PATCH 04/27] refactor: extract build_client() and drop SnapStart pool special-case --- src/lib.rs | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ae420c47..ebb8c8ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -565,16 +565,21 @@ pub struct Adapter { error_status_codes: Option>, } +/// Builds the hyper client used to talk to the inner web application. +/// +/// Shared by [`Adapter::new`] and the SnapStart after-restore hook so the +/// post-restore client is built identically to the original. +fn build_client() -> Client { + let mut builder = Client::builder(hyper_util::rt::TokioExecutor::new()); + builder.pool_idle_timeout(Duration::from_secs(4)); + builder.build(HttpConnector::new()) +} + impl Adapter { /// Creates a new HTTP Adapter instance. /// /// This function initializes a new HTTP client configured to communicate with - /// your web application. When Lambda SnapStart is detected - /// (`AWS_LAMBDA_INITIALIZATION_TYPE=snap-start`), connection pooling is - /// disabled to prevent stale connections after restore, where - /// `CLOCK_MONOTONIC` inconsistencies can cause hyper's pool to reuse dead - /// connections. Otherwise, a 4-second idle timeout is used for connection - /// pooling. + /// your web application, using a 4-second idle timeout for connection pooling. /// /// # Arguments /// @@ -599,19 +604,7 @@ impl Adapter { /// let adapter = Adapter::new(&options).expect("Failed to create adapter"); /// ``` pub fn new(options: &AdapterOptions) -> Result, Error> { - let mut builder = Client::builder(hyper_util::rt::TokioExecutor::new()); - - // When running under SnapStart, CLOCK_MONOTONIC can be inconsistent after - // restore, causing hyper's pool to reuse dead connections (hyper#3810, - // rust-lang/rust#79462). Disable pooling in that case. For localhost - // communication the overhead of new TCP connections is negligible. - if env::var("AWS_LAMBDA_INITIALIZATION_TYPE").as_deref() == Ok("snap-start") { - builder.pool_max_idle_per_host(0); - } else { - builder.pool_idle_timeout(Duration::from_secs(4)); - } - - let client = builder.build(HttpConnector::new()); + let client = build_client(); let schema = "http"; From 4acd9682c6021fca7b5f526d8eff027adfd73292 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 05:54:00 +0000 Subject: [PATCH 05/27] feat: add swappable restored_client with write-once OnceLock --- src/lib.rs | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ebb8c8ec..f53ae748 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -551,6 +551,7 @@ fn strip_forbidden_header_bytes(s: &str) -> Cow<'_, [u8]> { #[derive(Clone)] pub struct Adapter { client: Arc>, + restored_client: Arc>>>, healthcheck_url: Url, healthcheck_protocol: Protocol, healthcheck_healthy_status: Vec, @@ -648,6 +649,7 @@ impl Adapter { Ok(Adapter { client: Arc::new(client), + restored_client: Arc::new(OnceLock::new()), healthcheck_url, healthcheck_protocol: options.readiness_check_protocol, healthcheck_healthy_status: options.readiness_check_healthy_status.clone(), @@ -662,6 +664,12 @@ impl Adapter { error_status_codes: options.error_status_codes.clone(), }) } + + /// Returns the active inner-app HTTP client: the restored client if a + /// SnapStart restore has published one, otherwise the base client. + fn client(&self) -> &Arc> { + self.restored_client.get().unwrap_or(&self.client) + } } impl Adapter { @@ -811,7 +819,7 @@ impl Adapter { .parse() .expect("BUG: healthcheck_url should be valid - validated in Adapter::new()"); - match self.client.get(uri).await { + match self.client().get(uri).await { Ok(response) if self.healthcheck_healthy_status.contains(&response.status().as_u16()) => { tracing::debug!("app is ready"); Ok(()) @@ -1016,7 +1024,7 @@ impl Adapter { }; let request = builder.body(Body::Binary(body_bytes))?; - let mut app_response = self.client.request(request).await?; + let mut app_response = self.client().request(request).await?; // Check if status code should trigger an error if let Some(error_codes) = &self.error_status_codes { @@ -1593,4 +1601,32 @@ mod tests { .expect("Request failed despite control bytes in request context path"); assert_eq!(200, response.status().as_u16()); } + + #[tokio::test] + async fn test_client_helper_returns_restored_when_set() { + let options = AdapterOptions { + host: "127.0.0.1".to_string(), + port: "8080".to_string(), + readiness_check_port: "8080".to_string(), + ..Default::default() + }; + let adapter = Adapter::new(&options).expect("Failed to create adapter"); + + // Before restore: client() returns the base client. + let base_ptr = Arc::as_ptr(adapter.client()) as *const (); + + // Publish a fresh client. + let fresh = Arc::new(build_client()); + let fresh_ptr = Arc::as_ptr(&fresh) as *const (); + adapter + .restored_client + .set(fresh) + .ok() + .expect("set should succeed once"); + + // After restore: client() returns the restored client (different pointer). + let now_ptr = Arc::as_ptr(adapter.client()) as *const (); + assert_ne!(now_ptr, base_ptr); + assert_eq!(now_ptr, fresh_ptr); + } } From af95bed5408825f79dcc9c2f2cfef54682378385 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 05:57:58 +0000 Subject: [PATCH 06/27] refactor: return BoxBody from fetch_response --- src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f53ae748..45605a55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,13 +101,13 @@ const ENV_LAMBDA_RUNTIME_API: &str = "AWS_LAMBDA_RUNTIME_API"; // (Some(None)). static ORIGINAL_LAMBDA_RUNTIME_API: OnceLock> = OnceLock::new(); +use bytes::Bytes; use http::{ header::{HeaderName, HeaderValue}, Method, StatusCode, }; use http_body::Body as HttpBody; -use http_body_util::BodyExt; -use hyper::body::Incoming; +use http_body_util::{combinators::BoxBody, BodyExt}; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use lambda_http::request::RequestContext; @@ -945,7 +945,7 @@ impl Adapter { /// 4. Strips the base path if configured /// 5. Forwards the request to the web application /// 6. Returns the response (or error if status code is in error_status_codes) - async fn fetch_response(&self, event: Request) -> Result, Error> { + async fn fetch_response(&self, event: Request) -> Result>, Error> { if self.async_init && !self.ready_at_init.load(Ordering::SeqCst) { self.is_web_ready(&self.healthcheck_url, &self.healthcheck_protocol) .await; @@ -1051,7 +1051,9 @@ impl Adapter { tracing::debug!(status = %app_response.status(), body_size = ?app_response.body().size_hint().lower(), app_headers = ?app_response.headers().clone(), "responding to lambda event"); - Ok(app_response) + // Box the body into a uniform type so synthetic responses (e.g. the 403 + // hook-path guard) can share the return type with proxied responses. + Ok(app_response.map(|body| body.map_err(Error::from).boxed())) } } @@ -1060,7 +1062,7 @@ impl Adapter { /// This allows the adapter to be used directly with the Lambda runtime, /// which expects a `Service` that can handle Lambda events. impl Service for Adapter { - type Response = Response; + type Response = Response>; type Error = Error; type Future = Pin> + Send>>; From 5b7dae6bf21b4af25147ab23a9ee00a2a68132ee Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 06:02:27 +0000 Subject: [PATCH 07/27] feat: reject external requests to SnapStart hook paths with 403 --- src/lib.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 45605a55..e2c3f2ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,7 +107,7 @@ use http::{ Method, StatusCode, }; use http_body::Body as HttpBody; -use http_body_util::{combinators::BoxBody, BodyExt}; +use http_body_util::{combinators::BoxBody, BodyExt, Empty}; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use lambda_http::request::RequestContext; @@ -564,6 +564,8 @@ pub struct Adapter { invoke_mode: LambdaInvokeMode, authorization_source: Option, error_status_codes: Option>, + snapstart_before_checkpoint_path: Option, + snapstart_after_restore_path: Option, } /// Builds the hyper client used to talk to the inner web application. @@ -662,6 +664,8 @@ impl Adapter { invoke_mode: options.invoke_mode, authorization_source: options.authorization_source.clone(), error_status_codes: options.error_status_codes.clone(), + snapstart_before_checkpoint_path: options.snapstart_before_checkpoint_path.clone(), + snapstart_after_restore_path: options.snapstart_after_restore_path.clone(), }) } @@ -971,6 +975,17 @@ impl Adapter { path = self.pass_through_path.as_str(); } + // Block external traffic to the SnapStart hook paths. These routes are + // control-plane operations driven only by the adapter's own hook calls + // (which target `domain` directly and never reach this function). + let is_hook_path = |configured: &Option| configured.as_deref().is_some_and(|p| p == path); + if is_hook_path(&self.snapstart_before_checkpoint_path) || is_hook_path(&self.snapstart_after_restore_path) { + tracing::warn!(path = %path, "rejecting external request to SnapStart hook path"); + return Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Empty::::new().map_err(Error::from).boxed())?); + } + let mut req_headers = parts.headers; // include request context in http header "x-amzn-request-context" @@ -1448,6 +1463,77 @@ mod tests { assert_eq!(200, response.status().as_u16()); } + #[tokio::test] + async fn test_external_request_to_hook_path_is_forbidden() { + // App server should NOT be called for a guarded path. + let app_server = MockServer::start(); + let guarded = app_server.mock(|when, then| { + when.path("/snapstart/after"); + then.status(200).body("should not be called"); + }); + + let options = AdapterOptions { + host: app_server.host(), + port: app_server.port().to_string(), + readiness_check_port: app_server.port().to_string(), + readiness_check_path: "/".to_string(), + snapstart_after_restore_path: Some("/snapstart/after".to_string()), + ..Default::default() + }; + let adapter = Adapter::new(&options).expect("Failed to create adapter"); + + // External request (ALB) targeting the guarded hook path. + let alb_req = lambda_http::request::LambdaRequest::Alb({ + let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default(); + req.http_method = Method::POST; + req.path = Some("/snapstart/after".into()); + req + }); + let mut request = Request::from(alb_req); + request.extensions_mut().insert(make_lambda_context(None)); + + let response = adapter + .fetch_response(request) + .await + .expect("guard returns Ok response"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // The inner app must not have been contacted. + guarded.assert_calls(0); + } + + #[tokio::test] + async fn test_non_hook_path_is_proxied_normally() { + let app_server = MockServer::start(); + let hello = app_server.mock(|when, then| { + when.path("/hello"); + then.status(200).body("OK"); + }); + + let options = AdapterOptions { + host: app_server.host(), + port: app_server.port().to_string(), + readiness_check_port: app_server.port().to_string(), + readiness_check_path: "/".to_string(), + snapstart_after_restore_path: Some("/snapstart/after".to_string()), + ..Default::default() + }; + let adapter = Adapter::new(&options).expect("Failed to create adapter"); + + let alb_req = lambda_http::request::LambdaRequest::Alb({ + let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default(); + req.http_method = Method::GET; + req.path = Some("/hello".into()); + req + }); + let mut request = Request::from(alb_req); + request.extensions_mut().insert(make_lambda_context(None)); + + let response = adapter.fetch_response(request).await.expect("Request failed"); + assert_eq!(response.status(), StatusCode::OK); + hello.assert(); + } + #[tokio::test] async fn test_tenant_id_header_absent_when_no_tenant() { let app_server = MockServer::start(); From 541abc4240854ca8cd8ef2955c578bb030e4fb6a Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 15:58:00 +0000 Subject: [PATCH 08/27] feat: add SnapStartHooks bridging restore lifecycle to inner app --- src/lib.rs | 1 + src/snapstart.rs | 175 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/snapstart.rs diff --git a/src/lib.rs b/src/lib.rs index e2c3f2ed..f68ca1c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,6 +63,7 @@ //! with `InvokeMode: RESPONSE_STREAM`. mod readiness; +mod snapstart; // Environment variable names (AWS_LWA_ prefix) const ENV_PORT: &str = "AWS_LWA_PORT"; diff --git a/src/snapstart.rs b/src/snapstart.rs new file mode 100644 index 00000000..12d9593a --- /dev/null +++ b/src/snapstart.rs @@ -0,0 +1,175 @@ +//! SnapStart bridge: notifies the inner web application over HTTP at the +//! snapshot boundary and refreshes the adapter's HTTP client after restore. + +use std::sync::{Arc, OnceLock}; + +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; +use lambda_http::{Body, BoxFuture, Error, SnapStartResource}; +use url::Url; + +use crate::build_client; + +/// A [`SnapStartResource`] that bridges the Lambda SnapStart lifecycle to the +/// inner web application running behind the adapter. +pub(crate) struct SnapStartHooks { + /// Shared with the [`Adapter`](crate::Adapter); `after_restore` publishes the + /// fresh client here so invocations stop using pre-snapshot connections. + restored_client: Arc>>>, + /// Client used to make the hook calls themselves (the adapter's base client). + client: Arc>, + /// `http://host:port` of the inner application. + domain: Url, + before_checkpoint_path: Option, + after_restore_path: Option, +} + +impl SnapStartHooks { + pub(crate) fn new( + restored_client: Arc>>>, + client: Arc>, + domain: Url, + before_checkpoint_path: Option, + after_restore_path: Option, + ) -> Self { + Self { + restored_client, + client, + domain, + before_checkpoint_path, + after_restore_path, + } + } + + /// POSTs an empty body to `domain + path` using `client`. Non-2xx or a + /// transport error is an error. + async fn post_hook(client: &Client, domain: &Url, path: &str) -> Result<(), Error> { + let mut url = domain.clone(); + url.set_path(path); + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(url.to_string()) + .body(Body::Empty)?; + let resp = client.request(req).await?; + if !resp.status().is_success() { + return Err(Error::from(format!( + "SnapStart hook POST {path} returned non-success status: {}", + resp.status() + ))); + } + Ok(()) + } +} + +impl SnapStartResource for SnapStartHooks { + fn before_snapshot(&self) -> BoxFuture<'_, Result<(), Error>> { + Box::pin(async move { + if let Some(path) = self.before_checkpoint_path.as_deref() { + Self::post_hook(&self.client, &self.domain, path).await?; + } + Ok(()) + }) + } + + fn after_restore(&self) -> BoxFuture<'_, Result<(), Error>> { + Box::pin(async move { + // 1. Publish a fresh client FIRST so the hook POST below (and all + // subsequent invocations) use post-restore connections rather + // than stale pre-snapshot ones. Ignore "already set". + let fresh = Arc::new(build_client()); + let _ = self.restored_client.set(fresh.clone()); + + // 2. Notify the app over the fresh client. Failure fails the restore; + // the fresh client stays published regardless. + if let Some(path) = self.after_restore_path.as_deref() { + Self::post_hook(&fresh, &self.domain, path).await?; + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::MockServer; + + fn hooks(server: &MockServer, before: Option<&str>, after: Option<&str>) -> SnapStartHooks { + let domain: Url = format!("http://{}:{}", server.host(), server.port()).parse().unwrap(); + SnapStartHooks::new( + Arc::new(OnceLock::new()), + Arc::new(build_client()), + domain, + before.map(str::to_string), + after.map(str::to_string), + ) + } + + #[tokio::test] + async fn before_snapshot_posts_when_set() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/before"); + then.status(200); + }); + let h = hooks(&server, Some("/before"), None); + assert!(h.before_snapshot().await.is_ok()); + m.assert(); + } + + #[tokio::test] + async fn before_snapshot_noop_when_unset() { + let server = MockServer::start(); + let h = hooks(&server, None, None); + assert!(h.before_snapshot().await.is_ok()); + } + + #[tokio::test] + async fn before_snapshot_non_2xx_is_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/before"); + then.status(500); + }); + let h = hooks(&server, Some("/before"), None); + assert!(h.before_snapshot().await.is_err()); + } + + #[tokio::test] + async fn after_restore_publishes_client_then_posts() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/after"); + then.status(200); + }); + let h = hooks(&server, None, Some("/after")); + assert!(h.restored_client.get().is_none()); + assert!(h.after_restore().await.is_ok()); + assert!(h.restored_client.get().is_some(), "fresh client published"); + m.assert(); + } + + #[tokio::test] + async fn after_restore_publishes_client_even_when_hook_fails() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/after"); + then.status(503); + }); + let h = hooks(&server, None, Some("/after")); + let result = h.after_restore().await; + assert!(result.is_err(), "hook failure returns Err"); + assert!( + h.restored_client.get().is_some(), + "client published despite hook failure" + ); + } + + #[tokio::test] + async fn after_restore_publishes_client_when_path_unset() { + let server = MockServer::start(); + let h = hooks(&server, None, None); + assert!(h.after_restore().await.is_ok()); + assert!(h.restored_client.get().is_some()); + } +} From c2a12032a8fc40ebd8c846523361f59757431689 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 16:18:58 +0000 Subject: [PATCH 09/27] feat: register SnapStart resource in adapter run loop --- src/lib.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f68ca1c1..37bb0a23 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -880,13 +880,33 @@ impl Adapter { /// # } /// ``` pub async fn run(self) -> Result<(), Error> { + let hooks = Arc::new(snapstart::SnapStartHooks::new( + self.restored_client.clone(), + self.client.clone(), + self.domain.clone(), + self.snapstart_before_checkpoint_path.clone(), + self.snapstart_after_restore_path.clone(), + )); match (self.compression, self.invoke_mode) { (true, LambdaInvokeMode::Buffered) => { let svc = ServiceBuilder::new().layer(CompressionLayer::new()).service(self); - lambda_http::run_concurrent(svc).await + lambda_http::runtime_concurrent(svc) + .register_snapstart_resource(hooks) + .run_concurrent() + .await + } + (_, LambdaInvokeMode::Buffered) => { + lambda_http::runtime_concurrent(self) + .register_snapstart_resource(hooks) + .run_concurrent() + .await + } + (_, LambdaInvokeMode::ResponseStream) => { + lambda_http::streaming_runtime_concurrent(self) + .register_snapstart_resource(hooks) + .run_concurrent() + .await } - (_, LambdaInvokeMode::Buffered) => lambda_http::run_concurrent(self).await, - (_, LambdaInvokeMode::ResponseStream) => lambda_http::run_with_streaming_response_concurrent(self).await, } } From 53253540ff3c35062dd37b830556466c2f663a69 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 16:24:26 +0000 Subject: [PATCH 10/27] docs: document SnapStart hook environment variables --- CHANGELOG.md | 12 ++++++++++++ README.md | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5de420..99150a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### Features + +- Add SnapStart support. The adapter notifies your web application at the SnapStart + boundary via two opt-in HTTP hooks — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` + (before checkpoint) and `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` (after restore) — + so it can drain and re-establish connections. The adapter also refreshes its own + HTTP client after restore and rejects external traffic to the hook paths with 403. + +--- + ## v1.0.1 - 2026-05-28 ### Bug Fixes diff --git a/README.md b/README.md index c585c905..414135d7 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ The readiness check port/path and traffic port can be configured using environme | AWS_LWA_AUTHORIZATION_SOURCE | a header name to be replaced to `Authorization` | None | | AWS_LWA_ERROR_STATUS_CODES | HTTP status codes that will cause Lambda invocations to fail (e.g. "500,502-504") | None | | AWS_LWA_LAMBDA_RUNTIME_API_PROXY | overwrites `AWS_LAMBDA_RUNTIME_API` to allow proxying request | None | +| AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH | inner-app path the adapter POSTs to before a SnapStart snapshot (drain resources) | None | +| AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH | inner-app path the adapter POSTs to after a SnapStart restore (reconnect/reseed) | None | > **Deprecation Notice:** The following non-namespaced environment variables are deprecated and will be removed in version 2.0: > `HOST`, `READINESS_CHECK_PORT`, `READINESS_CHECK_PATH`, `READINESS_CHECK_PROTOCOL`, `REMOVE_BASE_PATH`, `ASYNC_INIT`. @@ -75,6 +77,31 @@ The readiness check port/path and traffic port can be configured using environme 👉 [Detailed configuration docs](https://aws.github.io/aws-lambda-web-adapter/configuration/environment-variables.html) +### SnapStart support + +When your function uses [Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html), +the adapter can notify your web application at the snapshot boundary so it can +drain and re-establish state (database connections, cached DNS, PRNG seeds, +unique identifiers). Both hooks are opt-in and independent. + +| Variable | When the adapter calls it | Use it to | +|---|---|---| +| `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` | Before the snapshot is taken | Drain/close resources that won't survive the snapshot | +| `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | After restore, before serving traffic | Reconnect, refresh credentials, reseed randomness, regenerate unique IDs | + +Each hook is an empty `POST`; your application must respond with a `2xx` status. +A non-`2xx` response or a connection failure fails the SnapStart phase +(initialization for the before-checkpoint hook, restore for the after-restore +hook) instead of serving traffic against an improperly prepared application. + +After restore, the adapter also automatically refreshes its own HTTP connection +to your application, so it never reuses a connection captured in the snapshot. + +> These hook paths are control-plane operations. External requests (via API +> Gateway or ALB) that target a configured hook path receive `403 Forbidden` and +> are never forwarded to your application, so choose paths your normal traffic +> does not use. + ## Examples - [FastAPI](examples/fastapi) From 9e62ff36680b25afc0e97893fb2f8c9bb8eee6e8 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 17:41:15 +0000 Subject: [PATCH 11/27] docs(guide): add SnapStart feature page and env vars --- docs/guide/src/SUMMARY.md | 1 + .../configuration/environment-variables.md | 2 + docs/guide/src/features/snapstart.md | 66 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 docs/guide/src/features/snapstart.md diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index bd47c0ff..93ff43d9 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -23,6 +23,7 @@ - [Non-HTTP Event Triggers](./features/non-http-events.md) - [Multi-Tenancy](./features/multi-tenancy.md) - [Lambda Managed Instances](./features/managed-instances.md) +- [SnapStart](./features/snapstart.md) - [Graceful Shutdown](./features/graceful-shutdown.md) - [Base Path Removal](./features/base-path-removal.md) - [Authorization Header](./features/authorization-header.md) diff --git a/docs/guide/src/configuration/environment-variables.md b/docs/guide/src/configuration/environment-variables.md index 3ac78a76..c733522d 100644 --- a/docs/guide/src/configuration/environment-variables.md +++ b/docs/guide/src/configuration/environment-variables.md @@ -19,6 +19,8 @@ All configuration is done through environment variables, set either in your Dock | `AWS_LWA_AUTHORIZATION_SOURCE` | Header name to replace with `Authorization` | None | | `AWS_LWA_ERROR_STATUS_CODES` | HTTP status codes that cause Lambda invocation failure (e.g. `500,502-504`) | None | | `AWS_LWA_LAMBDA_RUNTIME_API_PROXY` | Proxy URL for Lambda Runtime API requests | None | +| `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` | Inner-app path the adapter POSTs to before a SnapStart snapshot | None | +| `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | Inner-app path the adapter POSTs to after a SnapStart restore | None | ## Deprecated Variables diff --git a/docs/guide/src/features/snapstart.md b/docs/guide/src/features/snapstart.md new file mode 100644 index 00000000..2a297d51 --- /dev/null +++ b/docs/guide/src/features/snapstart.md @@ -0,0 +1,66 @@ +# SnapStart + +[Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) snapshots an initialized execution environment and restores it on later cold starts, reducing startup latency. Because the adapter runs your web application as a separate process, the application does not have direct access to the SnapStart lifecycle. The adapter bridges this gap with two optional HTTP hooks. + +## Hooks + +| Variable | When the adapter calls it | Use it to | +|----------|---------------------------|-----------| +| `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` | Before the snapshot is taken | Drain or close resources that will not survive the snapshot | +| `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | After restore, before serving traffic | Reconnect, refresh credentials, reseed randomness, regenerate unique identifiers | + +Both hooks are opt-in and independent — each fires only when its variable is set. + +## How it works + +When `AWS_LAMBDA_INITIALIZATION_TYPE` is `snap-start`, the adapter participates in the SnapStart lifecycle: + +1. **Before checkpoint** — if `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set, the adapter sends an empty `POST` to that path on your application, then signals Lambda that it is ready for the snapshot. +2. **After restore** — Lambda restores the environment. The adapter first refreshes its own HTTP connection to your application (so it never reuses a connection captured in the snapshot), then, if `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set, sends an empty `POST` to that path. + +Each hook is an empty `POST`, and your application must respond with a `2xx` status. A non-`2xx` response or a connection failure fails the SnapStart phase — initialization for the before-checkpoint hook, restore for the after-restore hook — rather than serving traffic against an improperly prepared application. + +## Why you need the hooks + +State captured in a snapshot is shared across every restored environment. Two classes of problem follow: + +- **Stale connections.** Database connections, cached DNS, and keep-alive HTTP connections captured in the snapshot are dead by the time the environment is restored. Close them in the before-checkpoint hook and re-establish them in the after-restore hook. +- **Uniqueness and entropy.** Values seeded once at initialization — random number generators, UUID seeds, security tokens — become identical across every restored environment. Reseed them in the after-restore hook. + +## Securing the hook paths + +The hook paths are control-plane operations. External requests (via API Gateway or ALB) that target a configured hook path receive `403 Forbidden` and are never forwarded to your application. The guard matches the exact configured path, so choose paths your normal application traffic does not use (for example, `/lwa/snapstart/before` and `/lwa/snapstart/after`). + +## Example + +```python +from fastapi import FastAPI, Response + +app = FastAPI() +pool = None # your database/connection pool + + +@app.post("/lwa/snapstart/before") +async def before_checkpoint(): + # Close resources that won't survive the snapshot. + if pool is not None: + await pool.close() + return Response(status_code=200) + + +@app.post("/lwa/snapstart/after") +async def after_restore(): + # Re-establish resources and reseed anything that must be unique. + global pool + pool = await create_pool() + return Response(status_code=200) +``` + +Configure the function with: + +``` +AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/lwa/snapstart/before +AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/lwa/snapstart/after +``` + +See the [fastapi-snapstart-zip example](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart-zip) for a complete, deployable application. From e03a011aa366f5116b8821f60c2299fa7531c3a4 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 17:44:58 +0000 Subject: [PATCH 12/27] docs: add fastapi-snapstart-zip example --- docs/guide/src/examples/overview.md | 1 + examples/fastapi-snapstart-zip/.gitignore | 244 ++++++++++++++++++ examples/fastapi-snapstart-zip/README.md | 50 ++++ examples/fastapi-snapstart-zip/app/main.py | 61 +++++ .../app/requirements.txt | 10 + examples/fastapi-snapstart-zip/app/run.sh | 5 + .../fastapi-snapstart-zip/events/event.json | 62 +++++ examples/fastapi-snapstart-zip/template.yaml | 44 ++++ 8 files changed, 477 insertions(+) create mode 100644 examples/fastapi-snapstart-zip/.gitignore create mode 100644 examples/fastapi-snapstart-zip/README.md create mode 100644 examples/fastapi-snapstart-zip/app/main.py create mode 100644 examples/fastapi-snapstart-zip/app/requirements.txt create mode 100755 examples/fastapi-snapstart-zip/app/run.sh create mode 100644 examples/fastapi-snapstart-zip/events/event.json create mode 100644 examples/fastapi-snapstart-zip/template.yaml diff --git a/docs/guide/src/examples/overview.md b/docs/guide/src/examples/overview.md index 2413e607..4ab068f5 100644 --- a/docs/guide/src/examples/overview.md +++ b/docs/guide/src/examples/overview.md @@ -8,6 +8,7 @@ The repository includes working examples for many popular web frameworks, packag |---------|-----------|-----------| | [FastAPI](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi) | Docker | No | | [FastAPI in Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-zip) | Zip | No | +| [FastAPI SnapStart Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart-zip) | Zip | No | | [FastAPI Background Tasks](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-background-tasks) | Docker | No | | [FastAPI Response Streaming](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming) | Docker | Yes | | [FastAPI Response Streaming Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming-zip) | Zip | Yes | diff --git a/examples/fastapi-snapstart-zip/.gitignore b/examples/fastapi-snapstart-zip/.gitignore new file mode 100644 index 00000000..4808264d --- /dev/null +++ b/examples/fastapi-snapstart-zip/.gitignore @@ -0,0 +1,244 @@ + +# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Ruby plugin and RubyMine +/.rakeTasks + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Build folder + +*/build/* + +# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode \ No newline at end of file diff --git a/examples/fastapi-snapstart-zip/README.md b/examples/fastapi-snapstart-zip/README.md new file mode 100644 index 00000000..1c526a5b --- /dev/null +++ b/examples/fastapi-snapstart-zip/README.md @@ -0,0 +1,50 @@ +# FastAPI with Lambda SnapStart + +This example shows how to use Lambda Web Adapter's SnapStart hooks to drain and re-establish a connection pool around the snapshot/restore boundary, running a FastAPI application on the managed python runtime. + +### How does it work? + +We add the Lambda Web Adapter layer to the function and configure the wrapper script. + +1. attach Lambda Adapter layer to your function. This layer contains the Lambda Adapter binary and a wrapper script. + 1. x86_64: `arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:28` + 2. arm64: `arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerArm64:28` +2. configure Lambda environment variable `AWS_LAMBDA_EXEC_WRAPPER` to `/opt/bootstrap`. This is a wrapper script included in the layer. +3. set function handler to a startup command: `run.sh`. The wrapper script will execute this command to boot up your application. + +To get more information of Wrapper script, please read Lambda documentation [here](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper). + +#### SnapStart hooks + +[Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) initializes your function, takes a snapshot of the initialized execution environment, and restores from that snapshot to serve invocations. Some resources do not survive the snapshot (open connections) and some values must not be shared across every restored environment (unique ids, seeds). The Lambda Web Adapter bridges the SnapStart lifecycle to your inner web app through two opt-in environment variables: + +- `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/lwa/snapstart/before`. Before the snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). +- `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/lwa/snapstart/after`. After the environment is restored and before traffic is served, the adapter sends an empty HTTP `POST` to this path. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). + +Both hook routes must return a `2xx` status code. A non-2xx response or a connection failure fails the SnapStart phase. The adapter also automatically refreshes its own HTTP connection to the inner app after restore, so you do not have to manage the adapter's client. + +These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/lwa/snapstart/before` or `/lwa/snapstart/after` receive a `403 Forbidden`. + +### Build and Deploy + +Run the following commands to build and deploy the application to lambda. + +```bash +sam build --use-container +sam deploy --guided +``` +When the deployment completes, take note of FastAPISnapStartApi's Value. It is the API Gateway endpoint URL. + +### Verify it works + +Open FastAPISnapStartApi's URL in a browser. The `/` response shows `connected: true` and a `connection_id`, for example: + +```json +{ + "message": "Hello from FastAPI on Lambda SnapStart", + "connected": true, + "connection_id": 482913007 +} +``` + +After a SnapStart restore, the `connection_id` is regenerated rather than shared across every restored environment, because the adapter calls the after-restore hook (`/lwa/snapstart/after`), which reconnects the pool and generates a fresh id. This is exactly the behavior you want for any per-environment value (connections, random seeds, unique identifiers) that must not be duplicated across restored snapshots. diff --git a/examples/fastapi-snapstart-zip/app/main.py b/examples/fastapi-snapstart-zip/app/main.py new file mode 100644 index 00000000..4f5e0763 --- /dev/null +++ b/examples/fastapi-snapstart-zip/app/main.py @@ -0,0 +1,61 @@ +import os +import random + +from fastapi import FastAPI, Response + +app = FastAPI() + + +class ConnectionPool: + """A stand-in for a real database/connection pool. + + In a real application these methods would open and close sockets to a + database. Here we just track state so the SnapStart lifecycle is observable. + """ + + def __init__(self): + self.connected = False + self.connection_id = None + + def connect(self): + # A fresh, unique id per environment — the kind of value that must be + # regenerated after a SnapStart restore so it is not shared across + # every restored environment. + self.connection_id = random.randint(1, 1_000_000_000) + self.connected = True + + def close(self): + self.connected = False + + +pool = ConnectionPool() +pool.connect() + + +@app.get("/") +async def root(): + return { + "message": "Hello from FastAPI on Lambda SnapStart", + "connected": pool.connected, + "connection_id": pool.connection_id, + } + + +@app.post("/lwa/snapstart/before") +async def before_checkpoint(): + """Called by the adapter before the snapshot is taken. + + Close resources that will not survive the snapshot. + """ + pool.close() + return Response(status_code=200) + + +@app.post("/lwa/snapstart/after") +async def after_restore(): + """Called by the adapter after the environment is restored. + + Re-establish connections and regenerate per-environment unique values. + """ + pool.connect() + return Response(status_code=200) diff --git a/examples/fastapi-snapstart-zip/app/requirements.txt b/examples/fastapi-snapstart-zip/app/requirements.txt new file mode 100644 index 00000000..e61869b3 --- /dev/null +++ b/examples/fastapi-snapstart-zip/app/requirements.txt @@ -0,0 +1,10 @@ +annotated-types==0.7.0 +anyio==4.6.2.post1 +fastapi==0.115.5 +idna==3.10 +pydantic==2.9.2 +pydantic-core==2.23.4 +sniffio==1.3.1 +starlette==0.41.2 +typing-extensions==4.12.2 +uvicorn==0.32.0 \ No newline at end of file diff --git a/examples/fastapi-snapstart-zip/app/run.sh b/examples/fastapi-snapstart-zip/app/run.sh new file mode 100755 index 00000000..a630f442 --- /dev/null +++ b/examples/fastapi-snapstart-zip/app/run.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +PATH=$PATH:$LAMBDA_TASK_ROOT/bin \ + PYTHONPATH=$PYTHONPATH:/opt/python:$LAMBDA_RUNTIME_DIR \ + exec python -m uvicorn --port=$PORT main:app diff --git a/examples/fastapi-snapstart-zip/events/event.json b/examples/fastapi-snapstart-zip/events/event.json new file mode 100644 index 00000000..a6197dea --- /dev/null +++ b/examples/fastapi-snapstart-zip/events/event.json @@ -0,0 +1,62 @@ +{ + "body": "{\"message\": \"hello world\"}", + "resource": "/hello", + "path": "/hello", + "httpMethod": "GET", + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar" + }, + "pathParameters": { + "proxy": "/path/to/resource" + }, + "stageVariables": { + "baz": "qux" + }, + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "1234567890.execute-api.us-east-1.amazonaws.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "requestContext": { + "accountId": "123456789012", + "resourceId": "123456", + "stage": "prod", + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", + "requestTime": "09/Apr/2015:12:34:56 +0000", + "requestTimeEpoch": 1428582896000, + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "accessKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Custom User Agent String", + "user": null + }, + "path": "/prod/hello", + "resourcePath": "/hello", + "httpMethod": "POST", + "apiId": "1234567890", + "protocol": "HTTP/1.1" + } +} diff --git a/examples/fastapi-snapstart-zip/template.yaml b/examples/fastapi-snapstart-zip/template.yaml new file mode 100644 index 00000000..97451edf --- /dev/null +++ b/examples/fastapi-snapstart-zip/template.yaml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + FastAPI with Lambda SnapStart + +# More info about Globals: https://github.com/aws/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 10 + +Resources: + FastAPISnapStartFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: app/ + Handler: run.sh + Runtime: python3.12 + MemorySize: 256 + AutoPublishAlias: live + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap + PORT: 8000 + AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /lwa/snapstart/before + AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /lwa/snapstart/after + Layers: + - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:28 + Events: + ApiEvent: + Type: HttpApi + + +Outputs: + FastAPISnapStartApi: + Description: "API Gateway endpoint URL for Prod stage for FastAPI SnapStart function" + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/" + FastAPISnapStartFunction: + Description: "FastAPI SnapStart Lambda Function ARN" + Value: !GetAtt FastAPISnapStartFunction.Arn + FastAPISnapStartIamRole: + Description: "Implicit IAM Role created for FastAPI SnapStart function" + Value: !GetAtt FastAPISnapStartFunctionRole.Arn From 6df9600b3c8669f6e9e5532ceec1646efdf84a8a Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 17:53:14 +0000 Subject: [PATCH 13/27] docs: link fastapi-snapstart example from README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 414135d7..02cace8e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ to your application, so it never reuses a connection captured in the snapshot. > are never forwarded to your application, so choose paths your normal traffic > does not use. +See the [FastAPI with SnapStart example](examples/fastapi-snapstart-zip) for a complete, deployable application. + ## Examples - [FastAPI](examples/fastapi) @@ -111,6 +113,7 @@ to your application, so it never reuses a connection captured in the snapshot. - [FastAPI with Response Streaming in Zip](examples/fastapi-response-streaming-zip) - [FastAPI with Response Streaming on Lambda Managed Instances](examples/fastapi-response-streaming-lmi) - [FastAPI Response Streaming Backend with IAM Auth](examples/fastapi-backend-only-response-streaming/) +- [FastAPI with SnapStart in Zip](examples/fastapi-snapstart-zip) - [Flask](examples/flask) - [Flask in Zip](examples/flask-zip) - [Serverless Django](https://github.com/aws-hebrew-book/serverless-django) by [@efi-mk](https://github.com/efi-mk) From 3645160b3812eb11a96e2ef49b5c743b74edd3a9 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 17:55:21 +0000 Subject: [PATCH 14/27] docs: drop /lwa prefix from SnapStart example hook paths --- docs/guide/src/features/snapstart.md | 10 +++++----- examples/fastapi-snapstart-zip/README.md | 8 ++++---- examples/fastapi-snapstart-zip/app/main.py | 4 ++-- examples/fastapi-snapstart-zip/template.yaml | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/guide/src/features/snapstart.md b/docs/guide/src/features/snapstart.md index 2a297d51..fd93d1e6 100644 --- a/docs/guide/src/features/snapstart.md +++ b/docs/guide/src/features/snapstart.md @@ -29,7 +29,7 @@ State captured in a snapshot is shared across every restored environment. Two cl ## Securing the hook paths -The hook paths are control-plane operations. External requests (via API Gateway or ALB) that target a configured hook path receive `403 Forbidden` and are never forwarded to your application. The guard matches the exact configured path, so choose paths your normal application traffic does not use (for example, `/lwa/snapstart/before` and `/lwa/snapstart/after`). +The hook paths are control-plane operations. External requests (via API Gateway or ALB) that target a configured hook path receive `403 Forbidden` and are never forwarded to your application. The guard matches the exact configured path, so choose paths your normal application traffic does not use (for example, `/snapstart/before` and `/snapstart/after`). ## Example @@ -40,7 +40,7 @@ app = FastAPI() pool = None # your database/connection pool -@app.post("/lwa/snapstart/before") +@app.post("/snapstart/before") async def before_checkpoint(): # Close resources that won't survive the snapshot. if pool is not None: @@ -48,7 +48,7 @@ async def before_checkpoint(): return Response(status_code=200) -@app.post("/lwa/snapstart/after") +@app.post("/snapstart/after") async def after_restore(): # Re-establish resources and reseed anything that must be unique. global pool @@ -59,8 +59,8 @@ async def after_restore(): Configure the function with: ``` -AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/lwa/snapstart/before -AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/lwa/snapstart/after +AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/snapstart/before +AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after ``` See the [fastapi-snapstart-zip example](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart-zip) for a complete, deployable application. diff --git a/examples/fastapi-snapstart-zip/README.md b/examples/fastapi-snapstart-zip/README.md index 1c526a5b..3074096e 100644 --- a/examples/fastapi-snapstart-zip/README.md +++ b/examples/fastapi-snapstart-zip/README.md @@ -18,12 +18,12 @@ To get more information of Wrapper script, please read Lambda documentation [her [Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) initializes your function, takes a snapshot of the initialized execution environment, and restores from that snapshot to serve invocations. Some resources do not survive the snapshot (open connections) and some values must not be shared across every restored environment (unique ids, seeds). The Lambda Web Adapter bridges the SnapStart lifecycle to your inner web app through two opt-in environment variables: -- `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/lwa/snapstart/before`. Before the snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). -- `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/lwa/snapstart/after`. After the environment is restored and before traffic is served, the adapter sends an empty HTTP `POST` to this path. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). +- `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. Before the snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). +- `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. After the environment is restored and before traffic is served, the adapter sends an empty HTTP `POST` to this path. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). Both hook routes must return a `2xx` status code. A non-2xx response or a connection failure fails the SnapStart phase. The adapter also automatically refreshes its own HTTP connection to the inner app after restore, so you do not have to manage the adapter's client. -These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/lwa/snapstart/before` or `/lwa/snapstart/after` receive a `403 Forbidden`. +These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/snapstart/before` or `/snapstart/after` receive a `403 Forbidden`. ### Build and Deploy @@ -47,4 +47,4 @@ Open FastAPISnapStartApi's URL in a browser. The `/` response shows `connected: } ``` -After a SnapStart restore, the `connection_id` is regenerated rather than shared across every restored environment, because the adapter calls the after-restore hook (`/lwa/snapstart/after`), which reconnects the pool and generates a fresh id. This is exactly the behavior you want for any per-environment value (connections, random seeds, unique identifiers) that must not be duplicated across restored snapshots. +After a SnapStart restore, the `connection_id` is regenerated rather than shared across every restored environment, because the adapter calls the after-restore hook (`/snapstart/after`), which reconnects the pool and generates a fresh id. This is exactly the behavior you want for any per-environment value (connections, random seeds, unique identifiers) that must not be duplicated across restored snapshots. diff --git a/examples/fastapi-snapstart-zip/app/main.py b/examples/fastapi-snapstart-zip/app/main.py index 4f5e0763..97b73a9f 100644 --- a/examples/fastapi-snapstart-zip/app/main.py +++ b/examples/fastapi-snapstart-zip/app/main.py @@ -41,7 +41,7 @@ async def root(): } -@app.post("/lwa/snapstart/before") +@app.post("/snapstart/before") async def before_checkpoint(): """Called by the adapter before the snapshot is taken. @@ -51,7 +51,7 @@ async def before_checkpoint(): return Response(status_code=200) -@app.post("/lwa/snapstart/after") +@app.post("/snapstart/after") async def after_restore(): """Called by the adapter after the environment is restored. diff --git a/examples/fastapi-snapstart-zip/template.yaml b/examples/fastapi-snapstart-zip/template.yaml index 97451edf..ee28a35d 100644 --- a/examples/fastapi-snapstart-zip/template.yaml +++ b/examples/fastapi-snapstart-zip/template.yaml @@ -23,8 +23,8 @@ Resources: Variables: AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap PORT: 8000 - AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /lwa/snapstart/before - AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /lwa/snapstart/after + AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /snapstart/before + AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /snapstart/after Layers: - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:28 Events: From 9b94c24345312ac06ff01eb0dd6ab3824fac5c8a Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 22 Jun 2026 18:04:32 +0000 Subject: [PATCH 15/27] refactor: extract register_and_run helper to dedup run() arms --- src/lib.rs | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 37bb0a23..af2ae3ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -890,26 +890,35 @@ impl Adapter { match (self.compression, self.invoke_mode) { (true, LambdaInvokeMode::Buffered) => { let svc = ServiceBuilder::new().layer(CompressionLayer::new()).service(self); - lambda_http::runtime_concurrent(svc) - .register_snapstart_resource(hooks) - .run_concurrent() - .await + Self::register_and_run(lambda_http::runtime_concurrent(svc), hooks).await } (_, LambdaInvokeMode::Buffered) => { - lambda_http::runtime_concurrent(self) - .register_snapstart_resource(hooks) - .run_concurrent() - .await + Self::register_and_run(lambda_http::runtime_concurrent(self), hooks).await } (_, LambdaInvokeMode::ResponseStream) => { - lambda_http::streaming_runtime_concurrent(self) - .register_snapstart_resource(hooks) - .run_concurrent() - .await + Self::register_and_run(lambda_http::streaming_runtime_concurrent(self), hooks).await } } } + /// Registers the SnapStart hooks on `runtime` and starts the concurrent event loop. + /// + /// Each `run()` arm builds a different runtime type (buffered vs. streaming), + /// so the shared "register, then run" tail lives here as a generic helper. + async fn register_and_run( + runtime: lambda_http::lambda_runtime::Runtime, + hooks: Arc, + ) -> Result<(), Error> + where + S: lambda_http::Service + + Clone + + Send + + 'static, + S::Future: Send, + { + runtime.register_snapstart_resource(hooks).run_concurrent().await + } + /// Applies runtime API proxy configuration from environment variables. /// /// If `AWS_LWA_LAMBDA_RUNTIME_API_PROXY` is set, this method overwrites From 51c77b2621838a54986526df0bdc7e93dc0c09a3 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 23 Jun 2026 15:38:54 +0000 Subject: [PATCH 16/27] feat: add 60s timeout to SnapStart inner-app hooks --- CHANGELOG.md | 5 +-- README.md | 7 ++-- docs/guide/src/features/snapstart.md | 2 +- examples/fastapi-snapstart-zip/README.md | 2 +- src/snapstart.rs | 44 ++++++++++++++++++++++-- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99150a4a..7e1ab5e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ - Add SnapStart support. The adapter notifies your web application at the SnapStart boundary via two opt-in HTTP hooks — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` (before checkpoint) and `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` (after restore) — - so it can drain and re-establish connections. The adapter also refreshes its own - HTTP client after restore and rejects external traffic to the hook paths with 403. + so it can drain and re-establish connections. Each hook call is bounded by a + 60-second timeout. The adapter also refreshes its own HTTP client after restore + and rejects external traffic to the hook paths with 403. --- diff --git a/README.md b/README.md index 02cace8e..bdcb4579 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,10 @@ unique identifiers). Both hooks are opt-in and independent. | `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` | After restore, before serving traffic | Reconnect, refresh credentials, reseed randomness, regenerate unique IDs | Each hook is an empty `POST`; your application must respond with a `2xx` status. -A non-`2xx` response or a connection failure fails the SnapStart phase -(initialization for the before-checkpoint hook, restore for the after-restore -hook) instead of serving traffic against an improperly prepared application. +A non-`2xx` response, a connection failure, or taking longer than 60 seconds to +respond fails the SnapStart phase (initialization for the before-checkpoint hook, +restore for the after-restore hook) instead of serving traffic against an +improperly prepared application. After restore, the adapter also automatically refreshes its own HTTP connection to your application, so it never reuses a connection captured in the snapshot. diff --git a/docs/guide/src/features/snapstart.md b/docs/guide/src/features/snapstart.md index fd93d1e6..496944c7 100644 --- a/docs/guide/src/features/snapstart.md +++ b/docs/guide/src/features/snapstart.md @@ -18,7 +18,7 @@ When `AWS_LAMBDA_INITIALIZATION_TYPE` is `snap-start`, the adapter participates 1. **Before checkpoint** — if `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set, the adapter sends an empty `POST` to that path on your application, then signals Lambda that it is ready for the snapshot. 2. **After restore** — Lambda restores the environment. The adapter first refreshes its own HTTP connection to your application (so it never reuses a connection captured in the snapshot), then, if `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set, sends an empty `POST` to that path. -Each hook is an empty `POST`, and your application must respond with a `2xx` status. A non-`2xx` response or a connection failure fails the SnapStart phase — initialization for the before-checkpoint hook, restore for the after-restore hook — rather than serving traffic against an improperly prepared application. +Each hook is an empty `POST`, and your application must respond with a `2xx` status. A non-`2xx` response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase — initialization for the before-checkpoint hook, restore for the after-restore hook — rather than serving traffic against an improperly prepared application. ## Why you need the hooks diff --git a/examples/fastapi-snapstart-zip/README.md b/examples/fastapi-snapstart-zip/README.md index 3074096e..e1d141f3 100644 --- a/examples/fastapi-snapstart-zip/README.md +++ b/examples/fastapi-snapstart-zip/README.md @@ -21,7 +21,7 @@ To get more information of Wrapper script, please read Lambda documentation [her - `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. Before the snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). - `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. After the environment is restored and before traffic is served, the adapter sends an empty HTTP `POST` to this path. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). -Both hook routes must return a `2xx` status code. A non-2xx response or a connection failure fails the SnapStart phase. The adapter also automatically refreshes its own HTTP connection to the inner app after restore, so you do not have to manage the adapter's client. +Both hook routes must return a `2xx` status code. A non-2xx response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase. The adapter also automatically refreshes its own HTTP connection to the inner app after restore, so you do not have to manage the adapter's client. These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/snapstart/before` or `/snapstart/after` receive a `403 Forbidden`. diff --git a/src/snapstart.rs b/src/snapstart.rs index 12d9593a..33eebae4 100644 --- a/src/snapstart.rs +++ b/src/snapstart.rs @@ -2,14 +2,21 @@ //! snapshot boundary and refreshes the adapter's HTTP client after restore. use std::sync::{Arc, OnceLock}; +use std::time::Duration; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use lambda_http::{Body, BoxFuture, Error, SnapStartResource}; +use tokio::time::timeout; use url::Url; use crate::build_client; +/// Maximum time the adapter waits for an inner-app hook to respond before +/// failing the SnapStart phase. Bounds a hung or unresponsive hook so the +/// snapshot/restore lifecycle cannot stall indefinitely. +const HOOK_TIMEOUT: Duration = Duration::from_secs(60); + /// A [`SnapStartResource`] that bridges the Lambda SnapStart lifecycle to the /// inner web application running behind the adapter. pub(crate) struct SnapStartHooks { @@ -41,16 +48,29 @@ impl SnapStartHooks { } } - /// POSTs an empty body to `domain + path` using `client`. Non-2xx or a - /// transport error is an error. + /// POSTs an empty body to `domain + path` using `client`. A non-2xx + /// response, a transport error, or exceeding [`HOOK_TIMEOUT`] is an error. async fn post_hook(client: &Client, domain: &Url, path: &str) -> Result<(), Error> { + Self::post_hook_with_timeout(client, domain, path, HOOK_TIMEOUT).await + } + + /// Implementation of [`post_hook`](Self::post_hook) with an explicit timeout, + /// so tests can exercise the timeout path without waiting [`HOOK_TIMEOUT`]. + async fn post_hook_with_timeout( + client: &Client, + domain: &Url, + path: &str, + hook_timeout: Duration, + ) -> Result<(), Error> { let mut url = domain.clone(); url.set_path(path); let req = hyper::Request::builder() .method(hyper::Method::POST) .uri(url.to_string()) .body(Body::Empty)?; - let resp = client.request(req).await?; + let resp = timeout(hook_timeout, client.request(req)) + .await + .map_err(|_| Error::from(format!("SnapStart hook POST {path} timed out after {hook_timeout:?}")))??; if !resp.status().is_success() { return Err(Error::from(format!( "SnapStart hook POST {path} returned non-success status: {}", @@ -165,6 +185,24 @@ mod tests { ); } + #[tokio::test] + async fn post_hook_times_out_when_app_is_slow() { + let server = MockServer::start(); + // The app takes far longer to respond than the timeout we pass below. + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/slow"); + then.status(200).delay(Duration::from_secs(2)); + }); + let domain: Url = format!("http://{}:{}", server.host(), server.port()).parse().unwrap(); + let client = build_client(); + + let result = + SnapStartHooks::post_hook_with_timeout(&client, &domain, "/slow", Duration::from_millis(100)).await; + + let err = result.expect_err("slow hook should time out"); + assert!(err.to_string().contains("timed out"), "unexpected error: {err}"); + } + #[tokio::test] async fn after_restore_publishes_client_when_path_unset() { let server = MockServer::start(); From b5279f19ae726c308cc3479b44d67f24074d1a99 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 02:41:19 +0000 Subject: [PATCH 17/27] feat: re-run readiness check after SnapStart restore --- CHANGELOG.md | 5 +- README.md | 4 +- docs/guide/src/features/snapstart.md | 4 +- examples/fastapi-snapstart-zip/README.md | 2 +- src/lib.rs | 58 ++---------- src/readiness.rs | 76 +++++++++++++++ src/snapstart.rs | 115 ++++++++++++++++++++++- 7 files changed, 207 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1ab5e1..83521655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ boundary via two opt-in HTTP hooks — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` (before checkpoint) and `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` (after restore) — so it can drain and re-establish connections. Each hook call is bounded by a - 60-second timeout. The adapter also refreshes its own HTTP client after restore - and rejects external traffic to the hook paths with 403. + 60-second timeout. After restore the adapter refreshes its own HTTP client and + re-runs the readiness check (bounded by 10 seconds) before admitting traffic, and + it rejects external traffic to the hook paths with 403. --- diff --git a/README.md b/README.md index bdcb4579..93ff2884 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ restore for the after-restore hook) instead of serving traffic against an improperly prepared application. After restore, the adapter also automatically refreshes its own HTTP connection -to your application, so it never reuses a connection captured in the snapshot. +to your application, so it never reuses a connection captured in the snapshot, and +then re-runs the readiness check before admitting traffic. If the application does +not report ready within 10 seconds of restore, the restore fails. > These hook paths are control-plane operations. External requests (via API > Gateway or ALB) that target a configured hook path receive `403 Forbidden` and diff --git a/docs/guide/src/features/snapstart.md b/docs/guide/src/features/snapstart.md index 496944c7..9f34e622 100644 --- a/docs/guide/src/features/snapstart.md +++ b/docs/guide/src/features/snapstart.md @@ -16,9 +16,9 @@ Both hooks are opt-in and independent — each fires only when its variable is s When `AWS_LAMBDA_INITIALIZATION_TYPE` is `snap-start`, the adapter participates in the SnapStart lifecycle: 1. **Before checkpoint** — if `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set, the adapter sends an empty `POST` to that path on your application, then signals Lambda that it is ready for the snapshot. -2. **After restore** — Lambda restores the environment. The adapter first refreshes its own HTTP connection to your application (so it never reuses a connection captured in the snapshot), then, if `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set, sends an empty `POST` to that path. +2. **After restore** — Lambda restores the environment. The adapter first refreshes its own HTTP connection to your application (so it never reuses a connection captured in the snapshot); then, if `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set, sends an empty `POST` to that path; and finally re-runs the readiness check before admitting traffic. -Each hook is an empty `POST`, and your application must respond with a `2xx` status. A non-`2xx` response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase — initialization for the before-checkpoint hook, restore for the after-restore hook — rather than serving traffic against an improperly prepared application. +Each hook is an empty `POST`, and your application must respond with a `2xx` status. A non-`2xx` response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase — initialization for the before-checkpoint hook, restore for the after-restore hook — rather than serving traffic against an improperly prepared application. The final readiness check runs on every restore (whether or not an after-restore path is configured); if your application does not report ready within 10 seconds of restore, the restore fails. ## Why you need the hooks diff --git a/examples/fastapi-snapstart-zip/README.md b/examples/fastapi-snapstart-zip/README.md index e1d141f3..779dcb16 100644 --- a/examples/fastapi-snapstart-zip/README.md +++ b/examples/fastapi-snapstart-zip/README.md @@ -21,7 +21,7 @@ To get more information of Wrapper script, please read Lambda documentation [her - `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. Before the snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). - `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. After the environment is restored and before traffic is served, the adapter sends an empty HTTP `POST` to this path. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). -Both hook routes must return a `2xx` status code. A non-2xx response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase. The adapter also automatically refreshes its own HTTP connection to the inner app after restore, so you do not have to manage the adapter's client. +Both hook routes must return a `2xx` status code. A non-2xx response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase. After restore, the adapter also automatically refreshes its own HTTP connection to the inner app (so you do not have to manage the adapter's client) and re-runs the readiness check before admitting traffic; if the app does not report ready within 10 seconds, the restore fails. These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/snapstart/before` or `/snapstart/after` receive a `403 Forbidden`. diff --git a/src/lib.rs b/src/lib.rs index af2ae3ff..4a7f19e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,7 +116,6 @@ pub use lambda_http::tracing; use lambda_http::Body; pub use lambda_http::Error; use lambda_http::{Request, RequestExt, Response}; -use readiness::Checkpoint; use std::borrow::Cow; use std::fmt::Debug; use std::{ @@ -129,8 +128,7 @@ use std::{ }, time::Duration, }; -use tokio::{net::TcpStream, time::timeout}; -use tokio_retry::{strategy::FixedInterval, Retry}; +use tokio::time::timeout; use tower::{Service, ServiceBuilder}; use tower_http::compression::CompressionLayer; use url::Url; @@ -798,59 +796,18 @@ impl Adapter { /// Uses a fixed 10ms interval between retry attempts and logs progress /// at increasing intervals (100ms, 500ms, 1s, 2s, 5s, 10s). async fn is_web_ready(&self, url: &Url, protocol: &Protocol) -> bool { - let mut checkpoint = Checkpoint::new(); - Retry::spawn(FixedInterval::from_millis(10), || { - if checkpoint.lapsed() { - tracing::info!(url = %url.to_string(), "app is not ready after {}ms", checkpoint.next_ms()); - checkpoint.increment(); - } - self.check_web_readiness(url, protocol) - }) - .await - .is_ok() + readiness::wait_until_ready(self.client(), url, *protocol, &self.healthcheck_healthy_status).await } /// Performs a single readiness check using the configured protocol. /// /// For HTTP: Makes a GET request and checks if the status code is in the healthy range. /// For TCP: Attempts to establish a TCP connection. + /// + /// Used by tests; `Adapter`'s own readiness path goes through [`is_web_ready`](Self::is_web_ready). + #[cfg(test)] async fn check_web_readiness(&self, url: &Url, protocol: &Protocol) -> Result<(), i8> { - match protocol { - Protocol::Http => { - // url is already validated in Adapter::new(), this conversion should always succeed - // If it fails, it indicates a programming error, not a runtime condition - let uri: http::Uri = url - .as_str() - .parse() - .expect("BUG: healthcheck_url should be valid - validated in Adapter::new()"); - - match self.client().get(uri).await { - Ok(response) if self.healthcheck_healthy_status.contains(&response.status().as_u16()) => { - tracing::debug!("app is ready"); - Ok(()) - } - _ => { - tracing::trace!("app is not ready"); - Err(-1) - } - } - } - Protocol::Tcp => { - // url is already validated in Adapter::new(), host and port should exist - // If they don't, it indicates a programming error, not a runtime condition - let host = url - .host_str() - .expect("BUG: healthcheck_url should have host - validated in Adapter::new()"); - let port = url - .port() - .expect("BUG: healthcheck_url should have port - validated in Adapter::new()"); - - match TcpStream::connect(format!("{}:{}", host, port)).await { - Ok(_) => Ok(()), - Err(_) => Err(-1), - } - } - } + readiness::check_web_readiness(self.client(), url, *protocol, &self.healthcheck_healthy_status).await } /// Starts the adapter and begins processing Lambda events. @@ -886,6 +843,9 @@ impl Adapter { self.domain.clone(), self.snapstart_before_checkpoint_path.clone(), self.snapstart_after_restore_path.clone(), + self.healthcheck_url.clone(), + self.healthcheck_protocol, + self.healthcheck_healthy_status.clone(), )); match (self.compression, self.invoke_mode) { (true, LambdaInvokeMode::Buffered) => { diff --git a/src/readiness.rs b/src/readiness.rs index a48e5096..d346f608 100644 --- a/src/readiness.rs +++ b/src/readiness.rs @@ -1,5 +1,81 @@ use std::time::Instant; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; +use lambda_http::Body; +use tokio::net::TcpStream; +use tokio_retry::{strategy::FixedInterval, Retry}; +use url::Url; + +use crate::Protocol; + +/// Performs a single readiness check against `url` using `protocol`. +/// +/// For HTTP: issues a GET via `client` and checks the status is in `healthy_status`. +/// For TCP: attempts to establish a TCP connection. Returns `Ok(())` when ready. +pub(crate) async fn check_web_readiness( + client: &Client, + url: &Url, + protocol: Protocol, + healthy_status: &[u16], +) -> Result<(), i8> { + match protocol { + Protocol::Http => { + // url is validated in Adapter::new(); this conversion should always succeed. + let uri: http::Uri = url + .as_str() + .parse() + .expect("BUG: healthcheck_url should be valid - validated in Adapter::new()"); + + match client.get(uri).await { + Ok(response) if healthy_status.contains(&response.status().as_u16()) => { + tracing::debug!("app is ready"); + Ok(()) + } + _ => { + tracing::trace!("app is not ready"); + Err(-1) + } + } + } + Protocol::Tcp => { + // url is validated in Adapter::new(); host and port should exist. + let host = url + .host_str() + .expect("BUG: healthcheck_url should have host - validated in Adapter::new()"); + let port = url + .port() + .expect("BUG: healthcheck_url should have port - validated in Adapter::new()"); + + match TcpStream::connect(format!("{}:{}", host, port)).await { + Ok(_) => Ok(()), + Err(_) => Err(-1), + } + } + } +} + +/// Waits for the web application to become ready, retrying on a fixed 10ms +/// interval and logging progress at increasing checkpoints. Returns `true` once +/// the app is ready. Callers bound the total wait with an external timeout. +pub(crate) async fn wait_until_ready( + client: &Client, + url: &Url, + protocol: Protocol, + healthy_status: &[u16], +) -> bool { + let mut checkpoint = Checkpoint::new(); + Retry::spawn(FixedInterval::from_millis(10), || { + if checkpoint.lapsed() { + tracing::info!(url = %url.to_string(), "app is not ready after {}ms", checkpoint.next_ms()); + checkpoint.increment(); + } + check_web_readiness(client, url, protocol, healthy_status) + }) + .await + .is_ok() +} + pub(crate) struct Checkpoint { start: Instant, interval_ms: u128, diff --git a/src/snapstart.rs b/src/snapstart.rs index 33eebae4..171654eb 100644 --- a/src/snapstart.rs +++ b/src/snapstart.rs @@ -10,13 +10,19 @@ use lambda_http::{Body, BoxFuture, Error, SnapStartResource}; use tokio::time::timeout; use url::Url; -use crate::build_client; +use crate::{build_client, readiness, Protocol}; /// Maximum time the adapter waits for an inner-app hook to respond before /// failing the SnapStart phase. Bounds a hung or unresponsive hook so the /// snapshot/restore lifecycle cannot stall indefinitely. const HOOK_TIMEOUT: Duration = Duration::from_secs(60); +/// Maximum time the adapter waits for the inner app to report ready after +/// restore (step 3). Tighter than [`HOOK_TIMEOUT`]: once the after-restore hook +/// has run, the app should become ready almost immediately, so a long stall here +/// indicates a failed restore rather than legitimate slow work. +const READINESS_TIMEOUT: Duration = Duration::from_secs(10); + /// A [`SnapStartResource`] that bridges the Lambda SnapStart lifecycle to the /// inner web application running behind the adapter. pub(crate) struct SnapStartHooks { @@ -29,15 +35,24 @@ pub(crate) struct SnapStartHooks { domain: Url, before_checkpoint_path: Option, after_restore_path: Option, + /// Readiness-check endpoint, protocol, and healthy statuses — shared with the + /// adapter so the post-restore readiness check (step 3) matches init behavior. + healthcheck_url: Url, + healthcheck_protocol: Protocol, + healthcheck_healthy_status: Vec, } impl SnapStartHooks { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( restored_client: Arc>>>, client: Arc>, domain: Url, before_checkpoint_path: Option, after_restore_path: Option, + healthcheck_url: Url, + healthcheck_protocol: Protocol, + healthcheck_healthy_status: Vec, ) -> Self { Self { restored_client, @@ -45,6 +60,9 @@ impl SnapStartHooks { domain, before_checkpoint_path, after_restore_path, + healthcheck_url, + healthcheck_protocol, + healthcheck_healthy_status, } } @@ -104,27 +122,87 @@ impl SnapStartResource for SnapStartHooks { if let Some(path) = self.after_restore_path.as_deref() { Self::post_hook(&fresh, &self.domain, path).await?; } + + // 3. Confirm the app is serving again before traffic is admitted. + self.check_readiness_with_timeout(&fresh, READINESS_TIMEOUT).await?; + Ok(()) }) } } +impl SnapStartHooks { + /// Step 3 of [`after_restore`](SnapStartResource::after_restore): retry-until-ready + /// over `client`, bounded by `readiness_timeout`. A timeout or an unready app is an + /// error, which fails the restore (reported to `/restore/error`). Split out with an + /// explicit timeout so tests can exercise the failure path without waiting + /// [`READINESS_TIMEOUT`]. + async fn check_readiness_with_timeout( + &self, + client: &Client, + readiness_timeout: Duration, + ) -> Result<(), Error> { + let ready = timeout( + readiness_timeout, + readiness::wait_until_ready( + client, + &self.healthcheck_url, + self.healthcheck_protocol, + &self.healthcheck_healthy_status, + ), + ) + .await + .map_err(|_| { + Error::from(format!( + "SnapStart after-restore readiness check timed out after {readiness_timeout:?}" + )) + })?; + if !ready { + return Err(Error::from("SnapStart after-restore readiness check failed")); + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; use httpmock::MockServer; - fn hooks(server: &MockServer, before: Option<&str>, after: Option<&str>) -> SnapStartHooks { + /// Builds hooks pointed at `server`, with the readiness check targeting + /// `health_path` on the same server. + fn hooks_with_health( + server: &MockServer, + before: Option<&str>, + after: Option<&str>, + health_path: &str, + ) -> SnapStartHooks { let domain: Url = format!("http://{}:{}", server.host(), server.port()).parse().unwrap(); + let healthcheck_url: Url = format!("http://{}:{}{}", server.host(), server.port(), health_path) + .parse() + .unwrap(); SnapStartHooks::new( Arc::new(OnceLock::new()), Arc::new(build_client()), domain, before.map(str::to_string), after.map(str::to_string), + healthcheck_url, + Protocol::Http, + (100..500).collect(), ) } + /// Builds hooks with a readiness check that always passes (a mocked `/health` + /// returning 200), for tests focused on the before/after hook behavior. + fn hooks(server: &MockServer, before: Option<&str>, after: Option<&str>) -> SnapStartHooks { + server.mock(|when, then| { + when.path("/health"); + then.status(200); + }); + hooks_with_health(server, before, after, "/health") + } + #[tokio::test] async fn before_snapshot_posts_when_set() { let server = MockServer::start(); @@ -210,4 +288,37 @@ mod tests { assert!(h.after_restore().await.is_ok()); assert!(h.restored_client.get().is_some()); } + + #[tokio::test] + async fn after_restore_readiness_check_runs_over_fresh_client() { + // No after-restore POST configured: step 3 must still run and pass. + let server = MockServer::start(); + let health = server.mock(|when, then| { + when.path("/ready"); + then.status(200); + }); + let h = hooks_with_health(&server, None, None, "/ready"); + assert!(h.after_restore().await.is_ok()); + health.assert(); + } + + #[tokio::test] + async fn check_readiness_times_out_when_app_never_ready() { + // Health endpoint always reports unhealthy; the bounded readiness check + // should give up and fail rather than retry forever. + let server = MockServer::start(); + server.mock(|when, then| { + when.path("/never"); + then.status(503); + }); + let h = hooks_with_health(&server, None, None, "/never"); + let client = build_client(); + + let result = h + .check_readiness_with_timeout(&client, Duration::from_millis(100)) + .await; + + let err = result.expect_err("unready app should fail the readiness check"); + assert!(err.to_string().contains("timed out"), "unexpected error: {err}"); + } } From ad9ca33c9108954d6298d41b0a9d79292f63f15c Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 03:18:04 +0000 Subject: [PATCH 18/27] docs: add fastapi-snapstart (OCI) example --- README.md | 1 + docs/guide/src/examples/overview.md | 1 + examples/fastapi-snapstart/.gitignore | 244 ++++++++++++++++++ examples/fastapi-snapstart/README.md | 110 ++++++++ examples/fastapi-snapstart/app/Dockerfile | 10 + examples/fastapi-snapstart/app/main.py | 60 +++++ .../fastapi-snapstart/app/requirements.txt | 10 + examples/fastapi-snapstart/events/event.json | 62 +++++ examples/fastapi-snapstart/template.yaml | 37 +++ 9 files changed, 535 insertions(+) create mode 100644 examples/fastapi-snapstart/.gitignore create mode 100644 examples/fastapi-snapstart/README.md create mode 100644 examples/fastapi-snapstart/app/Dockerfile create mode 100644 examples/fastapi-snapstart/app/main.py create mode 100644 examples/fastapi-snapstart/app/requirements.txt create mode 100644 examples/fastapi-snapstart/events/event.json create mode 100644 examples/fastapi-snapstart/template.yaml diff --git a/README.md b/README.md index 93ff2884..6213e6fd 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ See the [FastAPI with SnapStart example](examples/fastapi-snapstart-zip) for a c - [FastAPI with Response Streaming in Zip](examples/fastapi-response-streaming-zip) - [FastAPI with Response Streaming on Lambda Managed Instances](examples/fastapi-response-streaming-lmi) - [FastAPI Response Streaming Backend with IAM Auth](examples/fastapi-backend-only-response-streaming/) +- [FastAPI with SnapStart](examples/fastapi-snapstart) - [FastAPI with SnapStart in Zip](examples/fastapi-snapstart-zip) - [Flask](examples/flask) - [Flask in Zip](examples/flask-zip) diff --git a/docs/guide/src/examples/overview.md b/docs/guide/src/examples/overview.md index 4ab068f5..ca011f99 100644 --- a/docs/guide/src/examples/overview.md +++ b/docs/guide/src/examples/overview.md @@ -8,6 +8,7 @@ The repository includes working examples for many popular web frameworks, packag |---------|-----------|-----------| | [FastAPI](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi) | Docker | No | | [FastAPI in Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-zip) | Zip | No | +| [FastAPI SnapStart](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart) | Docker | No | | [FastAPI SnapStart Zip](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-snapstart-zip) | Zip | No | | [FastAPI Background Tasks](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-background-tasks) | Docker | No | | [FastAPI Response Streaming](https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming) | Docker | Yes | diff --git a/examples/fastapi-snapstart/.gitignore b/examples/fastapi-snapstart/.gitignore new file mode 100644 index 00000000..4808264d --- /dev/null +++ b/examples/fastapi-snapstart/.gitignore @@ -0,0 +1,244 @@ + +# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Ruby plugin and RubyMine +/.rakeTasks + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Build folder + +*/build/* + +# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode \ No newline at end of file diff --git a/examples/fastapi-snapstart/README.md b/examples/fastapi-snapstart/README.md new file mode 100644 index 00000000..296c2fca --- /dev/null +++ b/examples/fastapi-snapstart/README.md @@ -0,0 +1,110 @@ +# FastAPI with Lambda SnapStart (container image) + +This example shows how to use the Lambda Web Adapter's SnapStart hooks to drain and +re-establish a connection pool around the snapshot/restore boundary, packaged as a +**container image** (OCI) rather than a zip. + +> **Availability:** Lambda SnapStart for container images (OCI) is expected to launch +> in early July. Until it is available in your account/Region, deploying this example +> with `SnapStart: ApplyOn: PublishedVersions` on an `Image` package type will be +> rejected. The application itself runs unchanged with or without SnapStart — the +> hooks are simply not invoked when SnapStart is off. + +For the zip-packaged equivalent, see +[fastapi-snapstart-zip](../fastapi-snapstart-zip). + +## How does it work? + +The [Dockerfile](app/Dockerfile) copies the Lambda Web Adapter binary into +`/opt/extensions` and configures the two SnapStart hook endpoints: + +```dockerfile +FROM public.ecr.aws/docker/library/python:3.12-slim +COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter +ENV PORT=8000 +ENV AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/snapstart/before +ENV AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after +WORKDIR /var/task +COPY requirements.txt ./ +RUN python -m pip install -r requirements.txt +COPY *.py ./ +CMD exec uvicorn --port=$PORT main:app +``` + +When the function runs under SnapStart, the adapter calls your application at the +snapshot boundary: + +- `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. Before the + snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses + it to drain and close resources that will not survive the snapshot (in this example, + it closes the connection pool). +- `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. After the + environment is restored and before traffic is served, the adapter sends an empty HTTP + `POST` to this path. The app uses it to re-establish connections and regenerate + per-environment unique values (in this example, it reconnects the pool and generates + a fresh `connection_id`). + +Both hook routes must return a `2xx` status code. A non-2xx response, a connection +failure, or taking longer than 60 seconds to respond fails the SnapStart phase. After +restore, the adapter also automatically refreshes its own HTTP connection to the inner +app (so you do not have to manage the adapter's client) and re-runs the readiness check +before admitting traffic; if the app does not report ready within 10 seconds, the +restore fails. + +These hook routes are protected: the adapter only allows them to be invoked internally +during the SnapStart lifecycle. External callers that request `/snapstart/before` or +`/snapstart/after` receive a `403 Forbidden`. + +Because the adapter is packaged inside the image, the same container also runs +unchanged on Amazon ECS, Amazon EKS, or a local Docker host. + +## Pre-requisites + +* [AWS CLI](https://aws.amazon.com/cli/) +* [SAM CLI](https://github.com/aws/aws-sam-cli) +* [Docker](https://www.docker.com/products/docker-desktop) + +## Build and Deploy + +Build the container image and deploy with SAM: + +```bash +sam build +sam deploy --guided +``` + +When the deployment completes, take note of the `FastAPISnapStartApi` output — it is +the API Gateway endpoint URL. + +## Verify it works + +Open the API URL in a browser or with `curl`: + +```bash +curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/ +``` + +The response reports the connection state, for example: + +```json +{ + "message": "Hello from FastAPI on Lambda SnapStart (container image)", + "connected": true, + "connection_id": 426384719 +} +``` + +After a SnapStart restore, the `connection_id` is regenerated rather than shared across +every restored environment, because the adapter calls the after-restore hook +(`/snapstart/after`), which reconnects the pool and generates a fresh id. This is +exactly the behavior you want for any per-environment value (connections, random seeds, +unique identifiers) that must not be duplicated across restored snapshots. + +## Run the container locally + +The same image runs locally — without SnapStart, the hooks are simply never invoked: + +```bash +docker run -d -p 8000:8000 {ECR Image} +curl localhost:8000/ +``` diff --git a/examples/fastapi-snapstart/app/Dockerfile b/examples/fastapi-snapstart/app/Dockerfile new file mode 100644 index 00000000..0b9d24dc --- /dev/null +++ b/examples/fastapi-snapstart/app/Dockerfile @@ -0,0 +1,10 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim +COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter +ENV PORT=8000 +ENV AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/snapstart/before +ENV AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after +WORKDIR /var/task +COPY requirements.txt ./ +RUN python -m pip install -r requirements.txt +COPY *.py ./ +CMD exec uvicorn --port=$PORT main:app diff --git a/examples/fastapi-snapstart/app/main.py b/examples/fastapi-snapstart/app/main.py new file mode 100644 index 00000000..f9d1b790 --- /dev/null +++ b/examples/fastapi-snapstart/app/main.py @@ -0,0 +1,60 @@ +import random + +from fastapi import FastAPI, Response + +app = FastAPI() + + +class ConnectionPool: + """A stand-in for a real database/connection pool. + + In a real application these methods would open and close sockets to a + database. Here we just track state so the SnapStart lifecycle is observable. + """ + + def __init__(self): + self.connected = False + self.connection_id = None + + def connect(self): + # A fresh, unique id per environment — the kind of value that must be + # regenerated after a SnapStart restore so it is not shared across + # every restored environment. + self.connection_id = random.randint(1, 1_000_000_000) + self.connected = True + + def close(self): + self.connected = False + + +pool = ConnectionPool() +pool.connect() + + +@app.get("/") +async def root(): + return { + "message": "Hello from FastAPI on Lambda SnapStart (container image)", + "connected": pool.connected, + "connection_id": pool.connection_id, + } + + +@app.post("/snapstart/before") +async def before_checkpoint(): + """Called by the adapter before the snapshot is taken. + + Close resources that will not survive the snapshot. + """ + pool.close() + return Response(status_code=200) + + +@app.post("/snapstart/after") +async def after_restore(): + """Called by the adapter after the environment is restored. + + Re-establish connections and regenerate per-environment unique values. + """ + pool.connect() + return Response(status_code=200) diff --git a/examples/fastapi-snapstart/app/requirements.txt b/examples/fastapi-snapstart/app/requirements.txt new file mode 100644 index 00000000..37a49601 --- /dev/null +++ b/examples/fastapi-snapstart/app/requirements.txt @@ -0,0 +1,10 @@ +annotated-types==0.7.0 +anyio==4.6.2.post1 +fastapi==0.115.5 +idna==3.10 +pydantic==2.9.2 +pydantic-core==2.23.4 +sniffio==1.3.1 +starlette==0.41.2 +typing-extensions==4.12.2 +uvicorn==0.32.0 diff --git a/examples/fastapi-snapstart/events/event.json b/examples/fastapi-snapstart/events/event.json new file mode 100644 index 00000000..41be9620 --- /dev/null +++ b/examples/fastapi-snapstart/events/event.json @@ -0,0 +1,62 @@ +{ + "body": "{\"message\": \"hello world\"}", + "resource": "/hello", + "path": "/hello", + "httpMethod": "GET", + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar" + }, + "pathParameters": { + "proxy": "/path/to/resource" + }, + "stageVariables": { + "baz": "qux" + }, + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "1234567890.execute-api.us-east-1.amazonaws.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "requestContext": { + "accountId": "123456789012", + "resourceId": "123456", + "stage": "prod", + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", + "requestTime": "09/Apr/2015:12:34:56 +0000", + "requestTimeEpoch": 1428582896000, + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "accessKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Custom User Agent String", + "user": null + }, + "path": "/prod/hello", + "resourcePath": "/hello", + "httpMethod": "POST", + "apiId": "1234567890", + "protocol": "HTTP/1.1" + } + } diff --git a/examples/fastapi-snapstart/template.yaml b/examples/fastapi-snapstart/template.yaml new file mode 100644 index 00000000..3a8a83fe --- /dev/null +++ b/examples/fastapi-snapstart/template.yaml @@ -0,0 +1,37 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + FastAPI with Lambda SnapStart (container image) + +# More info about Globals: https://github.com/aws/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 10 + +Resources: + FastAPISnapStartFunction: + Type: AWS::Serverless::Function + Properties: + PackageType: Image + MemorySize: 256 + AutoPublishAlias: live + SnapStart: + ApplyOn: PublishedVersions + Events: + ApiEvent: + Type: HttpApi + Metadata: + Dockerfile: Dockerfile + DockerContext: ./app + DockerTag: python3.12-v1 + +Outputs: + FastAPISnapStartApi: + Description: "API Gateway endpoint URL for Prod stage for FastAPI SnapStart function" + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/" + FastAPISnapStartFunction: + Description: "FastAPI SnapStart Lambda Function ARN" + Value: !GetAtt FastAPISnapStartFunction.Arn + FastAPISnapStartIamRole: + Description: "Implicit IAM Role created for FastAPI SnapStart function" + Value: !GetAtt FastAPISnapStartFunctionRole.Arn From 0842dcbe65a6834bf22d5675a1e5fe52d82d68ba Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 03:21:22 +0000 Subject: [PATCH 19/27] refactor: move SnapStart hook env vars to SAM template in OCI example --- examples/fastapi-snapstart/README.md | 14 +++++++++++--- examples/fastapi-snapstart/app/Dockerfile | 2 -- examples/fastapi-snapstart/template.yaml | 4 ++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/examples/fastapi-snapstart/README.md b/examples/fastapi-snapstart/README.md index 296c2fca..392b3554 100644 --- a/examples/fastapi-snapstart/README.md +++ b/examples/fastapi-snapstart/README.md @@ -16,14 +16,12 @@ For the zip-packaged equivalent, see ## How does it work? The [Dockerfile](app/Dockerfile) copies the Lambda Web Adapter binary into -`/opt/extensions` and configures the two SnapStart hook endpoints: +`/opt/extensions`: ```dockerfile FROM public.ecr.aws/docker/library/python:3.12-slim COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter ENV PORT=8000 -ENV AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/snapstart/before -ENV AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after WORKDIR /var/task COPY requirements.txt ./ RUN python -m pip install -r requirements.txt @@ -31,6 +29,16 @@ COPY *.py ./ CMD exec uvicorn --port=$PORT main:app ``` +The two SnapStart hook endpoints are configured as function environment variables in +[`template.yaml`](template.yaml), keeping the image itself generic: + +```yaml + Environment: + Variables: + AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /snapstart/before + AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /snapstart/after +``` + When the function runs under SnapStart, the adapter calls your application at the snapshot boundary: diff --git a/examples/fastapi-snapstart/app/Dockerfile b/examples/fastapi-snapstart/app/Dockerfile index 0b9d24dc..240149e1 100644 --- a/examples/fastapi-snapstart/app/Dockerfile +++ b/examples/fastapi-snapstart/app/Dockerfile @@ -1,8 +1,6 @@ FROM public.ecr.aws/docker/library/python:3.12-slim COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter ENV PORT=8000 -ENV AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH=/snapstart/before -ENV AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH=/snapstart/after WORKDIR /var/task COPY requirements.txt ./ RUN python -m pip install -r requirements.txt diff --git a/examples/fastapi-snapstart/template.yaml b/examples/fastapi-snapstart/template.yaml index 3a8a83fe..3eb6e733 100644 --- a/examples/fastapi-snapstart/template.yaml +++ b/examples/fastapi-snapstart/template.yaml @@ -17,6 +17,10 @@ Resources: AutoPublishAlias: live SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH: /snapstart/before + AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH: /snapstart/after Events: ApiEvent: Type: HttpApi From 4b19336ff9bdce9d14fac3e1ca308b513eb19cc2 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 03:22:20 +0000 Subject: [PATCH 20/27] chore: point OCI example at 1.1.0 SnapStart-enabled adapter image --- examples/fastapi-snapstart/README.md | 2 +- examples/fastapi-snapstart/app/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/fastapi-snapstart/README.md b/examples/fastapi-snapstart/README.md index 392b3554..25af8979 100644 --- a/examples/fastapi-snapstart/README.md +++ b/examples/fastapi-snapstart/README.md @@ -20,7 +20,7 @@ The [Dockerfile](app/Dockerfile) copies the Lambda Web Adapter binary into ```dockerfile FROM public.ecr.aws/docker/library/python:3.12-slim -COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter +COPY --from=048972532408.dkr.ecr.us-west-2.amazonaws.com/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter ENV PORT=8000 WORKDIR /var/task COPY requirements.txt ./ diff --git a/examples/fastapi-snapstart/app/Dockerfile b/examples/fastapi-snapstart/app/Dockerfile index 240149e1..163511d8 100644 --- a/examples/fastapi-snapstart/app/Dockerfile +++ b/examples/fastapi-snapstart/app/Dockerfile @@ -1,5 +1,5 @@ FROM public.ecr.aws/docker/library/python:3.12-slim -COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter +COPY --from=048972532408.dkr.ecr.us-west-2.amazonaws.com/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter ENV PORT=8000 WORKDIR /var/task COPY requirements.txt ./ From 4c4ac32d0afc09e9a769838c49b9ae08efb0106f Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 03:59:52 +0000 Subject: [PATCH 21/27] chore: use public ECR adapter image in OCI SnapStart example --- examples/fastapi-snapstart/README.md | 2 +- examples/fastapi-snapstart/app/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/fastapi-snapstart/README.md b/examples/fastapi-snapstart/README.md index 25af8979..52bfbc0b 100644 --- a/examples/fastapi-snapstart/README.md +++ b/examples/fastapi-snapstart/README.md @@ -20,7 +20,7 @@ The [Dockerfile](app/Dockerfile) copies the Lambda Web Adapter binary into ```dockerfile FROM public.ecr.aws/docker/library/python:3.12-slim -COPY --from=048972532408.dkr.ecr.us-west-2.amazonaws.com/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter +COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter ENV PORT=8000 WORKDIR /var/task COPY requirements.txt ./ diff --git a/examples/fastapi-snapstart/app/Dockerfile b/examples/fastapi-snapstart/app/Dockerfile index 163511d8..98a4ad21 100644 --- a/examples/fastapi-snapstart/app/Dockerfile +++ b/examples/fastapi-snapstart/app/Dockerfile @@ -1,5 +1,5 @@ FROM public.ecr.aws/docker/library/python:3.12-slim -COPY --from=048972532408.dkr.ecr.us-west-2.amazonaws.com/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter +COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter ENV PORT=8000 WORKDIR /var/task COPY requirements.txt ./ From 189c7cbcf7b2edcb0fd5bfc7e8dec3a558bd01ea Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 04:01:49 +0000 Subject: [PATCH 22/27] docs: correct after-restore step ordering in OCI example README --- examples/fastapi-snapstart/README.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/examples/fastapi-snapstart/README.md b/examples/fastapi-snapstart/README.md index 52bfbc0b..aaa56e02 100644 --- a/examples/fastapi-snapstart/README.md +++ b/examples/fastapi-snapstart/README.md @@ -42,22 +42,26 @@ The two SnapStart hook endpoints are configured as function environment variable When the function runs under SnapStart, the adapter calls your application at the snapshot boundary: -- `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. Before the - snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses +- **Before the snapshot** — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to + `/snapstart/before`. The adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). -- `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. After the - environment is restored and before traffic is served, the adapter sends an empty HTTP - `POST` to this path. The app uses it to re-establish connections and regenerate - per-environment unique values (in this example, it reconnects the pool and generates - a fresh `connection_id`). +- **After restore**, before traffic is served, the adapter performs three steps in + order: + 1. It refreshes its own HTTP connection to the inner app, so it never reuses a + connection captured in the snapshot (this is automatic — you do not configure or + manage the adapter's client). + 2. `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. The adapter + sends an empty HTTP `POST` to this path over the refreshed connection. The app uses + it to re-establish connections and regenerate per-environment unique values (in + this example, it reconnects the pool and generates a fresh `connection_id`). + 3. It re-runs the readiness check against your app before admitting traffic. Both hook routes must return a `2xx` status code. A non-2xx response, a connection -failure, or taking longer than 60 seconds to respond fails the SnapStart phase. After -restore, the adapter also automatically refreshes its own HTTP connection to the inner -app (so you do not have to manage the adapter's client) and re-runs the readiness check -before admitting traffic; if the app does not report ready within 10 seconds, the -restore fails. +failure, or taking longer than 60 seconds to respond fails the SnapStart phase. +Likewise, if the readiness check does not pass within 10 seconds of restore, the +restore fails — so traffic is never served against an app that has not finished +recovering. These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/snapstart/before` or From 074b4c085051c956c0030b2aa17b7352cba7e921 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 04:04:03 +0000 Subject: [PATCH 23/27] docs: correct after-restore step ordering in zip example README --- examples/fastapi-snapstart-zip/README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/fastapi-snapstart-zip/README.md b/examples/fastapi-snapstart-zip/README.md index 779dcb16..1a4156ec 100644 --- a/examples/fastapi-snapstart-zip/README.md +++ b/examples/fastapi-snapstart-zip/README.md @@ -18,10 +18,13 @@ To get more information of Wrapper script, please read Lambda documentation [her [Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) initializes your function, takes a snapshot of the initialized execution environment, and restores from that snapshot to serve invocations. Some resources do not survive the snapshot (open connections) and some values must not be shared across every restored environment (unique ids, seeds). The Lambda Web Adapter bridges the SnapStart lifecycle to your inner web app through two opt-in environment variables: -- `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. Before the snapshot is taken, the adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). -- `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. After the environment is restored and before traffic is served, the adapter sends an empty HTTP `POST` to this path. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). +- **Before the snapshot** — `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set to `/snapstart/before`. The adapter sends an empty HTTP `POST` to this path. The app uses it to drain and close resources that will not survive the snapshot (in this example, it closes the connection pool). +- **After restore**, before traffic is served, the adapter performs three steps in order: + 1. It refreshes its own HTTP connection to the inner app, so it never reuses a connection captured in the snapshot (this is automatic — you do not configure or manage the adapter's client). + 2. `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set to `/snapstart/after`. The adapter sends an empty HTTP `POST` to this path over the refreshed connection. The app uses it to re-establish connections and regenerate per-environment unique values (in this example, it reconnects the pool and generates a fresh `connection_id`). + 3. It re-runs the readiness check against your app before admitting traffic. -Both hook routes must return a `2xx` status code. A non-2xx response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase. After restore, the adapter also automatically refreshes its own HTTP connection to the inner app (so you do not have to manage the adapter's client) and re-runs the readiness check before admitting traffic; if the app does not report ready within 10 seconds, the restore fails. +Both hook routes must return a `2xx` status code. A non-2xx response, a connection failure, or taking longer than 60 seconds to respond fails the SnapStart phase. Likewise, if the readiness check does not pass within 10 seconds of restore, the restore fails — so traffic is never served against an app that has not finished recovering. These hook routes are protected: the adapter only allows them to be invoked internally during the SnapStart lifecycle. External callers that request `/snapstart/before` or `/snapstart/after` receive a `403 Forbidden`. From 0dc76b9f548014337ea3f45cc8409d50f4081799 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 29 Jun 2026 04:07:39 +0000 Subject: [PATCH 24/27] docs: fix incorrect AWS_LAMBDA_INITIALIZATION_TYPE claim in snapstart guide --- docs/guide/src/features/snapstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/src/features/snapstart.md b/docs/guide/src/features/snapstart.md index 9f34e622..7445728c 100644 --- a/docs/guide/src/features/snapstart.md +++ b/docs/guide/src/features/snapstart.md @@ -13,7 +13,7 @@ Both hooks are opt-in and independent — each fires only when its variable is s ## How it works -When `AWS_LAMBDA_INITIALIZATION_TYPE` is `snap-start`, the adapter participates in the SnapStart lifecycle: +The adapter always registers for the SnapStart lifecycle; the Lambda runtime invokes the hooks only when your function runs under SnapStart. When it does, the adapter participates as follows: 1. **Before checkpoint** — if `AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATH` is set, the adapter sends an empty `POST` to that path on your application, then signals Lambda that it is ready for the snapshot. 2. **After restore** — Lambda restores the environment. The adapter first refreshes its own HTTP connection to your application (so it never reuses a connection captured in the snapshot); then, if `AWS_LWA_SNAPSTART_AFTER_RESTORE_PATH` is set, sends an empty `POST` to that path; and finally re-runs the readiness check before admitting traffic. From 9f5182888704ed504feb1ec09520418f79c15a39 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 16:05:09 +0000 Subject: [PATCH 25/27] chore: use released lambda_http 1.3.0 with SnapStart support Switch from the git-branch dependency to the published lambda_http/lambda_runtime 1.3.0 from crates.io, removing the release blocker. Also fix the integration test body-reader helpers to accept the BoxBody response type, and resolve a clippy ok().expect() lint. --- Cargo.lock | 16 ++++++++++------ Cargo.toml | 2 +- src/lib.rs | 6 +----- tests/integ_tests/main.rs | 15 +++++++++++---- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97d2fd87..44a3512e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,7 +263,8 @@ dependencies = [ [[package]] name = "aws_lambda_events" version = "1.2.0" -source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d02c123e89527e7b424f74f52d11be0d17ef2887819323a42dcae1c7630ca53d" dependencies = [ "base64", "bytes", @@ -1244,8 +1245,9 @@ dependencies = [ [[package]] name = "lambda_http" -version = "1.2.1" -source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e69eb3117d123f471f7d2b5d02b5afe150f54f9f7cff3264e578c9dd03675ad9" dependencies = [ "aws_lambda_events", "bytes", @@ -1268,8 +1270,9 @@ dependencies = [ [[package]] name = "lambda_runtime" -version = "1.2.1" -source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484647f147899f866a7b5db6aaa1671e943f89f4be76e2a383c96b1b27058294" dependencies = [ "async-stream", "base64", @@ -1293,7 +1296,8 @@ dependencies = [ [[package]] name = "lambda_runtime_api_client" version = "1.1.0" -source = "git+https://github.com/bnusunny/aws-lambda-rust-runtime.git?branch=feat%2Fsnapstart-resource-trait#f1618f8b8c391c6ebde31b20ac8c9c895375d7c8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92e6500e47d17c1ffd3e6ad3ca224bb86382fc8b63414f6f20b1b2d98dfb7cf" dependencies = [ "bytes", "futures-channel", diff --git a/Cargo.toml b/Cargo.toml index 7995e4ab..53e6477a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ http-body = "1.0.1" http-body-util = "0.1.0" hyper = { version = "1.5.2", features = ["client"] } hyper-util = "0.1.10" -lambda_http = { git = "https://github.com/bnusunny/aws-lambda-rust-runtime.git", branch = "feat/snapstart-resource-trait", default-features = false, features = [ +lambda_http = { version = "1.3.0", default-features = false, features = [ "apigw_http", "apigw_rest", "alb", diff --git a/src/lib.rs b/src/lib.rs index 4a7f19e8..6887a1fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1696,11 +1696,7 @@ mod tests { // Publish a fresh client. let fresh = Arc::new(build_client()); let fresh_ptr = Arc::as_ptr(&fresh) as *const (); - adapter - .restored_client - .set(fresh) - .ok() - .expect("set should succeed once"); + assert!(adapter.restored_client.set(fresh).is_ok(), "set should succeed once"); // After restore: client() returns the restored client (different pointer). let now_ptr = Arc::as_ptr(adapter.client()) as *const (); diff --git a/tests/integ_tests/main.rs b/tests/integ_tests/main.rs index 0238618e..d1227ddb 100644 --- a/tests/integ_tests/main.rs +++ b/tests/integ_tests/main.rs @@ -13,7 +13,6 @@ use httpmock::{ Method::{DELETE, GET, POST, PUT}, MockServer, }; -use hyper::body::Incoming; use lambda_http::Body; use lambda_http::Context; use lambda_web_adapter::{Adapter, AdapterOptions, LambdaInvokeMode, Protocol}; @@ -25,7 +24,7 @@ use flate2::Compression; use http_body_util::BodyExt; use lambda_http::lambda_runtime::Config; use serde_json::json; -use tower_http::compression::{CompressionBody, CompressionLayer}; +use tower_http::compression::CompressionLayer; #[test] fn test_adapter_options_from_env() { @@ -1162,12 +1161,20 @@ async fn test_http_tcp_readiness_check() { assert_eq!("TCP Ready", body_to_string(response).await); } -async fn body_to_string(res: Response) -> String { +async fn body_to_string(res: Response) -> String +where + B: http_body::Body, + B::Error: std::fmt::Debug, +{ let body_bytes = res.collect().await.unwrap().to_bytes(); String::from_utf8_lossy(&body_bytes).to_string() } -async fn compressed_body_to_string(res: Response>) -> String { +async fn compressed_body_to_string(res: Response) -> String +where + B: http_body::Body, + B::Error: std::fmt::Debug, +{ let body_bytes = res.collect().await.unwrap().to_bytes(); decode_reader(&body_bytes).unwrap() } From 5ffe17afb427f3702133b4370a7cbc2f087713cb Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 16:09:18 +0000 Subject: [PATCH 26/27] docs: add SnapStart to README features list --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6213e6fd..8b377204 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The same docker image can run on AWS Lambda, Amazon EC2, AWS Fargate, and local - Supports Amazon API Gateway Rest API and Http API endpoints, Lambda Function URLs, and Application Load Balancer - Supports Lambda managed runtimes, custom runtimes and docker OCI images - Supports Lambda Managed Instances for multi-concurrent request handling +- Supports Lambda SnapStart with before-checkpoint and after-restore hooks - Supports any web frameworks and languages, no new code dependency to include - Automatic encode binary response - Enables graceful shutdown From ada9aa2cfe422a1a4e2705b449e69a8b2dfd56cb Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 16:20:44 +0000 Subject: [PATCH 27/27] fix: reseed RNG in after-restore hook so restored envs get unique ids --- examples/fastapi-snapstart-zip/app/main.py | 4 ++++ examples/fastapi-snapstart/app/main.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/examples/fastapi-snapstart-zip/app/main.py b/examples/fastapi-snapstart-zip/app/main.py index 97b73a9f..b9deba7a 100644 --- a/examples/fastapi-snapstart-zip/app/main.py +++ b/examples/fastapi-snapstart-zip/app/main.py @@ -57,5 +57,9 @@ async def after_restore(): Re-establish connections and regenerate per-environment unique values. """ + # Reseed from OS entropy first. Python's random module keeps its state in + # process memory, which is captured in the snapshot — without reseeding, + # every restored environment would draw the same "unique" value. + random.seed() pool.connect() return Response(status_code=200) diff --git a/examples/fastapi-snapstart/app/main.py b/examples/fastapi-snapstart/app/main.py index f9d1b790..1476f5df 100644 --- a/examples/fastapi-snapstart/app/main.py +++ b/examples/fastapi-snapstart/app/main.py @@ -56,5 +56,9 @@ async def after_restore(): Re-establish connections and regenerate per-environment unique values. """ + # Reseed from OS entropy first. Python's random module keeps its state in + # process memory, which is captured in the snapshot — without reseeding, + # every restored environment would draw the same "unique" value. + random.seed() pool.connect() return Response(status_code=200)