Creating a simple Elixir Phoenix application to deploy in Kubernetes

Sergio Ocón-Cárdenas
11 min readApr 8, 2023

--

When you start working with Kubernetes, or you are interested in refreshing your knowledge, as I have, you start searching for content and end up reading uncountable tutorials on how to deploy applications. That is great, but there is always a similar problem: most of them are oversimplified version of a real deployment and thus are not significant enough to be of help to you. What works with a Python serving an HTML web page or a text, sometimes it is not enough when you deploy a real application.

This tutorial tries to show a real time application that even simple, requires some complex configuration to work on K8s. The application is chosen to be out of the way — it is really not important — , but requires some careful planning and updates to work, so I will be describing the steps taken to make it work again in K8s.

I am working on a second article that will describe more advanced topics, like what to do to configure the database, or how to make an Elixir application run in more than one container.

Step 1. The application

You can find a better description and information about the application in this link, as I am using this free tutorial as a base for this:

The version we are deploying is stored here:

To sum up quickly:

Two web browsers synced

It runs locally flawlessly in development mode, so we have a great start, now we need to see what changes are needed to run it in production.

A live view application is nice because it allows you to have real time updates, efficiently, without knowing frontend development. There are some things to consider making it all work, but will be talking about them lately. For now, just be aware of this:

  • The first time you connect the server will render the full webpage and send it to the browser as HTML
  • It will simultaneously open a websocket using javascript, that will be permanently open, and will send and receive updates. This is a second connection.
  • There is some javascript required to maintain the websocket connection open, and render those parts that get updated when receiving a change

If you are interested in knowing how it works there is a lot of information about it in the Internet, and you have the always friendly elixirforum

Step 2. Running it in a container locally to test it works

I will be using Podman Desktop to run the container while deploying the application. Running it locally reduces the time it takes to understand how the changes affect the deployment. Using a K8s cluster is great, but generating an image and creating a Deployment takes time, and when you have to debug and change things several times, they build up to something bigger.

So the first thing you need is a Dockerfile to generate the container. Fortunately Phoenix has a way of doing releases to be deployed that will work for us:

mix phx.gen.release --docker

That will create a Dockerfile like this:

# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
# instead of Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20230227-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.14.4-erlang-25.3-debian-bullseye-20230227-slim
#
ARG ELIXIR_VERSION=1.14.4
ARG OTP_VERSION=25.3
ARG DEBIAN_VERSION=bullseye-20230227-slim

ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"

FROM ${BUILDER_IMAGE} as builder

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*

# prepare build dir
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force

# set build ENV
ENV MIX_ENV="prod"

# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

COPY priv priv

COPY lib lib

COPY assets assets

# compile assets
RUN mix assets.deploy

# Compile the release
RUN mix compile

# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/

COPY rel rel
RUN mix release

# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

WORKDIR "/app"
RUN chown nobody /app

# set runner ENV
ENV MIX_ENV="prod"

# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/live_view_counter ./

USER nobody

CMD ["/app/bin/server"]

Let’s try to create and run a container using this Dockerfile:

The build process takes some minutes but it works. But there is no error message, so everything has been compiled as expected.

Next step, use that image to run a container with the application:

Let’s run the container as it is (and we know it will fail)

ERROR! Config provider Config.Reader failed with:
{"init terminating in do_boot",** (RuntimeError) environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret

/app/releases/1.7.0/runtime.exs:31: (file)
(elixir 1.14.4) src/elixir.erl:309: anonymous fn/4 in :elixir.eval_external_handler/1
(stdlib 4.3) erl_eval.erl:748: :erl_eval.do_apply/7
(stdlib 4.3) erl_eval.erl:492: :erl_eval.expr/6
(stdlib 4.3) erl_eval.erl:136: :erl_eval.exprs/6
(elixir 1.14.4) src/elixir.erl:294: :elixir.eval_forms/4
(elixir 1.14.4) lib/module/parallel_checker.ex:110: Module.ParallelChecker.verify/1
(elixir 1.14.4) lib/code.ex:425: Code.validated_eval_string/3

{#{'__exception__'=>true,'__struct__'=>'Elixir.RuntimeError',message=><<101,110,118,105,114,111,110,109,101,110,116,32,118,97,114,105,97,98,108,101,32,83,69,67,82,69,84,95,75,69,89,95,66,65,83,69,32,105,115,32,109,105,115,115,105,110,103,46,10,89,111,117,32,99,97,110,32,103,101,110,101,114,97,116,101,32,111,110,101,32,98,121,32,99,97,108,108,105,110,103,58,32,109,105,120,32,112,104,120,46,103,101,110,46,115,101,99,114,101,116,10>>},[{elixir_eval,'__FILE__',1,[{file,"/app/releases/1.7.0/runtime.exs"},{line,31}]},{elixir,'-eval_external_handler/1-fun-2-',4,[{file,"src/elixir.erl"},{line,309},{error_info,#{module=>'Elixir.Exception'}}]},{erl_eval,do_apply,7,[{file,"erl_eval.erl"},{line,748}]},{erl_eval,expr,6,[{file,"erl_eval.erl"},{line,492}]},{erl_eval,exprs,6,[{file,"erl_eval.erl"},{line,136}]},{elixir,eval_forms,4,[{file,"src/elixir.erl"},{line,294}]},{'Elixir.Module.ParallelChecker',verify,1,[{file,"lib/module/parallel_checker.ex"},{line,110}]},{'Elixir.Code',validated_eval_string,3,[{file,"lib/code.ex"},{line,425}]}]}}
init terminating in do_boot ({,[{elixir_eval,__FILE__,1,[{_},{_}]},{elixir,-eval_external_handler/1-fun-2-,4,[{_},{_},{_}]},{erl_eval,do_apply,7,[{_},{_}]},{erl_eval,expr,6,[{_},{_}]},{erl_eval,exprs,6,[{_},{_}]},{elixir,eval_forms,4,[{_},{_}]},{Elixir.Module.ParallelChecker,verify,1,[{_},{_}]},{Elixir.Code,validated_eval_string,3,[{_},{_}]}]})

Crash dump is being written to: erl_crash.dump...done

The error needs some explanation, although it is clear in the decription: in development, elixir will use a predefined SECRET_KEY_BASE, but in production you need to explicitly define it as an environment variable. We forgot to do so, and thus the container crashes.

The configuration is defined in the file config/runtime.exs, let’s open it to see what is included there:

import Config

# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.

# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/live_view_counter start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :live_view_counter, LiveViewCounterWeb.Endpoint, server: true
end

if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""

host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")

config :live_view_counter, LiveViewCounterWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :live_view_counter, LiveViewCounterWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :live_view_counter, LiveViewCounterWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.

# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :live_view_counter, LiveViewCounter.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

The message tells us what we need to do, add `SECRET_KEY_BASE` to the environment variables.

It also let us know that the application will be running in port 4000, let’s configure it and see if that is working.

Creating a secret_key is easy:

# mix phx.gen.secret
TG4gg8mUBpZjLnx7jtKz2/mwHtPrlYHfoadH3nPpr35D2FYoA6WiMykC/DAHRvbG

So when we create our pod we need to copy and past the secret, exposing the proper port so we can connect from outside:

Success!!

The logs tell us that the container is running and ready to accept connections:

We connect and see that the webpage is avaialble

But the buttons do not work and there is a new message after a few seconds

Refresh will work, so we know that the internet didn’t dissapear. It must be that there is something wrong with the javascript part of the application, and opening the developer console will tell us that is in fact the source of the error (the websocket connection can not be established)

It looks like the application is not allowing the host to connect to the websocket. Actually, the container logs tells us more about the error

19:22:42.741 [error] Could not check origin for Phoenix.Socket transport.

Origin of the request: http://127.0.0.1:4000

It can be the PHX_HOST parameter we didn’t worry about. Let’s deploy the container again adding a value for this, and as the error message explains, we can use the connection. PHX_HOST will be 127.0.0.1

This time it works. You can connect and the application runs without problems. Well done!

Photo by Bruno Nascimento on Unsplash

Step 3. Running it in Kubernetes

We will need an image that we can execute on Kubernetes, so we need an additional step: create the image and publish it to a repository.

I am using quay.io to do that. It works, and allows me to have some way of sharing my images with the world (I could use other tools like Docker or GitHub, but I have been used Quay for some time). Yes, it would be great to have private images, but fortunately the code is open source and it is ok to publish it for everybody.

Let’s create an image that we can run. Some clusters have an internal registry that can be used, but we will be using quay.io. As we have run the container locally and we have podman installed, it is quite easy

  1. First login to quay
% docker login quay.io
Username: chargi0
Password:
Login Succeeded

2. Then identify the container you want to publish

% docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
536c07d1c25e dockerfile-live-counter:latest "/app/bin/server" 15 seconds ago Up 14 seconds 4000/tcp stoic_banzai

3. Upload it

# docker commit 536c07d1c25e quay.io/chargi0/container_phoenix_live_view_counter
3a8c34a1e6af8f664aa4e1d8c5c8b8f68dc5168243a7e01f85db3fbfd37167d9

# docker push quay.io/chargi0/container_phoenix_live_view_counter
Using default tag: latest
The push refers to repository [quay.io/chargi0/container_phoenix_live_view_counter:latest]
latest: digest: sha256:750469cf54b12e9fb4175d7ae33d9cd01ac54188c8a1dcf7cbc6edfbe94c15b1 size: 1215

We are ready, now we have an image running. We need to deploy to a running cluster. Let’s stop podman and open Rancher Desktop.

Add the image to Rancher Desktop (be careful as by default the image will be added to the default namespace and you will get an error tying to use it in your container

Once we have the Rancher Desktop running the easiest way to create a Deployment is to use Monokle (monokle.io), and use the advanced template to create a Basic Service Deployment, filling it up with the image you want to use (the one created in the previous step)

Fill in the data you need (don’t forget to map port 4000 so it can be reached)

This will create a Deployment and a Service, that you need to identify and push to the cluster.

It will be running quickly.

You can use Cluster Insights in Monokle to see they are running in fact.

Step 4: Make it public

We want to connect to our cluster, so we need to forward the port so it can be reached in localhost. Just go to Rancher Desktop and add the port forward:

The result:

Now we have real success!!!!

We are ready to start working on more complex applications, as we have a workflow that works end to end.

--

--

Sergio Ocón-Cárdenas
Sergio Ocón-Cárdenas

Written by Sergio Ocón-Cárdenas

Telecoms Engineering, MBA. I have worked in many roles in the industry, from R&D to sales, and now product management

No responses yet