Pods with no network at all, the secure way
The job only needs its input file and its output file, so you gave it a deny-all NetworkPolicy and went home. The policy was accepted, listed, and enforced by nobody, because the cluster's CNI never read it.
The short answer
First confirm your CNI enforces NetworkPolicy. Then select no-network pods by label with a clusterwide deny rule for all ingress and egress, which no namespace allow rule can override. For code you trust least, add a gVisor RuntimeClass with networking disabled, so the sandbox has only a loopback. Test from inside the pod every time.
On this page
What goes wrong
Kubernetes has no "no network" switch for a pod. Every pod gets an interface and an address from the CNI plugin. To take the network away you write a NetworkPolicy that selects the pod and allows nothing, and three things can make that policy a comment:
The CNI does not enforce policy. The API server stores NetworkPolicy
objects whether or not anything implements them. The flanneld binary does
not enforce them. Talos deploys Flannel by default and can add the
kube-network-policies controller next to it (kubeNetworkPoliciesEnabled in
its KubeFlannelCNIConfig document), but a config from talosctl gen config
leaves that off. Without a controller, the policy is visible in
kubectl get netpol and has no effect on packets.
Someone adds an allow rule. Kubernetes NetworkPolicies only allow. A second policy in the same namespace that allows egress to DNS or the internet quietly punches through your deny-all.
Timing. A pod created before the plugin has processed the policy may start unprotected, and the Kubernetes API does not tell you when processing is done. A connection opened before the policy existed may or may not be cut, depending on the plugin.
And even an enforced policy leaves the pod with a network stack: an interface, a route, and a kernel that parses whatever packets reach it.
What the docs say
Creating a NetworkPolicy resource without a controller that implements it will have no effect.
Source: Kubernetes docs, Network Policies
it is implementation defined as to whether the change will take effect for that existing connection or not.
Source: Kubernetes docs, Network Policies (impact on existing connections)
Deny policies take precedence over allow policies, regardless of whether they are a Cilium Network Policy, a Clusterwide Cilium Network Policy or even a Kubernetes Network Policy.
Source: Cilium docs, Deny Policies
To completely isolate the host and network from the sandbox, external networking can be disabled. The sandbox will still contain a loopback provided by netstack.
Source: gVisor docs, Networking
Kubernetes documents that an unenforced policy does nothing, but nothing
warns you when you create one. gVisor's networking page shows --network=none
only in Docker's daemon.json. Its containerd configuration page says any
runsc flag can go under [runsc_config] in the shim's config file, which is
how a separate RuntimeClass handler gets the flag.
The secure configuration
1. Check that policies are enforced. Before anything else:
kubectl get pods -n kube-system -o name | grep -E 'cilium|calico|kube-router|antrea|kube-network-policies' || echo "no policy-enforcing CNI found"If the answer is Flannel alone, NetworkPolicy does nothing on this cluster. Install an enforcing CNI, or on Talos enable kube-network-policies, before you rely on the rest of this page. The grep only finds candidates; test step 1 under Prove it is the real check.
2. A namespace-level deny-all, for any enforcing CNI.
# k8s-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
namespace: batch
spec:
podSelector:
matchLabels:
network.example.com/none: "true"
policyTypes: ["Ingress", "Egress"] # both listed, neither allowed: nothing in, nothing out3. With Cilium, a clusterwide deny that no allow rule can undo. Deny rules win over every allow, including Kubernetes NetworkPolicies written by namespace owners.
# cilium-no-network.yaml
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: no-network
spec:
endpointSelector:
matchLabels:
network.example.com/none: "true" # any namespace
ingressDeny:
- fromEntities: ["all"]
egressDeny:
- toEntities: ["all"]Create the policy before the first pod that carries the label, so no pod starts before the policy is in place.
4. For the least trusted code, no network stack at all. A second gVisor
handler that starts sandboxes with --network=none. On Talos with the gVisor
extension, an EtcFileConfig document writes the shim's config file and a
CRICustomizationConfig document adds the handler to containerd:
# talos-runsc-nonet.yaml
# Sandbox worker pool only (needs the siderolabs/gvisor extension).
apiVersion: v1alpha1
kind: EtcFileConfig
name: runsc/nonet.toml # written to /etc/runsc/nonet.toml; Talos rejects paths under cri/
mode: 0o644
contents: |
[runsc_config]
network = "none" # passed to runsc as --network="none"; loopback only
# allow-flag-override stays unset (default false), so pod annotations cannot change flags
---
apiVersion: v1alpha1
kind: CRICustomizationConfig
name: 30-runsc-nonet
content: |
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc-nonet]
runtime_type = "io.containerd.runsc.v1"
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc-nonet.options]
TypeUrl = "io.containerd.runsc.v1.options"
ConfigPath = "/etc/runsc/nonet.toml"# k8s-runtimeclass-nonet.yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor-nonet
handler: runsc-nonet
scheduling:
nodeSelector:
sandbox.example.com/gvisor: "true"
tolerations:
- key: sandbox.example.com/gvisor
operator: Exists
effect: NoSchedule
---
apiVersion: v1
kind: Pod
metadata:
name: convert-4821
namespace: batch
labels:
network.example.com/none: "true" # the policies above apply as well
spec:
runtimeClassName: gvisor-nonet
automountServiceAccountToken: false
enableServiceLinks: false
restartPolicy: Never
activeDeadlineSeconds: 600
containers:
- name: convert
image: ghcr.io/example/converter@sha256:0000000000000000000000000000000000000000000000000000000000000000
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
limits:
cpu: "1"
memory: 512MiThe extension's own handlers read their config from /etc/cri/conf.d/,
but Talos rejects user files under cri/, so this file lives in
/etc/runsc/. The Talos docs do not say whether the containerd shim can read
a user file at that path. Check it on one node before you rely on it:
talosctl -n <node> read /etc/runsc/nonet.toml shows the file, and step 3
under Prove it shows whether the sandbox really started without a network.
If the pod starts with an interface other than lo, the handler did not get
the flag.
The pod still gets an IP from the CNI; the sandbox never uses it. HTTP
probes cannot reach such a pod, so use exec probes or none. Logs and
kubectl exec keep working, because they do not use the pod network.
Prove it
Run on a lab cluster (Kubernetes 1.34, Cilium 1.20.2, gVisor release-20260921)
with the policies and the gvisor-nonet RuntimeClass above. The converter's
placeholder image was replaced by busybox:1.37. On the lab, containerd uses
the older version 2 configuration, so the runtime section was named
plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc-nonet; the
Talos patch above uses the version 3 name. Real output:
1. No network through policy:
kubectl -n batch run nonet-check --image=busybox:1.37 --restart=Never \
--labels=network.example.com/none=true -- sleep 600
kubectl -n batch exec nonet-check -- wget -qO- -T 3 http://example.com/; echo "exit $?"
kubectl -n batch exec nonet-check -- nslookup kubernetes.default; echo "exit $?"wget: bad address 'example.com'
command terminated with exit code 1
exit 1
;; connection timed out; no servers could be reached
command terminated with exit code 1
exit 12. Cilium shows the drops:
hubble observe --namespace batch --pod nonet-check --verdict DROPPED --last 10Sep 24 23:23:41.524: batch/nonet-check:44143 (ID:29670) <> kube-system/coredns-66bc5c9577-ssrvf:53 (ID:1734) policy-verdict:all EGRESS DENIED (UDP)
Sep 24 23:23:41.524: batch/nonet-check:44143 (ID:29670) <> kube-system/coredns-66bc5c9577-ssrvf:53 (ID:1734) Policy denied by denylist DROPPED (UDP)Even DNS is refused: the pod has no network at all, not a network with a short allowlist.
3. No network in the sandbox itself (gvisor-nonet):
kubectl -n batch exec convert-4821 -- cat /proc/net/dev
kubectl -n batch exec convert-4821 -- dmesg | head -1
kubectl -n batch exec convert-4821 -- wget -qO- -T 3 http://1.1.1.1/Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
[ 0.000000] Starting gVisor...
wget: can't connect to remote host (1.1.1.1): Network is unreachable
command terminated with exit code 1Only lo exists inside the sandbox. There is nothing for a policy to
forget: the network stack has no interface to send on.
Mistakes people make
Trusting kubectl get netpol
A listed policy is a stored object, not an enforced rule. Test from inside a pod after every CNI change.
A deny-all in a namespace others can edit
Anyone who can create NetworkPolicies in the namespace can add an allow rule next to yours. Use a clusterwide deny rule, or keep policy write access away from namespace owners.
Starting pods before the policy
A pod created before the plugin handles the policy can start with full network access, and the Kubernetes API gives no signal when the plugin is done. Create policies well before the pods, and in pipelines test from a labeled canary pod before creating the real ones.
Forgetting existing connections
If you add the policy to running pods, whether open connections are cut is up to the plugin. Restart the pods after tightening a policy.
HTTP probes on a pod with no network
A readiness probe over HTTP fails when the sandbox has no network, and teams "fix" it by removing the isolation. Use exec probes.
Checklist
- The cluster runs a CNI that enforces NetworkPolicy, verified by a test pod.
- No-network pods carry a label that a deny-all policy selects, for both ingress and egress.
- On Cilium, a clusterwide
ingressDenyandegressDenycovers that label. - Policies exist before the pods they protect; pods are restarted after policy changes.
- The least trusted code runs in a gVisor handler with
network = "none". /proc/net/devinside those pods lists onlylo.- No-network pods use exec probes or none.
- No-network pods have no ServiceAccount token and no Service links.
The only packet that cannot hurt you is the one with nowhere to go. Make sure something actually enforces the "nowhere".
H2-CSPE
Learn it on a live range
Cluster networking and policy, in Secure Platform Engineering: a real host in your browser, and every objective checked on the machine.
Start freeThe Secure Way
More on sandboxing untrusted code
gVisor, pods with no network, and running other people's code without handing them your cluster.
All sandboxing untrusted code guides