2. Kubernetes installation — troubleshooting
This page collects symptoms, likely causes, and fixes for failures during
scripts/install-k8s-cluster.sh. Return to
02-kubernetes-install.md once the issue is resolved.
Troubleshooting first (proxy quick rules): - Put
LAB_HTTP_PROXYandLAB_HTTPS_PROXYinconfig/lab.envwhen this network requires a proxy. - The scripts build clusterNO_PROXYfrom inventory automatically. -kubectlcommands you type manually may still need shellno_proxy/NO_PROXY.
Proxy and NO_PROXY failures
Symptom: image pulls time out (dial tcp … i/o timeout)
level=info msg="fetch failed" error="... dial tcp <registry IP>:443: i/o timeout" host=registry.k8s.io
level=error msg="PullImage \"registry.k8s.io/kube-proxy:v1.35.1\" failed" error="... DeadlineExceeded ..."
Cause: containerd has no proxy configured and is trying to reach the registry directly.
Fix: Set LAB_HTTP_PROXY in config/lab.env and re-run the installer. Stage 1/5
will write the proxy into containerd's drop-in file before any pull is attempted.
Confirm containerd can reach the registry before waiting for kubeadm init to time
out:
sudo crictl pull registry.k8s.io/pause:3.10.1
This should complete in seconds. If it hangs, containerd cannot reach the registry; fix the proxy first.
To watch pull progress:
pgrep -a kubeadm # still running?
sudo crictl images | wc -l # count grows as pulls land
sudo journalctl -u containerd -n 40 --no-pager # pull errors and retries
tail -f ~/kubeadm-init.log # kubeadm's own output
Symptom: API server answers Forbidden
[control-plane-check] kube-apiserver is not healthy after 4m0.001354689s
error: ... kube-apiserver check failed at https://<control-plane IP>:6443/livez
Unable to connect to the server: Forbidden
Or in a later chapter:
== 1/4 draining worker-1 ==
Unable to connect to the server: Forbidden
Cause: The control-plane node's own IP is not in NO_PROXY, so kubectl and
kubeadm send the request to the proxy, which refuses CONNECT to port 6443 with
HTTP 403. The control plane itself is usually fine.
Forbidden is the tell. A crashed component gives connection refused, a TLS error,
or a CrashLoopBackOff in crictl ps -a, not Forbidden.
Prove it:
curl -sk -o /dev/null -w 'direct: %{http_code}\n' --noproxy '*' https://<control-plane IP>:6443/livez
curl -sk -o /dev/null -w 'proxied: %{http_code}\n' https://<control-plane IP>:6443/livez
direct: 200 with proxied: 403 confirms this problem.
Fix — on each node:
sudo tee -a /etc/environment >/dev/null <<'EOF'
no_proxy="localhost,127.0.0.1,10.96.0.0/12,10.244.0.0/16,<control-plane IP>,<worker IP>,control-plane,worker-1,.svc,.cluster.local"
NO_PROXY="localhost,127.0.0.1,10.96.0.0/12,10.244.0.0/16,<control-plane IP>,<worker IP>,control-plane,worker-1,.svc,.cluster.local"
EOF
Log out and back in for it to take effect on your own shell.
Fix — in your local shell (for kubectl you type by hand):
cat >>~/.bashrc <<'EOF'
export no_proxy="localhost,127.0.0.1,10.96.0.0/12,10.244.0.0/16,<control-plane IP>,<worker IP>,control-plane,worker-1,.svc,.cluster.local"
export NO_PROXY="$no_proxy"
EOF
localhost and 127.0.0.1 are not decoration: scripts/port-forward-prometheus.sh
publishes Prometheus on 127.0.0.1:19090, and without them the runner asks the
proxy for it and every measurement fails with nothing wrong in the cluster.
scripts/preflight.sh reports the gap:
WARN proxy set, and no_proxy in this shell does not cover the cluster
The permanent fix is LAB_HTTP_PROXY in config/lab.env — stage 1/5 then writes
NO_PROXY everywhere automatically, including the union of any existing containerd
drop-in you already have.
kubeadm HTTPProxy warnings
[WARNING HTTPProxy]: Connection to "https://<control-plane IP>" uses proxy "<proxy>"
[WARNING HTTPProxyCIDR]: connection to "10.96.0.0/12" uses proxy "<proxy>"
These warn that the node would talk to itself or to cluster-internal traffic through
the proxy. Both mean something is missing from NO_PROXY. The installer composes and
writes NO_PROXY from config/nodes.env automatically; if you see these warnings the
node was prepared before the current installer was in place. Apply the fix above.
Image pull / containerd / registry failures
Symptom: pull fails at stage 1/5
FATAL: containerd on worker-1 cannot pull registry.k8s.io/pause:3.10.1.
No proxy is configured, so containerd is trying to reach the
registry directly. If this network needs a proxy, put it in
LAB_HTTP_PROXY in config/lab.env ...
Cause: stage 1/5 runs a registry reachability check before proceeding; this failure stops the install before stage 2/5 can hang.
Fix: Set LAB_HTTP_PROXY and re-run. If you are behind a proxy, the installer
will write the proxy drop-in for containerd and re-test the pull.
If images are already on the node (registry mirror or pre-loaded air-gap set), skip the pull test:
SKIP_REGISTRY_CHECK=1 scripts/install-k8s-cluster.sh
Manual proxy check for containerd
If you need to configure containerd by hand (e.g., to check what the installer wrote):
sudo mkdir -p /etc/systemd/system/containerd.service.d
sudo tee /etc/systemd/system/containerd.service.d/http-proxy.conf >/dev/null <<'EOF'
[Service]
Environment="HTTP_PROXY=<proxy URL>"
Environment="HTTPS_PROXY=<proxy URL>"
Environment="NO_PROXY=localhost,127.0.0.1,10.96.0.0/12,10.244.0.0/16,<control-plane IP>,<worker IP>,control-plane,worker-1,.svc,.cluster.local"
EOF
sudo systemctl daemon-reload && sudo systemctl restart containerd
Verify it took effect:
sudo systemctl show containerd --property=Environment # must list HTTPS_PROXY
sudo crictl pull registry.k8s.io/pause:3.10.1 # seconds, not a 30 s timeout
Give apt its own setting too — it reads neither your shell nor /etc/environment:
sudo tee /etc/apt/apt.conf.d/95mxl-lab-proxy >/dev/null <<'EOF'
Acquire::http::Proxy "<proxy URL>";
Acquire::https::Proxy "<proxy URL>";
EOF
Calico rollout failures
Symptom: tigera-operator times out
Waiting for deployment "tigera-operator" rollout to finish: 0 out of 1 new replicas have been updated...
error: timed out waiting for the condition
Cause: the operator pod never became available. The most common reason is
ImagePullBackOff — Calico's images come from quay.io/tigera/operator and
docker.io/calico, not registry.k8s.io. A proxy that allows the Kubernetes
registry can still refuse those.
The installer prints pod details before it exits; to check by hand:
kubectl -n tigera-operator get pods -o wide
kubectl -n tigera-operator describe pod -l k8s-app=tigera-operator | tail -40
kubectl -n tigera-operator get events --sort-by=.lastTimestamp | tail -20
Stage 1/5 checks those registries and warns if they are unreachable:
WARN: quay.io answered 'nothing'. Calico's images come from there, so stage 3/5
of the cluster installer will fail until it is reachable
Fix: Test the exact image named in the event:
sudo crictl pull quay.io/tigera/operator:v1.42.0
If this fails, get quay.io and docker.io allowed through your proxy, or point
CALICO_OPERATOR_URL at a mirror you can reach.
Two things it is not:
- The missing CNI. The operator runs with
hostNetwork: true; it does not need pod networking to start. - The control-plane taint. Its Deployment tolerates every
NoScheduleandNoExecutetaint, so a single-node cluster schedules it fine.
Re-running scripts/install-k8s-cluster.sh after fixing the cause is safe; stage
3/5 reconciles what is already there.
Symptom: AlreadyExists from an older checkout
Error from server (AlreadyExists): error when creating "...tigera-operator.yaml":
namespaces "tigera-operator" already exists
Cause: the installer was using kubectl create, which cannot re-run over its own
work.
Fix: Update your checkout — the installer now uses server-side apply and converges on re-runs. Nothing on the cluster needs cleaning up first.
Failed kubeadm init — reset and retry
Symptom: installer detects a partial kubeadm init
FATAL: /etc/kubernetes/admin.conf already exists but the cluster is not healthy.
Reset the control plane before re-running.
Cause: A previous kubeadm init failed in wait-control-plane and left
certificates, static Pod manifests, and /etc/kubernetes/admin.conf behind.
Re-running cannot repair that state.
Fix: On the control-plane host:
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d ~/.kube
This removes the partial cluster state and nothing else. Then re-run:
scripts/install-k8s-cluster.sh
Manual node preparation
Stage 1/5 is the only part of the installer that touches each host individually.
If you want to prepare a worker directly on that machine, copy
scripts/install-k8s-node.sh there and run it as root with one "<IP> <node-name>"
pair per node in the cluster:
sudo ./install-k8s-node.sh worker-1 "<ctrl IP> control-plane" "<worker IP> worker-1"
scripts/install-k8s-cluster.sh is then idempotent from stage 2/5 onward and
picks up where the manual step left off.
Back to 02-kubernetes-install.md.