Multi-stage docker with nodejs

Multi-stage builds are mostly popular when using compiled languages, with heavy toolchains. For a node app, you would expect to need just the js files, and any dependencies.

I was therefore surprised to find that our images were weighing in at over 500MB, when the base node:18-alpine image is a mere 174MB. After some judicious commenting out, it seemed like the culprit was npm (shocker).

Moving the npm(/yarn) install to a discarded layer could save > 50%:

FROM node:18-alpine AS builder
WORKDIR /build
RUN --mount=type=ssh yarn install

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules
...

Minimal web app with cowboy & rebar3

The erlang docker images now include rebar3, so it’s very easy to get started with a new project (the name needs to be an erlang term, so underscores not dashes!):

$ docker run -v $PWD:/app/foo_app -w /app erlang:25 rebar3 new release foo_app
Unable to find image 'erlang:25' locally
25: Pulling from library/erlang
e756f3fdd6a3: Pull complete 
...
Digest: sha256:4eafc58e4475a7be2416af55ea142a7cd00c14b6ec2490a38db3a0869efde7e4
Status: Downloaded newer image for erlang:25
===> Writing foo_app/apps/foo_app/src/foo_app_app.erl
===> Writing foo_app/apps/foo_app/src/foo_app_sup.erl
===> Writing foo_app/apps/foo_app/src/foo_app.app.src
===> Writing foo_app/rebar.config
===> Writing foo_app/config/sys.config
===> Writing foo_app/config/vm.args
===> Writing foo_app/.gitignore
===> Writing foo_app/LICENSE
===> Writing foo_app/README.md

As dockerd is running as root, you then need to chown the generated files (you can run the docker cmd as the current user, but that didn’t go well when I tried it).

Then you need a Dockerfile:

# Build stage 0
FROM erlang:25-alpine

RUN apk add --no-cache git

# Set working directory
RUN mkdir /buildroot
WORKDIR /buildroot

# Copy our Erlang test application
COPY rebar.config .
COPY apps/ apps/
COPY config/ config/

# And build the release
RUN rebar3 as prod release

# Build stage 1
FROM alpine

# Install some libs
RUN apk add --no-cache openssl ncurses-libs libstdc++

# Install the released application
COPY --from=0 /buildroot/_build/prod/rel/foo_app /foo_app

# Expose relevant ports
EXPOSE 8080

CMD ["/foo_app/bin/foo_app", "foreground"]

At this point, you should be able to build the image:

$ docker build -t foo_app .
Sending build context to Docker daemon  104.4kB
Step 1/13 : FROM erlang:25-alpine
25-alpine: Pulling from library/erlang
2408cc74d12b: Already exists 
1e90e213ba89: Pull complete 
Digest: sha256:1fdd18a383206eeba257f18c5dd22d7f381942eda4cd76a88e36a1d3247c4130
Status: Downloaded newer image for erlang:25-alpine
 ---> 8f1437fc7749
Step 2/13 : RUN apk add --no-cache git
 ---> Running in 1af099a684a7
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
(1/6) Installing brotli-libs (1.0.9-r6)
(2/6) Installing nghttp2-libs (1.47.0-r0)
(3/6) Installing libcurl (7.83.1-r1)
(4/6) Installing expat (2.4.8-r0)
(5/6) Installing pcre2 (10.40-r0)
(6/6) Installing git (2.36.1-r0)
Executing busybox-1.35.0-r13.trigger
OK: 23 MiB in 30 packages
Removing intermediate container 1af099a684a7
 ---> 99d4529dc490
Step 3/13 : RUN mkdir /buildroot
 ---> Running in 47daa05e777b
Removing intermediate container 47daa05e777b
 ---> ac097064f098
Step 4/13 : WORKDIR /buildroot
 ---> Running in 51479ca5c35a
Removing intermediate container 51479ca5c35a
 ---> 649f740b900b
Step 5/13 : COPY rebar.config .
 ---> 3fe63710afa6
Step 6/13 : COPY apps/ apps/
 ---> 91e46ef9dfc9
Step 7/13 : COPY config/ config/
 ---> 4194973d4e23
Step 8/13 : RUN rebar3 as prod release
 ---> Running in 7e7a2112d86e
===> Verifying dependencies...
===> Analyzing applications...
===> Compiling foo_app
===> Assembling release foo_app-0.1.0...
===> Release successfully assembled: _build/prod/rel/foo_app
Removing intermediate container 7e7a2112d86e
 ---> 91154e8634db
Step 9/13 : FROM alpine
latest: Pulling from library/alpine
2408cc74d12b: Already exists 
Digest: sha256:686d8c9dfa6f3ccfc8230bc3178d23f84eeaf7e457f36f271ab1acc53015037c
Status: Downloaded newer image for alpine:latest
 ---> e66264b98777
Step 10/13 : RUN apk add --no-cache openssl ncurses-libs libstdc++
 ---> Running in 935857dec575
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
(1/5) Installing libgcc (11.2.1_git20220219-r2)
(2/5) Installing libstdc++ (11.2.1_git20220219-r2)
(3/5) Installing ncurses-terminfo-base (6.3_p20220521-r0)
(4/5) Installing ncurses-libs (6.3_p20220521-r0)
(5/5) Installing openssl (1.1.1o-r0)
Executing busybox-1.35.0-r13.trigger
OK: 9 MiB in 19 packages
Removing intermediate container 935857dec575
 ---> e62baafed2cd
Step 11/13 : COPY --from=0 /buildroot/_build/prod/rel/foo_app /foo_app
 ---> d768a4b774ea
Step 12/13 : EXPOSE 8080
 ---> Running in 11f013006a16
Removing intermediate container 11f013006a16
 ---> 0e347e1c2d46
Step 13/13 : CMD ["/foo_app/bin/foo_app", "foreground"]
 ---> Running in 7bf493256ded
Removing intermediate container 7bf493256ded
 ---> dc0f1632d27f
Successfully built dc0f1632d27f
Successfully tagged foo_app:latest

This is a good approach for building a final deployable image, but it’s a bit painful for local development, and doesn’t make the best use of the docker layer caching. You probably want to use a mounted vol instead.

You should now be able to run the application:

$ docker run -p 8080:8080 --rm foo_app
Exec: /foo_app/erts-13.0.1/bin/erlexec -noinput +Bd -boot /foo_app/releases/0.1.0/start -mode embedded -boot_var SYSTEM_LIB_DIR /foo_app/lib -config /foo_app/releases/0.1.0/sys.config -args_file /foo_app/releases/0.1.0/vm.args -- foreground
Root: /foo_app
/foo_app

Unfortunately Ctrl+C doesn’t work, so you need to docker kill it.

This isn’t a web app, yet, so we need to add cowboy to the dependencies list in rebar.config:

{deps, [
    {cowboy,"2.9.0"}
]}.

I’m using the latest tag, but you might want a specific version. If you build again, you should see some extra steps:

Step 8/13 : RUN rebar3 as prod release
 ---> Running in 95791181399b
===> Verifying dependencies...
===> Fetching cowboy v2.9.0
===> Fetching cowlib v2.11.0
===> Fetching ranch v1.8.0
===> Analyzing applications...
===> Compiling cowlib
===> Compiling ranch
===> Compiling cowboy
===> Analyzing applications...
===> Compiling foo_app
===> Assembling release foo_app-0.1.0...
===> Release successfully assembled: _build/prod/rel/foo_app

You also need to add cowboy to the list in the app.src file:

  {applications,
   [kernel,
    stdlib,
    cowboy
   ]},

You can then configure cowboy, in your _app file:

start(_StartType, _StartArgs) ->
    Port = 8080,
    Dispatch = cowboy_router:compile([
        {'_', [{"/ping", ping_handler, []}]}
    ]), 
    {ok, _} = cowboy:start_clear(my_http_listener,
        [{port, Port}],
        #{env => #{dispatch => Dispatch}}
    ), 
    foo_app_sup:start_link().

And create a handler:

-module(ping_handler).

-export([init/2]).

init(Req, State) ->
    {ok, Req, State}.

This is the absolute minimum, and will return a 204 for any request:

$ curl -I -XGET "http://localhost:8080/ping"
HTTP/1.1 204 No Content

Or, for something a bit more realistic:

init(#{method := Method} = Req, _State) ->
    handle_req(Method, Req).

handle_req(<<"GET">>, Req) ->
    {ok, text_plain(Req, <<"pong">>)};

handle_req(_Method, Req) ->
    cowboy_req:reply(404, Req).

text_plain(Request, ResponseBody) ->
    ResponseHeaders = #{
        <<"content-type">> => <<"text/plain">>
    },--
    cowboy_req:reply(200, ResponseHeaders, ResponseBody, Request).

This will return some text, for a GET:

$ curl "http://localhost:8080/ping"
pong

And a 404 for any other HTTP method:

$ curl -I -XPOST "http://localhost:8080/ping"
HTTP/1.1 404 Not Found
content-length: 0
date: Fri, 17 Jun 2022 11:54:35 GMT
server: Cowboy

Using docker, instead of virtualenv

If you fancy a change, you just need a Dockerfile:

FROM python:3.7

RUN pip install pytest
COPY requirements.txt  .
RUN  pip3 install -r requirements.txt

and a pytest.ini, in the root (don’t ask):

[pytest]
pythonpath = .

Then you can build the image:

docker build -t foo .

And run the tests:

docker run -it --rm -v $PWD:/app -w /app foo pytest tests/

Or run the app locally, e.g. a lambda func:

docker run -it --rm -v $PWD:/app -w /app -e PGHOST=... -e PGUSER=... -e PGPASSWORD=... foo python -c 'import app; app.bar(None, None)'

Is it better? Probably not, you’re just swapping one set of problems for a different set 🤷

Bootstrapping node in docker

It always seems to take me a few attempts to get this right, so I’m making a note here:

docker run -it -v $PWD:/app -w /app node:12-alpine npm init

You may need to chown the package.json after (the docker daemon runs as root).

You can use the same thing to add packages:

docker run -it -v $PWD:/app -w /app node:12-alpine npm i --save foo

Using rebar3 with Docker

I prefer to avoid installing dev tools on my laptop. I used to create a separate Vagrant instance for each project, but using Docker should provide a lighter weight alternative.

Assuming you already have the Docker tooling installed, the first thing to do is download the base container:

docker pull erlang

Next, download the latest rebar3 binary:

curl -OL https://s3.amazonaws.com/rebar3/rebar3 && chmod +x ./rebar3

At this point, you’re ready to create the app skeleton:

docker run --name $APP_NAME -it --rm -v ${PWD}:/app -w /app erlang ./rebar3 new app $APP_NAME

Annoyingly, rebar assumes that it should create the folder for the project, so you’ll need to move all the generated files up a level (or put up with an extra subdir). Also, the generated files are owned by root, so you probably want to chown the entire dir.

If you’re going to use any rebar plugins, you probably want to put it’s cache dir somewhere that will survive container restarts. Add this to your rebar.config:

{global_rebar_dir, "/app/.cache"}.

And then you can compile your app:

docker run --name $APP_NAME -it --rm -v ${PWD}:/app -w /app -e REBAR_CACHE_DIR=/app erlang ./rebar3 compile

SSH keys in Docker

One of the first stumbling blocks, when using Docker, is often discovering that your SSH keys are on the wrong side of the hatchway.

A simple solution is just to copy them, when building the image:

COPY ~/.ssh /root/.ssh

And this is reasonable, if the image is never going to leave your laptop. Any respectable tinfoil hat wearing neckbeard will look for an alternative though.

An obvious next step is to mount the same folder, as a volume:

SSH=~/.ssh
docker run -it --rm -v ${SSH}:/root/.ssh app npm install

Unfortunately, you’ll probably find at this point that the permissions are not correct inside the container, and that if you try and change them, they are hopelessly entangled with the permissions outside the container.

After much googling, and trying ideas from StackOverflow, the best solution I found was to use the same ssh-agent as the host:

SSH=~/.ssh
docker run -it --rm -v ${SSH}:/root/.ssh -v $SSH_AUTH_SOCK:/ssh-agent -e SSH_AUTH_SOCK=/ssh-agent app npm install

(I’m still mounting the .ssh folder, but only to recycle my known_hosts file).

Or, using compose:

version: '2' 
services:
  app:
    build: .
    volumes:
      - .:/app
      - ~/.ssh:/root/.ssh
      - $SSH_AUTH_SOCK:/ssh-agent