Solutions — Lesson 13
Reference answers for Lesson 13's three homework tasks · Docker healthcheck, Streamable HTTP probe, image-size experiment
Each solution below shows the worked answer for one homework task, followed by a "common mistake" callout that captures the failure modes we saw in earlier drafts. Your exact byte counts, container IDs, and build durations will obviously differ from the samples shown here. Compare against the self-check criteria from the homework before you call a task done.
Solution to Task 1 — Docker healthcheck for pkb
The healthcheck lives on the pkb service and is consumed by
caddy's depends_on clause. Because the
node:20-alpine runtime image does not ship curl,
the probe uses Node's built-in fetch (available since Node 18)
to avoid adding a new package to the image just for a one-line health probe.
services:
pkb:
build: .
environment:
- PKB_TRANSPORT=http
- PORT=8080
expose:
- "8080"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/healthz').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 2s
retries: 3
start_period: 5s
restart: unless-stopped
caddy:
image: caddy:2-alpine
depends_on:
pkb:
condition: service_healthy
ports:
- "443:443"
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
Self-check evidence on a successful run:
$ docker compose up -d
[+] Running 3/3
- Container pkb-pkb-1 Healthy 12.4s
- Container pkb-caddy-1 Started 12.6s
...
$ docker inspect pkb-pkb-1 --format='{{.State.Health.Status}}'
healthy
Reading this solution:
start_period: 5sis the grace window. Without it, the first probe fires immediately after the container starts and counts as a failure if the Node process is still bootstrapping the Express router. Five seconds is enough headroom for cold imports (Zod, pino, the SDK) without delaying real failure detection by much.condition: service_healthyoncaddy. Plaindepends_on: [pkb]only waits for the container to be running, not healthy. Without the condition, Caddy starts racing againstpkb's Express bind and you get oneconnection refusedin the Caddy logs on every cold start. With the condition, Caddy waits for the firsthealthymark before opening port 443.- The probe uses Node's
fetch, notcurl. The image stays unchanged; no new layer, no new package. The.catcharm matters — an unhandled rejection would still exit non-zero but print a stack trace to the container's stderr on every probe, which floods your log shipper at 6/minute.
Common mistake
Using curl in the healthcheck without confirming
curl is in the image — node:20-alpine
doesn't ship it. The healthcheck reports unhealthy forever
because curl: not found exits 127. Use Node's built-in
fetch (Node 18+) as shown, or wget (which
is on alpine: wget -q --spider
http://localhost:8080/healthz || exit 1). If you really want
curl, add RUN apk add --no-cache curl to the
runtime stage of the Dockerfile and accept the ~3 MB cost.
Solution to Task 2 — Streamable HTTP probe summary
This task is a reading exercise. The 12–20-line summary should cite at least one concrete SDK symbol and ground each claim in something you actually observed in the source. Sample summary:
The Streamable HTTP transport (StreamableHTTPServerTransportin@modelcontextprotocol/sdk/server/streamableHttp.js) collapses SSE's two-route design into a singlePOST /endpoint that uses HTTP chunked transfer-encoding for the server-to-client direction. Where SSE needs a long-livedGET /sseplus a separatePOST /messages?sessionId=...to feed it inbound JSON-RPC messages, Streamable HTTP returns a chunked response from the same POST that delivered the request — request and response share one connection. Edge runtimes (Cloudflare Workers, Vercel Functions, Deno Deploy) prefer this because they enforce short connection lifetimes (typically 30–100 seconds) and limit concurrent open streams per worker; one chunked POST is friendlier to those quotas than an hour-long SSE stream. To migrate PKB-TS from SSE to Streamable HTTP, replace theSSEServerTransportinstantiation in theGET /ssehandler and thePOST /messageshandler with a singlePOST /route that constructs aStreamableHTTPServerTransportper request, callsserver.connect(transport), and lets the SDK drive the chunked response. No tool code changes —tools.tsandschemas.tsstay byte-identical because the transport boundary is the only thing moving.
Reading this summary:
- One endpoint, not two. The biggest practical difference
is the route count. SSE:
GET /sse(open the stream) +POST /messages?sessionId=...(feed it). Streamable HTTP:POST /(request goes up, response chunks come down on the same connection). One route is easier to reverse-proxy, easier to rate-limit, and easier to authenticate — the bearer token only needs to be checked once. - Chunked HTTP, not WebSockets. Streamable HTTP uses
Transfer-Encoding: chunked, which any HTTP/1.1 or HTTP/2 intermediary handles natively. WebSockets need an explicitUpgradehandshake and many edge platforms either rate-limit them harder or don't proxy them at all. The point of "edge runtimes prefer it" is that the wire format is just HTTP. - The migration is a transport-layer edit, not a tool edit.
The
tools.ts,schemas.ts, andlogger.tsfrom L12 stay byte-identical. Onlyserver.tspicks up the new transport import and the new route shape. This is the payoff of the layered design from L12 — the business logic doesn't know what wire format is carrying it.
Common mistake
Summarising Streamable HTTP as "WebSockets" — it is not. WebSockets
are a separate protocol (RFC 6455) with an Upgrade
handshake and a binary framing layer. Streamable HTTP is plain HTTP/1.1
or HTTP/2 with Transfer-Encoding: chunked; nothing
upgrades, nothing frames. Reading the source is the point of this task —
grep streamableHttp.js for res.write and
res.end and you'll see vanilla Node HTTP. If your summary
mentions ws:// or wss://, you didn't read the
source. The other common variant: claiming SSE is "deprecated" —
it isn't; both transports are supported, SSE is just older and more
widely deployed in 2026.
Solution to Task 3 — Image-size reduction experiment
The three Dockerfile variants compared. Real numbers vary by Docker version,
base-image release, and how much of node_modules the build
needs — but the ordering is what matters and it should match
the table below.
Variant 1 — Multi-stage, node:20-alpine runtime (baseline):
# Builder stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "dist/server.js"]
Variant 2 — Single-stage, node:20 only:
FROM node:20
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
EXPOSE 8080
CMD ["node", "dist/server.js"]
Variant 3 — Multi-stage, node:20-bookworm-slim runtime:
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
FROM node:20-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "dist/server.js"]
Build each, then run docker images | grep pkb-ts. Sample results
table (real numbers vary):
$ docker images | grep pkb-ts
pkb-ts-alpine latest a1b2c3d4 2 min ago 95.4 MB
pkb-ts-fullnode latest e5f6a7b8 1 min ago 1.21 GB
pkb-ts-bookworm latest c9d0e1f2 30 sec ago 142 MB
Written up as a markdown table:
| Variant | Final image size | Build time (cold) |
|--------------------------------------------|------------------|-------------------|
| Multi-stage, `node:20-alpine` runtime | ~95 MB | ~45 s |
| Single-stage, `node:20` only | ~1.2 GB | ~30 s |
| Multi-stage, `node:20-bookworm-slim` | ~142 MB | ~50 s |
Reading the results:
- Alpine multi-stage is the smallest, by a lot. The ~95 MB
figure is what you ship to production. ~70 MB of that is the
node:20-alpinebase image itself; the PKB-TS app code plus runtimenode_modulesis the remaining ~25 MB. Most of the delta vs. the single-stage variant is the build-time dependencies (TypeScript,@types/*,tsx) that never ship. - Single-stage is ~13x larger because the whole toolchain ships.
The
node:20base is ~400 MB on its own (Debian-based, full package manager, build tools); addingnode_moduleswith devDependencies and the TypeScript compiler pushes it past 1 GB. This is what "the Dockerfile that just works in dev" looks like when you skip multi-stage — correct, but expensive. Image pulls in CI take 30+ seconds; on the deploy host they tie up disk I/O during the swap. - Bookworm-slim is the safe middle ground. ~30% larger
than alpine but with glibc instead of musl — which matters if you
later add a native module (e.g.,
better-sqlite3,sharp, ornode-rdkafka) that doesn't ship a prebuilt alpine binary. For pure-JS workloads alpine is fine; for "we might add a native dep later" workloads bookworm-slim saves you a re-platforming task in three months.
Common mistake
Measuring with docker images after a partial build
cache hit — sizes are accurate but build times mislead. Use
docker builder prune -af between variants for a fair cold-build
comparison. A second common variant: forgetting --omit=dev
in the runtime npm ci step, which silently doubles the
runtime image because TypeScript and @types/* ride along
into production. Always pair --omit=dev with npm cache
clean --force on the same layer so the cache directory isn't
written to disk before deletion.
What you learned
- Healthchecks are the contract between containers.
depends_on: condition: service_healthyonly works if there is a healthcheck to wait on. The healthcheck is the difference between "Caddy starts at the same time aspkband crashes once" and "Caddy waits forpkbto be ready, then opens 443 cleanly". Same principle scales: in Kubernetes the equivalent is a readiness probe; insystemdit'sExecStartPost+Type=notify. The pattern is universal even when the syntax changes. - Streamable HTTP is the next transport, but SSE is the current
one. Reading the SDK source for Streamable HTTP before you need
it means the migration in 6–12 months will be a transport-layer
swap (one file edit), not a re-architecture. The fact that
tools.tsdoesn't change between transports is the L12 investment paying off — layered design lets you swap the wire format without touching the business logic. - Multi-stage builds are non-negotiable. The single-stage
variant works perfectly — the bits are byte-for-byte the same code
— it just ships 10x the bytes. In a CI pipeline that pulls the image
on every deploy, that's 10x the network egress, 10x the registry storage,
and 10x the cold-start time. The two-line cost (add
AS builderandCOPY --from=builder) is one of the highest ROI patterns in container engineering. - Image size is a security surface too. The alpine
runtime ships ~50 binaries; the full
node:20image ships ~600. Every binary is a potential CVE you have to patch. "Smallest image that runs the workload" is a security posture as much as a performance one — CVE scanners (Trivy, Grype) flag fewer findings on alpine runtimes simply because there's less to find.
Lesson 14 makes the server safe to expose: bearer-token auth identifies
the caller, per-tenant note isolation prevents cross-user leaks, and a
token-bucket rate limiter caps abuse. The Dockerfile and Caddyfile from
this lesson stay untouched — only server.ts and the new
auth.ts / store.ts / rateLimit.ts
modules pick up the multi-tenant logic.