Background
Last time I was trying to make a serious elixir app was in v1.4. All I can remember about it was that it was hell. So what's changed? In v1.9 we got "releases" which makes deployment a breeze. At the time of writing, in v1.10, there have been further improvements to this.
For my new project, I just want some simple backend APIs and to manage them in a Kubernetes cluster, so it's time to readdress Elixir and Docker. I personally couldn't find exactly what I wanted online and went through some grief trying to accomplish my goals.
My aims were simple:
- An API that returns JSON
- A small container for said API
For the first goal, I struck gold. Jon Lunsford has a great guide for this.
Elixir: Building a Small JSON Endpoint With Plug, Cowboy and Poison
Jon Lunsford ・ May 10 '19 ・ 6 min read
It's served as a great jumping-off point. Although, I chose Jason for my JSON parser, instead of Poison - as it's been shown that it's at least twice as fast.
If you are following along, I would recommend looking over the article embedded above.
Dockerfile
In the above Dockerfile
, let's use the latest alpine version of elixir. On lines 3-5 we COPY
exactly what we need and no more. Then we build the API, and ultimately serve it up ready for requests. How are we doing on the size of the image?
103MB not bad for an OTP application. But, can we improve that?
Multi-stage Dockerfile
Some, if not a lot of that size, is down to the building process and the required toolchain. Multi-stage builds with Docker can be used to eliminate that extra space. Once the build for the app has been done, we just need the release binaries.
Now, let's re-jig the docker file into 2 separate parts: a build stage and an application stage. In the first line, you can see that the same base image has been used, but more importantly, as builder
.
In this part, we don't really care about space used, because it's all going to be removed and only the application stage counts towards the file size of the image. So, we could just as easily COPY .
for the whole directory; it would affect the build context, resulting in slower builds but you get the point. We have much of the same in the building section, copying just what we need and compiling the release binaries.
In the application stage, we choose alpine again. But, just the OS, we don't need the version that comes with elixir. On line 14, we will still need the tools in order to run the release the way we want. On line 16 is where it gets juicy. We just copy the release binaries that we created from the build stage and then serve them up as we did in the earlier docker file.
Now when we build, docker build -t my-app:latest .
Less than 20MB!
Testing
Let's give this a test.
For ease of use in the future, I normally create a script to build and run my images.
Normally, as I only want to do a quick test locally, I like to run in foreground mode so it's easier to see the logs; and with --rm
so I can just ctrl+c out and the container cleans up after itself.
And hey presto, we have a small JSON API running in a tiny image.
Thanks for reading ❤️
Top comments (3)
Nice article. I would like to suggest to show people also possibility to use even more stages. Nice example is using another Docker stage for building assets in case of Phoenix framework. It's easy and just a few lines of code. For example:
Really interesting read!
Not too long and very informative. Will be subscribing to you after this.
Found this so helpful. Thanks!