diff options
Diffstat (limited to 'www/containers.md')
| -rw-r--r-- | www/containers.md | 996 |
1 files changed, 996 insertions, 0 deletions
diff --git a/www/containers.md b/www/containers.md new file mode 100644 index 0000000..ac5f02f --- /dev/null +++ b/www/containers.md @@ -0,0 +1,996 @@ +# OCI Containers + +This document shows how to build Fossil into [OCI] compatible containers +and how to use those containers in interesting ways. We start off using +the original and still most popular container development and runtime +platform, [Docker], but since you have more options than that, we will +show some of these options later on. + +[Docker]: https://www.docker.com/ +[OCI]: https://opencontainers.org/ + + +## 1. Quick Start + +Fossil ships a `Dockerfile` at the top of its source tree, +[here][DF], which you can build like so: + + $ docker build -t fossil . + +If the image built successfully, you can create a container from it and +test that it runs: + + $ docker run --name fossil -p 9999:8080/tcp fossil + +This shows us remapping the internal TCP listening port as 9999 on the +host. This feature of OCI runtimes means there’s little point to using +the “`fossil server --port`” feature inside the container. We can let +Fossil default to 8080 internally, then remap it to wherever we want it +on the host instead. + +Our stock `Dockerfile` configures Fossil with the default feature set, +so you may wish to modify the `Dockerfile` to add configuration options, +add APK packages to support those options, and so forth. + +The Fossil `Makefile` provides two convenience targets, +“`make container-image`” and “`make container-run`”. The first creates a +versioned container image, and the second does that and then launches a +fresh container based on that image. You can pass extra arguments to the +first command via the Makefile’s `DBFLAGS` variable and to the second +with the `DCFLAGS` variable. (DB is short for “`docker build`”, and DC +is short for “`docker create`”, a sub-step of the “run” target.) +To get the custom port setting as in +second command above, say: + + $ make container-run DCFLAGS='-p 9999:8080/tcp' + +Contrast the raw “`docker`” commands above, which create an +_unversioned_ image called `fossil:latest` and from that a container +simply called `fossil`. The unversioned names are more convenient for +interactive use, while the versioned ones are good for CI/CD type +applications since they avoid a conflict with past versions; it lets you +keep old containers around for quick roll-backs while replacing them +with fresh ones. + +[DF]: /file/Dockerfile + + +## 2. <a id="storage"></a>Repository Storage Options + +If you want the container to serve an existing repository, there are at +least two right ways to do it. + +The wrong way is to use the `Dockerfile COPY` command, because by baking +the repo into the image at build time, it will become one of the image’s +base layers. The end result is that each time you build a container from +that image, the repo will be reset to its build-time state. Worse, +restarting the container will do the same thing, since the base image +layers are immutable. This is almost certainly not what you +want. + +The correct ways put the repo into the _container_ created from the +_image_, not in the image itself. + + +### <a id="repo-inside"></a> 2.1 Storing the Repo Inside the Container + +The simplest method is to stop the container if it was running, then +say: + + $ docker cp /path/to/my-project.fossil fossil:/museum/repo.fossil + $ docker start fossil + $ docker exec fossil chown -R 499 /museum + +That copies the local Fossil repo into the container where the server +expects to find it, so that the “start” command causes it to serve from +that copied-in file instead. Since it lives atop the immutable base +layers, it persists as part of the container proper, surviving restarts. + +Notice that the copy command changes the name of the repository +database. The container configuration expects it to be called +`repo.fossil`, which it almost certainly was not out on the host system. +This is because there is only one repository inside this container, so +we don’t have to name it after the project it contains, as is +traditional. A generic name lets us hard-code the server start command. + +If you skip the “chown” command above and put “`http://localhost:9999/`” +into your browser, expecting to see the copied-in repo’s home page, you +will get an opaque “Not Found” error. This is because the user and group +ID of the file will be that of your local user on the container’s host +machine, which is unlikely to map to anything in the container’s +`/etc/passwd` and `/etc/group` files, effectively preventing the server +from reading the copied-in repository file. 499 is the default “`fossil`” +user ID inside the container, causing Fossil to run with that user’s +privileges after it enters the chroot. (See [below](#args) for how to +change this default.) You don’t have to restart the server after fixing +this with `chmod`: simply reload the browser, and Fossil will try again. + + +### 2.2 <a id="bind-mount"></a>Storing the Repo Outside the Container + +The simple storage method above has a problem: containers are +designed to be killed off at the slightest cause, rebuilt, and +redeployed. If you do that with the repo inside the container, it gets +destroyed, too. The solution is to replace the “run” command above with +the following: + + $ docker run \ + --publish 9999:8080 \ + --name fossil-bind-mount \ + --volume ~/museum:/museum \ + fossil + +Because this bind mount maps a host-side directory (`~/museum`) into the +container, you don’t need to `docker cp` the repo into the container at +all. It still expects to find the repository as `repo.fossil` under that +directory, but now both the host and the container can see that repo DB. + +Instead of a bind mount, you could instead set up a separate +[volume](https://docs.docker.com/storage/volumes/), at which point you +_would_ need to `docker cp` the repo file into the container. + +Either way, files in these mounted directories have a lifetime +independent of the container(s) they’re mounted into. When you need to +rebuild the container or its underlying image — such as to upgrade to a +newer version of Fossil — the external directory remains behind and gets +remapped into the new container when you recreate it with `--volume/-v`. + + +#### 2.2.1 <a id="wal-mode"></a>WAL Mode Interactions + +You might be aware that OCI containers allow mapping a single file into +the repository rather than a whole directory. Since Fossil repositories +are specially-formatted SQLite databases, you might be wondering why we +don’t say things like: + + --volume ~/museum/my-project.fossil:/museum/repo.fossil + +That lets us have a convenient file name for the project outside the +container while letting the configuration inside the container refer to +the generic “`/museum/repo.fossil`” name. Why should we have to name +the repo generically on the outside merely to placate the container? + +The reason is, you might be serving that repo with [WAL mode][wal] +enabled. If you map the repo DB alone into the container, the Fossil +instance inside the container will write the `-journal` and `-wal` files +alongside the mapped-in repository inside the container. That’s fine as +far as it goes, but if you then try using the same repo DB from outside +the container while there’s an active WAL, the Fossil instance outside +won’t know about it. It will think it needs to write *its own* +`-journal` and `-wal` files *outside* the container, creating a high +risk of [database corruption][dbcorr]. + +If we map a whole directory, both sides see the same set of WAL files. +[Testing](https://tangentsoft.com/sqlite/dir/walbanger?ci=trunk) +gives us a reasonable level of confidence that using WAL across a +container boundary is safe when used in this manner. + +[dbcorr]: https://www.sqlite.org/howtocorrupt.html#_deleting_a_hot_journal +[wal]: https://www.sqlite.org/wal.html + + +## 3. <a id="security"></a>Security + +### 3.1 <a id="chroot"></a>Why Not Chroot? + +Prior to 2023.03.26, the stock Fossil container relied on [the chroot +jail feature](./chroot.md) to wall away the shell and other tools +provided by [BusyBox]. It included that as a bare-bones operating system +inside the container on the off chance that someone might need it for +debugging, but the thing is, Fossil is self-contained, needing none of +that power in the main-line use cases. + +Our weak “you might need it” justification collapsed when we realized +you could restore this basic shell environment with a one-line change to +the `Dockerfile`, as shown [below](#run). + +[BusyBox]: https://www.busybox.net/BusyBox.html + + +### 3.2 <a id="caps"></a>Dropping Unnecessary Capabilities + +The example commands above create the container with [a default set of +Linux kernel capabilities][defcap]. Although Docker strips away almost +all of the traditional root capabilities by default, and Fossil doesn’t +need any of those it does take away, Docker does leave some enabled that +Fossil doesn’t actually need. You can tighten the scope of capabilities +by adding “`--cap-drop`” options to your container creation commands. + +Specifically: + +* **`AUDIT_WRITE`**: Fossil doesn’t write to the kernel’s auditing + log, and we can’t see any reason you’d want to be able to do that as + an administrator shelled into the container, either. Auditing is + something done on the host, not from inside each individual + container. + +* **`CHOWN`**: The Fossil server never even calls `chown(2)`, and our + image build process sets up all file ownership properly, to the + extent that this is possible under the limitations of our + automation. + + Curiously, stripping this capability doesn’t affect your ability to + run commands like “`chown -R fossil:fossil /museum`” when + you’re using bind mounts or external volumes — as we recommend + [above](#bind-mount) — because it’s the host OS’s kernel + capabilities that affect the underlying `chown(2)` call in that + case, not those of the container. + + If for some reason you did have to change file ownership of + in-container files, it’s best to do that by changing the + `Dockerfile` to suit, then rebuilding the container, since that + bakes the need for the change into your reproducible build process. + If you had to do it without rebuilding the container, [there’s a + workaround][capchg] for the fact that capabilities are a create-time + change, baked semi-indelibly into the container configuration. + +* **`FSETID`**: Fossil doesn’t use the SUID and SGID bits itself, and + our build process doesn’t set those flags on any of the files. + Although the second fact means we can’t see any harm from leaving + this enabled, we also can’t see any good reason to allow it, so we + strip it. + +* **`KILL`**: The only place Fossil calls `kill(2)` is in the + [backoffice], and then only for processes it created on earlier + runs; it doesn’t need the ability to kill processes created by other + users. You might wish for this ability as an administrator shelled + into the container, but you can pass the “`docker exec --user`” + option to run commands within your container as the legitimate owner + of the process, removing the need for this capability. + +* **`MKNOD`**: As of 2023.03.26, the stock container uses the + runtime’s default `/dev` node tree. Prior to this, we had to create + `/dev/null` and `/dev/urandom` inside [the chroot jail](#chroot), + but even then, these device nodes were created at build time and + were never changed at run time, so we didn’t need this run-time + capability even then. + +* **`NET_BIND_SERVICE`**: With containerized deployment, Fossil never + needs the ability to bind the server to low-numbered TCP ports, not + even if you’re running the server in production with TLS enabled and + want the service bound to port 443. It’s perfectly fine to let the + Fossil instance inside the container bind to its default port (8080) + because you can rebind it on the host with the + “`docker create --publish 443:8080`” option. It’s the container’s + _host_ that needs this ability, not the container itself. + + (Even the container runtime might not need that capability if you’re + [terminating TLS with a front-end proxy](./ssl.wiki#server). You’re + more likely to say something like “`-p localhost:12345:8080`” and then + configure the reverse proxy to translate external HTTPS calls into + HTTP directed at this internal port 12345.) + +* **`NET_RAW`**: Fossil itself doesn’t use raw sockets, and while + you could [swap out the run layer](#run) for something more + functional that *does* make use of raw sockets, there’s little call + for it. The best reason I can come up with is to be able to run + utilities like `ping` and `traceroute`, but since we aren’t doing + anything clever with the networking configuration, there’s no + particularly compelling reason to run these from inside the + container. If you need to ping something, do it on the host. + + If we did not take this hard-line stance, an attacker that broke + into the container and gained root privileges might use raw sockets + to do a wide array of bad things to any network the container is + bound to. + +* **`SETFCAP, SETPCAP`**: There isn’t much call for file permission + granularity beyond the classic Unix ones inside the container, so we + drop root’s ability to change them. + +All together, we recommend adding the following options to your +“`docker run`” commands, as well as to any “`docker create`” command +that will be followed by “`docker start`”: + + --cap-drop AUDIT_WRITE \ + --cap-drop CHOWN \ + --cap-drop FSETID \ + --cap-drop KILL \ + --cap-drop MKNOD \ + --cap-drop NET_BIND_SERVICE \ + --cap-drop NET_RAW \ + --cap-drop SETFCAP \ + --cap-drop SETPCAP + +In the next section, we’ll show a case where you create a container +without ever running it, making these options pointless. + +[backoffice]: ./backoffice.md +[defcap]: https://docs.docker.com/engine/security/#linux-kernel-capabilities +[capchg]: https://stackoverflow.com/a/45752205/142454 + + + +## 4. <a id="static"></a>Extracting a Static Binary + +Our 2-stage build process uses Alpine Linux only as a build host. Once +we’ve got everything reduced to a single static Fossil binary, +we throw all the rest of it away. + +A secondary benefit falls out of this process for free: it’s arguably +the easiest way to build a purely static Fossil binary for Linux. Most +modern Linux distros make this [surprisingly difficult][lsl], but Alpine’s +back-to-basics nature makes static builds work the way they used to, +back in the day. If that’s all you’re after, you can do so as easily as +this: + + $ docker build -t fossil . + $ docker create --name fossil-static-tmp fossil + $ docker cp fossil-static-tmp:/bin/fossil . + $ docker container rm fossil-static-tmp + +The result is six or seven megs, depending on the CPU architecture you +build for. It’s built stripped. + +[lsl]: https://stackoverflow.com/questions/3430400/linux-static-linking-is-dead + + +## 5. <a id="custom" name="args"></a>Customization Points + +### <a id="pkg-vers"></a> 5.1 Fossil Version + +The default version of Fossil fetched in the build is the version in the +checkout directory at the time you run it. You could override it to get +a release build like so: + + $ docker build -t fossil --build-arg FSLVER=version-2.20 . + +Or equivalently, using Fossil’s `Makefile` convenience target: + + $ make container-image DBFLAGS='--build-arg FSLVER=version-2.20' + +While you could instead use the generic +“`release`” tag here, it’s better to use a specific version number +since container builders cache downloaded files, hoping to +reuse them across builds. If you ask for “`release`” before a new +version is tagged and then immediately after, you might expect to get +two different tarballs, but because the underlying source tarball URL +remains the same when you do that, you’ll end up reusing the +old tarball from cache. This will occur +even if you pass the “`docker build --no-cache`” option. + +This is why we default to pulling the Fossil tarball by checkin ID +rather than let it default to the generic “`trunk`” tag: so the URL will +change each time you update your Fossil source tree, forcing the builder to +pull a fresh tarball. + + +### 5.2 <a id="uids"></a>User & Group IDs + +The “`fossil`” user and group IDs inside the container default to 499. +Why? Regular user IDs start at 500 or 1000 on most Unix type systems, +leaving those below it for system users like this Fossil daemon owner. +Since it’s typical for these to start at 0 and go upward, we started at +500 and went *down* one instead to reduce the chance of a conflict to as +close to zero as we can manage. + +To change it to something else, say: + + $ make container-image DBFLAGS='--build-arg UID=501' + +This is particularly useful if you’re putting your repository on a +separate volume since the IDs “leak” out into the host environment via +file permissions. You may therefore wish them to mean something on both +sides of the container barrier rather than have “499” appear on the host +in “`ls -l`” output. + + +### 5.3 <a id="cengine"></a>Container Engine + +Although the Fossil container build system defaults to Docker, we allow +for use of any OCI container system that implements the same interfaces. +We go into more details about this [below](#light), but +for now, it suffices to point out that you can switch to Podman while +using our `Makefile` convenience targets unchanged by saying: + + $ make CENGINE=podman container-run + + +### 5.4 <a id="config"></a>Fossil Configuration Options + +You can use this same mechanism to enable non-default Fossil +configuration options in your build. For instance, to turn on +the JSON API and the TH1 docs extension: + + $ make container-image \ + DBFLAGS='--build-arg FSLCFG="--json --with-th1-docs"' + +If you also wanted [the Tcl evaluation extension](./th1.md#tclEval), +that brings us to [the next point](#run). + + +### 5.5 <a id="run"></a>Elaborating the Run Layer + +If you want a basic shell environment for temporary debugging of the +running container, that’s easily added. Simply change this line in the +`Dockerfile`… + + FROM scratch AS run + +…to this: + + FROM busybox AS run + +Rebuild and redeploy to give your Fossil container a [BusyBox]-based +shell environment that you can get into via: + + $ docker exec -it -u fossil $(make container-version) sh + +That command assumes you built it via “`make container`” and are +therefore using its versioning scheme. + +You will likely want to remove the `PATH` override in the “RUN” stage +when doing this since it’s written for the case where everything is in +`/bin`, and that will no longer be the case with a more full-featured +“`run`” layer. As long as the parent layer’s `PATH` value contains +`/bin`, delegating to it is more likely the correct thing. + +Another useful case to consider is that you’ve installed a [server +extension](./serverext.wiki) and you need an interpreter for that +script. The first option above won’t work except in the unlikely case that +it’s written for one of the bare-bones script interpreters that BusyBox +ships.(^[BusyBox]’s `/bin/sh` is based on the old 4.4BSD Lite Almquist +shell, implementing little more than what POSIX specified in 1989, plus +equally stripped-down versions of `awk` and `sed`.) + +Let’s say the extension is written in Python. Because this is one of the +most popular programming languages in the world, we have many options +for achieving this. For instance, there is a whole class of +“[distroless]” images that will do this efficiently by changing +“`STAGE 2`” in the `Dockefile` to this: + + ## --------------------------------------------------------------------- + ## STAGE 2: Pare that back to the bare essentials, plus Python. + ## --------------------------------------------------------------------- + FROM cgr.dev/chainguard/python:latest + USER root + ARG UID=499 + ENV PATH "/sbin:/usr/sbin:/bin:/usr/bin" + COPY --from=builder /tmp/fossil /bin/ + COPY --from=builder /bin/busybox.static /bin/busybox + RUN [ "/bin/busybox", "--install", "/bin" ] + RUN set -x \ + && echo "fossil:x:${UID}:${UID}:User:/museum:/false" >> /etc/passwd \ + && echo "fossil:x:${UID}:fossil" >> /etc/group \ + && install -d -m 700 -o fossil -g fossil log museum + +You will also have to add `busybox-static` to the APK package list in +STAGE 1 for the `RUN` script at the end of that stage to work, since the +[Chainguard Python image][cgimgs] lacks a shell, on purpose. The need to +install root-level binaries is why we change `USER` temporarily here. + +Build it and test that it works like so: + + $ make container-run && + docker exec -i $(make container-version) python --version + 3.11.2 + +The compensation for the hassle of using Chainguard over something more +general purpose like changing the `run` layer to Alpine and then adding +a “`apk add python`” command to the `Dockerfile` +is huge: we no longer leave a package manager sitting around inside the +container, waiting for some malefactor to figure out how to abuse it. + +Beware that there’s a limit to this über-jail’s ability to save you when +you go and provide a more capable runtime layer like this. The container +layer should stop an attacker from accessing any files out on the host +that you haven’t explicitly mounted into the container’s namespace, but +it can’t stop them from making outbound network connections or modifying +the repo DB inside the container. + +[cgimgs]: https://github.com/chainguard-images/images/tree/main/images +[distroless]: https://www.chainguard.dev/unchained/minimal-container-images-towards-a-more-secure-future +[MTA]: https://en.wikipedia.org/wiki/Message_transfer_agent + + +### 5.6 <a id="alerts"></a>Email Alerts + +The nature of our single static binary container precludes two of the +options for [sending email alerts](./alerts.md) from Fossil: + +* pipe to a command +* SMTP relay host + +There is no `/usr/sbin/sendmail` inside the container, and the container +cannot connect out to a TCP service on the host by default. + +While it is possible to get around the first lack by [elaborating the +run layer](#run), to inject a full-blown Sendmail setup into the +container would go against the whole idea of containerization. +Forwarding an SMTP relay port into the container isn’t nearly as bad, +but it’s still bending the intent behind containers out of shape. + +A far better option in this case is the “store emails in database” +method since the containerized Fossil binary knows perfectly well how to +write SQLite DB files without relying on any external code. Using the +paths in the configuration recommended above, the database path should +be set to something like `/museum/mail.db`. This, along with the use of +[bind mounts](#bind-mount) means you can have a process running outside +the container that passes the emails along to the host-side MTA. + +The included [`email-sender.tcl`](/file/tools/email-sender.tcl) script +works reasonably well for this, though in my own usage, I had to make +two changes to it: + +1. The shebang line at the top has to be `#!/usr/bin/tclsh` on my server. +2. I parameterized the `DBFILE` variable at the top thus: + + set DBFILE [lindex $argv 0] + +I then wanted a way to start this Tcl script on startup and keep it +running, which made me reach for systemd. My server is set to allow user +services to run at boot(^”Desktop” class Linuxes tend to disable that by +default under the theory that you don’t want those services to run until +you’ve logged into the GUI as that user. If you find yourself running +into this, [enable linger +mode](https://www.freedesktop.org/software/systemd/man/loginctl.html).) +so I was able to create a unit file called +`~/.local/share/systemd/user/alert-sender@.service` with these contents: + + [Unit] + Description=Fossil email alert sender for %I + + [Service] + WorkingDirectory=/home/fossil/museum + ExecStart=/home/fossil/bin/alert-sender %I/mail.db + Restart=always + RestartSec=3 + + [Install] + WantedBy=default.target + +I was then able to enable email alert forwarding for select repositories +after configuring them per [the docs](./alerts.md) by saying: + + $ systemctl --user daemon-reload + $ systemctl --user enable --now alert-sender@myproject + +Because this is a parameterized script and we’ve set our repository +paths predictably, you can do this for as many repositories as you need +to by passing their names after the “`@`” sign in the commands above. + + +## 6. <a id="light"></a>Lightweight Alternatives to Docker + +Those afflicted with sticker shock at seeing the size of a [Docker +Desktop][DD] installation — 1.65 GB here — might’ve immediately +“noped” out of the whole concept of containers. The first thing to +realize is that when it comes to actually serving simple containers like +the ones shown above is that [Docker Engine][DE] suffices, at about a +quarter of the size. + +Yet on a small server — say, a $4/month ten gig Digital Ocean droplet — +that’s still a big chunk of your storage budget. It takes ~60:1 overhead +merely to run a Fossil server container? Once again, I wouldn’t +blame you if you noped right on out of here, but if you will be patient, +you will find that there are ways to run Fossil inside a container even +on entry-level cloud VPSes. These are well-suited to running Fossil; you +don’t have to resort to [raw Fossil service][srv] to succeed, +leaving the benefits of containerization to those with bigger budgets. + +For the sake of simple examples in this section, we’ll assume you’re +integrating Fossil into a larger web site, such as with our [Debian + +nginx + TLS][DNT] plan. This is why all of the examples below create +the container with this option: + + --publish 127.0.0.1:9999:8080 + +The assumption is that there’s a reverse proxy running somewhere that +redirects public web hits to localhost port 9999, which in turn goes to +port 8080 inside the container. This use of port +publishing effectively replaces the use of the +“`fossil server --localhost`” option. + +For the nginx case, you need to add `--scgi` to these commands, and you +might also need to specify `--baseurl`. + +Containers are a fine addition to such a scheme as they isolate the +Fossil sections of the site from the rest of the back-end resources, +thus greatly reducing the chance that they’ll ever be used to break into +the host as a whole. + +(If you wanted to be double-safe, you could put the web server into +another container, restricting it to reading from the static web +site directory and connecting across localhost to back-end dynamic +content servers such as Fossil. That’s way outside the scope of this +document, but you can find ready advice for that elsewhere. Seeing how +we do this with Fossil should help you bridge the gap in extending +this idea to the rest of your site.) + +[DD]: https://www.docker.com/products/docker-desktop/ +[DE]: https://docs.docker.com/engine/ +[DNT]: ./server/debian/nginx.md +[srv]: ./server/ + + +### 6.1 <a id="nerdctl" name="containerd"></a>Stripping Docker Engine Down + +The core of Docker Engine is its [`containerd`][ctrd] daemon and the +[`runc`][runc] container runtime. Add to this the out-of-core CLI program +[`nerdctl`][nerdctl] and you have enough of the engine to run Fossil +containers. The big things you’re missing are: + +* **BuildKit**: The container build engine, which doesn’t matter if + you’re building elsewhere and shipping the images to the target. + A good example is using a container registry as an + intermediary between the build and deployment hosts. + +* **SwarmKit**: A powerful yet simple orchestrator for Docker that you + probably aren’t using with Fossil anyway. + +In exchange, you get a runtime that’s about half the size of Docker +Engine. The commands are essentially the same as above, but you say +“`nerdctl`” instead of “`docker`”. You might alias one to the other, +because you’re still going to be using Docker to build and ship your +container images. + +[ctrd]: https://containerd.io/ +[nerdctl]: https://github.com/containerd/nerdctl +[runc]: https://github.com/opencontainers/runc + + +### 6.2 <a id="podman"></a>Podman + +A lighter-weight [rootless][rl] [drop-in replacement][whatis] that +doesn’t give up the image builder is [Podman]. Initially created by +Red Hat and thus popular on that family of OSes, it will run on +any flavor of Linux. It can even be made to run [on macOS via Homebrew][pmmac] +or [on Windows via WSL2][pmwin]. + +On Ubuntu 22.04, the installation size is about 38 MiB, roughly a +tenth the size of Docker Engine. + +For our purposes here, the only thing that changes relative to the +examples at the top of this document are the initial command: + + $ podman build -t fossil . + $ podman run --name fossil -p 9999:8080/tcp fossil + +Your Linux package repo may have a `podman-docker` package which +provides a “`docker`” script that calls “`podman`” for you, eliminating +even the command name difference. With that installed, the `make` +commands above will work with Podman as-is. + +The only difference that matters here is that Podman doesn’t have the +same [default Linux kernel capability set](#caps) as Docker, which +affects the `--cap-drop` flags recommended above to: + + $ podman create \ + --name fossil \ + --cap-drop CHOWN \ + --cap-drop FSETID \ + --cap-drop KILL \ + --cap-drop NET_BIND_SERVICE \ + --cap-drop SETFCAP \ + --cap-drop SETPCAP \ + --publish 127.0.0.1:9999:8080 \ + localhost/fossil + $ podman start fossil + +[pmmac]: https://podman.io/getting-started/installation.html#macos +[pmwin]: https://github.com/containers/podman/blob/main/docs/tutorials/podman-for-windows.md +[Podman]: https://podman.io/ +[rl]: https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md +[whatis]: https://docs.podman.io/en/latest/index.html + + +### 6.3 <a id="nspawn"></a>`systemd-container` + +If even the Podman stack is too big for you, the next-best option I’m +aware of is the `systemd-container` infrastructure on modern Linuxes, +available since version 239 or so. Its runtime tooling requires only +about 1.4 MiB of disk space: + + $ sudo apt install systemd-container btrfs-tools + +That command assumes the primary test environment for +this guide, Ubuntu 22.04 LTS with `systemd` 249. For best +results, `/var/lib/machines` should be a btrfs volume, because +[`$REASONS`][mcfad]. For CentOS Stream 9 and other Red Hattish +systems, you will have to make several adjustments, which we’ve +collected [below](#nspawn-centos) to keep these examples clear. + +We’ll assume your Fossil repository stores something called +“`myproject`” within `~/museum/myproject/repo.fossil`, named according +to the reasons given [above](#repo-inside). We’ll make consistent use of +this naming scheme in the examples below so that you will be able to +replace the “`myproject`” element of the various file and path names. + +If you use [the stock `Dockerfile`][DF] to generate your +base image, `nspawn` won’t recognize it as containing an OS unless you +change the “`FROM scratch AS os`” line at the top of the second stage +to something like this: + + FROM gcr.io/distroless/static-debian11 AS os + +Using that as a base image provides all the files `nspawn` checks for to +determine whether the container is sufficiently close to a Linux VM for +the following step to proceed: + + $ make container + $ docker container export $(make container-version) | + machinectl import-tar - myproject + +Next, create `/etc/systemd/nspawn/myproject.nspawn`: + +---- + + [Exec] + WorkingDirectory=/ + Parameters=bin/fossil server \ + --baseurl https://example.com/myproject \ + --create \ + --jsmode bundled \ + --localhost \ + --port 9000 \ + --scgi \ + --user admin \ + museum/repo.fossil + DropCapability= \ + CAP_AUDIT_WRITE \ + CAP_CHOWN \ + CAP_FSETID \ + CAP_KILL \ + CAP_MKNOD \ + CAP_NET_BIND_SERVICE \ + CAP_NET_RAW \ + CAP_SETFCAP \ + CAP_SETPCAP + ProcessTwo=yes + LinkJournal=no + Timezone=no + + [Files] + Bind=/home/fossil/museum/myproject:/museum + + [Network] + VirtualEthernet=no + +---- + +If you recognize most of that from the `Dockerfile` discussion above, +congratulations, you’ve been paying attention. The rest should also +be clear from context. + +Some of this is expected to vary: + +* The references to `example.com` and `myproject` are stand-ins for + your actual web site and repository name. + +* The command given in the `Parameters` directive assumes you’re + setting up [SCGI proxying via nginx][DNT], but with adjustment, + it’ll work with the other repository service methods we’ve + [documented][srv]. + +* The path in the host-side part of the `Bind` value must point at the + directory containing the `repo.fossil` file referenced in said + command so that `/museum/repo.fossil` refers to your repo out + on the host for the reasons given [above](#bind-mount). + +That being done, we also need a generic `systemd` unit file called +`/etc/systemd/system/fossil@.service`, containing: + +---- + + [Unit] + Description=Fossil %i Repo Service + Wants=modprobe@tun.service modprobe@loop.service + After=network.target systemd-resolved.service modprobe@tun.service modprobe@loop.service + + [Service] + ExecStart=systemd-nspawn --settings=override --read-only --machine=%i bin/fossil + + [Install] + WantedBy=multi-user.target + +---- + +You shouldn’t have to change any of this because we’ve given the +`--setting=override` flag, meaning any setting in the nspawn file +overrides the setting passed to `systemd-nspawn`. This arrangement +not only keeps the unit file simple, it allows multiple services to +share the base configuration, varying on a per-repo level through +adjustments to their individual `*.nspawn` files. + +You may then start the service in the normal way: + + $ sudo systemctl enable fossil@myproject + $ sudo systemctl start fossil@myproject + +You should then find it running on localhost port 9000 per the nspawn +configuration file above, suitable for proxying Fossil out to the +public using nginx via SCGI. If you aren’t using a front-end proxy +and want Fossil exposed to the world via HTTPS, you might say this instead in +the `*.nspawn` file: + + Parameters=bin/fossil server \ + --cert /path/to/cert.pem \ + --create \ + --jsmode bundled \ + --port 443 \ + --user admin \ + museum/repo.fossil + +You would also need to un-drop the `CAP_NET_BIND_SERVICE` capability +to allow Fossil to bind to this low-numbered port. + +We use the `systemd` template file feature to allow multiple Fossil +servers running on a single machine, each on a different TCP port, +as when proxying them out as subdirectories of a larger site. +To add another project, you must first clone the base “machine” layer: + + $ sudo machinectl clone myproject otherthing + +That will not only create a clone of `/var/lib/machines/myproject` +as `../otherthing`, it will create a matching `otherthing.nspawn` file for you +as a copy of the first one. Adjust its contents to suit, then enable +and start it as above. + +[mcfad]: https://www.freedesktop.org/software/systemd/man/machinectl.html#Files%20and%20Directories + + +### 6.3.1 <a id="nspawn-rhel"></a>Getting It Working on a RHEL Clone + +The biggest difference between doing this on OSes like CentOS versus +Ubuntu is that RHEL (thus also its clones) doesn’t ship btrfs in +its kernel, thus ships with no package repositories containing `mkfs.btrfs`, which +[`machinectl`][mctl] depends on for achieving its various purposes. + +Fortunately, there are workarounds. + +First, the `apt install` command above becomes: + + $ sudo dnf install systemd-container + +Second, you have to hack around the lack of `machinectl import-tar`: + + $ rootfs=/var/lib/machines/fossil + $ sudo mkdir -p $rootfs + $ docker container export fossil | sudo tar -xf -C $rootfs - + +The parent directory path in the `rootfs` variable is important, +because although we aren’t able to use `machinectl` on such systems, the +`systemd-nspawn` developers assume you’re using them together; when you give +`--machine`, it assumes the `machinectl` directory scheme. You could +instead use `--directory`, allowing you to store the rootfs wherever +you like, but why make things difficult? It’s a perfectly sensible +default, consistent with the [LHS] rules. + +The final element — the machine name — can be anything +you like so long as it matches the nspawn file’s base name. + +Finally, since you can’t use `machinectl clone`, you have to make +a wasteful copy of `/var/lib/machines/myproject` when standing up +multiple Fossil repo services on a single machine. (This is one +of the reasons `machinectl` depends on `btrfs`: cheap copy-on-write +subvolumes.) Because we give the `--read-only` flag, you can simply +`cp -r` one machine to a new name rather than go through the +export-and-import dance you used to create the first one. + +[LHS]: https://refspecs.linuxfoundation.org/FHS_3.0/fhs/index.html +[mctl]: https://www.freedesktop.org/software/systemd/man/machinectl.html + + +### 6.3.2 <a id="nspawn-weaknesses"></a>What Am I Missing Out On? + +For all the runtime size savings in this method, you may be wondering +what you’re missing out on relative to Podman, which takes up +roughly 27× more disk space. Short answer: lots. Long answer: + +1. **Build system.** You’ll have to build and test your containers + some other way. This method is only suitable for running them + once they’re built. + +2. **Orchestration.** All of the higher-level things like + “compose” files, Docker Swarm mode, and Kubernetes are + unavailable to you at this level. You can run multiple + instances of Fossil, but on a single machine only and with a + static configuration. + +3. **Image layer sharing.** When you update an image using one of the + above methods, Docker and Podman are smart enough to copy only + changed layers. Furthermore, when you base multiple containers + on a single image, they don’t make copies of the base layers; + they can share them, because base layers are immutable, thus + cannot cross-contaminate. + + Because we use `systemd-nspawn --read-only`, we get *some* + of this benefit, particularly when using `machinectl` with + `/var/lib/machines` as a btrfs volume. Even so, the disk space + and network I/O optimizations go deeper in the Docker and Podman + worlds. + +4. **Tooling.** Hand-creating and modifying those `systemd` + files sucks compared to “`podman container create ...`” This + is but one of many affordances you will find in the runtimes + aimed at daily-use devops warriors. + +5. **Network virtualization.** In the scheme above, we turn off the + `systemd` private networking support because in its default mode, it + wants to hide containerized services entirely. While there are + [ways][ndcmp] to expose Fossil’s single network service port under + that scheme, it adds a lot of administration complexity. In the + big-boy container runtimes, `docker create --publish` fixes all this + up in a single option, whereas `systemd-nspawn --port` does + approximately *none* of that despite the command’s superficial + similarity. + + From a purely functional point of view, this isn’t a huge problem if + you consider the inbound service direction only, being external + connections to the Fossil service we’re providing. Since we do want + this Fossil service to be exposed — else why are we running it? — we + get all the control we need via `fossil server --localhost` and + similar options. + + The complexity of the `systemd` networking infrastructure’s + interactions with containers make more sense when you consider the + outbound path. Consider what happens if you enable Fossil’s + optional TH1 docs feature plus its Tcl evaluation feature. That + would enable anyone with the rights to commit to your repository the + ability to make arbitrary network connections on the Fossil host. + Then, let us say you have a client-server DBMS server on that same + host, bound to localhost for private use by other services on the + machine. Now that DBMS is open to access by a rogue Fossil committer + because the host’s loopback interface is mapped directly into the + container’s network namespace. + + Proper network virtualization would protect you in this instance. + +This author expects that the set of considerations is broader than +presented here, but that it suffices to make our case as it is: if you +can afford the space of Podman or Docker, we strongly recommend using +either of them over the much lower-level `systemd-container` +infrastructure. You’re getting a considerable amount of value for the +higher runtime cost; it isn’t pointless overhead. + +(Incidentally, these are essentially the same reasons why we no longer +talk about the `crun` tool underpinning Podman in this document. It’s +even more limited than `nspawn`, making it even more difficult to administer while +providing no runtime size advantage. The `runc` tool underpinning +Docker is even worse on this score, being scarcely easier to use than +`crun` while having a much larger footprint.) + +[ndcmp]: https://wiki.archlinux.org/title/systemd-networkd#Usage_with_containers + + +### 6.3.3 <a id="nspawn-assumptions"></a>Violated Assumptions + +The `systemd-container` infrastructure has a bunch of hard-coded +assumptions baked into it. We papered over these problems above, +but if you’re using these tools for other purposes on the machine +you’re serving Fossil from, you may need to know which assumptions +our container violates and the resulting consequences. + +Some of it we discussed above already, but there’s one big class of +problems we haven’t covered yet. It stems from the fact that our stock +container starts a single static executable inside a bare-bones container +rather than “boot” an OS image. That causes a bunch of commands to fail: + +* **`machinectl poweroff`** will fail because the container + isn’t running dbus. + +* **`machinectl start`** will try to find an `/sbin/init` + program in the rootfs, which we haven’t got. We could + rename `/bin/fossil` to `/sbin/init` and then hack + the chroot scheme to match, but ick. (This, incidentally, + is why we set `ProcessTwo=yes` above even though Fossil is + perfectly capable of running as PID 1, a fact we depend on + in the other methods above.) + +* **`machinectl shell`** will fail because there is no login + daemon running, which we purposefully avoided adding by + creating a “`FROM scratch`” container. (If you need a + shell, say: `sudo systemd-nspawn --machine=myproject /bin/sh`) + +* **`machinectl status`** won’t give you the container logs + because we disabled the shared journal, which was in turn + necessary because we don’t run `systemd` *inside* the + container, just outside. + +If these are problems for you, you may wish to build a +fatter container using `debootstrap` or similar. ([External +tutorial][medtut].) + +[medtut]: https://medium.com/@huljar/setting-up-containers-with-systemd-nspawn-b719cff0fb8d + +<div style="height:50em" id="this-space-intentionally-left-blank"></div> |
