Serverless with Lambda, API Gateway, and Go
Intro
Some time ago I made TinySite and I wrote an article about building the Biggest Smallest Website. As part of that work, I also built CompressTest in order to provide some insight on size of data post-compression for a few of the compress methods in the Go standard library.
Those services were running on EC2 instances via Elastic Beanstalk, and while the cost wasn’t great, I have other side projects which, like those, require few resources and therefore result in EC2 instances which are mostly idling. Ditto for the Elastic Load Balancers which are part and parcel of an Elastic Beanstalk setup. That money is wasted, and in the case of CompressTest, since it is CPU-intensive, I had to have the setup in Elastic Beanstalk such that it could auto-scale in the event that the site ends up on Hacker News or another aggregator which results in high traffic loads. What I wanted was a system that didn’t really cost anything if it wasn’t being used, but that could scale up at a moment’s notice to whatever load happened to come in.
New serverless approaches, like AWS API Gateway and AWS Lambda, provide a cheap, fast-scaling option for a relatively low-traffic service without strict performance and latency requirements.
Elastic Beanstalk - Old and busted
Elastic Beanstalk works great, but requires at least one Elastic Load Balancer and one EC2 instance to form a functioning environment. Additionally, scale-up time is determined by triggers that start or stop instances. This means that having additional capacity on hand might take five minutes for something like an average CPU usage alarm to trigger, and then another few minutes for instances to come online. If you need further instances, they will have to wait for the duration of whatever cool-down period you have configured (say three to five minutes), before initiating startup of additional instances. This may not be so bad for larger services where there are sustained requests over time and where the traffic scales up relatively smoothly throughout the day, but it’s not great for traffic that could come in large bursts, as in the situation I want to address.
Elastic Beanstalk is also not great for side projects, since the minimum cost to have the infrastructure running is around $25 USD per month. This isn’t going to break the bank for a single side project, but it does start to add up when you have many of them.
Considering the above, you might wonder about abstracting the load balancers and servers away entirely, and worry only about executing functions in your application. Enter AWS API Gateway and Lambda.
Serverless - New Hotness
Serverless has been a thing for a few years, but most of the systems I work with are high-performance (making it too expensive) and low latency (making it impossible to use due to calling overhead). Those two points, combined with vendor lock-in issues, are why I typically don’t recommend the serverless approach for my advising clients. However, in this case it makes a lot of sense. You only pay for what you use, and there isn’t really any such thing as idling servers because there are no servers (from a billing and administration standpoint). You pay for API Gateway on a per-request basis, and Lambda on a per-invocation basis, so the unused capacity problem goes away.
Requests to the API first hit API Gateway, which proxies requests to other systems - in this case, a function running on AWS Lambda. Lambda handles execution and scaling and, as long as each request doesn’t run for too long, it ends up being far cheaper than having instance(s) that idle for most of the time. Currently, everyone is eligible for the free tier of Lambda, regardless of how long you’ve been an AWS customer. The free tier gets you one million function invocations per month. API Gateway costs $3.50 per million function calls, plus data transfer costs (in this case, they’re effectively zero). In other words, instead of having a nano EC2 instance running TinySite (for roughly $5 per month) and an EB environment for CompressTest (for $25 per month), plus other instances for my other projects, I can run all the same services for pretty close to free.
API Gateway
API Gateway has a small cost, but as mentioned it’s only $3.50 per million API requests. For a side project like this, getting multiple millions of API requests per month is unlikely, so $3.50 is a reasonable approximation of the total cost.
In the case of CompressTest, I set up a POST
endpoint on API Gateway which forwards the requests to a Lambda function. The Lambda function processes the request and responds to API Gateway, which then passes the response back to the user. There’s a slight complication: in my testing, using API Gateway with Lambda means that even if you set up CORS headers on API Gateway, they will not be included in any response. Therefore, if you need CORS headers, make sure you set them in the response your function sends back to API Gateway. API Gateway will use whatever headers you set, with the exception of some reserved headers. I create the response object and set the headers immediately just to be on the safe side:
func compressHandler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Let's create the response we'll eventually send, being sure to have CORS headers in place
resp := events.APIGatewayProxyResponse{Headers: make(map[string]string)}
resp.Headers["Access-Control-Allow-Origin"] = "*"
// Remainder of the handler below...
Another complication is that API Gateway likes to assume everything is JSON, and neither CompressTest nor TinySite deal strictly with JSON or even UTF-8 data. CompressTest receives multipart/form-data
, and TinySite responds with compressed data, which may contain arbitrary bytes. AWS API Gateway can handle binary media types as long as you set the Content-Type values in the settings for the endpoint, but I found it to be difficult/unreliable. The basic flow is that API Gateway receives the request and base64 encodes it. It then passes the request on to the Lambda function, which decodes it back to bytes to perform the operations defined in the handler. The resulting response is sent as-is if possible, or if it contains binary data it is base64 encoded before being sent to API Gateway. If the Content-Type header supplied by the Lambda function matches that which was specified in the settings in API Gateway, then API Gateway will base64 decode the response body before forwarding it on to the client. Additionally, I used the command line utility to set the CONVERT_TO_BINARY
flag on egress as part of the process of trying to get it working.
aws apigateway update-integration-response \
--rest-api-id XXXXXX \
--resource-id XXXXXX \
--http-method GET \
--status-code 200 \
--patch-operations '[{"op" : "replace", "path" : "/contentHandling", "value" : "CONVERT_TO_BINARY"}]'
In the end, it all works, but it wasn’t trivial to set up since there are so many knobs to turn. I’m not sure why API Gateway requires a string response instead of just bytes, but oh well. Lastly, it does not seem to be totally clear which changes to API Gateway require the API to be redeployed, and which take effect without redeployment. When in doubt, redeploy the API.
Lambda
This part wasn’t very difficult in either case. In the case of Go and receiving events from API Gateway, you are receiving an APIGatewayProxyRequest
with a structure like:
// APIGatewayProxyRequest contains data coming from the API Gateway proxy
type APIGatewayProxyRequest struct {
Resource string `json:"resource"` // The resource path defined in API Gateway
Path string `json:"path"` // The url path for the caller
HTTPMethod string `json:"httpMethod"`
Headers map[string]string `json:"headers"`
QueryStringParameters map[string]string `json:"queryStringParameters"`
PathParameters map[string]string `json:"pathParameters"`
StageVariables map[string]string `json:"stageVariables"`
RequestContext APIGatewayProxyRequestContext `json:"requestContext"`
Body string `json:"body"`
IsBase64Encoded bool `json:"isBase64Encoded,omitempty"`
}
and you construct an APIGatewayProxyResponse
which looks like:
// APIGatewayProxyResponse configures the response to be returned by API Gateway for the request
type APIGatewayProxyResponse struct {
StatusCode int `json:"statusCode"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
IsBase64Encoded bool `json:"isBase64Encoded,omitempty"`
}
The IsBase64Encoded
field is used to differentiate between string data and raw bytes (compressed things, images, etc.) which have been base64 encoded, as mentioned in the section on API Gateway.
For CompressTest I just used the APIGatewayProxyRequest
to build a net/http.Request
, and then I used all the same code I had for the HTTP handler before.
func compressHandler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Let's create the response we'll eventually send, being sure to have CORS headers in place
resp := events.APIGatewayProxyResponse{Headers: make(map[string]string)}
resp.Headers["Access-Control-Allow-Origin"] = "*"
r := http.Request{}
r.Header = make(map[string][]string)
for k, v := range request.Headers {
if k == "content-type" || k == "Content-Type" {
r.Header.Set(k, v)
}
}
// NOTE: API Gateway is set up with */* as binary media type, so all APIGatewayProxyRequests will be base64 encoded
body, err := base64.StdEncoding.DecodeString(request.Body)
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
if err != nil {
resp.StatusCode = 403
resp.Body = "Could not read request body"
return resp, nil
}
err = r.ParseMultipartForm(maxRequestBodySize)
// Handling the request continues...
Instead of taking an http.Request
and an http.ResponseWriter
I just take the context and APIGatewayProxyRequest
and return the relevant APIGatewayProxyResponse
. The other trick is setting the body, which for an HTTP Request requires a Close()
method. However, if you try to .Close()
in Lambda you’ll get a nil pointer exception, since there isn’t really an HTTP request there. Therefore, use the ioutil.NopCloser
to wrap the reader, thus satisfying the ReadCloser
interface expected on the net/http
side when the form is parsed.
Also note how the Content-Type header is being set. Firefox will send the lower case version, and some tools like curl will send the title-case version, so it’s important to check both cases. I probably could have also hard-coded this to multipart/form-data
but in addition to being poor form it would also result in the boundary separator not being known, since it is specified in that header. This would result in the body of the HTTP request being impossible (or at least unreasonably difficult) to process.
Because CompressTest receives binary data, API Gateway will, as mentioned, forward the request body to our Lambda function after base64 encoding it. Therefore, we have to decode it first, and then process the request as normal.
In the case of TinySite, the only real change required was to set the IsBase64Encoded
flag in the response. In fact, the whole handler for TinySite is only a few lines of code.
func tinysite() (events.APIGatewayProxyResponse, error) {
var buf bytes.Buffer
zw, _ := flate.NewWriter(&buf, flate.BestCompression)
_, _ = zw.Write([]byte(resp))
zw.Close()
compressedBody := buf.Next(buf.Len())
resp := events.APIGatewayProxyResponse{StatusCode: 200, Body: base64.StdEncoding.EncodeToString(compressedBody), IsBase64Encoded: true}
resp.Headers = make(map[string]string)
resp.Headers["Content-Type"] = ""
resp.Headers["Content-Encoding"] = "deflate"
return resp, nil
}
Note as well that if the Content-Type
header is not set, then API Gateway will automatically set it to application/json
, which we do not want. The content type is inferred by the client in this case, and it is not required in the HTTP spec to send text/html
as one might suspect. By sending an empty string as the Content-Type
we can ensure that it is not set by API Gateway. Note that this does not work with all headers, for example the custom Amazon headers prefixed with x-
for tracing and other diagnostics will remain and if you try to set them to empty strings they will be copied and the generated headers inserted.
Summary
With the exception of handling binary media types and making sure to use an ioutil.NopCloser
, the whole process was pretty straightforward. While this approach is not appropriate for systems that need optimal performance or where latency constraints are tight, it may be ideal for some for some simpler projects. If you have relatively low-traffic sites that may need to scale to higher traffic volumes, and for which some request latency (e.g. 100-300ms) is tolerable, then this combination of API Gateway and Lambda may be a perfect fit.