This post outlines a container based development workflow using Vagrant and Docker.
Many common docker tutorials (eg. the official node tutorial) suggest a workflow where projects source is copied onto the image, which is then built and run through docker. This approach is not really practical for clojure development as normal clojure programming leans heavily on rapid prototyping and REPL driven development.
The setup below utilizes Vagrant and docker volumes to setup a development environment which ensures reproducibility and container isolation while retaining the short feedback cycle which clojure developers take pride in.
Our tiny Vagrantfile looks something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
Vagrant.configure("2") do |config| config.vm.define "pedestal-dev", primary: true do |v| v.vm.provider "docker" do |p| p.image = "clojure:lein-2.8.1-alpine" p.create_args = [ "--user=#{Process.uid}:#{Process.gid}", "--workdir=/app/pedestal_demo" ] p.env = { "HOME" => "/home", "_JAVA_OPTIONS" => "-Duser.home=/home" } p.ports = [ "9000:9000", "8080:8080" ] end v.vm.synced_folder ".", "/app/pedestal_demo" v.vm.synced_folder "./.docker_home", "/home" end end |
A multi-machine setup makes it easy for us to later add additional services like databases etc. in the same Vagrant file and manage and run them together.
The current directory is the project directory which will be mounted as /app/pedestal_home
. The synced_folder
terminology is slightly confusing in this context, but as Vagrant docker provider documentation clarifies:
When using Docker, Vagrant automatically converts synced folders and networking options into Docker volumes and forwarded ports
Using the docker-run command we can run arbitrary commands within the docker container.
For example: We can use lein new
to scaffold our pedestal application:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
> $ vagrant docker-run pedestal-dev -- lein new pedestal-service pedestal_demo :to-dir . ==> pedestal-dev: Creating the container... pedestal-dev: Name: pedestal_demo_pedestal-dev_1521227442_1521227442 pedestal-dev: Image: clojure:lein-2.8.1-alpine pedestal-dev: Cmd: lein new pedestal-service pedestal_demo :to-dir . pedestal-dev: Volume: /home/lorefnon/workspace/pedestal_demo:/app/pedestal_demo pedestal-dev: Volume: /home/lorefnon/workspace/pedestal_demo/.docker_home:/home pedestal-dev: pedestal-dev: Container is starting. Output will stream in below... pedestal-dev: pedestal-dev: Retrieving pedestal-service/lein-template/0.5.3/lein-template-0.5.3.pom from clojars pedestal-dev: Retrieving pedestal-service/lein-template/0.5.3/lein-template-0.5.3.jar from clojars pedestal-dev: Generating a pedestal-service application called pedestal_demo. |
This directory structure will be populated directly in the host, and can be edited by an editor (Emacs, obviously) installed in the host operating system
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 |
> $ tree . -a -I ".git|.vagrant" . ├── Capstanfile ├── config │ └── logback.xml ├── Dockerfile ├── .docker_home │ ├── .gitkeep │ ├── .lein │ └── .m2 │ └── repository │ └── pedestal-service │ └── lein-template │ ├── 0.5.3 │ │ ├── lein-template-0.5.3.jar │ │ ├── lein-template-0.5.3.jar.sha1 │ │ ├── lein-template-0.5.3.pom │ │ ├── lein-template-0.5.3.pom.sha1 │ │ └── _remote.repositories │ ├── maven-metadata-clojars.xml │ ├── maven-metadata-clojars.xml.sha1 │ └── resolver-status.properties ├── .gitignore ├── project.clj ├── README.md ├── src │ └── pedestal_demo │ ├── server.clj │ └── service.clj ├── test │ └── pedestal_demo │ └── service_test.clj └── Vagrantfile 12 directories, 19 files |
Not that we are passing current user’s UID and GID in the Vagrantfile through the --user
argument (All create_args are passed directly to docker).
If this was not done, files would be owned by root (docker daemon always runs as root). Marc Campbell has written a good post on how uid and gid work in docker containers.
Also in environment of host we set:
1 2 3 4 |
p.env = { "HOME" => "without docker/home", "_JAVA_OPTIONS" => "-Duser.home=/home" } |
Docker’s default HOME
env variable defaults to /
, which leiningen wouldn’t have write permission to.
I had not expected to be required to set the _JAVA_OPTIONS
but in its absence Maven dependencies are installed in ?/.m2/repo
(Yes, a directory named ?
), presumably because there is no user with specified UID in container context.
Also we don’t want dependencies to be downloaded for each container run, so having them in a synced folder .docker_home
is useful:
1 |
v.vm.synced_folder "./.docker_home", "/home" |
Thanks to the line above, all our maven dependencies are downloaded in .docker_home/.m2/repository
as seen in the tree above.
Also note that we have set our workdir through a create_arg to the project folder which allows us to run lein directory without having to cd into that directory first (Docker’s workdir defaults to /
).
We can invoke lein in a similar fashion to run nrepl:
1 2 3 4 5 6 7 8 9 10 |
> $ vagrant docker-run pedestal-dev -- lein repl :headless :host 0.0.0.0 :port 9000 ==> pedestal-dev: Creating the container... pedestal-dev: Name: pedestal_demo_pedestal-dev_1521148041_1521148041 pedestal-dev: Image: clojure:lein-2.8.1-alpine pedestal-dev: Cmd: bash -c cd /app ; lein repl :headless :host 0.0.0.0 :port 9000 pedestal-dev: Volume: /home/lorefnon/workspace/pedestal_demo:/app pedestal-dev: pedestal-dev: Container is starting. Output will stream in below... pedestal-dev: pedestal-dev: nREPL server started on port 9000 on host 0.0.0.0 - nrepl://0.0.0.0:9000 |
We can use docker ps and docker inspect to find the container ID and IP address of the container respectively.
1 2 3 4 5 6 7 |
lorefnon@ubuntu ~/workspace/pedestal_demo > $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 99681a4c6ad0 clojure:lein-2.8.1-alpine "lein repl :headless…" 2 minutes ago Up 2 minutes pedestal_demo_pedestal-dev_1521227897_1521227897 lorefnon@ubuntu ~/workspace/pedestal_demo > $ docker inspect 99681a4c6ad0 |
docker inspect
outputs a large JSON object, so tools like jq come in handy:
1 2 |
> $ docker inspect 99681a4c6ad0 | jq .[0].NetworkSettings.Networks.bridge.IPAddress "172.17.0.2" |
Now, assuming we have cider installed in emacs, we can invoke: M-x cider-connect
and in the prompt that follows enter above IP address, and 9000
as the port.
This is the port on which we have asked nrepl to bind to, and our Vagrant file maps this port to the same port in host machine.
We should now have direct access to nREPL running on docker within our editor running on host.
To conclude, it should be obvious at this point that we can also run our web server in a similar way:
1 |
$ vagrant docker-run pedestal-dev -- lein run-dev |
After this we should be able to visit the container’s mapped IP address in our browser running in host.
It is not a coincidence that the default port on which pedestal server is configured to listen to, is also configured to be mapped in our Vagrantfile.
Note that we don’t assume that the application is actually deployed as a docker container. There is nothing stopping us from building the application as an uberjar (within docker) and running it through any JVM compatible deployment solution.