Design: container exit and output observability (#909) #967
BatmanByte
started this conversation in
Ideas
Replies: 1 comment
|
do we think we can reuse attach API as the |
0 replies
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.
Container Exit and Output Observability
Status and scope
This proposal replaces the pre-#988 design in Discussion #967.
It addresses #909: init stdout and stderr are not durable, and an unread pipe can block the workload. It also completes the resource-lifecycle work exposed by always-on output draining.
#988 already made the box exit code durable. This document treats that behavior as a prerequisite; it does not redesign terminal box state.
The design is based on
origin/main, including #1022. Constants such as write-batch size, queue capacity, and shutdown timeouts are intentionally not chosen yet: they require a guest-side virtiofs benchmark.Goals
boxlite logsworks after the box has stopped.Non-goals
execsessions. Only init is captured to disk.Stoppedas evidence that capture completed successfully.Current model and constraints
#1022changes execution output from attach-driven reads to one long-lived pump per stream. The pump appends to a bounded ring used by Attach. EveryStdoutandStderrdata event carries that stream's absolute byteoffset; an empty event withoffset == total_bytesmarks that stream's end. It is the required base for this proposal.flowchart LR P["init / exec process"] --> K["kernel pipe"] K --> O["OutputManager pump"] O --> R["1 MiB attach ring"] O --> S["CaptureSink (init only)"] S --> L["output.log on virtiofs"]The important constraints are:
shutdown_all()sends SIGKILL and returns before the process is necessarily dead.sh -c 'child & exit'is the minimal counterexample.ExecHandlestill retains stdin and PTY file descriptors.Attach loss contract
Offsets are per-stream source-byte positions: an offset is the number of bytes emitted on that file descriptor before the event's payload. It starts at zero when the process stream is created and does not reset for Attach. A new Attach starts with an expected offset of zero for each stream. If a received offset is ahead of that expected value, the reader reports the difference as loss, flushes only that stream's UTF-8 decoder, and then decodes the payload.
The terminal empty event makes the same contract complete when no data event for a stream survived the shared ring:
total_bytes > expectedreports the missing tail or the entire stream. These positions are notoutput.logfile offsets; CRI headers and stdout/stderr interleaving make the two domains different. The optional fields preserve compatibility with older guests; a reader that receives no offset treats that event as contiguous and cannot infer a gap.Design summary
OutputManagerpump per session; capture is a consumer of that pump.try_send; full means dropping the newest capture chunk.boxlitestream for metadata.beginandend, plus zeroend.dropped, are required for lossless completion.beginis synchronously written and fsynced beforeContainer.Start.capture_logsand remove-on-stop are rejected as incompatible options.Data flow and backpressure
The capture sink is pushed from
OutputManager::push(). It does not maintain a cursor into the attach ring. A pull model would silently fall behind ring eviction, or would force the ring lifetime to follow the slowest consumer.There are three buffers:
Only the pipe may block. Awaiting channel capacity, filesystem writes, or an attach client would propagate pressure back through the pump and recreate #909.
CaptureSinkis one bounded channel and one writer task. The sender is stored only inOutputState.sink; pumps borrow it throughpush()and never clone it. This gives shutdown a single owner:If the bounded writer wait times out, the writer task is aborted and the run is incomplete. It must not continue writing after the shutdown RPC has returned.
Drop accounting
The sink keeps two per-stream counters:
pending_dropped: bytes not yet described by adropmetadata record.total_dropped: bytes lost during the entire run; monotonic and never cleared.On
TrySendError::Full,record_drop(stream, len)increments both. Before a later data chunk is sent, the producer tries to enqueueChunk::Drop(pending_dropped). Only a successful enqueue clearspending_dropped; it never clearstotal_dropped.The following edge case is intentional and needs a unit test: with a capacity-one channel, a successful
Dropfills the slot again, so the immediately followingDatasend can still be full. That data must be counted again.The writer reads
total_droppedfrom sharedArc<AtomicU64>values when it writesend. It must not receive the final total through the bounded channel: that message could be dropped precisely when the total is most important. Pumps have joined before the read, so no concurrent increment remains.Log format and integrity model
When capture is enabled, the host creates a dedicated logs share at:
The guest is the only writer. The path is not supplied over gRPC: the guest derives it from the fixed capture-share mount and opens the final file with
O_NOFOLLOW.The file is a CRI-compatible extension. A regular record is:
stdoutandstderrcontain application output. The privateboxlitestream contains structured metadata that an application cannot forge by printing matching text:This is not standard CRI because
boxliteis not a standard CRI stream. Generic CRI readers that only filter stdout and stderr still see application payload;boxlite logsand the future remote endpoint are the only readers required to interpret metadata.The writer never converts payload with
from_utf8_lossy. Content is framed at 16 KiB, withPfragments followed byFfor a newline-terminated line. A final line without a newline remains a trailingP; readers do not add a newline for it. Therefore binary data and split UTF-8 sequences are preserved without inventing replacement characters.Run records and reader behavior
Every
Container.Initattempt receives a host-generated UUID inLogCapture.run_id. A guest-memory counter is insufficient because the same log file survives VM restarts.Readers group metadata by UUID and classify each run independently:
begin+end,end.droppedis zerobegin+end,end.droppedis non-zerobeginwithoutendendwithoutbeginbeginin the retained fileThe four normal states apply only to structurally valid metadata. Duplicate
begin, duplicateend,endbeforebegin, or invalid metadata JSON is reported as corrupted/unknown, never as successful capture.boxlite logsrenders the newest run's status and warns if any retained earlier run is incomplete.The file rotates in the guest at 10 MiB × 5 using a temporary file and renames. Follow mode must retry an
ENOENTbetween rename steps. Rotation can remove the first record of an older run, which is why an isolatedendis a retention condition rather than a crash diagnosis.Startup and shutdown
Startup barrier
beginis a startup barrier, not an ordinary queued item:beginrecord and fsync it once.Container.Start.Failure at any of these steps fails
Container.Init. A caller who explicitly requested capture should not discover after execution that no capture was ever active. The one startup fsync is deliberately outside the hot write path; whether it is acceptable is part of the required benchmark.Shared drain sequence
Natural init exit and host-driven
Guest.Shutdownmust invoke the same bounded sequence:Step 1 is required even after init's exit slot becomes ready. A background child can retain a pipe write end; killing only registered exec PIDs leaves the pump waiting for EOF indefinitely. The existing reboot also kills such children, but too late for capture: the behavioral change is the ordering.
Both paths persist the exit record before draining. Capture drain precedes
sync(). The existing 500 ms network-output grace window remains separate from capture drain; it still protects in-flight attach responses, not file correctness.An
endrecord proves that the writer completed its own close path. It does not prove losslessness; onlyend.dropped == 0proves that no capture chunks were dropped. A timeout, writer failure, or hard VM kill naturally leaves abeginwithoutend; no unreliable crash-time status write is required.Session and storage lifecycle
After every stream reaches EOF, a session gets a delayed attach grace period. If no attach is active at expiry, release the attach ring. An attach that ends after expiry restarts the timer.
At the same point, replace a finished session with a tombstone:
ExecHandle, including stdin and PTY fdsTombstones are bounded by TTL and LRU size. An init tombstone is exempt because there is only one per box and its exit status is the box exit status. Before tombstone eviction,
Waitreturns the retained status, and lateAttachreturns an empty terminal event for each enabled stream with its retainedoffset == total_bytes, then EOF. Because the reader starts each stream at expected offset zero, those events report the exact per-stream loss without a control event.SendInputreturnsFailedPrecondition. After eviction, these requests returnNotFound.Capture consumes at most 50 MiB per box (10 MiB × 5). Capture and remove-on-stop are incompatible because removal deletes the box directory containing the log.
sanitize()rejects that option combination instead of silently changing box lifecycle semantics.Protocol and user-facing behavior
ContainerInitRequestgains:The presence of
log_captureenables capture; an absent message disables it. The host produces a UUID, while the guest parses the UUID at its gRPC boundary into an internalCaptureConfig. A malformed or empty UUID fails Init. No guest path is accepted from the host.Other public behavior:
capture_logsis create-time only because the share is mounted only at create time.boxlite logsreads container logs when available, follows rotation, and keeps console output behind an explicit flag.box.wait()andboxlite waitare independent of capture and return the persisted terminal status and exit code.Delivery plan
First, run a disposable guest benchmark against
/run/boxlite/logs/to measure 4 KiB write latency and throughput, rotation cost, and the one startup fsync. It supplies the batch, queue, and timeout constants; it is not a merged feature.flowchart TB P1["PR1: #1022 drain and ring"] --> P2["PR2: terminal session cleanup"] P1 --> P3["PR3: minimal durable capture"] P3 --> P4["PR4: positioned drop records"] P3 --> P5["PR5: boxlite logs"] P3 --> P6["PR6: SDK propagation"] P3 --> P7["PR7: REST/cloud propagation"] P7 --> P8["PR8: remote log endpoint"] P9["PR9: wait"]offsetand terminaltotal_bytes, andAsyncFdreadsWait/Attach/SendInputsemanticsLogCapture, startup barrier, writer, rotation, total drop accounting, shared cgroup teardown, and option validationdropmetadata using source-byte offsets; PR3 already reports the total inendboxlite logs, completeness rendering, rotation follow, and console flagbox.wait,boxlite wait, and SDK exposurePR3 is a minimum usable vertical slice: capture cannot ship without the startup barrier, bounded drain, rotation, and truthful
end.dropped. PR4 may follow because it only improves where loss is displayed. PR7 must not expose a cloud capture option until PR8 makes captured logs readable remotely.Validation matrix
total_bytesreports the exact loss even without a surviving data eventNotFound; init is retainedbegin, output, and zero-lossendare presentend.droppedequals full-run loss, not only the final pending deltadropeventDatasend is countedbeginexists withoutend; report interruption, not successsh -c 'child & exit'endend; reader reports incomplete capturecapture_logswith remove-on-stopRejected alternatives
All reactions