When your local machine and the cloud disagree
You spend three hours writing a Rust function that parses JSON, hits a database, and returns a response in under ten milliseconds. You compile it, zip the binary, and push it to AWS Lambda. The deployment succeeds. You invoke the function. It crashes instantly with a Runtime exited with error: exit status 127 or a missing shared library error.
The problem is not your Rust code. The problem is the environment. Your laptop runs macOS or Windows. AWS Lambda runs a stripped-down version of Amazon Linux 2. Rust defaults to dynamic linking, which means your binary expects to find system libraries like libc.so at runtime. Lambda's container does not have them. Your binary arrives at the cloud and immediately goes looking for tools that were never packed in the suitcase.
Static linking explained
Dynamic linking is like bringing a recipe to a restaurant and expecting the kitchen to already have every specific spice you listed. If they are missing one, the dish fails. Static linking is like bringing a fully prepared meal in a sealed container. The kitchen does not need to know how you cooked it. It just needs to serve it.
AWS Lambda expects the sealed container. When you deploy a Rust binary, it must be statically linked. Every library your code depends on gets baked directly into the executable file. The resulting binary is larger, but it runs anywhere that supports the Linux kernel and the x86_64 architecture. Lambda provides the kernel and the architecture. You provide the self-contained executable.
The community convention for this is targeting x86_64-unknown-linux-gnu with a musl toolchain, or using a helper that automates the cross-compilation. You do not need to manually configure rustup targets and musl-gcc paths unless you enjoy fighting compiler flags.
Compile for the cloud, not for your desktop. The two environments share very little.
The minimal path to a working function
Start with a basic handler. Lambda expects a function that accepts an event and returns a response. The lambda_runtime crate handles the boilerplate of receiving HTTP requests, deserializing JSON, and sending responses back to the API Gateway.
/// A minimal Lambda handler that returns a greeting.
async fn handler(_event: lambda_runtime::LambdaEvent<serde_json::Value>) -> Result<lambda_runtime::Response<serde_json::Value>, lambda_runtime::Error> {
// Construct the JSON payload directly.
// Lambda expects the response body to be serializable.
let body = serde_json::json!({
"message": "Hello from Rust",
"status": "success"
});
// Wrap the payload in a 200 OK response.
Ok(lambda_runtime::Response::ok(body))
}
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
// Initialize the async runtime.
// Lambda requires the handler to block until the container stops.
lambda_runtime::run(lambda_runtime::service_fn(handler)).await?;
Ok(())
}
Add the dependencies to Cargo.toml. You need the runtime, a JSON library, and an async executor. Tokio is the standard choice.
[dependencies]
lambda_runtime = "0.8"
serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
If you run cargo build --release on macOS, you get a binary that works on your Mac. It will not work on Lambda. You need to compile for Linux. The community standard for this is cargo-lambda. It wraps cargo and handles the cross-compilation, static linking, and zip packaging in one command.
Install it with cargo install cargo-lambda. Then run:
cargo lambda build --release
The tool downloads a Docker image containing the correct Linux toolchain, compiles your code inside it, statically links the output, and places a ready-to-upload zip file in target/lambda/your_crate_name/. You do not need to manually manage rustup target add or install musl on your host machine.
Trust the toolchain. Manual cross-compilation is a rabbit hole that rarely ends well.
What happens under the hood
When cargo lambda build runs, it spins up a temporary container based on Amazon Linux 2. This matches the exact environment your code will run in when deployed. Inside that container, it invokes cargo build --target x86_64-unknown-linux-gnu --release. The compiler links against musl instead of glibc. Musl is a lightweight C standard library designed for static linking. It produces a single executable file with zero external dependencies.
After compilation, the tool creates a zip archive. AWS Lambda has a strict requirement for the zip structure. The executable must sit at the root of the archive. If you zip the target/release folder, the archive contains a folder at the root, and Lambda cannot find the binary. The deployment fails with a handler not found error.
The convention here is explicit: always verify the zip contents before uploading. Run unzip -l function.zip and confirm the binary name appears at the top level, not nested inside a directory.
Verify the archive structure before you click deploy. Lambda will not forgive a misplaced folder.
A realistic deployment workflow
Real applications do more than return static JSON. They connect to databases, read environment variables, and handle errors gracefully. Here is how a production-ready setup looks.
use lambda_runtime::{service_fn, Error, LambdaEvent, Response};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Request {
name: String,
}
#[derive(Serialize)]
struct ResponseBody {
greeting: String,
}
/// Handles incoming requests and returns a personalized greeting.
async fn handler(event: LambdaEvent<Request>) -> Result<Response<ResponseBody>, Error> {
// Extract the payload from the Lambda event wrapper.
let name = event.payload.name;
// Build the response structure.
let body = ResponseBody {
greeting: format!("Hello, {}!", name),
};
// Return the serialized response with a 200 status code.
Ok(Response::ok(body))
}
#[tokio::main]
async fn main() -> Result<(), Error> {
// Create a service function from the handler.
// This bridges the gap between async Rust and Lambda's event loop.
let func = service_fn(handler);
// Start the runtime. It will listen for events indefinitely.
lambda_runtime::run(func).await?;
Ok(())
}
Build it with cargo lambda build --release. The output zip goes to target/lambda/your_project/. Upload it using the AWS CLI. The runtime identifier must be provided.al2. This tells AWS that you are bringing your own executable and it should run on the Amazon Linux 2 base image.
aws lambda create-function \
--function-name rust-greeting \
--runtime provided.al2 \
--role arn:aws:iam::123456789012:role/lambda-execution-role \
--handler your_project_name \
--zip-file fileb://target/lambda/your_project/your_project.zip
The handler name must match the executable name exactly. If your binary is my_api, the handler is my_api. No extensions, no paths. Lambda treats the handler as the entry point executable.
If you forget to derive Deserialize on your request struct, the compiler rejects you with E0277 (trait bound not satisfied). The error points directly to the missing trait implementation. Fix the derive macro and the compilation succeeds.
Pitfalls and compiler traps
The most common failure is dynamic linking. If you skip the cross-compilation step and upload a native macOS or Windows binary, Lambda rejects it immediately. The error message usually mentions Exec format error or cannot open shared object file. The fix is always the same: compile for x86_64-unknown-linux-gnu with static linking.
Another trap is the zip structure. Developers often run zip -r function.zip target/release/ from the project root. This creates function.zip/target/release/binary. Lambda looks for binary at the root. It finds a folder instead and crashes. Run zip function.zip target/release/your_binary without the -r flag, or use a tool that handles the path flattening automatically.
Environment variables are another silent killer. Rust does not automatically inject AWS environment variables into your code. You must read them explicitly using std::env::var. If a variable is missing, var returns an error. Use var_os or var with a fallback depending on your tolerance for missing configuration. The compiler will not warn you about missing runtime configuration. It only cares about compile-time correctness.
Convention aside: the community treats provided.al2 as the standard runtime for custom Rust binaries. Newer runtimes like provided.al2023 exist, but al2 remains the most tested and documented baseline for Lambda. Stick with al2 unless you have a specific reason to migrate.
Convention aside: cargo-lambda outputs the zip to target/lambda/<crate_name>/. Do not move the zip file to a different directory without updating your deployment scripts. Relative paths break silently in CI pipelines.
Read the error logs before rewriting the handler. Most Lambda failures are packaging issues, not logic bugs.
When to use which approach
Use manual cargo build and zip when you are running on a Linux machine that already matches the Lambda environment and you need zero external tooling. Use cargo-lambda when you are on macOS or Windows and want a reliable, repeatable build pipeline that handles cross-compilation and packaging automatically. Use Docker container images when your binary exceeds the 250 MB unzipped limit or when you need to bundle large third-party libraries that static linking struggles to compress. Reach for serverless frameworks like sls or sam when you need to manage infrastructure alongside your code in a single deployment command.
Pick the tool that matches your host OS and deployment scale. Overcomplicating the build step steals time from actual development.