Erlang and Elixir in AWS Lambda using Container Images (part 2)

Derek Berner
9 min readJun 16, 2021

In part 1, we went through the steps of provisioning a Docker image for running Elixir and Erlang code. In this installment, we will bootstrap a standalone Elixir application that runs within the docker container. This application will serve as the starting point from which to develop a concurrent Elixir/Erlang application, and later, explore the AWS building blocks we can use to serve a robust, highly available cloud-based application.

Refactoring the Dockerfile

First things first — in the previous installment we went through the steps of creating an Erlang Docker image using publicly available RPM packages. While these packages are convenient, they are not guaranteed to support the latest versions of Erlang and Elixir. Since it is preferable to have full control over the versions we are running, let’s create a docker image that has the languages compiled from scratch.

Create a directory images and add a file named base.Dockerfile with the following contents:

FROM public.ecr.aws/amazonlinux/amazonlinux:latest as root

RUN yum install deltarpm -y
RUN yum update -y
RUN yum install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm -y
RUN yum install ncurses-libs openssl-libs flex java-1.8.0-openjdk libxslt fop -y

ENV LANG="en_US.UTF-8"
ENV LC_COLLATE="en_US.UTF-8"
ENV LC_CTYPE="en_US.UTF-8"
ENV LC_MESSAGES="en_US.UTF-8"
ENV LC_MONETARY="en_US.UTF-8"
ENV LC_NUMERIC="en_US.UTF-8"
ENV LC_TIME="en_US.UTF-8"
ENV LC_ALL="en_US.UTF-8"

FROM root as buildelixir

RUN yum install git perl ncurses-devel openssl-devel flex-devel java-1.8.0-openjdk-devel libxslt-devel sed -y
RUN yum groupinstall "Development Tools" -y
RUN git clone https://github.com/erlang/otp.git erlang
RUN git clone https://github.com/elixir-lang/elixir.git

ENV ERL_TOP=/erlang
WORKDIR /erlang
RUN git checkout OTP-24.0.2
RUN ./configure --prefix=/opt/erlang
RUN make
RUN make release_tests
WORKDIR /erlang/release/tests/test_server
RUN /erlang/bin/erl -s ts install -s ts smoke_test batch -s init stop
WORKDIR /erlang
RUN make install

ENV PATH="$PATH:/opt/erlang/bin:/opt/erlang/lib"
WORKDIR /elixir
RUN git checkout v1.12.1
RUN make clean test
RUN make install PREFIX=/opt/elixir

FROM root

COPY --from=buildelixir /opt/erlang /opt/erlang
COPY --from=buildelixir /opt/elixir /opt/elixir
ENV PATH="$PATH:/opt/erlang/bin:/opt/erlang/lib:/opt/elixir/bin:/opt/elixir/lib"
RUN mix local.hex --force
RUN epmd -daemon

This is what’s known as a Multi-Stage Build — a docker image that relies on temporary, intermediate images to complete. After provisioning the root image with dependencies necessary to run Erlang and Elixir, and adding some common configuration, a build image based on the root image is further provisioned with dependencies necessary to build Erlang and Elixir, before cloning both git repos, checking out the desired release tags, and compiling them from scratch. Finally, the base image is created from the root, by copying over the compiled outputs while discarding the build dependencies, and running a few necessary commands to get a few things working later.

Create a Makefile

In the previous installment we created run.sh to encapsulate the docker command necessary to launch everything. It is possible to encapsulate all our common tasks into separate shell scripts, but this doesn’t scale well. In addition to polluting the root directory, common commands still have to be run in a particular order (e.g. you must build your docker image before you run the container).

GNU make provides a very simple, robust answer to this. Not only can we specify task dependencies, but make will automatically check if a build step is stale (specifically, whether its dependencies have a modification time later than the task’s output file) before deciding if it needs to run it at all. This can help us save time and only run the tasks that need running at any given moment.

Our initial Makefile, which goes in the root directory, looks like this:

CMD?=
WD?=

.PHONY: base bind

base: .make/base

bind: .make/base
docker run -it --mount type=bind,source=$(shell pwd),target=/bind elixirbase sh -ec "cd /bind/$(WD); $(CMD)"

.make/base: .make images/base.Dockerfile
docker build -t elixirbase -f $(shell pwd)/images/base.Dockerfile .
date > .make/base

.make:
mkdir -p .make

Note: make requires the tab character 0x09 for indentation. Spaces will not work. Unfortunately, medium converts the tab character to spaces automatically, so that means when you copy and paste these code blocks you will need to search and replace groups of spaces with the literal tab character.

Because make depends on files to check staleness, we create a phony build target called base which delegates to a real build target .make/base. Unless declared to be PHONY make assumes all targets resolve to real-world files. Note that after running the docker command, the .make/base build target simply touches an output file in the .make folder, which itself is a build target in this Makefile. This is because although the command doesn’t create an artifact, we still want make to be able to check the last time the command ran.

There is also a bind target that depends on .make/base. We will use this shortly. In the meantime, run:

make base

and your docker image will build. Note that this can take close to half an hour to complete, so now’s a good time to go grab a Venti latte and start learning Elixir, Erlang, and Docker. However, with proper use of make and docker you should never have to recompile on the same machine twice!

Create an Elixir project

Elixir projects are managed using a tool called Mix. We will be using this tool to manage our project, but we have a bit of a problem. We need to use Mix to bootstrap the project. We created this nice, fancy, pristine Docker container with Elixir in it, and by extension, Mix. But how can we bootstrap our project in our host machine’s file system, using a command line tool that only exists in a container?

An alchemy lab, where, according to sources, mixing occurs.

This is where the bind target comes in. Recall how it’s implemented:

docker run -it --mount type=bind,source=$(shell pwd),target=/bind elixirbase sh -ec "cd /bind/$(WD); $(CMD)"

The key here is --mount type=bind. While binding host folders is generally discouraged, one exception to that is when the location being bound is write-only. By binding a host folder into your container, you allow the container to write to a persistent location, in this case, the local directory.

make bind can be used to perform any action in the container that has an effect you want to persist.

Now that we have everything we need, let’s bootstrap the Elixir project!

make bind CMD="mix new vending_fleet --sup"

This sends the command mix new vending_fleet --sup to the container via make bind which will create the bootstrapped project in our local directory. The --sup flag instructs Mix to create a supervisor skeleton for the application.

Since we want the root directory of our project to be the project, let’s do a little housekeeping:

cp -rv vending_fleet/* vending_fleet/.* .
rm -rf vending_fleet

There. Now our Elixir project is ready to roll!

Running the Project

Before we can do much with the project, we should make sure we can run it. Update the Dockerfile in the project root so that it looks like this:

FROM elixirbase:latest

ENV MIX_ENV=dev

COPY . /vending_fleet
WORKDIR /vending_fleet

RUN mix deps.get
RUN mix compile
CMD mix app.start

This uses the images/base.Dockerfile image we created above as a starting point. The project root is copied into the directory /vending_fleet on the container, and set as the working directory. Then, several mix commands are run to download dependencies, compile the source, and launch the application.

There are some files in the project that we want Docker to ignore when copying that, so let’s create a .dockerignore file by copying the .gitignore file that Mix created for us (.gitignore is usually a pretty good starting point for .dockerignore):

cp .gitignore .dockerignore

Then, open up .dockerignore and add these lines to the bottom:

/.make/
/images/
Dockerfile
.dockerignore
.gitignore
Makefile
README.md

# IntelliJ files
/.idea/
*.iml

We need a make target to run this, so let’s change the Makefile so that it looks like this:

CMD?=
WD?=

PROJECTFILES=$(shell ls -1 *.ex{s,}) $(shell find lib src test -type f)
.PHONY: run base bind

base: .make/base
build: .make/build

run: .make/build
docker run -it erlambda $(CMD)

bind: .make/base
docker run -it --mount type=bind,source=$(shell pwd),target=/bind elixirbase sh -ec "cd /bind/$(WD); $(CMD)"

.make/build: .make/base Dockerfile $(PROJECTFILES)
docker build -t erlambda .
date > .make/build

.make/base: .make images/base.Dockerfile
docker build -t elixirbase -f $(shell pwd)/images/base.Dockerfile .
date > .make/base

.make:
mkdir -p .make

For those of you not keeping score at home, this change introduces a PROJECTFILES variable that dynamically calculates the files we want to watch for changes. This is used for dependencies of the .make/build target which builds the docker image from the Dockerfile in the project root. This way, if any of these files become newer than .make/build, make will know to re-run the target.

.make/build depends on .make/base, indicating to make that the base Docker image needs to be built before building the application image.

Finally two phony targets are created: build, an alias for .make/build, and run which depends on .make/build and calls docker run, optionally passing the environment variable $CMD to override the CMD from the Dockerfile.

Let’s take a brief moment to check our directory contents and verify everything is as we expect:

$ find . -type f |grep -v -e .idea -e .iml -e .make|sort
./.dockerignore
./.formatter.exs
./.gitignore
./Dockerfile
./Makefile
./README.md
./images/base.Dockerfile
./lib/vending_fleet.ex
./lib/vending_fleet/application.ex
./mix.exs
./test/test_helper.exs
./test/vending_fleet_test.exs

With this we are ready to build and run our pregenerated application.

make run

Wait! What happened to make build? If you’ve been following along closely, you’ll notice that through dependencies in Makefile, we are able to have make determine that .make/build is required by run, automatically notice that .make/build is missing, and build the application Docker image for us. Neat!

Using iex

Anyway, after running make run you won’t notice much. That’s because there’s nothing registered with the Supervisor. However, let’s take this opportunity to get acquainted with iex, or “Interactive Elixir”, the REPL command line tool for working with Elixir, and see how we can interact with the code that mix new scaffolded for us.

Run this command:

make run CMD="iex -S mix"

If all goes well you should see an IEx terminal:

Erlang/OTP 24 [erts-12.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]Interactive Elixir (1.12.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

Test out that your project is running by typing VendingFleet.hello:

iex(1)> VendingFleet.hello
:world

Fabulous! Exit by pressing Ctrl+C and typing a to “Abort” the shell.

Other considerations

There are a few other bits of setup here that, while technically optional, are useful as part of the Elixir development cycle.

Unit tests

We also want to be able to unit test our code, so type:

docker run -it erlambda mix test

You should see:

..Finished in 0.04 seconds (0.00s async, 0.04s sync)
1 doctest, 1 test, 0 failures
Randomized with seed 698154

Now we’re cooking with gas!

Speed up the compiler

As written, make build compiles the complete source code, from scratch, each time. As your codebase grows, this can slow down your code-and-test (or test-and-code for you Agile types) feedback cycle. We can improve on this by caching the build in a Docker image.

Let’s create a file images/cache.Dockerfile with the following contents:

FROM elixirbase:latest

ENV MIX_ENV=dev

COPY vending_fleet /vending_fleet
WORKDIR /vending_fleet

RUN mix deps.get
RUN mix compile

Next, we need to update the Dockerfile in the project root as follows, to build from the cached outputs:

FROM elixircache:latest

ENV MIX_ENV=dev

COPY . /vending_fleet
COPY --from=elixircache:latest /vending_fleet/_build /vending_fleet/dep[s] /vending_fleet/mix.loc[k] /vending_fleet
WORKDIR /vending_fleet

RUN mix deps.get
RUN mix compile
CMD mix app.start

Now, let’s create an intermediate (e.g. not aliased to a “PHONY”) build target in the Makefile:

.make/cache: .make/base images/cache.Dockerfile
docker build -t elixircache -f $(shell pwd)/images/cache.Dockerfile .
date > .make/cache

And then we’ll change .make/build and bind to depend on .make/cache.

.make/build: .make/cache Dockerfile project $(PROJECTFILES)

Now, the first time you make build will take some time, but subsequent compiles will be much faster. make won’t rebuild the cache image unless images/cache.Dockerfile changes, or .make/cache disappears.

In fact, let’s create a phony target recache that rebuilds the cached image. You can use this if you’ve been developing for a while and find that your builds are starting to slow down:

.PHONY: run base bind recache
# ...snip...
recache:
rm .make/cache .make/build
make .make/cache

Use ExDoc

When building Elixir applications, it is important to use the inline documentation features Elixir provides. These can be used to produce professional-looking, shareable API documentations in .html and .epub format.

To enable doc generation, Elixir needs the ExDoc dependency. Open mix.exs and change the defp deps do (dependencies) section as follows:

defp deps do
[
{:ex_doc, "~> 0.21", only: :dev, runtime: false},
]
end

Run make recache to rebuild the cache image (so make build doesn’t redownload the dependency each time).

Then, let’s add a doc command to the Makefile to generate documentation in the container and then copy it to a bound directory.

.PHONY: run base bind recache doc
# ...snip...
doc: .make/build $(PROJECTFILES)
docker run -it --mount type=bind,source=$(shell pwd),target=/bind erlambda sh -ec "mix docs; cp -rf /vending_fleet/doc /bind/doc"

Then run make doc to generate the docs. You can open docs/index.html in any browser, or, on a mac, type open docs/vending_fleet.epub to open the help document in Apple Books.

That’s it! We’ve covered a lot of ground this time, but we are poised to begin developing our containerized Elixir application in part 3!

--

--