Building Go + Libsodium Lambda Functions With Docker


Go is my favorite language for building serverless functions for AWS Lambda. I develop and test on my Mac then cross-compile for Linux and upload to Lambda for execution. Easy. Until my Go app had a C-library dependency and I needed to build on Linux. This post walks you through how you can build your deployment package using Docker without relying on external build servers.

I've been messing around with the awesome libsodium crypto library to do Ed25519 digital signatures and sealed boxes using X25519 and XSalsa20-Poly1305. libsodium is written in portable C, so using it from Go requires the use of cgo and a wrapper like the one James Ruan built.

Normally, you can cross-compile for Linux (and AWS/Lambda) like this:

GOOS=linux go build main.go

However, that doesn't work when using a cgo wrapper library - the Go compiler barfs because it can't load/link the shared object code.

Sean Schulte solved this problem by building his Lambda function on an EC2 instance running Linux. That approach works, but it seems like unnecessary overhead. I should be able to build serverless functions without servers, right?

Docker to the rescue. I haven't done much Docker image customization before, so this was a good excuse to dig in. Theoretically, I should be able to build an image and create containers that run effectively the same OS that Lambda runs. The container's job will be to build the libsodium shared library and Go app and then assemble the deployment package for Lambda (zip file with the binary and shared libraries).

Here's the repo with the Dockerfile, code and convenience scripts.

Customizing the Docker Image

Let's start with the Docker image.

Custom Docker images start from a base image. In this case, we want to try to mimic the AWS Lambda runtime environment. Per the docs, Lambda runs on the amzn-ami-hvm-2017.03.1.20170812-x86_64-gp2 AMI, so we can use a Docker image provided by AWS as a starting point.

FROM amazonlinux:2017.03

I'll customize this image by downloading and building/installing both libsodium and Go. I need to reference the Go and libsodim versions in several places so I'll use the ARG instruction to set defaults and allow build-time customization. This makes our container more flexible and the code cleaner.

ARG LIBSODIUM_VERSION="libsodium-1.0.17"
ARG GO_VERSION="go1.11.5.linux-amd64"

Next we'll use yum to update the underlying OS and install some tools we'll need later.

RUN yum update -y
RUN yum install -y zip gcc tar git

Now we're ready to download and build libsodium. This code makes a temporary build directory, downloads the libsodium source using the version we specified in ARG LIBSODIUM_VERSION, builds it, installs it, and cleans up after itself.

RUN \
    mkdir -p /tmpbuild/libsodium && \
    cd /tmpbuild/libsodium && \
    curl -L https://download.libsodium.org/libsodium/releases/$LIBSODIUM_VERSION.tar.gz -o $LIBSODIUM_VERSION.tar.gz && \
    tar xfvz $LIBSODIUM_VERSION.tar.gz && \
    cd /tmpbuild/libsodium/$LIBSODIUM_VERSION/ && \
    ./configure && \
    make && make check && \
    make install && \
    mv src/libsodium /usr/local/ && \
    rm -Rf /tmpbuild/

Next up is Go. We'll download the binary distribution for our platform (linux-amd64), install it, and setup some environment variables that Go needs.

RUN \
    curl -O https://storage.googleapis.com/golang/$GO_VERSION.tar.gz && \
    tar -C /usr/local -xzf $GO_VERSION.tar.gz && \
    mkdir -p ~/go/bin
ENV GOPATH "$HOME/go"
ENV PATH "$PATH:/usr/local/go/bin:~/go/bin"

The next one stumped me for a bit. The libsodium Go wrapper uses pkg-config to locate the shared libsodium library. You need to set the environment variable so pkg-config knows where to look. The value came from the output of the libsodium make/install above.

ENV PKG_CONFIG_PATH "/usr/local/lib/pkgconfig/"

Before we can compile our Go code, we need to download dependencies. Longer term it would be better to use versioned modules, but this works fine. We also need to create a working directory.

RUN go get "github.com/aws/aws-lambda-go/lambda"
RUN go get "github.com/jamesruan/sodium"
RUN mkdir /app
WORKDIR /app

Our deployment package (zip file) will include our binary Go app and a lib directory that includes our libsodium libraries. These commands make that directory and copy the libraries to that location.

RUN mkdir lib
RUN cp /usr/local/lib/libsodium.so.23.2.0 lib/
RUN cp /usr/local/lib/libsodium.so.23 lib/
RUN cp /usr/local/lib/libsodium.so lib/

Docker has a nifty build caching system based on layers so it can avoid building images from scratch. This point in the Dockerfile is effectively a checkpoint - I don't expect to change the libsodium or Go version often. These steps take 8ish minutes to execute, but Docker only has to do it once.

Next we'll setup more arguments. The SHARED_BUILD_FOLDER is the name of a local folder where we'll put the deployment package. Later the script that builds the image and runs the container can grab the package from there.

ARG BINARY_NAME
ARG SHARED_BUILD_FOLDER=/build
ARG PACKAGE_NAME="${BINARY_NAME}_handler.zip"

Now we're ready to build our Go code. We need to add the code to the image (main.go) and then build it for the Linux platform.

ADD main.go /app/
RUN GOOS=linux go build -o $BINARY_NAME main.go

Last, the container will zip up the binary and files in lib to create our deployment package and copy it to the shared build folder.

RUN zip $PACKAGE_NAME $BINARY_NAME lib/*
RUN mkdir -p $SHARED_BUILD_FOLDER
RUN cp $PACKAGE_NAME $SHARED_BUILD_FOLDER/

Invoking a Build

I created a bash script called build_deploy_package.sh to execute the following steps:

  1. Build the Docker image, passing in some arguments
  2. Create and run the container from the image in step 1 and create our deployment package
  3. Copy the deployment package to a known location (shared build folder)
  4. Remove the container
docker build -t $IMAGE_NAME -f Dockerfile --build-arg SHARED_BUILD_FOLDER=$SHARED_BUILD_FOLDER --build-arg BINARY_NAME=$BINARY_NAME .
docker create --name $CONTAINER_NAME $IMAGE_NAME
docker cp $CONTAINER_NAME:$SHARED_BUILD_FOLDER/$PACKAGE_NAME .
docker rm $CONTAINER_NAME

That's it. And incremental builds run in less than 10 seconds.

go  golang  aws  lambda