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#
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#
/// 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:
setsid— deixa de ter terminal de controlo; sobrevive ao fecho da shell que o lançou.detach_stdio(só emcreate) — o stdio do container vai paraoutput.log; ostdinpara/dev/null.unshare+map_ids+loopback_up(capítulo 12), depoisforkdo init.- Anexa o init ao cgroup logo que existe — antes de o init fazer o resto do setup.
- Espera o relatório do init e reencaminha-o.
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#
/// 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#
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 ostartgravarrunning. Se ostartescrevesserunningsem olhar, sobrepunha ostoppedque o supervisor já gravou. Por isso só passa arunningse ainda estivercreated— e isto só funciona porque as duas escritas passam pelo mesmoflock:
Estado em disco: flock e escrita atómica#
/// 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:
#[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.
/// 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#
$ 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→createdcom o PID real do init (host);start→running;logsmostra o que o container escreveu.createduplicado →already exists, saída 5.deletede um vivo → recusa, saída 5.kill(SIGKILL) →stopped. UmSIGTERMnão funcionaria: o init de um PID namespace só recebe sinais que tenha tratado — exceptoSIGKILLvindo de fora, que é garantido. É a mesma razão pela qualdocker stopespera e depois mandaSIGKILL.- Depois de
delete,staterespondeno 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.