>> Introspection
Using docker and docker-compose to share a packaged development version of an Elixir application in a team was successful. My Dockerfile and docker-compose.yml is straightforward although could be further optimized:
FROM elixir:1.9.0-alpine
RUN apk --no-cache --update add \
git \
erlang \
alpine-sdk \
gmp-dev \
automake \
libtool \
inotify-tools \
autoconf
ENV HOME="/opt/elixir" \
MIX_HOME="/opt/elixir/mix" \
MIX_BUILD_PATH="/opt/elixir/build" \
HEX_HOME="/opt/elixir/hex" \
PATH="$PATH:/opt/elixir/mix" \
ERL_AFLAGS="-kernel shell_history enabled" \
ELIXIR_ERL_OPTS="+C multi_time_warp"
RUN mkdir -p /opt/elixir/hex && \
mkdir -p /opt/elixir/mix && \
mkdir -p /opt/elixir/build && \
mkdir -p /opt/elixir/.cache
RUN mkdir -p /opt/app && \
mkdir -p /opt/app/deps
RUN chmod -R 777 /opt
RUN mix local.hex --force && mix local.rebar --force
WORKDIR /opt/app
EXPOSE 4000
CMD ["mix", "run", "--no-halt"]
For my development Dockerfile
, I use an alpine Elixir image to
minimize the download size and handpick a few packages to build
dependencies. I place everything related to Elixir in /opt/elixir
so that it can be cached via a docker volume and mount the project in
/opt/app
. I also prepare the deps
folder to be locally cached
which the docker-compose.yml
can illustrate:
version: '3.1'
services:
db:
image: postgres:9.5.17-alpine
restart: always
app:
build:
context: .
dockerfile: Dockerfile
command: "mix phx.server"
entrypoint: /opt/app/docker-entrypoint.sh
volumes:
- .:/opt/app
- elixir:/opt/elixir
- mix_deps:/opt/app/deps
depends_on:
- db
links:
- db:db
volumes:
elixir:
mix_deps:
This setup works usually for an phoenix umbrella application that requires a postgresql database. When the container runs, I have it fetch dependencies and migrations via docker-entrypoint.sh:
#!/bin/bash
set -e
echo "Updating dependencies if any uninstalled packages..."
HEX_HTTP_CONCURRENCY=1 HEX_HTTP_TIMEOUT=1200 mix deps.get
echo "Running migrations if any..."
mix do ecto.create --no-deps-check, ecto.migrate --no-deps-check
echo "Done with housecleaning"
exec "$@"
Running it is direct although with a minor chmod
on mix.lock
since
mix.lock
is updated in the container:
git pull
git checkout feature/branch
docker-compose build
sudo chmod 777 mix.lock
docker-compose up
For the most part, it works without issue; surprisingly, the most frequent issue is that hex.pm fails to fetch dependencies when rebuilding. Even with a good connection, I do run into the issue occasionally with minor annoyance. With poorer connection quality, it becomes a blocker. To mitigate this, is it possible to download all the dependencies locally and use that instead?
>> Local Mirror
In finding a solution, I came across mini_repo, a minimal hex.pm server that can mirror and serve a few remote dependencies. Sadly, it is bare, so I forked it to demonstrate how I tweaked it for docker.
Running it by default, it only mirrors decimal. To make it
configurable in docker-compose, I added an space-delimited
PACKAGES
environment variable:
services:
app:
enviroment:
PACKAGES: >-
decimal
phoenix
# config/dev.exs
packages = System.get_env("PACKAGES", "")
|> String.trim()
|> String.split(" ", trim: true)
config :mini_repo,
repositories: [
hexpm_mirror: [
# only: ~w(decimal)
only: packages,
]
]
# Does not fetch any package
mix run --no-halt
# Fetch one package
PACKAGES="decimal" mix run --no-halt
HEX_MIRROR=http://localhost:4000/repos/hexpm_mirror mix hex.package fetch decimal 1.8.0
# Fetch multiple packages
export HEX_MIRROR=http://localhost:4000/repos/hexpm_mirror
PACKAGES="decimal phoenix" mix run --no-halt
mix hex.package fetch phoenix 1.5.6
The mini_repo logs are promising:
17:17:21.433 [info] GET /repos/hexpm_mirror/packages/phoenix
17:17:21.434 [info] GET /repos/hexpm_mirror/tarballs/phoenix-1.5.6.tar
17:17:21.522 [info] Sent 200 in 88ms
17:17:21.523 [info] Sent 200 in 89ms
However for every package, it downloaded every available version of it:
17:16:24.717 [debug] MiniRepo.Mirror.Server fetching tarball phoenix-1.5.1.tar
17:16:25.301 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.4.17.tar"}
17:16:25.309 [debug] MiniRepo.Mirror.Server fetching tarball phoenix-1.5.2.tar
17:16:26.331 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.0-rc.0.tar"}
17:16:26.336 [debug] MiniRepo.Mirror.Server fetching tarball phoenix-1.5.3.tar
17:16:27.206 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.0.tar"}
17:16:27.228 [debug] MiniRepo.Mirror.Server fetching tarball phoenix-1.5.4.tar
17:16:28.017 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.1.tar"}
17:16:28.029 [debug] MiniRepo.Mirror.Server fetching tarball phoenix-1.5.5.tar
17:16:29.008 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.2.tar"}
17:16:29.022 [debug] MiniRepo.Mirror.Server fetching tarball phoenix-1.5.6.tar
17:16:29.706 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.3.tar"}
17:16:30.375 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.4.tar"}
17:16:30.967 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.5.tar"}
17:16:31.568 [debug] {MiniRepo.Store.Local, :put, "data/repos/hexpm_mirror/tarballs/phoenix-1.5.6.tar"}
This is problematic if the application only uses a single/fixed version of
an dependency. The current application does not support a targeted
version perhaps in a name
-version
format:
PACKAGES="decimal phoenix-1.5.6" mix run --no-halt
In this case, all of the decimal
versions are downloaded while a
specific version for phoenix
. Hacking this behavior can be done by
changing two files:
# config/dev.exs
packages = System.get_env("PACKAGES", "")
|> String.trim()
|> String.split(" ", trim: true)
|> Enum.map(fn package ->
case String.split(package, "-", trim: true, parts: 2) do
[package, version] ->
{package, version}
[package | _] ->
{package, nil}
[] ->
nil
end
end)
|> Enum.reject(&is_nil/1)
# lib/mini_repo/mirror/server.ex
defp sync(mirror) do
# Since mirror.only is now an list of tuples instead of strings
# We need to handle every mirror.only
only_packages = Enum.map(mirror.only, &elem(&1, 0))
versions = for %{name: name} = map <- versions,
!mirror.only or name in only_packages,
into: %{},
do: {name, Map.delete(map, :version)}
end
defp sync_created_packages(mirror, config, diff) do
# Before the main loop, load the pinned version map
only_map = Enum.into(mirror.only, %{})
{:ok, releases} = sync_package(mirror, config, name)
# Filter out releases if a pinned version exists
releases = if pin_version = Map.get(only_map, name, nil) do
Enum.filter(releases, fn release -> release.version == pin_version end)
else
releases
end
end
defp sync_releases(mirror, config, diff) do
# Before the main loop, load the pinned version map
only_map = Enum.into(mirror.only, %{})
{:ok, releases} = sync_package(mirror, config, name)
# Filter out releases if a pinned version exists
releases = if pin_version = Map.get(only_map, name, nil) do
Enum.filter(map.created, fn release -> release == pin_version end)
else
map.created
end
map = %{map | created: releases}
end
Regardless of the experimental hack, how about we test on a new
minimal phoenix
app:
mix archive.install hex phx_new 1.5.6
mix phx.new hello --no-html --no-webpack --no-ecto --no-gettext --no-dashboard
cd hello
HEX_MIRROR=http://localhost:4000/repos/hexpm_mirror mix deps.get
# Change port from 4000 to avoid conflict
sed -i -r s/4000/5000/ config/dev.exs
mix phx.server
Repeatedly running mix deps.get
and updating PACKAGES
until it succeeds:
PACKAGES="cowboy cowboy_telemetry cowlib decimal jason mime phoenix phoenix_html phoenix_pubsub plug plug_cowboy plug_crypto ranch telemetry telemetry_metrics telemetry_poller" mix run --no-halt
If I had to determine the exact version for each package without a
mix.lock
, it would be tedious so the previous hack is a post-setup
optimization. Anyway, the test app runs so our mirror works and time
to dockerize it.
>> Docker
Since this uses mix release
to package the app, I copy over
config/dev.exs
to config/releases.exs
and clean up the repo:
- Remove the
test_repo
and security keys since I only need mirroring, not publishing - Change the package directory to
data
instead ofpriv/data
- Set the fetch timeout to
:infinity
- Set the max concurrency to
nil
to use all machine cores. - Set the sync interval to
:timer.hours(24)
to sync daily instead of 5 minutes.
Since this is a released container, I use this Dockerfile instead:
FROM elixir:1.9.0-alpine AS builder
RUN mix local.hex --force && \
mix local.rebar --force
WORKDIR /opt/app
COPY config ./config
COPY lib ./lib
COPY rel ./rel
COPY mix.exs .
COPY mix.lock .
ENV MIX_ENV=prod
RUN mix deps.get && \
mix deps.compile && \
mix release
FROM elixir:1.9.0-alpine AS app
WORKDIR /opt/app
COPY --from=builder /opt/app/_build .
EXPOSE 4000
ENV PORT=4000
ENV PACKAGES=""
ENV PACAKGE_DIR=/opt/app/hex
CMD ["./prod/rel/mini_repo/bin/mini_repo", "start"]
More importantly, the docker-compose.yml looks good:
version: '3.1'
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: always
volumes:
- ./cache:/opt/app/data
networks:
default:
hexpm:
aliases:
- hexpm_mirror
environment:
PORT: "20000"
PACKAGES: >
cowboy
cowboy_telemetry
cowlib
decimal
jason
mime
phoenix
phoenix_html
phoenix_pubsub
plug
plug_cowboy
plug_crypto
ranch
telemetry
telemetry_metrics
telemetry_poller
networks:
hexpm:
external: true
The important part here is the network
setting which allows another
container to use this. Referencing the development template, this can
be done by adding the HEX_MIRROR
variable and hexpm
network:
version: '3.1'
services:
app:
networks:
default:
electron:
environment:
HEX_MIRROR: "http://hexpm_mirror:20000/repos/hexpm_mirror"
networks:
hexpm:
external: true
Staring the mirror and waiting for it to sync:
docker-compose build
docker network create hexpm
docker-compose up
The Elixir container should now build faster thanks to the local mirror:
docker-compose down -v
docker network create hexpm
docker-compose up
>> Conclusion
While we were able to locally cache certain Elixir dependencies in docker, it did take quite some work and it is not truly optimized. It would be nice if the package caching can be dynamic where the mirror downloads the package if it does not exist. While a static list is fine, it can be stale if a new library is introduced and is not synced which can cause the same build errors. Another lingering issue is the excess downloads which feels ironic but makes sense when the mix dependencies are not pinned to a specific version. Nonetheless, it works and time will tell if it will reduce the network fetch issue.