Building your own image
Corium’s published image is a base. Deriving your own is how you add the things
a corium: block was never going to cover — a monitoring agent, your
organisation’s CA bundle, a driver, a backup client. It is three lines of
Containerfile for the simple cases.
It is also the supported way to do it. Installing an agent on a running node
with dnf does not work, because /usr is read-only; installing it into /var
with a runcmd works exactly until the next upgrade, when nothing puts it back.
An agent that is part of the image is an agent that survives.
1. The one rule that decides everything
Corium’s filesystem contract, from concepts, is what makes a derived image either sound or quietly broken:
| Who owns it | What that means for you | |
|---|---|---|
/usr | the image, read-only | Put your things here. This is what upgrades replace, so this is what follows a node forward |
/etc | the machine, merged on upgrade | Ships defaults here only when there is nowhere else. An operator’s edit wins over yours, for ever |
/var | the machine, seeded at install and never updated by an image | Write nothing here at build time |
That last row is the mistake that costs the most, because it does not fail. A
file you COPY into /var reaches every machine installed from your image
and no machine upgraded into it. Your test node, freshly installed, works
perfectly. The fleet you upgrade never sees it, and nothing says so.
State directories are declared in tmpfiles.d instead, and systemd creates them
on every boot. That is a rule Corium follows for its own state and it is not
decoration.
2. A worked example: adding an agent
Say you want node_exporter on every node.
FROM ghcr.io/corium-os/corium:0.2.0
ARG VERSION=1.10.0
ARG SHA256=a1b2c3... # the checksum you looked up, pinned here
# The binary goes in /usr, which is what an upgrade carries forward.
#
# Pinned by checksum and not by "latest": this is a build that will be repeated
# months from now, and a download that silently changed underneath it is a node
# fleet running something nobody chose.
RUN curl --fail --silent --show-error --location --retry 3 \
--output /tmp/node_exporter.tar.gz \
"https://github.com/prometheus/node_exporter/releases/download/v${VERSION}/node_exporter-${VERSION}.linux-amd64.tar.gz" \
&& echo "${SHA256} /tmp/node_exporter.tar.gz" | sha256sum --check \
&& tar -xzf /tmp/node_exporter.tar.gz -C /tmp \
&& install -m 0755 "/tmp/node_exporter-${VERSION}.linux-amd64/node_exporter" \
/usr/bin/node_exporter \
&& rm -rf /tmp/node_exporter*
# Units belong in /usr/lib/systemd/system. Never /etc/systemd/system: that is
# the machine's, and an operator who disables your unit there should stay
# disabled across upgrades.
COPY node-exporter.service /usr/lib/systemd/system/node-exporter.service
# Enabled at build time, so a node boots with it running rather than waiting
# for someone to turn it on.
RUN systemctl enable node-exporter.service
# The same check CI runs on Corium's own image. It catches the /var mistake
# above, among others, and it is cheap.
RUN bootc container lint --fatal-warningsWith node-exporter.service beside it:
[Unit]
Description=Prometheus node exporter
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/node_exporter
Restart=always
DynamicUser=yes
ProtectSystem=strict
ProtectHome=yes
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.targetIf your agent needs a directory of its own, declare it rather than creating it:
# /usr/lib/tmpfiles.d/node-exporter.conf
d /var/lib/node-exporter 0750 root root -3. Build it, and sign it
podman build --tag ghcr.io/you/corium:0.2.0-1 --file Containerfile .
podman push ghcr.io/you/corium:0.2.0-1Then sign it. This is not optional hardening — an unsigned image cannot be upgraded onto a node.
Corium ships a /etc/containers/policy.json that requires a signature for
ghcr.io/corium-os/corium, with a default of insecureAcceptAnything for
everything else. Before staging an image, a node checks the policy scope that
would apply to it and refuses any image the policy would accept without
checking who built it. Your repository falls under the default, so it is
refused:
the node's signing policy does not require a signature for this imageThat is deliberate: it is what stops a typo rebasing a Kubernetes node onto somebody else’s image. The way through it is to sign yours and say so, which gives your image the same guarantee Corium gives its own:
cosign generate-key-pair
cosign sign --key cosign.key ghcr.io/you/corium:0.2.0-1Then teach your image to require it, by adding your repository to the policy inside your own image:
"ghcr.io/you/corium": [
{
"type": "sigstoreSigned",
"keyPath": "/usr/share/containers/your-cosign.pub",
"signedIdentity": { "type": "matchRepository" }
}
]The node accepts your image because you said so, not because it claims to be Corium. That is the whole design of the signing policy, and it is why deriving an image does not mean giving up the guarantee.
Two things about the order of events:
A node enforces the policy it is currently running, not the one in the image it is moving to. So the entry above has to reach a node before the upgrade that needs it. In practice that means one upgrade to a derived image that adds only the policy and the key — signed, so the nodes accept it — and your agents from the next one onwards. Getting this backwards leaves nodes that refuse every image you build, and the way back is the console.
Installing is not upgrading. A machine installed from an ISO or a disk built from your image never runs that check, because there is no previous node to refuse anything. New machines are easy; it is the fleet you already have that needs the order above.
4. Put it on your nodes
A derived image is an image. Everything that moves a node to a new one works unchanged:
cctl upgrade node-a node-b --image ghcr.io/you/corium:0.2.0-1One node at a time, stopping at the first that does not come back on the digest
it was sent to. Rollback is the same cctl rollback as ever — the previous
image is still on the disk, which is the other half of why this is safe to do
to a cluster you care about.
For a new machine, build an installer ISO or a disk from your image the way Corium builds its own; see downloads.
5. The mistakes, in the order people make them
Writing to /var at build time. Covered above, and worth repeating because
it is the only one that passes every test you are likely to run.
Putting a unit in /etc/systemd/system. It works, and then an upgrade
three-way merges /etc and you are arguing with a machine about whose file it
is. /usr/lib/systemd/system is the image’s.
Forgetting systemctl enable. The unit ships, the node boots, nothing runs,
and it looks like the agent is broken rather than switched off.
Deriving from latest. Your build stops being reproducible the moment
Corium publishes. Pin the version you tested — 0.2.0, not latest — and move
it deliberately.
Skipping bootc container lint. It is the cheapest check available and it
knows about this filesystem contract. Corium’s own CI would refuse an image
that fails it.
An agent that fights the node. DynamicUser, ProtectSystem and friends
cost nothing here and keep an agent from being the reason a Kubernetes node is
compromised. Corium’s own daemons carry them, and say in comments which ones had
to be dropped and why.
What this does not cover
Changing the Kubernetes version. The k0s binary lives in /usr and is pinned by
build/k0s.lock; replacing it in a derived image is possible and puts the
version skew of your cluster in your hands, which is a larger subject than this
page. See
upgrades.