I figured out how to set up the Traefik Edge Router to front my Elixir and ClojureScript Docker containers. My setup is a bit strange, and I couldn’t find a clear example in the extensive Traefik docs. So I’m writing this up in a quick post.
My problem: Use ClojureScript and Elixir at the same time
I am once again writing a server using Elixir with the front-end in ClojureScript. I like ClojureScript a lot, and should probably just move to using Clojure on the back end, but I also like Phoenix and Elixir. I had a similar suite of Docker containers I put together two years ago in order to produce a slideshow generator (make a movie out of photographs), so I went back to that for my nascent OR Tools route viewing service.
On the client side I’m going to use a Re-Frame application powered by Shadow CLJS. There are several canned starter packages available, and shadow-cljs has really improved on the interop with standard JS modules since the last time I played with it. I’m looking forward to playing around with D3 maps in this environment.
On the server side, I’m using Phoenix. One of the things that I like about Phoenix and Elixir is the friendly vibe on the Elixir mailing lists. Not as peppy and positive as dev.to perhaps, but pretty good nonetheless. The details of setting up a Phoenix app haven’t changed much since I last used it (compared to the larger changes in the ClojureScript world over the same time), although I haven’t done much more than scratch the surface and get a scaffold app up and running.
The real problem is that both Shadow-CLJS and Phoenix set up dev servers
with hot-reloading. Ordinarily this wouldn’t be a problem, except
that lately I insist on running everything in Docker containers on my
laptop. So although they are on the same Docker network as each other
and as my database, they are not on the same IP addresses, and they
are not on my laptop’s host network. So I can either load the Phoenix
app on address 172.18.0.3:4000
, or the CLJS app on address
172.18.0.4:3000
, but given that they’re different hosts it is
difficult to mix the two. I want the CLJS hot reloading when I change
the ClojureScript, and I want the Phoenix hot reloading when I change
the Phoenix templates, and I don’t want to figure out a bunch of
cross-origin scripting rules and permissions for this development-only
situation.
Solution: Use Traefik as a web proxy
Armed with the knowledge that I was able to solve this two years ago, I hunted around in my old config files and on the Elixir mailing list. I came across this thread and remembered how I did it, but I also remembered that I gave up on the ClojureScript hot reloading. In re-reading the Elixir forum posts in that thread, I saw again the note about one alternative solution to put both services behind a Traefik web proxy. I missed that two years ago, but ironically earlier this week I came across a mention of Traefik and was poking around its codebase and docs seeing how it was different from nginx. So this seemed like a perfect time to improve on my past solution, and to learn how to use the Traefik “edge router”.
More problems: How do you set this thing up?
As usual, many of my problems are of my own making. The Traefik docs seem to emphasize using Docker Compose. I don’t like Docker Compose, and prefer to set up small bash scripts to launch Docker containers, because reasons (as my daughters might say). Further, while the Traefik docs are quite detailed and cover lots of cases, I couldn’t figure out what the heck was going on.
The main selling point and story is that Traefik just works—set it up and it will autodiscover and proxy all your Docker containers' services. This is really only half the story. I set it up using the default configuration file, and indeed the Traefik server appeared to auto-discover my PostgreSQL container, the Elixir container, the ClojureScript container, and even my email client container. However, Traefik did not automatically route traffic from incoming web requests to those containers’ services. I needed to do some manual configuration.
Solution: Use the right labels.
While I’m still pretty hazy on the exact details, it appears that what I was missing on my first few attempts was that one can draw the line from the incoming request though the router to the Docker container’s service by using labels when firing up the Docker container. The last piece of the puzzle that took me lots of trial and error to figure out was that each container needs to have the correct “rule” label, so that Traefik knows which requests to pipe to which container.
First, my idea was and is to use ports to decide which service I want to hit. Sticking with the ports on the tin, I want 4000 to redirect to the Elixir container, and both 3000 and 9630 to redirect to my ClojureScript container. Because I’m not planning on using the 3000 port directly, I am initially just redirecting the 9630 port (the hot reloading web service) to the ClojureScript container. I also do not want to expose my email and my database to the router.
To accomplish this, I use the following setup for Traefik, which is just a slightly modified version of the V2 demo setup.
For the most part, I’ve deleted a lot of the extra comments from the original.
The two changes I made to the original sample configuration are first to set up “entry points” for port 4000 and port 9630. An entry point helps to define the origin of a path through the router. In this case, I want one path for Elixir (port 4000), and another path for ClojureScript (port 9630).
The second change I made is in the Docker configuration. The default is to expose all containers. I changed this rule to false, as I don’t want things like email clients, databases, photo-editing software, PDF readers, and so on being exposed by accident to the outside world.
I’ve also kept in one of the original comments in the Docker configuration, because I missed this key bit of information when I first set this up. The default “rule” is to just normalize the container’s name. I’m not sure how this might be useful in practice, but for my setup, it is pretty much useless.
Modify the standard Traefik V2 configuration example
[global]
checkNewVersion = true
sendAnonymousUsage = true
# Entrypoints definition
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.websecure]
address = ":443"
# my extra entry points for phoenix and cljs
[entryPoints.phoenix]
address = ":4000"
[entryPoints.cljs]
address = ":9630"
# Traefik logs
[log]
# set to debug while figuring this out!
level = "DEBUG"
# Enable API and dashboard
[api]
# Enabled Dashboard
dashboard = true
# Enable Docker configuration backend
[providers.docker]
# Default host rule.
# Default: "Host(`{{ normalize .Name }}`)"
# Do not expose containers by default in traefik
exposedByDefault = false
Next, in order to allow a container to be used by the above setup, I had to tweak my Docker bash scripts to use labels.
In each of “shell_elixir” and “shell_cljs”, I fire up the Docker
container using --label
options. Since they’re both essentially the
same, I’ll talk about the labels I used for the Elixir one in detail.
--label traefik.enable=true
This label tells the Traefik server that this Docker container is in play.
traefik.http.services.elixirshell.loadbalancer.server.port=4000
This rule tells Traefik the port to use for my container. I need to
be explicit about this for two reasons. First, I use named networks
via the --network=postgres_nw
flag rather than using the host
network. Second, I do not expose ports, because this is largely
unnecessary when containers are on the same Docker-defined network.
So the “usual” way that Traefik discovers the ports to use for Docker
containers are not in play. It cannot inspect the container for an
open port, because there are no open ports.
In order to solve this issue, this flag identifies which port to use. One thing I do not yet know how to do is to expose two ports in a container. For example, the ClojureScript dev server uses port 3000 for regular HTTP requests, and port 9630 for websocket requests for live code reloading. I don’t (yet) know the logic behind how Traefik determines “services” names versus “routers” names, and how those relate to the container name. Obviously, from reading the log files without any labels set, it is clear that there is some default mechanism that names the routers and the services in a container, but I can’t figure out where I might override those names.
traefik.http.routers.elixirshell.entrypoints=phoenix
The next label defines the entrypoint that should be connected to the
container. There are plenty of examples of combinations of entry
points in the documentation, but my use case is pretty simple. All I
want to do is wire up incoming port 4000 to port 4000 in the Elixir
container, and incoming port 9630 to port 9630 in the ClojureScript
container. I’ve defined the entrypoints “phoenix” and “cljs” in the
traefik.toml
file shown earlier, and they get used here.
When invoking containers, add the labels Traefik needs
shell_cljs(){
relies_on_network postgres_nw
docker run -it \
--rm \
-v ${PWD}:/workspace \
-w /workspace \
-v /etc/localtime:/etc/localtime:ro \
--user $(id -u):$(id -g) \
--network=postgres_nw \
--label traefik.enable=true \
--label traefik.http.services.cljsshell.loadbalancer.server.port=9630 \
--label traefik.http.routers.cljsshell.entrypoints=cljs \
--label 'traefik.http.routers.cljsshell.rule=PathPrefix(`/`)' \
--name cljsshell \
jmarca/cljs-base /bin/bash
}
shell_elixir(){
relies_on postgres
relies_on_network postgres_nw
docker run -it \
--rm \
-v ${PWD}:/home/user \
-w /home/user/phx_server \
-v /etc/localtime:/etc/localtime:ro \
--user $(id -u):$(id -g) \
-e "PGPASS=${PGPASS}" \
-e "PGUSER=${PGUSER}" \
-e "PGHOST=postgres" \
-e "PGDATABASE=${PGDATABASE}" \
--network=postgres_nw \
--label traefik.enable=true \
--label traefik.http.services.elixirshell.loadbalancer.server.port=4000 \
--label traefik.http.routers.elixirshell.entrypoints=phoenix \
--label 'traefik.http.routers.elixirshell.rule=PathPrefix(`/`)' \
--name elixirshell \
jmarca/elixir-base /bin/bash
}
The last rule label, the traefik.http.routers.elixirshell.rule
, took
me an age to figure out, and is really the reason why I wrote this
post. The first problem was that I had no idea it was necessary. I had the
containers running with the correct port specified and the correct
entry point set up, but I was missing the need for the “rule” entry
that mapped incoming requests to this container. For some reason, I
just assumed that everything coming to the entry point would
automatically get routed to this container.
I expected that Traefik would by default take all incoming requests to an entrypoint and map them to the specified port of the Docker container’s service. I thought it would know enough given what I’d specified to make this leap. But nope! I was sadly mistaken. All I got was an acknowledgment that the Router was up, but nothing was working, and I had no clue as to why nothing was happening.
As I read and re-read the examples with their simple file servers and
whois servers, I gradually began to understand that I needed some sort
of rule in order to map the incoming request to the Docker container.
There doesn’t seem to be a wildcard globbing path, but I noticed that
the default Traefik router used 'PathPrefix(
/)'
. So I did the
same thing with my …cljsshell.rule
and …elixirshell.rule
labels,
and it worked.