Post

Develop Phoenix applications using Docker

Develop Phoenix applications using Docker

Context

For the last couple of months, I have been developing a few Elixir and Phoenix web applications. I have to admit I really enjoy Phoenix philosophy. For me, it offers the best Ruby on Rails features (quick development, code generators, etc…) but uses an explicit approach over the ‘convention over configuration’ ideology.

But that’s out of the scope of this post. I will write a post in the next days about how I fell in love with Elixir.

So, back to the reason why I am writing this post. Recently, during a class about microservices, I was asked to write a microservice using the language I loved the most.

Without hesitating, I chose Elixir. This was an opportunity to improve my Elixir/Phoenix skills as I have never written a JSON RESTfull API.

It was also an opportunity to share a new programming paradigm with my colleagues as most of them have only written code in PHP, Java and C#.

This was my way to contribute to the amazing Elixir community.

Pre-requirements

In order to be able to follow this article, you need to have a working Phoenix web application.

You will also need to have Docker installed on your machine.

Building the container

Building the container

During my class, I was asked to create two different images for the project: a development and a production one.

Since most of tutorials/articles I found on the Internet only explained how to run production-ready applications on Docker containers, I had to create my own.

One of my requirements was to work on the project that was on the host without having to manually rebuild the container image. Thus, I had to use bind volumes.

In case you don’t know, Phoenix provides generators that make production deployments easy. You can pass --docker to mix phx.gen.release to generate a docker container image.

Based on that file, I built the docker image you can find in the next section.

Developement

The Dockerfile below, uses an image provided by Hex.

As you can see, I install Watchman which is required by Phoenix Live Reloader. It analyses file modifications on a particular directory and perform some actions.

For a development environment, this is important as we do not have to manually reload the web server each time a file is modified.

Some extra packages like procps, iproute2 and lsof are installed for debug purposes only. During the process of creating the development image, I encountered a few network problems regarding one of the best Elixir’s features: remote sessions.

In order to know what process where running on the container as well as what session token was being used, I had to use the ps command combined with the only and only one grep.

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ARG ELIXIR_VERSION=1.14.2
ARG OTP_VERSION=25.1.2
ARG DEBIAN_VERSION=bullseye-20221004-slim

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

FROM ${BUILDER_IMAGE} as builder

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git curl vim bash watchman procps iproute2 lsof\
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

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

# prepare build dir
ENV APP_HOME /app
RUN mkdir $APP_HOME
WORKDIR $APP_HOME

ENV MIX_ENV="dev"

# Elixir remote session port
EXPOSE 4369
EXPOSE 4000

COPY ./entrypoint.sh /app
RUN chmod +x /app/entrypoint.sh

ENTRYPOINT ["/app/entrypoint.sh"]

This Dockerfile exposes two ports:

Note: If you don’t need to export the 4369 port in order to develop a web application in Phoenix using a container. It is a optional feature that can deeply increase your development experience.

If you have any experience working with Docker containers, you know that once you reach a certain level of complexity, building and running containers using the docker command can be painful.

Hence, we will use docker-compose. As I mentioned before, the host directory where your application is stored is mounted on the container using a bind volume.

Also, a network is set so we can easily communicate with the container. Here, we set the ip address of the host to 172.40.0.1 and the container ip address to 172.40.0.2.

Feel free to change and adapt those values according to your needs.

Finally, we tell Docker to execute the file called entrypoint.sh once the container has started.

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
version: "3.3"
services:
  app:
    image: <YOUR_IMAGE_NAME>
    build:
      context: "."
    container_name: <YOUR_CONTAINER_NAME>
    networks:
      phoenix:
        ipv4_address: 172.40.0.2
    ports:
      - "4000:4000"
    environment:
      - <YOUR_ENV_VARIABLE>: ""
    volumes:
      - type: bind
        source: .
        target: /app
networks:
  phoenix:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.40.0.0/24
          gateway: 172.40.0.1

This is a simple shell script that installs and compiles dependencies as well as runs database migrations.

But, the last command is the most important one. It starts a remote session on the container named docker@172.40.0.2 with a cookie named my_cookie.

The session’s name is usually made up of the hostname and ip address of the remote machine. This allows us to identify/list remote nodes.

Without the session cookie, you will not be able to connect to the remote session.

And last but not least, we start the Phoenix web server.

entrypoint.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

set -e

# Ensure the app's dependencies are installed
mix deps.get

# Compile dependencies
mix deps.compile

# Create database and run migrations
mix ecto.create
mix ecto.migrate

# Launch Elixir's remote session
elixir --name docker@172.40.0.2 --cookie my_cookie -S mix phx.server

Running the dock er image as a non-root user

At my class, I used a docker image as part of a CI/CD pipeline. All the tests and other tasks regarding the code quality were run on the docker container.

And, in case you don’t know, when you launch Phoenix’s web server by typing mix phx.server, mix will actually download and compile both the dependencies and the project code base. Since you build and run the docker image as root, _buildand depswill belong to him.

This happens because we are mounting the hosts directory to the docker container /app/ directory.

When I tried to run my CI/CD pipeline, most of the tasks failed due to missing permissions. Thus, I decided to run the docker image as an user other than root.

I knew that I wasn’t the first person to have ever encountered this problem, so a quick research pointed me to this excellent answer by BMitch on Stackoverflow.

You can create an user for the docker image and set the UID and GUID of that user. If you are the only one using your computer, chances are that your UID and GUID are both 1000.

In case you want to set UID and GUID to a value other than 1000 you can pass it as a build option to docker.

Here are the final Dockerfile and docker-compose.yml files which allow you to run the docker container as a non-root user:

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
ARG ELIXIR_VERSION=1.14.2
ARG OTP_VERSION=25.1.2
ARG DEBIAN_VERSION=bullseye-20221004-slim

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

FROM ${BUILDER_IMAGE} as builder

ARG UNAME=bi
ARG UID=1000
ARG GID=1000
RUN groupadd -g $GID -o $UNAME
RUN useradd -m -u $UID -g $GID -o -s /bin/bash $UNAME

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git curl vim bash watchman procps iproute2 lsof\
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

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

# prepare build dir
USER root
ENV APP_HOME /app
RUN mkdir $APP_HOME
WORKDIR $APP_HOME

RUN chown -R $UNAME:$UNAME $APP_HOME

COPY ./entrypoint.sh /app
RUN chmod +x /app/entrypoint.sh

ENTRYPOINT ["/app/entrypoint.sh"]

USER $UNAME

ENV MIX_ENV="dev"

# Elixir remote session port
EXPOSE 4369
EXPOSE 4000

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
version: "3.3"
services:
  app:
    image: <YOUR_IMAGE_NAME>
    build:
      context: "."
      args:
        - "UID=${UID:-1000}"
        - "GID=${GID:-1000}"
    container_name: <YOUR_CONTAINER_NAME>
    networks:
      phoenix:
        ipv4_address: 172.40.0.2
    ports:
      - "4000:4000"
    environment:
      - <YOUR_ENV_VARIABLE>: ""
    volumes:
      - type: bind
        source: .
        target: /app
    user: 1000:1000
networks:
  phoenix:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.40.0.0/24
          gateway: 172.40.0.1

Production

The Dockerfile of the production release was way easier to use as it is generated by mix phx.release –docker.

Based on that file, I only added the lines 77 and 78 which expose the ports used by the EPMD.

Notice that the image’s user is root. If you want to change that, do the same thing we did for the development image.

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
ARG ELIXIR_VERSION=1.14.2
ARG OTP_VERSION=25.1.2
ARG DEBIAN_VERSION=bullseye-20221004-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

# 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 lsof procps\
  && 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

ENV DATABASE_PATH=./<YOUR_PROJECT_NAME>.db

# 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/business_intelligence ./

USER root

# Elixir remote session port
EXPOSE 4369
EXPOSE 4000

CMD ["/app/bin/server"]

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: "3.3"
services:
  app:
    image: <YOUR_IMAGE_NAME>
    build: .
    container_name: <YOUR_CONTAINER_NAME>
    networks:
      phoenix:
        ipv4_address: 172.40.0.2
    ports:
      - "4000:4000"
    environment:
      - YOUR_ENV_VARIABLE=

networks:
  phoenix:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.40.0.0/24
          gateway: 172.40.0.1

You also need to change the following file in your Phoenix application:

env.sh.eex

1
2
3
4
# # Set the release to work across nodes.
# # RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none".
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE="docker@172.40.0.2"

As per the Elixir’s documentation, this file is generated and copied to each generated release.

The RELEASE_NODE environment variable is used to set the node’s name. We need that in order to connect from a remote node.

Demo

To give a better idea of how the observer works I recorded a small demo where I connect to the remote session launched from the Docker container.

All you need to do in order to connect to a remote session is to type the following command:

1
iex --name <YOUR_USERNAME>@<YOUR_IP_ADDRESS> --cookie <YOUR_COOKIE>

Where:

  • <YOUR_USERNAME> is the user of the host
  • <YOUR_IP_ADDRESS> is the ip address of the gateway of the network created for the container. If we use the Dockerfile above as an example, it would be 172.40.0.1.
  • <YOUR_COOKIE> is the same cookie as the one defined in the entrypoint.sh file. In our case, it would be my_cookie.
This post is licensed under CC BY 4.0 by the author.