Working with Elixir Releases and performing CI/CD in containers
Elixir has seen fast adoption in Wego with multiple services already in production after the weekend project UltronEx became a daily driver for monitoring real time data it has spurred a movement to explore new options and build robust resilient systems.
As We run almost all our work loads in containers we take advantage of the same build pipelines for building container images to run CI/CD as well. Elixir runs on BEAM and has some nice features such as hot redeploys that is something anti to the concept of containers where they are immutable images none the less it does not mean Elixir is not container friendly. Mix, the elixir build tool similar to what Rake is for Ruby and has great support to generate a bundle with ERTS called a Release that you can take and run anywhere as long as it is the same platform it was built on. This is what works really well with containers keeping the image size significantly small without any external dependencies. Even though this makes the Elixir Release easy to work with it also strips it of its tool chain that you require to perform actions such as running migration or executing tasks as there is no Mix available in a Release. The below chart shows the different approaches and their features
One might ask why use a Release then for that there is a great thread on Elixir forum on why to Always use Releases. Let's say If you were to package your app with Mix and run it using mix run --no-halt
to overcome issues with missing Mix what difference does it make on the container size ? The elixir image for 1.11.2-erlang-23.1.3
is about 214MB
which is used as the base to either build a release in a multi stage Dockerfile or use it to run the app using Mix. Compared to that Debian
image is only 69.2MB
. When we use an Elixir Release for a Phoenix app and copy that to a stage based on Debian the final image size comes to 101MB
whereas if we were to use Mix and not generate a release the final image size is 627MB
a whopping 6X the release image size.
That is some significant advantage releases have over other approaches and also goes on to show the impact multi stage build files can have over image sizes.
So that sorts that we should always use Releases but how to then get the lost functionality of Mix ? For this Elixir and Docker both do their part to seamlessly make things similar to what you could do with a Ruby CI/CD containerised process with rake. For running database migration with releases Elixir has a work around with Ecto Migrator where in you create a separate file that executes the migrations, it is a standardised code and should actually IMO be part of Ecto
After creating your release you can run you migrations in production with eval
for e.g
$ _build/prod/rel/my_app/bin/my_app eval "MyApp.Release.migrate"
Migration is a special case and Ecto has some nice features to access the Database to make this all possible but for running some tasks in production environment requires a separate approach and some understanding of an Elixir application or for a web application built in Phoenix. Every Elixir application is built of multiple applications where a Phoenix app is a list of applications where Web and Database are separate applications which also gives Elixir applications its robustness where any single application can not bring down your entire Service. For this you can take lead from the elixir release docs and base understanding of elixir application to formulate your application container image to be used to execute tasks and run the application service as well. Let us take a Phoenix app GhostRider
as an example. Here we split the application children into two separate lists base
and web
. Based on what is required we can handle what children load from an environment variable which can conveniently be passed to a container at runtime.
defmodule GhostRider.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
base = [
# Start the Ecto repository
GhostRider.Repo,
{Finch, name: GhostRiderFinch}
]
web = [
# Start the Telemetry supervisor
GhostRiderWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: GhostRider.PubSub},
# Start the Endpoint (http/https)
GhostRiderWeb.Endpoint
# Start a worker by calling: GhostRider.Worker.start_link(arg)
# {GhostRider.Worker, arg},
]
children =
case System.get_env("APP_LEVEL") do
"TASK" -> base
_ -> base ++ web
end
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: GhostRider.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
def config_change(changed, _new, removed) do
GhostRiderWeb.Endpoint.config_change(changed, removed)
:ok
end
end
Though this approach would work but applications only start when a release is started. To overcome this we need to use the eval
to execute a wrapper function bound to perform operation but in order to execute that operation we need to start our application using Application.ensure_all_started(@app)
along with the environment variable controlling which children should be loaded.
With Migrations and Task sorted and capable of working with containers the last piece of the CI/CD puzzle is how do you run your test when there is no Mix in your release and the release is byte code and no test folder is available ? This is where docker shines with its multi stage builds and we leverage docker-compose to target a stage to be able to execute tests in the build to ensure they are consistent across different technology stacks we have.
Now some docker
and docker-compose
recipes to bring this all together to work in a CI/CD pipeline. One thing I have to admit is lack of resources around containerising Elixir/Phoenix applications, something the community should work towards improving. Below is a file for building a Phoenix application and you can see there is a stage called tester
this is what we will target using docker-compose
A standard docker-compose
file to run the application would be similar to this which would spin up a Postgres database and start the application for you.
To build and run your container image you would execute
To run your migration
To run the test you would need to create another docker-compose
file to override the default values and define the target it should execute up to. If you want you could create a completely new file as well
With this you can setup your test database and run test simply by doing
docker-compose -f docker-compose.yml -f docker-compose.test.yml run --rm ghost-rider mix ecto.setup
docker-compose -f docker-compose.yml -f docker-compose.test.yml run --rm ghost-rider mix test
All it takes is some understanding of Elixir and it can be used with Docker tools as any other language to build images and perform CI/CD without any friction.
I hope this helps with containerising Elixir and Phoenix applications as they are amazing technologies along with Docker and containers that make things really simple build pipelines for CI/CD.