conmonrs/init.rs
1//! The setup of the server process before it serves: umask, OOM score, open
2//! files limit, malloc and transparent huge pages, the standard streams and
3//! the descriptors it inherited, its working directory and the subreaper
4//! flag, and giving the runtime calls the OOM score, huge pages setting and
5//! open files limits the server started with.
6
7use crate::config::LogDriver;
8use anyhow::{Context, Result};
9use nix::{
10 errno::Errno,
11 fcntl::{OFlag, open},
12 sys::{
13 prctl,
14 resource::{Resource, getrlimit, rlim_t, setrlimit},
15 stat::{Mode, umask},
16 },
17 unistd::{chdir, dup2_stderr, dup2_stdin, dup2_stdout, write},
18};
19use std::{
20 fs::OpenOptions,
21 io::{ErrorKind, Write},
22 os::fd::{AsRawFd, IntoRawFd, RawFd},
23 path::Path,
24 sync::{
25 OnceLock,
26 atomic::{AtomicBool, Ordering},
27 },
28};
29use tokio::process::Command;
30
31/// The OOM score adjustment of the server.
32const OOM_SCORE_ADJ: &str = "/proc/self/oom_score_adj";
33
34/// Set up the process before it forks: remember the OOM score and raise the
35/// open files limit for the runtimes, configure malloc and protect the
36/// server from the OOM killer. The server keeps the C locale, unlike conmon,
37/// so that the errors it returns to CRI-O do not depend on the environment.
38/// The umask is set before the configuration prepares the runtime
39/// directories, see `crate::run`.
40pub(crate) fn set_up_process() -> Result<()> {
41 save_oom_score();
42 raise_nofile_limit();
43 configure_malloc();
44 // While we could configure this, standard practice has it as -1000, so
45 // it may be YAGNI to add configuration.
46 set_oom_score("-1000")
47}
48
49/// Like a daemon, do not keep the standard streams of the caller, like the
50/// file the Go client reads the launch errors from, see `startServer` in
51/// `pkg/client`. The processes the server starts, like the runtimes, would
52/// write into it otherwise. Only the stdout log driver writes to them.
53pub(crate) fn redirect_stdio(log_driver: LogDriver) -> Result<()> {
54 if log_driver == LogDriver::Stdout {
55 return Ok(());
56 }
57 let null = open(c"/dev/null", OFlag::O_RDWR, Mode::empty()).context("open /dev/null")?;
58 dup2_stdin(&null).context("redirect stdin")?;
59 dup2_stdout(&null).context("redirect stdout")?;
60 dup2_stderr(&null).context("redirect stderr")?;
61 // A closed standard stream got it, which has to stay.
62 if null.as_raw_fd() <= libc::STDERR_FILENO {
63 let _: RawFd = null.into_raw_fd();
64 }
65 Ok(())
66}
67
68/// Close file descriptors inherited from whatever started us.
69///
70/// conmon-rs daemonizes and outlives the process that spawned it, so any
71/// descriptor it inherits stays open for the lifetime of the server. If the
72/// spawning process was itself started with extra descriptors open, holding
73/// them can keep that process tree from ever finishing, for example a test
74/// harness waiting for its own pipe to close.
75///
76/// conmon does the same thing, see `close_all_fds_ge_than(3)` there. Any
77/// descriptor conmon-rs actually needs is created after this point, so
78/// nothing above stderr is worth keeping.
79#[expect(unsafe_code, reason = "closing descriptors which may be gone")]
80pub(crate) fn close_inherited_fds() {
81 for fd in inherited_fds() {
82 // The descriptor used to enumerate them is already closed by now, so
83 // check before closing to avoid closing an unrelated descriptor that
84 // reused the number. This runs before tokio starts, so nothing else
85 // can be opening descriptors concurrently.
86 //
87 // These are raw libc calls rather than the `nix` wrappers on purpose:
88 // `BorrowedFd`/`OwnedFd` require the descriptor to be open for the
89 // duration of the borrow, and whether it is still open is exactly
90 // what is unknown here. `fcntl(2)` and `close(2)` take a plain
91 // integer with no such precondition and report `EBADF`.
92 //
93 // SAFETY: both calls are defined for any integer; a stale or invalid
94 // descriptor number fails with `EBADF` rather than affecting anything
95 // else. Closing an open one takes it from nobody, as no live value
96 // owns a descriptor above stderr at this point: nothing the setup
97 // opened before is kept. Only the journal sockets in the statics of
98 // `crate::journal` get created that early, by `Server::log_early`,
99 // whose error ends the start before it gets here, and by the panic
100 // hook, which only a panic runs and which gets installed right
101 // before this call.
102 if unsafe { libc::fcntl(fd, libc::F_GETFD) } != -1 {
103 // SAFETY: as above.
104 unsafe { libc::close(fd) };
105 }
106 }
107}
108
109/// Every open descriptor above stderr. Split out from `close_inherited_fds`
110/// so it can be tested without closing the test runner's own descriptors.
111///
112/// This is Linux only: it enumerates `/proc/self/fd` and would report nothing
113/// at all where that is unavailable, silently leaking the inherited
114/// descriptors. conmon-rs already requires Linux elsewhere (`prctl`, epoll,
115/// journald), so the assertion below just makes a port fail here loudly
116/// rather than lose the behaviour quietly. A log line would not work,
117/// `close_inherited_fds` runs well before the logging got set up.
118fn inherited_fds() -> Vec<RawFd> {
119 const {
120 assert!(
121 cfg!(target_os = "linux"),
122 "inherited fds are enumerated through /proc/self/fd, which is Linux only"
123 );
124 };
125
126 let mut fds = Vec::new();
127
128 if let Ok(entries) = std::fs::read_dir("/proc/self/fd") {
129 for entry in entries.flatten() {
130 if let Some(fd) = entry
131 .file_name()
132 .to_str()
133 .and_then(|name| name.parse::<RawFd>().ok())
134 && fd > 2
135 {
136 fds.push(fd);
137 }
138 }
139 }
140
141 fds
142}
143
144/// Set up the process to serve, once the logging is: do not keep the working
145/// directory of the caller busy, like a mount which then cannot be unmounted
146/// (the paths of the configuration are absolute, see `Config::make_absolute`,
147/// and so are the ones of the requests), and make the container processes
148/// get reparented to the server once their runtime exits.
149pub(crate) fn set_up_serving() -> Result<()> {
150 chdir("/").context("change into the root directory")?;
151 prctl::set_child_subreaper(true).context("set child subreaper")
152}
153
154/// Adjust the OOM score of the server, best effort without the privileges
155/// to lower it.
156fn set_oom_score(score: &str) -> Result<()> {
157 set_oom_score_at(Path::new(OOM_SCORE_ADJ), score)
158}
159
160fn set_oom_score_at(path: &Path, score: &str) -> Result<()> {
161 match OpenOptions::new()
162 .write(true)
163 .open(path)
164 .and_then(|mut file| file.write_all(score.as_bytes()))
165 {
166 Ok(()) => Ok(()),
167 Err(e) if e.kind() == ErrorKind::PermissionDenied => {
168 eprintln!("Missing sufficient privileges to adjust OOM score");
169 Ok(())
170 }
171 Err(e) => Err(e).context("adjust OOM score"),
172 }
173}
174
175/// Set the default umask.
176pub(crate) fn set_default_umask() {
177 umask(Mode::from_bits_truncate(0o022));
178}
179
180/// The most malloc arenas of the server. glibc gives every thread an arena
181/// of its own, up to eight per CPU, and on AArch64 it backs them with 2 MiB
182/// transparent huge pages by default, so each costs up to 2 MiB resident. The
183/// runtime, which also runs the RPC `LocalSet`, runs on one thread, the others
184/// only serve short blocking work, so one arena is enough, the per thread
185/// caches of the allocator absorb most of the contention.
186#[cfg(target_env = "gnu")]
187const MALLOC_ARENAS: libc::c_int = 1;
188
189/// The size from which on glibc maps every allocation on its own, which goes
190/// back to the kernel once freed, and the free memory at the top of the heap
191/// from which on glibc returns it. Both are the defaults of glibc, which
192/// raises them to the size of every mapped allocation that gets freed, up to
193/// 32 and 64 MiB. After an exec_sync with a large output, up to 16 MiB per
194/// pipe which get copied into the response, the allocations of that size
195/// would stay in the heap then and keep it resident. Setting them turns that
196/// off.
197#[cfg(target_env = "gnu")]
198const MALLOC_THRESHOLD: libc::c_int = 128 * 1024;
199
200/// Limit the malloc arenas of the server, see [`MALLOC_ARENAS`], and fix the
201/// thresholds of glibc, see [`MALLOC_THRESHOLD`]. Runs before the fork, while
202/// the process has a single thread. The runtimes and containers keep the
203/// defaults, an exec starts their allocator anew.
204#[cfg_attr(target_env = "gnu", expect(unsafe_code, reason = "mallopt"))]
205fn configure_malloc() {
206 #[cfg(target_env = "gnu")]
207 for (param, value, name) in [
208 (libc::M_ARENA_MAX, MALLOC_ARENAS, "arenas"),
209 (libc::M_MMAP_THRESHOLD, MALLOC_THRESHOLD, "mmap threshold"),
210 (libc::M_TRIM_THRESHOLD, MALLOC_THRESHOLD, "trim threshold"),
211 ] {
212 // SAFETY: mallopt only changes a parameter of the allocator.
213 if unsafe { libc::mallopt(param, value) } != 1 {
214 // Before the logging gets set up.
215 eprintln!("Unable to set the malloc {name}");
216 }
217 }
218}
219
220/// Whether the server turned transparent huge pages off for itself, which
221/// the processes it runs must not inherit, see [`disable_thp`].
222static THP_DISABLED: AtomicBool = AtomicBool::new(false);
223
224/// Turn transparent huge pages off for the process, as early as possible.
225/// On AArch64 glibc asks for them for its heap by default, so the first
226/// growth of the heap beyond a 2 MiB boundary costs 2 MiB resident, which a
227/// server per pod does not need. mallopt cannot turn that off, and the
228/// tunable which can takes effect only from the start of the process. Huge
229/// pages which were already allocated stay.
230///
231/// The setting gets inherited and kept across exec, so the runtimes get the
232/// one the server started with, see
233/// [`reset_inherited`]. A setting of whoever started the server stays.
234pub(crate) fn disable_thp() {
235 match prctl::get_thp_disable() {
236 Ok(false) => {}
237 // Already disabled, or not supported, like under qemu-user.
238 Ok(true) | Err(Errno::EINVAL) => return,
239 Err(e) => {
240 // Before the logging gets set up.
241 eprintln!("Unable to get the transparent huge pages setting: {e}");
242 return;
243 }
244 }
245 match prctl::set_thp_disable(true) {
246 Ok(()) => THP_DISABLED.store(true, Ordering::Relaxed),
247 Err(e) => eprintln!("Unable to disable transparent huge pages: {e}"),
248 }
249}
250
251/// The OOM score adjustment the server started with, see [`reset_inherited`].
252static INHERITED_OOM_SCORE: OnceLock<Box<[u8]>> = OnceLock::new();
253
254/// The soft and hard limit of open files the server started with, if it
255/// raised the soft one, see [`raise_nofile_limit`].
256static INHERITED_NOFILE: OnceLock<(rlim_t, rlim_t)> = OnceLock::new();
257
258/// Raise the soft limit of open files to the hard one. The server holds the
259/// pipes, log files, pidfds and sockets of every container of a pod and the
260/// sessions of the streaming server, while Go programs like CRI-O start it
261/// with the soft limit they started with, often 1024. The runtimes get the
262/// limits the server started with, see [`reset_inherited`].
263fn raise_nofile_limit() {
264 let (soft, hard) = match getrlimit(Resource::RLIMIT_NOFILE) {
265 Ok(limits) => limits,
266 Err(e) => {
267 // Before the logging gets set up.
268 eprintln!("Unable to get the open files limit: {e}");
269 return;
270 }
271 };
272 if soft >= hard {
273 return;
274 }
275 match setrlimit(Resource::RLIMIT_NOFILE, hard, hard) {
276 Ok(()) => {
277 let _ = INHERITED_NOFILE.set((soft, hard));
278 }
279 Err(e) => eprintln!("Unable to raise the open files limit to {hard}: {e}"),
280 }
281}
282
283/// Remember the OOM score adjustment the server started with, before it
284/// protects itself with [`set_oom_score`].
285fn save_oom_score() {
286 match std::fs::read(OOM_SCORE_ADJ) {
287 Ok(score) => {
288 let _ = INHERITED_OOM_SCORE.set(score.trim_ascii().into());
289 }
290 Err(e) => eprintln!("Unable to read OOM score: {e}"),
291 }
292}
293
294/// Give a child the OOM score adjustment, the transparent huge pages setting
295/// and the open files limits the server started with instead of its own, see
296/// [`save_oom_score`], [`disable_thp`] and [`raise_nofile_limit`]. The
297/// runtimes pass them on to the container processes, an exec would be the
298/// last process the kernel picks otherwise. conmon resets the OOM score of
299/// its runtime the same way. Add it after the other pre_exec hooks, those run
300/// in order and may need the raised limit.
301#[expect(unsafe_code, reason = "a pre_exec hook")]
302pub(crate) fn reset_inherited(cmd: &mut Command) {
303 let score: Option<&'static [u8]> = INHERITED_OOM_SCORE.get().map(|score| &**score);
304 let thp_disabled = THP_DISABLED.load(Ordering::Relaxed);
305 let nofile = INHERITED_NOFILE.get().copied();
306 if score.is_none() && !thp_disabled && nofile.is_none() {
307 return;
308 }
309 // SAFETY: the hook allocates nothing and only calls open, write, close,
310 // prctl and setrlimit, which are async signal safe. Raising the score,
311 // enabling huge pages and lowering a limit need no privileges, a
312 // failure keeps the setting of the server, as before.
313 unsafe {
314 cmd.pre_exec(move || {
315 if let Some(score) = score
316 && let Ok(fd) = open(
317 c"/proc/self/oom_score_adj",
318 OFlag::O_WRONLY | OFlag::O_CLOEXEC,
319 Mode::empty(),
320 )
321 {
322 let _ = write(fd, score);
323 }
324 if thp_disabled {
325 let _ = prctl::set_thp_disable(false);
326 }
327 restore_nofile(nofile);
328 Ok(())
329 });
330 }
331}
332
333/// Set the open files limits of the process, if any, async signal safe for a
334/// pre_exec hook. A failure keeps the ones of the server.
335fn restore_nofile(limits: Option<(rlim_t, rlim_t)>) {
336 if let Some((soft, hard)) = limits {
337 let _ = setrlimit(Resource::RLIMIT_NOFILE, soft, hard);
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::{
345 child_reaper::{ChildReaper, RuntimeCall},
346 container_io::ContainerIO,
347 container_log::ContainerLog,
348 test_util::run_in_own_process,
349 };
350 use std::{fs, os::unix::fs::PermissionsExt};
351 use tokio::time::{Duration, Instant};
352
353 #[test]
354 fn inherited_fds_reports_descriptors_above_stderr() -> Result<()> {
355 let file = tempfile::tempfile()?;
356 let fd = file.as_raw_fd();
357
358 let fds = inherited_fds();
359
360 assert!(
361 fds.contains(&fd),
362 "expected an open descriptor {fd} to be reported, got {fds:?}"
363 );
364 // stdin, stdout and stderr are never reported, they are not ours to close.
365 assert!(!fds.contains(&0));
366 assert!(!fds.contains(&1));
367 assert!(!fds.contains(&2));
368
369 Ok(())
370 }
371
372 #[test]
373 fn oom_score_gets_written() -> Result<()> {
374 let dir = tempfile::tempdir()?;
375 let path = dir.path().join("oom_score_adj");
376 fs::write(&path, "0")?;
377 set_oom_score_at(&path, "-1000")?;
378 assert_eq!(fs::read_to_string(&path)?, "-1000");
379
380 // Without the privileges it stays as it is.
381 fs::set_permissions(&path, fs::Permissions::from_mode(0o444))?;
382 set_oom_score_at(&path, "-999")?;
383
384 // Anything else fails.
385 assert!(set_oom_score_at(&dir.path().join("missing").join("file"), "0").is_err());
386 assert!(set_oom_score_at(dir.path(), "0").is_err());
387 Ok(())
388 }
389
390 #[tokio::test]
391 #[expect(unsafe_code, reason = "a pre_exec hook")]
392 async fn restore_nofile_sets_the_child_limit() -> Result<()> {
393 // Like a server which raised the soft limit it started with. Only the
394 // child gets the lower one, the limits of the tests stay.
395 let limits = getrlimit(Resource::RLIMIT_NOFILE)?;
396 let inherited = limits.0.min(4096) / 2;
397 let mut cmd = Command::new("sh");
398 cmd.args(["-c", "ulimit -Sn"]);
399 // SAFETY: the hook only calls setrlimit, see `reset_inherited`.
400 unsafe {
401 cmd.pre_exec(move || {
402 restore_nofile(Some((inherited, limits.1)));
403 Ok(())
404 });
405 }
406 let output = cmd.output().await?;
407 assert_eq!(
408 String::from_utf8(output.stdout)?.trim(),
409 inherited.to_string()
410 );
411 assert_eq!(getrlimit(Resource::RLIMIT_NOFILE)?, limits);
412 Ok(())
413 }
414
415 /// Set for the test process which runs the test of the settings.
416 const SETTINGS_TEST_ENV: &str = "CONMONRS_SETTINGS_TEST";
417
418 /// The server remembers the settings it started with once, and the test
419 /// changes the ones of its process, which the children of the other
420 /// tests would inherit. It runs in a process of its own.
421 #[test]
422 fn runtimes_get_the_settings_the_server_started_with() -> Result<()> {
423 let test = "init::tests::settings_in_own_process";
424 run_in_own_process(test, SETTINGS_TEST_ENV, false)
425 }
426
427 #[tokio::test]
428 #[ignore = "run by runtimes_get_the_settings_the_server_started_with in a process of its own"]
429 async fn settings_in_own_process() -> Result<()> {
430 if std::env::var_os(SETTINGS_TEST_ENV).is_none() {
431 return Ok(());
432 }
433 // Like a server at its start, which then protects itself from the
434 // OOM killer. The test raises its score instead, which needs no
435 // privileges, unless it has the highest one already, like in a
436 // pod of the best effort class.
437 let read_score = || anyhow::Ok(fs::read_to_string(OOM_SCORE_ADJ)?.trim().to_owned());
438 let score = read_score()?;
439 save_oom_score();
440 let raised = (score.parse::<i32>()? + 1).min(1000);
441 set_oom_score(&raised.to_string())?;
442 let own_score = read_score()?;
443 if own_score == score {
444 eprintln!("skipped: unable to change the OOM score, the reset does not show");
445 }
446 // With a soft limit of open files below the hard one, which the
447 // server raises.
448 let (soft, hard) = getrlimit(Resource::RLIMIT_NOFILE)?;
449 let soft = soft.min(hard).min(4096) / 2;
450 setrlimit(Resource::RLIMIT_NOFILE, soft, hard)?;
451 raise_nofile_limit();
452 assert_eq!(getrlimit(Resource::RLIMIT_NOFILE)?, (hard, hard));
453
454 let settings = "cat /proc/self/oom_score_adj; ulimit -Sn";
455 let child = async |reset: bool| -> Result<String> {
456 let mut cmd = Command::new("sh");
457 cmd.args(["-c", settings]);
458 if reset {
459 reset_inherited(&mut cmd);
460 }
461 Ok(String::from_utf8(cmd.output().await?.stdout)?)
462 };
463 // A child inherits the settings of the server otherwise.
464 assert_eq!(child(false).await?, format!("{own_score}\n{hard}\n"));
465 let inherited = format!("{score}\n{soft}\n");
466 assert_eq!(child(true).await?, inherited);
467
468 // The runtime of a call gets them reset.
469 let dir = tempfile::tempdir()?;
470 let (pidfile, path) = (dir.path().join("pid"), dir.path().join("settings"));
471 let script = format!(
472 "{{ {settings}; }} >{}; echo $$ >{}",
473 path.display(),
474 pidfile.display()
475 );
476 let args = ["-c".into(), script];
477 let mut io = ContainerIO::new(false, ContainerLog::new())?;
478 let deadline = Instant::now() + Duration::from_secs(600);
479 let call = RuntimeCall::new(Path::new("sh"), &args, &pidfile, deadline);
480 let started = ChildReaper::default().create_child(call, &mut io).await?;
481 started.token.cancel();
482 assert_eq!(fs::read_to_string(path)?, inherited);
483 Ok(())
484 }
485
486 /// The transparent huge pages setting of a process, true if enabled.
487 fn thp_enabled(status: &str) -> Option<bool> {
488 let line = status.lines().find(|l| l.starts_with("THP_enabled:"))?;
489 Some(line.split_whitespace().nth(1)? == "1")
490 }
491
492 async fn child_thp_enabled(reset: bool) -> Result<Option<bool>> {
493 let mut cmd = Command::new("cat");
494 cmd.arg("/proc/self/status");
495 if reset {
496 reset_inherited(&mut cmd);
497 }
498 let output = cmd.output().await?;
499 Ok(thp_enabled(&String::from_utf8(output.stdout)?))
500 }
501
502 /// Changes the setting of the test process, which only its children
503 /// inherit, and restores it afterwards. The runtimes which other tests
504 /// spawn meanwhile get it reset, which does not matter to them.
505 #[tokio::test]
506 async fn runtimes_do_not_inherit_disabled_thp() -> Result<()> {
507 if thp_enabled(&fs::read_to_string("/proc/self/status")?) != Some(true)
508 || prctl::get_thp_disable() != Ok(false)
509 {
510 // Disabled by whoever started the tests, which stays, or not
511 // supported by the kernel.
512 eprintln!("skipped: transparent huge pages are disabled or not supported");
513 return Ok(());
514 }
515 disable_thp();
516 let disabled = thp_enabled(&fs::read_to_string("/proc/self/status")?);
517 let inherited = child_thp_enabled(false).await;
518 let reset = child_thp_enabled(true).await;
519 prctl::set_thp_disable(false)?;
520 THP_DISABLED.store(false, Ordering::Relaxed);
521
522 assert_eq!(disabled, Some(false));
523 assert_eq!(inherited?, Some(false));
524 assert_eq!(reset?, Some(true));
525 Ok(())
526 }
527}