swift-server / swift-aws-lambda-runtime

Swift implementation of AWS Lambda Runtime
https://swiftpackageindex.com/swift-server/swift-aws-lambda-runtime
Apache License 2.0
1.15k stars 106 forks source link

[!IMPORTANT] The documentation included here refers to the Swift AWS Lambda Runtime v2 (code from the main branch). If you're developing for the runtime v1.x, check this readme instead.

[!WARNING] The Swift AWS Runtime v2 is work in progress. We will add more documentation and code examples over time.

The Swift AWS Lambda Runtime

Many modern systems have client components like iOS, macOS or watchOS applications as well as server components that those clients interact with. Serverless functions are often the easiest and most efficient way for client application developers to extend their applications into the cloud.

Serverless functions are increasingly becoming a popular choice for running event-driven or otherwise ad-hoc compute tasks in the cloud. They power mission critical microservices and data intensive workloads. In many cases, serverless functions allow developers to more easily scale and control compute costs given their on-demand nature.

When using serverless functions, attention must be given to resource utilization as it directly impacts the costs of the system. This is where Swift shines! With its low memory footprint, deterministic performance, and quick start time, Swift is a fantastic match for the serverless functions architecture.

Combine this with Swift's developer friendliness, expressiveness, and emphasis on safety, and we have a solution that is great for developers at all skill levels, scalable, and cost effective.

Swift AWS Lambda Runtime was designed to make building Lambda functions in Swift simple and safe. The library is an implementation of the AWS Lambda Runtime API and uses an embedded asynchronous HTTP Client based on SwiftNIO that is fine-tuned for performance in the AWS Runtime context. The library provides a multi-tier API that allows building a range of Lambda functions: From quick and simple closures to complex, performance-sensitive event handlers.

Pre-requisites

Getting started

To get started, read the Swift AWS Lambda runtime v1 tutorial. It provides developers with detailed step-by-step instructions to develop, build, and deploy a Lambda function.

Or, if you're impatient to start with runtime v2, try these six steps:

  1. Create a new Swift executable project
mkdir MyLambda && cd MyLambda
swift package init --type executable
  1. Prepare your Package.swift file

    2.1 Add the Swift AWS Lambda Runtime as a dependency

    swift package add-dependency https://github.com/swift-server/swift-aws-lambda-runtime.git --branch main
    swift package add-target-dependency AWSLambdaRuntime MyLambda --package swift-aws-lambda-runtime

    2.2 (Optional - only on macOS) Add platforms after name

        platforms: [.macOS(.v15)],

    2.3 Your Package.swift file must look like this

    // swift-tools-version: 6.0
    
    import PackageDescription
    
    let package = Package(
        name: "MyLambda",
        platforms: [.macOS(.v15)],
        dependencies: [
            .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"),
        ],
        targets: [
            .executableTarget(
                name: "MyLambda",
                dependencies: [
                    .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
                ]
                ),
        ]
    )
  2. Edit Sources/main.swift file and replace the content with this code

import AWSLambdaRuntime

// in this example we are receiving and responding with strings

let runtime = LambdaRuntime {
    (event: String, context: LambdaContext) in
        return String(event.reversed())
}

try await runtime.run()
  1. Build & archive the package
swift build
swift package archive --allow-network-connections docker

If there is no error, there is a ZIP file ready to deploy. The ZIP file is located at .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/MyLambda/MyLambda.zip

  1. Deploy to AWS

There are multiple ways to deploy to AWS (SAM, Terraform, AWS Cloud Development Kit (CDK), AWS Console) that are covered later in this doc.

Here is how to deploy using the aws command line.

aws lambda create-function \
--function-name MyLambda \
--zip-file fileb://.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/MyLambda/MyLambda.zip \
--runtime provided.al2 \
--handler provided  \
--architectures arm64 \
--role arn:aws:iam::<YOUR_ACCOUNT_ID>:role/lambda_basic_execution

The --architectures flag is only required when you build the binary on an Apple Silicon machine (Apple M1 or more recent). It defaults to x64.

Be sure to replace with your actual AWS account ID (for example: 012345678901).

  1. Invoke your Lambda function
aws lambda invoke \
--function-name MyLambda \
--payload $(echo \"Hello World\" | base64)  \
out.txt && cat out.txt && rm out.txt

This should print

{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
"dlroW olleH"

Developing your Swift Lambda functions

Receive and respond with JSON objects

Typically, your Lambda functions will receive an input parameter expressed as JSON and will respond with some other JSON. The Swift AWS Lambda runtime automatically takes care of encoding and decoding JSON objects when your Lambda function handler accepts Decodable and returns Encodable conforming types.

Here is an example of a minimal function that accepts a JSON object as input and responds with another JSON object.

import AWSLambdaRuntime

// the data structure to represent the input parameter
struct HelloRequest: Decodable {
    let name: String
    let age: Int
}

// the data structure to represent the output response
struct HelloResponse: Encodable {
    let greetings: String
}

// the Lambda runtime
let runtime = LambdaRuntime {
    (event: HelloRequest, context: LambdaContext) in

    HelloResponse(
        greetings: "Hello \(event.name). You look \(event.age > 30 ? "younger" : "older") than your age."
    )
}

// start the loop
try await runtime.run()

You can learn how to deploy and invoke this function in the Hello JSON example README file.

Lambda Streaming Response

You can configure your Lambda function to stream response payloads back to clients. Response streaming can benefit latency sensitive applications by improving time to first byte (TTFB) performance. This is because you can send partial responses back to the client as they become available. Additionally, you can use response streaming to build functions that return larger payloads. Response stream payloads have a soft limit of 20 MB as compared to the 6 MB limit for buffered responses. Streaming a response also means that your function doesn’t need to fit the entire response in memory. For very large responses, this can reduce the amount of memory you need to configure for your function.

Streaming responses incurs a cost. For more information, see AWS Lambda Pricing.

You can stream responses through Lambda function URLs, the AWS SDK, or using the Lambda InvokeWithResponseStream API. In this example, we create an authenticated Lambda function URL.

Here is an example of a minimal function that streams 10 numbers with an interval of one second for each number.

import AWSLambdaRuntime
import NIOCore

struct SendNumbersWithPause: StreamingLambdaHandler {
    func handle(
        _ event: ByteBuffer,
        responseWriter: some LambdaResponseStreamWriter,
        context: LambdaContext
    ) async throws {
        for i in 1...10 {
            // Send partial data
            try await responseWriter.write(ByteBuffer(string: "\(i)\n"))
            // Perform some long asynchronous work
            try await Task.sleep(for: .milliseconds(1000))
        }
        // All data has been sent. Close off the response stream.
        try await responseWriter.finish()
    }
}

let runtime = LambdaRuntime.init(handler: SendNumbersWithPause())
try await runtime.run()

You can learn how to deploy and invoke this function in the streaming example README file.

Integration with AWS Services

Most Lambda functions are triggered by events originating in other AWS services such as Amazon SNS, Amazon SQS or AWS APIGateway.

The Swift AWS Lambda Events package includes an AWSLambdaEvents module that provides implementations for most common AWS event types further simplifying writing Lambda functions.

Here is an example Lambda function invoked when the AWS APIGateway receives an HTTP request.

import AWSLambdaEvents
import AWSLambdaRuntime

let runtime = LambdaRuntime {
    (event: APIGatewayV2Request, context: LambdaContext) -> APIGatewayV2Response in

    APIGatewayV2Response(statusCode: .ok, body: "Hello World!")
}

try await runtime.run()

You can learn how to deploy and invoke this function in the API Gateway example README file.

Integration with Swift Service LifeCycle

tbd + link to docc

Use Lambda Background Tasks

Background tasks allow code to execute asynchronously after the main response has been returned, enabling additional processing without affecting response latency. This approach is ideal for scenarios like logging, data updates, or notifications that can be deferred. The code leverages Lambda's "Response Streaming" feature, which is effective for balancing real-time user responsiveness with the ability to perform extended tasks post-response. For more information about Lambda background tasks, see this AWS blog post.

Here is an example of a minimal function that waits 10 seconds after it returned a response but before the handler returns.

import AWSLambdaRuntime
import Foundation

struct BackgroundProcessingHandler: LambdaWithBackgroundProcessingHandler {
    struct Input: Decodable {
        let message: String
    }

    struct Greeting: Encodable {
        let echoedMessage: String
    }

    typealias Event = Input
    typealias Output = Greeting

    func handle(
        _ event: Event,
        outputWriter: some LambdaResponseWriter<Output>,
        context: LambdaContext
    ) async throws {
        // Return result to the Lambda control plane
        context.logger.debug("BackgroundProcessingHandler - message received")
        try await outputWriter.write(Greeting(echoedMessage: event.message))

        // Perform some background work, e.g:
        context.logger.debug("BackgroundProcessingHandler - response sent. Performing background tasks.")
        try await Task.sleep(for: .seconds(10))

        // Exit the function. All asynchronous work has been executed before exiting the scope of this function.
        // Follows structured concurrency principles.
        context.logger.debug("BackgroundProcessingHandler - Background tasks completed. Returning")
        return
    }
}

let adapter = LambdaCodableAdapter(handler: BackgroundProcessingHandler())
let runtime = LambdaRuntime.init(handler: adapter)
try await runtime.run()

You can learn how to deploy and invoke this function in the background tasks example README file.

Testing Locally

Before deploying your code to AWS Lambda, you can test it locally by running the executable target on your local machine. It will look like this on CLI:

swift run

When not running inside a Lambda execution environment, it starts a local HTTP server listening on port 7000. You can invoke your local Lambda function by sending an HTTP POST request to http://127.0.0.1:7000/invoke.

The request must include the JSON payload expected as an event by your function. You can create a text file with the JSON payload documented by AWS or captured from a trace. In this example, we used the APIGatewayv2 JSON payload from the documentation, saved as events/create-session.json text file.

Then we use curl to invoke the local endpoint with the test JSON payload.

curl -v --header "Content-Type:\ application/json" --data @events/create-session.json http://127.0.0.1:7000/invoke
*   Trying 127.0.0.1:7000...
* Connected to 127.0.0.1 (127.0.0.1) port 7000
> POST /invoke HTTP/1.1
> Host: 127.0.0.1:7000
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type:\ application/json
> Content-Length: 1160
> 
< HTTP/1.1 200 OK
< content-length: 247
< 
* Connection #0 to host 127.0.0.1 left intact
{"statusCode":200,"isBase64Encoded":false,"body":"...","headers":{"Access-Control-Allow-Origin":"*","Content-Type":"application\/json; charset=utf-8","Access-Control-Allow-Headers":"*"}}

Modifying the local endpoint

By default, when using the local Lambda server, it listens on the /invoke endpoint.

Some testing tools, such as the AWS Lambda runtime interface emulator, require a different endpoint. In that case, you can use the LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT environment variable to force the runtime to listen on a different endpoint.

Example:

LOCAL_LAMBDA_SERVER_INVOCATION_ENDPOINT=/2015-03-31/functions/function/invocations swift run

Deploying your Swift Lambda functions

TODO

Swift AWS Lambda Runtime - Design Principles

The design document details the v2 API proposal for the swift-aws-lambda-runtime library, which aims to enhance the developer experience for building serverless functions in Swift.

The proposal has been reviewed and incorporated feedback from the community. The full v2 API design document is available in this repository.

Key Design Principles

The v2 API prioritizes the following principles:

New Capabilities

The v2 API introduces two new features:

Response Streaming: This functionality is ideal for handling large responses that need to be sent incrementally.  

Background Work: Schedule tasks to run after returning a response to the AWS Lambda control plane.

These new capabilities provide greater flexibility and control when building serverless functions in Swift with the swift-aws-lambda-runtime library.