Skip to main content

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}