Skip to main content

conmonrs/container_log/
log_file.rs

1//! Writing a log file on disk, see [`LogFile`].
2
3use crate::fs_util;
4use anyhow::{Context, Result};
5use std::{
6    fs::{self, File, OpenOptions},
7    io::{self, Write},
8    mem,
9    os::unix::fs::{FileExt, OpenOptionsExt},
10    path::{Path, PathBuf},
11    sync::Arc,
12};
13use tokio::task;
14use tracing::{debug, error, trace};
15
16/// A log file on disk which is written in whole chunks.
17///
18/// The drivers assemble their entries into `buf`, account each one with
19/// [`LogFile::commit_entry`] and write the buffer with a single call per read
20/// chunk, see [`LogFile::end_chunk`], or per [`LogFile::FLUSH_SIZE`] of
21/// entries for chunks of short lines.
22/// The writes, the opens and closing a replaced file run on the blocking
23/// pool, so writeback throttling, a slow disk or replacing a huge log only
24/// stalls this log and not the thread which drives every container of the
25/// pod and the RPCs. The buffer moves to the blocking task and back, unlike
26/// an async file, which copies every write into a buffer of its own that
27/// keeps the size of the largest one.
28#[derive(Debug)]
29pub(super) struct LogFile {
30    path: PathBuf,
31
32    /// The mode of a file this creates, an existing one keeps its own.
33    mode: Option<u32>,
34    pub(super) file: Option<Arc<File>>,
35
36    /// Maximum allowed log size in bytes.
37    max_size: Option<usize>,
38
39    /// The size of the file, counted from the one it had when it got
40    /// opened, including the committed entries of `buf`.
41    bytes_written: usize,
42
43    /// The pending output of the current chunk, allocated on first use and
44    /// released at its end, see [`LogFile::buf`].
45    buf: Vec<u8>,
46
47    /// Whether a failed write left the file in the middle of an entry, or it
48    /// had such an end when it got opened, so that the next entry has to
49    /// start on a line of its own.
50    torn: bool,
51
52    /// Whether the rotation failed during the current chunk, see
53    /// [`LogFile::commit_entry`].
54    rotation_failed: bool,
55}
56
57impl LogFile {
58    const ERR_UNINITIALIZED: &'static str = "logger not initialized";
59
60    /// Initial capacity of the chunk buffer, it grows to what the read chunks
61    /// need with their line overhead, up to about twice
62    /// [`LogFile::FLUSH_SIZE`] for the entry which crosses it.
63    const BUF_CAPACITY: usize = 8 * 1024;
64
65    /// The entries get written once they take this much, a chunk of very
66    /// short lines would grow the buffer to many times its size otherwise.
67    const FLUSH_SIZE: usize = 16 * 1024;
68
69    /// A log file at `path`, created with `mode`, which gets replaced once
70    /// it grows beyond `max_size`. Nothing gets opened before
71    /// [`LogFile::open`].
72    pub(super) fn new(path: impl AsRef<Path>, mode: Option<u32>, max_size: Option<usize>) -> Self {
73        Self {
74            path: path.as_ref().into(),
75            mode,
76            file: None,
77            max_size,
78            bytes_written: 0,
79            buf: Vec::new(),
80            torn: false,
81            rotation_failed: false,
82        }
83    }
84
85    /// The buffer to append the entries of a chunk to. A log file holds one
86    /// only while it writes a chunk, see [`LogFile::end_chunk`].
87    fn buf(&mut self) -> &mut Vec<u8> {
88        if self.buf.capacity() == 0 {
89            self.buf.reserve_exact(Self::BUF_CAPACITY);
90        }
91        &mut self.buf
92    }
93
94    /// Open the file and append to its existing content, like conmon does.
95    /// The content counts for the maximum size, the first entry which
96    /// exceeds it rotates the file. Content which does not end with a
97    /// newline, like of a server which crashed in the middle of a write,
98    /// counts as torn, see [`LogFile::write_buf`]. A symlink in place of the
99    /// log is not followed, the writes would hit its target. A temporary file
100    /// which a crashed server left behind gets removed, the kubelet would
101    /// never clean it up.
102    pub(super) async fn open(&mut self) -> Result<()> {
103        debug!("Open log file {}", self.path.display());
104        let options = self.open_options(false);
105        let path = self.path.clone();
106        let (file, torn) = task::spawn_blocking(move || {
107            let tmp = Self::tmp_path(&path);
108            if let Err(e) = fs_util::remove_file(&tmp) {
109                debug!("Unable to remove {}: {e}", tmp.display());
110            }
111            let file = options.open(path)?;
112            let torn = Self::ends_torn(&file);
113            io::Result::Ok((file, torn))
114        })
115        .await
116        .context("join log file open")?
117        .with_context(|| format!("open log file path '{}'", self.path.display()))?;
118        self.file = Some(Arc::new(file));
119        self.bytes_written = self.file_size().unwrap_or_default();
120        self.torn = torn;
121        Ok(())
122    }
123
124    /// Whether the file has content which does not end with a newline. One
125    /// which cannot be read counts as complete.
126    fn ends_torn(file: &File) -> bool {
127        let Some(last) = file.metadata().ok().and_then(|m| m.len().checked_sub(1)) else {
128            return false;
129        };
130        let mut byte = [0];
131        matches!(file.read_at(&mut byte, last), Ok(1)) && byte != *b"\n"
132    }
133
134    /// The new file of a replacement before it gets renamed over the path:
135    /// `.<name>.conmonrs-tmp` next to it. The kubelet removes `*.tmp` files
136    /// and treats `<name>.*` ones as rotated logs, a name which matches
137    /// neither keeps it from deleting the file while it gets renamed.
138    fn tmp_path(path: &Path) -> PathBuf {
139        let mut name = std::ffi::OsString::from(".");
140        name.push(path.file_name().unwrap_or_default());
141        name.push(".conmonrs-tmp");
142        path.with_file_name(name)
143    }
144
145    /// Replace the file with a new empty one, like conmon does: create a
146    /// temporary file, see [`LogFile::tmp_path`], and rename it over the
147    /// path. Unlike a truncation in place, a reader which follows the log,
148    /// like `kubectl logs -f`, keeps the old file with its offset and reads
149    /// it to its end, before it notices the new one. A rename does not follow
150    /// a symlink at the path either.
151    async fn replace(&mut self) -> Result<()> {
152        // The new file starts empty, also over a temporary file left behind.
153        let options = self.open_options(true);
154        let path = self.path.clone();
155        let tmp = Self::tmp_path(&path);
156        let file = task::spawn_blocking(move || Self::create_at(&options, &tmp, &path))
157            .await
158            .context("join log file replace")??;
159        // Closing the old file frees the blocks of a file which got unlinked,
160        // which takes long for a huge log. Not awaited, so that the log does
161        // not stay locked meanwhile. A sync which still holds it closes it
162        // on its own blocking thread.
163        if let Some(old) = self.file.replace(Arc::new(file)).and_then(Arc::into_inner) {
164            task::spawn_blocking(move || drop(old));
165        }
166        self.bytes_written = 0;
167        self.torn = false;
168        Ok(())
169    }
170
171    /// Create a new file at `tmp` and rename it to `path`, see
172    /// [`LogFile::replace`].
173    fn create_at(options: &OpenOptions, tmp: &Path, path: &Path) -> Result<File> {
174        let file = options
175            .open(tmp)
176            .with_context(|| format!("open log file path '{}'", tmp.display()))?;
177        if let Err(e) = fs::rename(tmp, path) {
178            if let Err(e) = fs::remove_file(tmp) {
179                debug!("Unable to remove {}: {e}", tmp.display());
180            }
181            return Err(e).with_context(|| format!("rename log file to '{}'", path.display()));
182        }
183        Ok(file)
184    }
185
186    /// The writes append, so that they land at the end also after the file
187    /// got truncated in place, like by an admin freeing disk space, instead
188    /// of at the old offset, which leaves a hole of zeros in front of them.
189    /// The standard library refuses append together with truncate, the
190    /// kernel takes both.
191    fn open_options(&self, truncate: bool) -> OpenOptions {
192        let mut options = OpenOptions::new();
193        options
194            .create(true)
195            .read(true)
196            .truncate(truncate)
197            .write(true)
198            .custom_flags(libc::O_NOFOLLOW | libc::O_APPEND);
199        if let Some(mode) = self.mode {
200            options.mode(mode);
201        }
202        options
203    }
204
205    /// Account for the entry appended to `buf` since `start`. If it would
206    /// exceed the maximum log size, the entries of the chunk before it go to
207    /// the old file, which readers following it still read to its end, and
208    /// the file gets replaced, the entry becomes the first one of the new
209    /// file.
210    ///
211    /// An error drops only this entry, or the entries of a failed write, the
212    /// writers still commit the later entries of the chunk and report the
213    /// first error once they ended it.
214    async fn commit_entry(&mut self, start: usize) -> Result<()> {
215        let len = self.buf.len() - start;
216        let mut total = self.bytes_written.saturating_add(len);
217        trace!(
218            "Verifying log size: max_size = {:?}, bytes_written = {}, entry = {}",
219            self.max_size, self.bytes_written, len,
220        );
221
222        // A file which got truncated in place, like by an admin freeing disk
223        // space, holds less than counted, the writes continue at its new end.
224        // Only rotate once the file itself grew beyond the size.
225        if self.max_size.is_some_and(|max| total > max)
226            && let Some(size) = self.file_size()
227            && size.saturating_add(start) < self.bytes_written
228        {
229            debug!(
230                "Log file {} shrank, counting from its size",
231                self.path.display()
232            );
233            self.bytes_written = size.saturating_add(start);
234            total = self.bytes_written.saturating_add(len);
235        }
236
237        if self.max_size.is_some_and(|max| total > max) {
238            if self.rotation_failed {
239                // A full disk would fail it again for every entry, the next
240                // chunk tries it again.
241                trace!("Dropping log entry which exceeds the size");
242                self.buf.truncate(start);
243                return Ok(());
244            }
245            debug!("Rotate log file {}", self.path.display());
246            let entry = self.buf.split_off(start);
247            if let Err(e) = self.flush().await {
248                error!("Unable to flush log file before rotation: {e:#}");
249            }
250            if let Err(e) = self.replace().await {
251                // The old file stays in place. Drop the uncounted entry, the
252                // next chunk tries the rotation again, until then entries
253                // which exceed the size get dropped.
254                self.rotation_failed = true;
255                return Err(e).context("reopen log file because of exceeded size");
256            }
257            self.buf.extend_from_slice(&entry);
258            self.bytes_written = len;
259        } else {
260            self.bytes_written = total;
261        }
262        if self.buf.len() >= Self::FLUSH_SIZE {
263            self.flush().await?;
264        }
265        Ok(())
266    }
267
268    /// Write the entries of the chunk which are still pending, see
269    /// [`LogFile::commit_entry`], and release the buffer. The line overhead
270    /// of the entries grows it beyond [`LogFile::BUF_CAPACITY`] for most
271    /// full read chunks, which an idle container would keep otherwise. The
272    /// allocation of the next chunk costs little next to its write.
273    async fn end_chunk(&mut self) -> Result<()> {
274        self.rotation_failed = false;
275        let res = self.flush().await;
276        self.buf = Vec::new();
277        res
278    }
279
280    /// Write a chunk of one entry per item, which `entry` appends to the
281    /// buffer, and end it. An entry which fails to commit does not stop the
282    /// later ones, the first error is returned.
283    pub(super) async fn write_entries<T>(
284        &mut self,
285        items: impl IntoIterator<Item = T>,
286        mut entry: impl FnMut(&mut Vec<u8>, T),
287    ) -> Result<()> {
288        let mut res = Ok(());
289        for item in items {
290            let buf = self.buf();
291            let start = buf.len();
292            entry(buf, item);
293            res = res.and(self.commit_entry(start).await);
294        }
295        res.and(self.end_chunk().await)
296    }
297
298    /// The size of the open file. A stat of an open file does not wait for
299    /// the disk, unlike its writes.
300    fn file_size(&self) -> Option<usize> {
301        let metadata = self.file.as_ref()?.metadata().ok()?;
302        usize::try_from(metadata.len()).ok()
303    }
304
305    /// Write the pending buffer to the file and clear it. The writers end
306    /// their chunks with [`LogFile::end_chunk`] instead.
307    async fn flush(&mut self) -> Result<()> {
308        if self.buf.is_empty() {
309            return Ok(());
310        }
311        let len = self.buf.len();
312        let (written, res) = self.write_buf().await;
313        if res.is_err() {
314            // Drop the chunk even if the write failed, retrying it along with
315            // the next chunk would duplicate the part which got written. Only
316            // that part counts for the rotation.
317            self.bytes_written = self.bytes_written.saturating_sub(len - written);
318        }
319        self.buf.clear();
320        res
321    }
322
323    /// Write the buffer and return how much of it got written. A write which
324    /// fails in the middle of an entry, like on a full disk, leaves the file
325    /// torn, the next write then starts with a newline of its own, so that
326    /// the next entry does not get appended to the cut one.
327    async fn write_buf(&mut self) -> (usize, Result<()>) {
328        let Some(file) = self.file.clone() else {
329            return (0, Err(anyhow::anyhow!(Self::ERR_UNINITIALIZED)));
330        };
331        let buf = mem::take(&mut self.buf);
332        let torn = self.torn;
333        let (buf, newline, written, res) = match task::spawn_blocking(move || {
334            let (newline, written, res) = Self::write_out(&file, torn, &buf);
335            (buf, newline, written, res)
336        })
337        .await
338        {
339            Ok(res) => res,
340            // The buffer went with the task, the next entry allocates one.
341            Err(e) => return (0, Err(e).context("join log write")),
342        };
343        self.buf = buf;
344        if newline {
345            self.bytes_written = self.bytes_written.saturating_add(1);
346        }
347        self.torn = if written > 0 {
348            res.is_err() && !self.buf[..written].ends_with(b"\n")
349        } else {
350            torn && !newline
351        };
352        (written, res.context("write log chunk"))
353    }
354
355    /// Write `buf` to the file, after a newline if the file is `torn`.
356    /// Return whether the newline got written and how much of the buffer.
357    fn write_out(mut file: &File, torn: bool, buf: &[u8]) -> (bool, usize, io::Result<()>) {
358        if torn && let Err(e) = file.write_all(b"\n") {
359            return (false, 0, Err(e));
360        }
361        let mut written = 0;
362        while written < buf.len() {
363            match file.write(&buf[written..]) {
364                Ok(0) => return (torn, written, Err(io::ErrorKind::WriteZero.into())),
365                Ok(n) => written += n,
366                Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
367                Err(e) => return (torn, written, Err(e)),
368            }
369        }
370        (torn, written, Ok(()))
371    }
372
373    /// Write the pending buffer and start a new file at the path. The
374    /// kubelet renamed the old one already, a file still at the path gets
375    /// replaced, like by the rotation.
376    ///
377    /// The file is not synced to disk: an fsync of a large log can take long
378    /// and the data sits in the page cache either way. Only the exit of the
379    /// container syncs the log, see
380    /// [`ContainerLog::sync`](super::ContainerLog::sync).
381    ///
382    /// A failed flush dropped the buffer already, failing the reopen would
383    /// not bring it back but stop the rotation of the kubelet, which moves
384    /// the rotated file back then.
385    pub(super) async fn reopen(&mut self) -> Result<()> {
386        debug!("Reopen log file {}", self.path.display());
387        if let Err(e) = self.flush().await {
388            error!("Unable to flush log file before reopen: {e:#}");
389        }
390        self.replace().await
391    }
392}
393
394#[cfg(test)]
395mod tests;