Create Your Own Talos or CoreOS Linux
Talos shows that a Kubernetes-focused OS can be tiny, immutable, and purpose-built, much like CoreOS once did for container hosts. But the more interesting idea is that this approach is not magic or exclusive to one vendor. With tools like LinuxKit, a minimal boot image, and a small Kubernetes runtime, you can build your own stripped-down node OS: custom, repeatable, and designed to include only the components your cluster actually needs.
This walkthrough combines two labs. Part I builds three immutable LinuxKit kernel+initrd images, boots them as Firecracker microVMs, and turns them into a small Kubernetes cluster with one control-plane node and two workers using k3s. Part II builds one reusable guest rootfs, clones it into multiple Firecracker disks, and brings up a full kubeadm HA cluster: three control-plane nodes with stacked etcd, two workers, and the Kubernetes API exposed through a host-side HAProxy endpoint.
Part I — LinuxKit and k3s
This part runs directly on a Linux lab host. Run everything as root on the host that will run
Firecracker. The host needs Docker, Firecracker, ip, iptables,
ssh, ssh-keygen, openssl, and outbound internet access for
pulling container images. Pick a subnet that does not overlap with other bridges on the host.
1. Export The Lab Variables
export RUN_ROOT=/var/lib/create-your-own-talos
export NODE_COUNT=3
export SUBNET_PREFIX=198.19.0
export SERVER_IP="${SUBNET_PREFIX}.10"
export GATEWAY="${SUBNET_PREFIX}.1"
export CIDR="${SUBNET_PREFIX}.0/24"
export BRIDGE_NAME=lkt198
export TAP_PREFIX=lkt198
export FIRECRACKER_BIN=/usr/local/bin/firecracker
export LINUXKIT_VERSION=v1.8.2
export LINUXKIT_BIN=/root/linuxkit/bin/linuxkit
export LINUXKIT_KERNEL_IMAGE=linuxkit/kernel:6.12.59
export K3S_IMAGE=rancher/k3s:v1.36.1-k3s1
export K3S_TOKEN="$(openssl rand -hex 24)"
export GUEST_SSH_KEY="${RUN_ROOT}/lab_ssh_key"
export GUEST_SSH_PUB="${GUEST_SSH_KEY}.pub"
2. Install LinuxKit And The Guest SSH Key
command -v docker ip iptables ssh ssh-keygen openssl >/dev/null
test -x "${FIRECRACKER_BIN}"
mkdir -p "$(dirname "${LINUXKIT_BIN}")" "${RUN_ROOT}"
if [[ ! -x "${LINUXKIT_BIN}" ]]; then
curl -fsSL \
"https://github.com/linuxkit/linuxkit/releases/download/${LINUXKIT_VERSION}/linuxkit-linux-amd64" \
-o "${LINUXKIT_BIN}"
chmod +x "${LINUXKIT_BIN}"
fi
if [[ ! -f "${GUEST_SSH_KEY}" || ! -f "${GUEST_SSH_PUB}" ]]; then
ssh-keygen -q -t ed25519 -N "" -f "${GUEST_SSH_KEY}"
fi
3. Define A Few Helper Functions
node_ip() {
printf '%s.%d\n' "${SUBNET_PREFIX}" "$((10 + $1))"
}
node_name() {
printf 'linuxkit-%02d\n' "$1"
}
node_tap() {
printf '%s%d\n' "${TAP_PREFIX}" "$1"
}
node_mac() {
printf '06:19:80:00:00:%02x\n' "$((16 + $1))"
}
node_mem() {
if [[ "$1" == "0" ]]; then
echo 2048
else
echo 1536
fi
}
SSH_OPTS=(-n -i "${GUEST_SSH_KEY}" -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
server_ssh() {
ssh "${SSH_OPTS[@]}" "root@${SERVER_IP}" "$@"
}
k3s_exec() {
local cmd="$1"
local exec_id="x$(date +%s%N)"
server_ssh "ctr -n services.linuxkit tasks exec --exec-id ${exec_id} k3s /bin/sh -lc $(printf '%q' "${cmd}")"
}
4. Write The LinuxKit YAML
Control plane:
cat > "${RUN_ROOT}/server.yml" <<EOF
kernel:
image: ${LINUXKIT_KERNEL_IMAGE}
cmdline: "console=tty0 console=ttyS0 console=ttyAMA0"
init:
- linuxkit/init:b5506cc74a6812dc40982cacfd2f4328f8a4b12a
- linuxkit/runc:9442aa234715e751a16144f1d4ae3fd1a00fd492
- linuxkit/containerd:ba19f64efd3331a8fd0a33e00eabd14f6ee1780e
- linuxkit/ca-certificates:256f1950df59f2f209e9f0b81374177409eb11de
onboot:
- name: sysctl
image: linuxkit/sysctl:43ac1d39da329c3567fcb9689e5ca99de6d169b6
- name: ip
image: linuxkit/ip:3c0676ee83a0dc739685be1253b8326f08581ef7
binds:
- /etc/ip:/etc/ip
command: ["ip", "-b", "/etc/ip/eth0.conf"]
services:
- name: getty
image: linuxkit/getty:a86d74c8f89be8956330c3b115b0b1f2e09ef6e0
env:
- INSECURE=true
- name: rngd
image: linuxkit/rngd:984eb580ecb63986f07f626b61692a97aacd7198
- name: sshd
image: linuxkit/sshd:08e5d4a46603eff485d5d1b14001cc932a530858
binds.add:
- /root/.ssh:/root/.ssh
- name: k3s
image: ${K3S_IMAGE}
capabilities:
- all
net: host
rootfsPropagation: shared
devices:
- path: /dev/kmsg
type: c
major: 1
minor: 11
mode: "0666"
mounts:
- type: cgroup
options: ["rw","nosuid","noexec","nodev","relatime"]
binds:
- /etc/resolv.conf:/etc/resolv.conf
- /lib/modules:/lib/modules
- /var:/var:rshared,rbind
env:
- PATH=/bin:/bin/aux:/usr/bin:/sbin:/usr/sbin
- K3S_TOKEN=${K3S_TOKEN}
command:
- /bin/k3s
- server
- --cluster-init
- --node-name=linuxkit-00
- --node-ip=${SERVER_IP}
- --advertise-address=${SERVER_IP}
- --bind-address=0.0.0.0
- --tls-san=${SERVER_IP}
- --write-kubeconfig-mode=0644
- --disable=traefik
- --disable=servicelb
- --disable=metrics-server
- --disable=local-storage
- --disable-cloud-controller
- --disable-network-policy
- --flannel-iface=eth0
- --flannel-backend=host-gw
files:
- path: etc/ip/eth0.conf
contents: |
address add ${SERVER_IP}/24 dev eth0
link set eth0 up
route add default via ${GATEWAY} dev eth0
- path: etc/resolv.conf
contents: |
nameserver 1.1.1.1
nameserver 8.8.8.8
- path: root/.ssh/authorized_keys
source: ${GUEST_SSH_PUB}
mode: "0600"
EOF
Workers:
cat > "${RUN_ROOT}/agent.yml.tmpl" <<EOF
kernel:
image: ${LINUXKIT_KERNEL_IMAGE}
cmdline: "console=tty0 console=ttyS0 console=ttyAMA0"
init:
- linuxkit/init:b5506cc74a6812dc40982cacfd2f4328f8a4b12a
- linuxkit/runc:9442aa234715e751a16144f1d4ae3fd1a00fd492
- linuxkit/containerd:ba19f64efd3331a8fd0a33e00eabd14f6ee1780e
- linuxkit/ca-certificates:256f1950df59f2f209e9f0b81374177409eb11de
onboot:
- name: sysctl
image: linuxkit/sysctl:43ac1d39da329c3567fcb9689e5ca99de6d169b6
- name: ip
image: linuxkit/ip:3c0676ee83a0dc739685be1253b8326f08581ef7
binds:
- /etc/ip:/etc/ip
command: ["ip", "-b", "/etc/ip/eth0.conf"]
services:
- name: getty
image: linuxkit/getty:a86d74c8f89be8956330c3b115b0b1f2e09ef6e0
env:
- INSECURE=true
- name: rngd
image: linuxkit/rngd:984eb580ecb63986f07f626b61692a97aacd7198
- name: sshd
image: linuxkit/sshd:08e5d4a46603eff485d5d1b14001cc932a530858
binds.add:
- /root/.ssh:/root/.ssh
- name: k3s
image: ${K3S_IMAGE}
capabilities:
- all
net: host
rootfsPropagation: shared
devices:
- path: /dev/kmsg
type: c
major: 1
minor: 11
mode: "0666"
mounts:
- type: cgroup
options: ["rw","nosuid","noexec","nodev","relatime"]
binds:
- /etc/resolv.conf:/etc/resolv.conf
- /lib/modules:/lib/modules
- /var:/var:rshared,rbind
env:
- PATH=/bin:/bin/aux:/usr/bin:/sbin:/usr/sbin
- K3S_TOKEN=${K3S_TOKEN}
command:
- /bin/k3s
- agent
- --server=https://${SERVER_IP}:6443
- --node-name=__NODE_NAME__
- --node-ip=__NODE_IP__
- --flannel-iface=eth0
files:
- path: etc/ip/eth0.conf
contents: |
address add __NODE_IP__/24 dev eth0
link set eth0 up
route add default via ${GATEWAY} dev eth0
- path: etc/resolv.conf
contents: |
nameserver 1.1.1.1
nameserver 8.8.8.8
- path: root/.ssh/authorized_keys
source: ${GUEST_SSH_PUB}
mode: "0600"
EOF
5. Build The Images
mkdir -p "${RUN_ROOT}/nodes/node0"
cp "${RUN_ROOT}/server.yml" "${RUN_ROOT}/nodes/node0/node.yml"
"${LINUXKIT_BIN}" build \
--decompress-kernel \
--dir "${RUN_ROOT}/nodes/node0" \
--name node \
-f kernel+initrd \
"${RUN_ROOT}/nodes/node0/node.yml"
for i in 1 2; do
ip="$(node_ip "${i}")"
name="$(node_name "${i}")"
dir="${RUN_ROOT}/nodes/node${i}"
mkdir -p "${dir}"
sed \
-e "s/__NODE_NAME__/${name}/g" \
-e "s/__NODE_IP__/${ip}/g" \
"${RUN_ROOT}/agent.yml.tmpl" > "${dir}/node.yml"
"${LINUXKIT_BIN}" build \
--decompress-kernel \
--dir "${dir}" \
--name node \
-f kernel+initrd \
"${dir}/node.yml"
done
6. Prepare The Bridge And NAT
ip link add name "${BRIDGE_NAME}" type bridge 2>/dev/null || true
ip addr add "${GATEWAY}/24" dev "${BRIDGE_NAME}" 2>/dev/null || true
ip link set "${BRIDGE_NAME}" up
sysctl -w net.ipv4.ip_forward=1
iptables -C FORWARD -i "${BRIDGE_NAME}" -j ACCEPT 2>/dev/null || iptables -A FORWARD -i "${BRIDGE_NAME}" -j ACCEPT
iptables -C FORWARD -o "${BRIDGE_NAME}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -o "${BRIDGE_NAME}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -t nat -C POSTROUTING -s "${CIDR}" ! -o "${BRIDGE_NAME}" -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s "${CIDR}" ! -o "${BRIDGE_NAME}" -j MASQUERADE
The explicit FORWARD rules matter on hosts where the default FORWARD policy is DROP. NAT alone is not enough.
7. Boot The MicroVMs
for i in 0 1 2; do
dir="${RUN_ROOT}/nodes/node${i}"
tap="$(node_tap "${i}")"
mac="$(node_mac "${i}")"
mem="$(node_mem "${i}")"
ip tuntap add dev "${tap}" mode tap 2>/dev/null || true
ip link set "${tap}" master "${BRIDGE_NAME}"
ip link set "${tap}" up
cat > "${dir}/vm.json" <<EOF
{"boot-source":{"kernel_image_path":"${dir}/node-kernel","initrd_path":"${dir}/node-initrd.img","boot_args":"$(tr -d '\n' <"${dir}/node-cmdline") reboot=k panic=1 pci=off random.trust_cpu=on"},"drives":[],"machine-config":{"vcpu_count":2,"mem_size_mib":${mem}},"network-interfaces":[{"iface_id":"eth0","host_dev_name":"${tap}","guest_mac":"${mac}"}],"logger":{"log_path":"${dir}/firecracker.log","level":"Info","show_level":true,"show_log_origin":true}}
EOF
"${FIRECRACKER_BIN}" \
--api-sock "${dir}/fc.sock" \
--config-file "${dir}/vm.json" \
> "${dir}/console.log" 2>&1 &
echo $! > "${dir}/pid"
done
8. Wait For SSH And Cluster Readiness
for ip in "$(node_ip 0)" "$(node_ip 1)" "$(node_ip 2)"; do
until ssh "${SSH_OPTS[@]}" "root@${ip}" true >/dev/null 2>&1; do
sleep 2
done
done
for _ in $(seq 1 180); do
k3s_exec "kubectl get nodes -o wide > /var/log/create-your-own-talos.nodes 2>&1 || true" >/dev/null 2>&1 || true
ready_count="$(server_ssh "awk '\$2==\"Ready\"{c++} END{print c+0}' /var/log/create-your-own-talos.nodes 2>/dev/null || echo 0")"
if [[ "${ready_count}" -ge "${NODE_COUNT}" ]]; then
break
fi
sleep 3
done
server_ssh "cat /var/log/create-your-own-talos.nodes"
Expected result:
linuxkit-00 Ready control-plane,etcd
linuxkit-01 Ready <none>
linuxkit-02 Ready <none>
9. Run A Smoke Test
k3s_exec "$(cat <<'EOF'
kubectl create namespace smoke --dry-run=client -o yaml | kubectl apply -f -
cat <<'YAML' | kubectl apply -f -
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-smoke
namespace: smoke
spec:
selector:
matchLabels:
app: node-smoke
template:
metadata:
labels:
app: node-smoke
spec:
tolerations:
- operator: Exists
containers:
- name: node-smoke
image: busybox:1.36
command: ["sh", "-lc", "sleep 3600"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: echo
namespace: smoke
spec:
replicas: 2
selector:
matchLabels:
app: echo
template:
metadata:
labels:
app: echo
spec:
containers:
- name: echo
image: nginx:1.27-alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: echo
namespace: smoke
spec:
selector:
app: echo
ports:
- port: 80
targetPort: 80
YAML
kubectl -n smoke rollout status daemonset/node-smoke --timeout=240s
kubectl -n smoke rollout status deployment/echo --timeout=240s
kubectl -n kube-system rollout status deployment/coredns --timeout=240s
cat <<'YAML' | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: dns-http
namespace: smoke
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: dns-http
image: busybox:1.36
command:
- sh
- -lc
- |
for _ in $(seq 1 30); do
nslookup echo.smoke.svc.cluster.local && \
wget -qO- http://echo.smoke.svc.cluster.local >/tmp/echo.html && \
test -s /tmp/echo.html && exit 0
sleep 2
done
exit 1
YAML
kubectl -n smoke wait --for=condition=complete job/dns-http --timeout=240s
kubectl get pods -A -o wide > /var/log/create-your-own-talos.pods 2>&1
kubectl -n smoke get pods -o wide > /var/log/create-your-own-talos.smoke-pods 2>&1
kubectl -n smoke logs job/dns-http > /var/log/create-your-own-talos.smoke-job.log 2>&1
kubectl config view --raw > /var/log/create-your-own-talos.kubeconfig 2>&1
EOF
)"
server_ssh "cat /var/log/create-your-own-talos.smoke-pods"
server_ssh "cat /var/log/create-your-own-talos.smoke-job.log"
That proves:
- all three nodes can schedule workloads;
- in-cluster DNS resolves
echo.smoke.svc.cluster.local; - a pod can reach the service over HTTP.
10. Save The Artifacts
mkdir -p "${RUN_ROOT}/artifacts"
server_ssh "cat /var/log/create-your-own-talos.nodes" > "${RUN_ROOT}/artifacts/nodes.txt"
server_ssh "cat /var/log/create-your-own-talos.pods" > "${RUN_ROOT}/artifacts/pods.txt"
server_ssh "cat /var/log/create-your-own-talos.smoke-pods" > "${RUN_ROOT}/artifacts/smoke-pods.txt"
server_ssh "cat /var/log/create-your-own-talos.smoke-job.log" > "${RUN_ROOT}/artifacts/smoke-job.log"
server_ssh "cat /var/log/create-your-own-talos.kubeconfig" \
| sed -e "s#https://127.0.0.1:6443#https://${SERVER_IP}:6443#g" \
-e "s#https://0.0.0.0:6443#https://${SERVER_IP}:6443#g" \
> "${RUN_ROOT}/artifacts/kubeconfig.yaml"
From there:
export KUBECONFIG="${RUN_ROOT}/artifacts/kubeconfig.yaml"
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl -n smoke get pods -o wide
11. Tear It Down
for pid_file in "${RUN_ROOT}"/nodes/*/pid; do
[[ -f "${pid_file}" ]] || continue
kill "$(cat "${pid_file}")" 2>/dev/null || true
done
sleep 1
for pid_file in "${RUN_ROOT}"/nodes/*/pid; do
[[ -f "${pid_file}" ]] || continue
kill -9 "$(cat "${pid_file}")" 2>/dev/null || true
done
for i in 0 1 2; do
ip link del "$(node_tap "${i}")" 2>/dev/null || true
done
iptables -D FORWARD -i "${BRIDGE_NAME}" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -o "${BRIDGE_NAME}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -t nat -D POSTROUTING -s "${CIDR}" ! -o "${BRIDGE_NAME}" -j MASQUERADE 2>/dev/null || true
ip link set "${BRIDGE_NAME}" down 2>/dev/null || true
ip link del "${BRIDGE_NAME}" type bridge 2>/dev/null || true
rm -rf "${RUN_ROOT}"
Part II — kubeadm HA
The second lab runs on the same Linux host. It builds one reusable guest rootfs, clones it into multiple Firecracker microVM disks, boots three kubeadm control-plane nodes with stacked etcd (one member per control-plane node), two workers by default, and exposes the Kubernetes API through a host-side HAProxy endpoint.
The host needs Docker, Firecracker, curl, ip, iptables,
ssh, scp, ssh-keygen, unsquashfs from
squashfs-tools, mkfs.ext4, e2fsck, resize2fs from e2fsprogs,
mount, umount, truncate, chroot, and outbound
internet access for the LinuxKit kernel image, the Firecracker CI Ubuntu rootfs, Kubernetes packages
from pkgs.k8s.io, the Docker containerd package repo, the Flannel manifest, and the HAProxy
container image.
1. Export The Lab Variables
This guide creates 3 control-plane nodes and 2 workers by default. Change WORKER_COUNT if you want fewer or more workers.
export RUN_ROOT=/var/lib/firecracker-kubeadm-ha
export CACHE_ROOT=/var/cache/firecracker-kubeadm-ha
export CONTROL_PLANE_COUNT=3
export WORKER_COUNT=2
export NODE_COUNT="$((CONTROL_PLANE_COUNT + WORKER_COUNT))"
export SUBNET_PREFIX=198.19.0
export GATEWAY="${SUBNET_PREFIX}.1"
export CIDR="${SUBNET_PREFIX}.0/24"
export API_LB_IP="${SUBNET_PREFIX}.5"
export API_LB_PORT=6443
export PRIMARY_CONTROL_PLANE_IP="${SUBNET_PREFIX}.10"
export BRIDGE_NAME=k8sha198
export TAP_PREFIX=k8sha198
export FIRECRACKER_BIN=/usr/local/bin/firecracker
export FIRECRACKER_ARCH="$(uname -m)"
export LINUXKIT_KERNEL_IMAGE=linuxkit/kernel:6.12.59
export ROOTFS_SIZE_GIB=12
export CONTROL_PLANE_MEM_MIB=2048
export WORKER_MEM_MIB=1536
export VCPU_COUNT=2
export KUBERNETES_MINOR=v1.36
export KUBERNETES_VERSION=v1.36.1
export POD_CIDR=10.244.0.0/16
export SERVICE_CIDR=10.96.0.0/12
export CNI_PLUGINS_VERSION=v1.3.0
export NETWORK_PLUGIN=flannel
export FLANNEL_MANIFEST_URL="https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml"
export HAPROXY_IMAGE=haproxy:3.2.19-alpine
export API_LB_CONTAINER_NAME="kubeadm-ha-api-lb-${BRIDGE_NAME}"
export GUEST_SSH_KEY="${CACHE_ROOT}/lab_ssh_key"
export GUEST_SSH_PUB="${GUEST_SSH_KEY}.pub"
export ARTIFACT_PREFIX=/var/log/kubeadm-ha-lab
export KERNEL_BOOT_ARGS="console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw random.trust_cpu=on [email protected] systemd.mask=systemd-random-seed.service"
2. Check The Host Dependencies And Create The Guest SSH Key
command -v awk chroot curl docker e2fsck grep ip iptables mkfs.ext4 mount \
resize2fs sha256sum sort ssh scp ssh-keygen truncate umount unsquashfs >/dev/null
test -x "${FIRECRACKER_BIN}"
mkdir -p "${RUN_ROOT}" "${CACHE_ROOT}"
if [[ ! -f "${GUEST_SSH_KEY}" || ! -f "${GUEST_SSH_PUB}" ]]; then
ssh-keygen -q -t ed25519 -N "" -f "${GUEST_SSH_KEY}"
fi
3. Define A Few Helper Functions
node_role() {
if (( $1 < CONTROL_PLANE_COUNT )); then
echo control-plane
else
echo worker
fi
}
node_ip() {
printf '%s.%d\n' "${SUBNET_PREFIX}" "$((10 + $1))"
}
node_name() {
printf 'k8s-%02d\n' "$1"
}
node_tap() {
printf '%s%d\n' "${TAP_PREFIX}" "$1"
}
node_mac() {
printf '06:36:00:00:00:%02x\n' "$((16 + $1))"
}
node_mem() {
if [[ "$(node_role "$1")" == "control-plane" ]]; then
echo "${CONTROL_PLANE_MEM_MIB}"
else
echo "${WORKER_MEM_MIB}"
fi
}
SSH_OPTS=(-i "${GUEST_SSH_KEY}" -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5)
node_ssh() {
local idx="$1"
shift
ssh "${SSH_OPTS[@]}" "root@$(node_ip "${idx}")" "$@"
}
server_ssh() {
node_ssh 0 "$@"
}
copy_guest_file() {
local remote_path="$1"
local local_path="$2"
if server_ssh "test -f $(printf '%q' "${remote_path}")" >/dev/null 2>&1; then
server_ssh "cat $(printf '%q' "${remote_path}")" > "${local_path}"
fi
}
wait_for_ssh() {
local ip="$1"
for _ in $(seq 1 120); do
if ssh "${SSH_OPTS[@]}" "root@${ip}" true >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
wait_for_node_ready() {
local name="$1"
for _ in $(seq 1 240); do
if server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf get node ${name} --no-headers 2>/dev/null | awk '\$2==\"Ready\"{ok=1} END{exit(ok?0:1)}'"; then
return 0
fi
sleep 2
done
return 1
}
4. Download The Guest Kernel And Rootfs Inputs
This guide uses a LinuxKit kernel image and the latest Firecracker CI Ubuntu squashfs for your Firecracker major/minor.
4.1 Pull The LinuxKit Kernel And Extract vmlinux
export LINUXKIT_KEY="$(printf '%s\n' "${LINUXKIT_KERNEL_IMAGE}" | sha256sum | awk '{print substr($1,1,16)}')"
export LINUXKIT_CACHE_DIR="${CACHE_ROOT}/downloads/linuxkit-${LINUXKIT_KEY}"
export KERNEL_PATH="${LINUXKIT_CACHE_DIR}/vmlinux"
export KERNEL_BZIMAGE_PATH="${LINUXKIT_CACHE_DIR}/kernel"
export KERNEL_MODULES_TAR_PATH="${LINUXKIT_CACHE_DIR}/kernel.tar"
export KERNEL_DEV_TAR_PATH="${LINUXKIT_CACHE_DIR}/kernel-dev.tar"
mkdir -p "${LINUXKIT_CACHE_DIR}"
if [[ ! -f "${KERNEL_PATH}" || ! -f "${KERNEL_MODULES_TAR_PATH}" ]]; then
rm -rf "${LINUXKIT_CACHE_DIR}.tmp"
mkdir -p "${LINUXKIT_CACHE_DIR}.tmp"
docker pull "${LINUXKIT_KERNEL_IMAGE}" >/dev/null
cid="$(docker create "${LINUXKIT_KERNEL_IMAGE}" /bin/sh)"
docker cp "${cid}:/kernel" "${LINUXKIT_CACHE_DIR}.tmp/kernel"
docker cp "${cid}:/kernel.tar" "${LINUXKIT_CACHE_DIR}.tmp/kernel.tar"
docker cp "${cid}:/kernel-dev.tar" "${LINUXKIT_CACHE_DIR}.tmp/kernel-dev.tar"
docker rm -f "${cid}" >/dev/null
export LINUXKIT_HEADERS_DIR="$(
tar -tf "${LINUXKIT_CACHE_DIR}.tmp/kernel-dev.tar" \
| sed -n 's#^\(usr/src/linux-headers-[^/]*\)/scripts/extract-vmlinux$#\1#p' \
| head -n 1
)"
tar -xOf "${LINUXKIT_CACHE_DIR}.tmp/kernel-dev.tar" "${LINUXKIT_HEADERS_DIR}/scripts/extract-vmlinux" > "${LINUXKIT_CACHE_DIR}.tmp/extract-vmlinux"
chmod +x "${LINUXKIT_CACHE_DIR}.tmp/extract-vmlinux"
"${LINUXKIT_CACHE_DIR}.tmp/extract-vmlinux" "${LINUXKIT_CACHE_DIR}.tmp/kernel" > "${LINUXKIT_CACHE_DIR}.tmp/vmlinux"
mv "${LINUXKIT_CACHE_DIR}.tmp/kernel" "${KERNEL_BZIMAGE_PATH}"
mv "${LINUXKIT_CACHE_DIR}.tmp/vmlinux" "${KERNEL_PATH}"
mv "${LINUXKIT_CACHE_DIR}.tmp/kernel.tar" "${KERNEL_MODULES_TAR_PATH}"
mv "${LINUXKIT_CACHE_DIR}.tmp/kernel-dev.tar" "${KERNEL_DEV_TAR_PATH}"
rm -rf "${LINUXKIT_CACHE_DIR}.tmp"
fi
4.2 Download The Firecracker Ubuntu Rootfs Squashfs
export FIRECRACKER_CI_VERSION="$("${FIRECRACKER_BIN}" --version | awk '{print $2}' | sed 's/\.[0-9]*$//')"
export UBUNTU_KEY="$(
curl -fsSL "https://s3.amazonaws.com/spec.ccfc.min?prefix=firecracker-ci/${FIRECRACKER_CI_VERSION}/${FIRECRACKER_ARCH}/ubuntu-&list-type=2" \
| grep -oP "(?<=<Key>)(firecracker-ci/${FIRECRACKER_CI_VERSION}/${FIRECRACKER_ARCH}/ubuntu-[0-9]+\.[0-9]+\.squashfs)(?=</Key>)" \
| sort -V | tail -1
)"
export ROOTFS_SQUASHFS_PATH="${CACHE_ROOT}/downloads/$(basename "${UBUNTU_KEY}")"
mkdir -p "${CACHE_ROOT}/downloads"
if [[ ! -f "${ROOTFS_SQUASHFS_PATH}" ]]; then
curl -fsSL "https://s3.amazonaws.com/spec.ccfc.min/${UBUNTU_KEY}" -o "${ROOTFS_SQUASHFS_PATH}"
fi
5. Create The Base ext4 Rootfs
This converts the squashfs into an ext4 image and injects the guest SSH key.
export BASE_KEY="$(
{
sha256sum "${ROOTFS_SQUASHFS_PATH}"
sha256sum "${GUEST_SSH_PUB}"
} | sha256sum | awk '{print substr($1,1,16)}'
)"
export BASE_ROOTFS_PATH="${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.ext4"
mkdir -p "${CACHE_ROOT}/base"
if [[ ! -s "${BASE_ROOTFS_PATH}" ]]; then
rm -rf "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs"
rm -f "${BASE_ROOTFS_PATH}.tmp"
mkdir -p "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs"
unsquashfs -d "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs" "${ROOTFS_SQUASHFS_PATH}" >/dev/null
mkdir -p "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/root/.ssh"
chmod 700 "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/root/.ssh"
cp "${GUEST_SSH_PUB}" "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/root/.ssh/authorized_keys"
chmod 600 "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/root/.ssh/authorized_keys"
if [[ -f "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/etc/ssh/sshd_config" ]]; then
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/etc/ssh/sshd_config" || true
sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/etc/ssh/sshd_config" || true
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs/etc/ssh/sshd_config" || true
fi
chown -R root:root "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs"
truncate -s 4G "${BASE_ROOTFS_PATH}.tmp"
mkfs.ext4 -d "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs/rootfs" -F "${BASE_ROOTFS_PATH}.tmp" >/dev/null
mv "${BASE_ROOTFS_PATH}.tmp" "${BASE_ROOTFS_PATH}"
rm -rf "${CACHE_ROOT}/base/ubuntu-${BASE_KEY}.rootfs"
fi
6. Prepare One Reusable kubeadm-Capable Guest Image
This installs kernel modules from the LinuxKit kernel tarball, containerd, kubelet, kubeadm, kubectl, CNI plugins, Kubernetes sysctls, and base service wiring.
export PREPARED_KEY="$(
{
sha256sum "${BASE_ROOTFS_PATH}" "${KERNEL_PATH}" "${GUEST_SSH_PUB}" "${KERNEL_MODULES_TAR_PATH}"
printf 'kubernetes_minor=%s\n' "${KUBERNETES_MINOR}"
printf 'kubernetes_version=%s\n' "${KUBERNETES_VERSION}"
printf 'cni_plugins=%s\n' "${CNI_PLUGINS_VERSION}"
printf 'rootfs_size_gib=%s\n' "${ROOTFS_SIZE_GIB}"
printf 'generation=manual-kubeadm-firecracker-ha-v1\n'
} | sha256sum | awk '{print substr($1,1,16)}'
)"
export PREPARED_ROOTFS_PATH="${CACHE_ROOT}/prepared-${PREPARED_KEY}.ext4"
if [[ ! -s "${PREPARED_ROOTFS_PATH}" ]]; then
cp --reflink=auto "${BASE_ROOTFS_PATH}" "${PREPARED_ROOTFS_PATH}.tmp" 2>/dev/null || cp "${BASE_ROOTFS_PATH}" "${PREPARED_ROOTFS_PATH}.tmp"
e2fsck -fy "${PREPARED_ROOTFS_PATH}.tmp" >/dev/null 2>&1 || true
truncate -s "${ROOTFS_SIZE_GIB}G" "${PREPARED_ROOTFS_PATH}.tmp"
resize2fs "${PREPARED_ROOTFS_PATH}.tmp" >/dev/null
export MNT="${CACHE_ROOT}/mnt-${PREPARED_KEY}"
mkdir -p "${MNT}"
mount -o loop "${PREPARED_ROOTFS_PATH}.tmp" "${MNT}"
mount -t proc proc "${MNT}/proc"
mount -t sysfs sysfs "${MNT}/sys"
mount --bind /dev "${MNT}/dev"
mount -t devpts devpts "${MNT}/dev/pts"
mount --bind /run "${MNT}/run"
rm -f "${MNT}/etc/resolv.conf"
printf 'nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions timeout:2 attempts:3\n' > "${MNT}/etc/resolv.conf"
rm -rf "${MNT}/lib/modules"
mkdir -p "${MNT}/lib"
tar -xf "${KERNEL_MODULES_TAR_PATH}" -C "${MNT}" ./lib/modules
chmod 1777 "${MNT}/tmp"
mkdir -p "${MNT}/dev/pts" "${MNT}/var/cache/apt/archives/partial" "${MNT}/var/lib/apt/lists/partial" "${MNT}/var/log/apt"
touch "${MNT}/var/log/dpkg.log"
mkdir -p "${MNT}/root/.ssh"
chmod 700 "${MNT}/root/.ssh"
cp "${GUEST_SSH_PUB}" "${MNT}/root/.ssh/authorized_keys"
chmod 600 "${MNT}/root/.ssh/authorized_keys"
if [[ -f "${MNT}/etc/ssh/sshd_config" ]]; then
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' "${MNT}/etc/ssh/sshd_config" || true
sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' "${MNT}/etc/ssh/sshd_config" || true
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' "${MNT}/etc/ssh/sshd_config" || true
fi
chroot "${MNT}" apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 update
DEBIAN_FRONTEND=noninteractive chroot "${MNT}" apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 \
-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install -y --no-install-recommends \
apt-transport-https ca-certificates conntrack curl ebtables ethtool gpg iptables ipset jq openssh-server socat tar xz-utils
mkdir -p "${MNT}/etc/apt/keyrings" "${MNT}/etc/apt/sources.list.d"
docker_arch="$(chroot "${MNT}" dpkg --print-architecture)"
docker_codename="$(awk -F= '/^VERSION_CODENAME=/{print $2}' "${MNT}/etc/os-release")"
chroot "${MNT}" curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" | chroot "${MNT}" gpg --dearmor -o /etc/apt/keyrings/docker.gpg
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu %s stable\n' "${docker_arch}" "${docker_codename}" > "${MNT}/etc/apt/sources.list.d/docker.list"
chroot "${MNT}" curl -fsSL "https://pkgs.k8s.io/core:/stable:/${KUBERNETES_MINOR}/deb/Release.key" | chroot "${MNT}" gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
printf 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/%s/deb/ /\n' "${KUBERNETES_MINOR}" > "${MNT}/etc/apt/sources.list.d/kubernetes.list"
chroot "${MNT}" apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 update
DEBIAN_FRONTEND=noninteractive chroot "${MNT}" apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 \
-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install -y containerd.io \
"kubelet=${KUBERNETES_VERSION#v}-*" "kubeadm=${KUBERNETES_VERSION#v}-*" "kubectl=${KUBERNETES_VERSION#v}-*"
chroot "${MNT}" apt-mark hold kubelet kubeadm kubectl >/dev/null 2>&1 || true
case "$(uname -m)" in
x86_64) cni_arch=amd64 ;;
aarch64) cni_arch=arm64 ;;
*) echo "unsupported CNI architecture: $(uname -m)" >&2; exit 1 ;;
esac
export CNI_ARCHIVE="${CACHE_ROOT}/downloads/cni-plugins-linux-${cni_arch}-${CNI_PLUGINS_VERSION}.tgz"
if [[ ! -f "${CNI_ARCHIVE}" ]]; then
curl -fsSL "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/cni-plugins-linux-${cni_arch}-${CNI_PLUGINS_VERSION}.tgz" -o "${CNI_ARCHIVE}"
fi
mkdir -p "${MNT}/opt/cni/bin"
tar -C "${MNT}/opt/cni/bin" -xzf "${CNI_ARCHIVE}"
pause_image="$(chroot "${MNT}" kubeadm config images list 2>/dev/null | awk '/pause/{print $1; exit}')"
mkdir -p "${MNT}/etc/containerd"
chroot "${MNT}" containerd config default > "${MNT}/etc/containerd/config.toml"
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' "${MNT}/etc/containerd/config.toml"
if [[ -n "${pause_image}" ]]; then
sed -i "s#sandbox_image = \".*\"#sandbox_image = \"${pause_image}\"#" "${MNT}/etc/containerd/config.toml"
fi
mkdir -p "${MNT}/etc/modules-load.d" "${MNT}/etc/sysctl.d" "${MNT}/etc/systemd/system/multi-user.target.wants"
cat > "${MNT}/etc/modules-load.d/kubernetes.conf" <<'EOF'
overlay
br_netfilter
EOF
cat > "${MNT}/etc/sysctl.d/99-kubernetes.conf" <<'EOF'
net.bridge.bridge-nf-call-iptables=1
net.bridge.bridge-nf-call-ip6tables=1
net.ipv4.ip_forward=1
EOF
touch "${MNT}/etc/cloud/cloud-init.disabled" 2>/dev/null || true
chroot "${MNT}" update-alternatives --set iptables /usr/sbin/iptables-legacy >/dev/null 2>&1 || true
chroot "${MNT}" update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy >/dev/null 2>&1 || true
ln -sf /lib/systemd/system/ssh.service "${MNT}/etc/systemd/system/multi-user.target.wants/ssh.service"
ln -sf /lib/systemd/system/systemd-networkd.service "${MNT}/etc/systemd/system/multi-user.target.wants/systemd-networkd.service"
umount "${MNT}/dev/pts" || true
umount "${MNT}/proc" || true
umount "${MNT}/sys" || true
umount "${MNT}/dev" || true
umount "${MNT}/run" || true
umount "${MNT}" || true
mv "${PREPARED_ROOTFS_PATH}.tmp" "${PREPARED_ROOTFS_PATH}"
fi
7. Prepare The Bridge, NAT, And Host-Side API Load Balancer
The explicit FORWARD rules matter on hosts where the default FORWARD policy is DROP.
docker rm -f "${API_LB_CONTAINER_NAME}" >/dev/null 2>&1 || true
ip link add name "${BRIDGE_NAME}" type bridge 2>/dev/null || true
ip addr add "${GATEWAY}/24" dev "${BRIDGE_NAME}" 2>/dev/null || true
ip addr add "${API_LB_IP}/24" dev "${BRIDGE_NAME}" 2>/dev/null || true
ip link set "${BRIDGE_NAME}" up
sysctl -w net.ipv4.ip_forward=1 >/dev/null
iptables -C FORWARD -i "${BRIDGE_NAME}" -j ACCEPT 2>/dev/null || iptables -A FORWARD -i "${BRIDGE_NAME}" -j ACCEPT
iptables -C FORWARD -o "${BRIDGE_NAME}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -A FORWARD -o "${BRIDGE_NAME}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -t nat -C POSTROUTING -s "${CIDR}" ! -o "${BRIDGE_NAME}" -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s "${CIDR}" ! -o "${BRIDGE_NAME}" -j MASQUERADE
cat > "${RUN_ROOT}/haproxy.cfg" <<EOF
global
maxconn 2048
defaults
mode tcp
timeout connect 5s
timeout client 60s
timeout server 60s
frontend kube_api
bind ${API_LB_IP}:${API_LB_PORT}
default_backend kube_apis
backend kube_apis
balance roundrobin
option tcp-check
default-server inter 2s fall 3 rise 2
EOF
for i in $(seq 0 "$((CONTROL_PLANE_COUNT - 1))"); do
printf ' server cp%s %s:6443 check\n' "${i}" "$(node_ip "${i}")" >> "${RUN_ROOT}/haproxy.cfg"
done
docker run -d --name "${API_LB_CONTAINER_NAME}" --network host \
-v "${RUN_ROOT}/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \
"${HAPROXY_IMAGE}" >/dev/null
8. Create And Boot The MicroVMs
Each VM gets its own cloned ext4 disk, hostname, /etc/hosts, and systemd-networkd config.
for i in $(seq 0 "$((NODE_COUNT - 1))"); do
vm_dir="${RUN_ROOT}/nodes/node${i}"
mkdir -p "${vm_dir}"
cp --reflink=auto "${PREPARED_ROOTFS_PATH}" "${vm_dir}/rootfs.ext4" 2>/dev/null || cp "${PREPARED_ROOTFS_PATH}" "${vm_dir}/rootfs.ext4"
e2fsck -fy "${vm_dir}/rootfs.ext4" >/dev/null 2>&1 || true
mkdir -p "${vm_dir}/mnt"
mount -o loop "${vm_dir}/rootfs.ext4" "${vm_dir}/mnt"
printf '%s\n' "$(node_name "${i}")" > "${vm_dir}/mnt/etc/hostname"
{
printf '127.0.0.1 localhost\n'
printf '127.0.1.1 %s\n' "$(node_name "${i}")"
printf '%s api-lb\n' "${API_LB_IP}"
for j in $(seq 0 "$((NODE_COUNT - 1))"); do
printf '%s %s\n' "$(node_ip "${j}")" "$(node_name "${j}")"
done
} > "${vm_dir}/mnt/etc/hosts"
mkdir -p "${vm_dir}/mnt/etc/systemd/network" "${vm_dir}/mnt/etc/systemd/system/multi-user.target.wants"
cat > "${vm_dir}/mnt/etc/systemd/network/20-eth0.network" <<EOF
[Match]
Name=eth0
[Network]
Address=$(node_ip "${i}")/24
Gateway=${GATEWAY}
DNS=1.1.1.1
DNS=8.8.8.8
EOF
rm -f "${vm_dir}/mnt/etc/resolv.conf"
printf 'nameserver 1.1.1.1\nnameserver 8.8.8.8\n' > "${vm_dir}/mnt/etc/resolv.conf"
rm -f "${vm_dir}/mnt/etc/machine-id" "${vm_dir}/mnt/var/lib/dbus/machine-id" 2>/dev/null || true
touch "${vm_dir}/mnt/etc/machine-id"
rm -rf "${vm_dir}/mnt/etc/kubernetes" "${vm_dir}/mnt/var/lib/etcd" "${vm_dir}/mnt/var/lib/cni" "${vm_dir}/mnt/var/lib/kubelet" "${vm_dir}/mnt/var/lib/containerd" "${vm_dir}/mnt/etc/cni/net.d"
mkdir -p "${vm_dir}/mnt/var/lib/containerd" "${vm_dir}/mnt/etc/cni/net.d"
ln -sf /lib/systemd/system/ssh.service "${vm_dir}/mnt/etc/systemd/system/multi-user.target.wants/ssh.service"
ln -sf /lib/systemd/system/systemd-networkd.service "${vm_dir}/mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service"
umount "${vm_dir}/mnt"
tap="$(node_tap "${i}")"
mac="$(node_mac "${i}")"
mem="$(node_mem "${i}")"
ip tuntap add dev "${tap}" mode tap 2>/dev/null || true
ip link set "${tap}" master "${BRIDGE_NAME}"
ip link set "${tap}" up
cat > "${vm_dir}/vm.json" <<EOF
{"boot-source":{"kernel_image_path":"${KERNEL_PATH}","boot_args":"${KERNEL_BOOT_ARGS}"},"drives":[{"drive_id":"rootfs","path_on_host":"${vm_dir}/rootfs.ext4","is_root_device":true,"is_read_only":false}],"machine-config":{"vcpu_count":${VCPU_COUNT},"mem_size_mib":${mem}},"network-interfaces":[{"iface_id":"eth0","host_dev_name":"${tap}","guest_mac":"${mac}"}],"logger":{"log_path":"${vm_dir}/firecracker.log","level":"Info","show_level":true,"show_log_origin":true}}
EOF
"${FIRECRACKER_BIN}" --api-sock "${vm_dir}/fc.sock" --config-file "${vm_dir}/vm.json" > "${vm_dir}/console.log" 2>&1 &
echo $! > "${vm_dir}/pid"
done
9. Wait For SSH And Prepare The Nodes
for i in $(seq 0 "$((NODE_COUNT - 1))"); do
wait_for_ssh "$(node_ip "${i}")"
done
for i in $(seq 0 "$((NODE_COUNT - 1))"); do
node_ssh "${i}" "cat >/root/prepare-node.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
swapoff -a || true
sed -i '/ swap / s/^/#/' /etc/fstab 2>/dev/null || true
modprobe overlay || true
modprobe br_netfilter || true
sysctl --system >/dev/null
systemctl daemon-reload || true
systemctl enable --now ssh containerd kubelet >/dev/null 2>&1 || true
systemctl restart containerd
kubeadm reset -f >/dev/null 2>&1 || true
rm -rf /etc/kubernetes /var/lib/etcd /var/lib/cni /var/lib/kubelet/* /etc/cni/net.d/*
mkdir -p /etc/cni/net.d
kubeadm config images pull --kubernetes-version="$(kubeadm version -o short)" >/dev/null 2>&1 || true
EOF
node_ssh "${i}" "chmod +x /root/prepare-node.sh && /root/prepare-node.sh"
done
10. Initialize The First Control Plane
server_ssh "cat >/root/init-primary.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
version="${KUBERNETES_VERSION}"
kubeadm init \
--kubernetes-version "\${version}" \
--control-plane-endpoint "${API_LB_IP}:${API_LB_PORT}" \
--apiserver-advertise-address "${PRIMARY_CONTROL_PLANE_IP}" \
--pod-network-cidr "${POD_CIDR}" \
--service-cidr "${SERVICE_CIDR}" \
--upload-certs \
--ignore-preflight-errors=all
mkdir -p /root/.kube
cp /etc/kubernetes/admin.conf /root/.kube/config
certificate_key="\$(kubeadm init phase upload-certs --upload-certs 2>/dev/null | tail -n 1)"
join_cmd="\$(kubeadm token create --print-join-command)"
printf '%s\n' "\${certificate_key}" >/root/certificate-key.txt
printf '%s\n' "\${join_cmd}" >/root/join-command.txt
EOF
server_ssh "chmod +x /root/init-primary.sh && /root/init-primary.sh"
11. Install Flannel
server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf apply -f $(printf '%q' "${FLANNEL_MANIFEST_URL}")"
for _ in $(seq 1 180); do
flannel_ns="$(server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf get daemonset -A --no-headers 2>/dev/null | awk '\$2==\"kube-flannel-ds\"{print \$1; exit}'" || true)"
if [[ -n "${flannel_ns}" ]]; then
if server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf -n ${flannel_ns} rollout status daemonset/kube-flannel-ds --timeout=5s" >/dev/null 2>&1; then
break
fi
fi
sleep 2
done
12. Join The Other Two Control-Plane Nodes
join_cmd="$(server_ssh "cat /root/join-command.txt")"
certificate_key="$(server_ssh "cat /root/certificate-key.txt")"
for i in 1 2; do
ip="$(node_ip "${i}")"
node_ssh "${i}" "cat >/root/join-control-plane.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
${join_cmd} --control-plane --certificate-key ${certificate_key} --apiserver-advertise-address ${ip} --ignore-preflight-errors=all
EOF
node_ssh "${i}" "chmod +x /root/join-control-plane.sh && /root/join-control-plane.sh"
wait_for_node_ready "$(node_name "${i}")"
done
13. Join The Worker Nodes
for i in $(seq "${CONTROL_PLANE_COUNT}" "$((NODE_COUNT - 1))"); do
node_ssh "${i}" "cat >/root/join-worker.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
${join_cmd} --ignore-preflight-errors=all
EOF
node_ssh "${i}" "chmod +x /root/join-worker.sh && /root/join-worker.sh"
wait_for_node_ready "$(node_name "${i}")"
done
14. Wait For Full Cluster Readiness
for _ in $(seq 1 240); do
server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf get nodes -o wide > ${ARTIFACT_PREFIX}.nodes 2>&1 || true
kubectl --kubeconfig /etc/kubernetes/admin.conf get nodes --no-headers > ${ARTIFACT_PREFIX}.nodes-plain 2>&1 || true
kubectl --kubeconfig /etc/kubernetes/admin.conf get endpoints kubernetes -o wide > ${ARTIFACT_PREFIX}.apiserver-endpoints 2>&1 || true" >/dev/null 2>&1 || true
ready_count="$(server_ssh "awk '\$2==\"Ready\"{c++} END{print c+0}' ${ARTIFACT_PREFIX}.nodes-plain 2>/dev/null || echo 0")"
control_plane_ready="$(server_ssh "awk '\$2==\"Ready\" && \$3 ~ /control-plane/ {c++} END{print c+0}' ${ARTIFACT_PREFIX}.nodes-plain 2>/dev/null || echo 0")"
endpoint_count="$(server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf get endpoints kubernetes -o jsonpath='{range .subsets[*].addresses[*]}{.ip}{\"\\n\"}{end}' 2>/dev/null | awk 'NF{c++} END{print c+0}' || echo 0")"
if [[ "${ready_count}" -ge "${NODE_COUNT}" && "${control_plane_ready}" -ge "${CONTROL_PLANE_COUNT}" && "${endpoint_count}" -ge "${CONTROL_PLANE_COUNT}" ]]; then
break
fi
sleep 3
done
server_ssh "cat ${ARTIFACT_PREFIX}.nodes"
Expected result:
k8s-00 Ready control-plane
k8s-01 Ready control-plane
k8s-02 Ready control-plane
k8s-03 Ready <none>
k8s-04 Ready <none>
15. Capture The etcd Membership Proof
etcd_pod="etcd-$(node_name 0)"
server_ssh "kubectl --kubeconfig /etc/kubernetes/admin.conf -n kube-system exec ${etcd_pod} -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt --key=/etc/kubernetes/pki/etcd/healthcheck-client.key member list -w table > ${ARTIFACT_PREFIX}.etcd-members 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf -n kube-system exec ${etcd_pod} -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt --key=/etc/kubernetes/pki/etcd/healthcheck-client.key endpoint status -w table > ${ARTIFACT_PREFIX}.etcd-endpoints 2>&1"
server_ssh "cat ${ARTIFACT_PREFIX}.etcd-members"
That should show three etcd members.
16. Run A Smoke Test
server_ssh "cat >/root/run-smoke.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
kubectl --kubeconfig /etc/kubernetes/admin.conf create namespace smoke --dry-run=client -o yaml | kubectl --kubeconfig /etc/kubernetes/admin.conf apply -f -
cat <<'YAML' | kubectl --kubeconfig /etc/kubernetes/admin.conf apply -f -
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-smoke
namespace: smoke
spec:
selector:
matchLabels:
app: node-smoke
template:
metadata:
labels:
app: node-smoke
spec:
tolerations:
- operator: Exists
containers:
- name: node-smoke
image: busybox:1.36
command: ["sh", "-lc", "sleep 3600"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: echo
namespace: smoke
spec:
replicas: 2
selector:
matchLabels:
app: echo
template:
metadata:
labels:
app: echo
spec:
tolerations:
- operator: Exists
containers:
- name: echo
image: nginx:1.27-alpine
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: echo
namespace: smoke
spec:
selector:
app: echo
ports:
- port: 80
targetPort: 80
YAML
kubectl --kubeconfig /etc/kubernetes/admin.conf -n smoke rollout status daemonset/node-smoke --timeout=240s
kubectl --kubeconfig /etc/kubernetes/admin.conf -n smoke rollout status deployment/echo --timeout=240s
kubectl --kubeconfig /etc/kubernetes/admin.conf -n kube-system rollout status deployment/coredns --timeout=240s
cat <<'YAML' | kubectl --kubeconfig /etc/kubernetes/admin.conf apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: dns-http
namespace: smoke
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
tolerations:
- operator: Exists
containers:
- name: dns-http
image: busybox:1.36
command:
- sh
- -lc
- |
for _ in $(seq 1 30); do
nslookup echo.smoke.svc.cluster.local && \
wget -qO- http://echo.smoke.svc.cluster.local >/tmp/echo.html && \
test -s /tmp/echo.html && exit 0
sleep 2
done
exit 1
YAML
kubectl --kubeconfig /etc/kubernetes/admin.conf -n smoke wait --for=condition=complete job/dns-http --timeout=240s
kubectl --kubeconfig /etc/kubernetes/admin.conf get nodes -o wide > /var/log/kubeadm-ha-lab.nodes 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf get pods -A -o wide > /var/log/kubeadm-ha-lab.pods 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf get svc -A -o wide > /var/log/kubeadm-ha-lab.services 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf -n smoke get pods -o wide > /var/log/kubeadm-ha-lab.smoke-pods 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf -n smoke get svc echo -o wide > /var/log/kubeadm-ha-lab.smoke-service 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf -n smoke logs job/dns-http > /var/log/kubeadm-ha-lab.smoke-job.log 2>&1
kubectl --kubeconfig /etc/kubernetes/admin.conf config view --raw > /var/log/kubeadm-ha-lab.kubeconfig 2>&1
EOF
server_ssh "chmod +x /root/run-smoke.sh && /root/run-smoke.sh"
server_ssh "cat ${ARTIFACT_PREFIX}.smoke-pods"
server_ssh "cat ${ARTIFACT_PREFIX}.smoke-job.log"
That proves:
- all nodes can schedule workloads
- in-cluster DNS resolves
echo.smoke.svc.cluster.local - a pod can reach the service over HTTP
17. Save The Artifacts
mkdir -p "${RUN_ROOT}/artifacts"
copy_guest_file "${ARTIFACT_PREFIX}.nodes" "${RUN_ROOT}/artifacts/nodes.txt"
copy_guest_file "${ARTIFACT_PREFIX}.pods" "${RUN_ROOT}/artifacts/pods.txt"
copy_guest_file "${ARTIFACT_PREFIX}.services" "${RUN_ROOT}/artifacts/services.txt"
copy_guest_file "${ARTIFACT_PREFIX}.apiserver-endpoints" "${RUN_ROOT}/artifacts/apiserver-endpoints.txt"
copy_guest_file "${ARTIFACT_PREFIX}.etcd-members" "${RUN_ROOT}/artifacts/etcd-members.txt"
copy_guest_file "${ARTIFACT_PREFIX}.etcd-endpoints" "${RUN_ROOT}/artifacts/etcd-endpoints.txt"
copy_guest_file "${ARTIFACT_PREFIX}.smoke-pods" "${RUN_ROOT}/artifacts/smoke-pods.txt"
copy_guest_file "${ARTIFACT_PREFIX}.smoke-service" "${RUN_ROOT}/artifacts/smoke-service.txt"
copy_guest_file "${ARTIFACT_PREFIX}.smoke-job.log" "${RUN_ROOT}/artifacts/smoke-job.log"
copy_guest_file "${ARTIFACT_PREFIX}.kubeconfig" "${RUN_ROOT}/artifacts/kubeconfig.yaml"
curl -ksSf --max-time 5 "https://${API_LB_IP}:${API_LB_PORT}/version" > "${RUN_ROOT}/artifacts/api-lb-version.json"
From there:
export KUBECONFIG="${RUN_ROOT}/artifacts/kubeconfig.yaml"
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl -n smoke get pods -o wide
18. Tear It Down
docker rm -f "${API_LB_CONTAINER_NAME}" >/dev/null 2>&1 || true
for pid_file in "${RUN_ROOT}"/nodes/*/pid; do
[[ -f "${pid_file}" ]] || continue
kill "$(cat "${pid_file}")" 2>/dev/null || true
done
sleep 1
for pid_file in "${RUN_ROOT}"/nodes/*/pid; do
[[ -f "${pid_file}" ]] || continue
kill -9 "$(cat "${pid_file}")" 2>/dev/null || true
done
for i in $(seq 0 "$((NODE_COUNT - 1))"); do
ip link del "$(node_tap "${i}")" 2>/dev/null || true
done
iptables -D FORWARD -i "${BRIDGE_NAME}" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -o "${BRIDGE_NAME}" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -t nat -D POSTROUTING -s "${CIDR}" ! -o "${BRIDGE_NAME}" -j MASQUERADE 2>/dev/null || true
ip link set "${BRIDGE_NAME}" down 2>/dev/null || true
ip link del "${BRIDGE_NAME}" type bridge 2>/dev/null || true
rm -rf "${RUN_ROOT}"
CACHE_ROOT is intentionally left in place so you can reuse the downloaded kernel,
Ubuntu squashfs, base ext4 image, and prepared guest image on the next run.