Skip to main content

blocksnoop

Detect blocking calls in Python asyncio event loops using eBPF + Austin.

blocksnoop attaches to a running Python process (or launches one) and reports every time the event loop is blocked longer than a configurable threshold — with the Python stack trace that caused it.

How it works

eBPF (kernel)          Austin (userspace)
  │ monitors               │ samples Python
  │ epoll gaps              │ stacks continuously
  └──────────┐   ┌─────────┘
             ▼   ▼
          Correlator
             │
             ▼
       Reporter → sinks (console, JSON, file)
  1. An eBPF probe hooks the epoll syscalls (epoll_wait, epoll_pwait, epoll_pwait2 — all of the family available on the kernel) and measures the time between returns (callback start) and the next entry (callback end). If the gap exceeds the threshold, it emits an event. Tracing every variant matters because which one a loop enters depends on its libc and implementation — glibc routes epoll_wait() through the epoll_pwait syscall, and uvloop/libuv call epoll_pwait directly.
  2. A stack sampler (Austin) runs as a long-lived subprocess, continuously streaming Python stack traces into a ring buffer. Austin's pipe mode avoids per-sample subprocess overhead, enabling sub-10ms threshold detection.
  3. The correlator enriches each blocking event with the closest matching Python stack.
  4. The reporter fans out events to one or more output sinks.

Requirements

  • Linux with eBPF support (kernel 4.15+ for BCC; Core namespace filtering requires kernel 5.7+ and readable kernel BTF)
  • Root privileges (for eBPF and Austin)
  • BCC (BPF Compiler Collection) when using the default --backend bcc
  • blocksnoop-ebpf sidecar when using --backend core
  • Austin
  • austin-python (installed automatically as a dependency)
  • Python 3.12+

Installation

pip install blocksnoop

Or for development:

git clone git@github.com:PaulM5406/blocksnoop.git
cd blocksnoop
uv sync --all-extras --dev

eBPF backends

--backend bcc remains the default compatibility path. --backend core is an experimental compile-once backend: Python launches the small blocksnoop-ebpf libbpf sidecar and consumes its versioned NDJSON event stream. Protocol v2 resolves the target PID namespace and its local PID/TID before attaching, so a hostPID collector can monitor a process in a private container namespace without broadening its filter. The current tracepoint-only program does not read kernel structures, so the precompiled object has no kernel-layout relocations despite carrying BTF metadata.

On linux/amd64 and linux/arm64, PyPI selects a native wheel containing the sidecar and precompiled object, so pip install blocksnoop is sufficient for Core itself; Austin and the required privileges are still host prerequisites. Other platforms receive the portable compatibility wheel without native assets. A source checkout or unpacked source distribution can build the sidecar on Linux with make -C native and place native/blocksnoop-ebpf on PATH.

The official Docker image is intentionally Core-only: it installs the native wheel and Austin, but not BCC or kernel headers. Use --backend core explicitly in that image. A forced Core backend never silently falls back to BCC.

Before attaching, inspect the exact environment and optional target without loading BPF or spawning the sidecar:

blocksnoop doctor --backend core
blocksnoop doctor --backend core 1234
blocksnoop doctor --backend core 1234 --json

doctor exits non-zero when a required check fails and includes remediation in both human-readable and machine-readable output. Target-specific output shows the host and namespace-local PID/TID plus whether the collector shares the PID namespace.

Usage

Attach to a running process

sudo blocksnoop <PID>
sudo blocksnoop -t 50 <PID>          # 50ms threshold (default: 100ms)
sudo blocksnoop --tid 1234 <PID>     # monitor specific thread
sudo blocksnoop -v <PID>             # enable debug logging

Launch and monitor a process

sudo blocksnoop -- python app.py
sudo blocksnoop -t 50 -- python app.py

Output modes

# Human-readable to stderr (default)
sudo blocksnoop -- python app.py

# JSON lines to stdout (for piping to jq, etc.)
sudo blocksnoop --json -- python app.py

# Structured JSON to file (for Datadog/Fluentd/CloudWatch)
sudo blocksnoop --log-file /var/log/blocksnoop/events.json --service my-api --env production -- python app.py

# Combine: console to terminal + JSON to file
sudo blocksnoop --log-file /var/log/blocksnoop/events.json --service my-api -- python app.py

Stats mode

Use --stats to run only the eBPF detector (no Austin profiler, no stack traces) and see the distribution of all epoll gaps. This helps you pick the right --threshold before running a full profiling session.

# Capture all epoll gaps and display live statistics
sudo blocksnoop --stats <PID>

# JSON lines output (one record per second)
sudo blocksnoop --stats --json <PID>

# Only gaps above 10ms
sudo blocksnoop --stats -t 10 <PID>

Sample output (redrawn in place every second):

blocksnoop stats — PID 1234 — 12.3s — 4821 events (391/s)

  min          0.0ms
  avg          2.1ms
  p50          0.8ms
  p90          4.2ms
  p95          8.7ms
  p99         45.3ms
  max        302.1ms

Example output

Human-readable:

[   1.23s] #1   BLOCKED     302.1ms  tid=1234
  Python stack (most recent call last):
    app.py:7 in blocking_io
      time.sleep(0.5)
    app.py:13 in main
      blocking_io()

[   2.05s] #2   BLOCKED     298.5ms  tid=1234
  Python stack (most recent call last):
    app.py:7 in blocking_io
      time.sleep(0.5)
    app.py:13 in main
      blocking_io()

--- blocksnoop session ---
Duration: 8.0s
Blocking events detected: 2
Lost detector events: 0

JSON (--json):

{"event_number": 1, "timestamp_s": 1.23, "duration_ms": 302.1, "pid": 5678, "tid": 1234, "python_stacks": [[{"function": "blocking_io", "file": "app.py", "line": 7, "source": "time.sleep(0.5)"}, {"function": "main", "file": "app.py", "line": 13, "source": "blocking_io()"}]], "level": "warning"}

CLI reference

blocksnoop [OPTIONS] [PID] [-- COMMAND ...]
blocksnoop doctor [OPTIONS] [PID]

Options:
  -t, --threshold FLOAT        Blocking threshold in ms (default: 100, or 0 with --stats)
  --stats                      eBPF-only mode: show epoll gap distribution (no Austin/stacks)
  --tid INT                    Thread ID to monitor (default: main thread)
  --backend {bcc,core}         eBPF backend to use (default: bcc)
  --json                       JSON lines output to stdout
  --log-file PATH              Write structured JSON to file for log aggregators
  --service NAME               Service name for structured logs (default: blocksnoop)
  --env ENV                    Environment tag for structured logs
  --no-color                   Disable ANSI colors in terminal output
  -v, --verbose                Enable debug logging to stderr
  --error-threshold MS         Duration in ms above which events are errors (default: 500)
  --correlation-padding MS     Correlation time window padding in ms (default: 200)

Docker

blocksnoop requires kernel access, so Docker containers need --privileged and --pid=host:

# Pull from Docker Hub
docker pull oloapm/blocksnoop

# Check that the host kernel can run the Core backend
docker run --rm --privileged --pid=host \
  -v /sys/kernel/debug:/sys/kernel/debug \
  oloapm/blocksnoop blocksnoop doctor --backend core --json

# Attach to a process on the host with the precompiled libbpf backend
docker run --rm --privileged --pid=host \
  -v /sys/kernel/debug:/sys/kernel/debug \
  oloapm/blocksnoop blocksnoop --backend core -t 100 <PID>

# Launch and monitor a process
docker run --rm --privileged --pid=host \
  -v /sys/kernel/debug:/sys/kernel/debug \
  oloapm/blocksnoop blocksnoop --backend core -t 100 -- python app.py

For local development:

# docker-compose.yml
services:
  blocksnoop:
    build: .
    privileged: true
    pid: host
docker compose run --rm blocksnoop blocksnoop --backend core -t 100 -- python app.py

The image is published for linux/amd64 and linux/arm64. It has no BCC fallback: a Core prerequisite failure is reported directly. For a local image smoke before attaching to a workload:

docker build -t blocksnoop:local .
docker run --rm blocksnoop:local python -c '
from blocksnoop.core_backend import find_sidecar
assert find_sidecar()
'
docker run --rm blocksnoop:local sh -ec '
  austin --version >/dev/null
  python -c "import importlib.util; assert importlib.util.find_spec(\"bcc\") is None"
'

Kubernetes

blocksnoop uses eBPF which operates at the kernel level, so you run it on the node, not inside the application container. The target process just needs to be visible from the host PID namespace.

Note: On kernel 5.7+, both backends translate a host-visible target into its container-local PID/TID. A node-level collector still needs hostPID: true to see the target in /proc; an ephemeral container sharing the target process namespace can use its local PID directly.

Ephemeral debug container (recommended)

Attach directly to a running pod with an ephemeral container. --profile=sysadmin (K8s 1.28+) grants the privileged access required for eBPF:

# Find the pod
kubectl get pods -l app=my-api

# Attach an ephemeral debug container with eBPF privileges
kubectl debug -it my-api-pod-7b8c9d \
  --image=oloapm/blocksnoop:latest \
  --target=my-api \
  --profile=sysadmin \
  -- sh -c "mount -t debugfs debugfs /sys/kernel/debug 2>/dev/null; exec sh"

--target shares the process namespace with the app container, so you can see its PIDs. --profile=sysadmin enables privileged mode for eBPF and debugfs access.

Inside the debug container, find the Python process and attach:

# Find the Python PID
ps aux | grep python

# Attach blocksnoop
blocksnoop -t 50 <PID>

# Or with structured logging
blocksnoop --json -t 50 <PID>

blocksnoop automatically detects and symlinks available kernel headers when the running kernel differs from the installed headers package (common in containers).

Cross-container attach (different mount namespaces)

When blocksnoop runs from a sidecar, ephemeral debug container, or a privileged Job pinned to the target's node, it lives in a different mount namespace than the target — so the Python binary paths Austin reads from /proc/<pid>/maps (e.g. /usr/local/bin/python3.11) don't exist in blocksnoop's filesystem. Without help, Austin then logs 🔢 Cannot determine the version of the Python interpreter. and produces zero samples.

blocksnoop handles this automatically: when the target's mount namespace differs from blocksnoop's, it generates a thin wrapper that opens austin (and the musl linker) as file descriptors in its own namespace, then nsenters into the target and execs via /proc/self/fd/N. fds survive execve, so Austin loads from blocksnoop's rootfs while sampling against the target's filesystem view.

This means:

  • The target image is never modified — no binaries copied, no files written under /proc/<TARGET>/root/.
  • Works against hardened targets with readOnlyRootFilesystem: true.
  • Works regardless of the target's libc (alpine/musl, debian/glibc, distroless).
  • Requires CAP_SYS_ADMIN on the blocksnoop side (already granted by --profile=sysadmin or privileged: true).

See examples/reproduce-cross-ns.sh for a runnable Docker reproduction.

DaemonSet sidecar

For continuous monitoring, deploy blocksnoop as a DaemonSet that monitors processes on each node:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: blocksnoop
spec:
  selector:
    matchLabels:
      app: blocksnoop
  template:
    metadata:
      labels:
        app: blocksnoop
    spec:
      hostPID: true
      containers:
        - name: blocksnoop
          image: oloapm/blocksnoop:latest
          command: ["blocksnoop", "--json", "--log-file", "/var/log/blocksnoop/events.json", "--service", "my-api", "--env", "production", "-t", "100"]
          securityContext:
            privileged: true
          volumeMounts:
            - name: logs
              mountPath: /var/log/blocksnoop
            - name: debugfs
              mountPath: /sys/kernel/debug
      volumes:
        - name: logs
          hostPath:
            path: /var/log/blocksnoop
        - name: debugfs
          hostPath:
            path: /sys/kernel/debug

The log file at /var/log/blocksnoop/events.json can be tailed by Datadog Agent, Fluentd, or any log collector running on the node.

Node shell (quick one-off)

For a quick check without building images:

# SSH into the node (or use a node shell tool)
kubectl node-shell <node-name>

# Install blocksnoop
pip install blocksnoop

# Find the Python process (hostPID shows all processes)
ps aux | grep python

# Attach
blocksnoop -t 50 <PID>

Development

# Install dependencies
uv sync --all-extras --dev

# Run unit tests
uv run --extra dev pytest tests/ -v --ignore=tests/integration

# Run integration tests (requires Docker)
uv run --extra dev pytest -m docker tests/integration/ -v

# Lint and format
ruff check blocksnoop/ tests/
ruff format blocksnoop/ tests/

# Type check
ty check blocksnoop/

License

GPL-3.0-or-later (due to the austin-python dependency)

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

blocksnoop-0.10.0.tar.gz (80.9 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

blocksnoop-0.10.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl (687.3 kB view details)

Uploaded Python 3manylinux: glibc 2.24+ x86-64manylinux: glibc 2.28+ x86-64

blocksnoop-0.10.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl (683.0 kB view details)

Uploaded Python 3manylinux: glibc 2.24+ ARM64manylinux: glibc 2.28+ ARM64

blocksnoop-0.10.0-py3-none-any.whl (53.7 kB view details)

Uploaded Python 3

File details

Details for the file blocksnoop-0.10.0.tar.gz.

File metadata

  • Download URL: blocksnoop-0.10.0.tar.gz
  • Upload date:
  • Size: 80.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for blocksnoop-0.10.0.tar.gz
Algorithm Hash digest
SHA256 210a4e586e5321b8de68b025d2aefce46090399c063c12d462f09c1bd0a5d5c6
MD5 b60dac61012001b9c72a8d58abf42ce1
BLAKE2b-256 d6fca7e1fd4dd7189987a5d924cf171796094c97554c4e192fa436190d50b9d5

See more details on using hashes here.

Provenance

The following attestation bundles were made for blocksnoop-0.10.0.tar.gz:

Publisher: release.yml on PaulM5406/blocksnoop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file blocksnoop-0.10.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for blocksnoop-0.10.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 1d6a183386c3ac5b9f884a8d76d2d9ddc0061b2cb77f77d5d3efe50a2b622071
MD5 ca47904f6aa4fdcba2e41bc46a2d6c7b
BLAKE2b-256 b0f2f7c38c38c1c6ceb60ff5f848405cea9b39e537f90a86fbc9486a67d94aed

See more details on using hashes here.

Provenance

The following attestation bundles were made for blocksnoop-0.10.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl:

Publisher: release.yml on PaulM5406/blocksnoop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file blocksnoop-0.10.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for blocksnoop-0.10.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 cbd94541c01b0a12467c430af08d00810db7ef85583d822437e6bcd9a8adf67f
MD5 2f8ceeadef01f40d082ec713051c50b1
BLAKE2b-256 45bba0180d6ba79cbb35df99d3744aa04d59b409425bad9b6dacc4b7f22d5d5d

See more details on using hashes here.

Provenance

The following attestation bundles were made for blocksnoop-0.10.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl:

Publisher: release.yml on PaulM5406/blocksnoop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file blocksnoop-0.10.0-py3-none-any.whl.

File metadata

  • Download URL: blocksnoop-0.10.0-py3-none-any.whl
  • Upload date:
  • Size: 53.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for blocksnoop-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7ed43996f79d5a0c246de6c2a58bc4c6eb349a78dce7d85b24879920a35108c3
MD5 ca1ab607e7000d371d61db9da0380f4b
BLAKE2b-256 d98ed6b56a82079457d494e0284c3383a0c0c8fffc3a5e5d5534f45528aaf3b6

See more details on using hashes here.

Provenance

The following attestation bundles were made for blocksnoop-0.10.0-py3-none-any.whl:

Publisher: release.yml on PaulM5406/blocksnoop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.11.1

4 files

0.11.0

4 files

0.10.1

4 files

This release

0.10.0 This release

4 files

0.9.2

2 files

0.9.1

2 files

0.9.0

2 files

0.8.0

2 files

0.7.3

2 files

0.7.2

2 files

0.7.1

2 files

0.7.0

2 files

0.6.2

2 files

0.6.1

2 files

0.6.0

2 files

0.5.3

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page