Erlang and Elixir in AWS Lambda using Container Images (part 2)
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 character0x09
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?
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 failuresRandomized 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!