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-spec: o que é uma imagem#
Uma imagem é um grafo de blobs endereçados pelo seu hash. No disco (image layout):
$ 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}:
$ 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:
- 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. - Layers são diffs de sistema de ficheiros, empilhados por ordem. A layer 2 pode acrescentar ficheiros, sobrescrever, e apagar — com um whiteout.
- A config não corre nada.
Entrypoint,Cmd,Env,WorkingDirsã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.
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:
/// 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:
$ 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:
/// `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:
/// 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:
$ 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):
$ 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.