← Todos os posts
22 de set. de 2026

Como uma Reconexão CIFS Deixou Todo Pod com Handle Obsoleto

TL;DR: pv-media-server, um PV ReadWriteMany provido por smb.csi.k8s.io contra um share TrueNAS (//192.168.20.5/media-server), passou a retornar stale file handle em stat() para todo pod agendado nele — sonarr, radarr, bazarr, jellyfin — em dois nodes diferentes (opal, onix) ao mesmo tempo. O csi-driver-smb mantém exatamente um mount CIFS por node (o “globalmount”) e faz bind-mount dele em cada pod via NodePublishVolume; ele nunca verifica se esse mount global ainda está vivo, então quando ele fica obsoleto, todo pod agendado naquele node herda o mesmo handle morto, indefinidamente, em loop de CreateContainerError. O mount usava serverino (confia nos números de inode que o servidor atribui), exatamente o que o TRaSH-Guides recomenda evitar em shares CIFS que sustentam uma stack *arr — churn de inode do lado servidor invalida todo dentry em cache que o client kernel está segurando. Corrigido com três camadas independentes: noserverino no mountOptions do PV, pra o client parar de confiar em números de inode que não consegue verificar; um DaemonSet novo que faz stat no globalmount CIFS de cada node a cada 30 segundos e umount -l em qualquer um que retorne ESTALE (forçando o csi-driver-smb a remontar no próximo pod que tentar usar); e um liveness probe que agora faz stat de verdade em /data em vez de checar se a porta TCP 445 está aberta — o probe antigo ficou verde durante todo o incidente, porque o servidor SMB estava acessível o tempo todo. Só o mount é que estava morto.

O trabalho de um driver CSI é mais estreito do que parece: montar o share uma vez por node, depois fazer bind-mount em cada pod. Nada nesse contrato obriga o driver a perceber que o mount morreu. O csi-driver-smb não percebe, e o modo de falha que isso produz — todo pod futuro num node herdando silenciosamente um cadáver de mount — é invisível pra um liveness probe que só checa alcançabilidade de rede. Este post é o rastro desde quatro pods em crash loop até um fix de três partes que trata “o mount morreu” como condição monitorada de primeira classe, não como suposição.

Pra quem é este post

Você roda Kubernetes em bare metal ou VMs com um volume SMB ou NFS compartilhado montado via driver CSI — csi-driver-smb, csi-driver-nfs, ou similar — e mais de um pod ou mais de um node consumindo o mesmo PV ReadWriteMany. Você já sabe o que o split NodeStageVolume/NodePublishVolume do CSI faz. O que talvez você não tenha checado é o que seu driver faz quando o mount staged morre por baixo dele, e se seu liveness probe pegaria isso.

Índice

  1. O setup: um share SMB, seis containers *arr
  2. O sintoma: CreateContainerError, stale file handle, dois nodes ao mesmo tempo
  3. Por que “dois nodes ao mesmo tempo” descarta um host ruim isolado
  4. O que o csi-driver-smb faz de fato em NodePublishVolume
  5. A causa raiz: serverino confiando em números de inode que não consegue verificar
  6. O fix, em três camadas
  7. Por que o liveness probe existente nunca pegou isso
  8. O que ainda não está provado

O setup: um share SMB, seis containers *arr

Resposta direta: Seis containers (jellyfin, sonarr, radarr, bazarr, e dois desativados — lingarr, lazylibrarian) compartilham um PersistentVolume ReadWriteMany provido por smb.csi.k8s.io v1.19.1, apontando pra um export SMB do TrueNAS. Cada pod também rodava um sidecar busybox feito pra pegar quedas do SMB, mas ele só checava alcançabilidade TCP da porta 445 — não se o mount em si funcionava.

O PV é provisionado à mão (não dinamicamente) como um recurso PersistentVolume do Pulumi, com um PersistentVolumeClaim correspondente que todo app *arr referencia pelo nome:

media_server_share = PersistentVolume(
    "pv-media-server",
    spec={
        "capacity": {"storage": "1000Gi"},
        "accessModes": ["ReadWriteMany"],
        "storageClassName": "smb",
        "mountOptions": [
            "dir_mode=0777", "file_mode=0777", "nounix",
            "echo_interval=30", "hard", "actimeo=30",
        ],
        "csi": {
            "driver": "smb.csi.k8s.io",
            "volumeHandle": "smb-server.default.svc.cluster.local/share#media-server#",
            "volumeAttributes": {"source": "//192.168.20.5/media-server"},
            "nodeStageSecretRef": {"name": "smb-credentials-secret", "namespace": "media-server"},
        },
    },
)

hard significa que o client CIFS do kernel tenta de novo indefinidamente em erros de I/O em vez de propagar pra aplicação — a escolha padrão pra um share que você não quer que retorne lixo silenciosamente numa falha de rede. Cada pod de app já carregava um sidecar smb-health-monitor, adicionado depois de uma queda anterior do SMB, que rodava nc -z -w 5 192.168.20.5 445 a cada 30 segundos e escrevia um arquivo de timestamp que um liveness_probe checava. Parecia defesa em profundidade. Não estava checando a coisa que de fato quebrou.

O sintoma: CreateContainerError, stale file handle, dois nodes ao mesmo tempo

Resposta direta: kubectl describe pod no sonarr mostrou eventos Warning Failed repetidos — failed to stat "…/pv-media-server/mount": stat … stale file handle — imediatamente depois de um pull de imagem bem-sucedido, em toda tentativa de criação de container, ao longo de reinícios do pod. O mesmo erro, mesma redação, apareceu no bazarr e jellyfin num node diferente (onix) ao mesmo tempo. Puxar os logs do DaemonSet csi-smb-node nos dois nodes confirmou o erro exato do kernel: stale NFS file handle (sim, essa string, num mount CIFS — a camada VFS do Linux reaproveita ESTALE entre tipos de filesystem).

Warning  Failed  8s  kubelet  spec.containers{sonarr}: Error: failed to generate container
"fe130c99…" spec: failed to generate spec: failed to stat
"/var/lib/kubelet/pods/…/volumes/kubernetes.io~csi/pv-media-server/mount":
stat …/mount: stale file handle

O kubelet tentou de novo a criação do container três vezes em nove segundos — cada tentativa refez o pull da imagem (já em cache), depois falhou exatamente na mesma chamada stat(). kubectl get pods -n media-server mostrou sonarr, radarr, bazarr e jellyfin todos em CreateContainerError, os quatro pods que referenciam pv-media-server. Todo pod que não montava esse volume — prowlarr, qbit-manage, recyclarr — estava saudável.

Por que “dois nodes ao mesmo tempo” descarta um host ruim isolado

Resposta direta: o sonarr estava no opal, bazarr e jellyfin estavam no onix — dois hosts físicos diferentes, dois clients CIFS de kernel diferentes, dois pods do DaemonSet csi-smb-node diferentes, ambos retornando o mesmo erro stale NFS file handle em timestamps sobrepostos. Uma placa de rede ruim, uma porta de switch instável, ou estado local corrompido num node explicariam uma falha isolada. Dois clients CIFS de kernel independentes ficando obsoletos dentro da mesma janela de minutos aponta pra coisa compartilhada que os dois clients conversam: o próprio export do TrueNAS.

ping 192.168.20.5 de dentro do cluster retornou round trips limpos de 30–56ms durante todo o incidente — sem perda de pacote, sem oscilação de rota. Isso descartou partição de rede. O que invalidou o mount não tirou o servidor do ar; só mudou algo no estado do filesystem que o servidor SMB estava expondo, e todo client segurando um handle aberto de antes dessa mudança recebeu ESTALE no próximo acesso.

O que o csi-driver-smb faz de fato em NodePublishVolume

Resposta direta: o csi-driver-smb monta o share SMB exatamente uma vez por node, no momento do NodeStageVolume, num diretório globalmount por volume, em /var/lib/kubelet/plugins/kubernetes.io/csi/smb.csi.k8s.io/<hash>/globalmount. Todo pod naquele node que referencia o mesmo volume recebe um bind-mount desse único globalmount no seu próprio diretório de pod via NodePublishVolume. O driver nunca reverifica se o globalmount está vivo antes de fazer o bind-mount — só roda mount -o bind. Se o globalmount está obsoleto, o bind-mount funciona (fazer bind-mount de um diretório não exige lê-lo por dentro), e o próximo stat() que alguém fizer — o próprio stat() de criação de container do kubelet — é o que retorna ESTALE.

Confirmado direto lendo a saída de mount no opal como root:

//192.168.20.5/media-server on /var/lib/kubelet/plugins/kubernetes.io/csi/smb.csi.k8s.io/<hash>/globalmount
    type cifs (rw,relatime,vers=3.1.1,…,serverino,mapposix,reparse=nfs,…,actimeo=30,closetimeo=1)
//192.168.20.5/media-server on /var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~csi/pv-media-server/mount
    type cifs (rw,relatime,vers=3.1.1,…,serverino,mapposix,reparse=nfs,…,actimeo=30,closetimeo=1)

As duas entradas são a mesma sessão CIFS — a segunda é um bind-mount da primeira, ambas mostrando o mesmo type cifs porque bind-mounts de filesystems de rede ainda reportam o fstype de baixo. E nos logs do container csi-smb-node, a chamada NodePublishVolume pra um pod recém-criado, segundos depois dos erros de ESTALE começarem, loga um Mounting cmd (mount) with arguments (-o bind …) limpo e uma resposta successfully — o driver reportou sucesso entregando um mount morto. A implementação de NodePublishVolume em nodeserver.go faz uma checagem de existência/mountpoint antes do bind, não uma checagem de vivacidade — um mount obsoleto ainda está, do ponto de vista do kernel, “montado”.

A causa raiz: serverino confiando em números de inode que não consegue verificar

Resposta direta: o mountOptions do PV não desabilitava serverino, então o client CIFS por padrão confia nos números de inode que o servidor TrueNAS entrega, em vez de gerar os próprios. serverino é o padrão correto pra maioria das cargas de trabalho — inodes gerados pelo client quebram detecção de hardlink dentro de um mesmo mount, o que importa exatamente pro tipo de move atômico que apps *arr fazem entre um diretório de download e um diretório de biblioteca no mesmo share. Mas também significa que, se o servidor reatribuir números de inode pra arquivos que o client tem dentries em cache — um rollback de snapshot, um re-export de dataset, um restart do serviço SMB, qualquer coisa que mude como o filesystem por baixo enumera inodes — todo dentry em cache fica irresolúvel, e o kernel retorna ESTALE no próximo lookup através dele.

Isso é uma ressalva documentada de hardlink em CIFS no ecossistema TRaSH-Guides que operadores de apps *arr encontram especificamente por causa do comportamento de move instantâneo/hardlink — mas o enquadramento usual é “hardlinks falham silenciosamente” ou “hardlinks entre shares viram cópias”, não “o mount inteiro fica obsoleto pra todo consumidor”. O modo de falha de handle obsoleto tem a mesma causa raiz — o client confiando na identidade de inode do lado servidor — surgindo pior, no nível do mount em vez do nível do arquivo, porque serverino estava combinado com hard (tenta de novo pra sempre, nunca propaga um erro suave) e um actimeo de 30 segundos (cache de atributos, incluindo estado derivado de inode, por 30 segundos antes de revalidar).

Eu não tive acesso direto aos logs de serviço ou journalctl do próprio TrueNAS durante esse incidente — o SSH pra caixa TrueNAS usava uma chave diferente da que estava disponível no ambiente admin do cluster, e quando isso foi resolvido, a janela relevante de logs já tinha rotacionado. Então o evento específico do lado servidor que remapeou os números de inode é inferido a partir das opções de mount e da assinatura da falha, não confirmado por uma linha de log do lado TrueNAS. Essa é a limitação honesta deste post — veja O que ainda não está provado.

O fix, em três camadas

Resposta direta: noserverino no mountOptions do PV faz o client parar de confiar em números de inode atribuídos pelo servidor, eliminando o gatilho específico. Um DaemonSet novo, smb-stale-mount-healer, rodando privilegiado com um hostPath bidirecional em /var/lib/kubelet/plugins/kubernetes.io/csi/smb.csi.k8s.io, faz stat em todo globalmount de todo node a cada 30 segundos e umount -l em qualquer um que retorne erro — a recuperação automática de verdade que o csi-driver-smb não tem. E a checagem de liveness do sidecar foi reescrita de um probe TCP de porta pra um stat /data de verdade, então um mount obsoleto de fato falha o probe em vez de ficar verde.

noserverino é uma linha na lista existente de mountOptions:

"mountOptions": [
    "dir_mode=0777", "file_mode=0777", "nounix",
    "echo_interval=30", "hard", "actimeo=30",
    "noserverino",
],

A man page do mount.cifs documenta noserverino como: o client gera números de inode em vez de usar os fornecidos pelo servidor. Isso não elimina ESTALE como possibilidade — uma sessão CIFS ainda pode ficar obsoleta por outros motivos, como uma reconexão em nível TCP que reseta o estado de handle de arquivo do lado servidor — mas remove o único gatilho de falha que a evidência deste incidente apontou, e é a mesma recomendação que o TRaSH-Guides dá pra essa classe exata de app.

O DaemonSet healer é o que torna “ainda ficou obsoleto por outro motivo” sobrevivível em vez de fatal, porque transforma uma queda permanente numa queda limitada — pior caso, 30 segundos até o próximo stat, depois um remount forçado no próximo pod que tentar usar:

smb_stale_mount_healer = DaemonSet(
    "smb-stale-mount-healer",
    metadata={"name": "smb-stale-mount-healer", "namespace": "kube-system"},
    spec={
        "template": {
            "spec": {
                "containers": [{
                    "name": "healer",
                    "image": "busybox:latest",
                    "command": ["/bin/sh", "-c"],
                    "args": ["""
while true; do
  for d in /var/lib/kubelet/plugins/kubernetes.io/csi/smb.csi.k8s.io/*/globalmount; do
    [ -d "$d" ] || continue
    if ! timeout 5 stat "$d" >/dev/null 2>&1; then
      echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [HEAL] $d is stale, forcing lazy umount"
      umount -l "$d" 2>&1
    fi
  done
  sleep 30
done
"""],
                    "security_context": {"privileged": True},
                    "volume_mounts": [{
                        "name": "csi-smb-root",
                        "mount_path": "/var/lib/kubelet/plugins/kubernetes.io/csi/smb.csi.k8s.io",
                        "mount_propagation": "Bidirectional",
                    }],
                }],
                "volumes": [{
                    "name": "csi-smb-root",
                    "hostPath": {"path": "/var/lib/kubelet/plugins/kubernetes.io/csi/smb.csi.k8s.io", "type": "DirectoryOrCreate"},
                }],
            },
        },
    },
)

mountPropagation: Bidirectional é o que faz o umount -l dentro do container de fato desmontar no host, não só dentro do namespace de mount do próprio container — sem isso, o container estaria desmontando uma cópia privada da tabela de mounts, e o globalmount do lado host, e todo pod ainda com bind-mount dele, ficaria intocado. umount -l (lazy unmount) desanexa o ponto de montagem imediatamente e limpa a referência por baixo assim que nada mais o tem aberto — a escolha certa aqui porque um umount -f forçado ainda pode travar contra um servidor genuinamente sem resposta, e um umount simples falha de cara se qualquer coisa ainda tem o caminho aberto.

privileged: true é um custo real, não uma formalidade — esse DaemonSet pode desmontar qualquer coisa sob esse caminho do host, em todo node, sem nenhuma checagem de autorização adicional. Está escopado o mais apertado que o mecanismo permite (um hostPath, uma árvore de diretório, só stat+umount), mas o modo de falha não ia ser corrigido com nada menos que algo rodando no namespace de mount do host.

Por que o liveness probe existente nunca pegou isso

Resposta direta: o sidecar original rodava nc -z -w 5 192.168.20.5 445 — uma conexão TCP crua na porta SMB — a cada 30 segundos, e considerava o mount “vivo” se essa conexão funcionasse. Durante todo esse incidente, a conexão TCP funcionou, porque o servidor SMB estava de pé e aceitando conexões o tempo todo; só os handles de arquivo do mount já estabelecido é que estavam inválidos. Um liveness probe testando alcançabilidade de rede e um liveness probe testando saúde do mount estão respondendo duas perguntas diferentes, e só uma delas de fato prevê CreateContainerError.

O fix inverte o que está sendo testado — faz stat na coisa que a aplicação de fato depende, não num proxy dela:

smb_healthcheck_sidecar = {
    "args": ["""
STATE=up
while true; do
  if timeout 5 stat /data >/dev/null 2>&1; then
    date +%s > /healthcheck/alive
    [ "$STATE" = "down" ] && echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [RECOVERY] /data mount is healthy again"
    STATE=up
  else
    [ "$STATE" = "up" ] && echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [FAILURE] /data mount unhealthy"
    STATE=down
  fi
  sleep 30
done
"""],
    "volume_mounts": [
        {"name": "smb-healthcheck", "mount_path": "/healthcheck"},
        {"name": "media-server-data", "mount_path": "/data", "read_only": True},
    ],
}

O sidecar precisa do seu próprio mount do mesmo volume pra fazer stat nele — uma checagem de alcançabilidade de rede não exige tocar o filesystem, uma checagem de saúde de mount exige por definição. Essa é uma mudança pequena mas real de raio de impacto: o sidecar agora é um segundo consumidor do mount CIFS dentro do mesmo pod, então se o remount do DaemonSet healer algum dia entrar em corrida com o tráfego de leitura/escrita do próprio pod, os dois containers veriam o mesmo estado transitório. Na prática isso não causou problema nenhum, porque as duas são operações seguras pra leitura, e timeout 5 limita quanto tempo qualquer uma das duas pode travar.

timeout 5 na frente do stat importa especificamente por causa da opção de mount hard: um servidor genuinamente sem resposta (não obsoleto, só lento ou particionado) faria o stat travar pelo tempo que o client do kernel continuar tentando de novo, e um probe que trava é pior que um que retorna uma falha honesta.

O que ainda não está provado

Resposta direta: o evento específico do lado TrueNAS que remapeou números de inode e disparou os handles obsoletos nunca foi identificado — acesso SSH à caixa TrueNAS não estava disponível com a chave em mãos durante a janela do incidente, e quando ficou, os logs de serviço relevantes já tinham rotacionado. noserverino corrige a classe de falha que essa evidência aponta; não confirma o que de fato aconteceu em 22/09/2026, e não protegeria contra toda causa possível de handle CIFS obsoleto.

O contra-argumento mais forte contra esse fix: se o gatilho real foi uma queda de sessão em nível TCP e reconexão — não um remapeamento de inode — noserverino não faz nada, porque a falha estaria na camada de sessão, não na confiança de inode. A opção de mount closetimeo=1 visível na saída de mount (um timeout de fechamento de um segundo, provavelmente um default do csi-driver-smb em vez de algo configurado explicitamente) é curta o suficiente pra que uma falha de rede marginal pudesse plausivelmente forçar um ciclo de reconexão que deixa handles obsoletos independente do serverino. É exatamente esse o cenário contra o qual o DaemonSet healer é seguro — ele não se importa com o porquê do mount ter ficado obsoleto, só que ficou. Se noserverino sozinho fosse o fix, o DaemonSet seria redundante; o fato dele continuar no lugar é a admissão honesta de que a causa raiz não está totalmente fechada.

Depois do rollout, verificado diretamente: os quatro pods *arr 2/2 Running com zero reinícios, o DaemonSet healer 3/3 pronto em todo node, stat /data de dentro de um container sonarr rodando retornando saída limpa de inode/modo, e nenhum evento stale file handle em kubectl get events desde que o fix entrou. Isso é confirmação de que o sintoma parou, não confirmação da causa exata — vale manter as duas afirmações separadas na segunda-feira, quando o próximo incidente de storage inevitavelmente precisar de um diagnóstico diferente.

Referências


Breno Zanato Detomini
Breno Zanato Detomini

Engenheiro de sistemas embarcados e redes, baseado no Brasil.

← Todos os posts