Cutting security scan times by 80% with async orchestration
- Python
- Docker
- Performance
- Security
My pentest automation framework orchestrates 10+ Dockerized security tools — port scanners, fuzzers, vulnerability checkers — against a target and merges their output into one report. The first version worked. It was also painfully slow: 12–15 minutes per scan.
This post is about how it got to 2–3 minutes, and the less obvious lessons along the way.
The sequential trap
The original orchestrator did the obvious thing:
for tool in tools:
result = run_container(tool, target) # blocks until done
results.append(parse(result))
Each tool waited for the previous one to finish, even though almost none of them depended on each other. An nmap scan doesn't care that nikto hasn't run yet. The total runtime was the sum of every tool's runtime, when it should have been closer to the max.
Restructuring around the dependency graph
The fix wasn't just "add async" — it was figuring out which tools actually depend on each other. A subdomain enumerator feeds targets to the HTTP scanners, so those form a stage. Everything else is independent.
async def run_stage(tools, target):
return await asyncio.gather(
*(run_container_async(tool, target) for tool in tools)
)
# Stage 1: discovery. Stage 2: everything that consumes discovery output.
discovery = await run_stage(DISCOVERY_TOOLS, target)
findings = await run_stage(SCAN_TOOLS, expand(target, discovery))
Two stages, each internally parallel. Scan time dropped from ~14 minutes to under 3 immediately.
The parser layer fights back
Parallelism exposed a problem the sequential version had been hiding: parsers. Each tool emits a different format (XML, JSON, free text), and a single malformed output used to crash the whole run — annoying at minute 14, catastrophic when it kills nine sibling tasks.
The fix was making every parser a boundary:
async def safe_parse(tool, raw):
try:
return tool.parser(raw)
except ParserError as e:
log.warning("parser failed", tool=tool.name, err=e)
return PartialResult(tool=tool.name, raw=raw)
A failed parser now degrades that one tool's section of the report instead of sinking the scan. That failure-detection layer is a big part of why the system has sustained 30+ hour uptimes.
Docker build caching: the other 83%
The report pipeline had its own bottleneck — container images were being rebuilt far more often than they needed to be. Ordering the Dockerfiles so that the volatile layers (tool configs) come after the stable ones (base image, dependencies) let the cache do its job, and report generation went from ~30 seconds to ~5.
What I'd tell past me
- Draw the dependency graph before writing the orchestrator. The stages fall out of it naturally; guessing them afterwards is refactoring.
- Parallelism amplifies failure. Every boundary between "my code" and "tool output" needs a try/except with a graceful degradation path.
- Measure before optimizing the exotic parts. The Docker cache fix was boring and took an afternoon — and it was worth 83% on its own.
The pattern generalizes: any pipeline of independent, containerized jobs is
begging for staged async execution. The hard part isn't the asyncio.gather
— it's the failure handling that makes it safe.