I wanted a reproducible container: a Kali base, a focused set of web-pentest tools, and a Python service on top. Simple, in theory. In practice I hit three walls that turn a 20-minute Dockerfile into an afternoon. All three are on Apple Silicon (arm64), and all three are avoidable once you know them.

This is the writeup I wanted while fighting it. Examples use a Kali base image and an open-source MCP tool server, but the lessons apply to any Python-on-Kali build.

Wall 1: Kali ships Python 3.14, and the wheels aren't there yet

The naive Dockerfile:

FROM kalilinux/kali-rolling RUN apt-get update && apt-get install -y python3 python3-venv python3-pip RUN python3 -m venv /opt/venv && /opt/venv/bin/pip install -r requirements.txt 

…dies on the pip step with something like:

error: [Errno 2] No such file or directory: 'aarch64-linux-gnu-gcc' ERROR: Failed building wheel for cffi ERROR: Failed to build 'zstandard' ... 

Two things collided. Kali rolling moved to Python 3.14, and for arm64 there are no prebuilt wheels yet for the heavy C/Rust dependencies (cffi, zstandard, cryptography, pydantic-core, aiohttp, psutil). pip falls back to compiling from source, and there's no compiler in the image.

You could install a full toolchain and compile everything. It works, but it's slow (Rust builds for cryptography and pydantic-core are not quick) and it bloats the image.

Fix: let apt do the heavy lifting, pip the rest

Kali packages most of those heavy libs as prebuilt apt packages. Install those, create the venv with --system-site-packages so it inherits them, and pip-install only the handful that apt doesn't carry.

# Prebuilt heavy deps from apt (no compiling), plus a small toolchain for the rest RUN apt-get update && apt-get install -y --no-install-recommends \  python3 python3-venv python3-pip python3-dev \  build-essential libffi-dev libssl-dev pkg-config cargo rustc \  mitmproxy python3-flask python3-requests python3-psutil \  python3-bs4 python3-selenium python3-aiohttp \  && rm -rf /var/lib/apt/lists/* # venv inherits the apt libs; pip only adds what's missing COPY requirements-lean.txt /opt/requirements-lean.txt RUN python3 -m venv --system-site-packages /opt/venv \  && /opt/venv/bin/pip install --no-cache-dir -r /opt/requirements-lean.txt 

requirements-lean.txt then contains only the packages apt can't provide: in my case two pure-Python ones plus pydantic-core, which the small cargo/rustc toolchain compiles quickly. The build went from "fails" to a couple of minutes.

The general principle: on a bleeding-edge Python, prefer the distro's prebuilt packages over pip wheels for anything with a C/Rust extension. --system-site-packages is the bridge that lets a venv use them.

Wall 2: a tool name that isn't the tool you think

My service shelled out to httpx and every call failed with:

Error: No such option: -l 

The trap: on Kali, /usr/bin/httpx is the Python HTTP client, not ProjectDiscovery's httpx probe. The security tool is packaged as httpx-toolkit. Same name, completely different CLI.

Fix: point the name you call at the tool you meant.

RUN apt-get install -y --no-install-recommends httpx-toolkit \  && if [ -x /usr/bin/httpx-toolkit ]; then \  ln -sf /usr/bin/httpx-toolkit /usr/local/bin/httpx; \  fi 

Lesson that generalizes: in a big toolbox, verify what a binary actually is (command -v, <tool> -version) before wiring it into automation. Name collisions between a language package and a security tool are more common than you'd expect (http, dig, ab, and friends).

Wall 3: installing 150 tools and failing on the one that moved

A single apt line with dozens of packages fails entirely if one name changed or isn't available for your arch. Split it: a core line that must succeed, and a best-effort line that tolerates individual misses.

# core: must succeed RUN apt-get update && apt-get install -y --no-install-recommends \  nmap nuclei sqlmap gobuster ffuf nikto subfinder \  && rm -rf /var/lib/apt/lists/* # best-effort: don't sink the build over one unavailable package RUN apt-get update && for p in katana dalfox hakrawler httpx-toolkit chromium ; do \  apt-get install -y --no-install-recommends "$p" || echo "[skip] $p"; \  done; rm -rf /var/lib/apt/lists/* 

Now a renamed or arch-missing extra prints [skip] <name> instead of failing the whole image, and you can see at a glance what didn't make it.

A few more things that paid off

  • Pin the source. If you clone an upstream tool inside the image, check out a specific commit via an ARG so rebuilds are reproducible: ARG TOOL_SHA=....
  • Keep it lean on purpose. I only needed a web-app subset, so I skipped the binary-analysis and forensics tooling. Smaller image, faster build, less to patch.
  • Add a healthcheck for any service in the image so docker ps tells the truth: HEALTHCHECK CMD curl -fsS http://127.0.0.1:PORT/health || exit 1.
  • Bind services to localhost (-p 127.0.0.1:PORT:PORT): a security toolbox is the last thing you want listening on all interfaces by accident.

Takeaway

Three rules save the weekend on a Kali + Python image:

  1. On a brand-new Python, install heavy C/Rust deps from apt, not pip; bridge with a --system-site-packages venv.
  2. Verify binaries before automating them; name collisions are real (httpx vs httpx-toolkit).
  3. Split apt installs into must-succeed and best-effort, pin sources, and keep the image to what you actually use.

Isolation note: run offensive tooling in a sandbox and bind it to localhost. The next piece covers microVM sandboxing for exactly this.