Repository navigation
feat(logs): deliver container logs at least once across restarts and outages - #407
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a robust log delivery tracking and checkpointing mechanism to ensure at-least-once delivery of container logs across agent restarts and collector outages. It adds a logs positions file to save how far each container log file has been delivered, tracks pending records, retries exports on transient failures, and handles log rotation gracefully by reading old files to their end. The review feedback highlights two key improvements in logs/positions.go: first, avoiding holding a mutex lock during blocking disk I/O operations inside save() to prevent performance degradation and blocking tail readers; second, delaying setting p.existed = true until after the positions file is successfully unmarshalled to prevent log duplication storms in case of file corruption.
27f7925 to
cf05f8e
Compare
mayankpande88
left a comment
There was a problem hiding this comment.
Solid design. I checked the assumptions it rests on against the SDK and they hold: the batch processor exports in emit order, WithExportTimeout(0) means no timeout (not an expired context), the queue is 4x the read gate so emitting can't block, and nothing else emits through the provider or calls ForceFlush. A few things before merge:
Blocker
go.modpins nudgebee/logparser#36's branch commit; re-pin to its merge commit once it lands.
Medium
- Shutdown drain never converges while journald logs (
delivery.drain,logs.Shutdown).stopReadingpauses only file readers; journald records (src == nil) keep being emitted, and since the batcher exports about once a second,pendingis rarely 0. On any node with active systemd-unit logging, shutdown waits the full 15 s and then logs a false "N log records were not delivered". Suggest droppingsrc == nilrecords oncestoppingis set, or havingdrainwait only for records emitted before the grace period ends. - First run on a node still starts new containers at the end (
positions.startOffset).existedis fixed when the file is loaded, so for the agent's whole first lifetime on a node every container started after the agent (including each crash-loop restart's new log file) is read from its end and its startup lines are lost. After one agent restart the same case reads from 0. Consistent would be: files present at startup start at the end, files found later start at 0 (e.g. once the initial container scan is done).
Low
- Two rotations before the first ack (
Source.newFile).previs only recorded when nil, so if the reader moves A → B → C before any of B's records are acknowledged (likely during a collector outage), B's acks match neither generation and are discarded. A crash in that window resumes at A, then reads C from 0, so B is lost. It falls under the documented "less than one file per outage" limit, but the loss is silent. permanent()defaults to retry-forever. It classifies by matching the error text, and any unrecognised error retries indefinitely, e.g. a 200 whose body isn't valid protobuf (endpoint pointed at the wrong server): the batch is re-sent, and duplicated, forever while every reader stays paused. Retrying only known-transient errors (url.Error, 5xx, 408, 429) and dropping the rest would be safer.- Stale positions file: after a long agent downtime, or the logs endpoint being disabled and re-enabled, every container's current file is re-read from 0. Backpressure bounds memory, but the backend gets a burst of old-timestamped records. Consider ignoring a positions file older than some max age.
- journald drops past
maxPendingUnpausedincrementdroppedbut are neither logged nor exported; a metric would make them visible.
Question: the test reports 0 duplicates with SIGKILL. With positions saved every 10 s at 100 lines/s, a SIGKILL should re-send up to ~1,000 lines. How were duplicates counted? If the backend deduplicated them, the number should say so.
cf05f8e to
c063599
Compare
|
@mayankpande88 thanks for the review. All items are addressed in c063599 (the single branch commit, amended).
|
…outages
Container logs were lost whenever the agent, the logs endpoint or its
backend restarted: readers started at the end of each file with no saved
position, a refused connection dropped the batch without a retry, the
queue (2048) dropped records when full, and nothing was flushed on exit.
- Positions (LOGS_POSITIONS_FILE): each file's position is saved every
10 s and on SIGTERM, only up to lines whose records the endpoint
accepted (counted in emit order through the batch processor, with the
offset of each message's last line from logparser). After a restart the
reader resumes there; a truncated file starts at 0.
- First run: on the agent's first run on a node, files present when it
starts are read from their end, as before; files found after the
initial container scan (a new container, or a crash-looping container's
new file) are read from 0. Once a positions file exists, any unknown
file is read from 0.
- Stale positions (LOGS_POSITIONS_MAX_AGE, default 24h, 0 disables): a
positions file saved longer ago than this is ignored and the first-run
rule applies, so a long downtime does not send a burst of old lines.
The age comes from saved_at in the file, or its mtime for older files.
- Rotation: the old file is read to its end before the new one, and the
saved position stays in the oldest file with undelivered lines. Acks
are tracked across any number of rotations before the first ack. A
file rotated while the agent was down is found again by its inode in
the same directory and finished first; files rotated in between are
not read, and a warning says how many.
- Retries: only known-transient errors are retried until accepted:
connection and timeout errors, 5xx, 408 and 429 (429/503 also by the
OTLP client with no time limit). Anything else (another 4xx, a response
that is not OTLP) drops the batch.
- Backpressure: readers stop reading while 2048 records or 16 MiB wait,
so the queue (8192, blocking) never fills and emitting, which runs under
the parser's lock that /metrics also takes, never blocks. journald,
which no reader can pause, is dropped past 4096 waiting records, with
a warning at most once a minute.
- SIGTERM: stop reading, drop new journald records, deliver what was read
(up to 15 s), save.
- node_agent_log_records_dropped_total{reason} counts dropped records:
backpressure, shutdown, rejected.
- logparser is pinned to the merge commit of nudgebee/logparser#36.
Without LOGS_ENDPOINT nothing changes except that rotation reads the old
file to its end.
Two gaps found on a kind cluster are closed. A rotated file's saved
position is kept while the file is there under its rotated name, instead
of dropped at the first save after the restart, so a reader that starts
after container discovery (~28 s) still reads its last lines. A reader
paused by backpressure while the kubelet rotates its file more than once
reads the rotated files that are not gzipped yet before the new file; the
ones gzipped before it got to them are skipped with a warning.
c063599 to
2128c09
Compare
Summary
Container logs exported over OTLP were lost whenever the agent, the logs endpoint or its backend restarted:
With this change, delivery is at least once across those restarts. On a local test (100 lines/s, files rotating every 40 s), stopping the agent (SIGTERM, then three SIGKILLs), restarting the collector and restarting the backend for 60 s lost 0 of 40,377 lines (the build before this change lost 3,772 of 26,760). A 330 s backend outage lost 0 of 38,789 (before: 5,436 lost). Duplicates, counted on raw rows as
count() - uniqExact(line id)on a plain MergeTree table: 1,432, all from the two SIGKILLs that landed 4.9 s and 9.0 s after a positions save (457 and 975 lines). Every other scenario, including a SIGKILL 1.0 s after a save, gave 0.LOGS_POSITIONS_FILE). Each file's position is saved every 10 s and on SIGTERM, and only up to lines whose records the endpoint accepted, using the offset of each message's last line from logparser. After a restart a reader resumes there, and a truncated file starts at 0.LOGS_POSITIONS_MAX_AGE, default24h,0disables). A positions file saved longer ago than this is ignored and the first-run rule applies, so a long downtime does not send a burst of old lines./metricsalso takes). journald, which can't be paused, is dropped past 4096 waiting records, with a warning at most once a minute.node_agent_log_records_dropped_total{reason}counts dropped records:backpressure,shutdown,rejected.Limits: the kubelet gzips every rotated file but the newest, and gzipped files are not read. A paused reader therefore absorbs at most two rotations of a container's log file (about 20 MiB at the default 10 MiB); files gzipped before it reaches them are skipped with a warning naming their count. After a restart, files rotated between the saved one and the current one are not read either, also with a warning.
Without
LOGS_ENDPOINT, nothing changes except that rotation now reads the old file to its end.Depends on nudgebee/logparser#36, now merged;
go.modpins its merge commit. The chart half (positions hostPath, a logs exporter that waits for ClickHouse) is nudgebee/k8s-agent#678.Test plan
go test -race ./logs/andgo vet ./logs/ ./flags/ ./containers/ .on Linux. The tests cover positions, the first-run rule, stale positions, rotation (live, several before an ack, and while down), delivery, the shutdown drain with journald active, error classification, retry, the read gate and the dropped metric.TestSaveKeepsRotatedFileForItsReader,TestPausedReaderReadsFilesRotatedMeanwhileandTestPausedReaderWarnsAboutGzippedFilesfail without these fixes.OPTIMIZE TABLE ... FINALleft the duplicate count unchanged.