MicroK8s containerd and Private Registry Trust
Objective
Every image a Pod runs is pulled by containerd, not by Kubernetes. When the image lives in a private registry whose TLS certificate is signed by your own CA, the pull fails with x509: certificate signed by unknown authority no matter how correct your imagePullSecrets are, because trust is configured in containerd on the node. MicroK8s ships its own containerd with its own configuration files, separate from any containerd or Docker on the host. This concept covers where those files are, how to make containerd trust a registry through certs.d/<host:port>/hosts.toml, and how to apply the change.
Use Cases
- Pulling images from an internal Harbor, Nexus, GitLab or
registry:2instance with a company CA. - Using an HTTP-only registry in a lab (
localhost:32000from theregistryadd-on). - Adding a pull-through mirror for Docker Hub to avoid rate limits.
- Turning on containerd debug logging to understand a pull failure.
Deep Dive
Where MicroK8s keeps service configuration
All component flags live in plain files under /var/snap/microk8s/current/args/ (${SNAP_DATA}/args), readable only by root:
plaintext/var/snap/microk8s/current/args/ kube-apiserver kubelet kube-proxy kube-scheduler kube-controller-manager containerd # command-line flags of containerd containerd-template.toml # template rendered into containerd.toml containerd-env # environment (e.g. HTTP_PROXY for pulls) certs.d/ # per-registry host configuration ...
The containerd flags file on 1.35:
plaintext--config ${SNAP_DATA}/args/containerd.toml --root ${SNAP_COMMON}/var/lib/containerd --state ${SNAP_COMMON}/run/containerd --address ${SNAP_COMMON}/run/containerd.sock
And the template points the CRI registry configuration at certs.d:
plaintextconfig_path = "${SNAP_DATA}/args/certs.d"
After editing any args file, restart the services:
plaintextsudo snap restart microk8s # or microk8s stop && microk8s start
On a single node this restarts the whole control plane and containerd; running containers keep running, but the API is unavailable for a while, so do it in a maintenance window. Example, debug logging for containerd:
plaintextecho '-l=debug' | sudo tee -a /var/snap/microk8s/current/args/containerd sudo snap restart microk8s sudo journalctl -u snap.microk8s.daemon-containerd -f
certs.d: one directory per registry
Each registry gets a directory named exactly like the host part of the image reference, including the port:
plaintextimage: registry.internal:5000/demo/web-app:1.4.2 โโโโโโโโโโโโโโโโโโโโโโ /var/snap/microk8s/current/args/certs.d/registry.internal:5000/hosts.toml
A fresh install already has two, docker.io and localhost:32000:
plaintext# certs.d/docker.io/hosts.toml server = "https://docker.io" [host."https://registry-1.docker.io"] capabilities = ["pull", "resolve"]
For a registry signed by a private CA:
plaintextsudo mkdir -p /var/snap/microk8s/current/args/certs.d/registry.internal:5000 sudo cp company-root-ca.crt /var/snap/microk8s/current/args/certs.d/registry.internal:5000/ca.crt
plaintext# certs.d/registry.internal:5000/hosts.toml server = "https://registry.internal:5000" [host."https://registry.internal:5000"] capabilities = ["pull", "resolve"] ca = "/var/snap/microk8s/current/args/certs.d/registry.internal:5000/ca.crt"
For an HTTP-only registry (labs only):
plaintextserver = "http://10.0.0.20:32000" [host."http://10.0.0.20:32000"] capabilities = ["pull", "resolve"]
skip_verify = true exists too; it removes the protection TLS gives you and should not survive past debugging.
Then restart MicroK8s and test a pull directly with the bundled ctr, bypassing Kubernetes:
plaintextsudo microk8s ctr images pull --hosts-dir /var/snap/microk8s/current/args/certs.d \ registry.internal:5000/demo/web-app:1.4.2
Common mistakes
- Port missing from the directory name.
certs.d/registry.internal/does not matchregistry.internal:5000/.... The directory name is the registry host as written in the image reference. - Trusting the CA on the host only.
update-ca-certificateson Ubuntu helpscurland Docker on the host; the MicroK8s containerd uses its owncerts.d. Both can be needed, they are independent. - Intermediate missing. If the registry serves only its leaf certificate,
ca.crtmust contain the chain up to the root (intermediate + root concatenated). - Editing
containerd.toml. It is generated fromcontainerd-template.toml; edit the template (or better, usecerts.d) or your change disappears. - Old guides. MicroK8s 1.22 and older configured registries as
registry.mirrorsinside the template. From 1.23 on,certs.dis the mechanism. - Credentials here.
hosts.tomlis for trust and endpoints. Registry credentials belong in akubernetes.io/dockerconfigjsonSecret referenced byimagePullSecrets, see the image pull concept.
Proxies
If the node reaches registries through an HTTP proxy, containerd needs the proxy variables in containerd-env:
plaintextHTTPS_PROXY=http://proxy.example.com:3128 NO_PROXY=10.1.0.0/16,10.152.183.0/24,127.0.0.1,localhost,registry.internal
Forgetting NO_PROXY for the Pod and Service CIDRs (10.1.0.0/16 and 10.152.183.0/24 by default) sends in-cluster traffic to the proxy.
Trade-offs
- Per-node trust vs cluster-wide objects.
certs.dis node configuration: with three nodes, all three need the file, which is why it belongs in your bootstrap script, not in a runbook step. - Private CA vs public certificate on the registry. A public certificate (e.g. Let's Encrypt via DNS-01) removes the need for any node configuration, at the price of the registry having a public DNS name.
- Restart cost. Every change requires a MicroK8s restart. Batch registry changes and test with
ctrbefore restarting.