Artem Krylysov


Porting Go web applications to AWS Lambda

Running Go on AWS Lambda is not something totally new - developers figured out how to launch Go binaries from Python a while ago, but it wasn't convenient and had some performance implications.

A few days ago Amazon announced an official Go support for AWS Lambda.

The API Gateway integration is straightforward, all you need to do is to import the github.com/aws/aws-lambda-go package, implement a Lambda handler and call the lambda.Start to register the handler:

package main

import (
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

func lambdaHandler(event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{Body: "hi", StatusCode: 200}, nil
}

func main() {
    lambda.Start(lambdaHandler)
}

I had a small service I wanted to port to Lambda:

// Very very simplified version of the service.
package main

import (
    "fmt"
    "net/http"
    "strconv"
)

func indexHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("index"))
}

func addHandler(w http.ResponseWriter, r *http.Request) {
    f, _ := strconv.Atoi(r.FormValue("first"))
    s, _ := strconv.Atoi(r.FormValue("second"))
    w.Header().Set("X-Hi", "foo")
    fmt.Fprintf(w, "%d", f+s)
}

func main() {
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/add", addHandler)
    http.ListenAndServe(":8080", nil)
}

I didn't want to rewrite any of my HTTP handlers. When I looked at the code, the first idea was that it would be nice to make the Lambda runtime support the existing net/http handlers.

The net/http server consists of two main parts:

  1. The HTTP request multiplexer ServeMux (Handler interface) which routes all incoming requests.

  2. The HTTP Server itself that handles the TCP connections.

You can replace any of these parts, e.g. you can plug in one of the custom HTTP routers (chi, gorilla mux, httprouter) that provide more features and/or better performance compared to ServeMux. Similarly, you can create your own Server implementation.

This flexibility makes many things easier, e.g. unit testing:

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestIndexHandler(t *testing.T) {
    http.HandleFunc("/", indexHandler)
    r := httptest.NewRequest("GET", "/", nil)
    w := httptest.NewRecorder()
    http.DefaultServeMux.ServeHTTP(w, r) // Note: you can directly call indexHandler here without involving ServeMux.
    if w.Code != http.StatusOK || w.Body.String() != "index" {
        t.Fail()
    }
}

As you may have noticed, the Go standard library provides everything to construct http.ResponseWriter and http.Request objects. In order to make the Lambda SDK support net/http handlers we need to convert APIGatewayProxyRequest into http.Request, create a mock http.ResponseWriter, call the router's ServeHTTP and return a new APIGatewayProxyResponse. A basic implementation:

func lambdaHandler(event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    r := httptest.NewRequest(event.HTTPMethod, event.Path, nil)
    w := httptest.NewRecorder()
    http.DefaultServeMux.ServeHTTP(w, r)
    respEvent := events.APIGatewayProxyResponse{
        Body: w.Body.String(),
        StatusCode: w.Code,
    }
    return respEvent, nil
}

The code above will work for simple handlers, but it needs a few more things to be ready for production - support headers, query string parameters and binary responses. I created a package algnhsa, it can be used as a drop-in replacement for the net/http server:

package main

import (
    "fmt"
    "net/http"
    "strconv"

    "github.com/akrylysov/algnhsa"
)

func indexHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("index"))
}

func addHandler(w http.ResponseWriter, r *http.Request) {
    f, _ := strconv.Atoi(r.FormValue("first"))
    s, _ := strconv.Atoi(r.FormValue("second"))
    w.Header().Set("X-Hi", "foo")
    fmt.Fprintf(w, "%d", f+s)
}

func main() {
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/add", addHandler)
    algnhsa.ListenAndServe(http.DefaultServeMux, nil)
}

On the API Gateway side define a proxy ANY method to handle requests to / and a catch-all {proxy+} resource to handle requests to every other path:

To make the API Gateway treat certain content types as binary, you need to add the desired types to your API's "Binary Media Types" (Settings section) and also pass them to the algnhsa.ListenAndServe function:

algnhsa.ListenAndServe(http.DefaultServeMux, []string{"image/jpeg", "image/png"}])

You can find the algnhsa package (Lambda Go net/http server adapter) on GitHub - https://github.com/akrylysov/algnhsa.

I'm not a native English speaker, and I'm trying to improve my language skills. Feel free to correct me if you spot any spelling or grammatical errors!