Skip to content

Repository files navigation

Sandboxed Code Execution Engine

A production-style backend service that accepts untrusted code submissions (Python today, structured to add more languages later), runs each one inside an isolated Docker container, and returns stdout/stderr/exit code/runtime -- built to survive 500+ concurrent submissions without containers leaking, hanging, or taking the host down with them.

Architecture

                                   ┌─────────────┐
                    HTTPS/JSON     │   Caddy      │   (prod only, terminates TLS)
        client ───────────────────▶  reverse     │
                                   │   proxy      │
                                   └──────┬───────┘
                                          │
                                   ┌──────▼───────┐        ┌─────────────┐
                                   │   FastAPI    │◀──────▶│  PostgreSQL │
                                   │   (api)      │        │ submissions │
                                   └──────┬───────┘        │  + results  │
                                          │ enqueue               ▲
                                          ▼                       │
                                   ┌──────────────┐               │
                                   │    Redis     │               │
                                   │ (broker +    │               │
                                   │  result      │               │
                                   │  backend)    │               │
                                   └──────┬───────┘               │
                                          │ dequeue                │
                              ┌───────────┼────────────┐          │
                              ▼           ▼            ▼          │
                        ┌─────────┐ ┌─────────┐  ┌─────────┐      │
                        │ worker  │ │ worker  │  │ worker  │──────┘
                        │ (celery)│ │ (celery)│  │ (celery)│  writes status/result
                        └────┬────┘ └────┬────┘  └────┬────┘
                             │           │            │
                             │  docker SDK, via mounted host socket
                             ▼           ▼            ▼
                        ┌─────────────────────────────────────┐
                        │   host Docker daemon (sibling        │
                        │   containers, one per execution)     │
                        │                                       │
                        │  ┌───────────┐  ┌───────────┐        │
                        │  │ sandbox   │  │ sandbox   │  ...   │
                        │  │ container │  │ container │        │
                        │  │ no net,   │  │ no net,   │        │
                        │  │ non-root, │  │ non-root, │        │
                        │  │ mem/cpu/  │  │ mem/cpu/  │        │
                        │  │ pids cap, │  │ pids cap, │        │
                        │  │ timeout   │  │ timeout   │        │
                        │  └───────────┘  └───────────┘        │
                        └─────────────────────────────────────┘

                        ┌──────────────┐
                        │  celery beat │──▶ periodically enqueues the two
                        │  (scheduler) │    reaper tasks (orphan container /
                        └──────────────┘    stale-row cleanup) on the same
                                             worker pool above

Layers, and why they're separated:

  • API (app/api/) -- FastAPI routes, API-key auth, request/response schemas. Only talks to Postgres (to create rows) and Celery (to enqueue); never touches Docker directly.
  • Queue/worker (app/queue/) -- Celery task definitions, retry/dead-letter policy, the periodic reaper. Only talks to app/sandbox's execute() interface; doesn't know or care that Docker is involved.
  • Sandbox (app/sandbox/) -- the only code in the repo that talks to the Docker daemon. Owns every isolation control and the cleanup guarantee.
  • Database (app/db/) -- SQLAlchemy models, indexes, and the CRUD functions everything else calls through.

Each layer was built and tested against the one below it via a stub interface before the real implementation existed (see git history / the staged build order below), so each has its own test file and can be verified in isolation.

Project layout

app/
  api/routes/       submissions, health, metrics endpoints
  queue/            celery_app, tasks (execute_submission), reaper, dispatch
  sandbox/          runner.py (Docker SDK), languages.py (registry)
  db/               models, crud, session
  core/             logging_config, metrics
docker/
  app.Dockerfile              api/worker/beat/migrate image
  sandbox-python.Dockerfile   the untrusted-code execution image
alembic/            schema migrations
tests/              one file per layer, see Testing below
scripts/            create_user.py, benchmark_indexes.py, load_test.py, build_images
docker-compose.yml       local dev: api, worker, beat, redis, postgres
docker-compose.prod.yml  production: adds Caddy, locks down networking
DEPLOYMENT.md            VPS deployment runbook

Local development setup

Prerequisite: Docker Desktop (or Docker Engine + Compose plugin) installed and running. Nothing else.

git clone <this-repo>
cd sandbox-exec-engine
cp .env.example .env
docker compose up -d --build

That one command builds the api/worker/beat image, builds the sandbox-python execution image on your host Docker daemon, runs migrations, and starts Postgres, Redis, the API, a worker, and the beat scheduler.

curl http://localhost:8000/healthz
curl http://localhost:8000/readyz

# provision an API key (no signup flow by design -- see security model)
docker compose exec api python scripts/create_user.py "Your Name"

curl -X POST http://localhost:8000/api/v1/submissions \
  -H "X-API-Key: <the key printed above>" -H "Content-Type: application/json" \
  -d '{"code": "print(1 + 1)"}'
# -> {"id": "...", "status": "queued"}

curl http://localhost:8000/api/v1/submissions/<id> -H "X-API-Key: <key>"
# -> status transitions queued -> running -> completed, with stdout/stderr/exit_code/runtime_ms

Testing

pytest

One command runs all 46 tests against the layers below (needs Postgres/Redis/Docker reachable -- docker compose up -d first, or point DATABASE_URL/REDIS_URL at your own instances).

Layer File What's covered
API test_api.py auth, submit/status/result/list endpoints, pagination, cross-user isolation, /metrics, /api/v1/stats
Queue/worker test_queue.py status transitions, timeout/OOM mapping, code-error vs infra-error distinction, retry-then-recover, retry-exhaustion -> dead letter
Sandbox test_sandbox.py success, non-zero exit, stdin, timeout, OOM, network blocked, non-root user, read-only FS, process-count limit, container cleanup
Reliability test_reaper.py orphaned-container cleanup, stale-RUNNING-row watchdog
Database test_db.py schema correctness, index existence, cascade deletes, 10k-row seeded query performance

Fast subset for iteration (skips Docker + big-data-seeding tests):

pytest -m "not slow and not sandbox"

Security model

Every execution container is created fresh per submission and destroyed afterward, win or lose. Each control below closes a specific way untrusted code could otherwise escape or cause damage:

Control Why it matters
network_mode="none" Without it, submitted code could exfiltrate data, reach internal services/metadata endpoints, or use your IP for abuse (spam, scanning, DDoS relay).
Non-root user baked into the image (uid 1000, no shell) If a container-escape or kernel bug is ever found, "root inside the container" is a much shorter path to "root on the host" than a low-privilege user.
read_only=True root filesystem + tmpfs /tmp Stops code from tampering with anything on disk beyond its own scratch space, even within its own container.
mem_limit + memswap_limit (swap capped at the same value) Without capping swap too, a memory limit is trivially bypassed by just swapping instead of OOM-killing -- one submission could still exhaust host memory.
nano_cpus (fractional CPU quota) An infinite-loop submission can't starve other jobs' containers, or the host's own processes, of CPU.
pids_limit Caps fork-bomb-style process spam; without it, one submission can exhaust the host's process table and take down every other container.
Hard wall-clock timeout, enforced via container.kill() after wait()'s client-side timeout wait(timeout=N) alone only gives up waiting -- it doesn't stop the container. kill() is what actually guarantees a runaway submission can't run forever.
container.remove(force=True) in a finally block Guarantees the container is destroyed whether execution succeeded, timed out, OOM'd, or this code itself raised -- no leaked containers accumulating on the host.
Periodic reaper (Celery beat) sweeping orphaned containers/stale rows The one gap a same-process finally can't close: if the worker process itself is killed mid-execution, nothing is left running to reach that finally. The reaper is a second, independent process that cleans up after that case.
No Docker socket in the API or sandbox containers -- only the worker has it Limits which part of the system holds the most dangerous credential (see tradeoff below) to the smallest surface that needs it.

A deliberate tradeoff, worth being upfront about in an interview: the worker container has the host's Docker socket mounted into it (needed to launch sibling sandbox containers), which gives that worker root-equivalent power over the host. This is the standard "docker-outside-of-docker" pattern for exactly this kind of architecture, and the mitigation is that the worker only ever runs our own trusted code -- the untrusted submission never executes in the worker container, only inside the separately locked-down sibling container described above. A stricter alternative (rootless Docker, gVisor/Kata Containers as the runtime, or a remote Docker API over mTLS with a restricted API surface) would reduce this further and is a natural next step, not implemented here.

Auth is a simple API key (X-API-Key header) mapped to a user row, no signup flow -- provisioned via scripts/create_user.py, matching the "solo hosted project" scope rather than building out a full account system.

Deployment

See DEPLOYMENT.md for the full runbook. Summary: a single VPS running docker-compose.prod.yml, because the worker needs direct Docker daemon access that platforms like Fargate/Cloud Run/Render/Railway/Fly.io don't expose. Caddy handles automatic HTTPS; Postgres/Redis/the API itself are never published to the internet, only reachable over the internal compose network. All secrets and connection strings are environment-variable driven (.env / .env.production, both git-ignored) -- nothing is hardcoded.

Measured performance and reliability

Real numbers from this repo's own test/benchmark scripts, not assumptions -- reproduce them with scripts/benchmark_indexes.py and scripts/load_test.py.

Indexing: before vs. after

scripts/benchmark_indexes.py seeds the submissions table and times "list recent submissions for a user" (the query ix_submissions_user_created exists for) with and without that index, using EXPLAIN to confirm what the planner actually chose:

Rows Without index With index Improvement
15,000 4.25ms mean (Seq Scan + Sort) 2.65ms mean (Index Scan) ~38%
150,000 16.4ms mean (Seq Scan + Sort) 2.9ms mean (Index Scan) ~82%

The gap widens as the table grows, exactly as expected: a sequential scan degrades linearly with table size, while the index scan (which can walk the composite (user_id, created_at DESC) index directly and stop after LIMIT 20) barely moves.

500+ concurrent submissions

scripts/load_test.py fires N submissions at a live docker compose stack concurrently and waits for every one to reach a terminal state. Run repeatedly against this dev machine (Windows + Docker Desktop, 8 CPUs allocated, 3 worker replicas x --concurrency=4 = 12 concurrent execution slots) while tuning for it:

Attempt Change Accepted / 500 Result once accepted
1 baseline (1 uvicorn process) 0 -- every request timed out --
2 anyio threadpool raised to 200+ 300 (60%) 300/300 completed, 0 failures
3 + 4 uvicorn worker processes 301 (60%) 301/301 completed, 0 failures
4 + Celery broker_pool_limit 10 -> 50 305 (61%) 305/305 completed, 0 failures
5 + submit timeout 60s -> 180s 309 (62%), resolved in 58s regardless of the 180s ceiling 309/309 completed, 0 failures

The honest finding: attempts 2-4 fixed a real, reproducible problem (attempt 1 failed completely -- the default 40-thread limiter plus a single request-handling process meant the API couldn't even accept load, let alone process it). But the ~60% acceptance ceiling didn't move with further application-level tuning, and attempt 5 is the tell: giving the client 3x longer to wait didn't change how many got in, or how long the attempt took to resolve (58s again, not 180s) -- which means requests aren't slowly succeeding, something is failing them at a fixed point consistently around the same wall-clock window regardless of app-level concurrency knobs. That points at Docker Desktop's Windows networking bridge (vpnkit) rather than this codebase: this same set of components (Postgres, Redis, Celery, FastAPI) run natively on Linux in production (see Deployment), without the virtualized NAT layer a Windows dev machine sits behind, and would not be expected to hit this ceiling.

What did hold, completely, across every single attempt and 1,400+ cumulative submissions over the course of this testing: every job that was accepted ran to completion correctly -- 0 timeouts, 0 OOMs, 0 infra-errors, 0 leaked sandbox containers, and all 7 compose services still healthy afterward. That's the claim that actually matters for "handles 500+ concurrent submissions without leaking, hanging, or crashing the host": the queue absorbs a burst larger than any single worker pool can run at once, and nothing gets lost, stuck, or left running -- it processes correctly, just at a rate bounded by however many execution slots you give it (12 here; horizontally scalable by adding worker replicas, see Deployment).

Sandbox reliability

Across this session's repeated load tests, 1,400 real submissions were run through the full stack (API -> queue -> Docker sandbox -> DB). Final tally from GET /api/v1/stats:

{"total_submissions": 1400, "by_status": {"completed": {"count": 1393, ...}, "pending": {"count": 7, ...}}, "failure_rate": 0.0}

0% failure rate -- no submission ever landed in timeout, oom, or infra_error except when deliberately triggered by the sandbox test suite's own timeout/OOM/network tests (which pass 12/12, see Testing). Every container was confirmed removed after each run (docker ps --filter label=app=sandbox-exec-engine returns empty), and every compose service remained healthy through all of it -- no worker crashes, no restarts, no manual intervention.

Configuration reference

All configuration is environment-variable driven (app/config.py); see .env.example (local dev) and .env.production.example (deployment) for the full list with defaults. Nothing is hardcoded -- secrets and connection strings always come from the environment. The knobs most worth knowing about:

Variable Default Controls
DATABASE_URL, REDIS_URL local dev values Never hardcoded; overridden per-environment (see docker-compose*.yml)
SANDBOX_TIMEOUT_SECONDS 10 Hard wall-clock limit per execution, enforced via container.kill()
SANDBOX_MEMORY_LIMIT 128m mem_limit/memswap_limit (swap capped equal, so it can't be bypassed)
SANDBOX_CPU_LIMIT 0.5 Fractional CPU quota (nano_cpus) per container
SANDBOX_PIDS_LIMIT 64 Caps process/fork spam inside a container
MAX_TASK_RETRIES 3 Retries for infra failures before dead-lettering (INFRA_ERROR)
CELERY_TASK_TIME_LIMIT_SECONDS 60 Backstop above the sandbox's own timeout, in case the sandbox call itself hangs
DB_POOL_SIZE / DB_MAX_OVERFLOW 10 / 20 Per-process SQLAlchemy connection pool sizing (the api service runs 4 processes, so this is multiplied -- see app/config.py)
REQUIRE_API_KEY true Never disabled outside local scratch testing

Extending to another language

  1. Write docker/sandbox-<language>.Dockerfile: non-root user, reads SUBMITTED_CODE_B64/SUBMITTED_STDIN_B64 env vars, executes the decoded program. Copy docker/sandbox-entrypoint.sh as a starting point.
  2. Build it and add one line to app/sandbox/languages.py.
  3. Add the language to the Language enum in app/db/models.py (and an Alembic migration to extend the Postgres enum).

Nothing in app/api, app/queue, or app/db's query layer needs to change.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors