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

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s