delonix-rust tutorial

Linux e containers · 30 min de leitura

OCI — imagem e runtime#

A Open Container Initiative define duas normas independentes. Perceber que são duas é metade do trabalho:

Norma Responde a Artefacto
image-spec Como se empacota e distribui uma imagem? image layout: index.json, manifest, config, layers
runtime-spec Como se corre um container a partir de uma pasta? bundle: config.json + rootfs/, e um ciclo de vida

Entre as duas há uma ponte, e é o trabalho de qualquer ferramenta tipo umoci/skopeo/docker: desempacotar a imagem num bundle. Um runtime de baixo nível (runc, crun, o minicontainer) só conhece o bundle.

image-specruntime-spec Imagem (layout) index.json↓ manifest↓ config + layersblobs/sha256/<hash>endereçada por conteúdo unpack Bundle config.json+ rootfs/verifica digestsaplica layers e whiteouts run Runtime OCI createstartkilldelete · staterunc · mc · delonix processoisolado, PID 1limitado um runtime de baixo nível só conhece o bundle — desempacotar a imagem é trabalho de quem chama

image-spec: o que é uma imagem#

Uma imagem é um grafo de blobs endereçados pelo seu hash. No disco (image layout):

saída real · medida neste host
$ find oci-layout -type f   # (digests abreviados)
./blobs/sha256/a184019c3c14…
./blobs/sha256/c47a79988f17…
./blobs/sha256/d16cfcdae88e…
./blobs/sha256/e935c9788314…
./index.json
./oci-layout
$ mc unpack layout bundle2
[exit 0]
$ ls bundle2/rootfs/etc   # 'removido-pela-layer-2' foi apagado pelo whiteout
motd
passwd
$ jq -c .process bundle2/config.json
{"terminal":false,"args":["/bin/sh","-c","cat /etc/motd; ls /etc; echo cwd=$(pwd) MSG=$MSG"],"env":["PATH=/bin","MSG=ola-da-imagem"],"cwd":"/tmp"}
$ mc run img -b bundle2
vem da layer 2
motd
passwd
cwd=/tmp MSG=ola-da-imagem
[exit 0]

Do topo para baixo: index.json aponta para um manifest; o manifest aponta para uma config e para uma lista ordenada de layers. Cada ponteiro é um descritor {mediaType, digest, size}:

saída real · medida neste host
$ cat index.json
{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:d16cfcdae88e9d49d389014575e797b12a2e397d91fc9e6d39c859a445802359","size":669}]}
$ cat blobs/sha256/<manifest>
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:e935c9788314377cdd806df3dc256af32fcb7405d50b0f141811521508d9c220",
    "size": 514
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:c47a79988f17e55f4a5513b1eb9d62dc3d103791bbf253e17f51e7300c017bdb",
      "size": 1110580
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:a184019c3c14966202cfd39730b5ea811f4de055310e725f0bb61c3d10463091",
      "size": 213
    }
  ]
}
$ cat blobs/sha256/<config>
{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Entrypoint": [
      "/bin/sh",
      "-c"
    ],
    "Cmd": [
      "cat /etc/motd; ls /etc; echo cwd=$(pwd) MSG=$MSG"
    ],
    "Env": [
      "PATH=/bin",
      "MSG=ola-da-imagem"
    ],
    "WorkingDir": "/tmp"
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:ad097e57710b778cc4773d479965b38f95e387d90bdbc9633ee37bcf874680d8",
      "sha256:13cb15d282896b8ae63864034dc1c56ad3357cc73f0ceb1be0387d9649fb79c6"
    ]
  }
}

Três ideias a reter:

  1. Endereçamento por conteúdo. O nome do ficheiro em blobs/sha256/ é o SHA-256 do seu conteúdo. Dois ficheiros iguais são um só; um ficheiro alterado tem outro nome. É por isto que uma imagem se pode partilhar entre imagens e verificar.
  2. Layers são diffs de sistema de ficheiros, empilhados por ordem. A layer 2 pode acrescentar ficheiros, sobrescrever, e apagar — com um whiteout.
  3. A config não corre nada. Entrypoint, Cmd, Env, WorkingDir são só o que a imagem sugere; quem decide é o bundle.

Whiteouts: como se apaga numa camada só de adições#

Um tar só sabe acrescentar. Para a layer 2 «apagar» /etc/removido, contém um ficheiro vazio chamado .wh.removido no mesmo directório (e .wh..wh..opq para esvaziar um directório inteiro). No output acima: ls bundle2/rootfs/etc mostra só motd e passwd — o ficheiro da layer 1 foi apagado pelo whiteout da layer 2.

minicontainer/src/image.rs · layerver no GitHub ↗
fn apply_layer(blob: &Path, media_type: &str, rootfs: &Path) -> Result<()> {
    let file = fs::File::open(blob).ctx(|| format!("opening {}", blob.display()))?;
    let reader: Box<dyn Read> = match media_type {
        "application/vnd.oci.image.layer.v1.tar+gzip" => Box::new(flate2::read::GzDecoder::new(file)),
        "application/vnd.oci.image.layer.v1.tar" => Box::new(file),
        other => return Err(Error::Unsupported(format!("layer media type {other}"))),
    };
    let mut archive = tar::Archive::new(reader);
    archive.set_preserve_ownerships(false); // sem root não há chown; e não devia haver.
    archive.set_overwrite(true);

    for entry in archive.entries().ctx(|| format!("reading {}", blob.display()))? {
        let mut entry = entry.ctx(|| "reading tar entry".to_string())?;
        let path = entry.path().ctx(|| "tar entry path".to_string())?.into_owned();
        let kind = entry.header().entry_type();

        // Whiteouts: `.wh.<nome>` apaga o que as layers de baixo puseram; `.wh..wh..opq` esvazia o dir.
        if let Some(name) = path.file_name().and_then(|n| n.to_str())
            && let Some(target) = name.strip_prefix(".wh.")
        {
            let parent =
                crate::fsutil::resolve_in_root(rootfs, path.parent().unwrap_or_else(|| Path::new("")), false);
            if let Ok(parent) = parent {
                if target == ".wh..opq" {
                    empty_dir(&parent)?;
                } else {
                    remove_any(&parent.join(target))?;
                }
            }
            continue;
        }
        // Sem root não se criam device nodes — e um device dentro de uma imagem é suspeito.
        if kind.is_block_special() || kind.is_character_special() {
            eprintln!("warning: skipping device node {}", path.display());
            continue;
        }
        // `unpack_in` recusa `..` e symlinks que escapem do destino.
        if !entry.unpack_in(rootfs).ctx(|| format!("unpacking {}", path.display()))? {
            return Err(Error::UnsafePath(path));
        }
    }
    make_dirs_writable(rootfs)
}

Verificar tudo, antes de escrever#

O unpack do minicontainer verifica cada blob contra o digest que o referencia antes de o usar — e verifica todas as layers antes de escrever um único byte no bundle:

minicontainer/src/image.rs · verifiedver no GitHub ↗
/// Devolve o caminho do blob **já verificado**.
fn verified_blob(layout: &Path, digest: &str) -> Result<PathBuf> {
    let (path, expected) = blob_path(layout, digest)?;
    let actual = sha256_file(&path)?;
    if actual != expected {
        return Err(Error::Digest { what: digest.into(), expected, actual });
    }
    Ok(path)
}

Adultera um byte e a extracção recusa, sem deixar lixo:

saída real · medida neste host
$ echo x >> blobs/sha256/<layer1>   # adulterar 1 byte
$ mc unpack layout-adulterado bundle3
mc: digest mismatch for sha256:<sha256>: expected <sha256>, got <sha256>
[exit 1]

E o formato do digest é validado antes de tocar no disco — um digest como sha256:../../etc/passwd sairia do layout:

minicontainer/src/image.rs · blob-pathver no GitHub ↗
/// `sha256:<64 hex>` → caminho do blob. A validação do formato NÃO é cosmética:
/// um `digest` como `sha256:../../etc/passwd` sairia do layout.
fn blob_path(layout: &Path, digest: &str) -> Result<(PathBuf, String)> {
    let hex = digest
        .strip_prefix("sha256:")
        .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
        .ok_or_else(|| Error::Spec(format!("unsupported digest {digest:?}")))?;
    Ok((layout.join("blobs/sha256").join(hex), hex.to_ascii_lowercase()))
}

O digest decorativo — uma auditoria real

O delonix já teve um ALTO exactamente aqui, um nível acima: pull repo@sha256:X verificava cada blob contra o que o manifest declarava, mas nunca o manifest contra o digest pedido. Um registo comprometido devolvia um manifest totalmente diferente, internamente consistente, e o motor instalava o conteúdo do atacante sem um erro. O pin era decorativo. A correcção:

delonix-runtime · crates/adapters/delonix-oci/src/registry.rs · linhas 979–1002ver no GitHub ↗
/// When `reference` names a content digest (`sha256:...`), verifies the fetched
/// manifest bytes hash to EXACTLY that digest. A digest-pinned pull
/// (`repo@sha256:...`) is the whole point of pinning: even a compromised or
/// MITM'd registry must not be able to substitute the content. The blobs are
/// already checked against the digests the manifest declares — but if the
/// manifest ITSELF is not checked against the pinned reference, the registry
/// can serve a completely different, internally-consistent manifest (pointing
/// at the attacker's blobs) and the pin becomes decorative. The chain of trust
/// for a digest pull rests entirely on this check. A tag reference (`:latest`)
/// has no digest to verify, so it is a no-op there — TLS is the only integrity
/// for tags, same as `docker pull`.
fn verify_manifest_digest(reference: &str, manifest_bytes: &[u8]) -> Result<()> {
    if let Some(want) = reference.strip_prefix("sha256:") {
        let got = sha256_hex(manifest_bytes);
        if !got.eq_ignore_ascii_case(want) {
            return Err(Error::DigestMismatch(format!(
                "manifest digest mismatch: reference pins sha256:{want} but the registry \
                 served a manifest hashing to sha256:{got} — refusing (possible compromised \
                 registry or MITM)"
            )));
        }
    }
    Ok(())
}

runtime-spec: como se corre uma pasta#

O bundle é uma pasta com config.json e o rootfs. O config.json diz tudo o que o runtime tem de aplicar:

saída real · medida neste host
$ mc spec bundle -- /bin/sh   # gera um config.json mínimo
{
  "ociVersion": "1.0.2",
  "process": {
    "terminal": false,
    "args": [
      "/bin/sh"
    ],
    "env": [
      "PATH=/usr/local/bin:/usr/bin:/bin:/sbin",
      "TERM=xterm"
    ],
    "cwd": "/"
  },
  "root": {
    "path": "rootfs",
    "readonly": false
  },
  "hostname": "minicontainer",
  "mounts": [
    {
      "destination": "/proc",
      "type": "proc",
      "source": "proc",
      "options": [
        "nosuid",
        "noexec",
        "nodev"
      ]
    },
    {
      "destination": "/dev",
      "type": "tmpfs",
      "source": "tmpfs",
      "options": [
        "nosuid",
        "mode=755"
      ]
    },
    {
      "destination": "/tmp",
      "type": "tmpfs",
      "source": "tmpfs",
      "options": [
        "nosuid",
        "nodev",
        "mode=1777"
      ]
    }
  ],
  "linux": {}
}

Cada campo corresponde a um mecanismo do capítulo anterior:

Campo Mecanismo
root.path, root.readonly pivot_root (+ remount só de leitura)
mounts[] mount(2) — proc, tmpfs, bind
hostname sethostname no uts namespace
linux.namespaces[] quais unshare
linux.resources ficheiros do cgroup v2
process.args/env/cwd o exec final
linux.seccomp, process.capabilities filtro e conjunto limitador

O ciclo de vida#

A runtime-spec fixa cinco operações e quatro estados. É isto que o runc, o crun e o minicontainer partilham — e é o que uma camada acima (o CRI) invoca:

                create               start                 (o processo sai / kill)
   (nada) ──────────────▶ created ──────────────▶ running ────────────────────▶ stopped ── delete ──▶ (nada)
                    │                                                              ▲
                    └── creating (transitório: o runtime está a preparar)          │
   state <id>  → JSON {ociVersion, id, status, pid, bundle}      kill <id> <sinal> ─┘

A separação create / start não é decorativa: entre as duas o container existe (namespaces, mounts, cgroup prontos) mas o processo do utilizador ainda não correu. É a janela em que o orquestrador liga a rede, corre hooks e só então dá o tiro de partida.

Contra-prova: o mesmo bundle corre no runc#

Um runtime que só corre os seus bundles não cumpre a norma. O bundle que o minicontainer acabou de correr, entregue ao runc 1.5.1 (só com os mapeamentos de user namespace que o runc exige explícitos):

saída real · medida neste host
$ runc --version | head -1
runc version 1.5.1
$ runc run rc   # o MESMO bundle que o mc acabou de correr (+ mapeamentos de user ns)
vem da layer 2
motd
passwd
cwd=/tmp MSG=ola-da-imagem
[exit 0]

Mesma saída. Isto é a prova de conformidade que interessa: dois runtimes independentes, uma norma.

O que esta prova NÃO cobre

Corre-se um bundle. A suite oficial (opencontainers/runtime-tools, validation) testa centenas de casos e o minicontainer não a passa toda — recusa por desenho o que não implementa (seccomp, terminal, juntar-se a namespaces existentes). Está dito no código e testado: um bundle que peça seccomp é recusado, não ignorado.

Do que o delonix é feito#

O delonix não delega o «desempacotar» a outra ferramenta: o crate delonix-oci faz pull de registos, gere o armazenamento por conteúdo (CAS) e as layers, e os containers rootless partilham as layers por overlayfs montado dentro do user namespace (em vez de uma cópia por container — medido: containers/ de 47 para 7,2 GiB). Isso é matéria de Anatomia do delonix; aqui o que importa é que o contrato com o exterior é este: imagem OCI dentro, bundle OCI para o runtime.