Build & Deploy Tips(The Ultimate Guide to AWS Lambda Development Chapter 2)

George Mao
6 min readJan 19, 2024

--

Chapter 1 of this guide focused on themes you should follow during your development lifecycle. This chapter focuses on things that can help you build and deploy more efficient Lambda packages. There are 3 themes we will walk through: Minimize, Optimize, Remove. Lets go!

Minimize your build!

In Chapter 1, we learned that you shouldn’t include unnecessary things in your build. Let’s take it one step further — you can optimize your build for maximum runtime efficiency. For example, if you’re building a function with Node, you should use ESBuild to produce a single deployable file with all dependencies. This will load much faster into memory vs loading multiple assets. AWS SAM has this built right into the framework. Here’s the general configuration I use (we’ll tweak some configs for comparison):

firstFn:
Type: AWS::Serverless::Function
Properties:
PackageType: Zip
CodeUri: firstFn/
Handler: index.handler
MemorySize: 256
Role: !GetAtt SomeRole.Arn
Tracing: Active
Architectures:
- arm64
Environment:
Variables:
NODE_OPTIONS: '--enable-source-maps'
Metadata:
BuildMethod: esbuild
BuildProperties:
Format: esm
Minify: true
OutExtension:
- .js=.mjs
Target: es2022
Sourcemap: true
SourcesContent: false
EntryPoints:
- ./src/index.mjs
- ./src/myModule.mjs
Banner:
- js=import { createRequire } from 'module'; const require = createRequire(import.meta.url);
  • Under the Metadata we can specify the BuildMethod as esbuild
  • For Format I specify esm because I want to build es modules. This is the only way to properly support Top Level Await. This also means the Target must be es2022 and the OutExtension should use .mjs
  • I enable Sourcemap so it generates a debuggable map since the entire build will be minified and unreadable. SourcesContent specifies if I want the entire src included or just file names. More on this later.
  • You should specify an EntryPoint for your function handler file and any additional modules
  • I’m specifying a Banner which gets added at the top of the generated output file. This is a workaround since esbuild currently cannot build dependencies that use require

I have two functions in my SAM template: secondFn and thirdFn. secondFn was built with a fully optimized ESBuild. All dependencies are included with no Externals. The entire package is ~3.3MB. ESBuild writes my source and all dependencies into a single file. The .map files are optional and only necessary for debugging.

thirdFn was built using the standard zip build process. As you can see, it has it has a node_modules folder with all of the dependencies in it. This results in a ~24 MB package.

Executing this in AWS results in very significant cold start differences. Here’s secondFn. Total duration: 1.22s. Cold Start: 735 ms.

Total duration: 1.22s. Cold Start: 735ms.

Here’s thirdFn. Total duration: 1.62s. Cold Start: 1.08 s.

Total duration: 1.62s. Cold Start: 1.08ms.

secondFn initialized 273ms faster (~27% better) than thirdFn. I ran this multiple times at a various memory settings at 256MB and above with very similar results.

ESBuild resulted in nearly 30% better cold start performance. That is a direct translation to cold response time for this function (1.22s vs 1.62s).

Optimize your build!

AWS has always told us *smaller* packages is better. So let’s remove the AWS SDK from ESBuild and use the AWS vended SDK in the Lambda runtime. This is done by specifying the External option in ESBuild. I created firstFn to test this.

External:
- '@aws-sdk/client-dynamodb'
- '@aws-sdk/node-http-handler'

This results in a ~865KB package compared to ~3.3MB!

Here’s what cold start looks like for firstFn — remember, this is a minimal ESBuild (without the AWS SDK). Total duration: 1.51s. Cold Start: 1.06s.

Total duration: 1.51s. Cold Start: 1.06s.

Cold start for secondFn (a fully optimized ESBuild package WITH the AWS SDK included) was only ~735ms. So while firstFn is the smallest deployment, it’s about the same performance as a thirdFn which is a non ESBuild deployment.

It actually makes sense that firstFn is slower. Even though it was optimized with ESBuild, the Lambda service still needs to look at additional files from the Lambda runtime, while secondFn includes everything it needs in a single source file.

Cold Start comparison

Remove Debug stuff!

We normally include the source map so you can attach a debugger and step through code. These maps can drastically increase the overall deployment package size, especially if you include the full source with it. Here’s my fully optimized ESBuild, secondFn:

Source Map included:

No Source Map:

Since you cannot attach a debugger when deployed into AWS there is no reason to include it in your deployed build! You still want this in Dev and QA but take it out of production builds.

Other Runtimes

I spend most of my time in Node, but you can accomplish similar behaviors in other runtimes. Here are some tips for Java:

Maven & Gradle are popular build tools. Just make sure you use the shade plugin to build an optimized Jar file. Additionally, when you use the AWS Java SDK (v2), it will include 3 different HTTP client implementations:

  • Apache HTTP
  • Netty HTTP
  • Java standard URL Connection client

You probably don’t need all 3. Explicitly select the one you need and exclude the others from your build.

<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<exclusions>
<exclusion>
<groupId>software.amazon.awssdk</groupId>
<artifactId>netty-nio-client</artifactId>
</exclusion>
<exclusion>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apache-client</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
</dependency>
</dependencies>

Finally, Java has a two tier compilation optimization that allows long running processes to optimize commonly executed Java code. Its usually better to disable the second tier in Lambda. You can read all about the details in Mark Sailes’s post here. The easiest way to disable the second tier of compilation is done by setting one environment variable on your function:

JAVA_TOOL_OPTIONS = -XX:+TieredCompilation -XX:TieredStopAtLevel=1

Check out Mark Sailes’s follow up post for additional information on this.

I’m least familiar with Go and Python but maybe someone can help me write build tips (and I refuse to write in .net/c#) !

Summary

The TLDR;

  • Small deployment packages are better than large ones. Remove everything that is not used to run the function.
  • Optimal builds usually involve minifying and removal of all debugging dependencies. Each runtime has their own specific ways to do this.

Join us Discord #BelieveInServerless to talk about these. Let me know if there are more themes you follow!

--

--

George Mao

Distinguished Engineer @ Capital One leading all things Serverless | Ex -AWS WW Serverless Tech Lead.