Skip to main content

fakecloud_lambda/runtime/k8s/
mod.rs

1//! Kubernetes [`LambdaBackend`] implementation.
2//!
3//! Spawns Lambda function runtimes as native Pods in a Kubernetes
4//! cluster instead of as Docker containers. Gated by
5//! `FAKECLOUD_LAMBDA_BACKEND=k8s` (or the global
6//! `FAKECLOUD_CONTAINER_BACKEND=k8s`) on the fakecloud server.
7//!
8//! The shared client bootstrap, Pod lifecycle (create/wait/delete),
9//! reaping, and naming live in the `fakecloud-k8s` crate; this module
10//! only builds the Lambda-specific Pod spec and wires the lifecycle into
11//! the [`LambdaBackend`] trait.
12//!
13//! See `website/content/docs/guides/kubernetes-backend.md` for the
14//! operator-facing setup (ServiceAccount, RBAC, Deployment yaml).
15
16pub mod spec;
17
18use std::time::Duration;
19
20use async_trait::async_trait;
21use fakecloud_k8s::{K8sClient, K8sEnv, K8sEnvError};
22
23use super::backend::{BackendHandle, LambdaBackend, RuntimeError, WarmInstance};
24use crate::state::LambdaFunction;
25use spec::{build_pod_spec, pod_name_for, PodSpecContext};
26
27/// Which `fakecloud-service` label Lambda Pods carry, so reaping only
28/// touches Lambda Pods.
29const SERVICE: &str = "lambda";
30
31/// Errors that can prevent the K8s backend from initializing. Surfaced
32/// to the operator at fakecloud startup; never silently swallowed.
33#[derive(Debug, thiserror::Error)]
34pub enum K8sBackendError {
35    #[error(transparent)]
36    Env(#[from] K8sEnvError),
37    #[error("failed to connect to the Kubernetes cluster: {0}")]
38    Connect(String),
39}
40
41/// Native Kubernetes Lambda execution backend.
42pub struct K8sBackend {
43    client: K8sClient,
44    /// In-cluster URL of the fakecloud server (e.g.
45    /// `http://fakecloud.fakecloud.svc.cluster.local:4566`). Init
46    /// containers fetch code + layers from this host.
47    self_url: String,
48    /// Just the host part of `self_url` — used to rewrite localhost env
49    /// values so user code can talk to fakecloud from inside the Pod.
50    self_host: String,
51    /// Host:port for the fakecloud ECR endpoint (defaults to the host
52    /// of `self_url` when `FAKECLOUD_K8S_ECR_URL` is unset).
53    ecr_host: String,
54    ecr_port: u16,
55    /// Bearer token the init container presents when fetching code +
56    /// layers. Generated at server startup, kept in process memory only.
57    internal_token: String,
58    /// Optional `imagePullSecrets` reference for image-package functions
59    /// that pull from a registry needing credentials.
60    pull_secret: Option<String>,
61}
62
63impl K8sBackend {
64    /// Read configuration from env vars and connect to the cluster.
65    /// Fails fast on missing required config — never silently degrades.
66    /// `default_ecr_port` is fakecloud's bound port; used as the ECR
67    /// port when `FAKECLOUD_K8S_ECR_URL` is unset.
68    pub async fn from_env(
69        default_ecr_port: u16,
70        internal_token: String,
71    ) -> Result<Self, K8sBackendError> {
72        let env = K8sEnv::from_env(default_ecr_port)?;
73        let client = K8sClient::connect(env.namespace.clone())
74            .await
75            .map_err(|e| K8sBackendError::Connect(e.to_string()))?;
76
77        tracing::info!(
78            namespace = %env.namespace,
79            self_url = %env.self_url,
80            ecr = %format!("{}:{}", env.ecr_host, env.ecr_port),
81            "K8s Lambda backend initialized"
82        );
83
84        Ok(Self {
85            client,
86            self_url: env.self_url,
87            self_host: env.self_host,
88            ecr_host: env.ecr_host,
89            ecr_port: env.ecr_port,
90            internal_token,
91            pull_secret: env.pull_secret,
92        })
93    }
94}
95
96/// Extract the account ID from a function ARN
97/// (`arn:aws:lambda:<region>:<account>:function:<name>[:<qual>]`).
98fn account_id_from_arn(arn: &str) -> &str {
99    arn.split(':').nth(4).unwrap_or("000000000000")
100}
101
102#[async_trait]
103impl LambdaBackend for K8sBackend {
104    fn name(&self) -> &str {
105        "kubernetes"
106    }
107
108    async fn launch(
109        &self,
110        func: &LambdaFunction,
111        _code_zip: Option<&[u8]>,
112        _layers: &[Vec<u8>],
113        deploy_id: &str,
114    ) -> Result<WarmInstance, RuntimeError> {
115        let account_id = account_id_from_arn(&func.function_arn);
116        let ctx = PodSpecContext {
117            instance_id: self.client.instance_id(),
118            namespace: self.client.namespace(),
119            self_url: &self.self_url,
120            self_host: &self.self_host,
121            ecr_host: &self.ecr_host,
122            ecr_port: self.ecr_port,
123            internal_token: &self.internal_token,
124            account_id,
125            pull_secret: self.pull_secret.as_deref(),
126        };
127        let pod =
128            build_pod_spec(func, deploy_id, &ctx).map_err(RuntimeError::ContainerStartFailed)?;
129        let pod_name = pod
130            .metadata
131            .name
132            .clone()
133            .unwrap_or_else(|| pod_name_for(&func.function_name, deploy_id));
134
135        self.client
136            .create_pod(&pod)
137            .await
138            .map_err(|e| RuntimeError::ContainerStartFailed(format!("k8s create pod: {e}")))?;
139
140        // Tear the Pod down again if it never becomes ready, so a failed
141        // launch doesn't leak a Pod.
142        let pod_ip = match self
143            .client
144            .wait_for_pod_ip(&pod_name, Duration::from_secs(60))
145            .await
146        {
147            Ok(ip) => ip,
148            Err(e) => {
149                self.client.delete_pod(&pod_name).await;
150                return Err(RuntimeError::ContainerStartFailed(e.to_string()));
151            }
152        };
153        // Pod-Running doesn't guarantee the RIE inside the main container
154        // is listening yet — TCP-handshake the invoke port like Docker.
155        if let Err(e) = K8sClient::wait_for_tcp(&pod_ip, 8080, Duration::from_secs(10)).await {
156            self.client.delete_pod(&pod_name).await;
157            return Err(RuntimeError::ContainerStartFailed(format!(
158                "RIE on {pod_ip}:8080 not ready: {e}"
159            )));
160        }
161
162        tracing::info!(
163            function = %func.function_name,
164            pod = %pod_name,
165            namespace = %self.client.namespace(),
166            pod_ip = %pod_ip,
167            "Lambda Pod started"
168        );
169
170        Ok(WarmInstance {
171            endpoint: format!("{pod_ip}:8080"),
172            handle: BackendHandle::Pod {
173                namespace: self.client.namespace().to_string(),
174                name: pod_name,
175            },
176        })
177    }
178
179    async fn terminate(&self, handle: &BackendHandle) {
180        match handle {
181            BackendHandle::Pod { name, .. } => self.client.delete_pod(name).await,
182            // Docker handles aren't ours to manage — defensive no-op.
183            BackendHandle::Container { .. } => {}
184        }
185    }
186
187    /// Sweep Lambda Pods that belong to a previous fakecloud process.
188    /// Without this, a fakecloud restart leaks the previous run's Pods
189    /// and `Create` collides on function names. Mirrors the docker
190    /// `reaper` semantics.
191    async fn reap_stale(&self) {
192        self.client.reap_stale(SERVICE).await;
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::account_id_from_arn;
199
200    #[test]
201    fn account_id_from_simple_arn() {
202        assert_eq!(
203            account_id_from_arn("arn:aws:lambda:us-east-1:123456789012:function:my-fn"),
204            "123456789012"
205        );
206    }
207
208    #[test]
209    fn account_id_from_qualified_arn() {
210        assert_eq!(
211            account_id_from_arn("arn:aws:lambda:us-east-1:000000000000:function:my-fn:PROD"),
212            "000000000000"
213        );
214    }
215
216    #[test]
217    fn account_id_falls_back_for_malformed_arn() {
218        assert_eq!(account_id_from_arn("not-an-arn"), "000000000000");
219    }
220}