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;