O Container Zumbi: o Testcontainers Singleton em Rust Vaza (e Ninguém te Avisa)

O Container Zumbi: o Testcontainers Singleton em Rust Vaza (e Ninguém te Avisa)

No artigo anterior, mostrei como escrever testes de integração em Rust usando Testcontainers contra Postgres e Redis reais. Funciona bem, mas tem um detalhe que só apareceu quando levei o padrão "container singleton" para produção: se você não tomar cuidado, cada cargo test deixa um container Postgres zumbi rodando na sua máquina, para sempre.

Não é um bug raro. É uma consequência direta de como static funciona em Rust, combinada com o fato de que testcontainers-rs — ao contrário das versões Java e Go — não tem um reaper estilo ryuk. Vale entender por quê antes de copiar o padrão de singleton do artigo anterior para o seu projeto.

O padrão que parecia certo

A ideia é simples: em vez de subir um container novo a cada teste, sobe um container Postgres uma vez por binário de teste e reaproveita para todos, criando um banco de dados isolado por teste dentro dele.

static CONTAINER: OnceCell<(ContainerAsync<Postgres>, u16)> = OnceCell::const_new();

async fn container_port() -> u16 {
    let (_container, port) = CONTAINER
        .get_or_init(|| async {
            let container = Postgres::default()
                .with_tag("16-alpine")
                .start()
                .await
                .expect("postgres container started");
            let port = container
                .get_host_port_ipv4(5432)
                .await
                .expect("postgres port");
            (container, port)
        })
        .await;
    *port
}

Cada teste continua isolado — só que em vez de pagar o boot completo do container (tipicamente de 1 a 3 segundos), ele paga apenas o custo de um CREATE DATABASE. Numa suíte com dezenas de testes, a diferença é enorme.

Só que tem um problema escondido nesse static.

static nunca é dropado — nem quando o processo termina

Em Rust, valores armazenados em static nunca chamam seus destrutores. Isso não depende de como o processo termina — não importa se o main retorna normalmente ou se alguém chama std::process::exit. É uma garantia da linguagem: destrutores de static simplesmente não rodam.

O Drop de ContainerAsync existe e faz o trabalho certo — ele chama docker rm no container. O problema é que, guardado num OnceCell static, esse Drop nunca é executado. Nada nunca remove o container.

Em Java e Go, isso não costuma doer porque o Testcontainers roda um container companheiro chamado ryuk: um reaper que fica de olho na conexão do processo de teste e remove os recursos órfãos quando ela cai, independente de qualquer Drop/finally/defer ter rodado. testcontainers-rs não implementa ryuk. Sem isso, e sem Drop de static, não existe rede de segurança.

A forma mais direta de confirmar isso é rodar a suíte de testes e contar containers antes e depois:

docker ps --filter "ancestor=postgres:16-alpine" --format "{{.ID}}" | wc -l

Sem correção, esse número só cresce a cada cargo test.

O fix: um destructor de verdade, fora do Rust

Como não dá para confiar em Drop, a solução é interceptar o encerramento real do processo — no nível do runtime C, não do Rust. É exatamente para isso que serve a crate dtor (a parte de destructors que saiu da ctor a partir da versão 1.0): ela registra uma função na seção .fini_array do binário, que o sistema operacional executa no encerramento do processo, sem depender de nenhuma garantia da linguagem Rust.

O detalhe importante: dentro desse hook não existe runtime tokio ativo, então não dá para simplesmente chamar o Drop assíncrono do ContainerAsync de novo (ele usa tokio::runtime::Handle::current() internamente e entra em pânico sem um runtime). A saída é guardar só o ID do container como String e remover o container via docker rm -f diretamente, de forma síncrona:

static CONTAINER_ID: OnceLock<String> = OnceLock::new();

// ... dentro do get_or_init do container:
CONTAINER_ID.set(container.id().to_owned()).expect("set once");

#[dtor::dtor]
unsafe fn stop_shared_container() {
    if let Some(id) = CONTAINER_ID.get() {
        let _ = std::process::Command::new("docker")
            .args(["rm", "-f", id])
            .output();
    }
}

A crate exige que a função seja marcada unsafe fn — o próprio código antes de main não tem as garantias normais do runtime Rust, e a macro avisa isso via deprecation warning se você não marcar. Depois dessa mudança, rodar a suíte inteira e contar containers com docker ps mostra zero sobras, em qualquer cenário: sucesso, teste que falha, ou processo interrompido no meio.

Pegadinha extra: o singleton é por binário, não por suíte

Se você vem de Java ou Go, é fácil assumir que "container singleton" significa um único container para a rodada inteira de cargo test. Não é isso que acontece.

O Cargo compila cada arquivo em tests/ como um binário de teste separado, e os testes unitários dentro de src/ (os #[cfg(test)] inline) compilam para outro binário totalmente à parte. Um static vive no escopo de um processo — então "singleton" aqui quer dizer um container por binário, não um container por projeto. Um projeto com testes unitários espalhados pelo src/ e um tests/integration.rs vai, no mínimo, subir dois containers por rodada completa de cargo test, não um. Isso é esperado e não é um bug, mas vale saber antes de estranhar ver mais de um container no docker ps no meio da suíte.

Banco por teste em vez de transação por teste

O artigo anterior usa rollback de transação para isolar testes — funciona bem quando cada teste segura uma única conexão. O problema aparece se sua aplicação usa um pool de conexões de verdade (diesel-async + deadpool, por exemplo): o pool entrega conexões arbitrárias, então não existe "a" conexão do teste para dar rollback.

A alternativa que combina bem com o container singleton: em vez de uma transação, cada teste cria seu próprio banco dentro do container compartilhado.

let db_name = format!("test_{}", DB_COUNTER.fetch_add(1, Ordering::Relaxed));
diesel::PgConnection::establish(&admin_url)
    .expect("admin connection")
    .batch_execute(&format!(r#"CREATE DATABASE "{db_name}""#))
    .expect("create test database");

let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/{db_name}");
diesel::PgConnection::establish(&url)
    .expect("migration connection")
    .run_pending_migrations(MIGRATIONS)
    .expect("migrations");

let pool = DbPool::new(&DatabaseSettings { url }).expect("test db pool");

O custo de um CREATE DATABASE é muito menor que o de subir um container novo, e o isolamento continua total: cada teste tem seu próprio schema, sem estado compartilhado entre eles, sem depender de qual conexão o pool decidiu entregar.

Resultado

Numa suíte real com 65 testes unitários e 17 de integração, o binário de testes unitários passou a rodar em cerca de 6 segundos e o de integração em cerca de 3 — cada um pagando o boot do container uma única vez, não uma vez por teste. E depois de cada rodada, docker ps mostra zero containers órfãos.

O padrão de container singleton do artigo anterior continua sendo o caminho certo. Só que "singleton" em Rust, sem o reaper que outras linguagens têm de graça, exige um destructor explícito de verdade — e vale saber disso antes de descobrir pelo docker system df crescendo silenciosamente no seu disco.


Recursos adicionais: