diff --git a/lambda-microvm-s3files/README.md b/lambda-microvm-s3files/README.md new file mode 100644 index 000000000..588be6cfd --- /dev/null +++ b/lambda-microvm-s3files/README.md @@ -0,0 +1,194 @@ +# AWS Lambda MicroVMs with Amazon S3 Files + +Mount an Amazon S3 bucket as a POSIX file system *inside* a Lambda MicroVM — a Firecracker-isolated, snapshot-resumable serverless compute environment — and read/write your objects as files over NFS, with changes synchronized back to S3 automatically. The MicroVM reaches the file system through a VPC egress network connector. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/lambda-microvm-s3files + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage — please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) **v2.35.0 or newer**, installed and configured. Check with `aws --version` and [upgrade](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) if needed. +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed +* `python3` and `zip` on your `PATH` + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repo: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +2. Change directory to the pattern directory: + ``` + cd lambda-microvm-s3files + ``` +3. Upload the MicroVM application artifact (the zipped `Dockerfile` + `app.py`) to an S3 bucket in your region. The image build reads this zip, so it must exist **before** you deploy. The helper creates the bucket if it does not exist: + ``` + ./src/run.sh package + ``` +4. **Have a VPC and subnet ready.** This pattern deploys the S3 Files mount target + and the VPC egress connector's elastic network interfaces (ENIs) into a subnet + **you supply** — the template does *not* create a VPC. Before deploying, either + pick an existing VPC/subnet or create one: + * **Use an existing VPC** – every account has a **default VPC** in each region + whose subnets work out of the box. Find one with: + ``` + aws ec2 describe-vpcs --region us-west-2 \ + --filters "Name=isDefault,Values=true" --query 'Vpcs[0].VpcId' --output text + aws ec2 describe-subnets --region us-west-2 \ + --filters "Name=vpc-id,Values=" --query 'Subnets[].SubnetId' --output text + ``` + * **Or create one** – e.g. `aws ec2 create-default-vpc`, or provision a dedicated + VPC + subnet with your own tooling. + * **Subnet requirements:** any subnet in the chosen VPC works — public *or* + private. The subnet only needs an available IP for the mount-target ENI; the + MicroVM reaches the mount target by its **private IPv4** over the egress + connector, so no public IP or internet route is required on this subnet. + (Build-time package installs use a separate managed `INTERNET_EGRESS` + connector, not this subnet.) If you create a *dedicated* VPC for this pattern, + remember to tear it down during cleanup — see [Cleanup](#cleanup). +5. From the command line, deploy the AWS SAM template: + ``` + sam deploy --guided + ``` +6. During the prompts, supply: + * **Stack Name** – e.g. `lambda-microvm-s3files` + * **AWS Region** – `us-west-2` + * **CodeArtifactBucket** – the bucket name from step 3 + * **VpcId** – the VPC from step 4 + * **SubnetId** – a subnet in that VPC (the CLI validates it belongs to `VpcId`) + * Accept the IAM-capabilities prompt (`CAPABILITY_IAM`) +7. Wait for the stack to reach `CREATE_COMPLETE`. The MicroVM **image build runs + asynchronously** as part of the stack; confirm it finished before running: + ``` + aws cloudformation describe-stacks --stack-name --region us-west-2 \ + --query "Stacks[0].Outputs[?OutputKey=='ImageState'].OutputValue" --output text + ``` + Wait until this prints `CREATED` (it starts as `CREATING`). + The other stack outputs — `ImageArn`, `ExecutionRoleArn`, `EgressConnectorArn`, + `FileSystemId`, `AccessPointId`, `MountTargetId`, `DataBucket` — are informational + (FYI): `src/run.sh` reads them automatically from the stack by name, so you do not + need to copy them anywhere. +8. Launch a MicroVM from the built image and mount the file system: + ``` + ./src/run.sh run + ``` + +## How it works + +The application is a small HTTP service that runs inside a Lambda MicroVM and exposes the mounted file tree. Amazon S3 Files presents the S3 bucket as a POSIX file system over NFS 4.2; the app reads and writes files on `/mnt/s3files` and S3 Files synchronizes changes to and from the bucket in both directions. + +``` + ┌─────────────────────────────────────────────┐ + client │ Firecracker MicroVM (Amazon Linux 2023) │ + (curl / browser) │ │ + │ X-aws-proxy │ :8080 Flask app ── reads/writes ──┐ │ + └───────────────▶│ :9000 lifecycle hooks │ │ + │ │ mount in /run ▼ │ + │ │ /mnt/s3files │ + │ │ │ NFS 4.2 │ + └────────────┼───────────────────────┼────────┘ + │ │ :2049 + run-hook payload VPC egress + (fs id, AP, mount IP) network connector + │ + ▼ + ┌──────────────────┐ + │ S3 Files mount │ + │ target (in VPC) │ + └────────┬─────────┘ + sync ▲│▼ both ways + ┌────────┴─────────┐ + │ S3 bucket │ + │ (versioning on) │ + └──────────────────┘ +``` + +The mount happens in the MicroVM's **`/run` lifecycle hook**, not at image-build time, for two reasons: + +1. **The network connector is bound at run time.** The MicroVM reaches the S3 Files mount target (NFS port 2049) over a VPC egress connector that only exists on the running instance — not in the build sandbox. +2. **Credentials are run-time only.** The S3 Files mount helper authenticates with the MicroVM's execution-role credentials, which are exposed to the guest via IMDSv2 and are not present during the build. + +The same logic re-mounts in the **`/resume` hook**: suspending a MicroVM tears down the NFS connection, so it is re-established on resume. + +**What CloudFormation provisions** (`template.yaml`): the data `S3::Bucket` (versioning on), the `S3Files::FileSystem` / `MountTarget` / `AccessPoint`, the NFS security group, the `Lambda::NetworkConnector` (VPC egress), the IAM roles (one combined build + execution role, the S3-Files-access role, the connector-operator role), the CloudWatch log group, and the `Lambda::MicrovmImage`. + +**What the `src/run.sh` helper does** (data-plane operations with no CloudFormation resource): `package` zips and uploads the app artifact, and `run` calls `RunMicrovm`, mints an auth token, and exercises the app. + +## Testing + +After `./src/run.sh run ` returns the `GET /` JSON with `"mounted": true`, prove the bidirectional sync — write a file through the mount and watch it appear in S3: + +``` +./src/run.sh prove +``` + +This calls `GET /sync-proof` (which writes a uniquely named file on the mount) and then polls the bucket until S3 Files exports it. Because the access point scopes the mount to `/microvm`, the file appears at `s3:///microvm/sync-proof/`. + +You can also drive the app directly with the endpoint and an auth token: + +``` +# List the mounted tree +curl "https:///files" -H "X-aws-proxy-auth: " -H "X-aws-proxy-port: 8080" +# Write a file directly to S3 and read it back through the mount +aws s3 cp ./local.txt s3:///microvm/local.txt --region us-west-2 +curl "https:///files/local.txt" -H "X-aws-proxy-auth: " -H "X-aws-proxy-port: 8080" +``` + +## Cleanup + +1. Terminate the running MicroVM: + ``` + ./src/run.sh terminate + ``` +2. Look up the data bucket name (used in the steps below): + ``` + BUCKET=$(aws cloudformation describe-stacks --stack-name --region us-west-2 \ + --query "Stacks[0].Outputs[?OutputKey=='DataBucket'].OutputValue" --output text) + ``` +3. Delete the CloudFormation stack. This removes the image, the S3 Files resources + (file system, mount target, access point), the connector, the roles, and the log + group. **Expect it to fail on `DataBucket`** — the bucket has **versioning + enabled** (S3 Files requires it), so CloudFormation will not delete it while it + holds objects: + ``` + sam delete --stack-name --region us-west-2 + ``` + It ends in `DELETE_FAILED` with + `The bucket you tried to delete is not empty. You must delete all versions...`. + Everything *except* the bucket is now gone — importantly the S3 Files file + system, which had been actively syncing objects into the bucket. Emptying the + bucket *before* this step does not help: while the file system still exists it + keeps exporting files (and flushes once more as it is torn down), re-populating + the bucket. So empty it **after** the file system is gone. +4. Empty the now-static bucket — delete every object version and delete marker. + This loop batches both and stops when the bucket is empty: + ``` + while true; do + BATCH=$(aws s3api list-object-versions --bucket "$BUCKET" --region us-west-2 --max-items 500 \ + --query '{Objects: [Versions, DeleteMarkers][][].{Key:Key,VersionId:VersionId}}' --output json) + # Stop when nothing is left (query returns {"Objects": null}). + echo "$BATCH" | grep -q '"Key"' || break + aws s3api delete-objects --bucket "$BUCKET" --region us-west-2 --delete "$BATCH" >/dev/null + done + ``` +5. Re-run the stack delete; with the bucket empty it now removes the bucket and + completes: + ``` + aws cloudformation delete-stack --stack-name --region us-west-2 + aws cloudformation wait stack-delete-complete --stack-name --region us-west-2 + ``` +6. Confirm the stack is deleted (should print nothing): + ``` + aws cloudformation list-stacks --region us-west-2 \ + --query "StackSummaries[?StackName=='' && StackStatus!='DELETE_COMPLETE'].StackStatus" + ``` +7. **If you created a dedicated VPC/subnet for this pattern**, it is *not* part of + the stack, delete it separately once the stack is gone. + +---- +SPDX-License-Identifier: MIT-0 + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/lambda-microvm-s3files/example-pattern.json b/lambda-microvm-s3files/example-pattern.json new file mode 100644 index 000000000..17f2227a1 --- /dev/null +++ b/lambda-microvm-s3files/example-pattern.json @@ -0,0 +1,62 @@ +{ + "title": "AWS Lambda MicroVMs with Amazon S3 Files", + "description": "Mount an Amazon S3 bucket as a POSIX file system inside a Lambda MicroVM, reachable over NFS through a VPC egress network connector.", + "language": "Python", + "level": "300", + "framework": "AWS SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern runs a containerized application inside an AWS Lambda MicroVM — a Firecracker-isolated, snapshot-resumable serverless compute environment — and mounts an Amazon S3 bucket as a local POSIX file system using Amazon S3 Files (NFS 4.2). The application reads and writes files on a mount path and S3 Files synchronizes changes to and from the bucket in both directions, with no GetObject/PutObject plumbing in the code.", + "Because the file system lives in your VPC (not on a public endpoint), the MicroVM reaches the mount target over a VPC egress network connector that provisions elastic network interfaces. The mount itself happens at run time inside the MicroVM's /run lifecycle hook — the network path and execution-role credentials only exist once the MicroVM is running, so they cannot be baked into the image snapshot.", + "CloudFormation provisions the data bucket, the S3 Files file system, mount target, and access point, the VPC egress connector, all IAM roles, and the MicroVM image (AWS::Lambda::MicrovmImage). The MicroVM itself is launched afterwards with the included run.sh helper, since RunMicrovm is a runtime API rather than a CloudFormation resource." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-microvm-s3files", + "templateURL": "serverless-patterns/lambda-microvm-s3files", + "projectFolder": "lambda-microvm-s3files", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda MicroVMs", + "link": "https://docs.aws.amazon.com/lambda/" + }, + { + "text": "Amazon S3 Files", + "link": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files.html" + }, + { + "text": "AWS::Lambda::MicrovmImage CloudFormation resource", + "link": "https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-lambda-microvmimage.html" + } + ] + }, + "deploy": { + "text": [ + "./src/run.sh package ", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the README for full testing instructions (run.sh run / run.sh prove)." + ] + }, + "cleanup": { + "text": [ + "see the README Cleanup section" + ] + }, + "authors": [ + { + "name": "Ben Freiberg", + "image": "https://serverlessland.com/assets/images/contributors/ben-freiberg.jpg", + "linkedin": "benfreiberg" + } + ] +} diff --git a/lambda-microvm-s3files/lambda-microvm-s3files.json b/lambda-microvm-s3files/lambda-microvm-s3files.json new file mode 100644 index 000000000..2d4bdef08 --- /dev/null +++ b/lambda-microvm-s3files/lambda-microvm-s3files.json @@ -0,0 +1,91 @@ +{ + "title": "AWS Lambda MicroVMs with Amazon S3 Files", + "description": "Mount an Amazon S3 bucket as a POSIX file system inside a Lambda MicroVM, reachable over NFS through a VPC egress network connector.", + "language": "Python", + "level": "300", + "framework": "AWS SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern runs a containerized application inside an AWS Lambda MicroVM — a Firecracker-isolated, snapshot-resumable serverless compute environment — and mounts an Amazon S3 bucket as a local POSIX file system using Amazon S3 Files (NFS 4.2). The application reads and writes files on a mount path and S3 Files synchronizes changes to and from the bucket in both directions, with no GetObject/PutObject plumbing in the code.", + "Because the file system lives in your VPC (not on a public endpoint), the MicroVM reaches the mount target over a VPC egress network connector that provisions elastic network interfaces. The mount itself happens at run time inside the MicroVM's /run lifecycle hook — the network path and execution-role credentials only exist once the MicroVM is running, so they cannot be baked into the image snapshot.", + "CloudFormation provisions the data bucket, the S3 Files file system, mount target, and access point, the VPC egress connector, all IAM roles, and the MicroVM image (AWS::Lambda::MicrovmImage). The MicroVM itself is launched afterwards with the included run.sh helper, since RunMicrovm is a runtime API rather than a CloudFormation resource." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-microvm-s3files", + "templateURL": "serverless-patterns/lambda-microvm-s3files", + "projectFolder": "lambda-microvm-s3files", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda MicroVMs", + "link": "https://docs.aws.amazon.com/lambda/" + }, + { + "text": "Amazon S3 Files", + "link": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files.html" + }, + { + "text": "AWS::Lambda::MicrovmImage CloudFormation resource", + "link": "https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-lambda-microvmimage.html" + } + ] + }, + "deploy": { + "text": [ + "./src/run.sh package ", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the README for full testing instructions (run.sh run / run.sh prove)." + ] + }, + "cleanup": { + "text": [ + "see the README Cleanup section" + ] + }, + "authors": [ + { + "name": "Ben Freiberg", + "image": "https://serverlessland.com/assets/images/contributors/ben-freiberg.jpg", + "bio": "Ben is a Senior Solutions Architect at Amazon Web Services (AWS) based in Frankfurt, Germany.", + "linkedin": "benfreiberg" + } + ], + "patternArch": { + "icon1": { + "x": 20, + "y": 50, + "service": "lambda", + "label": "Lambda MicroVM" + }, + "icon2": { + "x": 50, + "y": 50, + "service": "s3", + "label": "S3 Files" + }, + "icon3": { + "x": 80, + "y": 50, + "service": "s3", + "label": "S3 Bucket" + }, + "line1": { + "from": "icon1", + "to": "icon2" + }, + "line2": { + "from": "icon2", + "to": "icon3" + } + } +} diff --git a/lambda-microvm-s3files/src/Dockerfile b/lambda-microvm-s3files/src/Dockerfile new file mode 100644 index 000000000..907a9e041 --- /dev/null +++ b/lambda-microvm-s3files/src/Dockerfile @@ -0,0 +1,22 @@ +# S3 Files demo image for AWS Lambda MicroVMs. +# +# Amazon Linux 2023 so we can install `amazon-efs-utils` straight from Amazon's +# repos. amazon-efs-utils provides the `mount -t s3files` helper (TLS + IAM) that +# S3 Files requires; nfs-utils provides the underlying NFS 4.2 client. +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 + +# amazon-efs-utils >= 3.0.0 is required for S3 Files. It bundles the efs-proxy +# binary used to establish the TLS-encrypted NFS connection to the mount target. +RUN dnf -y install amazon-efs-utils nfs-utils python3 python3-pip \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +RUN pip3 install --no-cache-dir flask==3.0.3 + +WORKDIR /app +COPY app.py /app/app.py + +# 8080: application traffic (proxied via X-aws-proxy-port). 9000: lifecycle hooks. +EXPOSE 8080 9000 + +CMD ["python3", "-u", "/app/app.py"] diff --git a/lambda-microvm-s3files/src/app.py b/lambda-microvm-s3files/src/app.py new file mode 100644 index 000000000..e23354e80 --- /dev/null +++ b/lambda-microvm-s3files/src/app.py @@ -0,0 +1,520 @@ +""" +Lambda MicroVMs + S3 Files demo application. + +S3 Files (https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files.html) is a +shared NFS 4.2 file system, built on EFS, that presents an S3 bucket (or a prefix of +one) as a POSIX file tree. You read/write files on a local mount path and S3 Files +synchronizes changes to and from the bucket in both directions. + +This app exposes the mounted tree over HTTP so you can prove the round-trip: + + GET / -> status + whether the file system is mounted + GET /files -> list the mount root + GET /files/ -> list a directory, or read a file + PUT /files/ -> write a file (body = contents); syncs to S3 + DELETE /files/ -> delete a file + GET /sync-proof -> write a uniquely-named file and report where it + will appear in S3 (s3:////) + GET /lifecycle -> everything the hooks have recorded so far + +Two HTTP listeners: + - Application: port 8080 (the service the client talks to) + - Lifecycle: port 9000 (hooks the platform invokes) + +The S3 Files mount is NOT part of the snapshot: the VPC egress network connector and +the execution-role credentials are bound at run time, and the NFS connection is killed +across snapshot/suspend. So we mount in the /run hook and re-mount in /resume. See +hook_run() / hook_resume() below. + +Hooks live at POST /aws/lambda-microvms/runtime/v1/{ready,validate,run,resume,suspend,terminate}. +""" +import json +import os +import secrets +import shutil +import socket +import subprocess +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +from flask import Flask, jsonify, request, Response + +# --------------------------------------------------------------------------- +# Configuration. Anything that is the SAME for every MicroVM can be a build-time +# env var (burnt into the snapshot). Anything per-VM (the file system to mount, +# its mount-target IP) arrives in the /run hook payload — never in the snapshot. +# --------------------------------------------------------------------------- +MOUNT_PATH = os.environ.get("S3FILES_MOUNT_PATH", "/mnt/s3files") +HOOK_PATH = "/aws/lambda-microvms/runtime/v1" + +# Populated from the /run (and /resume) hook payload. Live mount status is NOT +# tracked here — _is_mounted() derives it authoritatively from findmnt. +MOUNT = { + "file_system_id": None, + "access_point_id": None, + "mount_target_ip": None, + "region": None, + "bucket": None, + "prefix": None, + "last_error": None, +} + +STATE = { + "boot_time": datetime.now(timezone.utc).isoformat(), + "host": socket.gethostname(), + "instance_id": secrets.token_hex(8), + "request_count": 0, + "lifecycle_events": [], +} + + +def log(msg, **kw): + """Structured log so it shows up cleanly in CloudWatch.""" + print(json.dumps({"ts": datetime.now(timezone.utc).isoformat(), "msg": msg, **kw}), flush=True) + + +def record_event(name, detail=None): + STATE["lifecycle_events"].append( + {"event": name, "at": datetime.now(timezone.utc).isoformat(), "detail": detail} + ) + log(f"lifecycle:{name}", detail=detail) + + +# --------------------------------------------------------------------------- +# S3 Files mount helpers +# --------------------------------------------------------------------------- +def _is_mounted() -> bool: + """True if something is mounted at MOUNT_PATH (survives stale MOUNT dict).""" + try: + out = subprocess.run( + ["findmnt", "-T", MOUNT_PATH, "-n"], + capture_output=True, text=True, timeout=5, + ) + return out.returncode == 0 and bool(out.stdout.strip()) + except Exception: + return False + + +def _mount_usable(timeout: int = 5) -> bool: + """True once the mount actually carries I/O, not just once findmnt sees it. + + amazon-efs-utils registers the mountpoint (so `findmnt` reports it) a few + seconds before the efs-proxy TLS tunnel is fully carrying data. A file written + in that window lands *under* the not-yet-ready mount and never syncs to S3. + Do a write -> read -> delete round trip on the mount (bounded by `timeout` via + subprocess so a stuck tunnel can't hang the run hook) and only treat the mount + as ready once it succeeds. The probe file is a hidden dotfile that is removed + immediately, so it leaves nothing behind in the bucket.""" + probe = f"{MOUNT_PATH}/.s3files-readycheck-{secrets.token_hex(4)}" + try: + res = subprocess.run( + ["sh", "-c", f'echo ready > "{probe}" && cat "{probe}" >/dev/null && rm -f "{probe}"'], + capture_output=True, text=True, timeout=timeout, + ) + return res.returncode == 0 + except Exception: + return False + + +def mount_s3files() -> bool: + """Mount the S3 file system at MOUNT_PATH using the amazon-efs-utils helper. + + Uses `mount -t s3files`. The helper always adds TLS + IAM. We pass the + mount-target IP explicitly (`-o mounttargetip=`) because a MicroVM reaches + the file system over a VPC egress connector — the regional mount-target DNS + name may not resolve from inside the guest, but the AZ-local mount-target IP + always works. An access point scopes us to a sub-directory and pins the + POSIX uid/gid. + """ + fsid = MOUNT["file_system_id"] + if not fsid: + MOUNT["last_error"] = "no file_system_id in run payload" + log("mount_skipped", reason=MOUNT["last_error"]) + return False + + if _is_mounted(): + log("mount_already_present", path=MOUNT_PATH) + return True + + Path(MOUNT_PATH).mkdir(parents=True, exist_ok=True) + + opts = ["tls", "iam"] + if MOUNT.get("access_point_id"): + opts.append(f"accesspoint={MOUNT['access_point_id']}") + if MOUNT.get("mount_target_ip"): + opts.append(f"mounttargetip={MOUNT['mount_target_ip']}") + if MOUNT.get("region"): + opts.append(f"region={MOUNT['region']}") + + cmd = ["mount", "-t", "s3files", "-o", ",".join(opts), f"{fsid}:/", MOUNT_PATH] + log("mount_attempt", cmd=" ".join(cmd)) + try: + # This whole function runs inside the /run (or /resume) hook, which the + # platform bounds by RunTimeoutInSeconds (60, the max). Keep the worst + # case comfortably under that: mount is capped at 35s and the readiness + # probe below at a ~12s deadline (~50s worst case). If either stage runs + # long, we return False and the app reports mounted:false rather than + # letting the hook get killed mid-mount. + res = subprocess.run(cmd, capture_output=True, text=True, timeout=35) + if res.returncode != 0 or not _is_mounted(): + MOUNT["last_error"] = (res.stderr or res.stdout or "mount failed").strip() + log("mount_failed", rc=res.returncode, err=MOUNT["last_error"]) + return False + # The mount is registered, but the efs-proxy TLS tunnel may still be + # coming up. Block until a real I/O round trip succeeds so callers (and + # run.sh) never see mounted=true before the mount can actually carry data + # — otherwise a write issued immediately after run can be lost. Bound by a + # monotonic deadline (not an iteration count) because each probe can hang + # up to its own timeout when the tunnel is not yet carrying I/O; in the + # happy path the very first probe succeeds in well under a second. + probe_deadline = time.monotonic() + 12 + while time.monotonic() < probe_deadline: + if _mount_usable(timeout=3): + MOUNT["last_error"] = None + log("mount_ok", path=MOUNT_PATH) + return True + time.sleep(1) + MOUNT["last_error"] = "mount registered but not usable (efs-proxy tunnel not ready)" + log("mount_not_usable", err=MOUNT["last_error"]) + return False + except Exception as e: # noqa: BLE001 + MOUNT["last_error"] = str(e) + log("mount_exception", err=str(e)) + return False + + +def _apply_run_payload(raw: str): + """Parse the /run (or /resume) hook body and pull out mount parameters. + + The platform wraps our payload in an envelope: + { "microvmId": "...", "runHookPayload": } + where `runHookPayload` is the opaque blob passed to RunMicrovm. Its value may + arrive as a JSON string or as an already-parsed object. We unwrap that key + (or the alternate `runPayload`), and also accept a bare, unwrapped payload. + """ + if not raw: + return + try: + env = json.loads(raw) + except Exception: + log("run_payload_not_json", sample=raw[:200]) + return + inner = env + if isinstance(env, dict): + val = next((env[k] for k in ("runHookPayload", "runPayload") if k in env), env) + if isinstance(val, dict): + inner = val + elif isinstance(val, str): + try: + inner = json.loads(val) + except Exception: + pass + if not isinstance(inner, dict): + return + for k_src, k_dst in ( + ("fileSystemId", "file_system_id"), + ("accessPointId", "access_point_id"), + ("mountTargetIp", "mount_target_ip"), + ("region", "region"), + ("bucket", "bucket"), + ("prefix", "prefix"), + ): + if inner.get(k_src): + MOUNT[k_dst] = inner[k_src] + + +# --------------------------------------------------------------------------- +# Path safety: keep every file operation inside MOUNT_PATH. +# --------------------------------------------------------------------------- +def _safe_target(rel: str) -> Path: + base = Path(MOUNT_PATH).resolve() + target = (base / rel.lstrip("/")).resolve() + if target != base and base not in target.parents: + raise ValueError("path escapes the mount root") + return target + + +# --------------------------------------------------------------------------- +# Application server (port 8080) +# --------------------------------------------------------------------------- +app = Flask("s3files-demo") + + +@app.get("/") +def index(): + STATE["request_count"] += 1 + return jsonify( + { + "service": "lambda-microvms-s3files-demo", + "message": "S3 bucket mounted as a file system inside a Firecracker microVM", + "instance_id": STATE["instance_id"], + "host": STATE["host"], + "boot_time": STATE["boot_time"], + "now": datetime.now(timezone.utc).isoformat(), + "request_count": STATE["request_count"], + "python": sys.version.split()[0], + "mount": { + "path": MOUNT_PATH, + "mounted": _is_mounted(), + "file_system_id": MOUNT["file_system_id"], + "access_point_id": MOUNT["access_point_id"], + "bucket": MOUNT["bucket"], + "prefix": MOUNT["prefix"], + "last_error": MOUNT["last_error"], + }, + "endpoints": ["/files", "/files/", "/sync-proof", "/lifecycle"], + } + ) + + +def _require_mount(): + if not _is_mounted(): + return ( + jsonify( + { + "error": "s3files not mounted", + "hint": "the /run hook mounts the file system; check /lifecycle and CloudWatch", + "last_error": MOUNT["last_error"], + } + ), + 503, + ) + return None + + +@app.get("/files") +@app.get("/files/") +@app.get("/files/") +def get_files(rel=""): + """List a directory, or stream a file if `rel` points at one.""" + guard = _require_mount() + if guard: + return guard + try: + target = _safe_target(rel) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + if not target.exists(): + return jsonify({"error": "not found", "path": f"/{rel}"}), 404 + + if target.is_dir(): + entries = [] + for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name)): + st = child.stat() + entries.append( + { + "name": child.name + ("/" if child.is_dir() else ""), + "type": "dir" if child.is_dir() else "file", + "size": st.st_size if child.is_file() else None, + "modified": datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(), + } + ) + return jsonify({"path": "/" + rel.strip("/"), "entries": entries}) + + # File: stream it back. Large reads are served straight from S3 by S3 Files. + data = target.read_bytes() + return Response(data, mimetype="application/octet-stream") + + +@app.put("/files/") +def put_file(rel): + """Write a file. The bytes land on the high-performance storage and S3 Files + exports them to the bucket (typically within ~1 minute).""" + guard = _require_mount() + if guard: + return guard + try: + target = _safe_target(rel) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + target.parent.mkdir(parents=True, exist_ok=True) + body = request.get_data() + target.write_bytes(body) + st = target.stat() + log("file_written", path=f"/{rel}", bytes=st.st_size) + return ( + jsonify( + { + "written": "/" + rel, + "bytes": st.st_size, + "syncs_to_s3": _s3_uri(rel), + "note": "S3 Files exports changes to the bucket asynchronously (~1 min).", + } + ), + 201, + ) + + +@app.delete("/files/") +def delete_file(rel): + guard = _require_mount() + if guard: + return guard + try: + target = _safe_target(rel) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + if not target.exists(): + return jsonify({"error": "not found"}), 404 + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + log("file_deleted", path=f"/{rel}") + return jsonify({"deleted": "/" + rel}) + + +def _s3_uri(rel: str) -> str: + if not MOUNT["bucket"]: + return "(set bucket in run payload to compute the s3:// path)" + parts = [MOUNT["bucket"]] + if MOUNT["prefix"]: + parts.append(MOUNT["prefix"].strip("/")) + parts.append(rel.strip("/")) + return "s3://" + "/".join(p for p in parts if p) + + +@app.get("/sync-proof") +def sync_proof(): + """Write a uniquely-named file through the mount and report where it will + appear in S3. Poll the bucket (or `aws s3 ls`) to watch it sync out.""" + guard = _require_mount() + if guard: + return guard + name = f"sync-proof/{STATE['instance_id']}-{secrets.token_hex(4)}.txt" + target = _safe_target(name) + target.parent.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).isoformat() + target.write_text(f"written by microVM {STATE['instance_id']} at {stamp}\n") + log("sync_proof_written", path=f"/{name}") + return jsonify( + { + "wrote_file": "/" + name, + "on_mount": f"{MOUNT_PATH}/{name}", + "will_appear_in_s3_at": _s3_uri(name), + "verify": f"aws s3 ls {_s3_uri(name)}", + "written_at": stamp, + } + ) + + +@app.get("/lifecycle") +def lifecycle(): + return jsonify( + { + "instance_id": STATE["instance_id"], + "mount": {**MOUNT, "mounted": _is_mounted()}, + "events": STATE["lifecycle_events"], + } + ) + + +# --------------------------------------------------------------------------- +# Lifecycle hooks server (port 9000) +# --------------------------------------------------------------------------- +hooks = Flask("s3files-hooks") +_app_ready = threading.Event() + + +def _simulate_warmup(): + """Pretend the app has some non-trivial init before it's snapshot-ready.""" + time.sleep(1.5) + _app_ready.set() + log("warmup_complete") + + +@hooks.post(f"{HOOK_PATH}/ready") +def hook_ready(): + """Image build: 200 = snapshot-ready, 503 = retry. We do NOT mount here — + the network connector and credentials don't exist at build time.""" + if _app_ready.is_set(): + record_event("ready") + return jsonify({"status": "ready"}), 200 + return jsonify({"status": "warming_up"}), 503 + + +@hooks.post(f"{HOOK_PATH}/validate") +def hook_validate(): + """Post-snapshot validation. Exercise the app so the platform can sample + snapshot pages for prefetch. The mount isn't available here, so we only + validate the HTTP surface.""" + try: + with app.test_client() as c: + ok = c.get("/").status_code == 200 + record_event("validate", detail={"ok": ok}) + return jsonify({"status": "valid" if ok else "invalid"}), 200 if ok else 503 + except Exception as e: # noqa: BLE001 + log("validate_error", error=str(e)) + return jsonify({"error": str(e)}), 503 + + +@hooks.post(f"{HOOK_PATH}/run") +def hook_run(): + """Fires once after run from snapshot. This is where we (a) reseed per-VM + identity and (b) mount the S3 file system, using parameters from the run + payload. Both the connector and the execution-role credentials are now live.""" + STATE["instance_id"] = secrets.token_hex(8) + STATE["boot_time"] = datetime.now(timezone.utc).isoformat() + raw = request.get_data(as_text=True) or "" + _apply_run_payload(raw) + mounted = mount_s3files() + record_event( + "run", + detail={ + "payload_bytes": len(raw), + "file_system_id": MOUNT["file_system_id"], + "mounted": mounted, + }, + ) + return jsonify({"status": "ok", "mounted": mounted, "instance_id": STATE["instance_id"]}), 200 + + +@hooks.post(f"{HOOK_PATH}/resume") +def hook_resume(): + """Fires after SUSPENDED -> RUNNING. The NFS connection was torn down on + suspend, so re-mount. Reseed identity entropy too.""" + STATE["instance_id"] = secrets.token_hex(8) + mounted = mount_s3files() + record_event("resume", detail={"mounted": mounted, "instance_id": STATE["instance_id"]}) + return jsonify({"status": "ok", "mounted": mounted}), 200 + + +@hooks.post(f"{HOOK_PATH}/suspend") +def hook_suspend(): + """Fires before RUNNING -> SUSPENDED. Acknowledge fast (1-60s timeout).""" + record_event("suspend") + return jsonify({"status": "ok"}), 200 + + +@hooks.post(f"{HOOK_PATH}/terminate") +def hook_terminate(): + """Final flush opportunity before the VM goes away.""" + record_event("terminate") + return jsonify({"status": "ok"}), 200 + + +@hooks.get("/health") +def hook_health(): + return jsonify({"app_ready": _app_ready.is_set(), "mounted": _is_mounted()}), 200 + + +def _run_app(): + app.run(host="0.0.0.0", port=int(os.environ.get("APP_PORT", "8080")), + threaded=True, use_reloader=False) + + +def _run_hooks(): + hooks.run(host="0.0.0.0", port=int(os.environ.get("HOOKS_PORT", "9000")), + threaded=True, use_reloader=False) + + +if __name__ == "__main__": + log("startup", pid=os.getpid(), mount_path=MOUNT_PATH) + threading.Thread(target=_simulate_warmup, daemon=True).start() + threading.Thread(target=_run_hooks, daemon=True).start() + _run_app() diff --git a/lambda-microvm-s3files/src/run.sh b/lambda-microvm-s3files/src/run.sh new file mode 100755 index 000000000..3f0b504c2 --- /dev/null +++ b/lambda-microvm-s3files/src/run.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Data-plane helper for the Lambda MicroVMs + S3 Files pattern. +# +# CloudFormation (template.yaml) provisions the image, the S3 Files file system / +# mount target / access point, the VPC egress connector, and all IAM roles. The +# two things CloudFormation can NOT do are runtime/data-plane operations: +# +# package — zip src/ and upload it to the artifact bucket. The image build +# reads this zip, so it must exist BEFORE `sam deploy`. +# run — RunMicrovm + mint an auth token + exercise the app. RunMicrovm is +# a runtime API (like invoking a Lambda), not a CloudFormation resource. +# +# Usage: +# ./src/run.sh package [key] # before sam deploy +# ./src/run.sh run # after sam deploy +# ./src/run.sh prove # write a file, watch it sync to S3 +# ./src/run.sh terminate # terminate the running MicroVM +# +# REGION defaults to us-west-2 (override with REGION=...). Credentials come from +# the usual AWS chain (AWS_PROFILE / env / instance role). +set -euo pipefail + +: "${REGION:=us-west-2}" +: "${APP_PORT:=8080}" +SRC_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_FILE="${SRC_DIR}/../.run-state.${REGION}.env" + +log() { printf '\033[1;36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# Load ALL of a stack's outputs into shell vars OUT_ in one +# describe-stacks call (instead of one round-trip per output). +load_stack_outputs() { + local kv + kv=$(aws cloudformation describe-stacks --stack-name "$1" --region "$REGION" \ + --query "Stacks[0].Outputs[].[OutputKey,OutputValue]" --output text) + [[ -n "$kv" ]] || die "stack '$1' not found or has no outputs" + while IFS=$'\t' read -r key val; do + [[ -n "$key" ]] && eval "OUT_${key}=\$val" + done <<< "$kv" +} + +# curl an endpoint with the proxy auth headers, retrying until HTTP 200. +# Usage: wait_for_200 [tries] [sleep] [max-time] +# Echoes the last HTTP code; returns non-zero if 200 was never seen. +wait_for_200() { + local url=$1 token=$2 out=$3 tries=${4:-40} slp=${5:-2} mt=${6:-5} i code=000 + for ((i=0; i/dev/null || true) + if [[ -n "$body" ]]; then + mounted=$(printf '%s' "$body" | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +runs = [e for e in d.get("events", []) if e.get("event") == "run"] +if runs: + print("true" if (runs[-1].get("detail") or {}).get("mounted") else "false") +' 2>/dev/null || true) + [[ "$mounted" == "true" ]] && return 0 + [[ "$mounted" == "false" ]] && return 1 + fi + sleep "$slp" + done + return 2 +} + +# ── package: zip src/ and upload to the artifact bucket ────────────────────── +package() { + local bucket="${1:?usage: run.sh package [key]}" key="${2:-app.zip}" + [[ -f "$SRC_DIR/Dockerfile" && -f "$SRC_DIR/app.py" ]] \ + || die "Dockerfile and app.py must exist in $SRC_DIR" + # Create the artifact bucket if it is missing (must be in REGION). + if ! aws s3api head-bucket --bucket "$bucket" --region "$REGION" 2>/dev/null; then + log "creating artifact bucket $bucket ($REGION)" + if [[ "$REGION" == "us-east-1" ]]; then + aws s3api create-bucket --bucket "$bucket" --region "$REGION" >/dev/null + else + aws s3api create-bucket --bucket "$bucket" --region "$REGION" \ + --create-bucket-configuration "LocationConstraint=${REGION}" >/dev/null + fi + fi + local zip; zip="$(mktemp -t s3files-app).zip" + ( cd "$SRC_DIR" && zip -q "$zip" Dockerfile app.py ) + aws s3 cp "$zip" "s3://${bucket}/${key}" --region "$REGION" >/dev/null + rm -f "$zip" + log "uploaded → s3://${bucket}/${key}" + log "now: sam deploy --guided (CodeArtifactBucket=${bucket} CodeArtifactKey=${key})" +} + +# ── run: launch a MicroVM from the stack's image and mount S3 Files ────────── +run() { + local stack="${1:?usage: run.sh run }" + load_stack_outputs "$stack" + [[ -n "${OUT_ImageArn:-}" && "$OUT_ImageArn" != "None" ]] \ + || die "stack '$stack' has no ImageArn output" + + # The MicroVM mounts by mount-target IP (regional mount DNS may not resolve over + # the connector). Look the IPv4 up from the mount target id. + local mt_ip + mt_ip=$(aws s3files get-mount-target --mount-target-id "$OUT_MountTargetId" --region "$REGION" \ + --query 'ipv4Address' --output text 2>/dev/null || echo "") + [[ -n "$mt_ip" && "$mt_ip" != "None" ]] \ + || die "could not resolve mount-target IP for $OUT_MountTargetId (is the file system AVAILABLE?)" + + # The access point roots the mount at a sub-directory (e.g. /microvm), so files + # written on the mount land under that prefix in the bucket. Look the path up and + # pass it so the app reports correct s3:// locations (bucket + AP prefix + file). + local ap_path + ap_path=$(aws s3files get-access-point --access-point-id "$OUT_AccessPointId" --region "$REGION" \ + --query 'rootDirectory.path' --output text 2>/dev/null || echo "") + [[ "$ap_path" == "None" ]] && ap_path="" + + local payload + payload=$(python3 - "$OUT_FileSystemId" "$OUT_AccessPointId" "$mt_ip" "$REGION" "$OUT_DataBucket" "$ap_path" <<'PY' +import json, sys +fsid, apid, mt_ip, region, bucket, ap_path = sys.argv[1:7] +payload = {"fileSystemId": fsid, "accessPointId": apid, + "mountTargetIp": mt_ip, "region": region, "bucket": bucket} +# The access-point root directory becomes the bucket-side prefix for synced files. +prefix = ap_path.strip("/") +if prefix: + payload["prefix"] = prefix +print(json.dumps(payload)) +PY +) + log "running MicroVM from $OUT_ImageArn" + local out mvm_id endpoint + out=$(aws lambda-microvms run-microvm --region "$REGION" \ + --image-identifier "$OUT_ImageArn" \ + --execution-role-arn "$OUT_ExecutionRoleArn" \ + --egress-network-connectors "[\"${OUT_EgressConnectorArn}\"]" \ + --idle-policy '{"maxIdleDurationSeconds":900,"suspendedDurationSeconds":600,"autoResumeEnabled":true}' \ + --maximum-duration-in-seconds 3600 \ + --run-hook-payload "$payload") + # Pull both fields in one python invocation. + read -r mvm_id endpoint < <(echo "$out" | python3 -c \ + "import sys,json;d=json.load(sys.stdin);print(d['microvmId'],d['endpoint'])") + # Persist DATA_BUCKET too so prove/terminate need no further describe-stacks call. + printf 'MVM_ID=%s\nMVM_ENDPOINT=%s\nDATA_BUCKET=%s\n' \ + "$mvm_id" "$endpoint" "$OUT_DataBucket" > "$STATE_FILE" + log "microvmId=$mvm_id" + log "endpoint=$endpoint" + + local token; token=$(mint_token "$mvm_id") + log "polling app until first 200…" + wait_for_200 "https://${endpoint}/" "$token" /dev/null 40 2 5 >/dev/null || true + # Do NOT return on the first 200: the app answers before the /run hook has + # finished mounting. Wait for the mount to be usable so a following `prove` + # (or any write) does not race the efs-proxy tunnel and silently lose data. + log "waiting for the S3 Files mount to become usable…" + if wait_for_mount "$endpoint" "$token" 40 3; then + log "mount ready" + else + log "WARNING: mount did not report ready — check /lifecycle and CloudWatch (last GET / below)" + fi + log "GET / →" + curl -sS "https://${endpoint}/" -H "X-aws-proxy-auth: $token" \ + -H "X-aws-proxy-port: ${APP_PORT}" | python3 -m json.tool +} + +mint_token() { + aws lambda-microvms create-microvm-auth-token --region "$REGION" \ + --microvm-identifier "$1" --expiration-in-minutes 30 \ + --allowed-ports "[{\"port\":${APP_PORT}}]" \ + --query 'authToken."X-aws-proxy-auth"' --output text +} + +# ── prove: write a file through the mount and watch it sync to S3 ──────────── +prove() { + local stack="${1:?usage: run.sh prove }" + [[ -f "$STATE_FILE" ]] || die "run './src/run.sh run $stack' first" + # shellcheck disable=SC1090 + source "$STATE_FILE" # provides MVM_ID, MVM_ENDPOINT, DATA_BUCKET + local token; token=$(mint_token "$MVM_ID") + + # The VM may have idle-suspended; the first request auto-resumes it and can + # cold-path time out. Retry /sync-proof until it returns a 200 body. + local body code + body=$(mktemp); trap 'rm -f "$body"' RETURN + log "GET /sync-proof (writes a file through the mount) →" + code=$(wait_for_200 "https://${MVM_ENDPOINT}/sync-proof" "$token" "$body" 20 3 15) \ + || die "/sync-proof did not return 200 (last code: ${code})" + python3 -m json.tool < "$body" + + # Poll for the EXACT s3:// object this call just wrote (from the response's + # will_appear_in_s3_at), not merely a non-empty prefix — otherwise a re-run + # would "pass" instantly on a file left by a previous run. + local s3_uri + s3_uri=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("will_appear_in_s3_at",""))' "$body" 2>/dev/null || true) + [[ "$s3_uri" == s3://* ]] || die "could not determine the written object's S3 URI from the response" + + log "polling ${s3_uri} for the synced file (S3 export is async)…" + local i + for i in $(seq 1 24); do + if aws s3 ls "$s3_uri" --region "$REGION" 2>/dev/null | grep -q .; then + log "synced to S3:"; aws s3 ls "$s3_uri" --region "$REGION"; return 0 + fi + sleep 5 + done + log "not visible yet — sync can lag; re-check: aws s3 ls ${s3_uri}" +} + +# ── terminate ──────────────────────────────────────────────────────────────── +terminate() { + local stack="${1:?usage: run.sh terminate }" + [[ -f "$STATE_FILE" ]] || die "no run state for region $REGION" + # shellcheck disable=SC1090 + source "$STATE_FILE" + log "terminating $MVM_ID" + aws lambda-microvms terminate-microvm --microvm-identifier "$MVM_ID" --region "$REGION" >/dev/null + rm -f "$STATE_FILE" + log "terminated" +} + +cmd="${1:-}"; shift || true +case "$cmd" in + package) package "$@" ;; + run) run "$@" ;; + prove) prove "$@" ;; + terminate) terminate "$@" ;; + *) die "usage: run.sh {package [key] | run | prove | terminate }" ;; +esac diff --git a/lambda-microvm-s3files/template.yaml b/lambda-microvm-s3files/template.yaml new file mode 100644 index 000000000..20d1fa844 --- /dev/null +++ b/lambda-microvm-s3files/template.yaml @@ -0,0 +1,416 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: >- + Lambda MicroVMs + S3 Files: a Firecracker-isolated MicroVM image that mounts an + Amazon S3 bucket as a POSIX file system over NFS, reachable through a VPC egress + network connector. Provisions everything that has a CloudFormation resource; the + MicroVM is launched afterwards with src/run.sh (RunMicrovm is a runtime API). + +# ───────────────────────────────────────────────────────────────────────────── +# If cfn-lint flags E3006 ("type does not exist in ") for the +# AWS::Lambda::MicrovmImage / AWS::Lambda::NetworkConnector / AWS::S3Files::* +# resources, or W3037 for the `s3files:` IAM action prefix, those are false +# positives from an out-of-date bundled resource spec. They are suppressed below; +# remove the suppression once cfn-lint ships an updated spec. +# +# IAM propagation: the build/execution/S3-Files-access roles are created in this +# same stack. Newly created roles can take a few seconds to become assumable by +# the consuming service. If the MicrovmImage build or the FileSystem create fails +# with "unable to assume the role provided", simply update/redeploy the stack — +# the roles will have propagated by then. +# ───────────────────────────────────────────────────────────────────────────── + +Metadata: + cfn-lint: + config: + ignore_checks: + - E3006 # resource types not yet in cfn-lint's bundled spec + - W3037 # 's3files' IAM action prefix not yet in cfn-lint's service list + +Parameters: + CodeArtifactBucket: + Type: String + Description: >- + Name of an EXISTING S3 bucket (same region as this stack) holding the app + zip (Dockerfile + app.py at the root). Upload it before deploying — see + README. This is NOT the bucket that gets mounted. + CodeArtifactKey: + Type: String + Default: app.zip + Description: Key of the app zip within CodeArtifactBucket. + + VpcId: + Type: AWS::EC2::VPC::Id + Description: VPC for the NFS security group, mount target, and egress connector. + SubnetId: + Type: AWS::EC2::Subnet::Id + Description: >- + Subnet (in VpcId) for the S3 Files mount target and the connector ENIs. + The MicroVM reaches the mount target over the connector within this VPC. + + ImageName: + Type: String + Default: microvm-s3files-pattern + AllowedPattern: "^[a-zA-Z0-9-_]+$" + Description: Name of the MicroVM image (changing it replaces the image). + + BaseImageName: + Type: String + Default: al2023-1 + Description: >- + Managed base MicroVM image name. The ARN is built as + arn:aws:lambda::aws:microvm-image:. Availability is + region-specific (e.g. us-west-2 offers al2023-1). + BaseImageVersion: + Type: String + Default: "0" + Description: >- + Major version of the managed base image. The MicrovmImage resource expects a + single major version number (e.g. "0"); the service normalizes it to "0.0". + + MinimumMemoryInMiB: + Type: Number + Default: 2048 + Description: Minimum memory allocated to the MicroVM. + + MountPath: + Type: String + Default: /mnt/s3files + Description: Where the file system is mounted inside the guest (read by app.py). + +Resources: + # ── Data source ──────────────────────────────────────────────────────────── + # The bucket S3 Files mounts. Versioning is REQUIRED by S3 Files. + DataBucket: + Type: AWS::S3::Bucket + Properties: + # Versioning is REQUIRED by S3 Files. (SSE-S3/AES256 is already the S3 + # default, so no explicit BucketEncryption block is needed.) + VersioningConfiguration: + Status: Enabled + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + + # Role S3 Files assumes to read/write the bucket and manage its sync rules. + S3FilesAccessRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: elasticfilesystem.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + ArnLike: + aws:SourceArn: !Sub arn:${AWS::Partition}:s3files:${AWS::Region}:${AWS::AccountId}:file-system/* + Policies: + - PolicyName: S3FilesBucketAccess + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: S3BucketPermissions + Effect: Allow + Action: [s3:ListBucket, s3:ListBucketVersions] + Resource: !GetAtt DataBucket.Arn + Condition: + StringEquals: + aws:ResourceAccount: !Ref AWS::AccountId + - Sid: S3ObjectPermissions + Effect: Allow + Action: + - s3:AbortMultipartUpload + - s3:DeleteObject* + - s3:GetObject* + - s3:List* + - s3:PutObject* + Resource: !Sub ${DataBucket.Arn}/* + Condition: + StringEquals: + aws:ResourceAccount: !Ref AWS::AccountId + - Sid: EventBridgeManage + Effect: Allow + Action: + - events:DeleteRule + - events:DisableRule + - events:EnableRule + - events:PutRule + - events:PutTargets + - events:RemoveTargets + Condition: + StringEquals: + events:ManagedBy: elasticfilesystem.amazonaws.com + Resource: !Sub arn:${AWS::Partition}:events:*:*:rule/DO-NOT-DELETE-S3-Files* + - Sid: EventBridgeRead + Effect: Allow + Action: + - events:DescribeRule + - events:ListRuleNamesByTarget + - events:ListRules + - events:ListTargetsByRule + Resource: !Sub arn:${AWS::Partition}:events:*:*:rule/* + + # ── S3 Files file system + network access ──────────────────────────────────── + FileSystem: + Type: AWS::S3Files::FileSystem + Properties: + Bucket: !GetAtt DataBucket.Arn + RoleArn: !GetAtt S3FilesAccessRole.Arn + AcceptBucketWarning: true + Tags: + - Key: Project + Value: serverless-pattern-lambda-microvm-s3files + + # Self-referencing SG allowing NFS (2049) within itself. Attached to BOTH the + # mount target and the egress connector ENIs so the MicroVM can reach the mount. + NfsSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: S3 Files NFS 2049 for the MicroVM egress connector. + VpcId: !Ref VpcId + NfsSelfIngress: + Type: AWS::EC2::SecurityGroupIngress + Properties: + GroupId: !Ref NfsSecurityGroup + IpProtocol: tcp + FromPort: 2049 + ToPort: 2049 + SourceSecurityGroupId: !Ref NfsSecurityGroup + + MountTarget: + Type: AWS::S3Files::MountTarget + Properties: + FileSystemId: !GetAtt FileSystem.FileSystemId + SubnetId: !Ref SubnetId + IpAddressType: IPV4_ONLY + SecurityGroups: + - !Ref NfsSecurityGroup + + # Access point scoped to /microvm with uid/gid 1000 (matches the app's mount). + AccessPoint: + Type: AWS::S3Files::AccessPoint + Properties: + FileSystemId: !GetAtt FileSystem.FileSystemId + PosixUser: + Uid: "1000" + Gid: "1000" + RootDirectory: + Path: /microvm + CreationPermissions: + OwnerUid: "1000" + OwnerGid: "1000" + Permissions: "0755" + + # ── VPC egress network connector ───────────────────────────────────────────── + # Role Lambda assumes to manage the connector's ENIs in your VPC. + ConnectorOperatorRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: [sts:AssumeRole, sts:TagSession] + Policies: + - PolicyName: NetworkConnectorEni + PolicyDocument: + Version: "2012-10-17" + Statement: + # Scope ENI creation to THIS subnet and security group (i.e. to the + # VPC you deployed into) by listing the exact subnet/SG ARNs the + # connector is allowed to create interfaces in. The connector cannot + # place ENIs in any other subnet or attach any other SG. + # + # NOTE: scoping via the `ec2:Vpc` condition key (the intuitive choice) + # does NOT work here — CreateNetworkConnector pre-validates this role + # WITHOUT the VPC request context, so a condition on ec2:Vpc evaluates + # false and the connector fails to create with "invalid + # ConnectorOperatorRole permissions". Resource-ARN scoping is validated + # correctly and provisions the ENIs. See README "How it works". + - Sid: CreateEniInThisSubnet + Effect: Allow + Action: ec2:CreateNetworkInterface + Resource: + - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/* + - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${SubnetId} + - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:security-group/${NfsSecurityGroup.GroupId} + # Delete/tag apply to the ENIs the connector manages (ENI IDs are + # created dynamically and are unpredictable, so the resource is the + # network-interface wildcard within this account/region). + - Sid: ManageConnectorEnis + Effect: Allow + Action: + - ec2:DeleteNetworkInterface + - ec2:CreateTags + Resource: !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/* + # ec2:Describe* actions do NOT support resource-level scoping — they + # must use "*". They are read-only. + - Sid: DescribeNetworkResources + Effect: Allow + Action: + - ec2:DescribeNetworkInterfaces + - ec2:DescribeSubnets + - ec2:DescribeSecurityGroups + - ec2:DescribeVpcs + Resource: "*" + + EgressConnector: + Type: AWS::Lambda::NetworkConnector + Properties: + Name: !Sub ${ImageName}-egress + OperatorRole: !GetAtt ConnectorOperatorRole.Arn + Configuration: + VpcEgressConfiguration: + SubnetIds: + - !Ref SubnetId + SecurityGroupIds: + - !Ref NfsSecurityGroup + NetworkProtocol: IPv4 + AssociatedComputeResourceTypes: + - MicroVm + Tags: + - Key: Project + Value: serverless-pattern-lambda-microvm-s3files + + # ── MicroVM image build ────────────────────────────────────────────────────── + MicrovmLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda-microvms/${ImageName} + RetentionInDays: 7 + + # One IAM role used BOTH to build the image (read the artifact zip, ship build + # logs) and as the MicroVM's run-time execution role (exposed to the guest via + # IMDSv2 for the IAM-authenticated S3 Files mount). A single role keeps the demo + # simple; scope into two roles for least privilege in production. + # + # NOTE: the assume-role source ARN is the IMAGE (microvm-image:*) at build AND + # at run time — the running VM also presents the image ARN, NOT microvm:* — so + # the confused-deputy condition must allow BOTH. Restricting to microvm:* alone + # makes the VM fail to assume the role and self-terminate with "unable to assume + # the role provided". + MicrovmRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + ArnLike: + aws:SourceArn: + - !Sub arn:${AWS::Partition}:lambda:*:${AWS::AccountId}:microvm-image:* + - !Sub arn:${AWS::Partition}:lambda:*:${AWS::AccountId}:microvm:* + Policies: + - PolicyName: MicrovmBuildAndExecution + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: ReadCodeArtifact + Effect: Allow + Action: s3:GetObject + Resource: !Sub arn:${AWS::Partition}:s3:::${CodeArtifactBucket}/${CodeArtifactKey} + - Sid: WriteLogs + Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda-microvms/* + - Sid: MountS3Files + Effect: Allow + # Client permissions for the IAM-authenticated S3 Files NFS mount. + Action: s3files:Client* + Resource: !GetAtt FileSystem.FileSystemArn + + MicrovmImage: + Type: AWS::Lambda::MicrovmImage + Properties: + Name: !Ref ImageName + Description: Lambda MicroVMs + S3 Files pattern — S3 bucket mounted as a filesystem. + BaseImageArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:aws:microvm-image:${BaseImageName} + BaseImageVersion: !Ref BaseImageVersion + BuildRoleArn: !GetAtt MicrovmRole.Arn + CodeArtifact: + Uri: !Sub s3://${CodeArtifactBucket}/${CodeArtifactKey} + CpuConfigurations: + - Architecture: ARM_64 + Resources: + - MinimumMemoryInMiB: !Ref MinimumMemoryInMiB + # Required for the NFS mount (EFS utils / FUSE). + AdditionalOsCapabilities: + - ALL + # Build-time egress: INTERNET_EGRESS lets the Dockerfile install packages. + # The VPC egress connector (above) is attached at RUN time by src/run.sh. + EgressNetworkConnectors: + - !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:aws:network-connector:aws-network-connector:INTERNET_EGRESS + EnvironmentVariables: + - Key: S3FILES_MOUNT_PATH + Value: !Ref MountPath + Hooks: + Port: 9000 + MicrovmImageHooks: + Ready: ENABLED + ReadyTimeoutInSeconds: 60 + Validate: ENABLED + ValidateTimeoutInSeconds: 30 + MicrovmHooks: + Run: ENABLED + # The /run hook mounts S3 Files and blocks until the mount is usable + # (mount ~15s + efs-proxy tunnel readiness probe). app.py bounds its own + # work to ~51s worst case (35s mount cap + ~16s probe deadline), so give + # the hook the max 60s. Same for /resume, which re-mounts after a suspend. + RunTimeoutInSeconds: 60 + Resume: ENABLED + ResumeTimeoutInSeconds: 60 + Suspend: ENABLED + SuspendTimeoutInSeconds: 5 + Terminate: ENABLED + TerminateTimeoutInSeconds: 5 + Logging: + CloudWatch: + LogGroup: !Ref MicrovmLogGroup + Tags: + - Key: Project + Value: serverless-pattern-lambda-microvm-s3files + +Outputs: + ImageArn: + Description: ARN of the built MicroVM image (pass to src/run.sh). + Value: !GetAtt MicrovmImage.ImageArn + ImageState: + Description: Image state (CREATING / CREATED / CREATE_FAILED). + Value: !GetAtt MicrovmImage.State + ExecutionRoleArn: + Description: Role the MicroVM assumes at run time (also the image build role). + Value: !GetAtt MicrovmRole.Arn + EgressConnectorArn: + Description: VPC egress connector ARN — attach at run time so the VM reaches the mount. + Value: !GetAtt EgressConnector.Arn + FileSystemId: + Description: S3 Files file system ID (run-hook payload). + Value: !GetAtt FileSystem.FileSystemId + AccessPointId: + Description: S3 Files access point ID (run-hook payload). + Value: !GetAtt AccessPoint.AccessPointId + MountTargetId: + Description: Mount target ID — src/run.sh looks up its IPv4 for the run-hook payload. + Value: !GetAtt MountTarget.MountTargetId + DataBucket: + Description: Bucket mounted by the MicroVM as a file system. + Value: !Ref DataBucket + Region: + Description: Region everything is deployed in. + Value: !Ref AWS::Region