README
¶
tare-agent (proof of concept)
tare decides whether a container is starved of CPU from PSI
(container_pressure_cpu_waiting_seconds_total). Many clusters do not have PSI:
cgroup v1 nodes, RHEL 8/9/10 kernels (PSI ships switched off), kubelets older than
1.33. tare-agent measures the same thing another way, so it can be compared with
PSI where PSI exists, and used where it does not.
It measures CPU wait per container: the seconds the container's tasks spent
ready to run but waiting for a CPU, summed over all of its tasks, including tasks
that have already exited. On every scrape it reads the scheduler counters of every
live thread (/proc/<pid>/task/<tid>/schedstat); in between, the kernel sends it
the final counters of every task that exits (taskstats exit notifications), so
short-lived processes are counted too. To know which container an exited task
belonged to, it also notes the container of every task that starts a program
(process connector exec events): exec probes are started from outside the
container and are usually gone before their exit can be traced back.
This is a proof of concept. tare does not need it, does not install it, and does not read the agent's metrics yet.
Before you deploy it: it runs privileged, with hostPID and hostNetwork,
on every node including the control plane, and serves /metrics on port 9105 of
each node over plain HTTP with no authentication. NetworkPolicy does not apply
to host-network pods, so restrict 9105 to your Prometheus at the node firewall
or security group. The image is ghcr.io/2listic/tare-agent (linux/amd64, arm64).
To see how its numbers compare with PSI today, use compare-pressure (below),
which reads Prometheus and judges the agent's data the way tare judges PSI.
What gets deployed
| what | where | needed for |
|---|---|---|
the agent, as a DaemonSet in namespace tare-agent |
deploy/agent/agent.yaml |
measuring |
| a scrape configuration in your Prometheus | snippets below | storing the metrics |
| nothing else |
The agent does not talk to the Kubernetes API (no ServiceAccount token, no RBAC).
Requirements:
- Linux nodes, kernel 4.18 or newer, cgroup v1 or v2, containerd or CRI-O.
- A Prometheus that already scrapes the kubelet's cAdvisor endpoint
(
/metrics/cadvisor), with theidlabel kept on those series. The agent labels its series only withid, the container's cgroup path. The kubelet's series carry the sameid, and that is how namespace, pod and container names are attached. - kube-state-metrics, optional: with it, CPU requests and limits are taken into account.
- Permission to run a privileged pod with
hostPIDandhostNetwork(see below).
1. The image
deploy/agent/agent.yaml pulls ghcr.io/2listic/tare-agent:main, rebuilt by CI
on every change to agent/ on main. Every release also has :vX.Y.Z (and
:latest), and every main build :sha-<commit>; those never move, so pin one of
them for anything you keep, and set imagePullPolicy: IfNotPresent. Nothing else
is needed if the nodes can pull from ghcr.io; otherwise build it yourself (2).
2. Build it yourself (optional)
From the repository root:
docker build -t tare-agent:poc agent/
The build runs inside Docker (Go is not needed on your machine). The result is a static binary on a distroless base, about 12 MB.
To build or test the binary itself (Go 1.27):
cd agent
go test ./...
go build -o tare-agent .
Then either push it to a registry the cluster can pull from, or import it into every node's container runtime.
Registry:
docker tag tare-agent:poc registry.example.com/tare-agent:poc
docker push registry.example.com/tare-agent:poc
Then in deploy/agent/agent.yaml set image: registry.example.com/tare-agent:poc
and imagePullPolicy: IfNotPresent.
Import into each node (no registry): set image: tare-agent:poc and
imagePullPolicy: Never in deploy/agent/agent.yaml, then
# containerd
docker save tare-agent:poc | ssh <node> 'sudo ctr -n k8s.io images import -'
# CRI-O
docker save tare-agent:poc | ssh <node> 'cat > /tmp/tare-agent.tar &&
sudo skopeo copy docker-archive:/tmp/tare-agent.tar containers-storage:docker.io/library/tare-agent:poc'
ctr may live in /usr/local/bin, which sudo leaves out of its PATH on
RHEL-family nodes: use the full path (sudo /usr/local/bin/ctr ...).
3. Deploy
kubectl apply -f deploy/agent/agent.yaml
kubectl -n tare-agent rollout status ds/tare-agent
kubectl -n tare-agent logs ds/tare-agent
The log should show the cgroup version, registration sent for cpus ... and
process connector: listening for execs, and the pods should become ready. If the exec
listener cannot start, the agent keeps serving what it has and logs why. If the exit
listener cannot register or stops (no CAP_NET_ADMIN, not in the host network
namespace), the agent logs why, keeps serving the live threads, reports
tare_agent_exit_listener_up 0, and fails its readiness probe: without exit records CPU
wait misses every task that exited between scrapes.
Why the pod is privileged:
| setting | reason |
|---|---|
hostPID: true |
the PIDs in the cgroups are host PIDs, and /proc must be the host's |
hostNetwork: true |
the kernel's taskstats interface and process connector only answer in the host network namespace |
privileged: true |
for the POC. What is actually needed: CAP_NET_ADMIN (taskstats exit events, process connector exec events, large receive buffers) and read access to every task's /proc entry |
/sys/fs/cgroup mounted read-only |
to find the containers' cgroups |
The namespace carries pod-security.kubernetes.io/enforce: privileged. On
OpenShift the pods also need a privileged SCC.
Agent flags (rarely needed): -listen (default :9105), -cgroup (default
/sys/fs/cgroup), -proc (default /proc).
4. Have Prometheus scrape it
The agent publishes its numbers on a web page on every node
(http://<node>:9105/metrics). That alone stores nothing: Prometheus has to be
told to read ("scrape") those pages every 15 seconds and keep the values.
Prometheus does not find new things to scrape on its own, so you add one piece of
configuration. Which one depends on how your Prometheus was installed.
Check which case you are in:
kubectl get prometheus -A
If this lists something, your Prometheus is managed by the Prometheus Operator
(for example the kube-prometheus-stack Helm chart): case A. If the command says the
resource type does not exist, you configure Prometheus yourself through its
prometheus.yml: case B.
Case A: Prometheus Operator
You do not edit Prometheus' configuration; the operator writes it from Kubernetes
objects. Create one object, a PodMonitor, that says "scrape the pods labelled
app=tare-agent, on their port named metrics".
-
Find the label your Prometheus requires on PodMonitors:
kubectl get prometheus -A -o jsonpath='{range .items[*]}{.metadata.name}: {.spec.podMonitorSelector}{"\n"}{end}'With kube-prometheus-stack this is usually
{"matchLabels":{"release":"<your Helm release>"}}. An empty selector ({}) means every PodMonitor is picked up. -
Save this as
tare-agent-podmonitor.yaml, with that label undermetadata.labels:apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: name: tare-agent namespace: tare-agent labels: release: kube-prometheus-stack # the label found in step 1 spec: selector: matchLabels: {app: tare-agent} podMetricsEndpoints: - port: metrics interval: 15s -
Apply it:
kubectl apply -f tare-agent-podmonitor.yamlThe Prometheus Operator may also limit which namespaces it reads PodMonitors from (
spec.podMonitorNamespaceSelectorof theprometheusobject);tare-agentmust be one of them.
Case B: plain Prometheus
Add this job under scrape_configs: in prometheus.yml, then reload Prometheus
(restart it, or curl -X POST http://<prometheus>/-/reload if it runs with
--web.enable-lifecycle):
scrape_configs:
- job_name: tare-agent
scrape_interval: 15s
kubernetes_sd_configs:
- role: pod
namespaces: {names: [tare-agent]}
relabel_configs:
- {source_labels: [__meta_kubernetes_pod_label_app], regex: tare-agent, action: keep}
- {source_labels: [__meta_kubernetes_pod_container_port_name], regex: metrics, action: keep}
- {source_labels: [__meta_kubernetes_pod_node_name], target_label: node}
It tells Prometheus to look up the tare-agent pods through the Kubernetes API
(Prometheus' service account needs list on pods, which it already has if it
discovers the kubelet that way) and to scrape their metrics port.
Check
In Prometheus' web UI, Status → Targets must list the agent with one target per node, all UP. Or run this query in Prometheus: it must return one series per node with value 1.
up{job=~".*tare-agent.*"}
(With a PodMonitor the job is usually called tare-agent/tare-agent or tare-agent.)
5. Check that it works
kubectl -n tare-agent port-forward ds/tare-agent 9105 &
curl -s localhost:9105/metrics | grep '^tare_'
| metric | meaning |
|---|---|
tare_cpu_wait_seconds_total{id} |
CPU wait of the container (counter, seconds) |
tare_cpu_run_seconds_total{id} |
CPU time the agent accounted for the container: compared with the kubelet's CPU usage it gives coverage |
tare_agent_exit_listener_up |
1 when the exit listener is registered and receiving. Must be 1: at 0 CPU wait misses exited tasks |
tare_agent_exits_total |
exit records received |
tare_agent_exits_unattributed_total |
exit records that could not be assigned to any container. Healthy: a few percent at most |
tare_agent_exits_outside_containers_total |
exits of host processes, not in any container (normal) |
tare_agent_exits_never_received_total |
threads whose exit record did not arrive within 30 s of leaving /proc. Healthy: close to 0 |
tare_agent_exits_layout_total{layout} |
records decoded per kernel record layout (upstream, rhel10) |
tare_agent_exits_layout_unknown_total |
records from a kernel whose layout the agent does not know. Must be 0 |
tare_agent_netlink_drops_total |
exit records the kernel dropped. Must be 0 |
tare_agent_execs_total{location} |
exec events from the process connector, by where the task was (container, outside). 0 means the exec listener is not running (see its log line) |
tare_agent_exits_placed_by_exec_total |
exit records assigned to a container through their process's exec event (exec probes, and short tasks no scrape saw) |
tare_agent_exec_drops_total |
exec events the kernel dropped; those tasks fall back to the exit-time lookup. Should be 0 |
tare_agent_containers{location} |
containers found under the kubelet's kubepods hierarchy, or outside it (see Troubleshooting) |
tare_agent_build_info{version,kernel,cgroup} |
one per node |
In Prometheus, this must return your containers' names; if it returns nothing,
the id join is not working (see Troubleshooting):
sum by (namespace, pod, container) (
rate(tare_cpu_wait_seconds_total[5m])
* on (id) group_left (namespace, pod, container)
max by (id, namespace, pod, container) (container_cpu_usage_seconds_total{container!="",container!="POD"} * 0 + 1))
6. Compare with PSI: compare-pressure
agent/compare-pressure is a standalone script (Python 3.9+, standard library
only). It reads Prometheus and, per container, judges the agent's CPU wait the
way tare judges PSI. Let the agent run for at least a day of normal traffic first:
each container's "normal" is learned from its own history in the window.
agent/compare-pressure --prometheus http://localhost:9090 --window 1d
agent/compare-pressure --prometheus http://localhost:9090 --window 7d --namespace shop,payments
agent/compare-pressure --prometheus http://localhost:9090 --window 1d --all --stretches stretches.csv
If Prometheus is not reachable directly:
kubectl -n <monitoring namespace> port-forward svc/<prometheus service> 9090.
| option | meaning |
|---|---|
--window |
how far back to look (default 1d; 30m, 12h, 7d, 2w) |
--end |
end of the window (Unix time or 2026-10-21T16:36:00Z; default now) |
--namespace |
comma-separated namespaces (default all) |
--share |
share of stalled 5-minute stretches that makes a container SATURATED (default 0.05) |
--kubelet-job |
Prometheus job of the kubelet's cAdvisor endpoint (default kubelet; "" for any job) |
--all |
list every container, also those NOT-STARVED under every rule |
--stretches FILE |
write every 5-minute stretch of every container to a CSV file, for your own analysis |
--redact |
replace namespace, pod, workload and container names and the Prometheus host with stable hashes, in the output and the --stretches file, to paste the results outside the organisation. Same hashes as tare report --redact; stable, not secret: a guessed name can be confirmed |
How it decides
Per container, the window is cut into 5-minute stretches. In each stretch:
- wait: the agent's CPU wait (s/s);
- usage: CPU usage from the kubelet (cores);
- slowdown = wait ÷ usage: seconds of waiting per second of running;
- psi: PSI waiting from the kubelet (s/s), when the cluster has it.
A stretch is stalled under each rule:
| rule | stalled when | idea |
|---|---|---|
psi |
PSI > 0.03 | tare's pressure threshold, the reference |
relative-slowdown |
slowdown ≥ 2 × the container's own normal and wait ≥ 0.01, or slowdown ≥ 1 | compare each container with itself: containers differ a lot in how much they wait when nothing is wrong. Normal = the container's 10th percentile of slowdown in the window |
fixed-slowdown |
slowdown ≥ 0.488 and wait ≥ 0.01 | one slowdown cut for every container |
absolute-wait |
wait ≥ 0.0435 s/s | one cut on the raw wait |
Then each rule gives each container the verdict tare's pressure check gives:
| verdict | meaning |
|---|---|
SATURATED |
stalled in at least --share of its stretches, and its peak CPU reached half of its request or limit. tare would never lower its CPU |
NOT-STARVED |
pressure does not stop a reduction: tare would go on to its other checks. Also when the container was stalled as often but used under half of its allocation, with the code NODE_CONTENTION (the node was busy, a bigger request would not help; tare treats it the same way) |
UNVERIFIED |
fewer than 12 stretches, or (agent rules) the agent accounted under half of the container's CPU time. Idle containers (never above 0.005 cores) have no coverage to check and are not UNVERIFIED for it |
- |
no PSI for this container |
tare's other verdicts (LEAKING, IDLE, WASTE, BLOCKED, UNSET, CONFLICT, NO-DATA) come from memory and usage, not from pressure. The script does not reproduce them.
Output
using http://127.0.0.1:9191: tare-agent on 2 nodes, PSI present, 1h ending 2026-10-21T16:35Z
# section=findings
PSI RELATIVE-SLOWDOWN FIXED-SLOWDOWN ABSOLUTE-WAIT NAMESPACE WORKLOAD CONTAINER STALLED NORMAL COVER WHY
SATURATED SATURATED SATURATED SATURATED reallab postgres-db postgres-db 69%/38%/38%/85% 0.131 0.86 CPU_PRESSURE,CPU_WAIT_RELATIVE,+2
NOT-STARVED NOT-STARVED NOT-STARVED NOT-STARVED reallab load-pg pgbench 0%/0%/0%/0% 0.216 0.98 -
7 containers (7 listed) over 1h: SATURATED by psi 6, relative-slowdown 6, fixed-slowdown 6, absolute-wait 6
agreement relative-slowdown: same verdict as psi on 7 of 7 containers; same call on 76.9% of 91 stretches (16 psi only, 5 relative-slowdown only)
| column / line | meaning |
|---|---|
| one column per rule | the container's verdict under that rule |
WORKLOAD |
the pod name without its ReplicaSet / StatefulSet suffix |
STALLED |
share of stretches stalled, per rule in column order |
NORMAL |
the container's normal slowdown (used by relative-slowdown) |
COVER |
share of the container's CPU time the agent accounted (below 0.5 the agent rules say UNVERIFIED) |
WHY |
evidence codes: CPU_PRESSURE (PSI), CPU_WAIT_RELATIVE, CPU_WAIT_SLOWDOWN, CPU_WAIT_ABSOLUTE (the rule that made it SATURATED), NODE_CONTENTION, LOW_COVERAGE, FEW_STRETCHES, PSI_ONLY / AGENT_ONLY (psi and relative-slowdown disagree). Like tare, the first two codes and +N |
agreement |
per agent rule: same container verdict as PSI, and same call per stretch, with the stretches where only PSI or only the rule said stalled |
warning |
SIGNAL_ABSENT (no PSI, or no CPU requests), AGENT_LOW_COVERAGE, AGENT_LAYOUT_UNKNOWN, AGENT_DROPS, AGENT_UNATTRIBUTED |
On a cluster without PSI the PSI column and the agreement lines are left out, and the agent's rules are shown on their own.
--stretches writes one row per container and stretch: namespace, pod, container,
time, wait, usage, slowdown, psi, normal, and 0/1 per rule.
Troubleshooting
| symptom | cause | what to do |
|---|---|---|
no tare_cpu_wait_seconds_total series |
the agent is not scraped | check the PodMonitor / scrape config and the Prometheus targets page |
tare-agent series exist but none matches a container |
the kubelet's cAdvisor series lack the id label, or the kubelet job has another name |
keep id (some setups drop it with a metric relabelling); pass --kubelet-job |
tare_agent_containers{location="outside"} above 0 |
the kubelet and the container runtime use different cgroup drivers (e.g. kubelet systemd, containerd SystemdCgroup = false): containers live under the runtime's service, not under kubepods |
the agent still finds them by their container id; whether the kubelet's id matches depends on the setup. Fixing the driver mismatch is the real cure |
AGENT_LOW_COVERAGE on fork-heavy containers |
processes torn down after their exit record is sent; values of 0.7–0.9 are normal for them | below 0.5, look at tare_agent_exits_unattributed_total |
AGENT_LOW_COVERAGE on idle containers with exec probes (databases, caches) |
the probes' processes are started by the runtime's shim, outside the container, and were counted as host processes | an agent with the exec listener places them: check tare_agent_execs_total{location="container"} grows on that node |
AGENT_LAYOUT_UNKNOWN |
a kernel whose taskstats record layout the agent does not know | report the kernel version; exits on that node are not counted |
AGENT_DROPS |
the kernel dropped exit records (very high process churn) | the agent requests a 64 MB receive buffer; check the agent's CPU |
sudo: ctr: command not found |
/usr/local/bin is not in sudo's PATH (RHEL family) |
use sudo /usr/local/bin/ctr |
Remove
kubectl delete -f deploy/agent/agent.yaml # and your PodMonitor / scrape config
Limits of the proof of concept
- There is no Helm chart.
- The pod runs privileged; the capability-only profile is not done yet.
- tare does not read the agent yet:
compare-pressureis the way to look at it. - Measured cost: about 0.002–0.05 cores and 15–20 MiB per node, growing with the rate of process exits (0.05 cores at about 280 exits per second).
- Exercised on Ubuntu 24.04 (kernel 6.8), Debian 12 (6.1), AlmaLinux 9 (5.14), AlmaLinux 10 (6.12), Debian 11 with cgroup v1 (5.10), CRI-O 1.36, Rocky Linux 8.9 (4.18, without Kubernetes) and arm64 (emulated, without Kubernetes).
Documentation
¶
Overview ¶
tare-agent measures per-container CPU wait on nodes without PSI and serves it to Prometheus. CPU wait is the time a container's tasks spent ready to run but waiting for a CPU, summed over all of its tasks, including those that already exited: live threads are read from /proc on every scrape, exited tasks are counted from the kernel's taskstats exit notifications. Proof of concept.