Upgrades
The operating system and Kubernetes upgrade on separate axes. To move the OS,
you boot a new image. This has one mechanism and one rollback path. To move
Kubernetes, you use kubernetes.version or cctl k8s upgrade, and you need no
new image.
Upgrading Kubernetes specifically
below covers it. Only one operation is not symmetric: to roll a Kubernetes
version back. k0s does not support a downgrade of a minor.
This page tells you how to:
- Move one node to a new image.
- Roll a whole cluster, one node at a time.
- Roll back when an image is wrong.
- Choose the tag that a node follows.
- Verify the image that you are about to boot.
It applies to every Corium node, for every install path: ISO, qcow2, cloud image, or an image that you derived yourself.
The model
A Corium node runs an image. An upgrade replaces that image and reboots. There
is no in-place package upgrade, no k0s binary to swap, and no second
mechanism that can move the Kubernetes version independently of the OS.
For this last reason,
Corium does not use k0s Autopilot.
Autopilot upgrades Kubernetes by replacing the k0s binary on disk. The Corium
binary is in the read-only /usr. Two mechanisms that can each change the
version on their own are worse than one that cannot.
What survives
| Path | Across an upgrade |
|---|---|
/usr | Replaced in full. It is the image |
/etc | Three-way merge. Your edits stay, and the image defaults update around them |
/var | Untouched. /var/lib/k0s, container storage, the cluster CA, your data |
This was tested on a live node. After the image switch and the reboot, the
cluster CA was still in place, and the node kept its name.
corium-bootstrap.service did not run. The marker in /var/lib/corium stayed,
so the node did not try to bootstrap itself a second time.
All results below come from measurements on a three-controller cluster, not from assumptions.
Upgrading a node
Before you start
- A node on which you can run
sudo, locally or over SSH. - An image tag. See choosing what to track.
- For the
cctlpath only: the management API enabled on that node, and a client certificate for it.
# See what is running and what, if anything, is staged.
sudo bootc status
# Pull a newer build of the image this node already tracks.
sudo bootc upgrade
# Or move it to a different image or tag.
sudo bootc switch ghcr.io/corium-os/corium:0.5From your workstation, cctl upgrade <node> --image ... does the same thing.
It also drains and reboots the node. See
cctl.
By default, neither command reboots. They stage a deployment for the next boot. Thus you choose the maintenance window:
Queued for next boot: ghcr.io/corium-os/corium:0.5
Version: 0.5.0
Digest: sha256:38c2194904b1de400a6f2366760c85dae5e45bb7f5f500612bb428817e850bb6To reboot immediately, add --apply. Or reboot when you are ready.
Only the difference is transferred
This output comes from a real switch between two builds of this image:
layers already present: 65; layers needed: 11 (214.0 MB)
Deploying...done (15 seconds)The switch moved 214 MB for an image of approximately 2.3 GB. The layers that the two builds share were already on disk. This is the practical argument for an OS that is an OCI image. Upgrades are incremental for the same reason that application images are, and there is no custom delta format to maintain.
Upgrading a cluster
Nodes are replaceable, but the control plane still has a quorum that you must keep.
One node at a time
If the management API is enabled, one command does the whole sequence for a list of nodes:
Warning.
cctl upgraderefuses an image that the signing policy of the node accepts without a signature. This stops a typo from rebasing a Kubernetes node onto a desktop image. It is also the first problem that you meet when you derive your own image. Your repository falls under the permissive default of the policy. Thuscctl upgraderefuses it until you sign it and declare it in the policy. See building your own image. If you runbootc switchby hand, it does not do this check.
cctl upgrade node-1 node-2 node-3 --image ghcr.io/corium-os/corium:<tag>Before it changes any node, the command makes sure that each node meets these conditions:
- The node is reachable.
- The node is safe to lose: it is bootstrapped, k0s runs, and greenboot did not mark the last boot as bad.
- The node is in the same cluster as the others.
- The node is listed only once.
- The node accepts the image under its own signing policy.
If one node fails a check, the command stops and changes nothing. Thus you find
a wrong address or a refused image before the first nodes reboot. To run only
that check, add --dry-run.
Then the command takes the nodes in order, and asks each node again if it is safe to lose. It stops at the first node that does not come back on the digest it was sent to, and tells you how many nodes it upgraded.
Both of these behaviors are deliberate. A rollout that continues past a broken machine turns one outage into an outage of the whole cluster. Also, a half-upgraded cluster needs a decision from a person. It is not a number to hide.
Without the API, do the same sequence by hand:
# 1. Stop scheduling and move the workloads off.
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data
# 2. Stage the new image and reboot into it.
ssh <node> sudo bootc switch ghcr.io/corium-os/corium:<tag>
ssh <node> sudo systemctl reboot
# 3. Wait for it to come back Ready, then let it take work again.
kubectl wait --for=condition=Ready node/<node> --timeout=5m
kubectl uncordon <node>After the drain and the reboot, the node comes back in approximately 45 seconds
and rejoins on its own. It does not bootstrap again, because the marker in
/var/lib/corium stops it.
Controllers
With three controllers, you can lose one and keep etcd quorum. Thus upgrade
them one at a time, and wait for Ready between each one. Never stop two of the
three controllers at the same time. If you do, the cluster loses quorum, and the
API server stops accepting writes.
cctl upgrade and cctl k8s upgrade enforce this rule. Before and after each
controller, they make sure that every etcd member is healthy. If a member is
not healthy, they stop. See
etcd around controllers.
With five controllers, the cluster survives two at a time. With two controllers, the cluster has no quorum to spare, and it is unavailable during each reboot.
If you use HA, a reboot of the controller that holds the virtual IP moves the virtual IP to another controller. Clients that point at the VIP reconnect on their own. This failover was tested with a hard stop of the controller that held the VIP.
A node that breaks rolls itself back
Nodes run greenboot health checks at boot. If a check fails and there is a previous deployment to return to, the node rolls back on its own. Thus you can stage an image and reboot without supervision.
This table comes from a test with an image whose health check always fails:
| Boot | Check | What greenboot does |
|---|---|---|
| 1 | fails | First failure. Sets the boot counter to 3 |
| 2 | fails | Counter at 2, reboots to try again |
| 3 | fails | Counter at 1, reboots to try again |
| 4 | fails | Counter at zero. Starts the rollback, which succeeds |
| 5 | healthy | greenboot health-check passed, boot_success=1 |
GREENBOOT_MAX_BOOT_ATTEMPTS in /etc/greenboot/greenboot.conf sets the
count. After the rollback, the next upgrade replaces the image that failed. Thus
the node does not switch back and forth between the two images. A rollback also
occurs only if an upgrade staged something. If a check fails on a node that
nobody upgraded, you get a warning and must act manually. The node does not
move to an older image without warning.
To add your own checks, put executables in /etc/greenboot/check/required.d/.
A non-zero exit fails the boot.
Corium ships one check: is k0s running and answering? It does not require the
node to be Ready in Kubernetes, on purpose. A node can be NotReady for reasons
that the image cannot fix, for example:
- No CNI is installed yet.
- A control plane is still coming back.
- The whole cluster has a problem.
An OS rollback fixes none of these. It only takes a healthy machine out of service at the worst possible moment. A health check that is too strict is worse than no check. The check gives k0s five minutes to start, because k0s needs time to unpack its supervised binaries and start etcd. On a node that was never bootstrapped, the check always passes.
Rolling back
sudo bootc rollback
sudo systemctl rebootOr remotely, if the node still answers:
cctl rollback <node> # marks the previous image; does not rebootcctl rollback stops there on purpose. When you need it, you already have a
problem. A reboot of the node at a moment that you did not choose does not help.
You schedule the reboot.
A rollback is instant and downloads nothing: Next boot: rollback deployment.
It changes the order of the boot entries for the deployment that you run and
the previous deployment. Both are already on disk.
A rollback is also symmetric. The image that you left becomes the new rollback target. If you run the rollback again, you return to where you were. This was tested in both directions on a live controller.
sudo bootc status # shows booted, staged and rollbackTwo things rollback does not undo:
- Anything written to
/var. If a Kubernetes version migrated etcd data, an older image does not reverse the migration. Before you skip Kubernetes minor versions, read the k0s release notes. - Cluster state. Rolling back one node does not roll back the cluster.
Choosing what to track
Each release publishes a ladder of tags. Thus each node can choose how much change it accepts.
| Tag | Moves | You get |
|---|---|---|
corium@sha256:... | Never | Exactly one image. The strongest pin |
corium:0.5.0 | Never | One release |
corium:0.5 | On patch releases | Fixes, no new behavior |
corium:latest | On every release | Whatever is newest |
corium:main | On every push to main | Development builds, unreleased |
Track 0.5. It moves only on patch releases, which do not change
behavior. Thus the node gets fixes without a decision from you. If you prefer to
choose the moment yourself, pin 0.5.0 exactly. Before you move to 0.6, read
the changelog. A minor release can require a configuration change, and the
changelog names it.
Moving from
0.4to0.5changes a node’s posture. From 0.5.0, the management API is on by default. A node that names noapi:block serves its enrolment route during the minute between boot and bootstrap. The pairing code on its console protects that route. To keep the old behavior, setapi.enabled: false. For this reason, the change is a minor release and not a patch. Nothing on the0.4rung moves, so a node gets it only when you tell it to. See ADR 11.
The ladder does not offer three things:
- There is no major rung below 1.0. Semantic versioning lets a
0.yminor release break things. A0tag promises a stability that the version number does not give. latesttells you which release is newest. It does not tell you about compatibility. It crosses the minor releases, which is where breaking changes occur. Do not run a cluster on it.- Prereleases publish only their exact tag.
0.5.0-rc.1never becomes0.5orlatest. Thus a node that follows a stable tag does not get a release candidate.
bootc status always reports the digest that the node booted, whatever the tag
pointed to at that time. During an incident, this is the information that you
need.
Unattended upgrades
By default, a node does nothing on its own. A Kubernetes node that reboots
without a request causes an outage that nobody scheduled. Thus the image masks
bootc-fetch-apply-updates.timer.
If you want more, two levels are available:
corium:
upgrades:
automatic: download # none (default) | download | apply
schedule: "Mon *-*-* 03:00:00" # systemd OnCalendar, default daily| Policy | What the node does | Reboots itself |
|---|---|---|
none | Nothing. The default | No |
download | Stages a newer image and queues it for the next boot, whenever that is | No |
apply | Stages it and reboots | Yes |
download is the one most clusters want. The fetch and the deployment
occur without supervision. Thus the reboot that you schedule is almost instant,
because the slow part is already done. You still choose the moment, drain
first, and do one node at a time.
apply drains the node before it reboots. It cordons the node, evicts the pods,
reboots into the staged image, and uncordons the node when k0s is back. Thus a
node that upgrades itself moves its workloads to other nodes and does not kill
them.
These behaviors are deliberate:
- A drain that cannot finish cancels the upgrade. When a pod disruption budget refuses an eviction, the system works correctly. It is not a fault. The node uncordons itself and stays on its current image. The new image stays staged, and the node tries again at the next scheduled run. A reboot that ignores a PDB defeats the purpose of the PDB.
- A plain worker reboots without a drain. A drain needs cluster admin
credentials, and only a node that runs a control plane has them locally. On
workernodes,applyreboots without a drain. On those nodes, usedownload, and start the reboot from a machine that can talk to the API. - A single-node cluster also reboots without a drain. A drain of the only
node has nowhere to move its pods. Eviction then blocks on their disruption
budgets, and the drain never finishes. Under the rule above, this cancels
every upgrade. Thus a
singlenode (or any cluster of one) skips the drain and reboots directly into the staged image. Its pods come back when the node does. This letscctl upgradereboot a one-node cluster. Otherwise, the image stays staged forever. - The new image stays locked until the drain succeeds.
applystages withbootc upgrade --download-only, which leaves the deployment locked for finalization. It unlocks the deployment with--from-downloadedonly after the node is drained. A reboot for a different reason in between can come from an operator, a crash, or a power cut during the drain. After such a reboot, the node comes back on the image that it already ran. It does not half-apply an upgrade that nobody scheduled.
schedule takes any systemd
OnCalendar
expression. Corium adds a random delay of up to one hour, so that all nodes do
not contact the registry at the same time. If a node was off, it runs the
missed checks when it starts again. It does not wait for the next window.
To see what a node staged, run:
sudo bootc status
systemctl list-timers 'corium-upgrade-*' 'bootc-*'Verifying what you are about to boot
Corium signs every image that it publishes, from a release tag and from main.
It uses
cosign in keyless mode. There is no
private key, and the signing identity is the GitHub Actions workflow itself.
The signature is attached to the image digest and not to a tag. Tags move, and
a signature on a moving tag tells you nothing about the image that the tag
points to now.
cosign verify ghcr.io/corium-os/corium:0.5 \
--certificate-identity-regexp 'https://github.com/Corium-OS/Corium/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comA successful verification prints this output:
Verification for ghcr.io/corium-os/corium:0.5 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate
authority certificatesIf cosign verify stops with no output at all, your registry credential
helper is probably waiting for something. A locked macOS keychain causes this.
To find out, run the command with an empty DOCKER_CONFIG:
mkdir -p /tmp/emptycfg && echo '{}' > /tmp/emptycfg/config.json
DOCKER_CONFIG=/tmp/emptycfg cosign verify ghcr.io/corium-os/corium:0.5 \
--certificate-identity-regexp 'https://github.com/Corium-OS/Corium/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comNodes enforce it
Corium signs images twice, because the two signatures answer different questions.
| Signature | Proves | Used by |
|---|---|---|
| Keyless | Which workflow run built this image, recorded in a public transparency log | A human, running cosign verify |
| Key | That the image came from this project | The node, at pull time |
A Corium node ships a policy that requires the key signature for
ghcr.io/corium-os/corium. The public key is at
/usr/share/corium/cosign.pub. The node refuses an image from that repository
that is not signed:
Source image rejected: A signature was required, but no signature existsThe policy is narrow on purpose. Everything else stays permissive, because k0s pulls its own images from quay.io and docker.io. A policy for all registries breaks the cluster and does not make it more secure.
To verify the image yourself with the key from a node, run:
cosign verify --key /usr/share/corium/cosign.pub ghcr.io/corium-os/corium:0.5Why two signatures rather than one. A node cannot enforce the keyless
signature. containers-policy.json matches a signer by subjectEmail, but a
GitHub Actions certificate carries a URI instead. Thus enforcement fails with
Required email ... not found (got []). This is a limitation of
containers-image, not a configuration mistake. Know this before you spend
an afternoon on it.
Upgrading Kubernetes specifically
Most of the time you do not need this section. With
ADR 10, the Kubernetes version is a
separate axis. Set kubernetes.version, or roll a cluster with
cctl k8s upgrade. No OS image changes. See
reference §3.20.
A controller snapshots its datastore first
Before a controller stops k0s to change its version, it runs k0s backup. This
happens after the drain, while the old version still runs, because k0s backup
needs k0s to be up. The archive holds the datastore, the PKI and k0s.yaml.
The datastore is etcd on controller and controller+worker, and kine/SQLite
on single. A worker has no datastore, so it takes no snapshot.
| Where | backup.path. If the node has no backup block, /var/lib/corium/backups |
| Name | corium-backup-<UTC timestamp>.tar.gz, as for
scheduled backups |
| Retention | backup.keep, or 7. Upgrade snapshots and scheduled backups count together |
| Mode | 0600, in a 0700 directory. The archive holds the cluster CA key and every Secret |
The API returns the path, and cctl k8s upgrade and cctl apply print it. If
k0s does not come back, the error names the path too. Restore from that
archive. See
restore is manual.
If the snapshot fails, the node cancels the change. Nothing is stopped yet. So the node uncordons itself and stays on its version. The usual causes are a full disk, or k0s not running. Fix the cause, then try again. The snapshot is part of the change that continues after the drain starts. So it finishes even if your client disconnects.
This section covers the other axis: how to change the version that an image
ships, and the versions for which it accepts extensions. Both are in
build/k0s.lock:
K0S_FLOOR=v1.36.5+k0s.0
# version arch sha256
v1.34.12+k0s.0 amd64 8536acf9...
v1.35.9+k0s.0 amd64 517b88e1...
v1.36.5+k0s.0 amd64 377cdb37...K0S_FLOOR is baked into /usr/bin/k0s. A node runs this version when its
configuration names no version. The table is the window: the versions for which
this image builds and accepts system extensions. The window is three minors
wide. To change the floor, rebuild the image and roll it out as described above.
To add a version to the window, merge the change. Then the extension is
published, and any node can ask for it without a new image.
mise run k0s-window vX.Y.Z+k0s.N adds a version. It downloads the binaries for
both architectures and hashes them. It does not trust a checksum published next
to the download. Review the resulting diff, because every later build verifies
against it.
CI makes sure that the image ships the floor that the lock file names. Thus a lock file and an image cannot disagree without a failure.
Before you skip a Kubernetes minor version, read the k0s release notes. k0s follows upstream Kubernetes, which does not support skipping minors on the control plane. A node refuses such a change immediately and does not attempt it.