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
- has-container?
- list-containers
- raw-command
- raw-exec
- start-container
- start-reaped
- start-runtime
- start-ryuk
- stop-all-reaped
- stop-container
- stop-runtime
- stop-ryuk
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
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?
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?
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?
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"}})})
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?
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?)
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"
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"}]
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*
- *timeout*
- get-ip
- get-published-port
- has-container?
- list-containers
- raw-command
- raw-exec
- start-container
- stop-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?
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
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?
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?
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"})
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"
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"}}]]
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?
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?
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?)
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]