Consul & Clojure

Consul is a powerful tool for building effective distributed systems, and lately Clojure has been my language of choice for applications. Getting them to play well together is easy thanks to two libraries by Anatoly Polinsky, aka @tolitius. This post will cover the bare minimum to get Clojure, Consul, Mount, and Envoy to work well together.

While Consul has many usages, we’re going to be using Consul’s ability to serve as a distributed key-value store to share state or update configuration between distributed instances of the same application. One of the frustrations of testing out infrastructure tools is that they need an application to demonstrate their concepts. In this example, we’ll be using a dummy application called consul-printer, which won’t do anything except print messages based on Consul. You can find the finished source for the application, including Consul integration, here.

To start, we’ll run lein new app consul-printer and end up with some basic scaffolding. In project.clj, we’ll add a few dependencies and plugins.

:dependencies [[org.clojure/clojure "1.8.0"]
               [mount "0.1.11-SNAPSHOT"]
               [tolitius/envoy "0.0.1-SNAPSHOT"]
               [clj-time "0.12.2"]
               [jarohen/chime "0.1.9"]
               [environ "1.1.0"]]
:plugins [[lein-environ "1.1.0"]]

We’ll be using mount for mounting components and managing the internal state of the Clojure application, envoy for interacting with Consul, clj-time and chime for periodically printing messages, and environ for pulling environment variables.

Our main file, like many mount based applications, will just contain references to our components and a call to mount/start.

(ns consul-printer.core
  (:require [mount.core :as mount]
            [consul-printer.printer :refer [timer]])

(defn -main
  [& args]

Our printer namespace will initially have only one state, timer. This timer should periodically print a phrase when started, and cancel itself when stopped.

(ns consul-printer.printer
  (:require [mount.core :as mount :refer [defstate]]
            [envoy.core :as envoy]
            [clj-time.core :as t]
            [chime :refer [chime-at]]
            [clj-time.periodic :refer [periodic-seq]]
            [environ.core :refer [env]]))

(def config
  {:printer {:interval 1000
             :phrase "Test"}})

(defn periodic-print
  [interval phrase]
  (chime-at (periodic-seq (t/now) (-> interval t/millis))
            (fn [time]
              (println (str time) phrase))))

(defstate timer
  :start (let [{:keys [interval phrase]} (:printer config)]
           {:interval interval
            :phrase phrase
            :running? true
            :cancel-fn (periodic-print interval phrase)})
  :stop (do
          ((:cancel-fn timer))
          {:running? false}))

Here periodic-print takes advantage of the chime/chime-at function, which can take an infinite sequence and a function, calling that function at each time in the infinite sequence. Our timer pulls from the config variable to configure what we print, and how often we print it.

This is the basic application we’ll be adding Consul functionality to, so now it’s time to set up the dev environment. A straightforward docker-compose.yml file will be used to bring up Consul:

version: "2"

    image: consul:0.7.1
      - "8500:8500"

Running docker-compose up consul from the project directory will bring up a working instance of Consul. From the web app, we can add the printer/interval and printer/phrase keys like so:

Consul Screenshot

Now, to point our Clojure app to Consul, we add a profiles.clj file containing the Consul API URL.

{:dev  {:env {:consul-url "http://localhost:8500/v1/kv"}}
 :test {:env {:consul-url "http://localhost:8500/v1/kv"}}}

At this point, we should start modifying our app to read from Consul. Anytime we introduce dependencies on outside databases in Clojure, we want to wrap that interaction in a state. Furthermore, instead of a hardcoded Clojure map, we’d like to read from Consul and make a map from that. To do that, we’ll be using envoy/consul->map, which takes a Consul URL and returns a nested Clojure map of the values, and replacing the previously defined config.

(defstate config
  :start (envoy/consul->map (env :consul-url)))

If you already had a REPL running, you will need to restart it to properly pull the changes you made to profiles.clj. Running mount/start, we see that the program behaves exactly the same as before. We can update the values in Consul, and refresh them in our app using mount/stop followed by mount/start.

We’d like it if updating Consul automatically restarted our Mount components, though.

(def listener
  (let [restart-vec [#'consul-printer.printer/config #'consul-printer.printer/timer]
        watchers {:printer/interval restart-vec
                  :printer/phrase restart-vec}]
    (mount/restart-listener watchers)))

(defn watch-consul [path]
  (envoy/watch-path path #(mount/on-change listener (keys %))))

(defstate consul-watcher :start (watch-consul (str (config :consul) "/printer"))
  :stop (envoy/stop consul-watcher))

Here we’re using a few important functions: mount/restart-listener, mount/on-change, and envoy/watch-path. To better understand what’s happening, let’s work from the inside out.

mount/restart-listener takes a map, and returns a listener that implements mount’s ChangeListener protocol. Each map entry has a keyword that represents the message being passed to your application to trigger a restart, and a vector of the components to restart.

We use that listener with the function mount/on-change. mount/on-change takes a listener and a collection of keywords, and if the collection of keywords matches any in the listener, actually triggers a restart of the corresponding components.

Finally, envoy/watch-path takes a path, in this case something like http://localhost:8500/v1/kv/printer. Everytime a key on that path is added or changed, or the value of that key is updated, it calls the corresponding function with the relevant changes. In this case we’re calling on-change, looking only at keys of the map that was passed, to see if we should trigger a config refresh.

Surprisingly, that’s it. An entire working example of Clojure and Consul, all in a file less than 40 lines long. Many thanks to Anatoly’s presentation at SoftwareGR that demonstrated these concepts, as well as his example project Hubble which demonstrates the same concepts in a larger application.