Design: container exit observability + host-side log capture (#933, #909) #967
BatmanByte
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Design: container exit observability + host-side log capture
This design addresses #933 and #909.
Running; the exit code is unavailable and the VM may stay allocated.Read this first
The two issues share one missing path: the guest can see the init process and its pipes, but the host has no durable record of either fact.
flowchart LR Init["container init<br/>the workload"] Guest["guest<br/>observe exit + drain output"] Shim["shim<br/>persist host-side facts"] Runtime["runtime<br/>reconcile SDK state"] Init --> Guest --> Shim --> RuntimeIn one sentence: init runs, the guest observes, the shim persists, and the runtime reports.
The design keeps two concerns separate:
Exited(code)orFailed(reason)stop,keep, orremoveRuntime map
boxlite-shimboxlite-guestwaitpidprocesses it createdTwo details matter throughout the design:
execare different. The zygote already waits for exec processes it creates, but init is currently created by the guest main process, so nobody keeps its exit result.What is broken today
RunningThe existing
exit/exit.previousfiles are shim/VM crash records, not container exit records.logs/console.logis the VM serial console, not container stdout/stderr.Target behavior
Exit and log paths
flowchart TB Init["init"] -->|"exit"| Supervisor["guest InitSupervisor"] Supervisor -->|"Container.Wait"| Monitor["shim monitor"] Monitor --> Outcome["state/container-exit.json"] Outcome --> Reconcile["runtime reconcile"] Reconcile --> State["Exited / Failed"] Init -->|"stdout / stderr"| Drain["guest's only pipe reader"] Drain --> Tail["bounded diagnostic tail"] Drain --> Manager["InitOutputBuffer"] Manager -->|"Container.StreamOutput"| LogTask["shim log task"] LogTask --> Log["logs/entrypoint.log"]The exit path determines state. The output path determines observability. They run concurrently; neither is allowed to block the other.
End-to-end sequence
sequenceDiagram participant R as host runtime participant S as shim monitor participant G as guest participant Z as zygote participant I as init R->>S: InstanceSpec(container_id, generation) S->>G: Container.Wait (may arrive before init exists) R->>G: Container.Init(generation) G->>Z: build init Z->>I: create and start I-->>G: stdout / stderr G->>G: drain pipes and monitor init I-->>Z: exit Z-->>G: exit result G->>G: wait for stdout and stderr EOF G-->>S: terminal Wait response S->>S: atomically persist outcome R->>R: reconcile outcome into SDK stateThe ordering is intentional:
Container.Waitreturns only after guest-local stdout and stderr reach EOF. At that point the shim knows the guest has read all final output; it only gives the network log stream a bounded finalization window.Guest design
Build init through the zygote
Init must be built by zygote, just like exec. The process that calls youki
build()becomes init's parent and can therefore obtain its exit result through the existingWait(pid)IPC.BuildSpecgainsbundle_path,mode: Init | Tenant, anddetach. The create/start split remains valid across the process boundary because youki reconstructs start state from disk paths rather than an in-memory parent handle.This also moves init's
clone3()out of the guest's multithreaded Tokio process, which is the reason zygote exists in the first place.InitSupervisorThere is one supervisor per init generation, not one polling loop per
Container.Waitrequest.Container.WaitbehaviorPendingNotReadyReadyExitedStartupFailedNotReadyand generation mismatch are deliberately opposite:NotReadymeans this generation is still being created, so the shim retries with backoff.FailedPrecondition, so the old monitor stops instead of retrying forever.The supervisor is the only task polling zygote's non-blocking
Wait(pid). It stores the terminal result intokio::sync::watch. AContainer.Waithandler first reads the current watch value withborrow(); only an empty value waits withchanged().await. That makes reconnect idempotent: a newly created receiver can return an already-known exit code immediately.Always drain init output
Each init stdout/stderr pipe has exactly one reader. It runs even when log capture is disabled:
The current drain worker uses blocking OS threads. The supervisor and gRPC handlers are Tokio async tasks; a handler waiting with
awaitdoes not monopolize an OS thread.PR2 adds an EOF completion signal to the two drain workers. The workers notify the supervisor after
read()returns EOF; the supervisor publishes the terminal watch value only after it has both the init result and both completion signals.drain_output()remains a non-blocking snapshot of the current tail for diagnostics; it must never wait for EOF.InitOutputBufferandStreamOutputThe first version has exactly one log consumer: the shim. It does not need a general multi-subscriber manager.
InitOutputBufferowns the recent-output ring, one bounded channel for the active shimStreamOutput, and that channel's dropped-byte count. The drain reader feeds it; it never creates another pipe reader.When the shim connects or reconnects, the buffer snapshots the ring and installs the one active stream while holding the same state lock. The handler sends the snapshot first and then consumes the channel, so replay and live output meet without a gap. A second still-active stream is rejected; the shim must not reconnect in parallel.
The drain path never waits for a network send. It writes the ring and uses
try_sendon the single channel. When the channel is full, it accumulates dropped bytes; the next successful delivery first carriesExecOutput.dropped{byte_count}. This event measures that one shim channel's overflow only. Ring overwrite and reconnect gaps remain best-effort and cannot be counted precisely without a cursor protocol. A future second consumer can justify evolving this component into a generalOutputManager.Protocol changes
ContainerInitRequest.lifecycle_generationContainer.WaitContainer.StreamOutputContainerWaitResponseexit_code,signal, andexited_atExecOutput.droppedInstanceSpec.container_id+ generationContainerConfig.capture_logsStreamOutputlifecycle_generationoriginates in persistedBoxState, increments on every start/restart, and must flow throughInstanceSpec,Container.Init,Wait,StreamOutput, andcontainer-exit.json. Runtime only trusts an outcome whose generation equals the current box generation.Shim design
Monitor and durable outcome
The shim starts its monitor thread before
krun_start_enter(), because that call takes over the main shim thread for the lifetime of the VM. The monitor retriesWaitand, when requested,StreamOutputwhile the shim/VM identity is verified. It stops only when the VM is gone, the generation is stale, or guest returns a terminal startup failure.On a terminal result, the shim writes one durable fact:
{"schema_version":1,"lifecycle_generation":7,"outcome":"exited","exit_code":7,"signal":null,"exited_at":"..."}Startup failure uses
outcome:"failed"withfailure_kind:"startup_failed"and a reason.The write must be crash-durable:
Writing to a temporary file prevents readers from seeing partial JSON.
fsync(temp)persists file data; same-directoryrenamemakes replacement atomic;fsync(state_dir)persists the directory entry change across a host crash.The file lives in
state/container-exit.json, not in the box root.state/must exist beforeJailer::prepare()and receive directory-level write permission; two pre-authorized files are insufficient becauserenamemodifies the parent directory. If any write step fails, the shim retries before claiming a terminal state or shutting down the VM. If it ultimately cannot persist the result, it exits and recovery turns a formerly active box intoFailed.Logs and resource action
With
capture_logs=true, the shim runsWaitandStreamOutputconcurrently. It reassembles byte chunks into lines, timestamps them at host receipt time, and writes k8s-file records tologs/entrypoint.log. AfterWaitreturns, it waits forStreamOutputEOF only for a bounded interval. Logging is best-effort and must not pin lifecycle completion.exit_actionstop(default)ExitedorFailedkeepstop()or restart tears down residual VMremoveremove_requested:true, then release VM memoryThe runtime must wait for
ProcessIdentity::Absentbefore consuming a remove intent. It must not force-kill a shim that is still performing graceful teardown.Runtime design
BoxStategains persistedexit_code,finished_at, andlifecycle_generation; new fields use serde defaults so old DB rows remain readable.All state readers use one
reconcile_terminal_state(config, state)path: activeinfo(), DB fallback,list_info, recovery, health checks, andwait().ConfiguredorRunningbox with a valid same-generation outcome becomesExitedorFailed.Stoppingand existing terminal states are not overwritten by a late outcome. This preserves an explicitstop()operation over a concurrent natural exit.Failed; only an explicitly persistedStoppingbecomesStopped.The transition is a compare-and-set under the state write lock, preventing concurrent
info,list, recovery, and health-check paths from racing. DB I/O happens after the in-memory transition decision.runtime.wait()returns:It returns immediately for existing terminal states and otherwise re-runs the same reconcile with bounded backoff. This works for detached boxes and freshly started runtimes without requiring a runtime-global event bus.
keepmay leave a live VM behindExitedorFailed. Teardown must therefore depend on residual shim/VM identity, not onlystatus == Running. Restart first tears down that residual VM, clears old outcome data, increments generation, and starts a new init.auto_removeremains an existing option for the owning runtime's explicitstop(). It is not the natural-exit policy. Automatic deletion after init exit is exclusivelyexit_action=remove; recovery must not delete a natural-exit outcome before users can query its exit code or read offline logs.Validation
A guest-only prototype previously validated the central process-parenting assumption on a real macOS arm64 VM: when zygote builds init, it becomes the parent and receives the actual exit code (
/bin/falsereturned1); the fast-exit path no longer reports a startup error; and a long-running init remains unaffected. The prototype was reverted.Each implementation PR must add reproducer-first tests for the behavior it owns. The end-to-end validation matrix is:
get_info,list_info, andwaitagree onExited(7)Running; runtime converges toFailedwhen shim is absentFailedPrecondition; old outcome cannot alter the new generationwait()returns 7; output reaches EOFdroppedor a best-effort replay gapDelivery plan
flowchart TD P1["PR1: guest supervision + stdio safety"] --> P2["PR2: durable exit loop, default stop"] P2 --> P3["PR3: wait + SDK/CLI"] P2 --> P4["PR4: keep lifecycle"] P2 --> P5["PR5: remove + auto_remove"] P2 --> P6["PR6: logs end to end"]InitSupervisor,Wait, generation, and drain EOF completionstate/permission and durable outcome,Exited/Failed, reconcile and defaultstopWaitOutcome, SDKs, REST mapping, andboxlite waitkeepbehavior and residual VM lifecycleremove, remove intent, absent-process gate, full deletionInitOutputBuffer,StreamOutput, replay, single-channel backpressure, shim k8s-file writer,capture_logs,log_path, andboxlite logsPR2 can be reviewed as a stacked PR on PR1. PR3, PR4, PR5, and PR6 can be developed in parallel once PR2's interface is stable. A stacked PR can be developed in parallel, but an intermediate change that exposes incomplete user semantics must not ship by itself.
References
WaitProcessand stdout/stderr streaming over vsock.wait()semantics.Beta Was this translation helpful? Give feedback.
All reactions