delonix-rust tutorial

Projecto: minicontainer · 30 min de leitura

5 · Ciclo de vida e estado#

Como é que mc create volta ao terminal, deixando um container a esperar, e mc start — um processo diferente, minutos depois — o faz arrancar? Sem daemon, sem socket. A resposta é um supervisor, um FIFO e ficheiros.

create: dois relatórios em cadeia#

minicontainer/src/container.rs · spawnver no GitHub ↗
fn spawn(store: &Store, id: &str, spec: &Spec, rootfs: &Path, stdio: Stdio) -> Result<()> {
    let fifo = store.fifo(id)?;
    mkfifo(&fifo, Mode::from_bits_truncate(0o600))?;
    // O cgroup é preparado ANTES do fork: um erro de delegação chega ao utilizador com o remédio.
    let cgroup = Cgroup::create(id, spec.linux.resources.as_ref())?;

    let (rd, wr) = pipe()?;
    // SAFETY: o processo é single-thread neste ponto (nenhuma thread foi lançada), logo
    // `fork` é seguro; o filho só chama funções async-signal-safe até ao `exec`/`_exit`.
    match unsafe { fork() }? {
        ForkResult::Parent { .. } => {
            drop(wr);
            let report = recv_report(rd)?;
            let pid: i32 = report.parse().map_err(|_| Error::Setup(format!("bad report {report:?}")))?;
            let cg = cgroup.as_ref().map(|c| c.path().to_owned());
            store.update(id, |st| {
                st.pid = pid;
                st.cgroup = cg;
                if st.status == Status::Creating {
                    st.status = Status::Created;
                }
                Ok(())
            })
        }
        ForkResult::Child => {
            drop(rd);
            supervise(store, id, spec, rootfs, &fifo, cgroup, wr, stdio)
        }
    }
}

O create faz fork e espera um relatório do supervisor por um pipe. O supervisor faz o mesmo ao seu filho (o init). É uma cadeia de dois elos, e cada elo traduz o erro do seguinte:

mc create ──pipe──◀ supervisor ──pipe──◀ init
                       (relatório K<pid> ou E<mensagem>)

Se o init falhar a montar o rootfs, a mensagem sobe até ao utilizador (mc: container setup failed: … EPERM …) e o create desfaz o que criou (store.remove(id)) — nunca deixa um meio-container.

O protocolo do relatório — e o bug do EOF#

minicontainer/src/container.rs · recvver no GitHub ↗
/// Lê o relatório: `K<texto>` = ok, `E<mensagem>` = erro, EOF = o filho morreu sem dizer nada.
///
/// UMA leitura, não «até ao EOF»: o emissor pode manter o pipe aberto até ao `exec`
/// (o init só o fecha depois do `start`), e esperar por EOF bloquearia o `create` para sempre.
fn recv_report(fd: OwnedFd) -> Result<String> {
    let mut raw = [0u8; 4096];
    let n = fs::File::from(fd).read(&mut raw).ctx(|| "reading start-up report".to_string())?;
    let buf = String::from_utf8_lossy(&raw[..n]).into_owned();
    match buf.split_at_checked(1) {
        Some(("K", rest)) => Ok(rest.to_owned()),
        Some(("E", msg)) => Err(Error::Setup(msg.to_owned())),
        _ => Err(Error::Setup("container process died during setup".into())),
    }
}

Uma leitura, não «até ao EOF». A primeira versão fazia read_to_string e o mc create bloqueava para sempre: o init mantém o pipe aberto até ao exec (só acontece depois do start), logo o EOF nunca chegava. O comentário no código guarda a lição. Bug real, apanhado ao correr a primeira vez.

O supervisor#

minicontainer/src/container.rs · supervisever no GitHub ↗89 linhas
/// Corre no processo supervisor. Nunca regressa.
#[allow(clippy::too_many_arguments)]
fn supervise(
    store: &Store,
    id: &str,
    spec: &Spec,
    rootfs: &Path,
    fifo: &Path,
    cg: Option<Cgroup>,
    out: OwnedFd,
    stdio: Stdio,
) -> ! {
    let _ = setsid();
    if stdio == Stdio::Log
        && let Err(e) = detach_stdio(&store.dir(id).unwrap_or_default().join("output.log"))
    {
        die(out, &e);
    }
    let (irx, itx) = match pipe() {
        Ok(p) => p,
        Err(e) => die(out, &Error::Sys(e)),
    };
    // Ler os ids ANTES do unshare: depois dele `getuid()` devolve o uid «overflow» (65534)
    // e o `uid_map` seria recusado com EPERM.
    let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
    let prep = || -> Result<()> {
        unshare(
            CloneFlags::CLONE_NEWUSER
                | CloneFlags::CLONE_NEWPID
                | CloneFlags::CLONE_NEWUTS
                | CloneFlags::CLONE_NEWIPC
                | CloneFlags::CLONE_NEWNET,
        )?;
        map_ids(uid, gid)?;
        loopback_up()
    };
    if let Err(e) = prep() {
        die(out, &e);
    }
    // SAFETY: continuamos single-thread; ver `spawn`.
    let init = match unsafe { fork() } {
        Ok(ForkResult::Child) => {
            drop(irx);
            let err = match container_init(spec, rootfs, fifo, &itx) {
                Ok(never) => match never {},
                Err(e) => e,
            };
            send(&itx, format!("E{err}").as_bytes());
            std::process::exit(1);
        }
        Ok(ForkResult::Parent { child }) => child,
        Err(e) => die(out, &Error::Sys(e)),
    };
    drop(itx);
    if let Some(c) = &cg
        && let Err(e) = c.attach(init.as_raw())
    {
        let _ = kill(init, Signal::SIGKILL);
        die(out, &e);
    }
    match recv_report(irx) {
        Ok(_) => {
            send(&out, format!("K{}", init.as_raw()).as_bytes());
            drop(out);
        }
        Err(e) => {
            let _ = waitpid(init, None);
            die(out, &e);
        }
    }

    // Espera pelo fim do container e regista o resultado.
    let code = match waitpid(init, None) {
        Ok(WaitStatus::Exited(_, c)) => c,
        Ok(WaitStatus::Signaled(_, sig, _)) => 128 + sig as i32,
        _ => 255,
    };
    let oom = cg.as_ref().is_some_and(|c| c.oom_kills() > 0);
    let _ = store.update(id, |st| {
        st.status = Status::Stopped;
        st.exit_code = Some(code);
        st.oom_killed = oom;
        Ok(())
    });
    if let Some(c) = &cg {
        Cgroup::remove(c.path());
    }
    std::process::exit(0)
}

Lê-o como uma história:

  1. setsid — deixa de ter terminal de controlo; sobrevive ao fecho da shell que o lançou.
  2. detach_stdio (só em create) — o stdio do container vai para output.log; o stdin para /dev/null.
  3. unshare + map_ids + loopback_up (capítulo 12), depois fork do init.
  4. Anexa o init ao cgroup logo que existe — antes de o init fazer o resto do setup.
  5. Espera o relatório do init e reencaminha-o.
  6. waitpid(init) — e daqui em diante só há um trabalho: ver o container morrer e gravar o resultado.

O bug do stdout que prende pipes#

minicontainer/src/container.rs · stdiover no GitHub ↗
/// Para onde vai o stdio do container.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stdio {
    /// Herda o do chamador (`mc run`: o utilizador vê a saída).
    Inherit,
    /// Vai para `<estado>/output.log` (`mc create`): o container sobrevive ao comando que o criou,
    /// e um chamador que capture a saída por pipe não fica preso à espera de um EOF que só
    /// chegaria quando o container morresse.
    Log,
}

Bug real, apanhado pelos testes de integração: Command::output() bloqueou 3 minutos num mc create. O supervisor e o init herdavam o stdout do chamador — um pipe —, e enquanto o container vivesse, esse pipe tinha um escritor, logo output() nunca via EOF. É o mesmo defeito que o delonix documenta com o processo do pin de rede: «o processo é detached» não quer dizer «já não preciso dos fds do chamador». A regra: um processo de vida longa nunca herda o stdio de quem o lançou. Em create vai para um ficheiro (mc logs <id>); em run (foreground) herda de propósito, para veres a saída.

start: um byte num FIFO#

minicontainer/src/container.rs · startver no GitHub ↗
pub fn start(store: &Store, id: &str) -> Result<()> {
    let st = reconcile(store.load(id)?);
    if st.status != Status::Created {
        return Err(Error::WrongState { id: id.into(), status: st.status.to_string(), expected: "created" });
    }
    let mut fifo =
        fs::OpenOptions::new().write(true).open(store.fifo(id)?).ctx(|| "opening start fifo".to_string())?;
    fifo.write_all(&[1]).ctx(|| "signalling start".to_string())?;
    // Compare-and-set: se o container já acabou (comando curto), o supervisor ganhou — não desfazer.
    store.update(id, |s| {
        if s.status == Status::Created {
            s.status = Status::Running;
        }
        Ok(())
    })
}

O init está bloqueado num read de 1 byte do FIFO. start abre-o e escreve um byte. É tudo. O FIFO foi criado com mkfifo no create e aberto antes do pivot_root (depois dele o caminho já não existe); aberto O_RDWR, para o open não bloquear.

Duas subtilezas:

  • Compare-and-set. Um comando curto (echo hi) pode terminar antes de o start gravar running. Se o start escrevesse running sem olhar, sobrepunha o stopped que o supervisor já gravou. Por isso só passa a running se ainda estiver created — e isto só funciona porque as duas escritas passam pelo mesmo flock:

Estado em disco: flock e escrita atómica#

minicontainer/src/state.rs · state-updatever no GitHub ↗
    /// Read-modify-write sob `flock`. A closure decide o que fazer com o estado ACTUAL.
    pub fn update<T>(&self, id: &str, f: impl FnOnce(&mut State) -> Result<T>) -> Result<T> {
        let dir = self.dir(id)?;
        let lock_file =
            fs::OpenOptions::new().create(true).append(true).open(dir.join("lock")).map_err(|e| {
                if e.kind() == std::io::ErrorKind::NotFound {
                    Error::NotFound(id.into())
                } else {
                    Error::io("opening lock", e)
                }
            })?;
        let _guard = Flock::lock(lock_file, FlockArg::LockExclusive).map_err(|(_, e)| Error::Sys(e))?;
        let mut state = self.load(id)?;
        let out = f(&mut state)?;
        self.write(&state)?;
        Ok(out)
    }

O padrão de sempre — o do capítulo 6: lock exclusivo → reler sob o lock → aplicar a closure → escrever atómico. A closure recebe o estado actual e decide (Ok(()) grava; Err aborta sem gravar). O teste que o guarda lança 16 threads:

minicontainer/src/state.rs · concurrent-testver no GitHub ↗
    #[test]
    fn concurrent_updates_do_not_lose_writes() {
        let (_d, s) = store();
        let mut init = st("c");
        init.pid = 0;
        s.create(&init).unwrap();
        let s = std::sync::Arc::new(s);
        let handles: Vec<_> = (0..16)
            .map(|_| {
                let s = s.clone();
                std::thread::spawn(move || {
                    s.update("c", |st| {
                        let v = st.pid;
                        std::thread::sleep(std::time::Duration::from_millis(2));
                        st.pid = v + 1;
                        Ok(())
                    })
                    .unwrap();
                })
            })
            .collect();
        handles.into_iter().for_each(|h| h.join().unwrap());
        assert_eq!(s.load("c").unwrap().pid, 16);
    }

Sem o flock, algumas das 16 escritas perdiam-se e pid acabava < 16.

O ficheiro de lock é um ficheiro à parte (lock), e não o state.json, porque o state.json é substituído por rename — um lock sobre um ficheiro que é apagado e recriado deixa de proteger nada.

reconcile: o estado em disco pode mentir#

O supervisor pode ser morto (kill -9), a máquina pode reiniciar. O state.json diz running para um PID que já não existe.

minicontainer/src/state.rs · reconcilever no GitHub ↗
/// O estado em disco pode mentir (o supervisor morreu, a máquina reiniciou): reconcilia com a
/// realidade. Nunca se confia só no ficheiro — a mesma lição do `reconcile_status` do delonix.
pub fn reconcile(mut st: State) -> State {
    if matches!(st.status, Status::Created | Status::Running)
        && nix::sys::signal::kill(nix::unistd::Pid::from_raw(st.pid), None).is_err()
    {
        st.status = Status::Stopped;
    }
    st
}

Nunca se confia só no ficheiro: confronta-se com kill(pid, 0) (o sinal 0 não envia nada, só testa se o processo existe). É a versão mínima do reconcile_status do delonix.

Limitação conhecida: PIDs reciclados

kill(pid, 0) responde «existe algum processo com este PID» — não «o meu container». Num host de vida longa o PID pode ser reciclado. O delonix guarda o starttime do processo (de /proc/<pid>/stat) e só sinaliza se bater (safe_to_signal) — um processo de outro nunca leva um SIGTERM por engano.

O ciclo completo, com a saída real#

saída real · medida neste host
$ mc create life -b bundle
[exit 0]
$ mc state life
{
  "ociVersion": "1.0.2",
  "id": "life",
  "status": "created",
  "pid": 1990121,
  "bundle": "bundle"
}
$ mc start life
[exit 0]
$ mc list
life                 running  1990121  bundle
$ mc logs life
arrancou
$ mc create life -b bundle   # duplicado
mc: container already exists: life
[exit 5]
$ mc delete life             # ainda vivo
mc: container life is running, expected stopped (or use --force)
[exit 5]
$ mc kill life
[exit 0]
$ mc state life
{
  "ociVersion": "1.0.2",
  "id": "life",
  "status": "stopped",
  "pid": 1990121,
  "bundle": "bundle",
  "exitCode": 137
}
$ mc delete life
[exit 0]
$ mc state life
mc: no such container: life
[exit 4]

Lê os detalhes, porque cada linha é uma decisão:

  • create → created com o PID real do init (host); start → running; logs mostra o que o container escreveu.
  • create duplicado → already exists, saída 5. delete de um vivo → recusa, saída 5.
  • kill (SIGKILL) → stopped. Um SIGTERM não funcionaria: o init de um PID namespace só recebe sinais que tenha tratado — excepto SIGKILL vindo de fora, que é garantido. É a mesma razão pela qual docker stop espera e depois manda SIGKILL.
  • Depois de delete, state responde no such container, saída 4.

Verifica#

cargo test -p minicontainer state::
cargo test -p minicontainer --test e2e lifecycle

Falta responder à pergunta mais importante do projecto: como sabemos que funciona? Testes e validação.