Skip to content

[FIX][CHAOS] Fix disk-failure cgroupv2 filtering and relative-path disruption#1092

Closed
aymericDD wants to merge 20 commits into
mainfrom
aymeric.daurelle/fix/disk-failure
Closed

[FIX][CHAOS] Fix disk-failure cgroupv2 filtering and relative-path disruption#1092
aymericDD wants to merge 20 commits into
mainfrom
aymeric.daurelle/fix/disk-failure

Conversation

@aymericDD

Copy link
Copy Markdown
Contributor

Motivation

The eBPF disk-failure injector had two gaps:

  1. cgroupv2 sub-cgroup miss — When targeting a pod-level disruption, the old PID filter only matched the container's main PID and its direct children. kubectl exec creates a containerd exec-<id> sub-cgroup whose processes share a different cgroup than the target, so they escaped disruption entirely.

  2. Relative-path opens not disrupted — If the target process does cd /mnt/data && cat file, the kernel receives openat(AT_FDCWD, "file") with a relative path. The eBPF program only compared absolute path arguments against filter_path, so this open was never disrupted even though /mnt/data/file matches the configured prefix.

Changes

cgroupv2 process filtering (cgroup/, injector/disk_failure.go, ebpf/disk-failure/)

  • Added CgroupV2Path() to cgroup.Manager interface and implementation — returns the absolute cgroupv2 unified hierarchy path on v2, empty string on v1.
  • Injector passes -cgroup-path to bpf-disk-failure when a cgroupv2 path is available; the BPF program populates a BPF_MAP_TYPE_CGROUP_ARRAY and uses bpf_current_task_under_cgroup() to match the container and all its sub-cgroups.
  • Gate disk-failure on HaveCgroupArrayMapType (alongside the existing HavePerfEventArrayMapType check) so unsupported kernels get a clear error before BPF load.

Relative-path disruption (injector/disk_failure.go, ebpf/disk-failure/)

  • Injector stats the filter path inside the container via <ProcRoot>/<pid>/root<path> and passes two inode/device pairs:
    • filter_dir_inode / filter_dir_dev: parent directory inode for basename-prefix matching (cwd=/parent && cat dir/file)
    • filter_dir_inode2 / filter_dir_dev2: the path's own inode when it is a directory, for exact-CWD matching (cwd=/dir && cat file)
  • BPF check_relative_path runs both checks and only fires when dirfd == AT_FDCWD, preventing false matches on non-CWD dirfd opens.
  • Device IDs are converted from glibc encode_dev format to kernel MKDEV encoding (major<<20|minor) to match super_block.s_dev — without this the device guard silently rejected every relative open.
  • ProcRoot is injected via config (default /proc) so unit tests set it to a nonexistent path and remain deterministic on both macOS and Linux CI.

QA Instructions

cgroupv2 filtering

  1. Deploy a pod disruption with diskFailure on a cgroupv2 cluster.
  2. kubectl exec into the target pod and run cat /target/file — it should return the configured error code.
  3. Verify that without the fix (use_cgroup_filter=0), exec'd processes were not disrupted.

Relative-path disruption

  1. Configure paths: [/mnt/data/file] and have the target process do cd /mnt/data && cat file — should now return the configured error.
  2. Configure paths: [/mnt/data] (directory) and do cd /mnt/data && cat anyfile — should be disrupted; cat ../other should not.
  3. Absolute path opens (existing behavior) must be unaffected.

Blast Radius

  • Only the disk-failure injector is modified; no other disruption kinds are affected.
  • The new cgroup array map is unconditionally included in the BPF object; the new HaveCgroupArrayMapType gate ensures unsupported kernels fail fast with a clear error rather than crashing at BPF load.
  • Relative-path filtering is best-effort (only CWD-relative opens via AT_FDCWD are matched; multi-component relative opens from ancestor directories are not yet supported).

Documentation

🤖 Generated with Claude Code

@datadog-official

datadog-official Bot commented Jun 12, 2026

Copy link
Copy Markdown

Pipelines  Tests

Fix all issues with BitsAI

⚠️ Warnings

🚦 1 Pipeline job failed

CI | lint-and-format   View in Datadog   GitHub Actions

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 41.94%
Overall Coverage: 45.64% (-0.01%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: fec2bcb | Docs | Datadog PR Page | Give us feedback!

@aymericDD aymericDD force-pushed the aymeric.daurelle/fix/disk-failure branch 3 times, most recently from 13255ef to fa08683 Compare June 12, 2026 12:35
Adds two capabilities to the eBPF disk-failure injector:

1. Cgroupv2-based process filtering
   Expose CgroupV2Path() on cgroup.Manager (returns the absolute
   cgroupv2 unified hierarchy path on v2, empty string on v1).
   The injector passes this path as -cgroup-path to bpf-disk-failure,
   which populates a BPF_MAP_TYPE_CGROUP_ARRAY and uses
   bpf_current_task_under_cgroup() to match the target container and
   all its sub-cgroups (e.g. kubectl exec creates containerd
   exec-<id> sub-cgroups that the old PID filter missed).
   Gate disk-failure on HaveCgroupArrayMapType so load failures on
   kernels that lack it surface as a clear error.

2. Relative-path disruption (cd /dir && cat file)
   Previously only absolute openat paths were matched. Now the
   injector stats the filter path inside the container via
   /proc/<pid>/root and passes two inode/device pairs to the BPF
   program:
   - filter_dir_inode/filter_dir_dev: inode of the parent directory
     for basename-prefix matching ("cwd=/parent && rtk read dir/file")
   - filter_dir_inode2/filter_dir_dev2: inode of the path itself
     when it is a directory, for exact-CWD matching
     ("cwd=/dir && rtk read file")
   The BPF check_relative_path helper runs both checks and only fires
   when dirfd == AT_FDCWD, avoiding false matches on non-CWD dirfds.
   Device IDs are passed in kernel MKDEV encoding (major<<20|minor)
   rather than the glibc encode_dev format to match super_block.s_dev.
   ProcRoot is injected via config so tests point at a nonexistent
   path and remain deterministic across macOS and Linux CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@aymericDD aymericDD force-pushed the aymeric.daurelle/fix/disk-failure branch from fa08683 to 71e2c33 Compare June 12, 2026 12:49
Fix TID/TGID naming inversion in eBPF (bpf_get_current_pid_tgid
lower bits = TID, upper bits = TGID/process-ID). Improve process
filter to use TGID for exclude_pid check, covering all threads.

Decouple HaveCgroupArrayMapType requirement: only fail when a
cgroupv2 path is actually available, allowing cgroupv1 fallback.
Add syscall.Stat check on the cgroup path before passing it to
the eBPF binary; fall back to PID filter on stale paths.

Add bpf_trace_printk after bpf_override_return to confirm the
override fires. Mount /sys/kernel/debug/tracing at /mnt/tracefs
in the chaos pod so the trace pipe is reachable from the container
rootfs. Replace helpers.TracePipeListen with a custom reader that
routes output through zap and logs failures visibly.

Add diagnostic log of bpf-disk-failure args (cgroupPath, pid,
exitCode) to make filter setup observable without bpftool.
libbpfgo/helpers is no longer imported after replacing
helpers.TracePipeListen() with a custom listenTracePipe
function. Run go mod tidy && go mod vendor to clean up.
The helpers sub-package was removed from vendor in the previous
commit; remove the corresponding entry from the license CSV to
keep it in sync with the vendor directory.
@aymericDD aymericDD force-pushed the aymeric.daurelle/fix/disk-failure branch from 77753da to 61f05b9 Compare June 15, 2026 14:10
aymericDD and others added 14 commits June 15, 2026 18:38
On x86_64, kprobes on __x64_sys_openat are ftrace-based ([FTRACE]
flag in /sys/kernel/debug/kprobes/list). bpf_override_return
silently does nothing through the ftrace path even when all
ALLOW_ERROR_INJECTION conditions are met. Switch to fmod_ret which
fires via the ftrace trampoline and overrides the return directly.

On ARM64, kprobes on __arm64_sys_openat are traditional int3-based
(no [FTRACE] flag). bpf_override_return works correctly there.
fmod_ret links are accepted but callbacks never fire on the tested
ARM64 kernels (6.8 Ubuntu), so keep the kprobe approach on ARM64.

The BPF C file now uses SEC("fmod_ret/__x64_sys_openat") on x86
and SEC("kprobe/__arm64_sys_openat") on ARM64 with the matching
argument-reading and override semantics for each. The Go side
branches on ebpf.UseKprobe to call AttachKprobe vs AttachGeneric.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Strip path comparison entirely so any openat call from the
target process/cgroup triggers bpf_override_return. This
validates whether the ARM64 kprobe override mechanism works
on Datadog clusters before re-adding argument parsing.
Replace dual fmod_ret/kprobe paths with a single kprobe +
bpf_override_return approach. Confirmed working on ARM64;
the architecture distinction was a false lead.

Remove const-arm.go and const-x64.go in favour of a single
ebpf.SysOpenat() helper that returns the right kernel symbol
at runtime. Drop the UseKprobe branch from main.go.
kprobe + bpf_override_return silently fails on nodes where
kprobes are FTRACE-based. fmod_ret fires via the ftrace
trampoline and its return value overrides the syscall directly,
no bpf_override_return needed.

Replace SEC("kprobe/...") with SEC("fmod_ret/...") and change
the override to a plain return -(int)exit_code. Switch
AttachKprobe to AttachGeneric in main.go. Remove the now-unused
SysOpenat helper and ebpf import.
With fmod_ret, __arm64_sys_openat and __x64_sys_openat both wrap
syscall args in a single (const struct pt_regs *) argument, so
PARM1(ctx) is the inner regs pointer on both architectures.
Use a single code path: inner_regs dereference + bpf_probe_read_user
for the path string (user-space pointer).

Restore check_basename_prefix and check_relative_path helpers.
The only remaining arch guard is the minimal SEC annotation.
fmod_ret programs (BPF_PROG_TYPE_TRACING) reject direct field reads
from the ctx pointer (LDX instruction at ctx+offset). The BPF verifier
only accepts reads via bpf_probe_read_kernel.

PT_REGS_PARM1(ctx) compiles to a raw LDX — rejected.
PT_REGS_PARM1_CORE(ctx) uses BPF_CORE_READ → bpf_probe_read_kernel
— accepted.
Previously check_relative_path returned 0 immediately for any dirfd
other than AT_FDCWD, so openat(fd, "file", ...) calls where fd is an
open directory descriptor were never disrupted. On internal nodes this
caused ~1-10% disruption rate instead of the expected 100%.

Fix: when dirfd >= 0, walk task->files->fdt->fd[dirfd] to retrieve the
directory inode and compare against filter_dir_inode / filter_dir_inode2,
matching the same logic already used for the AT_FDCWD CWD case.

Jira: CHAOSPLT-TBD
Add bpf_trace_printk calls to identify why relative-path openat
calls are not being disrupted at 100% on internal clusters:

- relpath no-inode-filter: filter_dir_inode not set by injector
- relpath fd/found_ino/filter_ino: inode lookup result vs expected
- relpath miss: both inode checks failed (mismatch details)
- abs miss: absolute path did not match filter_path

These traces appear in the chaos pod logs via trace_pipe and will
show whether the issue is a missing inode filter, an inode mismatch,
or a device number discrepancy on x86_64.

Jira: CHAOSPLT-764

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tracefs is frequently blocked by node security policy on internal
clusters, making bpf_trace_printk output invisible. Switch to a
debug_counters BPF_MAP_TYPE_ARRAY instead:

- abs_hit/abs_miss: absolute path filter match/reject counts
- rel_no_filter: relative path with no inode filter set
- rel_hit/rel_miss: relative path inode match/reject counts

The Go loader reads and logs non-zero counters every 10 s so the
path filter behaviour is always visible in kubectl logs without
requiring tracefs access.

Jira: CHAOSPLT-764

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two additional debug counters to distinguish root causes of rel_miss:

- rel_ino_match: the dirfd's inode equals filter_dir_inode (the app
  opens the right directory) but the full check still fails. When this
  counter is high and rel_hit is low, the basename comparison is the
  culprit. When rel_ino_match stays 0, the dirfd never points to the
  expected directory.
- rel_null_fd: the fdtable lookup returned a null struct file pointer.
  Previously these calls were silently dropped with no counter, masking
  the actual fdtable miss rate.

Together with the existing rel_miss counter, these allow isolating
whether the 1% disruption rate is caused by: (a) the app using a
dirfd to a different directory, (b) a basename mismatch, or (c) the
fdtable read failing silently.

Jira: CHAOSPLT-764

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bpf_current_task_under_cgroup returns != 1 for ~99% of container
process invocations on the internal cluster despite those processes
being correctly in the target cgroup. This causes the cgroup filter
to exclude legitimate target processes, resulting in only 1% of
openat calls being disrupted instead of 100%.

Add PID-based fallback: when the cgroup filter returns 0 or an error,
also accept processes whose ppid or tgid matches target_pid (the
container's PID 1). This covers the common case where the container's
init process directly spawns the test workload (dd's ppid == 110340).

Add three cgroup-specific debug counters (cgroup_hit, cgroup_miss,
cgroup_err) to measure how often bpf_current_task_under_cgroup returns
each result, confirming the hypothesis.

Jira: CHAOSPLT-764

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the one-level ppid check with a 10-level ancestor walk
using is_in_target_tree(). The previous fallback only matched
direct children of the container init, missing processes spawned
via kubectl exec where the hierarchy is:

  container_init (target_pid) → exec_agent → shell → dd

bpf_current_task_under_cgroup() returns 0 for ~99.8% of calls on
the internal x86_64 cluster, so the fallback must cover the full
process ancestry chain to reliably disrupt all openat calls.

Jira: CHAOSPLT-1397
Add network namespace inode filtering as fallback when
bpf_current_task_under_cgroup() fails (~99.8% miss rate on the
internal x86_64 cluster).

kubectl exec processes are placed in a separate process tree by
containerd (containerd-shim → exec-agent → shell → dd), so they
are NOT descendants of the container init. The PID ancestry walk
cannot reach them. However, all processes in a container — whether
from the original workload or from kubectl exec — share the same
network namespace.

The BPF program now falls back to comparing the current task's
netns inode (task->nsproxy->net_ns->ns.inum via CO-RE) against the
target container's netns inode. The injector stats
/proc/<pid>/ns/net to obtain the inode and passes it via -netns-ino.

Fallback priority:
  1. bpf_current_task_under_cgroup  (fast, O(1))
  2. netns inode match              (catches kubectl exec)
  3. process ancestry walk          (last resort)

Jira: CHAOSPLT-1397
The original kprobe implementation used bpf_probe_read to read the
user-space path argument and path filter worked correctly. When we
switched to fmod_ret for the syscall override (to replace the
bpf_override_return that silently fails on the cluster), we also
switched to bpf_probe_read_user. This caused 99% path match failures:
bpf_probe_read_user intermittently fails in fmod_ret context on the
x86_64 cluster, leaving the path buffer zeroed so the abs/rel branch
never matches the filter.

Reverting to bpf_probe_read restores the behaviour that worked in the
original kprobe version. All kernel struct reads remain on the correct
_kernel variant; only the user-supplied path argument bytes are
affected.

Jira: CHAOSPLT-1764
@aymericDD

Copy link
Copy Markdown
Contributor Author

Closed in favor of #1095

@aymericDD aymericDD closed this Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant