Skip to content

Commit 033a8e3

Browse files
committed
[Docker] Rework implementation, add podman as alternative executable
Docker is a pain, so just accept that it's gonna be slow and do it the safe way, using OverlayMounts and eschewing the entrypoint. This allows me to run `Sandbox` workloads on my Apple M1 laptop.
1 parent ff10c05 commit 033a8e3

File tree

7 files changed

+148
-133
lines changed

7 files changed

+148
-133
lines changed

src/Docker.jl

Lines changed: 55 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,35 @@ import Tar_jll
44
Base.@kwdef mutable struct DockerExecutor <: SandboxExecutor
55
label::String = Random.randstring(10)
66
privileges::Symbol = :privileged
7-
persistence_dir::Union{String,Nothing} = nothing
7+
end
8+
9+
function docker_exe()
10+
return load_env_pref(
11+
"SANDBOX_DOCKER_EXE",
12+
"docker_exe",
13+
something(Sys.which.(("docker", "podman"))..., Some(nothing)),
14+
)
815
end
916

1017
function cleanup(exe::DockerExecutor)
11-
success(`docker system prune --force --filter=label=$(docker_image_label(exe))`)
12-
13-
if exe.persistence_dir !== nothing && isdir(exe.persistence_dir)
14-
# Because a lot of these files are unreadable, we must `chmod +r` them before deleting.
15-
chmod_recursive(exe.persistence_dir, 0o777, true)
16-
try
17-
rm(exe.persistence_dir; force=true, recursive=true)
18-
catch
19-
end
20-
end
18+
success(`$(docker_exe()) system prune --force --filter=label=$(docker_image_label(exe))`)
2119
end
2220

2321
Base.show(io::IO, exe::DockerExecutor) = write(io, "Docker Executor")
2422

2523
function executor_available(::Type{DockerExecutor}; verbose::Bool = false)
2624
# don't even try to exec if it doesn't exist
27-
if Sys.which("docker") === nothing
25+
if docker_exe() === nothing
2826
if verbose
29-
@info("No `docker` command found; docker unavailable")
27+
@info("No `docker` or `podman` command found; DockerExecutor unavailable")
3028
end
3129
return false
3230
end
3331

3432
# Return true if we can `docker ps`; if we can't, then there's probably a permissions issue
35-
if !success(`docker ps`)
33+
if !success(`$(docker_exe()) ps`)
3634
if verbose
37-
@warn("Unable to run `docker ps`; perhaps you're not in the `docker` group?")
35+
@warn("Unable to run `$(docker_exe()) ps`; perhaps you're not in the `docker` group?")
3836
end
3937
return false
4038
end
@@ -68,19 +66,21 @@ function save_timestamp(image_name::String, timestamp::Float64)
6866
end
6967
end
7068

71-
docker_image_name(root_path::String, uid::Cint, gid::Cint) = "sandbox_rootfs:$(string(Base._crc32c(root_path), base=16))-$(uid)-$(gid)"
69+
function docker_image_name(paths::Dict{String,String}, uid::Cint, gid::Cint)
70+
hash = foldl(, vcat(Base._crc32c.(keys(paths))..., Base._crc32c.(values(paths))))
71+
return "sandbox_rootfs:$(string(hash, base=16))-$(uid)-$(gid)"
72+
end
7273
docker_image_label(exe::DockerExecutor) = string("org.julialang.sandbox.jl=", exe.label)
73-
function should_build_docker_image(root_path::String, uid::Cint, gid::Cint)
74+
function should_build_docker_image(paths::Dict{String,String}, uid::Cint, gid::Cint)
7475
# If the image doesn't exist at all, always return true
75-
image_name = docker_image_name(root_path, uid, gid)
76-
if !success(`docker image inspect $(image_name)`)
76+
image_name = docker_image_name(paths, uid, gid)
77+
if !success(`$(docker_exe()) image inspect $(image_name)`)
7778
return true
7879
end
7980

8081
# If this image has been built before, compare its historical timestamp to the current one
81-
curr_ctime = max_directory_ctime(root_path)
8282
prev_ctime = get(load_timestamps(), image_name, 0.0)
83-
return curr_ctime != prev_ctime
83+
return any(max_directory_ctime(path) > prev_ctime for path in values(paths))
8484
end
8585

8686
"""
@@ -92,25 +92,34 @@ things on top of that, with no recursive mounting. We cut down on unnecessary w
9292
somewhat by quick-scanning the directory for changes and only rebuilding if changes
9393
are detected.
9494
"""
95-
function build_docker_image(root_path::String, uid::Cint, gid::Cint; verbose::Bool = false)
96-
image_name = docker_image_name(root_path, uid, gid)
97-
if should_build_docker_image(root_path, uid, gid)
98-
max_ctime = max_directory_ctime(root_path)
95+
function build_docker_image(mounts::Dict, uid::Cint, gid::Cint; verbose::Bool = false)
96+
overlayed_paths = Dict(path => m.host_path for (path, m) in mounts if m.type == MountType.Overlayed)
97+
image_name = docker_image_name(overlayed_paths, uid, gid)
98+
if should_build_docker_image(overlayed_paths, uid, gid)
99+
max_ctime = maximum(max_directory_ctime(path) for path in values(overlayed_paths))
99100
if verbose
100-
@info("Building docker image $(image_name) with max timestamp $(max_ctime)")
101+
@info("Building docker image $(image_name) with max timestamp 0x$(string(round(UInt64, max_ctime), base=16))")
101102
end
102103

104+
# We're going to tar up all Overlayed mounts, using `--transform` to convert our host paths
105+
# to the required destination paths.
106+
tar_cmds = String[]
107+
append!(tar_cmds, ["--transform=s&$(host[2:end])&$(dst[2:end])&" for (dst, host) in overlayed_paths])
108+
append!(tar_cmds, [host for (_, host) in overlayed_paths])
109+
103110
# Build the docker image
104-
open(`docker import - $(image_name)`, "w", verbose ? stdout : devnull) do io
111+
open(`$(docker_exe()) import - $(image_name)`, "w", verbose ? stdout : devnull) do io
105112
# We need to record permissions, and therefore we cannot use Tar.jl.
106113
# Some systems (e.g. macOS) ship with a BSD tar that does not support the
107114
# `--owner` and `--group` command-line options. Therefore, if Tar_jll is
108115
# available, we use the GNU tar provided by Tar_jll. If Tar_jll is not available,
109116
# we fall back to the system tar.
110-
cd(root_path) do
111-
tar = Tar_jll.is_available() ? Tar_jll.tar() : `tar`
112-
run(pipeline(`$(tar) -c --owner=$(uid) --group=$(gid) .`, stdout=io))
113-
end
117+
tar = Tar_jll.is_available() ? Tar_jll.tar() : `tar`
118+
run(pipeline(
119+
`$(tar) -c --owner=$(uid) --group=$(gid) $(tar_cmds)`,
120+
stdout=io,
121+
stderr=verbose ? stderr : devnull,
122+
))
114123
end
115124

116125
# Record that we built it
@@ -121,20 +130,20 @@ function build_docker_image(root_path::String, uid::Cint, gid::Cint; verbose::Bo
121130
end
122131

123132
function commit_previous_run(exe::DockerExecutor, image_name::String)
124-
ids = split(readchomp(`docker ps -a --filter label=$(docker_image_label(exe)) --format "{{.ID}}"`))
133+
ids = split(readchomp(`$(docker_exe()) ps -a --filter label=$(docker_image_label(exe)) --format "{{.ID}}"`))
125134
if isempty(ids)
126135
return image_name
127136
end
128137

129138
# We'll take the first docker container ID that we get, as its the most recent, and commit it.
130139
image_name = "sandbox_rootfs_persist:$(first(ids))"
131-
run(`docker commit $(first(ids)) $(image_name)`)
140+
run(`$(docker_exe()) commit $(first(ids)) $(image_name)`)
132141
return image_name
133142
end
134143

135144
function build_executor_command(exe::DockerExecutor, config::SandboxConfig, user_cmd::Cmd)
136145
# Build the docker image that corresponds to this rootfs
137-
image_name = build_docker_image(config.mounts["/"].host_path, config.uid, config.gid; verbose=config.verbose)
146+
image_name = build_docker_image(config.mounts, config.uid, config.gid; verbose=config.verbose)
138147

139148
if config.persist
140149
# If this is a persistent run, check to see if any previous runs have happened from
@@ -158,7 +167,7 @@ function build_executor_command(exe::DockerExecutor, config::SandboxConfig, user
158167
else
159168
throw(ArgumentError("invalid value for exe.privileges: $(exe.privileges)"))
160169
end
161-
cmd_string = String["docker", "run", privilege_args..., "-i", "--label", docker_image_label(exe)]
170+
cmd_string = String[docker_exe(), "run", privilege_args..., "-i", "--label", docker_image_label(exe)]
162171

163172
# If we're doing a fully-interactive session, tell it to allocate a psuedo-TTY
164173
if all(isa.((config.stdin, config.stdout, config.stderr), Base.TTY))
@@ -178,8 +187,7 @@ function build_executor_command(exe::DockerExecutor, config::SandboxConfig, user
178187
if mount_info.type == MountType.ReadOnly
179188
mount_type_str = ":ro"
180189
elseif mount_info.type == MountType.Overlayed
181-
mount_type_str = ":ro"
182-
push!(overlay_mappings, sandbox_path)
190+
continue
183191
elseif mount_info.type == MountType.ReadWrite
184192
mount_type_str = ""
185193
else
@@ -188,52 +196,6 @@ function build_executor_command(exe::DockerExecutor, config::SandboxConfig, user
188196
append!(cmd_string, ["-v", "$(mount_info.host_path):$(sandbox_path)$(mount_type_str)"])
189197
end
190198

191-
# There are two ways to emulate overlayed mounts in Docker; either by building a
192-
# new image that has `COPY` statements in its Dockerfile to copy in the directories,
193-
# or by manually inserting an entrypoint into container to run the necessary `mount`
194-
# statements for us. We choose the latter as it should be much more efficient.
195-
entrypoint = config.entrypoint
196-
if !isempty(overlay_mappings)
197-
# We need a bindmount to store our persistence data, create one now and mount it in.
198-
# If we're not persistent, we generate a new one every time.
199-
if exe.persistence_dir === nothing || !config.persist
200-
exe.persistence_dir = mktempdir()
201-
end
202-
append!(cmd_string, ["-v", "$(exe.persistence_dir):/.overlayed"])
203-
204-
entrypoint_path = joinpath(exe.persistence_dir, "entrypoint")
205-
open(entrypoint_path, write=true) do io
206-
println(io, """
207-
#!/bin/sh
208-
""")
209-
210-
# For each path in our overlay mappings, we create an overlay that stores its
211-
# state in our persistence directory.
212-
for path in overlay_mappings
213-
path_slug = "$(path)-$(string(hash(path); base=16))"
214-
println(io, """
215-
mkdir -p /.overlayed/$(path_slug)-upper
216-
mkdir -p /.overlayed/$(path_slug)-work
217-
mount -t overlay overlay -olowerdir=$(path) -oupperdir=/.overlayed/$(path_slug)-upper -oworkdir=/.overlayed/$(path_slug)-work $(path)
218-
""")
219-
end
220-
221-
# Finally, if we were given an entrypoint, sub off to that, otherwise directly execute the command:
222-
if config.entrypoint !== nothing
223-
println(io, "exec \"$(config.entrypoint)\"")
224-
else
225-
println(io, "exec \"\$@\"")
226-
end
227-
end
228-
chmod(entrypoint_path, 0o755)
229-
230-
# Tell the later `--entrypoint` argument to use this
231-
entrypoint = "/.overlayed/entrypoint"
232-
233-
# Tell Julia to clean this up before we exit
234-
Base.Filesystem.temp_cleanup_later(entrypoint_path)
235-
end
236-
237199
# Apply environment mappings, first from `config`, next from `user_cmd`.
238200
for (k, v) in config.env
239201
append!(cmd_string, ["-e", "$(k)=$(v)"])
@@ -245,8 +207,8 @@ function build_executor_command(exe::DockerExecutor, config::SandboxConfig, user
245207
end
246208

247209
# Add in entrypoint, if it is set
248-
if entrypoint !== nothing
249-
append!(cmd_string, ["--entrypoint", entrypoint])
210+
if config.entrypoint !== nothing
211+
append!(cmd_string, ["--entrypoint", config.entrypoint])
250212
end
251213

252214
if config.hostname !== nothing
@@ -307,7 +269,7 @@ function export_docker_image(image_name::String,
307269
end
308270

309271
# Get a container ID ready to be passed to `docker export`
310-
container_id = readchomp(`docker create $(image_name) /bin/true`)
272+
container_id = readchomp(`$(docker_exe()) create $(image_name) /bin/true`)
311273

312274
# Get the ID of that container (since we can't export by label, sadly)
313275
if isempty(container_id)
@@ -320,14 +282,14 @@ function export_docker_image(image_name::String,
320282
# Export the container filesystem to a directory
321283
try
322284
mkpath(output_dir)
323-
open(`docker export $(container_id)`) do tar_io
285+
open(`$(docker_exe()) export $(container_id)`) do tar_io
324286
Tar.extract(tar_io, output_dir) do hdr
325287
# Skip known troublesome files
326288
return hdr.type (:chardev,)
327289
end
328290
end
329291
finally
330-
run(`docker rm -f $(container_id)`)
292+
run(`$(docker_exe()) rm -f $(container_id)`)
331293
end
332294
return output_dir
333295
end
@@ -347,7 +309,7 @@ image with `platform`.
347309
"""
348310
function pull_docker_image(image_name::String,
349311
output_dir::String = @get_scratch!("docker-$(sanitize_key(image_name))");
350-
platform::String = "",
312+
platform::Union{String,Nothing} = nothing,
351313
force::Bool = false,
352314
verbose::Bool = false)
353315
if ispath(output_dir) && !isempty(readdir(output_dir))
@@ -363,12 +325,10 @@ function pull_docker_image(image_name::String,
363325

364326
# Pull the latest version of the image
365327
try
366-
p = isempty(platform) ? `` : `--platform $(platform)`
367-
run(`docker pull $(p) $(image_name)`)
368-
catch
369-
if verbose
370-
@warn("Cannot pull", image_name)
371-
end
328+
p = platform === nothing ? `` : `--platform $(platform)`
329+
run(`$(docker_exe()) pull $(p) $(image_name)`)
330+
catch e
331+
@warn("Cannot pull", image_name, e)
372332
return nothing
373333
end
374334

src/Sandbox.jl

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,7 @@ all_executors = Type{<:SandboxExecutor}[
6161

6262
function select_executor(verbose::Bool)
6363
# If `FORCE_SANDBOX_MODE` is set, we're a nested Sandbox.jl invocation, and we should always use whatever it says
64-
executor = nothing
65-
if haskey(ENV, "FORCE_SANDBOX_MODE")
66-
executor = ENV["FORCE_SANDBOX_MODE"]
67-
else
68-
# If we have a preference set, use that.
69-
executor = @load_preference("executor")
70-
end
71-
64+
executor = load_env_pref("FORCE_SANDBOX_MODE", "executor", nothing)
7265
if executor !== nothing
7366
executor = lowercase(executor)
7467
if executor ("unprivilegedusernamespacesexecutor", "unprivileged", "userns")

src/utils.jl

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,20 +247,49 @@ function sudo_cmd()
247247
return _sudo_cmd
248248
end
249249

250-
function default_persist_root_dirs()
251-
dirs = String[]
250+
_env_pref_dict = Dict{AbstractString,Union{AbstractString,Nothing}}()
251+
"""
252+
load_env_pref(env_var, prefs_name, default)
252253
253-
# If the user has set a persistence dir preference, of course try that first:
254-
ppd_pref = @load_preference("persist_dir", nothing)
255-
if ppd_pref !== nothing
256-
push!(dirs, ppd_pref)
254+
Many pieces of `Sandbox.jl` functionality can be controlled either through
255+
environment variables or preferences. This utility function makes it easy
256+
to check first the environment, then preferences, finally falling back to
257+
the default. Additionally, it memoizes the result in a caching dictionary.
258+
"""
259+
function load_env_pref(env_var::AbstractString, prefs_name::AbstractString,
260+
default::Union{AbstractString,Nothing})
261+
if !haskey(_env_pref_dict, env_var)
262+
_env_pref_dict[env_var] = get(ENV, env_var, @load_preference(prefs_name, default))
257263
end
258264

265+
return _env_pref_dict[env_var]
266+
end
267+
268+
"""
269+
default_persist_root_dirs()
270+
271+
Returns the default list of directories that should be attempted to be used as
272+
persistence storage. Influenced by the `SANDBOX_PERSISTENCE_DIR` environment
273+
variable, as well as the `persist_dir` preference. The last place searched by
274+
default is the `persist_dirs` scratch space.
275+
"""
276+
function default_persist_root_dirs()
277+
# While this function appears to be duplicating much of the logic within
278+
# `load_env_pref()`, it actually collects all of the given values, rather
279+
# than choosing just one.
280+
dirs = String[]
281+
259282
# When doing nested sandboxing, we pass information via environment variables:
260283
if haskey(ENV, "SANDBOX_PERSISTENCE_DIR")
261284
push!(dirs, ENV["SANDBOX_PERSISTENCE_DIR"])
262285
end
263286

287+
# If the user has set a persistence dir preference, try that too
288+
ppd_pref = @load_preference("persist_dir", nothing)
289+
if ppd_pref !== nothing
290+
push!(dirs, ppd_pref)
291+
end
292+
264293
# Storing in a scratch space (which is within our writable depot) usually works,
265294
# except when our depot is on a `zfs` or `ecryptfs` mount, for example.
266295
push!(dirs, @get_scratch!("persist_dirs"))

0 commit comments

Comments
 (0)