Skip to content

External docker restart can orphan a running container because runtime reconciliation is one-way #1016

Description

@milkway

Relato

Reiniciar diretamente pela linha de comando um container gerenciado pelo Ruscker pode causar upstream error e deixar o container fora do registro de réplicas, embora ele volte a executar normalmente no Docker.

Exemplo:

docker restart <container-id-ou-nome>

Este cenário é relacionado à #1015, mas não é igual a docker rm -f: docker restart preserva o container, seu ID, labels e normalmente o binding de porta. O problema aqui é uma corrida entre a janela de parada/reinicialização e o tick do scaler.

Comportamentos possíveis

Restart termina entre dois ticks

Se o container parar e voltar antes da próxima reconciliação, a réplica permanece Ready no registro em memória. Durante a parada e o boot da aplicação, HTTP e WebSocket podem receber 502 upstream error, pois o Ruscker continua roteando para o upstream sem revalidar readiness.

Depois que a aplicação volta a escutar, o tráfego tende a se recuperar sozinho.

Scaler observa o container parado

Se o tick do scaler acontecer enquanto o container está parado/restarting:

  1. list_managed_containers() encontra o container com running == false;
  2. stopped_replica_ids() o classifica como morto;
  3. o scaler remove a réplica do ReplicaRegistry;
  4. as sessões da réplica são apagadas com sessions.drop_replica(...);
  5. o Docker conclui o restart e volta a executar o mesmo container.

Nesse ponto o container está running e mantém os labels Ruscker, mas não volta para o registro em memória.

Análise da causa

A reconciliação em runtime é unidirecional:

  • o scaler consulta list_managed_containers() para remover réplicas observadas como stopped;
  • ContainerBackend::list() é usado para adotar containers etiquetados somente durante a inicialização do processo Ruscker;
  • não existe merge/readopção periódica de containers que voltaram a executar.

Trecho do liveness reconcile:

if let Ok(managed) = backend.list_managed_containers().await {
    let dead = stopped_replica_ids(&registry_snap, &managed);
    // remove do registry + drop_replica
}

Readopção, apenas no startup:

if let Some(backend) = self.state.backend.as_ref() {
    match backend.list().await {
        Ok(mut existing) => {
            // apply seat caps + registry.reset(existing)
        }
    }
}

O teste atual confirma apenas a poda running versus stopped; não cobre a sequência do mesmo container:

Running → Stopped/Restarting → Running

Impacto

  • Container running no Docker, mas invisível para proxy, dashboard e scaler.
  • Próximo acesso ou min-replicas > 0 pode criar uma nova réplica, deixando o container reiniciado órfão.
  • A spec pode acabar com mais containers reais do que max-replicas, pois o cap consulta apenas o registro em memória.
  • Sessões sticky são descartadas quando o scaler observa a parada.
  • Requisições são encaminhadas enquanto a aplicação ainda está inicializando, causando 502 transitório.
  • O container órfão pode ser readotado somente no próximo restart do próprio Ruscker, criando nova divergência ou excesso de réplicas.

Por que o restart da interface funciona

O botão de restart do dashboard não executa o equivalente a docker restart. Ele:

  1. para/remove a réplica antiga via backend;
  2. remove explicitamente a réplica do registro;
  3. apaga suas sessões;
  4. cria imediatamente um container novo com spawn_replica().

Esse fluxo é coordenado com o estado interno e, portanto, não sofre a corrida descrita aqui.

Proposta de solução

1. Reconciliação bidirecional em runtime

Em cada ciclo de liveness, reconciliar o snapshot do backend com o registro em memória, sem usar reset destrutivo:

  • réplica conhecida + running: manter e atualizar estado/upstream se necessário;
  • réplica conhecida + stopped: marcar indisponível e iniciar uma janela curta de restart;
  • réplica etiquetada running, mas ausente do registro: readotar com o seat cap da spec;
  • réplica confirmadamente removida: podar registro e sessões conforme Auto-recuperação falha quando container gerenciado é removido via Docker CLI (docker rm -f) #1015;
  • host multi-Docker indisponível: classificar como Unknown, sem podar ou duplicar.

O merge deve preservar contadores de sessão de réplicas que permaneceram vivas e nunca ultrapassar max-replicas considerando também containers readotáveis descobertos no backend.

2. Modelar restart como estado transitório

Não tratar imediatamente todo Stopped/Restarting como remoção definitiva. Usar uma janela de graça ou eventos Docker:

  • die/stop/restart: retirar temporariamente a réplica do roteamento;
  • start: inspecionar novamente o binding, executar readiness e devolver a réplica ao pool;
  • destroy: remoção definitiva e limpeza de sessões;
  • timeout sem retorno: podar e permitir substituição.

A reconciliação periódica deve continuar como fallback para eventos perdidos.

3. Readiness após restart externo

Um container que voltou para estado running não deve ser marcado imediatamente como Ready. Reutilizar a verificação de readiness usada no spawn antes de liberar HTTP/WebSocket para esse upstream.

4. Recuperação reativa no proxy

Em falha de transporte, confirmar o estado do container e acionar a mesma reconciliação. Replay automático somente para requisições seguramente reproduzíveis (GET/HEAD); nunca repetir cegamente métodos não idempotentes.

Critérios de aceite

  • docker restart do container não exige restart pela interface Ruscker.
  • O mesmo container, ao voltar, é readotado e fica Ready somente após readiness.
  • Há exatamente uma réplica registrada e um container gerenciado após o restart.
  • Nenhum container reiniciado fica órfão.
  • O scaler não cria uma substituta duplicada durante uma janela normal de restart.
  • Sessões antigas são invalidadas de forma consistente e um novo sticky binding pode ser criado.
  • Falha/timeout de um host em multi-host não remove nem duplica suas réplicas.
  • Restart prolongado além da janela de graça converge para poda + substituição.
  • Logs distinguem restart detected, replica readopted e restart timed out.

Testes sugeridos

  • Unitário: sequência Running → Stopped → Running com o mesmo ID termina com uma única réplica readotada.
  • Unitário: Stopped que não retorna até o timeout é podado e substituído.
  • Unitário multi-host: host Unknown não causa poda nem spawn duplicado.
  • Proxy: durante restart, upstream indisponível não permanece preso a uma réplica fantasma.
  • docker-it: spawn, docker restart <container-id>, aguardar readiness e verificar que o mesmo container voltou ao pool sem duplicata.
  • docker-it: restart suficientemente longo para atravessar um tick do scaler e validar a corrida real.

Verificação realizada

  • Busca de issues abertas: nenhuma equivalente encontrada.
  • Teste existente liveness_reconcile_prunes_only_stopped_containers: passa, mas documenta apenas a poda de stopped e não cobre readopção após restart.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:adminruscker-admin cratearea:dockerruscker-docker cratearea:proxyruscker-proxy cratekind:bugBugphase:3Phase 3 — Proxy + Docker backend

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions