Skip to main content
  1. Tetragons/

Let's Install Tetragon on Kubernetes

·2274 words·11 mins·
Joseph Ligier
Author
Joseph Ligier
CNCF ambassador | Kubestronaut 🐝
Table of Contents
Close Encounters with Tetragon - This article is part of a series.
Part 2: This Article

In the first part, we provided an overview of Tetragon. We also installed Tetragon on a standalone Linux host to get hands-on experience with Tetragon.

In this second part, we’ll continue our journey with Tetragon by looking at how to install it on Kubernetes and the various components deployed during the installation process.

We’ll wrap up by looking at Tetragon events in JSON format and creating our first tracing policy!


Installing Tetragon on Kubernetes
#

Prerequisites and KinD Installation
#

Tetragon can be installed on any Kubernetes cluster with eBPF support. In this article, we’ll install it on KinD (Kubernetes in Docker).

Kind logo

KinD is a tool that allows you to run Kubernetes clusters locally. It uses Docker containers to emulate the cluster nodes.

Unlike a traditional Kubernetes cluster, all KinD nodes share the same Linux kernel: the one from your host machine. Thus, regardless of the number of nodes created, there is only one kernel.

Kind archi
The Linux kernel is shared by all nodes in Kind

As we saw in the previous section, Tetragon is based on eBPF programs, which run directly in the Linux kernel.

Therefore, a single KinD cluster is enough to test Tetragon.

lab If you create a tracing policy in KinD, it will also apply to the machine hosting your Kubernetes cluster—not just to the containers inside the cluster.

To install KinD, simply type:

VERSION=v0.32.0
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/$VERSION/kind-linux-amd64
[ $(uname -m) = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/$VERSION/kind-linux-arm64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind

If you encounter any issues, feel free to check out the official website.

Creating the KinD Cluster
#

I based this section on the official documentation.

Let’s create this configuration file to set up the cluster:

kind-config.yaml
1apiVersion: kind.x-k8s.io/v1alpha4
2kind: Cluster
3nodes:
4  - role: control-plane
5    extraMounts:
6      - hostPath: /proc
7        containerPath: /procHost
lab

extraMounts allows you to create additional mounts between the host and the KinD cluster.

Here, we mount the host machine’s /proc directory into the Kubernetes node. This allows access to actual system information (processes, PIDs, etc.).

Without this mount, Tetragon would only see the Docker container’s /proc, which would skew the events observed via eBPF.

Next, we create the Kubernetes cluster:

kind create cluster --config kind-config.yaml

Installing Tetragon
#

We’ll use Helm to install Tetragon. To specify the host machine’s /proc, we’ll pass an option to Helm:

EXTRA_HELM_FLAGS=(--set tetragon.hostProcPath=/procHost)

The Tetragon installation otherwise uses the default options:

helm repo add cilium https://helm.cilium.io
helm repo update
helm install tetragon ${EXTRA_HELM_FLAGS[@]} cilium/tetragon -n kube-system
lab For a production installation, I would recommend installing Tetragon in a namespace other than kube-system. To customize the installation, feel free to check out the Helm chart.

To find out when Tetragon is finally deployed, you can run the following command:

kubectl rollout status -n kube-system ds/tetragon -w

How Tetragon Is Installed on Kubernetes
#

Let’s try to understand how Tetragon works on Kubernetes.

Kube archi

Let’s take a look at what we’ve installed with Helm.

Pods
#

Let’s start by looking at the pods:

kubectl get pod -n kube-system -l app.kubernetes.io/instance=tetragon
NAME                                 READY   STATUS    RESTARTS   AGE
tetragon-operator-59bdd59db8-8865c   1/1     Running   0          47s
tetragon-vjgfb                       2/2     Running   0          47s

There are two different pods:

  • tetragon-*, which is installed on each node via a DaemonSet. This is the Tetragon agent.
  • tetragon-operator-*, which is installed via a Deployment. This is the Tetragon operator.

Let’s take a closer look at the role of each.

The Tetragon Agent
#

We can also see that the tetragon pod has two containers running simultaneously. Let’s look at the names of these containers:

kubectl get daemonset tetragon -n kube-system -o jsonpath='{.spec.template.spec.containers[*].name}'
export-stdout tetragon
  • The export-stdout container exposes the events generated by Tetragon
  • The tetragon container is responsible for installing and running eBPF programs

The agent is the component that actually monitors the system on each node.

The Tetragon Operator
#

The operator plays a control role:

  • It monitors Tracing Policies
  • It translates these policies into instructions
  • It instructs the agents to install the corresponding eBPF programs

It therefore serves as the link between the Kubernetes configuration and execution on each node.

CRDs
#

As we’ve just seen, the operator manages tracing policies. But how do you install them on Kubernetes? Configuration is done via CRDs.

Let’s take a look at the CRDs that have been installed:

kubectl get crd

The command returns the following:

NAME                                  CREATED AT
tracingpolicies.cilium.io             2026-06-16T12:44:59Z
tracingpoliciesnamespaced.cilium.io   2026-06-16T12:45:02Z

There are two CRDs:

  • Tracing policies that apply to the entire cluster
  • Namespaced tracing policies that apply, as the name suggests, to a Kubernetes namespace.
If you’ve ever looked at what Cilium installs in a Kubernetes cluster, the architecture is nearly identical: one Cilium agent per node to install the eBPF programs, an operator that acts as the bridge between eBPF and Kubernetes, and CRDs for Cilium network policies

Tetragon Up and Running
#

Now that we’ve seen what we’re actually installing in Kubernetes with Tetragon, let’s take a look at how to use it.

Status
#

You can now verify that Tetragon is running properly:

kubectl exec -n kube-system ds/tetragon -c tetragon -- \
tetra status

You should see:

Health Status: running

Logs
#

Now let’s monitor an nginx pod with Tetra:

kubectl exec -n kube-system ds/tetragon -c tetragon -- \
tetra getevents -o compact --pods nginx

Since this pod does not yet exist, there are no logs yet.

In another terminal, we create the nginx pod:

kubectl run --image nginx nginx

Here’s what we see:

🚀 process default/nginx /usr/bin/basename /docker-entrypoint.d/20-envsubst-on-templates.sh
💥 exit    default/nginx /usr/bin/basename /docker-entrypoint.d/20-envsubst-on-templates.sh 0
🚀 process default/nginx /usr/bin/awk -v filter= "END { for (name in ENVIRON) { print ( name ~ filter ) ? name : "" } }"
💥 exit    default/nginx /usr/bin/awk -v filter= "END { for (name in ENVIRON) { print ( name ~ filter ) ? name : "" } }" 0
💥 exit    default/nginx /docker-entrypoint.d/20-envsubst-on-templates.sh /docker-entrypoint.d/20-envsubst-on-templates.sh 0
🚀 process default/nginx /usr/bin/basename /docker-entrypoint.d/30-tune-worker-processes.sh
💥 exit    default/nginx /usr/bin/basename /docker-entrypoint.d/30-tune-worker-processes.sh 0
🚀 process default/nginx /kind/bin/mount-product-files.sh /kind/bin/mount-product-files.sh
🚀 process default/nginx /usr/bin/jq -r .bundle
💥 exit    default/nginx /usr/bin/jq -r .bundle 0
etc

This shows all the different binaries that are executed when the nginx pod starts up.

We’ve already seen this output in the previous section. Now let’s take a look at the default log. To do this, we need to run the tetra command again:

kubectl exec -n kube-system ds/tetragon -c tetragon -- \
tetra getevents --pods nginx | jq

We’re using the jq command to make the JSON a little easier to read.

Now let’s connect to the pod:

kubectl exec -it nginx -- bash

In the Tetra CLI, we then see:

tetra cli output
 1{
 2  "process_exec": {
 3    "process": {
 4      "exec_id": "a2luZC1jb250cm9sLXBsYW5lOjExNTEwNDY3OTYwNTY6NjQwOQ==",
 5      "pid": 6409,
 6      "uid": 0,
 7      "cwd": "/",
 8      "binary": "/usr/bin/bash",
 9      "flags": "execve rootcwd clone",
10      "start_time": "2026-06-16T13:29:38.388236227Z",
11      "auid": 4294967295,
12      "pod": {
13        "namespace": "default",
14        "name": "nginx",
15        "uid": "82d08485-bc2a-45ce-bf99-85e255fc5903",
16        "container": {
17          "id": "containerd://f29206e762bd035592b1ba0cc14a3d6deafa8dcc98bb974eaf5ca1ef38dffbea",
18          "name": "nginx",
19          "image": {
20            "id": "docker.io/library/nginx@sha256:608a100c71651bf5b773c89083b4a1ad7ef4b2bd05d7a7e552271e03123692ad",
21            "name": "docker.io/library/nginx:latest"
22          },
23          "start_time": "2026-06-16T13:22:31Z",
24          "pid": 40,
25          "security_context": {}
26        },
27        "pod_labels": {
28          "run": "nginx"
29        },
30        "workload": "nginx",
31        "workload_kind": "Pod"
32      },
33      "docker": "f29206e762bd035592b1ba0cc14a3d6",
34      "parent_exec_id": "a2luZC1jb250cm9sLXBsYW5lOjcwMDgyMTgyNjc0Nzo1NTQ3",
35      "tid": 6409,
36      "in_init_tree": false
37    },
38    "parent": {
39      "exec_id": "a2luZC1jb250cm9sLXBsYW5lOjcwMDgyMTgyNjc0Nzo1NTQ3",
40      "pid": 5547,
41      "uid": 0,
42      "cwd": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/65418056615070bc5892ebbd73d5cc97f039f389a88c2fc06232f815ecc9c96e",
43      "binary": "/usr/local/bin/containerd-shim-runc-v2",
44      "arguments": "-namespace k8s.io -id 65418056615070bc5892ebbd73d5cc97f039f389a88c2fc06232f815ecc9c96e -address /run/containerd/containerd.sock",
45      "flags": "execve clone",
46      "start_time": "2026-06-16T13:22:17.386071799Z",
47      "auid": 4294967295,
48      "parent_exec_id": "a2luZC1jb250cm9sLXBsYW5lOjcwMDgxMzkzNTEwOTo1NTQw",
49      "tid": 5547,
50      "in_init_tree": false
51    }
52  },
53  "node_name": "kind-control-plane",
54  "time": "2026-06-16T13:29:38.388235451Z",
55  "node_labels": {
56    "beta.kubernetes.io/arch": "amd64",
57    "beta.kubernetes.io/os": "linux",
58    "kubernetes.io/arch": "amd64",
59    "kubernetes.io/hostname": "kind-control-plane",
60    "kubernetes.io/os": "linux",
61    "node-role.kubernetes.io/control-plane": ""
62  }
63}

We can see a whole lot of data that wasn’t visible in the previous standard output. We won’t explain every field, but let’s take a look at the most important ones—or at least the ones that seemed the least straightforward to me.

  • process_exec: indicates that a process has been launched. You can see which node it’s running on via node_name, the launch time with time, and also the container runtime that launched the container with parent.
  • process provides details about the launched process: for example, which container in which pod
  • exec_id is an identifier that allows you to track this process until it terminates. It’s in Base64:
echo 'a2luZC1jb250cm9sLXBsYW5lOjExNTEwNDY3OTYwNTY6NjQwOQ==' | base64 -d
kind-control-plane:1151046796056:6409
  • 1151046796056 corresponds to the time in nanoseconds from when the machine started up until the process started (a monotonic kernel timestamp)

Let’s Build My First Tracing Policy
#

To wrap up this chapter, we’re going to write a tracing policy. Its purpose? To prevent the file /var/run/secrets/kubernetes.io/serviceaccount/token from being read.

This file contains a token for accessing the Kubernetes API.

To do this, we’ll build it step by step.

The World’s Simplest Tracing Policy
#

This first tracing policy will allow us to monitor all read accesses to the system. To do this, we will monitor the openat system call (syscall).

tp-openat-basic.yaml
1apiVersion: cilium.io/v1alpha1
2kind: TracingPolicy
3metadata:
4  name: "sys-openat"
5spec:
6  kprobes:
7  - call: "sys_openat"

Let’s quickly break down the lines:

  • kprobes are a type of eBPF program that allow you to observe all functions (symbols, to be precise) in the Linux kernel;
  • call lets you define the function you want to observe.

This way, we’ll see a message every time the sys_openat function is called in the Linux kernel. This function is triggered every time the openat syscall is initiated.

Let’s check it out:

kubectl apply -f tp-openat-basic.yaml

Let’s run tetra again in a more readable way:

kubectl exec -n kube-system ds/tetragon -c tetragon -- \
tetra getevents -o compact --pods nginx

In another terminal, let’s connect to the nginx pod:

kubectl exec -it nginx -- bash

What do we see in the Tetragon logs?

🚀 process default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash

We can see that when bash starts up, it reads quite a few files. But we can’t see which ones.

Tracing policy—a bit more complicated
#

To do this, we’ll need to modify the tracing policy.

Let’s look at the signature of the sys_open_at() function:

int sys_openat(int dirfd, const char *path, int flags, mode_t mode)

You need to know how to read a C function:

  • int: corresponds to the return code of the open_at syscall => we’re not interested in this.
  • int dirfd: corresponds to a file descriptor. It is an integer (int).
  • const char *path: corresponds to the path of the file being read. It is of type const char* or string in high-level languages.

This is exactly the parameter we want! So we’ll need to add the first two arguments to the previous files.

Therefore, we’ll use args:

tp-openat-args.yaml
 1apiVersion: cilium.io/v1alpha1
 2kind: TracingPolicy
 3metadata:
 4  name: "sys-openat"
 5spec:
 6  kprobes:
 7  - call: "sys_openat"
 8    args:
 9    - index: 0 #dirfd
10      type: int
11    - index: 1 #path
12      type: string
  • index corresponds to the argument’s index (starting at 0!)
  • type corresponds to the argument’s type

This can be interpreted as:

  • The first argument is a file descriptor; it is of type integer
  • The second argument is the path to the file being read; it is of type string.

Let’s check:

kubectl replace -f tp-openat-args.yaml

Let’s reconnect to the nginx pod; we now know exactly which files are being read:

🚀 process default/nginx /usr/bin/bash
📬️ openat  default/nginx /usr/bin/bash /etc/ld.so.cache
📬️ openat  default/nginx /usr/bin/bash /lib/x86_64-linux-gnu/libtinfo.so.6
📬️ openat  default/nginx /usr/bin/bash /lib/x86_64-linux-gnu/libc.so.6
📬️ openat  default/nginx /usr/bin/bash /etc/nsswitch.conf
📬️ openat  default/nginx /usr/bin/bash /etc/passwd
📬️ openat  default/nginx /usr/bin/bash /etc/bash.bashrc
📬️ openat  default/nginx /usr/bin/bash /root/.bashrc
📬️ openat  default/nginx /usr/bin/bash /root/.bash_history
📬️ openat  default/nginx /usr/bin/bash /usr/share/terminfo/x/xterm
📬️ openat  default/nginx /usr/bin/bash /root/.inputrc
📬️ openat  default/nginx /usr/bin/bash /etc/inputrc

Now let’s verify that we have access to the /var/run/secrets/kubernetes.io/serviceaccount/token file in the pod:

cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIsImtpZCI6Ik9VRTBaVEFUTXNNaTRPOUZFTkZrbHRWVnZIQ0JfeUt1S0RRd2lLUnFtYncifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxODA4Mjc4OTA1LCJpYXQiOjE3NzY3NDI5MDUsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiMWI1ZGEwMjUtYzk2ZS00OTJjLWI2MTItZGE4YTUyY2ZmN2E1Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwibm9kZSI6eyJuYW1lIjoia2luZC1jb250cm9sLXBsYW5lIiwidWlkIjoiZmNmYmU3M2EtYzhhMy00YzRmLWFiMjgtZWZhYTM4MDNiNTgwIn0sInBvZCI6eyJuYW1lIjoibmdpbngiLCJ1aWQiOiIxYWU2NmU4YS02MWFjLTQ5NmEtYWQ3My03OGNlNmNlN2JlNTgifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiIzNTAwZWY5NC00MWU4LTQ2YjctYmE4ZC1jMmVmZjdkZmY5YmQifSwid2FybmFmdGVyIjoxNzc2NzQ2NTEyfSwibmJmIjoxNzc2NzQyOTA1LCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.JdkU6I6CJ3DCZDgC3iDfPy9MOC6Y60a5E0RKmkhJduC9j8hqmfR1wssESiYmrx_QRA5YhnOY6qARJZfIEKM9g_XQr_ZvI7MBjiRduqofROWZz5gMpFt_E1FBZTFbZsplc2QWuylAUozo0EUha56oFfM5TYimKPbJfOTywijHnbcZcXAvvZX9GLyAIu20rgzK9iIdqj1dSa89K48To_uGZIaaQUhvrF7dh4PSJ67Xuvp2qOA3Cq-xkdbxO-GKmAGasKfoJq5GCvAvQOxc6Rx2iXBbFKnPpTjMzvI0Xfx19_9e5I7l1XhYexVgkHBtPHBTri9eKMxVqEvRQm69g-TgLQ

How can we prevent this?

Final tracing policy
#

To do this, simply reuse the previous tracing policy and send a SIGKILL to the process when the path matches the desired value:

tp-openat-enforcement.yaml
 1apiVersion: cilium.io/v1alpha1
 2kind: TracingPolicy
 3metadata:
 4  name: "sys-openat"
 5spec:
 6  kprobes:
 7  - call: "sys_openat"
 8    args:
 9    - index: 0 #dirfd
10      type: int
11    - index: 1 #path
12      type: string
13    selectors:
14    - matchArgs:
15      - index: 1 #path
16        operator: "Equal"
17        values:
18        - "/var/run/secrets/kubernetes.io/serviceaccount/token"
19      matchActions:
20      - action: Sigkill

This policy is relatively straightforward: it checks the second argument, which corresponds to the file path; if it matches the value /var/run/secrets/kubernetes.io/serviceaccount/token, it sends the Sigkill signal to it.

Let’s check:

kubectl replace -f tp-openat-enforcement.yaml
kubectl exec -it nginx -- bash
cat /var/run/secrets/kubernetes.io/serviceaccount/token

The output is then:

Killed

In Tetragon, we observe:

🚀 process default/nginx /usr/bin/bash
🚀 process default/nginx /usr/bin/cat /var/run/secrets/kubernetes.io/serviceaccount/token
📬️ openat  default/nginx /usr/bin/cat /var/run/secrets/kubernetes.io/serviceaccount/token
💥 exit    default/nginx /usr/bin/cat /var/run/secrets/kubernetes.io/serviceaccount/token SIGKILL

That’s exactly what we wanted to do.

lab The purpose of this tracing policy is to demonstrate what can be done with Tetragon. This policy is easily bypassed! Here’s a quick exercise if you want to dig deeper: How can you bypass this policy? How can you strengthen it?

We continued exploring Tetragon by covering:

  • installing Tetragon on a Kubernetes cluster;
  • how Tetragon works in a Kubernetes environment;
  • Tetragon’s JSON-formatted logs;
  • a first introduction to TracingPolicy.

In the next article, we’ll dive deeper into TracingPolicy.

Close Encounters with Tetragon - This article is part of a series.
Part 2: This Article

Related