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
ARGso 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 pstells 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:
- On a brand-new Python, install heavy C/Rust deps from apt, not pip; bridge with a
--system-site-packagesvenv. - Verify binaries before automating them; name collisions are real (
httpxvshttpx-toolkit). - 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.