O status de saída de um pipeline SSH pode esconder um comando que falhou?
O status de saída de um pipeline SSH pode esconder uma falha remota. Capture o PIPESTATUS do Bash, trate o pipefail e retorne resultados honestos aos agentes.

Um comando remoto pode falhar, um formatador pode imprimir uma saída convincente e um agente ainda anunciar sucesso. Isso não é um mistério do SSH. É a semântica normal do shell atravessando um limite de rede sem evidências suficientes.
A solução não é apenas adicionar set -o pipefail a todos os scripts. O pipefail muda um único resultado agregado. Um agente que executa uma ação SSH importante precisa do status de cada etapa do pipeline, de uma regra definida para saídas diferentes de zero esperadas e de um código de saída remoto final que não possa ser confundido com sucesso. Capture o vetor imediatamente, dê um nome a ele e faça o wrapper decidir o que significa sucesso.
Um comando final verde pode esconder um primeiro comando vermelho
Por padrão, um pipeline de shell informa o status de saída de seu último comando. Isso torna esta linha perigosa em tarefas de implantação, migração, backup e reparo:
build_manifest | sign_manifest | tee /var/tmp/manifest.json
Suponha que build_manifest falhe porque não consegue ler um arquivo obrigatório. sign_manifest pode receber uma entrada inutilizável e também falhar, ou produzir um resultado vazio. tee ainda pode criar um arquivo, gravar zero bytes e terminar com status zero. O shell informa zero para o pipeline inteiro. Um chamador que verifica apenas $? vê sucesso.
O GNU Bash Reference Manual afirma isso claramente: um pipeline usa o status de saída do último comando, a menos que pipefail esteja habilitado. O Bash espera todos os comandos em um pipeline síncrono, mas esperar não é o mesmo que preservar os resultados de cada comando.
Uma pessoa diante de um terminal interativo às vezes percebe os dados ausentes ou a mensagem de erro. Um agente costuma ter uma visão mais limitada. Ele pode receber um transcript truncado, um resumo formatado ou apenas o resultado final do comando. Se o script informa zero, o agente tem motivos para dizer que a ação foi concluída, mesmo quando não fez o que foi solicitado.
A distinção que as equipes costumam misturar é simples:
- O status de saída de um pipeline é um valor de decisão.
- Os status de seus comandos são as evidências por trás dessa decisão.
Você precisa dos dois. O valor de decisão controla se o comando remoto retorna sucesso. As evidências informam a um revisor, a um log ou a um agente supervisor onde ocorreu o problema.
Isso é mais importante quando a primeira etapa altera o mundo. Considere uma exportação remota que lê dados de produção, comprime, criptografa e envia o resultado. O cliente de upload pode terminar com zero depois de enviar um fluxo vazio. O transcript pode conter palavras tranquilizadoras como «concluído», porque um programa posterior concluiu sua tarefa específica. Esse resultado não pode se transformar em uma afirmação falsa de que a exportação foi bem-sucedida.
O SSH retorna o que o shell remoto decide retornar
O OpenSSH não inspeciona os comandos dentro de um pipeline do shell remoto. Ele retorna o status do comando remoto ou 255 quando o próprio SSH encontra um erro.
Esse comportamento é correto e útil. O SSH não tem como saber se este texto remoto é um pipeline, uma função do shell, um script ou um aplicativo que usa códigos de saída à sua maneira:
ssh deploy@host 'generate | transform | tee result.txt'
O shell de login remoto interpreta esse comando. Se a semântica do pipeline informar o status do tee final, o SSH retornará esse status à máquina local. O chamador local não tem como reconstruir os resultados anteriores depois que o shell remoto os descartou.
Colocar set -o pipefail no shell local não corrige um pipeline executado remotamente. Este comando muda apenas as regras de status do pipeline local:
set -o pipefail
ssh deploy@host 'generate | transform | tee result.txt'
O shell remoto continua sendo o responsável por generate | transform | tee result.txt. Ele precisa de seu próprio shell explícito e de seu próprio tratamento de falhas.
Há uma segunda armadilha. Este comando local cria outro pipeline depois que o SSH retorna:
ssh deploy@host 'remote command' 2>&1 | tee session.log
Agora existem dois pipelines diferentes:
- O shell remoto pode conter um pipeline dentro de
remote command. - O shell local contém
ssh | tee session.log.
Um tee local bem-sucedido pode esconder uma falha de transporte do SSH ou uma saída diferente de zero do wrapper remoto. Você precisa inspecionar o pipeline remoto no host e o pipeline local ao redor do SSH. Tratar a linha como um único comando opaco é como resultados falsamente verdes continuam passando por revisões.
Pipefail detecta uma falha, mas não a explica
set -o pipefail muda o resultado agregado do Bash. Com ele habilitado, o Bash retorna o status do comando mais à direita que terminou com valor diferente de zero, ou zero se todos os comandos tiveram sucesso.
Para muitos scripts, isso é uma melhoria real:
set -o pipefail
produce_data | validate_data | publish_data
printf 'pipeline status: %s\n' "$?"
Se produce_data terminar com 17 e os comandos seguintes terminarem com zero, o pipeline retorna 17. Se validate_data terminar com 4 e publish_data com zero, o pipeline retorna 4. O processo chamador recebe uma falha em vez de uma mentira.
Mas o pipefail perde detalhes quando mais de uma etapa falha. Suponha que os status sejam 17 4 0. O resultado do pipeline é 4, porque 4 veio do último comando que falhou. Isso informa que ocorreu uma falha, mas não prova se o validador causou a falha do produtor, reagiu a ela ou falhou de forma independente.
Por isso, pipefail é uma proteção, não um formato de relatório. Use-o quando quiser que um pipeline falhe como uma unidade. Use PIPESTATUS quando precisar responder depois a estas perguntas:
- Qual etapa retornou um código diferente de zero?
- Uma etapa posterior foi executada e teve sucesso depois que uma etapa anterior falhou?
- O processo recebeu um sinal em vez de retornar seu próprio erro?
- Um código diferente de zero é esperado para este comando específico?
Não tente encobrir essa lacuna com || true:
produce_data | validate_data | publish_data || true
Esse padrão é popular porque mantém o script em execução. Ele também apaga o único sinal que o chamador tinha. Se uma etapa puder legitimamente retornar um valor diferente de zero, registre o status permitido para essa etapa depois de capturar o vetor real. Não ignore a falha do pipeline inteiro.
PIPESTATUS desaparece se você esperar até mesmo um comando
O Bash expõe o código de saída de cada etapa no array PIPESTATUS. O array é frágil por definição: ele descreve o pipeline em foreground executado mais recentemente, e o comando seguinte pode substituí-lo.
Isto parece razoável, mas está errado:
source_data | normalize | upload
pipeline_rc=$?
printf 'pipeline result: %s\n' "$pipeline_rc"
statuses=("${PIPESTATUS[@]}")
Quando o Bash chega à última atribuição, a atribuição pipeline_rc=$? e o printf já foram executados. PIPESTATUS não descreve mais source_data | normalize | upload.
Copie o array primeiro, antes de fazer qualquer outra coisa:
source_data | normalize | upload
statuses=("${PIPESTATUS[@]}")
Depois, inspecione-o sem depender do código agregado do pipeline:
printf 'source_data=%s normalize=%s upload=%s\n' \
"${statuses[0]}" "${statuses[1]}" "${statuses[2]}"
Essa também é a razão pela qual um set -e usado sem cuidado pode piorar o diagnóstico. Com pipefail ativo, um pipeline com falha pode fazer o Bash sair antes que a linha seguinte copie PIPESTATUS. O tratamento de erros do shell tem várias exceções que dependem do contexto, e scripts que dependem apenas de set -e costumam produzir menos evidências justamente quando um comando falha.
Quando os status de um pipeline são importantes, desative errexit nas poucas linhas necessárias para executá-lo e capturá-lo. Depois, tome uma decisão explícita. Isso exige mais código do que uma opção mágica do shell, mas é um código que você consegue ler durante um incidente.
Execute o programa remoto sob o shell necessário
PIPESTATUS é um array do Bash. Ele não faz parte da sintaxe POSIX sh, e pipefail não é obrigatório no shell POSIX. Um comando remoto chamado pelo SSH pode ser executado em um shell de login que você não escolheu. Em um host pode ser Bash; em outro, dash, zsh ou um shell restrito.
Não envie sintaxe Bash para um shell remoto não especificado esperando que a máquina coincida com você. Inicie o Bash explicitamente:
ssh deploy@host 'bash -s' <<'REMOTE_SCRIPT'
printf 'alpha\n' | grep 'beta' | tee /var/tmp/example.out
statuses=("${PIPESTATUS[@]}")
printf 'stages=%s,%s,%s\n' \
"${statuses[0]}" "${statuses[1]}" "${statuses[2]}" >&2
REMOTE_SCRIPT
O delimitador do heredoc entre aspas é importante. <<'REMOTE_SCRIPT' impede que o shell local expanda variáveis, substituições de comandos e barras invertidas antes de enviar o script. O processo Bash remoto recebe o texto que você escreveu.
No macOS, o Bash do sistema é antigo, mas oferece suporte a arrays indexados, PIPESTATUS e set -o pipefail. Isso não significa que /bin/sh seja Bash. Um script com #!/bin/bash ajuda apenas quando você executa o arquivo diretamente. Se você passar um comando de uma linha para ssh host '...', o shell de login remoto ainda o interpretará, a menos que você inicie o Bash explicitamente.
Para uma automação mantida ao longo do tempo, coloque o wrapper remoto em um script versionado e invoque seu caminho absoluto. Para uma ação curta executada por um agente, bash -s com um heredoc entre aspas costuma ser mais fácil de auditar, porque o programa remoto completo aparece na solicitação de ação local.
Um wrapper deve nomear as etapas e retornar um resultado honesto
Um wrapper remoto útil faz quatro coisas. Executa o pipeline, copia imediatamente o vetor de status, emite um registro legível por máquina e termina com um valor diferente de zero quando uma etapa obrigatória falha.
Este exemplo usa uma transferência de dados em três etapas. Substitua os comandos, mas mantenha o fluxo de controle. Ele não depende deliberadamente de set -e para decidir o que acontece depois do pipeline.
#!/usr/bin/env bash
set -uo pipefail
run_export() {
local -a status
local stage
local -a names=("collect" "compress" "send")
set +e
collect_records | gzip -c | send_archive --destination daily
status=("${PIPESTATUS[@]}")
set -e
if ((${#status[@]} != ${#names[@]})); then
printf 'agent_pipeline_error pipeline=export reason=status_count expected=%s got=%s\n' \
"${#names[@]}" "${#status[@]}" >&2
return 70
fi
for stage in "${!names[@]}"; do
printf 'agent_pipeline_status pipeline=export stage=%s code=%s\n' \
"${names[$stage]}" "${status[$stage]}" >&2
done
for stage in "${!status[@]}"; do
if (( status[stage] != 0 )); then
printf 'agent_pipeline_result pipeline=export outcome=failed\n' >&2
return "${status[$stage]}"
fi
done
printf 'agent_pipeline_result pipeline=export outcome=ok\n' >&2
return 0
}
run_export
Uma coleta que falha com um compressor e um remetente bem-sucedidos produz uma saída deste tipo:
agent_pipeline_status pipeline=export stage=collect code=23
agent_pipeline_status pipeline=export stage=compress code=0
agent_pipeline_status pipeline=export stage=send code=0
agent_pipeline_result pipeline=export outcome=failed
O wrapper termina com 23. O SSH retorna 23 ao processo local. O agente pode informar que a exportação falhou em collect, mesmo que send_archive tenha exibido uma mensagem de conclusão para um fluxo vazio.
O código exato retornado é menos importante do que a disciplina por trás dele. Neste wrapper, o primeiro status diferente de zero na ordem do pipeline vence. O pipefail do Bash seleciona o status diferente de zero mais à direita. Qualquer uma das políticas pode funcionar, desde que você a declare e teste. Para operações, prefiro a primeira etapa que falhou, porque ela costuma apontar mais perto da causa inicial. Mantenha o vetor completo de status no registro da ação para que ninguém precise deduzir o que aconteceu a partir de um único número.
Os nomes das etapas não são decoração. 0=23,1=0,2=0 obriga alguém a reabrir o script. collect=23,compress=0,send=0 permite que um supervisor encaminhe a falha, acrescente contexto ou decida se uma nova tentativa é segura.
O registro local pode criar um segundo sucesso falso
Os operadores querem um transcript local. Os agentes também. A maneira ingênua de obtê-lo é esta:
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
Se o SSH retornar 23, mas o tee local gravar o transcript e retornar zero, o pipeline local retornará zero por padrão. Você corrigiu a mentira remota e introduziu uma mentira local.
Capture também os status locais:
set +e
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
local_status=("${PIPESTATUS[@]}")
set -e
ssh_rc=${local_status[0]}
tee_rc=${local_status[1]}
printf 'ssh=%s tee=%s\n' "$ssh_rc" "$tee_rc" >&2
if (( ssh_rc != 0 )); then
exit "$ssh_rc"
fi
if (( tee_rc != 0 )); then
exit "$tee_rc"
fi
Não habilite o pipefail local e pare por aí. Ele fornece um resultado agregado diferente de zero se ssh ou tee falhar, o que é melhor do que o comportamento padrão. Mas não informa ao agente se a ação remota falhou, se a conexão de rede falhou ou se o registro local falhou. Esses casos levam a decisões diferentes.
Um status SSH de 255 exige tratamento especial. O OpenSSH reserva esse valor para um erro no caminho do cliente SSH, não para o resultado de um comando remoto. Um wrapper deve informá-lo como uma falha de transporte ou de execução do SSH, não afirmar que uma etapa remota nomeada retornou 255.
Há outro motivo prático para manter os resultados locais e remotos separados. Um transcript pode conter vários registros de pipelines remotos, avisos do shell de login e um diagnóstico do SSH. Se um agente analisar texto livre procurando o último número que aparece, cedo ou tarde escolherá o número errado. Use registros reconhecíveis e vincule o resultado final da ação ao status real de saída do processo.
SIGPIPE precisa de uma exceção escrita, não de um perdão geral
pipefail revela uma falha que muitos scripts ignoravam: SIGPIPE. No Bash, um processo terminado pelo sinal N recebe o status 128 + N; SIGPIPE normalmente aparece como 141.
Um caso clássico e intencional é:
generate_many_lines | head -n 10
head lê dez linhas e termina com sucesso. O gerador pode continuar escrevendo, receber SIGPIPE porque não há mais leitor e terminar com 141. Com pipefail, o pipeline pode parecer ter falhado, embora a amostra de dez linhas solicitada tenha sido produzida.
Isso não torna 141 inofensivo em todos os pipelines. Um cliente de rede, compressor ou produtor de dados pode receber SIGPIPE porque um consumidor downstream inesperado caiu ou rejeitou a entrada. Se você marcar todos os status 141 como sucesso, esconderá uma transferência interrompida.
A regra correta é específica: permita um status derivado de sinal apenas para uma etapa cuja interrupção antecipada faça parte do contrato pretendido do comando. Coloque essa exceção junto da etapa, não em uma configuração global do shell.
Por exemplo, um wrapper para uma prévia intencional pode aceitar generate_many_lines=141 somente quando head=0:
if (( status[0] == 141 && status[1] == 0 )); then
printf 'agent_pipeline_result pipeline=preview outcome=ok reason=expected_sigpipe\n' >&2
return 0
fi
Qualquer outro resultado diferente de zero continua sendo uma falha. Essa pequena dose de especificidade evita uma reação exagerada comum: alguém habilita pipefail, vê um 141 barulhento uma vez e depois o desabilita em todo o ambiente de automação.
Um agente precisa de evidências separadas da saída do comando
O agente não deve determinar o sucesso lendo prosa. Os comandos imprimem palavras de sucesso antes de falhar, as ferramentas misturam avisos com resultados e um script remoto pode emitir uma última linha depois que uma etapa já deu errado.
Defina um contrato de ação em duas camadas:
- O código de saída do processo decide se a ação solicitada teve sucesso.
- Registros estruturados de status explicam cada etapa relevante do pipeline.
Mantenha a saída normal dos comandos disponível para depuração, mas não peça ao agente que deduza o fluxo de controle a partir dela. No wrapper acima, o stderr carrega registros que começam com agent_pipeline_status e agent_pipeline_result. Um programa chamador pode preservar esse fluxo, analisar apenas esses registros exatos e ainda mostrar o restante a uma pessoa.
Não confie em um marcador apenas porque ele aparece em uma saída de comando não confiável. Se uma etapa do pipeline manipula dados fornecidos por outro usuário ou sistema, esses dados podem conter uma linha parecida com seu registro de status. O padrão mais seguro é fazer o wrapper capturar a saída das etapas e emitir os registros depois que o pipeline terminar. Para tarefas de maior risco, use um arquivo de resultado dedicado com permissões restritivas. Depois, faça o wrapper lê-lo e validá-lo antes de emitir um único registro final.
O relatório do agente deve incluir o código de saída remoto, o código de saída local do SSH e os status das etapas remotas nomeadas, quando disponíveis. Também deve distinguir estes resultados:
- a ação remota foi executada e uma etapa nomeada falhou;
- o wrapper remoto não conseguiu produzir um registro de status completo;
- o SSH não conseguiu estabelecer ou manter o canal da ação;
- a captura local do transcript falhou depois que a ação remota foi concluída.
Esses são fatos operacionalmente diferentes. Uma nova tentativa depois de uma perda de rede pode duplicar uma alteração remota já concluída. Uma nova tentativa depois de uma etapa de validação que falhou pode ser segura. Uma nova tentativa depois de um tee local com falha pode ser inútil, porque o trabalho remoto já aconteceu.
O Sallyport pode manter a credencial SSH fora do agente enquanto executa a ação SSH, mas o comando remoto ainda precisa desse contrato honesto de saída e evidências.
Teste os caminhos de falha antes que um agente os alcance
Um wrapper de shell só merece confiança depois de falhar de maneiras controladas. Testar o caminho feliz comprova o ramo menos interessante.
Crie comandos descartáveis que retornem os status que você quer observar:
fail_23() { printf 'collector failed\n' >&2; return 23; }
pass_through() { cat; }
succeed() { cat >/dev/null; return 0; }
set +e
fail_23 | pass_through | succeed
status=("${PIPESTATUS[@]}")
set -e
printf 'observed=%s,%s,%s\n' "${status[0]}" "${status[1]}" "${status[2]}"
O resultado esperado é 23,0,0. Depois, execute o mesmo padrão pela invocação SSH exata usada pelo agente. Não pare em um teste no shell local, porque a seleção do shell remoto, as aspas do heredoc, o pipeline local de registro e o comportamento de saída do wrapper ficam fora dessa primeira verificação.
Teste pelo menos estes casos:
- todas as etapas têm sucesso e o wrapper termina com zero;
- uma etapa inicial falha enquanto as etapas seguintes retornam zero;
- uma etapa intermediária falha depois de consumir parte da entrada;
- o SSH não consegue se conectar ou autenticar;
- o
teelocal não consegue gravar o transcript; - um pipeline deliberado com
headaciona a regra esperada para SIGPIPE.
Registre o código de saída esperado e os registros de etapas esperados para cada caso. Se um teste informar sucesso depois que uma etapa anterior retornou um valor diferente de zero, o wrapper não cumpriu sua função.
O atalho tentador é fazer o agente inspecionar um transcript depois de cada ação e julgar se a saída «parece correta». Isso falha sob carga, quando as ferramentas mudam suas mensagens e quando a saída é truncada. Os códigos de saída são o canal de controle. Os registros das etapas são o canal de evidências. Mantenha-os separados, preserve ambos através do SSH e não permita que um tee final decida se uma ação remota aconteceu.
FAQ
O SSH retorna o código de saída de todos os comandos em um pipeline remoto?
Não. O OpenSSH retorna o status de saída do comando remoto, mas um pipeline do shell remoto normalmente informa o status de sua última etapa. Se essa etapa for tee, cat ou um formatador que termina com zero, o SSH pode retornar zero mesmo quando um comando remoto anterior falhou.
Pipefail é suficiente para automação com SSH?
set -o pipefail muda o resultado do pipeline, que deixa de ser o status da última etapa e passa a ser o status da etapa mais à direita que terminou com valor diferente de zero. Isso informa ao processo chamador que algo falhou, mas não identifica todas as etapas com erro nem preserva um registro etapa por etapa para um agente.
Como capturo o status de saída de cada etapa de um pipeline no Bash?
No Bash, copie o valor imediatamente após o pipeline: statuses=("${PIPESTATUS[@]}"). Faça isso antes de echo, local, de uma atribuição que leia $? ou de qualquer outro comando, pois o comando seguinte substitui o conteúdo do array.
O Bash do macOS oferece suporte a PIPESTATUS?
O macOS inclui o Bash 3.2, que oferece suporte a PIPESTATUS e set -o pipefail. Ainda assim, não presuma que /bin/sh seja Bash. Execute o programa remoto como bash -s ou invoque um script Bash usando um caminho explícito.
Por que pipefail às vezes retorna 141?
Um status 141 geralmente significa que um processo recebeu SIGPIPE. Isso pode ser normal quando um comando downstream para de ler de propósito, como em head. Considere esse status esperado apenas em um pipeline cuja interrupção antecipada foi planejada e testada. Caso contrário, investigue-o como qualquer outra falha.
Também preciso verificar um pipeline local com tee depois do SSH?
Não. Um pipeline remoto e um pipeline local como ssh ... | tee log são separados. O wrapper remoto precisa informar suas próprias etapas, e o wrapper local precisa capturar o status tanto de ssh quanto de tee.
Devo usar set -e com pipefail?
set -e tem exceções que dependem do contexto, especialmente perto de condicionais, substituições de comandos e pipelines. Ele pode interromper um script antes que você colete evidências úteis. Por isso, use a captura explícita de status nos pipelines de ações e reserve set -e para estruturas de script mais simples.
O que um agente deve receber depois da execução de um pipeline remoto?
Use um registro estável e legível por máquina que identifique o pipeline e cada etapa, e termine com um valor diferente de zero se alguma etapa obrigatória falhar. Mantenha esse registro separado da saída destinada a pessoas, para que o agente não confunda uma linha final bem formatada com um sinal de sucesso.
Posso ignorar o código de saída diferente de zero de uma etapa do pipeline?
Não trate todo status diferente de zero como uma falha genérica. Decida se cada etapa permite valores como o 1 do grep para indicar que não houve correspondência e registre essa regra junto da etapa. Um || true genérico esconde justamente as falhas que você queria detectar.
Como testo se um agente não pode informar falsamente que o SSH teve sucesso?
Coloque um produtor que falhe de propósito, uma etapa intermediária bem-sucedida e uma etapa final bem-sucedida em um script remoto descartável. Verifique o vetor exato de status, a saída diferente de zero do wrapper e o status local do SSH. Teste separadamente o caso de sucesso e um caso intencional de SIGPIPE.