Container Runtimes · 2026-07-11 · Container Runtime arc
MCP Tools for Container Runtimes: The Full Lifecycle Trap, Health Probe Depth, and Exec Process Semantics Across Docker Engine API, containerd, Podman, Amazon ECS, and Google Cloud Run
When you build your first MCP tool that manages containers, three problems appear in a predictable sequence. You call what looks like a "run" endpoint, the API confirms the call succeeded, and you report the container as started — not realizing that "created" and "started" are separate operations and the container has not yet executed any process. You check the container's status field, it reads running, and you report it healthy — not realizing the process inside may be crash-looping or behind a health check grace period. You implement an exec command by connecting to the container's main process — not realizing that exec creates an independent new process in the container's namespace, that the new process must be cleaned up separately, and that the stream format for exec output has a binary multiplexing header you need to strip. By the second container runtime you encounter you recognize all three problems wearing a different API's uniform. This synthesis covers five runtimes — Docker Engine API, containerd gRPC, Podman REST, Amazon ECS, and Google Cloud Run — through the three structural patterns they all share, so you can implement them correctly before they surface as production failures in your MCP tools.
TL;DR
Five container runtimes, three shared patterns. (1) The full lifecycle trap: no runtime has a single atomic "run" operation — each exposes multiple distinct steps between image acquisition and a running process; Docker requires separate POST /containers/create then POST /containers/{id}/start (two calls, not one); containerd requires five steps (image.pull → snapshots.prepare → containers.create → tasks.create → task.start); Podman pods require pod creation before containers can be added, then pod start; ECS has an 8-state machine (PROVISIONING → PENDING → ACTIVATING → RUNNING → DEACTIVATING → STOPPING → DEPROVISIONING → STOPPED) with only RUNNING and STOPPED as stable states; Cloud Run deploy creates an immutable revision and returns a long-running operation (LRO) that must be polled until complete, then the revision's Ready condition array must be checked separately from the LRO completion. Failures at each step are distinct, require different recovery actions, and leave different partial state that must be cleaned up. (2) Health probe depth: every runtime has a status string that reads "running" when the container process is alive — none of those strings tell you whether the application inside is functional; Docker's three-layer model (State.Status → State.Health → external probe) is the most explicit, but requires a HEALTHCHECK directive in the image for the middle layer; containerd has no built-in health check equivalent at all; Podman adds a pod-level Degraded state (some containers stopped, others running) that has no per-container equivalent; ECS's service health requires checking both runningCount === desiredCount AND the deployments array AND the load balancer target group health — each gap is independently reachable; Cloud Run's servingStatus SERVING does not mean the latest revision is Ready, and a Ready revision does not mean instances are warm (scale-to-zero cold starts add 1-5s of latency that AliveMCP probes will hit). (3) Exec process semantics: all five runtimes support running a new process inside a running container's namespace, but the lifecycle of that new process is independent of the container's main process; Docker exec requires a two-call sequence (POST /exec + POST /exec/{id}/start) and multiplexes stdout/stderr using an 8-byte binary frame header when Tty is false; containerd's task.exec() creates an exec process that must be explicitly deleted after exit or it accumulates metadata in the containerd store; Podman exec shares the pod's network namespace; ECS Exec requires the task to have enableExecuteCommand: true and the IAM role to have SSM permissions — it works via an SSM WebSocket session, not a direct connection; Cloud Run has no exec API (SSH debugging exists but is not API-programmable). Wire AliveMCP to an external HTTP probe endpoint that survives runtime process restarts — neither a status string nor an exec check can substitute for an independent external probe.
Pattern 1: The full lifecycle trap — multiple steps before "running" means "running"
Every container runtime you encounter in this arc was built to give callers explicit control over each stage of container creation. The cost of that control is that there is no single "run a container" API call — each step is separate, each step can fail independently, and each step leaves different partial state if it fails. MCP tools that treat any single API success as "the container is now running" will silently leave resources in intermediate states on failure, produce misleading status reports, and miss the recovery step at each failure point.
Docker Engine API: create, start, and wait are three distinct operations
The Docker CLI's docker run command chains at least three API operations. At the Docker Engine REST API level, these are always separate:
POST /containers/create allocates the container: validates the image, reserves the configuration (environment, port bindings, volume mounts, resource limits), assigns a container ID, and creates the writable container layer. The container exists after this call but no process is running. The response includes a Warnings array — always log it. Deprecated flags and missing capabilities appear here as warnings, not errors, so a container can be successfully created with a broken configuration.
POST /containers/{id}/start launches the main process (PID 1 inside the container). After this call returns 204, the container has transitioned to running state — but the application inside may still be initializing. A web server container in running state is not necessarily accepting HTTP connections yet. Port binding conflicts surface here, not at create time — a container with a conflicting host port will create successfully and fail only at start.
POST /containers/{id}/wait?condition=not-running blocks until the container exits. For ephemeral workloads (test runners, migrations, one-shot data processors), use this endpoint rather than polling GET /containers/{id}/json in a loop. The response body contains {"StatusCode": 0} — a non-zero exit code means the process failed.
The separation matters for error handling. If start fails, the container exists in created state and must be explicitly removed with DELETE /containers/{id}. MCP tools that don't handle start failures leave created containers accumulating. Each API step's failure requires a different cleanup action — pull failure (nothing to clean), create failure (nothing to clean), start failure (remove the created container).
containerd gRPC: five steps across five separate resource types
containerd exposes each lifecycle step as a separate gRPC call against a different resource type: image service, snapshotter service, container service, task service. The full sequence from image to running process:
Step 1: images.pull() — downloads the image blobs and registers the manifest. Nothing is runnable yet.
Step 2: snapshotService.prepare(snapshotKey, parentDigest) — creates the writable copy-on-write layer (the container's root filesystem). The snapshot key must be globally unique within the namespace. The container and task do not exist yet.
Step 3: containers.create() — registers metadata: associates the snapshot, the OCI runtime spec, and labels. This is metadata only — no process exists yet.
Step 4: tasks.create(container, io) — configures the process's I/O streams and creates the task record. The process still has not started.
Step 5: task.start() — invokes the containerd shim, which runs the OCI runtime (runc, gVisor, etc.) to start the init process. The task's init process is now running.
The cleanup order on failure is the exact reverse of creation: task → container → snapshot. A failure at task.start() leaves a container record and a snapshot. A failure at tasks.create() leaves a container record and a snapshot. A failure at containers.create() leaves only a snapshot. Each step's cleanup path is different. MCP tools that don't implement per-step cleanup will accumulate orphaned snapshots that hold disk space but have no container record associated with them.
Podman REST API: pod lifecycle is separate from container lifecycle
Podman's most important lifecycle distinction from Docker is the pod: a group of containers that share a network namespace. Pods are a first-class resource with their own lifecycle, separate from the containers inside them.
A pod must be created before containers are added to it: POST /libpod/pods/create creates the pod and the infra/pause container (which holds the shared network namespace alive). Containers are then created with a pod reference: POST /libpod/containers/create with "pod": "mcp-workload". Only then can the pod be started: POST /libpod/pods/{name}/start, which starts the infra container and all member containers.
Port mappings belong to the pod, not to individual containers. An MCP tool that sets port mappings on the container create call (not the pod create call) in Podman will have the ports silently not bound, because in pod mode all ports are managed at the infra container level.
The second socket-path trap: rootless Podman (the default on RHEL/Fedora/CentOS systems) uses a per-user socket at /run/user/{uid}/podman/podman.sock, not /var/run/docker.sock. An MCP tool ported from Docker that hardcodes the Docker socket path will silently fail to connect. The detection pattern is: try Docker socket, then rootful Podman socket (/run/podman/podman.sock), then rootless Podman socket (/run/user/{uid}/podman/podman.sock). Include the detected engine type and mode in every health response so agents know which runtime they are talking to.
Amazon ECS: an 8-state machine with two terminal states
ECS task lifecycle is the most complex in this arc: eight distinct states, only two of which are stable. A common MCP tool error is treating RUNNING as the only terminal state — but RUNNING is not terminal (the task will eventually stop). Another common error is polling until the task enters RUNNING and reporting success without checking whether the task will immediately stop due to an application startup failure.
| State | Meaning | Action on stuck |
|---|---|---|
PROVISIONING | Acquiring capacity (ENI attachment for Fargate, finding EC2 instance) | >5 minutes = capacity problem; check capacity provider health |
PENDING | Capacity acquired; pulling container images | >10 minutes = image pull failure; check ECR access |
ACTIVATING | Registering with load balancer target group | >60 seconds = ALB target group registration issue |
RUNNING | Containers started; may be in health check grace period | Stable (until stopped). Check deployments array |
DEACTIVATING | Deregistering from load balancer | Transient; normal during deployment |
STOPPING | SIGTERM sent; waiting for graceful shutdown | Transient; exceeding stopTimeout = SIGKILL |
DEPROVISIONING | Releasing ENI and capacity reservation | Transient; normal after STOPPING |
STOPPED | Task terminated. Check stopCode and stoppedReason | Terminal. Diagnose via stopCode (see below) |
The stopCode field on a STOPPED task is the primary diagnostic signal. EssentialContainerExited means the application process exited — check the container's exitCode (0 = clean, non-zero = error) and reason fields. TaskFailedToStart usually means image pull failure or resource constraint. CannotPullContainerError means the registry is inaccessible — check ECR permissions or network access. OutOfMemoryError means the task hit its memory limit. UserInitiated and ServiceSchedulerInitiated are intentional stops, not failures. MCP tools diagnosing unexplained task stops should filter for stopCode !== 'UserInitiated' && stopCode !== 'ServiceSchedulerInitiated'.
Google Cloud Run: deploy → LRO → revision conditions → traffic
Cloud Run's lifecycle is the most abstracted in this arc. You never directly create or start a container — you deploy a service configuration and Cloud Run manages the instance lifecycle. But the indirection creates its own sequence of steps, each of which can fail independently.
Step 1: updateService() with the new container image digest. This call returns a long-running operation (LRO) object, not a completed result. The service has accepted the request but has not yet started any containers.
Step 2: Poll the LRO until operation.isDone(). LRO completion means the revision has been created and registered — not that it is healthy or serving traffic. This is the first common mistake: treating LRO completion as deployment success.
Step 3: Check the revision's conditions array. A revision that passed LRO creation has up to four conditions that must each reach CONDITION_SUCCEEDED: Ready (aggregate), ContainerHealthy (startup probes passed), ResourcesAvailable (CPU/memory allocated — can be PENDING during regional capacity events), and ConfigurationsReady (secrets and environment variables applied). A revision with LRO complete but Ready: CONDITION_FAILED will never serve traffic.
Step 4: Verify traffic routing. Cloud Run does not automatically switch traffic to the new revision if the service uses explicit revision pinning (traffic array with named revisions). If the previous deploy pinned traffic to a specific revision name, the new revision is ready but receives zero traffic. MCP tools that implement deployment must check and update the traffic array separately from revision readiness.
A revision that fails the Ready condition check does not trigger automatic rollback. Traffic remains on whatever revisions were previously configured. The new revision simply never receives traffic. This is safety-first behavior but means failed deployments are silent from the outside — the service URL keeps serving the old version, and only a revision readiness check reveals the failure.
Pattern 2: Health probe depth — the status string is the first layer, not the only layer
Every runtime in this arc has a status string that reads "running" (or its equivalent) when the main process has been started and has not yet exited. None of those strings tell you whether the application inside is functional, whether its dependencies are reachable, or whether it will successfully handle the next request. The gap between "process started" and "application healthy" is where the most expensive production incidents originate.
Docker: a three-layer health model
Docker exposes the most structured health model in this arc. Each layer answers a different question:
Layer 1 — .State.Status: the container lifecycle state. Values: created, running, paused, restarting, removing, exited, dead. The restarting state is a critical signal — it means the main process is crash-looping. Always check .State.RestartCount alongside status; a running container with RestartCount > 0 recently crash-looped and recovered but has not been investigated.
Layer 2 — .State.Health: the Docker HEALTHCHECK result. Only present if the image defines a HEALTHCHECK directive or the container was created with a Healthcheck config override. Values: starting (grace period before first check), healthy, unhealthy. The .State.Health.Log array contains the last 5 health check command outputs — always surface these when status is unhealthy. If .State.Health is absent, the image has no health check — you must fall back to Layer 3, and you should not report the container as healthy just because Layer 1 is running.
Layer 3 — external probe: an HTTP or TCP probe from outside the container. This is the most accurate signal because it verifies that the application is actually reachable over the network, not just that a process is running. Register the container's HTTP endpoint with AliveMCP for continuous external monitoring that survives container restarts and Docker daemon restarts.
The diagnostic synthesis: running + healthy is the target state. running + unhealthy is the worst state (process alive but application broken — often the case during deadlocks, OOM-adjacent states, and dependency failures). running + no health check is ambiguous — the application may be fine or broken with no Layer 2 signal to distinguish. restarting with any RestartCount > 0 is a crash loop, regardless of what Layer 2 reports at this moment.
containerd: no built-in health check — you implement all of it
containerd's task state model has four states: CREATED, RUNNING, STOPPED, and PAUSED. RUNNING means the containerd shim holds a reference to the init process and the process has not yet exited. There is no Layer 2 equivalent — containerd has no HEALTHCHECK directive, no health status field, no health log.
All health checking beyond "is the task RUNNING?" must be implemented by the MCP tool itself. The two options:
Exec-based probes: run a health check command inside the task using task.exec(). This is analogous to Docker's HEALTHCHECK exec option. The command runs inside the task's network namespace, so it can reach localhost endpoints that are not published externally. The downside: exec-based probes are only possible when the task is RUNNING, and they fail if the task process is so overloaded it cannot accept new exec sessions.
External HTTP probes: probe the container's published port from outside. This is equivalent to Docker's HEALTHCHECK http option. Register the endpoint with AliveMCP for continuous monitoring. The advantage: the probe is independent of the containerd daemon — if containerd itself has issues but the container's application is still serving, AliveMCP will report healthy. If containerd is fine but the application is broken, AliveMCP will detect it within 60 seconds.
containerd also lacks Docker's automatic restart policy. A task that exits with a non-zero exit code stays in STOPPED state permanently until the MCP tool (or a higher-level system) restarts it. MCP tools managing long-lived services via containerd must implement restart logic: subscribe to the containerd events API for /tasks/exit events and restart on non-zero exit codes. OOM kills produce a /tasks/oom event before the exit event — surface these separately since they indicate memory limit violations requiring a different fix than a simple restart.
Podman: container status + pod state + rootless network hop
Podman's health probe model adds a layer that Docker does not have: the pod state. A pod can be in Running, Stopped, Degraded, Paused, or Error state. Degraded is Podman-specific — it means the pod has some running and some stopped containers. This is a critical monitoring signal: a Degraded pod has at least one crashed container while others are still running.
The Podman health probe requires checking at two levels: individual container status from GET /libpod/containers/{name}/json (including .State.Healthcheck.Status for containers with healthcheck directives), and pod status from GET /libpod/pods/{id}/json (for the aggregate State field and the per-container status array). An MCP tool that only checks container status will miss the case where a container is nominally running but the pod is Degraded due to a crashed sidecar.
Rootless mode adds a third health consideration: the slirp4netns or pasta userspace network stack. In rootless Podman, traffic to a published port goes through an additional userspace NAT hop before reaching the container. This hop can fail independently of the container — the container may be running and healthy on its internal port while the slirp4netns process has crashed, making the published port unreachable. External probes (AliveMCP probing the host port) catch this failure; exec-based internal probes to localhost inside the container will not.
Amazon ECS: four independent health layers, each catchable in isolation
ECS service health has more independently-reachable failure modes than any other runtime in this arc. A service that passes every obvious check can still be broken in a way that a more thorough probe would catch:
Check 1 — runningCount vs desiredCount: the most common check, and the least sufficient. A service with runningCount === desiredCount and pendingCount === 0 has its full set of tasks in RUNNING state. But RUNNING tasks may not be serving traffic yet (grace period), may be serving the old version (mid-deployment), or may be failing their application-level health checks (ALB reports unhealthy).
Check 2 — the deployments array: a service with both PRIMARY and ACTIVE deployments is mid-rollout. Some tasks run the old task definition, some the new one. The rolloutState field on the PRIMARY deployment reads IN_PROGRESS, COMPLETED, or FAILED. A service is stable only when there is one deployment, it is PRIMARY, its rolloutState is COMPLETED, and runningCount === desiredCount. Always check all of these together.
Check 3 — health check grace period: when a new task enters RUNNING state in a service with a load balancer, ECS waits for healthCheckGracePeriodSeconds before registering the task with the ALB target group and starting health checks. A service can report runningCount === desiredCount while all tasks are in grace period and no traffic is actually being routed to them yet. Compare each task's launch time against the service's healthCheckGracePeriodSeconds to determine whether tasks are still in grace period.
Check 4 — ALB target group health: the ECS view (via DescribeServices) reflects the ECS scheduler's understanding of task lifecycle. The ALB view (via Elastic Load Balancing APIs) reflects whether targets are passing application-level HTTP health checks. A task can be ECS-RUNNING while ALB-unhealthy if the application is returning 5xx responses or if the health check endpoint is timing out. For MCP tools monitoring ECS services behind a load balancer, the ALB target group healthy host count is the ground truth for "is traffic actually being served."
Google Cloud Run: servingStatus ≠ revision Ready ≠ warm instances
Cloud Run's health model has three distinct layers that answer different questions:
Service-level: servingStatus — SERVING or NOT_SERVING. SERVING means the service is configured to accept traffic. A service can be SERVING while routing 100% of traffic to a revision that has Ready: CONDITION_FAILED — if you pin traffic to a specific revision name that later fails, the service keeps routing to that revision, and servingStatus stays SERVING while all requests fail.
Revision-level: conditions array — the Ready condition is the aggregate. latestReadyRevision on the service points to the last revision that passed all conditions. If latestCreatedRevision !== latestReadyRevision, the newest revision was deployed but failed readiness — traffic is still flowing to the previous ready revision. This is the failure case servingStatus does not surface.
Instance-level: warm vs cold — Cloud Run scales to zero by default. Even a SERVING service with a Ready revision may have zero warm instances. The next request will trigger a cold start: instance allocation, layer pulls, container startup, and startup probe validation, adding 1-5 seconds of latency before the request is processed. AliveMCP's 60-second probe interval means that for low-traffic services, each probe will itself trigger a cold start. The AliveMCP probe timeout must be set above 10 seconds for scale-to-zero services, or the probes will timeout during cold start and report spurious failures. Setting scaling.minInstanceCount: 1 keeps one instance always warm and eliminates cold start latency at the cost of a small always-on billing charge — the right trade-off for MCP servers where cold start latency would cause MCP client timeouts.
Pattern 3: Exec process semantics — a new process, not a connection to the existing one
All five runtimes support running a new process inside a running container's namespace. The consistent pattern: the exec process shares the container's filesystem, environment variables, and network interfaces, but has its own PID, its own stdin/stdout/stderr, its own exit code, and its own lifecycle that is independent of the container's main process. Getting this independence wrong — either by treating exec as a connection to the main process, or by not cleaning up exec process state after it exits — produces incorrect diagnostics and resource leaks.
Docker: two calls, binary multiplexed output, and orphaned exec instances
Docker exec is a two-call operation. POST /containers/{id}/exec creates the exec instance and returns an exec ID. POST /exec/{execId}/start starts the process and optionally attaches to its streams. The exec ID is a separate resource from the container ID — you need both to manage the exec lifecycle.
The most common Docker exec implementation mistake is the 8-byte stream multiplexing header. When you exec with AttachStdout: true, AttachStderr: true, and Tty: false, Docker multiplexes stdout and stderr into a single stream using a frame format: byte 0 is the stream type (1 = stdout, 2 = stderr), bytes 1-3 are zero padding, bytes 4-7 are the frame payload length as a big-endian uint32, followed by the payload. If you read the raw stream without stripping these headers, your output will contain binary garbage interspersed with the actual command output. The dockerode library provides docker.modem.demuxStream() to handle this automatically. When Tty: true, there is no multiplexing — stdout and stderr are merged into a single stream — making them indistinguishable but not requiring header stripping.
After the exec process exits, its state remains in the Docker engine until you call GET /exec/{execId}/json to retrieve the exit code. The exec instance is cleaned up when the container is removed, but if you create many exec instances without retrieving their results, they accumulate. For MCP tools that run frequent exec operations (health checks, diagnostic commands), always retrieve the exit code and surface it in the tool response — don't fire-and-forget exec calls.
containerd: three calls, manual cleanup, and exec process isolation
containerd's exec sequence is three calls: task.exec(execId, processSpec) creates the exec process configuration, process.start() starts the process (analogous to Docker's POST /exec/{id}/start), and process.wait() blocks until the process exits and returns the exit code.
The critical cleanup requirement: process.delete() must be called after an exec process exits. Unlike Docker, containerd does not automatically clean up exec process metadata when the process exits or even when the container is removed. Failing to call delete accumulates exec process records in the containerd store. The pattern:
const execProcess = await task.exec(execId, spec);
await execProcess.start({});
const status = await execProcess.wait();
await execProcess.delete(); // always delete after wait()
return { exitCode: status.exitCode };
A task that becomes STOPPED while an exec process is still running creates an unusual state: the exec process continues executing but the init process has exited. containerd permits this, but calling task.exec() on a STOPPED task will fail — exec can only be initiated against RUNNING tasks. MCP tools that check task state before initiating exec will avoid this failure mode.
Podman: Docker-compatible exec in pod context
Podman's exec is Docker-compatible: POST /libpod/containers/{name}/exec (or the Docker-compatible /v1.41/containers/{name}/exec) followed by POST /exec/{id}/start. The same 8-byte stream multiplexing header applies when Tty: false.
The pod-context distinction: an exec process inside a Podman pod container shares the pod's network namespace. This means the exec process can reach any port that is published at the pod level — not just ports on the container's own network interface. For health check exec commands, this is usually what you want (curl to the container's localhost port works because the container and pod share the network namespace). For diagnostic commands where you need to distinguish per-container vs per-pod network state, be explicit about which container you are execing into.
Rootless mode exec works without any special configuration — exec is not a privileged operation and does not require root. However, exec commands that try to perform root operations inside the container (bind to low-numbered ports, modify /etc/ files) are subject to the user namespace mapping. Inside the container, the exec process may believe it is root (UID 0), but on the host it runs as the invoking user's UID. Operations that require real host root will fail even though the exec command appears to have root inside the container.
Amazon ECS: SSM WebSocket session, not a direct connection
ECS Exec is architecturally different from all local container runtime exec implementations. It does not connect directly to the container — it tunnels through AWS Systems Manager (SSM) Session Manager using WebSockets. This indirection is what makes it work for Fargate tasks that have no inbound network access and no public IP.
The requirements are more extensive than for Docker exec: the task definition must set enableExecuteCommand: true on the service or task. The task's IAM execution role must have the permissions ssmmessages:CreateControlChannel, ssmmessages:CreateDataChannel, ssmmessages:OpenControlChannel, ssmmessages:OpenDataChannel. The task must be in RUNNING state. The SSM agent inside the container must be able to reach the SSM endpoint (via VPC endpoint or NAT gateway — without this, the ECS Exec session establishment will timeout silently).
The ExecuteCommandCommand response contains a session object with streamUrl and tokenValue — these are passed to the SSM SDK to establish the actual WebSocket connection. The response from ExecuteCommandCommand alone does not run the command; it only starts the session establishment. For MCP tools that only need non-interactive diagnostic output (not a shell), consider running a short-lived ECS task with a specific command override instead of ECS Exec — short-lived tasks produce output in CloudWatch Logs with a complete audit trail, while ECS Exec sessions are harder to audit and require additional SDK setup for output capture.
// Check ECS Exec availability before attempting
async function ecsExecAvailable(cluster, taskArn) {
const { tasks } = await ecs.send(new DescribeTasksCommand({
cluster, tasks: [taskArn]
}));
const task = tasks?.[0];
return task?.enableExecuteCommand === true
&& task?.lastStatus === 'RUNNING';
}
// Only proceed if ECS Exec is enabled AND task is running
if (!(await ecsExecAvailable(cluster, taskArn))) {
return { error: 'ECS Exec unavailable — check enableExecuteCommand flag and task state' };
}
Google Cloud Run: no exec API — design for it
Cloud Run has no exec API. Container instances are ephemeral and inaccessible by direct connection — there is no shell access from the API layer. Google provides a Cloud Run SSH feature for debugging (accessible via the Cloud Console or gcloud CLI), but this is a developer tool, not an API-callable operation from MCP tools.
This architectural difference matters for how you design health probes for Cloud Run MCP tools. Approaches that work with exec (running a health command inside the container to check a dependency) are not available. All health information must come from:
- The Cloud Run Admin API (service status, revision conditions, traffic configuration)
- External HTTP probes to the service URL (what AliveMCP provides)
- Cloud Monitoring metrics (
run.googleapis.com/container/instance_countby state: active/idle/starting;run.googleapis.com/request_countfiltered by response code) - Startup and liveness probe results exposed via the revision's ContainerHealthy condition
Design MCP tools for Cloud Run services to surface all four data sources in a composite health response. An HTTP 200 from the service URL (via AliveMCP) combined with a Ready revision condition and a non-zero active instance count is a high-confidence health signal. A 200 from the URL with zero active instances means the response came from a cold start — the service is healthy but the next request after scale-to-zero will incur cold start latency again.
Cross-platform comparison
The five runtimes in this arc span the full spectrum from local development (Docker, Podman) to cluster infrastructure (containerd) to managed serverless (ECS, Cloud Run). The choice of which runtime to target with your MCP tool is usually determined by where the workload runs, not by API preference.
| Runtime | Connection | Lifecycle steps | Health probe API | Exec mechanism | Auth model |
|---|---|---|---|---|---|
| Docker Engine | Unix socket /var/run/docker.sock or TLS TCP 2376 |
create → start → (wait) — 2-3 calls | GET /containers/{id}/json → .State.Health (requires HEALTHCHECK in image) |
POST /exec → POST /exec/{id}/start; 8-byte mux header when Tty:false |
Socket access = full trust; TLS with client cert for remote |
| containerd | gRPC socket /run/containerd/containerd.sock |
pull → prepare snapshot → create container → create task → start — 5 calls | task.status() → RUNNING; no HEALTHCHECK equivalent — exec-based probe required | task.exec() → process.start() → process.wait() → process.delete(); must delete after exit | Socket access = full trust; namespace isolation (k8s.io, moby, custom) |
| Podman REST | Rootless: /run/user/{uid}/podman/podman.sock; Rootful: /run/podman/podman.sock |
pod create → container create → pod start — 3 calls; port maps on pod, not container | GET /libpod/containers/{name}/json → .State.Healthcheck.Status + pod Degraded state |
Same as Docker exec via Docker-compat path; exec inside pod shares pod network namespace | Socket access = rootless user trust; Docker-compat path at /v1.41/; libpod path at /v5.0.0/libpod/ |
| Amazon ECS | AWS SDK + IAM credentials (IMDS in-region, env vars or creds file for external) | RunTask → poll DescribeTasks through 8 states until RUNNING or STOPPED | DescribeServices → runningCount + deployments array + primaryDeployment.rolloutState + ALB target group | ECS Exec via SSM WebSocket; requires enableExecuteCommand:true + ssmmessages IAM permissions | IAM roles; minimum permissions list in ECSClient setup code |
| Google Cloud Run | REST/gRPC via @google-cloud/run; Application Default Credentials |
updateService → poll LRO → check revision conditions array (4 conditions) | service.servingStatus + revision.conditions[Ready].state + Cloud Monitoring instance_count | No exec API. Health must come from HTTP probes and Cloud Monitoring metrics | ADC chain (env var → workload identity → metadata server → gcloud CLI) |
Runtime selection: when to target which API
The right container runtime for your MCP tool depends on where the workloads run and what level of control you need. The selection criteria:
Local development environments (developer laptops, CI runners): Docker Engine API is the universal choice. Docker is installed on most development machines and CI systems. If you need to support Linux environments where Docker is not the default (RHEL, Fedora, CentOS Stream in enterprise environments), add Podman support via the Docker-compatible endpoint — the same dockerode code works with Podman by changing the socket path.
Rootless security requirements: Podman rootless mode is the only runtime in this arc that runs containers without requiring root privileges on the host. For MCP tools that create container workloads from agent requests (which may be less trusted or carry user-provided inputs), rootless Podman limits the blast radius of a container escape to the invoking user's permissions. Docker daemon requires root; containerd typically requires root; ECS and Cloud Run handle isolation at the platform level but require cloud credentials.
Kubernetes node inspection (infrastructure tooling): containerd is the correct target when you need to inspect or manage containers at the node level inside a Kubernetes cluster. Kubernetes uses containerd as its container runtime interface (CRI); the containers visible in the k8s.io containerd namespace are the actual pod containers running on the node. The Docker Engine API (if installed) shows Docker-managed containers in the moby namespace, not Kubernetes pod containers. For diagnostic tools that need to see inside Kubernetes pods at the runtime level — check OCI spec details, inspect snapshots, or verify resource limits applied by the kubelet — the containerd gRPC API at the k8s.io namespace is the correct target.
AWS-managed workloads (serverless, no cluster management): Amazon ECS with Fargate is the choice when your organization uses AWS and wants managed infrastructure without operating a Kubernetes cluster. ECS Fargate handles instance provisioning, patching, and scaling; you manage task definitions and service configuration. The 8-state machine and four-layer health check add complexity, but there is no node to manage. For workloads that need to run on EC2 (for GPU access, specific instance types, or container instance-level control), ECS EC2 mode provides the same task API with more infrastructure visibility.
GCP-managed workloads (serverless, scale to zero): Google Cloud Run is the choice for GCP environments where workload traffic is bursty or where zero infrastructure management is the priority. The scale-to-zero default makes it cost-efficient for low-traffic services, but requires MCP tools to handle cold start latency in probe timeouts and to understand that servingStatus does not reflect warm instance availability. Cloud Run Jobs (not covered in this arc) serve the batch workload use case: running a task to completion rather than handling HTTP requests.
Deep infrastructure control (custom runtimes, image layer management, CRIU checkpointing): containerd's gRPC API is the only option in this arc that provides access to snapshotters, content stores, and the ability to specify OCI runtimes (gVisor, Kata Containers, Firecracker) by name. If your MCP tool needs to implement container checkpointing, custom snapshot management, or work with alternative OCI runtimes below the Docker abstraction layer, containerd is the correct target.
The composite health endpoint pattern across all five runtimes
The most useful pattern across all five runtimes is a composite health endpoint on the MCP server itself — a single /health/container URL that aggregates status from the runtime API and exposes a machine-readable JSON result that AliveMCP can probe every 60 seconds. This composite endpoint catches the failures that runtime status strings miss: image pull regression, exec permission revocation, capacity provider degradation, revision startup probe failure, and credential expiry.
The endpoint should use Promise.allSettled() to query each health layer independently, aggregate the results into a single status, and return the most severe failure rather than reporting the first failure and stopping. A partial health check that always times out on one component will hide other component failures — allSettled ensures all components are evaluated on every probe.
// Generic composite health pattern (adapt per runtime)
async function containerHealth() {
const [runtimeCheck, processCheck, connectivityCheck] = await Promise.allSettled([
checkRuntimeConnectivity(), // can we reach the socket/API?
checkContainerProcess(), // is the process RUNNING / state healthy?
checkApplicationEndpoint(), // does HTTP /health return 200?
]);
const healthy = [runtimeCheck, processCheck, connectivityCheck]
.every(r => r.status === 'fulfilled' && r.value.ok);
return {
healthy,
runtime: runtimeCheck.status === 'fulfilled' ? runtimeCheck.value : { ok: false, error: runtimeCheck.reason?.message },
process: processCheck.status === 'fulfilled' ? processCheck.value : { ok: false, error: processCheck.reason?.message },
connectivity: connectivityCheck.status === 'fulfilled' ? connectivityCheck.value : { ok: false, error: connectivityCheck.reason?.message },
};
}
For ECS and Cloud Run, where the container's endpoint is behind a load balancer or a managed URL, the connectivity check should probe the load balancer URL — not the internal ECS task IP or Cloud Run instance URL. The load balancer is the path your users take; AliveMCP probing the same path confirms the full path is healthy, including the load balancer, target group registration, and task readiness, not just the task process.
Register this composite endpoint URL with AliveMCP. Set the probe timeout to at least 15 seconds for Cloud Run services that may cold-start during the probe interval, and to at least 10 seconds for any runtime where the health check involves an exec operation (which has process startup overhead). Wire AliveMCP alerts to a channel that is monitored — the composite endpoint will surface the most important container runtime failures, including the three patterns in this synthesis, within 60 seconds of occurrence.
Further reading
- MCP Tools for Docker Engine API — container lifecycle, exec vs attach, and image cache
- MCP Tools for containerd — gRPC API, task lifecycle, and snapshot management
- MCP Tools for Podman REST API — rootless containers, pods, and quadlets
- MCP Tools for Amazon ECS — task state machine, service health, and capacity providers
- MCP Tools for Google Cloud Run — revision lifecycle, traffic splitting, and cold start
- MCP Servers on Kubernetes — readiness probes, PDBs, and session affinity
- MCP Server Docker — Dockerfile, signal handling, and health checks
- MCP Server Docker Compose — local dev, Redis, and production setup
- MCP Server Health Checks — implementing and monitoring protocol probes
- MCP Server Deployment — transport selection and hosting patterns
- MCP Tools for DevOps — patterns across container runtimes and orchestrators
- MCP Tools for CI/CD Pipelines — async lifecycle, log retrieval, and runner health