Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 45 additions & 50 deletions claude-code/.devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# docker build --target sandbox -t claude-code:sandbox .
# ═══════════════════════════════════════════════════════════════════════════════

# Global so Renovate bumps one line; re-declared as the last step of each target.
# Global so Renovate bumps one line; re-declared in the shared stage after Chromium.
# An ARG joins the cache key of every later RUN, so declaring it earlier would
# rebuild Chromium on each Claude Code release.
# renovate: datasource=npm depName=@anthropic-ai/claude-code
Expand Down Expand Up @@ -205,28 +205,33 @@ USER node
# ── gh-stack (native stacked PRs) ────────────────────────────────────────────
# Baked as node into ~/.local/share/gh/extensions, which is on the image layer (only
# ~/.config/gh is a volume); a volume over ~/.local/share/gh would shadow it. See #160.
# ~/.cache/gh is gh's HTTP cache — the install leaves a 25 MB copy of the download.
# renovate: datasource=github-releases depName=github/gh-stack
ARG GH_STACK_VERSION=0.2.0
RUN gh extension install github/gh-stack --pin "v${GH_STACK_VERSION}"
RUN gh extension install github/gh-stack --pin "v${GH_STACK_VERSION}" \
&& rm -rf /home/node/.cache/gh

# ─── DEFAULT — full dev environment ───────────────────────────────────────────
FROM base AS default
# ─── SHARED — Chromium + Claude Code, common to both targets ─────────────────
# Everything heavy lives here so default and sandbox share these layers: pulling
# both images downloads Chromium and Claude Code once. Each target adds only a
# small layer on top (agent-browser / firewall packages).
FROM base AS shared

# Passwordless sudo for node user — standard practice for devcontainer images.
# Required by the canonical "sudo chown" pattern for named volume ownership.
# Needed for the canonical "sudo chown" pattern for named volume ownership, and
# in the sandbox for "sudo /usr/local/bin/init-firewall.sh". Sandbox security
# comes from the network firewall, not sudo restrictions.
# See: https://code.visualstudio.com/remote/advancedcontainers/improve-performance
USER root
RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node-nopasswd \
&& chmod 0440 /etc/sudoers.d/node-nopasswd
USER node

# System Chromium + fonts for headless browser testing.
# - chromium: used by Playwright and agent-browser via
# PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH (see .claude/rules/dockerfile.md).
# - fonts-freefont-ttf: baseline fonts for headless rendering.
# Mirrored into the sandbox stage too — the firewall blocks deb.debian.org,
# so chromium cannot be added at runtime.
USER root
# Baked in for both targets — the sandbox firewall blocks deb.debian.org, so
# chromium cannot be added at runtime. Required for the Playwright MCP plugin.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
Expand All @@ -238,31 +243,42 @@ USER node
# Avoids version coupling between @playwright/mcp (alpha playwright-core builds)
# and cached browser binaries. Projects using @playwright/test must point their
# playwright.config.ts at process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
# AGENT_BROWSER_EXECUTABLE_PATH points agent-browser at the same binary —
# agent-browser is a Rust CLI with its own env-var convention, NOT Playwright,
# so PLAYWRIGHT_* vars are silently ignored. Without this, agent-browser would
# auto-detect or attempt a Chrome-for-Testing download on first use.
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \
AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium

# agent-browser: headless browser automation for AI agents.
# Uses the apt-installed chromium above (via AGENT_BROWSER_EXECUTABLE_PATH).
# Installed as the node user (see the Claude Code install below for rationale).
# Version pinned and kept up to date by Renovate (see .github/renovate.json5).
# renovate: datasource=npm depName=agent-browser
ARG AGENT_BROWSER_VERSION=0.38.2
RUN npm install -g agent-browser@${AGENT_BROWSER_VERSION}
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium

# ── Claude Code CLI — last: it changes most often (see the global ARG) ───────
# ── Claude Code CLI — after Chromium: it changes most often (see global ARG) ─
# npm, not the native installer: the installer rate-limits (429) under parallel
# Docker builds. npm stays supported "for compatibility reasons" — this is that
# reason. https://code.claude.com/docs/en/getting-started#install-with-npm
#
# Installed as node so files land node-owned; a later chown -R would duplicate
# every file on overlayfs, adding hundreds of MB.
#
# The npm cache goes to a BuildKit cache mount, not ~/.npm: left in the image it
# keeps a second, compressed copy of every package (~160 MB across both installs).
ARG CLAUDE_CODE_VERSION
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
RUN --mount=type=cache,target=/tmp/npm-cache,uid=1000,gid=1000 \
npm install -g --cache /tmp/npm-cache @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}

# ─── DEFAULT — full dev environment ───────────────────────────────────────────
FROM shared AS default

# agent-browser is a Rust CLI with its own env-var convention, NOT Playwright,
# so PLAYWRIGHT_* vars are silently ignored. Without this, agent-browser would
# auto-detect or attempt a Chrome-for-Testing download on first use.
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium

# agent-browser: headless browser automation for AI agents.
# Version pinned and kept up to date by Renovate (see .github/renovate.json5).
# The package ships prebuilt binaries for 7 platforms (~113 MB); the global
# `agent-browser` link targets agent-browser-linux-<arch> directly, so the other
# six are deleted. Rebuilt on each Claude Code bump (ARG above) — a small layer.
# renovate: datasource=npm depName=agent-browser
ARG AGENT_BROWSER_VERSION=0.38.2
RUN --mount=type=cache,target=/tmp/npm-cache,uid=1000,gid=1000 \
npm install -g --cache /tmp/npm-cache agent-browser@${AGENT_BROWSER_VERSION} \
&& find /usr/local/share/npm-global/lib/node_modules/agent-browser/bin \
-name 'agent-browser-*' ! -name "agent-browser-linux-$(node -p process.arch)" -delete

LABEL org.opencontainers.image.source="https://github.com/gatezh/devcontainers" \
org.opencontainers.image.description="Claude Code devcontainer — full dev environment with agent-browser, Playwright, and passwordless sudo" \
Expand All @@ -273,12 +289,11 @@ LABEL org.opencontainers.image.source="https://github.com/gatezh/devcontainers"
CMD ["sleep", "infinity"]

# ─── SANDBOX — network-restricted environment ─────────────────────────────────
FROM base AS sandbox
FROM shared AS sandbox

# Firewall packages (not needed in default target).
# Chromium + fonts are also baked in here — the sandbox firewall blocks
# deb.debian.org, so they cannot be added at runtime. Required for the
# Playwright MCP plugin to launch a browser.
# The firewall script is NOT baked into the image — each project mounts its
# own script via bind mount in devcontainer.json to customize the domain allowlist.
USER root
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
Expand All @@ -287,29 +302,9 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
ipset \
iproute2 \
dnsutils \
aggregate \
chromium \
fonts-freefont-ttf

# Use the apt-installed chromium instead of Playwright-managed browsers
# (matches the default stage; see comment above the default chromium block).
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium

# Passwordless sudo for node user — needed for:
# - "sudo chown" on named volumes (node_modules isolation)
# - "sudo /usr/local/bin/init-firewall.sh" (firewall setup)
# Sandbox security comes from the network firewall, not sudo restrictions.
# The firewall script is NOT baked into the image — each project mounts its
# own script via bind mount in devcontainer.json to customize the domain allowlist.
RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node-nopasswd \
&& chmod 0440 /etc/sudoers.d/node-nopasswd
aggregate
USER node

# ── Claude Code CLI — last (see the global ARG and the default target) ───────
ARG CLAUDE_CODE_VERSION
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}

LABEL org.opencontainers.image.source="https://github.com/gatezh/devcontainers" \
org.opencontainers.image.description="Claude Code devcontainer — network-restricted sandbox with firewall packages" \
org.opencontainers.image.licenses="MIT" \
Expand Down
194 changes: 194 additions & 0 deletions docs/plans/2026-10-06-claude-code-image-size-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# claude-code Image Size — Roadmap

> Living roadmap. Pick up the next unchecked tier; update the status and the
> measurements table as each one lands. Move to `completed/` when Tier 3 ships
> or the remaining tiers are explicitly dropped.

**Goal:** Shrink `ghcr.io/gatezh/devcontainers/claude-code` (and `-sandbox`) back
toward the lean image this repo started with, without losing tools that are
actually used.

**Status:**

- [x] Analysis: baseline measured (2026-10-06)
- [x] **Tier 1:** remove waste and share layers (branch `perf/claude-code-image-size`, 2026-10-06)
- [ ] **Tier 2:** strip the Mesa/LLVM graphics stack from Chromium (optional, hacky)
- [ ] **Tier 3:** take the browser out of the core image (the architectural change)

---

## Baseline (2026-10-06, `latest`, linux/amd64, Claude Code 2.1.291)

| Image | On disk | Download (compressed) |
|---|---|---|
| `claude-code` (default) | 2.82 GB | 812 MB |
| `claude-code-sandbox` | ~2.6 GB | 715 MB |

### Where the bytes go (default target, on disk)

| Component | Size | Notes |
|---|---|---|
| Chromium + deps | 731 MB | `chromium` 320 MB, `chromium-common` 66 MB, `libllvm19` 127 MB, `mesa-libgallium` 42 MB, `libz3-4` 27 MB, GTK/icons. LLVM/Mesa/z3 come in via `chromium → libgbm1 → mesa-libgallium`. |
| Claude Code (npm) | 366 MB | 239 MB native binary (`claude.exe`) **+ 107 MB npm cache** (`~/.npm/_cacache`) left in the layer |
| node:24-trixie-slim | 244 MB | Debian 88 MB + Node 156 MB |
| agent-browser (npm) | 174 MB | ships **7 prebuilt binaries** (darwin×2, win32, linux-musl×2, linux×2) — only `linux-<arch>` (18 MB) is used **+ 51 MB npm cache** |
| mise | 153 MB | the upstream binary really is this size now (stripping saves only 15 MB); installed unpinned via `curl mise.run` |
| apt base | 147 MB | git (+ ~50 MB perl), openssh-client, zsh, curl, jq, sudo, fzf |
| gh (.deb) + gh-stack | 94 MB | gh 43 MB; gh-stack 26 MB **+ 25 MB `~/.cache/gh`** (gh's HTTP cache of the download) |
| oh-my-zsh + p10k, rtk, ralphex | 45 MB | |

### Structural problems

1. **The daily-churn layer is bloated.** Claude Code is bumped by Renovate
almost daily, and its layer was 214 MB compressed — half of it the npm cache.
Every consumer re-pulls it on every bump.
2. **default and sandbox share nothing heavy.** Each target installed Chromium
and Claude Code in its own layers, so anyone using both images (and the
registry) carried ~490 MB compressed twice.

### How to re-measure

```bash
IMG=ghcr.io/gatezh/devcontainers/claude-code:latest
docker history --format '{{.Size}}\t{{printf "%.100s" .CreatedBy}}' "$IMG" | grep -v '^0B'
docker run --rm --entrypoint sh -u root "$IMG" -c \
'du -xsh /usr/lib/chromium /usr/local/share/npm-global/lib/node_modules/* /home/node/.[a-z]* /usr/local/bin/*'
docker run --rm --entrypoint sh -u root "$IMG" -c \
"dpkg-query -Wf '\${Installed-Size}\t\${Package}\n' | sort -rn | head -20"
# compressed (download) size per layer — note: in zsh write "${R}:latest", "$R:latest" triggers the :l modifier
R=ghcr.io/gatezh/devcontainers/claude-code
DG=$(docker buildx imagetools inspect --raw "${R}:latest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest')
docker buildx imagetools inspect --raw "${R}@${DG}" | jq '[.layers[].size] | add / 1048576'
```

---

## Tier 1 — Remove waste, share layers (safe)

No behavior change; every tool stays. Changes in `claude-code/.devcontainer/Dockerfile`:

- [x] `npm install -g` uses `--cache /tmp/npm-cache` on a BuildKit cache mount, so
no npm cache lands in the image (−158 MB, already-compressed data, so the
download shrinks by nearly the same amount).
- [x] Delete agent-browser's six non-matching platform binaries in the install
step (−95 MB). Safe: npm's global `agent-browser` link points straight at
`bin/agent-browser-linux-<arch>`.
- [x] `rm -rf ~/.cache/gh` in the gh-stack install step (−25 MB).
- [x] New `shared` stage (sudoers, Chromium, Claude Code) that both targets build
`FROM`; each target adds only a small layer (agent-browser / firewall packages).
Trade-off: those small layers now rebuild on each Claude Code bump.

**Expected:** default download ~812 → ~610 MB; the Claude Code layer halves
(~214 → ~107 MB compressed per bump); default + sandbox share all heavy layers.

**Result (local amd64 build, 2026-10-06):** 2.82 → 2.32 GB on disk; download
852 → 633 MB (−26%, measured as Docker "content size" for both, which reads
~40 MB higher than the registry's 812 MB). Claude Code layer 214 → 108 MB
compressed; agent-browser layer 174 → 19 MB; default and sandbox share 22 of
23 layers (sandbox adds only a 17.6 MB firewall layer). CI verify commands pass
for both targets; also checked: headless Chromium renders, `agent-browser`
opens and snapshots a page, runtime `npx` works and creates a node-owned `~/.npm`.

---

## Tier 2 — Strip the graphics stack from Chromium (optional)

Headless Chromium renders in software through its bundled SwiftShader
(`/usr/lib/chromium/libvk_swiftshader.so`), so these are dead weight at runtime:

- `libllvm19` (127 MB), `mesa-libgallium` (42 MB), `libz3-4` (27 MB)
- `/usr/lib/chromium/libVkLayer_khronos_validation.so` (23 MB, a Vulkan debug layer)

**Catch:** `libgbm1` hard-depends on `mesa-libgallium`, so removing them with
`dpkg --force-depends` / `rm` leaves apt with broken dependencies. Users have
sudo; a later `apt-get install` will try to "fix" it. Options to evaluate:

- `dpkg --path-exclude` rules (in `/etc/dpkg/dpkg.cfg.d/`) for the library
files before installing Chromium: packages stay "installed", files never land.
- An equivs dummy package instead of the real one.

**Gate:** only ship with a CI smoke test that actually launches the browser
(`chromium --headless --no-sandbox --dump-dom about:blank`, plus an
`agent-browser` open/snapshot) on **both** amd64 and arm64.

**Expected:** −~220 MB on disk, ~−60 MB compressed.

---

## Tier 3 — Take the browser out of the core image (architecture)

Chromium + agent-browser are ~0.9 GB of the image, and many projects never
drive a browser. A core image without them is roughly:

| Core content | Size |
|---|---|
| node:24-trixie-slim | 244 MB |
| apt base (git, zsh, ssh, …) | 147 MB |
| mise | 153 MB |
| Claude Code | 239 MB |
| gh + gh-stack, oh-my-zsh, rtk, ralphex | ~115 MB |
| **Total** | **~0.9 GB on disk, ~330 MB download** |

### Option 3a — layered `browser` variant (recommended first)

- `claude-code` = core (no Chromium, no agent-browser)
- `claude-code-browser` = `FROM` core + Chromium + agent-browser
- sandbox: same split (`claude-code-sandbox` / `claude-code-sandbox-browser`),
or sandbox always gets the browser (firewall blocks runtime apt installs).

Simple, no runtime changes; projects pick the variant in their compose file.
The browser variant shares every core layer.

**Open questions:**
- Which consumer projects actually use the Playwright MCP plugin / agent-browser?
(They switch image tag; the others get the slim one for free.)
- Is the Playwright plugin wired by `init-plugins.sh` unconditionally? Then it
needs to skip it (and `patch-playwright-mcp`) when `/usr/bin/chromium` is absent.
- CI verify commands and image tags per variant; README + template updates.
- Tag naming per `.claude/CLAUDE.md` conventions.

### Option 3b — browser as a sidecar container (experiment later)

Run Chromium (or Microsoft's Playwright MCP image, serving MCP over HTTP) as a
second Compose service next to the dev container:

- `network_mode: "service:<dev>"` puts it in the dev container's network
namespace: `localhost` dev servers keep working, and in the sandbox the
iptables firewall should apply to the browser too. **Unverified, must test.**
- Playwright MCP / agent-browser connect over CDP (`--cdp-endpoint` /
`--cdp`), or Claude connects to the MCP server's HTTP endpoint directly.
- Could retire `patch-playwright-mcp` and its SessionStart hook (#87, #98).
- Sidecar image changes rarely, so it's pulled once — not on every Claude bump.

Costs: more moving parts in every consumer's compose file; needs a design pass
on the sandbox network model before adopting.

---

## Considered and not recommended (for now)

- **Back to Alpine.** Saves maybe ~150 MB (perl, glibc userland). The heavy
items — Claude Code, mise, Chromium — are the same size on any distro, and
rtk's arm64 build is glibc-only. Not worth the musl risk.
- **Drop Node.** Playwright MCP runs via `npx`; mise-managed Node would shadow
`npx` (see Dockerfile). The node base layer is shared and rarely changes.
- **Install Claude Code at container start** (native installer into a volume,
self-updating). Removes 239 MB and most rebuilds, but reverses the pinned,
Renovate-driven versions from #116, and the sandbox firewall would need the
download host allowlisted. Revisit only if per-bump pulls are still painful
after Tier 1.

## Small follow-ups noticed

- mise is installed unpinned (`curl https://mise.run | sh`): non-reproducible,
and only refreshed when the layer cache busts. Move it to a pinned
GitHub-release download stage under Renovate, like rtk/ralphex.

---

## Measurements log

| Date | Change | default disk | default download | sandbox download | Claude layer (compressed) |
|---|---|---|---|---|---|
| 2026-10-06 | baseline (`latest`) | 2.82 GB | 812 MB (852 local) | 715 MB | 214 MB |
| 2026-10-06 | Tier 1 (local build, before publish) | 2.32 GB | 633 MB local | 631 MB local, 18 MB on top of default | 108 MB |
Loading