Skip to content

Commit 99cdb2e

Browse files
committed
Enable Docker-in-Sandbox via Modal's official alpha support
Switch from manual docker.io + background dockerd to Modal's enable_docker experimental option with proper networking setup: - ubuntu:22.04 base with Docker CE 27.5.0 from official repo - runc v1.3.0 for reliable networking in gVisor - iptables-legacy (gVisor lacks nftables support) - Proper NAT/SNAT rules in start-dockerd.sh - Wait-for-dockerd readiness before starting runner - Adds test-docker.yml to validate Docker + services (postgres)
1 parent 1ec3b52 commit 99cdb2e

3 files changed

Lines changed: 150 additions & 5 deletions

File tree

.github/workflows/test-docker.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Test Docker Support
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
test-docker-basics:
8+
name: Docker Basics
9+
runs-on: [self-hosted, modal, "job-${{ github.run_id }}-${{ github.job }}"]
10+
steps:
11+
- name: Check Docker
12+
run: |
13+
docker version
14+
docker info
15+
16+
- name: Run a container
17+
run: |
18+
docker run --rm hello-world
19+
20+
- name: Docker build
21+
run: |
22+
mkdir -p /tmp/test-build
23+
cat > /tmp/test-build/Dockerfile <<'EOF'
24+
FROM alpine:3.19
25+
RUN echo "build works"
26+
CMD ["echo", "hello from built image"]
27+
EOF
28+
docker build --network=host -t test-image /tmp/test-build
29+
docker run --rm test-image
30+
31+
test-services:
32+
name: Docker Services (Postgres)
33+
runs-on: [self-hosted, modal, "job-${{ github.run_id }}-${{ github.job }}"]
34+
services:
35+
postgres:
36+
image: postgres:16-alpine
37+
env:
38+
POSTGRES_USER: test_user
39+
POSTGRES_PASSWORD: test_password
40+
POSTGRES_DB: test_db
41+
options: >-
42+
--health-cmd pg_isready
43+
--health-interval 10s
44+
--health-timeout 5s
45+
--health-retries 5
46+
ports:
47+
- 5432:5432
48+
steps:
49+
- name: Install psql
50+
run: |
51+
apt-get update && apt-get install -y postgresql-client
52+
53+
- name: Test Postgres connection
54+
env:
55+
PGHOST: localhost
56+
PGPORT: 5432
57+
PGUSER: test_user
58+
PGPASSWORD: test_password
59+
PGDATABASE: test_db
60+
run: |
61+
psql -c "SELECT version();"
62+
psql -c "CREATE TABLE test (id serial PRIMARY KEY, name varchar(100));"
63+
psql -c "INSERT INTO test (name) VALUES ('modal-docker-works');"
64+
psql -c "SELECT * FROM test;"
65+
66+
- name: Summary
67+
if: always()
68+
run: |
69+
echo "## Docker Services Test" >> "$GITHUB_STEP_SUMMARY"
70+
echo "- Docker version: $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'N/A')" >> "$GITHUB_STEP_SUMMARY"
71+
echo "- Status: ${{ job.status }}" >> "$GITHUB_STEP_SUMMARY"

DEPLOY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ This guide outlines the steps to deploy this project using Modal.
8787
8888
### ⚠️ Limitations
8989
90-
* **Docker-in-Docker:** Standard GitHub "Container Actions" (actions that run inside a Docker container) are not supported by default within Modal Sandboxes.
90+
* **Docker-in-Docker:** Docker support is enabled via Modal's Alpha Docker-in-Sandbox feature (`experimental_options={"enable_docker": True}`). GitHub Actions `services:` and container actions should work, but this is an Alpha feature and may have edge cases.
9191
* **Wiping State:** Every job runs in a fresh sandbox. Files saved outside the repository workspace will be lost after the job completes.
9292
9393
### How it Works

app.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import time
99
import re
10+
import tempfile
1011
import asyncio
1112
from urllib.parse import urlparse
1213
from fastapi import Request, HTTPException
@@ -19,6 +20,10 @@
1920
# CONFIGURATION
2021
# =============================================================================
2122

23+
# CRITICAL: Use 2025.06+ Image Builder for Docker-in-Sandbox support
24+
# https://modal.com/docs/guide/docker-in-sandboxes
25+
os.environ.setdefault("MODAL_IMAGE_BUILDER_VERSION", "2025.06")
26+
2227
# Runner version - configurable via environment for security updates
2328
RUNNER_VERSION = os.environ.get("RUNNER_VERSION", "2.333.1")
2429

@@ -67,16 +72,77 @@
6772
# - Consider using fine-grained PATs with minimal repository access
6873
# =============================================================================
6974

70-
# Pinned dependency versions for reproducibility and supply chain security
75+
# start-dockerd.sh content for proper Docker networking inside Modal Sandbox
76+
# Sets up NAT/SNAT rules, forces iptables-legacy (gVisor lacks nftables support),
77+
# and starts dockerd with --iptables=false since we manage rules manually.
78+
# Ref: https://modal.com/docs/guide/docker-in-sandboxes
79+
START_DOCKERD_SH = r"""#!/bin/bash
80+
set -e -o pipefail
81+
82+
dev=$(ip route show default 2>/dev/null | awk '/default/ {print $5}')
83+
if [ -n "$dev" ]; then
84+
addr=$(ip addr show dev "$dev" | grep -w inet | awk '{print $2}' | cut -d/ -f1)
85+
if [ -n "$addr" ]; then
86+
echo 1 > /proc/sys/net/ipv4/ip_forward
87+
iptables-legacy -t nat -A POSTROUTING -o "$dev" -j SNAT --to-source "$addr" -p tcp 2>/dev/null || true
88+
iptables-legacy -t nat -A POSTROUTING -o "$dev" -j SNAT --to-source "$addr" -p udp 2>/dev/null || true
89+
fi
90+
fi
91+
92+
update-alternatives --set iptables /usr/sbin/iptables-legacy 2>/dev/null || true
93+
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy 2>/dev/null || true
94+
95+
exec /usr/bin/dockerd --iptables=false --ip6tables=false > /var/log/dockerd.log 2>&1
96+
"""
97+
98+
_temp_dockerd_script = tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False)
99+
_temp_dockerd_script.write(START_DOCKERD_SH)
100+
_temp_dockerd_script.flush()
101+
os.chmod(_temp_dockerd_script.name, 0o755)
102+
_TEMP_DOCKERD_PATH = _temp_dockerd_script.name
103+
71104
runner_image = (
72-
modal.Image.debian_slim()
73-
.apt_install("curl", "git", "ca-certificates", "sudo", "jq", "docker.io")
105+
modal.Image.from_registry("ubuntu:22.04")
106+
.env({"DEBIAN_FRONTEND": "noninteractive"})
107+
.apt_install(
108+
"wget",
109+
"ca-certificates",
110+
"curl",
111+
"net-tools",
112+
"iproute2",
113+
"git",
114+
"sudo",
115+
"jq",
116+
)
117+
.run_commands(
118+
"install -m 0755 -d /etc/apt/keyrings",
119+
"curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
120+
"chmod a+r /etc/apt/keyrings/docker.asc",
121+
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \\"${UBUNTU_CODENAME:-$VERSION_CODENAME}\\") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null',
122+
)
123+
.apt_install(
124+
"docker-ce=5:27.5.0-1~ubuntu.22.04~jammy",
125+
"docker-ce-cli=5:27.5.0-1~ubuntu.22.04~jammy",
126+
"containerd.io",
127+
"docker-buildx-plugin",
128+
"docker-compose-plugin",
129+
)
130+
.run_commands(
131+
"rm -f $(which runc) || true",
132+
"wget -q https://github.com/opencontainers/runc/releases/download/v1.3.0/runc.amd64",
133+
"chmod +x runc.amd64",
134+
"mv runc.amd64 /usr/local/bin/runc",
135+
"update-alternatives --set iptables /usr/sbin/iptables-legacy",
136+
"update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy",
137+
)
74138
.pip_install("fastapi==0.115.0", "httpx==0.27.0")
75139
.run_commands(
76140
"mkdir -p /actions-runner",
77141
f"curl -L https://github.com/actions/runner/releases/download/v{RUNNER_VERSION}/actions-runner-linux-x64-{RUNNER_VERSION}.tar.gz | tar -xz -C /actions-runner",
78142
"/actions-runner/bin/installdependencies.sh",
79143
)
144+
.add_local_file(_TEMP_DOCKERD_PATH, "/start-dockerd.sh", copy=True)
145+
.run_commands("chmod +x /start-dockerd.sh")
80146
)
81147

82148
app = modal.App("modal-github-runner")
@@ -356,7 +422,14 @@ async def github_webhook(request: Request):
356422
logger.info(f"Spawning sandbox for job {job_id}...")
357423

358424
try:
359-
cmd = "dockerd > /dev/null 2>&1 & sleep 2 && cd /actions-runner && export RUNNER_ALLOW_RUNASROOT=1 && ./run.sh --jitconfig $GHA_JIT_CONFIG"
425+
cmd = (
426+
"bash -c '/start-dockerd.sh &'"
427+
" && sleep 5"
428+
" && until docker info > /dev/null 2>&1; do sleep 1; done"
429+
" && cd /actions-runner"
430+
" && export RUNNER_ALLOW_RUNASROOT=1"
431+
" && exec ./run.sh --jitconfig $GHA_JIT_CONFIG"
432+
)
360433

361434
sandbox = modal.Sandbox.create(
362435
"bash",
@@ -366,6 +439,7 @@ async def github_webhook(request: Request):
366439
app=app,
367440
timeout=TIMEOUT_SECONDS,
368441
env={"GHA_JIT_CONFIG": jit_config},
442+
experimental_options={"enable_docker": True},
369443
)
370444

371445
sandbox.set_tags({"job_id": str(job_id)})

0 commit comments

Comments
 (0)