DIY Build Server with AWS Lambda MicroVMs and S3 Files
Ever since AWS announced AWS Lambda MicroVMs I was thinking of use cases where Serverless stateful Docker based virtual machines can demonstrate a use case that goes past a very basic and artificial “Hello World” where we run an application that proves how subsequent invocations keep local state. Inspired by performance problems with build pipelines from current and previous real world projects, a recurring issue that requires resources on demand and benefits from keeping state in between invocations is a Build Server that both allows users to spin up instances on demand (and to scale those instances down when not needed, e.g. over night) and that the same instances will keep build artefacts such as dependencies between runs to optimise runtime whilst at the same time don’t require users to handle the case of a brand new instance spun up as the build tool will take care of transparently downloading dependencies if they aren’t available at the time of build.
The setup will be that we will be storing two “Hello World” applications (one written in Golang to test cross-compilation and one written in Java built with Maven to test the caching of dependencies on the MicroVM between runs) in S3, exposing it via S3 Files to a AWS Lambda MicroVM that on invoke will build either application on the MicroVM and writing the resulting artefacts back onto S3.
Setup
Bucket and Filesystem
For this I am reusing the Stack defined in my article on S3 Files and AWS Lambda. So that afterwards we will have a new, empty Bucket with a Filesystem, Mount Targets and Security Group.
If we would now deploy an EC2 instance into one of the new Subnets we could mount the Filesystem and access it to prove the setup works so far. In case you do that, don’t forget to allow your EC2 instance’s Security Group access to the Mount Target’s Security Group.
MicroVM Image
I am generally using default values for the new MicroVM image, only to ensure the “Enable all operating system capabilities” flag is active. The ZIP archive we are using as the basis for the MicroVM image consists of five files: Dockerfile, three shell scripts (to mount S3 Files, build Go and build Java) and the application itself that will be running on the MicroVM. I’ve added all files in an appendix below, create a new archive containing those five files, upload it to an S3 Bucket and point the MicroVM’s “S3 destination” to it.
Create the new MicroVM image and check the logs for potential errors, the build might take several minutes. Afterwards, the state should be “Successful”.
Demo
Before we can run the MicroVM itself we both need a new IAM role and a File Connector allow the MicroVM to access S3 Files.
The IAM role needs to be manually created in the IAM section and have the relevant permissions to access S3 (I gave the role temporarily AdministratorAccess to avoid too much time spent on this) and to trust the Lambda service
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}The File Connector can be created from the MicroVM creation screen, sitting in the same Subnets as we used for the File System above and its Security Group needs to be able to access the File System’s Security Group. The remaining settings can be left at defaults.
Now we can run the MicroVM with the image we built in the last step, using the role we just created and making sure the “Egress network interface” is set to the File Connector we just created. The MicroVM should come up almost immediately.
To demo, we need to copy the example projects into the Bucket, I chose to put both into separate ‘folders’, so that the Bucket has a ‘go’ and a ‘java’ root folder now, containing the respective example. I’ve added the example files I used in the appendix below again.
Get Johannes Geiger’s stories in your inbox
Join Medium for free to get updates from this writer.
Finally, we can now invoke the MicroVM, in my case I did that manually from my local machine with JavaScript (replace the endpoint with the correct one and obtain a valid token via the AWS Console for the MicroVM, replace the File System ID with the correct one from your setup):
const endpoint = '<ENDPOINT>';
const token = '<TOKEN>';
const type = 'java'
const projectPath = './java';
const mountPath = '/mnt/app';
const fileSystemId = '<FILE_SYSTEM_ID>';
const response = await fetch(`https://${endpoint}`, {
method: 'POST',
headers: {
'X-aws-proxy-auth': token,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
projectPath,
mountPath,
fileSystemId,
})
});
console.log(await response.json());This builds the example Java application under the /java ‘folder’ and will place the resulting Application-1.0-SNAPSHOT.jar in the Bucket under the /target ‘folder’. Download it and run it with java -jar Application-1.0-SNAPSHOT.jar and you should see [main] INFO org.example.Main — Hello World! . Note that the response from the MicroVM contains a lot of ‘Maven dependency download’ noise that on subsequent runs is absent as the Maven repository on the MicroVM is kept between runs. The same build can be invoked for the Go application as well, replacing the type with go and specifying the right location in the Bucket.
Summary
Giving a MicroVM access to S3 works well but can be fiddly at times and more convenience from AWS side’ would be very helpful. The MicroVM performance is great and the ability to re-use MicroVM to optimise builds by re-using cached information I could imagine opens up a lot of possibilities for use cases where intermediate artefacts are heavily relied upon, such as building Java application with Maven where a lot of dependencies are needed.
Appendix
MicroVM
Dockerfile
FROM public.ecr.aws/amazonlinux/amazonlinux:2023
RUN dnf -y install amazon-efs-utils nfs-utils nodejs golang java-21-amazon-corretto maven \
&& dnf clean all \
&& rm -rf /var/cache/dnf
RUN mkdir /mnt/app
WORKDIR /app
COPY --chmod=+x mount-s3.sh .
COPY --chmod=+x build-go.sh .
COPY --chmod=+x build-java.sh .
COPY app.js .
EXPOSE 8080
CMD ["node", "app.js"]mount-s3.sh
MOUNT_PATH=$1
FILE_SYSTEM_ID=$2
echo "Mounting file system '$FILE_SYSTEM_ID' under '$MOUNT_PATH'..."
mount -t s3files "$FILE_SYSTEM_ID" "$MOUNT_PATH"
echo "File system mounted."build-go.sh
As I am writing this on a Mac but we’re building on Linux I have added the cross-compile flags GOOS=darwin GOARCH=amd64 so that I can execute the resulting application locally.
MOUNT_PATH=$1
PROJECT_PATH=$2
echo "Building application under mount path '$MOUNT_PATH' and project path '$PROJECT_PATH' with Go..."
cd "$MOUNT_PATH" || exit
cd "$PROJECT_PATH" || exit
env GOOS=darwin GOARCH=amd64 go build
echo "Application built."build-java.sh
MOUNT_PATH=$1
PROJECT_PATH=$2
echo "Building application under mount path '$MOUNT_PATH' and project path '$PROJECT_PATH' with Maven..."
cd "$MOUNT_PATH" || exit
cd "$PROJECT_PATH" || exit
mvn clean install
echo "Application built."app.js
const http = require('http');
const {exec} = require('child_process');
const TYPE_GO = 'go';
const TYPE_JAVA = 'java';
const populateResponse = (response, status, content, stdout, stderr) => {
response.writeHead(status, {
'Content-Type': 'application/json'
});
response.end(JSON.stringify({
content,
stdout,
stderr,
}));
}
const server = http.createServer((req, res) => {
console.log('Request received');
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
const content = JSON.parse(body);
const {type, mountPath, fileSystemId, projectPath} = content;
if (type === TYPE_GO) {
exec(`./mount-s3.sh ${mountPath} ${fileSystemId} && ./build-go.sh ${mountPath} ${projectPath}`, (err, stdout, stderr) => {
if (err) {
populateResponse(res, 500, content, stdout, stderr);
} else {
populateResponse(res, 200, content, stdout, stderr);
}
});
} else if (type === TYPE_JAVA) {
exec(`./mount-s3.sh ${mountPath} ${fileSystemId} && ./build-java.sh ${mountPath} ${projectPath}`, (err, stdout, stderr) => {
if (err) {
populateResponse(res, 500, content, stdout, stderr);
} else {
populateResponse(res, 200, content, stdout, stderr);
}
});
}
});
});
server.listen(8080, () => {
console.log('Listening on port 8080');
});Example Project
main.go
package main
import "fmt"
func main() {
fmt.Println("Hello", "World!")
}go.mod
module github.com/johannesfloriangeiger/gopom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>Application</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.20</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.20</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>org.example.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>src/main/java/org/example/Main.java
package org.example;
import org.slf4j.LoggerFactory;
public class Main {
public static void main(final String[] args) {
LoggerFactory.getLogger(Main.class)
.info("Hello World!");
}
}









