lib.docker

container lifecycle helpers for managed runtimes and tests

Start, inspect, and stop Docker containers, with optional automatic cleanup for temporary resources.

1    Overview

The public namespace exposes common container operations and composes them into runtime lifecycle hooks. Temporary containers can be associated with the cleanup service used by development and test workflows.

2    Walkthrough

2.1    Inspect containers

(require '[lib.docker :as docker])

(docker/list-containers)
(docker/has-container? "example-service")
(docker/get-ip "example-service")

2.2    Start and stop a container

(def container
  (docker/start-container
   {:id "example-service"
    :image "redis:7"
    :ports {6379 6379}}))

(docker/stop-container container)

2.3    Attach a runtime

start-runtime adds container identity and networking to a runtime map. stop-runtime respects permanent and secondary container options.

(def runtime
  (docker/start-runtime
   {:lang :redis :tag :cache}
   {:image "redis:7" :suffix "dev"}))

(docker/stop-runtime runtime (:container runtime))

3    API



get-ip ^

[container-id]
Added 4.0

gets the ip of a container

v 4.0
(defn get-ip
  [container-id]
  (first (raw-command ["inspect" "-f"
                       "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
                       container-id]
                      {:format false
                       :json false})))
link
(get-ip (:container-id (ryuk/start-ryuk))) => string?

has-container? ^

[{:keys [id group], :or {group "testing"}}]
Added 4.0

checks that a container exists

v 4.0
(defn has-container?
  ([{:keys [id group]
     :or {group "testing"}}]
   (not (empty? (filter #(-> % :name (= (str group "_" id)))
                        (list-containers))))))
link
(has-container? (ryuk/start-ryuk)) => true

list-containers ^

[] [opts tail]
Added 4.0

gets all local containers

v 4.0
(defn list-containers
  ([]
   (list-containers {} ["-f" "name=.*"]))
  ([opts tail]
   (raw-command ["ps"]
                (merge {:format "{"id":"{{ .ID }}", "image": "{{ .Image }}", "name":"{{ .Names }}"}"}
                       opts)
                (or tail ["-f" "name=.*"]))))
link
(list-containers) => vector?

raw-command ^

[command & [opts tail]]
Added 4.0

executes a docker command

v 4.0
(defn raw-command
  [command & [opts tail]]
  (let [{:keys [host format]} opts
        args (concat ["docker"]
                     (when host ["--host" host])
                     command
                     (cond (= false format)
                           []

                           :else
                           ["--format" (or format "{{json .}}")])
                     tail)]
    (raw-exec args (merge {:json true} opts))))
link
(raw-command ["ps"]) => vector?

raw-exec ^

[args opts]
Added 4.0

executes a shell command

v 4.0
(defn raw-exec
  [args opts]
  (let [lines (->> @(apply os/sh args)
                   (clojure.string/trim)
                   (clojure.string/split-lines)
                   (filter not-empty))]
    (cond->> lines
      (:json opts) (mapv #(json/read % json/+keyword-mapper+)))))
link
(common/raw-exec (concat ["docker" "ps"] (when common/*host* ["--host" common/*host*]) ["--format" "{{json .}}"]) {}) => coll?

start-container ^

[{:keys [group id image cmd flags labels volumes environment ports expose remove detached no-host], :as m, :or {remove true, detached true, group "testing"}} & [repeat]]
Added 4.0

starts a container

v 4.0
(defn start-container
  ([{:keys [group id image cmd flags labels volumes
            environment ports expose remove detached no-host] :as m
     :or {remove true detached true
          group "testing"}}
    & [repeat]]
   (let [volume-args (reduce (fn [acc [from to]]
                               (conj acc "-v" (str from ":" to)))
                             []
                             volumes)
         label-args (reduce (fn [acc [k v]]
                              (conj acc "-l" (str k "=" v)))
                            volume-args
                            labels)
         env-args   (reduce (fn [acc [k v]]
                              (conj acc "-e" (str k "=" v)))
                            label-args
                            environment)
         port-args  (reduce (fn [acc ports]
                              (conj acc "-p"
                                    (if (vector? ports)
                                      (clojure.string/join ":" ports)
                                      ports)))
                            env-args
                            expose)
         base-args  (cond-> port-args
                      (not no-host)
                      (conj "--add-host=host.docker.internal:host-gateway"))
         name       (str group "_" (or id (f/error "Id required")))
         cmd-args   (cond
                      (coll? cmd) cmd
                      (some? cmd) [cmd]
                      :else [])
         args       (-> (concat base-args
                                ["--name" name image]
                                cmd-args)
                        vec)
         run-args   (->> (concat ["docker" "run"]
                                 (when *host* ["--host" *host*])
                                 (when detached ["-d"])
                                 (when remove ["--rm"])
                                 flags
                                 args)
                        (clojure.core/remove nil?)
                        vec)
         cid        (cond
                      (has-container? m)
                      nil

                      :else
                      @(apply os/sh run-args))
         cid        (if (empty? cid)
                      @(os/sh {:args (concat ["docker" "ps"]
                                            (when *host* ["--host" *host*])
                                            ["-aqf" (str "name=^" name "$")])})
                      cid)
         ip         (get-ip cid)]
     (assoc
      (if (or repeat ip)
        (assoc m :container-id cid :container-ip ip :container-name name)
        (start-container m true))
       :args args))))
link
(with-redefs [has-container? (constantly false) os/sh (fn [& _] (delay "cid-1n")) get-ip (constantly "127.0.0.1")] (start-container {:id "hello" :image "redis:latest" :cmd ["redis-server"]})) => (contains {:container-id "cid-1n" :container-ip "127.0.0.1" :container-name "testing_hello"})

start-reaped ^

[{:keys [group id image cmd flags labels environment volumes ports remove detached], :as m, :or {group "testing"}}]
Added 4.0

starts a reaped container

v 4.0
(defn start-reaped
  ([{:keys [group id image cmd flags labels environment volumes ports remove detached] :as m
     :or {group "testing"}}]
   (if (not *ryuk*) (do (alter-var-root #'*ryuk* (constantly :starting))
                        (future (start-ryuk))))
   (common/start-container (-> m
                               (assoc :group group)
                               (assoc-in [:labels "reaped"] true)))))
link
(start-reaped {:id "test" :image "node:16" :ports [6379] :cmd ["node"]}) => map?

start-runtime ^

[rt container]
Added 4.0

starts a runtime with attached container

v 4.0
(defn start-runtime
  [rt container]
  (let [{:keys [id suffix host no-reap]} container
        {:keys [lang tag module]} rt
        start-fn (if no-reap
                   start-container
                   (do (start-ryuk)
                       start-reaped))
        container (assoc container
                         :id (or id (str (name tag) "-" suffix))
                         :labels {"rt/lang"   (name lang)
                                  "rt/module" (or (some-> module name)
                                                  "")})
        {:keys [container-ip
                container-id]} (start-fn container)]
    (assoc rt
           :host container-ip
           :container (assoc container :container-id container-id))))
link
(with-redefs [docker/start-ryuk (fn [] :ryuk) docker/start-reaped (fn [container] {:container-ip "127.0.0.1" :container-id "cid" :container container}) docker/start-container (fn [container] {:container-ip "127.0.0.1" :container-id "cid" :container container})] (docker/start-runtime {:lang :clj :tag :app :module :core} {:suffix "dev"})) => (contains {:host "127.0.0.1" :container (contains {:id "app-dev" :container-id "cid" :labels {"rt/lang" "clj" "rt/module" "core"}})})

start-ryuk ^

[]
Added 4.0

starts the reaper

v 4.0
(defn start-ryuk
  []
  (if-not (common/has-container? +ryuk+)
    (let [m    (common/start-container +ryuk+)
          port (common/get-published-port (:container-id m)
                                          (first (:ports m)))
          _    (network/wait-for-port "127.0.0.1" port
                                      {:timeout common/*timeout*})
          s    (network/socket "127.0.0.1" port)
          r    (cc/relay {:type :socket
                          :attached s})
          _    (cc/send  r "label=reaped=true")]
      (alter-var-root #'*ryuk* (fn [_]
                                 (assoc m :socket s :relay r
                                        :host "127.0.0.1" :host-port port))))
    *ryuk*))
link
(start-ryuk) => map?

stop-all-reaped ^

[]
Added 4.0

stops all reaped

v 4.0
(defn stop-all-reaped
  ([]
   (let [cs (->> (common/list-containers {} ["-f" "label=reaped"])
                 (filter #(-> % :Name (not= "testing_reaper")))
                 (map :id))]
     (when (not-empty cs)
       ;;cs
       (os/sh {:args (concat ["docker" "stop"]
                            (when common/*host* ["--host" common/*host*])
                            cs)})))))
link
(stop-all-reaped) => (any nil? f/wrapped?)

stop-container ^

[{:keys [group id], :as m, :or {group "testing"}}]
Added 4.0

stops a container

v 4.0
(defn stop-container
  ([{:keys [group id] :as m
     :or {group "testing"}}]
   (when (has-container? m)
     @(os/sh {:args (concat ["docker" "kill"]
                           (when *host* ["--host" *host*])
                           [(str group "_" (or id (f/error "Id required")))])}))))
link
(with-redefs [has-container? (constantly true) os/sh (fn [& _] (delay "killed"))] (stop-container {:id "hello"})) => "killed"

stop-runtime ^

[rt container]
Added 4.0

stops a runtime with attached container

v 4.0
(defn stop-runtime
  [rt container]
  (let [{:keys [id secondary permanent]} container]
    
    (when (and (not permanent)
               (not secondary))
      (stop-container container))
    rt))
link
(let [stopped (atom nil)] (with-redefs [docker/stop-container (fn [container] (reset! stopped container))] [(docker/stop-runtime {:id :rt} {:id "cid"}) @stopped])) => [{:id :rt} {:id "cid"}]

stop-ryuk ^

[]
Added 4.0

stops the reaper

v 4.0
(defn stop-ryuk
  []
  (common/stop-container +ryuk+)
  (if *ryuk*
    (let [_ (component/stop  (:relay *ryuk*))
          _ (env/close (:socket *ryuk*))]
      (alter-var-root #'*ryuk* (constantly nil)))))
link
(let [stopped (atom []) original *ryuk*] (try (alter-var-root #'*ryuk* (constantly {:relay :relay :socket :socket})) (with-redefs [common/stop-container (fn [_] :stopped) component/stop (fn [relay] (swap! stopped conj [:relay relay])) env/close (fn [socket] (swap! stopped conj [:socket socket]))] [(stop-ryuk) @stopped *ryuk*]) (finally (alter-var-root #'*ryuk* (constantly original))))) => [nil [[:relay :relay] [:socket :socket]] nil]


*host* ^

NONE
(defonce ^:dynamic *host* (System/getenv "DOCKER_HOST"))
link

*timeout* ^

NONE
(defonce ^:dynamic *timeout* 10000)
link

get-ip ^

[container-id]
Added 4.0

gets the ip of a container

v 4.0
(defn get-ip
  [container-id]
  (first (raw-command ["inspect" "-f"
                       "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
                       container-id]
                      {:format false
                       :json false})))
link
(get-ip (:container-id (ryuk/start-ryuk))) => string?

get-published-port ^

[container-id port]

NONE
(defn get-published-port
  [container-id port]
  (some-> (raw-command ["port" container-id (str port)]
                       {:format false
                        :json false})
          (first)
          (clojure.string/split #":")
          (last)
          (Long/parseLong)))
link

has-container? ^

[{:keys [id group], :or {group "testing"}}]
Added 4.0

checks that a container exists

v 4.0
(defn has-container?
  ([{:keys [id group]
     :or {group "testing"}}]
   (not (empty? (filter #(-> % :name (= (str group "_" id)))
                        (list-containers))))))
link
(has-container? (ryuk/start-ryuk)) => true

list-containers ^

[] [opts tail]
Added 4.0

gets all local containers

v 4.0
(defn list-containers
  ([]
   (list-containers {} ["-f" "name=.*"]))
  ([opts tail]
   (raw-command ["ps"]
                (merge {:format "{"id":"{{ .ID }}", "image": "{{ .Image }}", "name":"{{ .Names }}"}"}
                       opts)
                (or tail ["-f" "name=.*"]))))
link
(list-containers) => vector?

raw-command ^

[command & [opts tail]]
Added 4.0

executes a docker command

v 4.0
(defn raw-command
  [command & [opts tail]]
  (let [{:keys [host format]} opts
        args (concat ["docker"]
                     (when host ["--host" host])
                     command
                     (cond (= false format)
                           []

                           :else
                           ["--format" (or format "{{json .}}")])
                     tail)]
    (raw-exec args (merge {:json true} opts))))
link
(raw-command ["ps"]) => vector?

raw-exec ^

[args opts]
Added 4.0

executes a shell command

v 4.0
(defn raw-exec
  [args opts]
  (let [lines (->> @(apply os/sh args)
                   (clojure.string/trim)
                   (clojure.string/split-lines)
                   (filter not-empty))]
    (cond->> lines
      (:json opts) (mapv #(json/read % json/+keyword-mapper+)))))
link
(common/raw-exec (concat ["docker" "ps"] (when common/*host* ["--host" common/*host*]) ["--format" "{{json .}}"]) {}) => coll?

start-container ^

[{:keys [group id image cmd flags labels volumes environment ports expose remove detached no-host], :as m, :or {remove true, detached true, group "testing"}} & [repeat]]
Added 4.0

starts a container

v 4.0
(defn start-container
  ([{:keys [group id image cmd flags labels volumes
            environment ports expose remove detached no-host] :as m
     :or {remove true detached true
          group "testing"}}
    & [repeat]]
   (let [volume-args (reduce (fn [acc [from to]]
                               (conj acc "-v" (str from ":" to)))
                             []
                             volumes)
         label-args (reduce (fn [acc [k v]]
                              (conj acc "-l" (str k "=" v)))
                            volume-args
                            labels)
         env-args   (reduce (fn [acc [k v]]
                              (conj acc "-e" (str k "=" v)))
                            label-args
                            environment)
         port-args  (reduce (fn [acc ports]
                              (conj acc "-p"
                                    (if (vector? ports)
                                      (clojure.string/join ":" ports)
                                      ports)))
                            env-args
                            expose)
         base-args  (cond-> port-args
                      (not no-host)
                      (conj "--add-host=host.docker.internal:host-gateway"))
         name       (str group "_" (or id (f/error "Id required")))
         cmd-args   (cond
                      (coll? cmd) cmd
                      (some? cmd) [cmd]
                      :else [])
         args       (-> (concat base-args
                                ["--name" name image]
                                cmd-args)
                        vec)
         run-args   (->> (concat ["docker" "run"]
                                 (when *host* ["--host" *host*])
                                 (when detached ["-d"])
                                 (when remove ["--rm"])
                                 flags
                                 args)
                        (clojure.core/remove nil?)
                        vec)
         cid        (cond
                      (has-container? m)
                      nil

                      :else
                      @(apply os/sh run-args))
         cid        (if (empty? cid)
                      @(os/sh {:args (concat ["docker" "ps"]
                                            (when *host* ["--host" *host*])
                                            ["-aqf" (str "name=^" name "$")])})
                      cid)
         ip         (get-ip cid)]
     (assoc
      (if (or repeat ip)
        (assoc m :container-id cid :container-ip ip :container-name name)
        (start-container m true))
       :args args))))
link
(with-redefs [has-container? (constantly false) os/sh (fn [& _] (delay "cid-1n")) get-ip (constantly "127.0.0.1")] (start-container {:id "hello" :image "redis:latest" :cmd ["redis-server"]})) => (contains {:container-id "cid-1n" :container-ip "127.0.0.1" :container-name "testing_hello"})

stop-container ^

[{:keys [group id], :as m, :or {group "testing"}}]
Added 4.0

stops a container

v 4.0
(defn stop-container
  ([{:keys [group id] :as m
     :or {group "testing"}}]
   (when (has-container? m)
     @(os/sh {:args (concat ["docker" "kill"]
                           (when *host* ["--host" *host*])
                           [(str group "_" (or id (f/error "Id required")))])}))))
link
(with-redefs [has-container? (constantly true) os/sh (fn [& _] (delay "killed"))] (stop-container {:id "hello"})) => "killed"


create-compose ^

[{:keys [config network entries]}]
Added 4.0

executes a docker command

v 4.0
(defn create-compose
  [{:keys [config
           network
           entries]}]
  (let [config  (collection/map-entries (fn [[k m]]
                                 [k (assoc m :name (name k))])
                               config)
        order   (collection/map-vals (fn [{:keys [name ip]}]
                              (if ip
                                (f/parse-long (last (clojure.string/split ip #".")))
                                name))
                            config)
        prep    (collection/map-vals (fn [{:keys [type] :as m}]
                              ((or (get entries type)
                                   (f/error "NOT FOUND:" {:type type}))
                               m))
                            config)
        environments (collection/map-vals (fn [{:keys [deps environment]}]
                                   (->> deps
                                        (map (fn [k]
                                               (get-in prep [k :export])))
                                        (apply merge environment)))
                                 config)
        depends-on  (collection/map-vals (fn [{:keys [deps]}]
                                  (mapv name deps))
                                config)]
    (->> prep
         (map (fn [[k {:keys [compose]}]]
                
                [k (create-compose-single
                    compose
                    {:environment (get environments k)
                     :depends-on  (get depends-on k)
                     :network      network
                     :ip-address  (get-in config [k :ip])
                   :override    (dissoc (get config k)
                                        :type :name
                                        :ip :environment :deps)})]))
         (sort-by (comp order first))
         vec)))
link
(compose/create-compose {:config {:minio {:type :minio :ip "172.1.0.10"} :redis-mq {:type :redis.mq :ip "172.1.0.20"} :app {:type :app :image "app-server-2" :ip "172.1.0.50" :ports {80 8080} :deps [:redis-mq :minio]}} :entries {:minio #'entry-minio :redis.mq #'entry-redis-mq :app #'entry-app-server} :network :app-demo}) => [[:minio [[:image "minio/minio"] [:command ["minio" "server" "/data" "--console-address" ":9001"]] [:volumes ["./volumes/minio/data:/data"]] [:ports ["9001:9001"]] [:environment {:MINIO_ROOT_USER "minioadmin", :MINIO_ROOT_PASSWORD "minioadmin", :MINIO_DEFAULT_BUCKETS "image:public,static:public"}] [:healthcheck {:test ["CMD" "curl" "-f" "http://minio:9000/minio/health/live"], :interval "1m"}] [:networks {:app-demo {:ipv4_address "172.1.0.10"}}]]] [:redis-mq [[:image "redis"] [:volumes ["./volumes/redis-mq/data:/opt/redis/data"]] [:command ["redis-server" "--bind" "0.0.0.0" "--dir" "/opt/redis/data" "--appendonly" "yes"]] [:healthcheck {:test ["CMD" "redis-cli" "ping"], :interval "1m"}] [:networks {:app-demo {:ipv4_address "172.1.0.20"}}]]] [:app [[:image "app-server-2"] [:depends_on ["redis-mq" "minio"]] [:healthcheck {:test ["CMD" "curl" "-f" "http://app"], :interval "1m"}] [:environment {:APP_EV_HOST "172.1.0.20", :APP_EV_PORT 6379, :APP_MINIO_HOST "172.1.0.10", :APP_MINIO_PORT 9000, :APP_MINIO_USER "minioadmin", :APP_MINIO_PASS "minioadmin", :APP_MINIO_URL_IMAGE "image"}] [:depends_on ["redis-mq" "minio"]] [:image "app-server-2"] [:ports "80:8080"] [:networks {:app-demo {:ipv4_address "172.1.0.50"}}]]]]

create-compose-single ^

[compose {:keys [environment depends-on network ip-address override]}]
Added 4.0

executes a shell command

v 4.0
(defn create-compose-single
  [compose {:keys [environment
                   depends-on
                   network
                   ip-address
                   override]}]
  (let [keys (mapv first compose)
        vals (mapv second compose)
        lu   (zipmap keys vals)
        m    (cond-> (update-in lu [:environment] merge environment)
               (not-empty depends-on) (assoc :depends_on depends-on))
        keys (cond-> keys
               (not (get lu :environment))   (conj :environment)
               :then (conj :depends_on))
        override (if (map? (:ports override))
                   (assoc override :ports
                          (clojure.string/join ","
                                    (map (fn [[k v]]
                                           (str k ":" v))
                                         (:ports override))))
                   override)
        out  (->> keys 
                  (map (fn [k] [k (get m k)]))
                  (filterv second))
        out  (if (empty? override)
               out
               (reduce conj out override))
        out  (if (:networks lu)
               out
               (conj out 
                     [:networks
                      {network
                       (if ip-address
                         {:ipv4_address ip-address}
                         {})}]))]
    out))
link
(compose/create-compose-single [[:image "postgres:14"] [:environment {:POSTGRES_USER "postgres", :POSTGRES_PASSWORD "postgres", :POSTGRES_DB "statstrade"}] [:volumes ["./volumes/app-db/data:/var/lib/postgresql/data"]] [:healthcheck {:test ["CMD" "pg_isready" "-U" "postgres"], :interval "1m"}]] {:environment {:EV_HOST "172.1.0.20", :EV_PORT 6379}, :depends-on ["redis"], :network :stats_dev_internal :ip-address "172.1.0.10"}) => [[:image "postgres:14"] [:environment {:POSTGRES_USER "postgres", :POSTGRES_PASSWORD "postgres", :POSTGRES_DB "statstrade", :EV_HOST "172.1.0.20", :EV_PORT 6379}] [:volumes ["./volumes/app-db/data:/var/lib/postgresql/data"]] [:healthcheck {:test ["CMD" "pg_isready" "-U" "postgres"], :interval "1m"}] [:depends_on ["redis"]] [:networks {:stats_dev_internal {:ipv4_address "172.1.0.10"}}]]


*ryuk* ^

NONE
(defonce ^:dynamic *ryuk* nil)
link

+ryuk+ ^

NONE
(def +ryuk+)
link

start-reaped ^

[{:keys [group id image cmd flags labels environment volumes ports remove detached], :as m, :or {group "testing"}}]
Added 4.0

starts a reaped container

v 4.0
(defn start-reaped
  ([{:keys [group id image cmd flags labels environment volumes ports remove detached] :as m
     :or {group "testing"}}]
   (if (not *ryuk*) (do (alter-var-root #'*ryuk* (constantly :starting))
                        (future (start-ryuk))))
   (common/start-container (-> m
                               (assoc :group group)
                               (assoc-in [:labels "reaped"] true)))))
link
(start-reaped {:id "test" :image "node:16" :ports [6379] :cmd ["node"]}) => map?

start-ryuk ^

[]
Added 4.0

starts the reaper

v 4.0
(defn start-ryuk
  []
  (if-not (common/has-container? +ryuk+)
    (let [m    (common/start-container +ryuk+)
          port (common/get-published-port (:container-id m)
                                          (first (:ports m)))
          _    (network/wait-for-port "127.0.0.1" port
                                      {:timeout common/*timeout*})
          s    (network/socket "127.0.0.1" port)
          r    (cc/relay {:type :socket
                          :attached s})
          _    (cc/send  r "label=reaped=true")]
      (alter-var-root #'*ryuk* (fn [_]
                                 (assoc m :socket s :relay r
                                        :host "127.0.0.1" :host-port port))))
    *ryuk*))
link
(start-ryuk) => map?

stop-all-reaped ^

[]
Added 4.0

stops all reaped

v 4.0
(defn stop-all-reaped
  ([]
   (let [cs (->> (common/list-containers {} ["-f" "label=reaped"])
                 (filter #(-> % :Name (not= "testing_reaper")))
                 (map :id))]
     (when (not-empty cs)
       ;;cs
       (os/sh {:args (concat ["docker" "stop"]
                            (when common/*host* ["--host" common/*host*])
                            cs)})))))
link
(stop-all-reaped) => (any nil? f/wrapped?)

stop-ryuk ^

[]
Added 4.0

stops the reaper

v 4.0
(defn stop-ryuk
  []
  (common/stop-container +ryuk+)
  (if *ryuk*
    (let [_ (component/stop  (:relay *ryuk*))
          _ (env/close (:socket *ryuk*))]
      (alter-var-root #'*ryuk* (constantly nil)))))
link
(let [stopped (atom []) original *ryuk*] (try (alter-var-root #'*ryuk* (constantly {:relay :relay :socket :socket})) (with-redefs [common/stop-container (fn [_] :stopped) component/stop (fn [relay] (swap! stopped conj [:relay relay])) env/close (fn [socket] (swap! stopped conj [:socket socket]))] [(stop-ryuk) @stopped *ryuk*]) (finally (alter-var-root #'*ryuk* (constantly original))))) => [nil [[:relay :relay] [:socket :socket]] nil]