The binary that won't fit
You finish a Rust service that processes data in microseconds. You run gcloud functions deploy and the terminal rejects you. Google Cloud Functions lists Node.js, Python, Go, and a handful of others. Rust isn't on the menu. The platform expects a script that a managed runtime can interpret. It doesn't know how to execute a compiled binary. You can't upload a Linux executable and expect the runtime to figure it out. The solution isn't a hidden flag or a special runtime mode. It's a container.
Pack your binary in a container. The platform will do the rest.
Containers are the universal adapter
Think of Cloud Functions 1st Gen like a vending machine. It only accepts specific coins. If you bring a weird foreign currency, it rejects you. Rust is that foreign currency. Cloud Functions 2nd Gen and Cloud Run are like a shipping dock. They accept a standard shipping container. Inside that container, you can pack whatever you want. Rust fits perfectly inside a container. The platform doesn't care about Rust. It cares about the container. You build the container, the platform runs the container.
This approach gives you full control. You choose the base image. You choose the dependencies. You choose the entrypoint. The tradeoff is that you manage the container lifecycle. You build the image, you push it to a registry, you deploy the image. The platform handles scaling, networking, and health checks. You handle the build.
The platform doesn't care about Rust. It cares about the container. Build the container, and you win.
Minimal server code
Your Rust application must act as an HTTP server. Google Cloud routes traffic to your container via a proxy. The proxy expects your app to listen on a specific port. The port is passed via the PORT environment variable. If you ignore the variable, your app listens on the wrong port, the proxy can't connect, and you get a 502 error.
Use a lightweight framework. actix-web and axum are the standard choices. actix-web is mature and fast. axum is ergonomic and integrates well with the tower ecosystem. Both work well for serverless. Pick one and stick to it. The community convention is to match the framework to your team's experience. If you know tokio and hyper, axum feels natural. If you want battle-tested performance, actix-web has the track record.
/// Minimal HTTP server for containerized deployment.
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
async fn index() -> impl Responder {
// Return a simple text response.
// The framework handles the HTTP headers and status code.
HttpResponse::Ok().body("Hello from Rust on GCP!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// GCP sets the PORT env var. Default to 8080 for local testing.
// The container must listen on this port, or health checks fail.
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let host = "0.0.0.0";
// Bind to all interfaces.
// 127.0.0.1 won't work inside a container because the proxy can't reach it.
HttpServer::new(|| {
App::new().route("/", web::get().to(index))
})
.bind((host, port.parse().unwrap()))?
.run()
.await
}
Read the PORT variable. Hardcoding the port is the fastest way to get a 502 error.
The multi-stage build
A Rust build environment is heavy. It contains the compiler, the standard library, debug symbols, and gigabytes of dependency artifacts. You need all that to compile your code. You need none of that to run your code. If you ship the build environment to production, your container image will be huge. Huge images take longer to pull. Longer pulls mean slower cold starts. Cold starts kill serverless performance.
Use a multi-stage Dockerfile. The first stage compiles the binary. The second stage copies only the binary into a tiny runtime image. The result is a container that weighs a few megabytes instead of a few gigabytes. Small images pull fast. Fast pulls mean your function starts before the user notices.
# Stage 1: Build environment.
# Use a slim Rust image to keep the build layer manageable.
FROM rust:1.75-slim AS builder
WORKDIR /app
# Copy manifest files first.
# This leverages Docker layer caching.
# If only source changes, dependencies don't rebuild.
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
# Copy source and build.
# --release enables optimizations.
# --bin targets the specific binary name.
COPY . .
RUN cargo build --release --bin my-function
# Stage 2: Runtime.
# Distroless images contain only the binary and libc.
# No package manager, no shell, no SSH.
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
# Copy the binary from the builder stage.
# The path matches the output of cargo build.
COPY --from=builder /app/target/release/my-function .
# Run the binary.
# Distroless images expect the entrypoint to be the binary.
CMD ["./my-function"]
The cargo fetch step before COPY . . is a caching trick. If you copy everything first, any file change invalidates the dependency cache. By copying manifests first, Docker caches the dependency download. This saves minutes on rebuilds. The community calls this "manifest-first caching". It's a standard pattern in Rust Dockerfiles.
Treat the distroless image as a security boundary. If there's no shell, there's no way in.
Squeezing the last byte
Rust binaries are large by default. They link dynamically against libc. They include debug information. They contain unwind tables. You can shrink the image further by compiling a fully static binary. A static binary links all dependencies into the executable. It doesn't need libc at runtime. You can use a distroless/static image. The image drops to about 1 megabyte.
Ah-ha reveal: Rust's "cold start" is often a lie. The binary loads in milliseconds. The delay is the network fetching the image. Don't blame Rust for slow cold starts. Blame the image size. Optimizing the binary startup time gives you microseconds. Optimizing the image size gives you seconds. Focus on the image.
To build a static binary, use the musl target. musl is a lightweight C library designed for static linking. Add the target in your Dockerfile and build with --target.
# Stage 1: Static build.
FROM rust:1.75-slim AS builder
WORKDIR /app
# Install musl tools and add the target.
# musl provides the static C library.
RUN apt-get update && apt-get install -y musl-tools
RUN rustup target add x86_64-unknown-linux-musl
# Cache dependencies.
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
# Build static binary.
# The target produces a fully static executable.
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin my-function
# Stage 2: Static runtime.
# distroless/static has no libc.
# It only works with static binaries.
FROM gcr.io/distroless/static
WORKDIR /app
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/my-function .
CMD ["./my-function"]
The distroless/static image is the smallest option. It contains nothing but the binary. If your code uses libc features that musl doesn't support, you'll hit link errors. Most standard Rust code works fine. If you need dynamic linking, stick to distroless/cc. The community convention is to use musl for serverless containers. The size reduction is worth the minor compatibility checks.
Small images pull fast. Fast pulls mean your function starts before the user notices.
Pitfalls and errors
Binding to the wrong interface is the most common mistake. If you bind to 127.0.0.1, the server listens only on the loopback interface. The proxy runs outside the loopback. The proxy can't connect. The health check times out. GCP kills the instance. You see a 502 error. The fix is 0.0.0.0. Always bind to all interfaces.
Ignoring the PORT variable is the second trap. GCP sets PORT to a random high port. If you hardcode 8080, your app listens on 8080. GCP routes traffic to 8081. The request hits nothing. Always read PORT. Use std::env::var("PORT") with a fallback for local development.
Deploying to Cloud Functions 1st Gen with a container fails immediately. 1st Gen doesn't support custom containers. It only supports the managed runtimes. The CLI rejects the deployment with an error about "source" vs "image". You must use Cloud Functions 2nd Gen or Cloud Run. 2nd Gen is built on Cloud Run under the hood. It supports containers. 1st Gen is a dead end for Rust.
Parsing the port string can panic at runtime. port.parse().unwrap() panics if the variable is empty or invalid. The container crashes on startup. GCP restarts it. You get a loop of crashes. Use parse::<u16>().expect("PORT must be a valid number") to get a clear error message. Or better, handle the error gracefully. The convention is to use expect with a descriptive message in serverless code. A clear panic message helps debugging.
Read the PORT variable. Hardcoding the port is the fastest way to get a 502 error.
Decision matrix
Use Cloud Run when you need full control over the container, environment variables, and scaling behavior. Cloud Run is the native home for compiled binaries on GCP. It handles HTTP traffic directly and supports long-running processes if you need them. Use Cloud Functions 2nd Gen when you want tighter integration with GCP event triggers like Pub/Sub or Cloud Storage. 2nd Gen is built on Cloud Run under the hood, so you get the container support with a simplified event-driven interface. Use Cloud Functions 1st Gen only when you are maintaining legacy code that depends on the old runtime model. 1st Gen cannot run containers. It cannot run Rust. Avoid it for new projects. Reach for AWS Lambda with a custom runtime if you are already deep in the AWS ecosystem and need Rust there. The container approach is similar, but the tooling differs.
Pick the tool that matches your trigger. Cloud Run for HTTP, 2nd Gen for events. 1st Gen is a dead end for Rust.