mirror of
https://github.com/Katakate/k7.git
synced 2026-09-22 01:53:19 +00:00
13fafe0997
Per-key node pins, k7d-fc pause/resume/exec, and HA-soak fixes. Playbook pins k7d 0.7.0. GitHub .deb, Launchpad PPA, and PyPI k7-sdk are 0.4.0.
524 lines
28 KiB
Markdown
524 lines
28 KiB
Markdown
# Sandbox backends: kfd, kql, k7d, k7d-fc
|
||
|
||
k7 turns every sandbox into a Kubernetes Deployment whose pod runs inside a
|
||
hardware-isolated microVM. *How* that VM is built, stored, snapshotted, and
|
||
forked is the backend's job. Four backends exist today; a node can install
|
||
any combination (`k7 install --backend kfd,kql,k7d,k7d-fc`) and each sandbox picks
|
||
one (`k7 create --backend …`, annotation `k7.katakate.org/backend`).
|
||
`--backend` / inventory `k7_backends` is **required** — there is no default
|
||
list. `none` means no sandbox runtime on that node (scheduling-only
|
||
master: `[k7_servers:vars] k7_backends=none`). Empty/omitted is an error,
|
||
not kfd.
|
||
|
||
| | `kfd` — kata-firecracker-devmapper | `kql` — kata-qemu-longhorn | `k7d` | `k7d-fc` |
|
||
|---|---|---|---|---|
|
||
| VMM | Firecracker via Kata Containers | QEMU via Kata Containers | [k7d](https://github.com/katakate/k7d) in-process rust-vmm | Same k7d daemon; stock Firecracker under the stock jailer |
|
||
| containerd RuntimeClass | `kata` | `kata-qemu` | `k7` | `k7-fc` |
|
||
| Rootfs / storage | devmapper LVM thin-pool (needs a spare raw disk) | overlayfs + Longhorn PVC mounted at `/mnt/state` | cached erofs images on virtio-blk, guest tmpfs upper, reflink-XFS volume images | same as k7d (erofs/scratch virtio-blk; **no virtiofs / hostPath**) |
|
||
| Named snapshots (`k7 snapshot`) | ✗ | ✅ Longhorn VolumeSnapshot (disk-only, crash-consistent) | ✗ by design — see below | ✗ (same as k7d) |
|
||
| `k7 fork` | ✗ | disk clone: snapshot → new PVC → cold boot | **warm fork: CoW disk *and* memory of the live VM** | same daemon socket / `fork-source-*` annotations |
|
||
| `k7 pause` / `resume` | scale to 0/1 (state lost) | scale to 0/1, disk persists on the PVC | **VM frozen in place: vCPUs stop, memory stays** | same `/run/k7d/k7d.sock` path |
|
||
| `k7 restore` | ✗ | ✅ boot a new sandbox from any named snapshot | ✗ (fork the live sandbox instead) | ✗ |
|
||
| Docker | **`--docker`**: privileged `docker-vehicle`, overlay2 on ephemeral `k7-docker-lvm` block LV | **`--docker`**: privileged `docker-vehicle`, overlay2 on Longhorn Block PVC (pause/fork/restore) | **`--docker`**: agent-supervised dockerd | **`--docker`** (same guest service) |
|
||
| hostPath | Kata virtiofs | Kata virtiofs | k7d virtiofs fallback | **refused up front** (Firecracker has no virtiofs) |
|
||
|
||
Measured numbers for all of this live in [PERFORMANCE.md](../PERFORMANCE.md):
|
||
on the same node, k7d creates in ~2.1s vs kql's ~17.1s, forks to a usable
|
||
sandbox in ~2.4s vs ~46.7s (and the k7d fork inherits the source's memory),
|
||
and pauses/resumes in ~0.2s/~0.3s vs ~1.3s/~4.1s — while kql keeps named
|
||
persistent snapshots and overlay2 Docker on a Longhorn block volume.
|
||
|
||
`k7 create --docker` is one user contract on every backend (`docker` /
|
||
`compose` / `buildx` on PATH, `overlay2` on a **block** disk, socket shared
|
||
as a directory). k7d/k7d-fc run dockerd as a guest agent service (no extra
|
||
CRI container). Kata (kfd/kql) injects one privileged **docker-vehicle**
|
||
container in the same VM; the sandbox container's security context is
|
||
unchanged. The graph is never virtio-fs / emptyDir. kql's graph is a
|
||
second Longhorn Block PVC (`<name>-docker-lh`) included in pause / fork /
|
||
restore (two VolumeSnapshots are crash-consistent per volume, not
|
||
cross-volume atomic). kfd's graph is a generic ephemeral volume from
|
||
StorageClass `k7-docker-lvm` (OpenEBS LVM LocalPV over `kata-vg/thin-pool`)
|
||
and is deleted with the pod; `k7 fork` of a kfd `--docker` sandbox is
|
||
rejected. `--sidecar docker` is a deprecated alias of `--docker`. Inside
|
||
one Kata VM the vehicle and the sandbox are a single trust domain; the
|
||
isolation boundary is the VM. No hostPath is used for the payload or the
|
||
CLI.
|
||
|
||
## Which backend when?
|
||
|
||
- **kfd** — smallest attack surface and fast boots, when you don't need any
|
||
snapshot/fork lifecycle. Requires a dedicated raw disk for the thin-pool.
|
||
- **kql** — durable sandboxes. The root disk is a replicated Longhorn volume:
|
||
it survives pod restarts and node reboots, can be snapshotted by name,
|
||
restored into brand-new sandboxes, and forked (at the cost of a full VM
|
||
boot on the cloned disk).
|
||
- **k7d** — ephemeral-but-forkable compute, e.g. agent/RL rollouts. The whole
|
||
VM (filesystem *and* RAM: running processes, page cache, sockets) can be
|
||
forked in seconds, and pause/resume is instantaneous because nothing is
|
||
torn down. State does not survive pod deletion — persistence is "fork it
|
||
again", not "write it to a disk".
|
||
- **k7d-fc** (`k7-fc`) — pick this when you want k7d's fork/pause daemon
|
||
**and** Firecracker's jailer (separate VMM process, per-VM uid/chroot/seccomp).
|
||
You lose virtiofs/hostPath and time-warp. See k7d
|
||
`SECURITY.md` "Firecracker profile" and `docs/backends.md`. Kata's
|
||
Firecracker (`kfd`) is a different binary (`/opt/kata/bin`, older pin);
|
||
k7d-fc installs upstream v1.16.2 at `/usr/local/bin`.
|
||
|
||
## The k7d backend
|
||
|
||
**Architecture:** Linux **amd64 / x86_64** only (Debian `amd64` ≡ tarball
|
||
`x86_64`). Unlike `kfd` / `kql`, there is no arm64 build yet.
|
||
|
||
### What k7 installs (`k7 install --backend k7d`)
|
||
|
||
The Ansible playbook:
|
||
|
||
1. checks `/dev/kvm` exists (loud failure otherwise) and loads
|
||
`vhost_vsock` + `tun`;
|
||
2. installs `erofs-utils`, `xfsprogs`, `virtiofsd`, and provisions a sparse
|
||
reflink-capable XFS image loop-mounted at `/var/lib/k7d/disks` (warm forks
|
||
clone writable volume images with `FICLONE` reflinks);
|
||
3. downloads the k7d release tarball (`k7d_artifact_url`, default the
|
||
public `Katakate/k7d` GitHub release for `k7d_version`, currently
|
||
**0.7.0**) and runs the bundled `install.sh`, which installs `k7d` +
|
||
`containerd-shim-k7-v1` into `/usr/local/bin`, guest kernel/initramfs
|
||
into `/usr/local/share/k7d`, and starts `k7d.service` (control socket
|
||
`/run/k7d/k7d.sock`). Override with `k7 install --k7d-version <ver>`
|
||
(same GitHub URL, other tag) or `--k7d-artifact <path>` /
|
||
`k7d_artifact_local_path` (local tarball). `--docker` needs a k7d that
|
||
ships the guest docker service (payload at
|
||
`/usr/local/share/k7d/docker` or `/root/k7d/guest/docker/payload`); an
|
||
older k7d fails loudly.
|
||
4. registers the `k7` runtime in the k3s containerd template **with**
|
||
`pod_annotations = ["k7d.katakate.org/*"]` and **without** a `BinaryName`
|
||
option (containerd resolves the shim from
|
||
`runtime_type = "io.containerd.k7.v1"`), restarts k3s, and creates
|
||
RuntimeClass `k7`;
|
||
5. labels the node `k7.katakate.org/backend-k7d=true`.
|
||
|
||
### What k7 installs (`k7 install --backend k7d-fc`)
|
||
|
||
Same daemon/shim as `k7d`, plus:
|
||
|
||
1. pinned Firecracker + jailer via `src/k7/deploy/k7d-fc/install-firecracker.sh`
|
||
(pins from `pins.env`; sha mismatch fails the playbook);
|
||
2. `/etc/k7d/shim-k7-fc.toml` (`backend = "firecracker"`) and containerd
|
||
`runtimes.k7-fc` with `ConfigPath` and **no** `BinaryName`;
|
||
3. RuntimeClass `k7-fc` with PodOverhead `32Mi` / `50m`;
|
||
4. node label `k7.katakate.org/backend-k7d-fc=true` (and `backend-k7d`
|
||
so native k7 pods still schedule).
|
||
|
||
Do not point `--k7d-artifact` at an older GitHub tarball after developing
|
||
the shim — 0.5.0 does not know ConfigPath. The playbook
|
||
renders the whole containerd template from the selected backends; on a
|
||
node that already has other runtimes (kata, nvidia, wasm, …) use
|
||
`--tags k7d-fc` (Firecracker install + RuntimeClass + labels) rather
|
||
than a full `k7 install` that would drop those blocks.
|
||
|
||
### How a sandbox maps to a VM
|
||
|
||
One pod = one k7d microVM. The pod's sandbox container runs as a runc
|
||
container *inside* that VM. ``k7 create --docker`` does **not** add a
|
||
second CRI container: the guest agent (PID 1) supervises a pinned
|
||
`dockerd` with its graph on a per-sandbox virtio-blk scratch disk.
|
||
Inner containers share the guest kernel, network namespace, and the
|
||
exported sandbox paths (`/home`, `/root`, `/tmp`, `/opt`, `/workspace`).
|
||
VM size follows the pod's CPU/memory limits.
|
||
|
||
### The fork story
|
||
|
||
`k7 fork src dst` on a k7d sandbox does **not** copy a disk. Instead the new
|
||
Deployment's pod carries two annotations:
|
||
|
||
```yaml
|
||
k7d.katakate.org/fork-source-cluster: <source CRI sandbox id>
|
||
k7d.katakate.org/fork-source-vm: <source CRI sandbox id>
|
||
```
|
||
|
||
The pod's containerd shim resolves the source VM through the k7d daemon and
|
||
issues a `fork_vm`: the daemon briefly pauses the source, captures dirty
|
||
pages, and builds the child from a `MAP_PRIVATE` CoW mapping of the source's
|
||
memory plus reflink clones of its disk overlays. The child inherits
|
||
*everything* — files, tmpfs, running processes, page cache — and diverges
|
||
independently from that point. The source keeps running (its vsock identity
|
||
is preserved). The fork pod then *adopts* the workload container already
|
||
running inside the forked guest, so `kubectl exec` / `k7 exec` transparently
|
||
target the inherited state.
|
||
|
||
Properties and limits (fail loudly, never silently):
|
||
|
||
- Warm fork works for **single-workload sandboxes**, including
|
||
`--docker` (dockerd is not a CRI sidecar). Forking a sandbox with a
|
||
**real CRI sidecar** is rejected (no reliable container mapping — use
|
||
kql for that).
|
||
- The fork is pinned to the source's node (the k7d daemon is node-local;
|
||
cross-node fork is k7d's M12 roadmap item).
|
||
- If the fork pod is ever restarted by Kubernetes it re-forks from the (then
|
||
current) source — a fork is a live branch, not a stored artifact.
|
||
- `k7 snapshot`/`k7 restore` are rejected on k7d: named, storable snapshots
|
||
are a Longhorn/kql feature. k7d has its own richer VM snapshot **trees**
|
||
(fork/rollback/suspend of whole VM states, including multi-VM clusters)
|
||
driven through the k7d daemon API — see the
|
||
[k7d project](https://github.com/katakate/k7d). Whole-cluster fork
|
||
(forking an inner k8s cluster of VMs as one unit) is deliberately a
|
||
k7d-level feature, not a k7 verb.
|
||
|
||
### k7d VM ops work on any node (per-node k7-agent)
|
||
|
||
k7d pause/resume/fork need three things that only exist **on the node
|
||
hosting the sandbox**: the k7d daemon socket (`/run/k7d/k7d.sock`), the k3s
|
||
containerd socket, and `crictl`. This is handled by the `k7-agent`
|
||
DaemonSet (kube-system, same `k7-api:local` image running
|
||
`k7.api.agent:app`), so VM ops work **through the API for a sandbox on any
|
||
node**:
|
||
|
||
- A VM op on a sandbox co-located with the k7-api pod runs directly (the
|
||
deployment mounts both sockets and ships `crictl`).
|
||
- A VM op on a sandbox on any OTHER node is forwarded to the k7-agent pod
|
||
on that node (`POST /agent/v1/vm/{pause,resume,lookup}` on the pod
|
||
IP). The agent does **not** create Deployments or patch nodes
|
||
(no ServiceAccount token). Fork Kubernetes writes stay on `k7-api`.
|
||
Forwarding authenticates with the token the install playbook
|
||
writes to `/etc/k7/agent_token` (root, 0600) on every node; a
|
||
CiliumNetworkPolicy allows agent ingress from the k7-api pod and
|
||
local `host` only (`remote-node` denied). No Ready agent / missing
|
||
token → loud error, never a silent no-op.
|
||
- **CLI on a node** talks to the local daemon for local sandboxes.
|
||
Remote VM ops go through `k7-api` (default CLI path). `k7 --core`
|
||
cannot host-forward to another node's agent.
|
||
- A fork still **lands on the source's node** (`spec.nodeName` on the
|
||
fork Deployment). k7-api creates that Deployment after an agent
|
||
lookup; CoW is node-local on that k7d daemon. Cross-node fork *data
|
||
path* is future k7d work (daemon side, not built yet).
|
||
|
||
k7d is one daemon per node. Namespace scoping on API keys does not stop
|
||
two tenants sharing that daemon. Pin a tenant with
|
||
`k7 generate-api-key tenant-a -n tenant-a --node <node>` so creates
|
||
cannot *choose* another node. `<node>` is the Kubernetes Node name
|
||
(`k7 nodes list` / `kubectl get nodes`) — the Linux hostname K3s
|
||
registered. The pin is a `nodeSelector` on `kubernetes.io/hostname`
|
||
(kubelet stamps that label; `inventory.ini` does not). Inventory
|
||
`k7_backends` only stamps `k7.katakate.org/backend-*` at install.
|
||
To keep **other** sandboxes off that node, also run
|
||
`k7 nodes dedicate <node> --tenant <id>` (label + NoSchedule taint
|
||
`k7.katakate.org/tenant`). See `SECURITY.md`.
|
||
|
||
kql pause/resume/fork have none of these constraints (they are pure
|
||
Kubernetes/Longhorn operations) and work through the API for any node.
|
||
|
||
### Pause / resume
|
||
|
||
`k7 pause` on k7d asks the daemon (over `/run/k7d/k7d.sock`) to stop the VM's
|
||
vCPU threads and park its device workers: RAM, devices, and the vsock CID all
|
||
stay. The pod object remains scheduled (annotated
|
||
`k7.katakate.org/k7d-paused=true`), so `k7 resume` is just "restart the vCPU
|
||
loop" — sub-second, and every in-memory byte survives. Compare kql, where
|
||
pause scales the Deployment to zero (the VM is destroyed; only the Longhorn
|
||
disk survives) and resume pays a full VM boot.
|
||
|
||
## Egress modes
|
||
|
||
A sandbox has exactly one of three egress modes:
|
||
|
||
| Mode | CLI | YAML / API | Result |
|
||
|---|---|---|---|
|
||
| **block-all** (CLI default) | `k7 create …` (no egress flag) | `egress_whitelist: []` | deny-all egress NetworkPolicy |
|
||
| **whitelist** | `--egress <entry>` (repeatable) | `egress_whitelist: [<entries>]` | only listed CIDRs/domains |
|
||
| **open** | `--egress-open` | omit `egress_whitelist` (or `null`) | no per-sandbox egress policy; internet reachable, platform is not (see below) |
|
||
|
||
In every mode, on a Cilium cluster, the cluster-wide
|
||
`k7-sandbox-platform-deny` policy additionally denies sandbox egress to
|
||
the node, the other nodes, the Kubernetes API server, link-local / cloud
|
||
metadata, and the `kube-system` + `longhorn-system` pods except CoreDNS.
|
||
It is deny-only, so it never narrows what a whitelist allows beyond those
|
||
targets. `--cni flannel` clusters do **not** get this protection.
|
||
|
||
`--egress-open` and `--egress` are mutually exclusive. Note the asymmetric
|
||
defaults, kept for backward compatibility: the CLI without flags is
|
||
**block-all**, while an API/YAML request that omits `egress_whitelist`
|
||
entirely is **open**.
|
||
|
||
Wildcard semantics: `--egress '*.docker.com'` covers subdomains at **any
|
||
depth** (`registry.docker.com` *and* `production.cloudfront.docker.com`) but
|
||
not the apex `docker.com` itself — add it as its own entry. Under the hood
|
||
k7 translates a leading `*.` into Cilium's multi-label `**.` matchPattern; a
|
||
bare Cilium `*` never crosses label boundaries, which used to silently break
|
||
CDN-backed registries. The install also sets Cilium `dnsProxy.minTtl=3600`
|
||
so clients that cache a resolved IP longer than the CDN's 30–60s DNS TTL
|
||
(dockerd's blob downloader does) keep their learned FQDN→IP allowance for an
|
||
hour.
|
||
|
||
## Ingress
|
||
|
||
Ingress is denied by default and opt-in per sandbox. A sandbox with no
|
||
ingress flags gets the same deny-all `{name}-deny-ingress` NetworkPolicy
|
||
k7 has always created; `--ingress-port` adds allow rules to that same
|
||
object.
|
||
|
||
| Setting | CLI | YAML / API | Result |
|
||
|---|---|---|---|
|
||
| **deny-all** (default) | `k7 create …` (no ingress flag) | omit `ingress_ports` | deny-all ingress NetworkPolicy |
|
||
| **open TCP ports** | `--ingress-port <port>` (repeatable) | `ingress_ports: [<ports>]` | listed TCP ports reachable from sandboxes in the same namespace |
|
||
| **scope the sources** | `--ingress-from <source>` (repeatable) | `ingress_from: [<sources>]` | only the listed sources reach those ports |
|
||
|
||
Sources:
|
||
|
||
| Entry | Matches |
|
||
|---|---|
|
||
| `sandbox:<name>` | that sandbox, same namespace (`podSelector` on `katakate.org/sandbox`) |
|
||
| `namespace:<ns>` | every pod in that namespace (`namespaceSelector` on `kubernetes.io/metadata.name`) |
|
||
| `cidr:<cidr>` | clients in that CIDR (`ipBlock`) |
|
||
|
||
Anything else is a hard error at create time — an unreadable source in a
|
||
security rule is never guessed at. `--ingress-from` without
|
||
`--ingress-port` is an error too: sources alone open nothing.
|
||
|
||
`--ingress-port` with no `--ingress-from` means "other sandboxes in this
|
||
namespace", never the world. Reaching a sandbox from anywhere requires
|
||
`--ingress-from cidr:0.0.0.0/0`, typed out in full.
|
||
|
||
These are stock v1 `NetworkPolicy` rules, which Cilium enforces natively,
|
||
so ingress works on `--cni flannel` as well.
|
||
|
||
**`cidr:` does not scope in-cluster peers on Cilium.** Cilium does not
|
||
evaluate an `ipBlock` peer for traffic where both ends are Cilium-managed
|
||
([Layer 3 policy docs][cilium-l3]), so a rule whose peers are all `cidr:`
|
||
degrades to a *port-only* allow inside the cluster: the port is reachable
|
||
from **any sandbox in the cluster**, even when the CIDR contains no cluster
|
||
IP. Measured on this cluster — a sandbox opened with
|
||
`--ingress-from cidr:198.51.100.0/24` answered an unrelated sandbox on the
|
||
opened port, while an unlisted port stayed closed. Adding a `sandbox:` peer
|
||
alongside the `cidr:` does not close it.
|
||
|
||
`k7 create` warns on stderr whenever `--ingress-from` contains a `cidr:`
|
||
entry. Use `cidr:` for **external** clients (that is what `--expose-port`
|
||
needs it for) and `sandbox:` / `namespace:` to scope in-cluster access —
|
||
those are evaluated by pod identity and were verified to deny precisely.
|
||
|
||
Two further reasons not to reach for `cidr:` in-cluster: pod IPs are
|
||
reassigned on every restart, so a rule written against one silently stops
|
||
matching; and the NetworkPolicy spec leaves `ipBlock`-vs-pod-traffic up to
|
||
the CNI, so such a rule is not portable between CNIs either.
|
||
|
||
[cilium-l3]: https://docs.cilium.io/en/stable/security/policy/layer3/
|
||
|
||
`kubectl exec` and `k7 shell` are unaffected either way: they go through
|
||
the Kubernetes API and the CRI, not the pod network.
|
||
|
||
A sandbox is a bare Deployment with no Service, so ingress rules only cover
|
||
in-cluster traffic. To reach a sandbox from outside the cluster, see
|
||
"Exposing a sandbox" below.
|
||
|
||
## Exposing a sandbox outside the cluster
|
||
|
||
`--expose-port <port>` (repeatable, `expose_ports` in YAML/API) creates a
|
||
`NodePort` Service named `{name}-expose` in the sandbox's namespace. `k7
|
||
create` prints the resolved `http://<node-ip>:<nodeport>` for each port, and
|
||
`k7 list` shows the allocated NodePorts.
|
||
|
||
- **Every `--expose-port` needs a matching `--ingress-port`.** k7 refuses
|
||
otherwise: a public NodePort in front of a deny-all policy is a confusing
|
||
no-op, and one in front of an accidentally-open policy is a breach.
|
||
- The Service uses `externalTrafficPolicy: Local`, so the client's real
|
||
source IP reaches the pod and `--ingress-from cidr:…` rules can mean
|
||
something. With the default `Cluster` the source would be SNAT'd to an
|
||
address on the pod network and any CIDR allowlist would be decoration.
|
||
- **A `cidr:` allowlist does not constrain clients on the cluster's own
|
||
nodes.** As in-cluster (above), Cilium does not evaluate an `ipBlock`
|
||
peer against a node IP without `policy-cidr-match-mode=nodes`, so a
|
||
request from a node answers even when the CIDR excludes it. Verify a
|
||
`cidr:` allowlist from a genuinely external client — `curl` from the node
|
||
passes no matter how the rule is written.
|
||
- Consequence of `Local`: the NodePort **only answers on the node running
|
||
the sandbox** (`k7 list` shows which one).
|
||
- On a bare-metal host (Hetzner) the node IP is public, so
|
||
`--expose-port` with `--ingress-from cidr:0.0.0.0/0` publishes an
|
||
untrusted sandbox to the internet. k7 warns on stderr on every such
|
||
create.
|
||
- `k7 delete` removes the Service; a leaked NodePort is both a resource leak
|
||
and an open port.
|
||
- A `k7 fork` inherits the source's ingress and egress **policy** but not its
|
||
Service — a fork is not reachable from outside until you expose it.
|
||
|
||
```bash
|
||
k7 create web python:3.12-slim \
|
||
--ingress-port 8000 --ingress-from cidr:203.0.113.0/24 --expose-port 8000
|
||
# 🌐 Port 8000 exposed at http://<node-ip>:31234
|
||
```
|
||
|
||
## TLS for `k7-api`
|
||
|
||
Default `k7 install` serves the NodePort on `31007` over HTTPS. The
|
||
`k7-api` container stays HTTP on `:8000` (kubelet probes unchanged); a
|
||
Caddy sidecar in the same pod terminates TLS on `:8443`. API keys no
|
||
longer travel in cleartext. This is not rate limiting and not a claim
|
||
that the API is safe to expose.
|
||
|
||
| Flag | Cert |
|
||
|---|---|
|
||
| (none) | Playbook cluster CA at `/etc/k7/tls/ca.crt`. Laptop: copy that file and `k7 config set api.ca ./ca.crt` plus `api.url` to `https://<first-master-ip>:31007`. |
|
||
| `--api-hostname NAME` | Let's Encrypt via Caddy (HTTP-01 on host port 80). `NAME` must be a DNS name whose A record is the first-master IP; a bare IP is refused. The CLI uses the system trust store (no `api.ca`). |
|
||
| `--api-tls-cert` + `--api-tls-key` | Operator-supplied PEM pair. Incompatible with `--api-hostname`. |
|
||
| `--api-insecure-http` | Today's HTTP NodePort, bit for bit. Keys travel in cleartext. |
|
||
|
||
`--api-insecure-http` cannot be combined with the hostname or cert
|
||
flags. It can be combined with `--api-allow-cidr` (the allowlist still
|
||
applies; the warning is that keys are still cleartext).
|
||
|
||
## Restricting who can reach `k7-api`
|
||
|
||
`k7-api` is a `NodePort` Service on `31007`, and on a dedicated box the
|
||
node IP is public. `k7 install --api-allow-cidr 203.0.113.4/32
|
||
--api-allow-cidr 198.51.100.0/24` (repeatable, Cilium only — with
|
||
`--cni flannel` it fails at the CLI) renders a `CiliumNetworkPolicy`
|
||
`k7-api-ingress` on the API pod and flips the Service to
|
||
`externalTrafficPolicy: Local`. Both are skipped entirely when the flag
|
||
is unset, which is the default.
|
||
|
||
This is **defence in depth for operators who know their client CIDRs**.
|
||
It stacks with TLS and does not replace it. There is still no rate
|
||
limiting. See `SECURITY.md`.
|
||
|
||
Measured behaviour worth knowing before you debug it:
|
||
|
||
- The policy's `ingress:` section makes the API pod default-deny for
|
||
ingress. The `fromEntities` block (`host`, `remote-node`, `health`,
|
||
`kube-apiserver`) is what keeps the kubelet liveness/readiness probes
|
||
alive — probes arrive from the node's `cilium_host` address and match
|
||
`host`. Remove it and the pod enters `CrashLoopBackOff`.
|
||
- `externalTrafficPolicy: Local` is what preserves the client's source
|
||
IP; without it every external caller would arrive SNAT'd as a node IP.
|
||
The trade-off: the NodePort then answers **only** on the node running
|
||
the `k7-api` pod, which is the first master
|
||
(`nodeSelector: k7.katakate.org/first-master: "true"`) — exactly the
|
||
address the playbook writes into `/etc/k7/api_endpoint`. Operators
|
||
hitting other node IPs directly must switch to that one.
|
||
- **A curl from the node is not a test of the allowlist.** Node-local
|
||
traffic matches the `host` entity, and node-local NodePort traffic is
|
||
SNAT'd to the pod network even under `Local` (measured: the pod sees
|
||
the node's `cilium_host` address), so it succeeds however wrong the
|
||
CIDR list is. Test from off-cluster.
|
||
- Nor is a curl from **another Cilium node that runs its own `k7-api`**:
|
||
with `kubeProxyReplacement=true` its socket load-balancer translates a
|
||
host-originated connection to *any* address on that NodePort to its own
|
||
local backend. Measured: such a client got a healthy `200` while
|
||
`tcpdump` on the target node saw no packet at all. Use a client that is
|
||
not a Cilium node, or confirm with `tcpdump` that the packets arrive.
|
||
- A `fromCIDR` peer is not evaluated when the source is Cilium-managed,
|
||
so allowlisting a pod's own /32 does not admit that pod. In-cluster
|
||
callers are governed by `fromEndpoints` rules
|
||
(`agent-networkpolicy.yaml`), not by this allowlist.
|
||
- `hubble observe --verdict DROPPED` shows the drops (see below).
|
||
|
||
Rollback — re-running `k7 install` without the flag removes both pieces,
|
||
or by hand:
|
||
|
||
```bash
|
||
k3s kubectl -n kube-system delete ciliumnetworkpolicy k7-api-ingress
|
||
k3s kubectl -n kube-system patch svc k7-api \
|
||
-p '{"spec":{"externalTrafficPolicy":"Cluster"}}'
|
||
```
|
||
|
||
A wrong CIDR only costs API access: the policy selects the `k7-api` pod
|
||
endpoint, never the host, so SSH, `kubectl` and
|
||
`src/k7/cli/dev.sh --core` on the node keep working. The Cilium host
|
||
firewall would not have that property, which is why it was rejected.
|
||
|
||
## Debugging policy drops (Hubble)
|
||
|
||
Hubble is **opt-in observability**, not enforcement. It is off by
|
||
default and is not a security control. Enable it with
|
||
`k7 install --hubble` (Cilium CNI only — `--hubble --cni flannel` fails
|
||
loudly). The Hubble UI is not installed: it has no authentication and
|
||
the node IP is public. Rollback: `cilium hubble disable`.
|
||
|
||
`hubble observe` talks to Hubble Relay. On the node (verified: Relay
|
||
is ClusterIP `:80`; this forwards it to `127.0.0.1:4245`, the Hubble
|
||
CLI default):
|
||
|
||
```bash
|
||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||
cilium hubble port-forward & # kube-system/hubble-relay → 127.0.0.1:4245
|
||
hubble status # Healthcheck (via localhost:4245): Ok
|
||
hubble observe --verdict DROPPED --last 50
|
||
```
|
||
|
||
When a sandbox with an FQDN whitelist cannot reach a host, the symptom
|
||
is always a hang or timeout — policy drop, DNS, MTU, and a dead
|
||
upstream look the same from inside the VM. The CHALLENGES.md #9 recipe
|
||
tells them apart:
|
||
|
||
```bash
|
||
hubble observe --namespace <ns> --verdict DROPPED --last 50
|
||
cilium fqdn cache list
|
||
cilium ip list
|
||
```
|
||
|
||
A `Policy denied DROPPED` verdict with the destination still labelled
|
||
`world` means the FQDN→identity mapping missed (wildcard too narrow, or
|
||
the learned IP expired). `cilium fqdn cache list` shows what Cilium
|
||
learned; `cilium ip list` shows which IPs carry an `fqdn:` identity.
|
||
|
||
## Disk pool sizing (kfd + k7d)
|
||
|
||
Both node-local storage pools have fixed sizes chosen at install
|
||
time — set them per node in the inventory:
|
||
|
||
| Pool | Backend | Default | Inventory knob | Utilization |
|
||
|---|---|---|---|---|
|
||
| `kata-vg/thin-pool` (LVM, on the spare disk) | kfd | 100G PV | `kata_thinpool_pv_size` | `lvs kata-vg` (`Data%`/`Meta%`) |
|
||
| `/var/lib/k7d/disks` (sparse XFS loopback) | k7d | 32G image | `k7d_disks_image_size` | `df -h /var/lib/k7d/disks` |
|
||
|
||
Failure modes when a pool fills — both are **invisible to kubelet** (no
|
||
disk-pressure eviction, the pools are not part of the root filesystem):
|
||
|
||
- **kfd thin-pool full**: LVM autoextend (`thin_pool_autoextend_threshold=80`)
|
||
grows the pool within the PV; once the PV itself is exhausted writes inside
|
||
sandboxes start failing with I/O errors and new kfd pods fail to create
|
||
their devmapper snapshots (`CreateContainerError`). Only the first
|
||
`kata_thinpool_pv_size` of the spare disk is used — size it generously.
|
||
Docker-heavy kfd nodes share this pool with `--docker` graph LVs
|
||
(`k7-docker-lvm`); raise `kata_thinpool_pv_size` rather than silently
|
||
resizing.
|
||
- **k7d pool full**: writable volume images and fork reflink clones fail;
|
||
the k7d daemon rejects new sandboxes/forks loudly (`no space left on
|
||
device`). The image is sparse, so `ls -l` shows the virtual size —
|
||
use `du`/`df` for actual usage.
|
||
|
||
Utilization for both pools on every node is surfaced through the API and
|
||
CLI/SDK: `GET /api/v1/nodes/storage`, `k7 nodes storage` (`--json` for
|
||
raw), and `Client.nodes_storage()`
|
||
return a per-node map of `kata_thinpool` (`lvs` size/data%/metadata%)
|
||
and `k7d_disks` (`df` size/used/avail), collected from the k7-agent
|
||
DaemonSet. A node whose agent is unreachable gets a loud
|
||
`{"error": ...}` entry.
|
||
|
||
## Memory limits (`--memory`)
|
||
|
||
All three backends honour `k7 create --memory <qty>` (Kubernetes quantity
|
||
like `2Gi` / `4096Mi`):
|
||
|
||
| Backend | Mechanism |
|
||
|---|---|
|
||
| **kfd** | Kata stamps `io.katacontainers.config.hypervisor.default_memory` (MiB). containerd forwards `io.katacontainers.*` (`pod_annotations` on `runtimes.kata`) and `configuration-fc.toml` allowlists `default_memory` (before that the annotation was silently ignored and the VM stayed at the 2048 MiB default). |
|
||
| **kql** | Same annotation path via `runtimes.kata-qemu` + `configuration-qemu.toml`. |
|
||
| **k7d** | No Kata annotation — the k7d shim sizes the VM straight from the pod's CRI CPU/memory limits. |
|
||
|
||
## Known issues
|
||
|
||
- **kata-fc VMM leak under churn**: kata 3.24.0 with the jailer records the
|
||
`--daemonize`d jailer's PID, so its SIGTERM fallback at pod deletion
|
||
signals a dead PID. When the graceful in-guest shutdown fails (dead agent
|
||
under parallel churn), the firecracker process is orphaned and spins at
|
||
100% CPU. `k7 install` deploys a per-node systemd timer
|
||
(`k7-vmm-reaper.timer`, 1-minute cadence) that kills VMM processes whose
|
||
kata shim is gone, and the integration suite asserts zero orphans cluster
|
||
wide after teardown (`tests/integration/test_zz_leaks.py`).
|
||
- **kql docker-in-VM wedge under heavy fsync + replicas ≥ 2 — FIXED**:
|
||
kata's default single-threaded virtiofsd serialized all virtio-fs IO;
|
||
under a sustained fsync burst against an r≥2 Longhorn volume the
|
||
kata-agent's health ping starved behind the IO convoy and the shim
|
||
killed the healthy VM. `k7 install` now widens the virtiofsd thread
|
||
pool (`--thread-pool-size=16`); see PERFORMANCE.md for the root-cause
|
||
narrative and post-fix numbers.
|