Architecture¶
devcontainer-builder is a single Node.js HTTP service
(service/src/) with three routes and no database — every real
decision it makes is derived fresh from the incoming request plus its
own startup configuration (see Configuration).
Request lifecycle: POST /build¶
flowchart TD
A[POST /build] --> B{Valid JSON + shape?}
B -- no --> B1[400]
B -- yes --> C[parseGitUrl]
C -- unparseable --> C1[400]
C -- ok --> D[resolveGitCredential]
D --> E[git clone --branch --single-branch --depth 1]
E -- fails --> E1[500: real git error text]
E -- ok --> F[resolve registry / name / tag]
F -- no registry resolved --> F1[400]
F -- ok --> G[ensureRemoteBuilder]
G --> H[devcontainer build --push]
H -- fails --> H1[500: real error text]
H -- ok --> I[200: pushed image reference]
- Shape validation (
server.ts'sisValidBuildRequest) — a request missingrepository, or with a wrong-typed optional field, is rejected before anything real happens. See the HTTP API reference for the exact accepted shape and every status code. - URL parsing (
build.ts'sparseGitUrl) — acceptshttps://host/path,ssh://[user@]host[:port]/path, and git's own SCP-style shorthand[user@]host:path. Anything else is a real parse failure, not a guess. - Credential resolution (
resolveGitCredential) — request-levelgitCredentials(always HTTPS) win; otherwise the server's own per-host configured entries apply, which may be HTTPS- or SSH-keyed. Whichever resolves determines the clone URL's protocol — a caller can pass anhttps://URL for a host the server only has an SSH credential for, and the actual clone still happens over SSH. Full rationale in Credential handling. - The clone itself — always
--single-branch --depth 1, onbranch(defaultmain) if given. A failure here (bad branch, no access, host unreachable) surfaces as a real 500 with the realgiterror text — devcontainer-builder never invents its own wording for an underlying tool's failure. - Image resolution (
resolveRegistry,deriveImageName) —image.registry/.name/.tagfrom the request win field-by-field; anything omitted is derived:namefrom the repo path's last segment (.gitstripped),tagfromsha-<short HEAD sha>,registryfrom the server's configured mapping rules (hostMatch/pathPrefix, first match wins). No resolvable registry is a 400, not a 500 — see 0003: Registry resolution via mapping rules. - The build (
ensureRemoteBuilder+devcontainer build --push) —docker buildx create --driver remote <endpoint>against the configuredBUILDKIT_ENDPOINT, reused by name across requests (docker buildx inspectfirst, create only if missing); then the real@devcontainers/clibuilds and pushes. There is no localdockerdanywhere in this path — see 0001: Remote BuildKit builder for why. - Response —
200 {"image": "<registry>/<name>:<tag>"}on success. Any thrownBuildRequestError(a user-fixable problem discovered mid-build, e.g. no registry resolved) maps to 400; everything else — a real clone failure, a real build failure — maps to 500 witherr.messageas the real underlying text.
Health endpoints¶
GET /health/startup— always200 {"status":"started"}once the process is up. KubernetesstartupProbe; identical check to/health/livetoday (no separate async startup phase exists to distinguish them yet), kept as its own route so the two probes can be tuned independently — see API reference.GET /health/live— always200 {"status":"ok"}once the process is up. Used as the liveness probe; it deliberately checks nothing beyond "the HTTP server is answering."GET /health/ready—200 {"status":"ready"}ifBUILDKIT_ENDPOINTis configured,503 {"status":"not ready","reason":"BUILDKIT_ENDPOINT not configured"}otherwise. This is a presence check, not a live connectivity probe against BuildKit itself — a configured-but-unreachable endpoint still reports ready (and fails at build time instead, with a real error).
Observability¶
- Structured, event-oriented logs — every log line is one JSON object
(pino, via a standalone instance in
logger.tswired into Fastify through itsloggerInstanceoption, not the inlinelogger:options object an earlier version of this service used), with a real stringlevel("info","error", ...) rather than pino's own default numeric level, so lines are directly filterable in whatever log sink scrapes container stdout (e.g. Grafana Loki, whosedetected_levelheuristic recognizes this exact field name) without a level-number lookup table. Every business-logic log call carries an expliciteventname (image.lookup.started/.completed/.failed,image.delete.*,devcontainer.lookup.*,build.*) plus domain fields (image.registry,image.name,image.tag, ...) rather than a free-text sentence, and every request gets exactly onehttp.request.completedaccess-log-style event (anonResponsehook inserver.ts, replacing Fastify's own default per-request logging viadisableRequestLogging) whosehttp.routeis always the matched route template (/image), never the raw querystring — the query values themselves only ever appear as their own named domain fields.service.name/service.version/deployment.environment.nameare baked into every line (logger.ts'sbaseoption), sourced fromconfig.ts'sserviceName/environmentfields (SERVICE_NAME/DEPLOYMENT_ENVIRONMENT— see Configuration) and this build's ownpackage.jsonversion. GET /metrics— Prometheus text-format metrics (prom-client), Node.js process/runtime defaults plus real build/image-check/image-delete/ devcontainer-lookup counters and a build-duration histogram — see API reference.- Error tracking — optional
Sentry
SDK integration (
@sentry/node'sfastifyIntegration+setupFastifyErrorHandler), entirely off unlessSENTRY_DSN(or the settings file'ssentry.dsn) is set. GlitchTip works too, being Sentry-protocol-compatible — pointSENTRY_DSNat it the same way. Real unexpected failures (500s, uncaught errors) get captured; documented 400s don't. - Distributed tracing, correlated with logs — optional
OpenTelemetry auto-instrumentation
(
@opentelemetry/auto-instrumentations-node, covering the underlyingnode:httpserver every request goes through), entirely off unlessOTEL_EXPORTER_OTLP_ENDPOINT(or the traces-specificOTEL_EXPORTER_OTLP_TRACES_ENDPOINT) is set — the same standard env vars any OTel SDK reads, so this composes with a Tempo/OTel Collector endpoint already configured elsewhere in the cluster, not an app-specific flag. Bootstrapped fromtracing.ts, imported as the very first thingindex.tsdoes — auto-instrumentation works by monkey-patching modules (node:http, etc.) at import time, so it has to run before anything else (including Fastify itself) ever imports them. The bundled@opentelemetry/instrumentation-pino(already part ofauto-instrumentations-node) injects the active span's context into every log line emitted during that span —tracing.tsoverrides its default snake_case key names (trace_id/span_id) totrace.id/span.id, matching this app's dotted field-naming convention elsewhere. This is a distinct identifier from Fastify's own per-requestreqId(surfaced in every log line automatically as the request-scoped child logger's own binding) —reqIdidentifies this service's one HTTP request;trace.id/span.ididentify the distributed operation a request might be one leg of, present only while tracing is enabled. GET /devcontainer— a built image'sdevcontainer.metadatalabel, read straight from the registry and merged the way the Dev Containers CLI merges it, plus ready-to-run lifecycle scripts and merged VS Code customizations — see 0011.GET /config— read-only, non-sensitive view of the service's own loaded configuration, credential material always redacted — see API reference.
Startup and shutdown¶
index.ts is the real entrypoint (see the
Dockerfile's
ENTRYPOINT). It registers uncaughtException/unhandledRejection
handlers before dynamically importing server.ts — startup
misconfiguration (a bad settings file, an unrecognized CLI flag, an
invalid SSH_HOST_KEY_POLICY) throws synchronously during that import,
and only handlers registered ahead of it get a chance to format the
crash as one structured JSON line
({"level":"fatal","message":...,"stack":...}) to stderr, instead of
Node's own default raw stack trace. A static import "./server.js"
at the top of index.ts would throw at the same uncatchable point — the
dynamic await import(...) at the bottom is what makes the handlers
already-registered by the time it runs.
The container runs as PID 1 with no init process, so the kernel's
default signal disposition doesn't apply — an unhandled SIGTERM would
be silently ignored rather than terminating the process, leaving a pod
to sit through its full terminationGracePeriodSeconds (30s default) on
every rollout or scale-down before kubelet resorts to SIGKILL.
server.ts installs an explicit process.on("SIGTERM", () =>
process.exit(0)) for exactly this reason.
Per-request isolation¶
Nothing about handling one /build request is shared with another
beyond the process's own startup configuration:
- Each request gets its own scratch clone directory
(
mkdtemp(devcontainer-build-*)), removed in afinallyblock whether the build succeeds or fails. - Git and registry credentials get their own scratch files per request
(a
.netrc, an SSH key +known_hosts, aDOCKER_CONFIGoverlay) — see Credential handling for why, and why none of it ever touches argv, an env var, or the clone URL itself. - The remote buildx builder (
ensureRemoteBuilder) is the one thing genuinely shared and reused across requests, by design — recreating it per request would mean re-registering the same named builder with the same remote endpoint on every single call, for no benefit.