Automatizando o polling de arquivos SFTP com Ruby
Use o Net::SFTP para consultar periodicamente um diretório de depósito confiável e publicar arquivos completos em um diretório local. O script abaixo verifica a chave de host do servidor, prepara os downloads em uma área temporária antes de substituir os arquivos locais e mantém os arquivos remotos, a menos que você ative a exclusão explicitamente. Ele consulta novamente todos os arquivos elegíveis após cada espera de 60 segundos; ele não guarda quais arquivos você já importou.
Pré-requisitos e configuração
Este exemplo usa Linux, Ruby 3.4.10 com suporte a OpenSSL e Bundler 2.6.9. O Ruby 3.4 é uma série de versões mantida; o Ruby 3.1 e o Ruby 3.2 chegaram ao fim da vida útil. Tenha um compilador e os headers de desenvolvimento do Ruby disponíveis para as extensões nativas das gems.
Você precisa de uma conta SFTP existente cujo administrador tenha autorizado a sua chave pública e
concedido acesso de leitura/listagem a um diretório de depósito. Para a configuração do servidor e a
autorização de chaves, consulte o
manual do servidor OpenSSH.
Este exemplo não supervisionado usa uma chave privada dedicada e sem criptografia, protegida pelas
permissões do sistema de arquivos. Obtenha a chave pública de host do servidor ou a impressão
digital dela com o administrador por um canal confiável. Se você coletar uma candidata com ssh-keyscan,
verifique-a antes de adicioná-la a um arquivo known_hosts dedicado; uma varredura por si só não
estabelece confiança. Para uma porta não padrão, a entrada do host precisa usar [hostname]:port.
Comece em um novo diretório e salve isto como Gemfile. Estas são as versões usadas aqui,
incluindo as dependências de Ed25519 do Net::SSH 7.3.0:
source 'https://rubygems.org'
gem 'net-sftp', '4.0.0'
gem 'net-ssh', '7.3.0'
gem 'ed25519', '1.4.0'
gem 'bcrypt_pbkdf', '1.1.1'
gem 'base64', '0.3.0'
Instale o bundle nesse diretório e depois mantenha o Gemfile.lock gerado junto com o script:
bundle install
Use um destino local de uso exclusivo deste importador. Os produtores precisam terminar o upload com um nome prefixado por ponto e renomeá-lo para o nome final, sem alterar depois os bytes publicados. Este script não bloqueia arquivos remotos nem detecta um produtor modificando um arquivo enquanto ele está sendo lido.
Construindo o poller de arquivos
Salve o seguinte como sftp_poller.rb ao lado de Gemfile. Ele importa arquivos regulares diretamente
dentro de REMOTE_DIR, ignorando dotfiles, subdiretórios e links simbólicos. Cada download
bem-sucedido substitui qualquer arquivo local existente com o mesmo nome. Um download com falha
mantém o arquivo local anterior no lugar.
Este poller pode excluir cada arquivo do servidor remoto após baixá-lo, o que é irreversível e é
o comportamento errado para um diretório de depósito compartilhado. Essa etapa vem desativada por
padrão; defina DELETE_AFTER_DOWNLOAD=true somente quando este script for o dono do diretório remoto. Ativar a
opção, por si só, não basta: se um produtor reescrever um nome entre o download e a remoção, você
acaba excluindo uma revisão que nunca importou. Só ative a exclusão quando os produtores gravarem
cada arquivo uma única vez, com um nome que nunca reutilizem.
require 'net/sftp'
require 'logger'
require 'json'
require 'tempfile'
require 'fileutils'
require 'time' # Time#iso8601, used by the log formatter below
# Configuration constants
SFTP_HOST = ENV.fetch('SFTP_HOST')
SFTP_USER = ENV.fetch('SFTP_USER')
SFTP_PORT = ENV.fetch('SFTP_PORT', 22).to_i
REMOTE_DIR = ENV.fetch('REMOTE_DIR')
LOCAL_DIR = ENV.fetch('LOCAL_DIR', './downloads')
SSH_KEY = ENV.fetch('SSH_KEY_PATH')
KNOWN_HOSTS = ENV.fetch('SSH_KNOWN_HOSTS', File.expand_path('~/.ssh/known_hosts'))
# Removing the remote copy cannot be undone, so it has to be asked for explicitly.
DELETE_AFTER_DOWNLOAD = ENV.fetch('DELETE_AFTER_DOWNLOAD', 'false') == 'true'
# Initialize structured logger
STDOUT.sync = true
logger = Logger.new(STDOUT)
logger.formatter = proc do |severity, datetime, progname, msg|
JSON.dump(
timestamp: datetime.iso8601,
severity: severity,
message: msg,
service: 'sftp-poller'
) + "\n"
end
def download_file(sftp, remote_file, final_path, logger)
temp_path = nil
begin
# Stage inside the destination directory so the final rename stays on one filesystem. That is
# what makes it atomic; Tempfile's default directory is usually a different mount.
temp = Tempfile.create('sftp-download', File.dirname(final_path))
temp_path = temp.path
temp.close
sftp.download!(remote_file, temp_path)
File.rename(temp_path, final_path)
temp_path = nil
logger.info({ action: 'download_complete', file: remote_file, destination: final_path })
true
rescue Net::SFTP::StatusException => e
logger.error({ action: 'download_failed', file: remote_file, error: e.message, code: e.code })
false
rescue SystemCallError, IOError => e
# A local failure (permissions, a full disk) must not take the whole poller down.
logger.error({ action: 'download_failed', file: remote_file, error: e.message })
false
ensure
File.unlink(temp_path) if temp_path && File.exist?(temp_path)
end
end
def with_retries(max_attempts: 3, base_delay: 1, logger:)
attempt = 0
begin
attempt += 1
yield
rescue Net::SSH::AuthenticationFailed => e
logger.error({ action: 'authentication_failed', error: e.message })
raise
rescue Errno::ECONNREFUSED, Net::SSH::ConnectionTimeout => e
if attempt < max_attempts
delay = base_delay * (2 ** (attempt - 1))
logger.warn({ action: 'retry_attempt', attempt: attempt, delay: delay, error: e.message })
sleep delay
retry
end
logger.error({ action: 'max_retries_reached', error: e.message })
raise
end
end
# Entry names come from the server, so only a plain basename may be joined onto REMOTE_DIR and
# LOCAL_DIR. A name such as `a/../../escaped.txt` is neither hidden nor a directory, yet it reads
# outside REMOTE_DIR, writes outside LOCAL_DIR, and with deletion enabled removes the wrong remote
# file. Reject those names rather than trying to repair them.
def plain_basename?(name)
return false if name.nil? || name.empty?
return false if name.include?('/') || name.include?('\\')
return false if name == '.' || name == '..'
File.basename(name) == name
end
def poll_sftp(logger)
with_retries(logger: logger) do
Net::SFTP.start(
SFTP_HOST,
SFTP_USER,
port: SFTP_PORT,
keys: [SSH_KEY],
keys_only: true,
config: false,
use_agent: false,
auth_methods: ['publickey'],
non_interactive: true,
timeout: 10,
# Refuse to connect to a host whose key is not already trusted.
verify_host_key: :always,
user_known_hosts_file: KNOWN_HOSTS,
global_known_hosts_file: []
) do |sftp|
logger.info({ action: 'connection_established', host: SFTP_HOST, directory: REMOTE_DIR })
sftp.dir.foreach(REMOTE_DIR) do |entry|
break if @shutdown
# A leading dot is usually a producer's partial upload, so skip those quietly.
next if entry.name.start_with?('.')
unless plain_basename?(entry.name)
logger.warn({ action: 'entry_rejected', file: entry.name })
next
end
# download! raises on directories, which would otherwise break every future cycle too.
next unless entry.attributes.file?
remote_file = File.join(REMOTE_DIR, entry.name)
local_file = File.join(LOCAL_DIR, entry.name)
next unless download_file(sftp, remote_file, local_file, logger)
next unless DELETE_AFTER_DOWNLOAD
begin
sftp.remove!(remote_file)
logger.info({ action: 'remote_file_removed', file: remote_file })
rescue Net::SFTP::StatusException => e
logger.error({ action: 'remove_failed', file: remote_file, error: e.message })
end
end
end
end
end
# Ensure the local download directory exists
FileUtils.mkdir_p(LOCAL_DIR)
# Set up signal handling for graceful shutdown
@shutdown = false
Signal.trap('TERM') { @shutdown = true }
Signal.trap('INT') { @shutdown = true }
# Main polling loop with graceful shutdown
until @shutdown
poll_sftp(logger)
break if @shutdown
logger.info({ action: 'polling_wait', delay: 60 })
60.times do
break if @shutdown
sleep 1
end
end
logger.info({ action: 'shutdown_complete' })
Substitua os detalhes de conexão e os caminhos absolutos abaixo e execute isto a partir do
diretório do script. SSH_KEY_PATH indica o arquivo da chave privada; não coloque o conteúdo da chave
em uma variável de ambiente.
SFTP_HOST=sftp.example.com \
SFTP_PORT=22 \
SFTP_USER=importer \
REMOTE_DIR=/drop \
LOCAL_DIR=/srv/sftp-import/downloads \
SSH_KEY_PATH=/srv/sftp-import/id_ed25519 \
SSH_KNOWN_HOSTS=/srv/sftp-import/known_hosts \
DELETE_AFTER_DOWNLOAD=false \
bundle exec ruby sftp_poller.rb
Após uma varredura bem-sucedida, os logs JSON contêm connection_established, um evento download_complete
para cada arquivo importado e polling_wait. Confira os arquivos de destino indicados, incluindo os
bytes deles, antes de conectar um consumidor. Um evento de conclusão significa que a renomeação
local foi bem-sucedida; não significa que o processamento posterior ou a exclusão remota opcional
tenham sido bem-sucedidos. Arquivos vazios são importações válidas.
Entendendo o script
Este script implementa vários recursos importantes:
-
Configuração baseada em ambiente: usa variáveis de ambiente para configurações sensíveis, seguindo boas práticas de segurança.
-
Logs estruturados: emite um objeto JSON por evento, com os campos do evento aninhados em
messageem vez de serializados em uma string, para que agregadores de logs possam indexá-los. -
Operações de arquivo atômicas: prepara cada download ao lado do destino e o renomeia para o lugar final. A renomeação só é atômica dentro de um único sistema de arquivos, e é por isso que o arquivo temporário é criado em
LOCAL_DIRem vez de/tmp. Os consumidores precisam ignorar nomes que começam comsftp-download, que são arquivos temporários; reserve esse prefixo e não o use em nomes de entrada. Somente os nomes de arquivo finais são publicados de forma atômica. Observe queTempfile.createusa o modo0600, e a renomeação o mantém; por isso, adicione umFile.chmodantes da renomeação se outra conta precisar ler o que você importou. Isso só vale se o script for o dono exclusivo deLOCAL_DIR: aponte-o para um diretório em que nada mais grave e faça os consumidores moverem os arquivos para fora em vez de criar subdiretórios dentro dele. A atomicidade também termina na ponta remota. Um produtor que reescreve um arquivo enquanto ele está sendo lido entrega a você uma mistura de duas revisões; então, faça os produtores fazerem o upload com um nome temporário e renomearem paraREMOTE_DIRquando todos os bytes estiverem lá. A substituição atômica não é uma garantia de durabilidade contra queda de energia: o script não chamafsync. A documentação de Tempfile do Ruby explica as permissões e a limpeza explícita usadas aqui. -
Nomes de entrada não confiáveis:
plain_basename?rejeita tudo o que o servidor listar que não seja um nome de arquivo simples. Sem isso, um nome comoa/../../escaped.txtpassa tanto pela verificação de dotfile quanto porattributes.file?, depois escapa deLOCAL_DIRao ser concatenado, lê um caminho fora deREMOTE_DIRe, com a exclusão ativada, remove um arquivo que você nunca pediu. -
Tratamento de erros: tenta novamente em caso de conexão recusada e de tempo limite de conexão, até três tentativas, aguardando um segundo e depois dois segundos. Erros de status SFTP e erros do sistema de arquivos local durante um download individual são registrados e ignorados, para que a próxima entrada ainda possa ser importada. Chaves de host desconhecidas ou alteradas, falha de autenticação, falha na listagem do diretório e uma conexão SSH interrompida encerram o processo. Corrija a causa antes de reiniciar; não contorne as verificações de host.
-
Encerramento: Ctrl+C ou
SIGTERMsolicita o encerramento. Durante a espera do polling, o script verifica essa solicitação uma vez por segundo. Durante uma transferência, ele conclui o arquivo atual e qualquer remoção ativada e então para antes da próxima entrada. O tempo limite de conexão limita a configuração inicial da conexão, não a transferência inteira, então um servidor travado pode atrasar o encerramento. Uma exceção comum executa a limpeza dos arquivos temporários;SIGKILLou uma falha da máquina não conseguem fazer isso. Após uma parada forçada, remova os arquivossftp-download*restantes somente enquanto o importador estiver parado. -
Limpeza remota opcional: remove do servidor remoto os arquivos baixados com sucesso para evitar processamento duplicado, mas somente quando
DELETE_AFTER_DOWNLOAD=true. A remoção tem como alvo um nome, não a revisão que foi baixada, então depende da mesma disciplina de gravação única descrita no aviso acima. Deixá-la desativada significa que você precisa de outra forma de evitar o reprocessamento, como um registro local dos nomes de arquivo importados.
Implantação em produção
O exemplo executável acima é um processo em primeiro plano. Antes de colocá-lo sob supervisão, decida como os consumidores evitam processamento duplicado, monitore os erros por arquivo além das saídas do processo e forneça armazenamento durável para os downloads. Execute apenas um importador por destino. Os itens a seguir são considerações de empacotamento, não configurações completas de implantação.
Usando o Systemd
A identidade do serviço precisa de acesso de leitura à chave, ao arquivo de hosts confiáveis, ao script e ao bundle instalado, além de acesso de gravação ao destino. Use o diretório do script como diretório de trabalho e invoque-o pelo Bundler. Dimensione o período de tolerância de parada para as suas transferências; uma solicitação de encerramento não cancela um download ativo. Um supervisor que acaba matando um importador travado pode deixar um arquivo temporário para trás, então a recuperação precisa levar isso em conta.
Usando o docker
Use uma imagem Ruby mantida, instale o mesmo bundle, com as versões de dependências fixadas pelo
arquivo de lock, e mantenha as credenciais fora da imagem. Monte a chave e o arquivo de hosts
confiáveis como somente leitura e persista o diretório de downloads em um volume. Garanta que o Ruby
receba o sinal de parada. O período de tolerância de parada padrão do Docker no Linux é de apenas
10 segundos; depois disso, ele envia SIGKILL. Configure o período de tolerância para a sua carga
de trabalho e teste uma transferência ativa, não apenas um contêiner ocioso. Veja o
comportamento de parada do Docker.
Boas práticas de segurança
-
Verificação da chave de host:
- Mantenha
verify_host_key: :alwayspara que uma chave de host desconhecida ou alterada aborte a conexão - Provisione
known_hostsa partir de uma impressão digital que você confirmou por outro canal, não a partir do que a primeira conexão por acaso apresentar - Distribua
known_hostsjunto com a implantação (veja a variávelSSH_KNOWN_HOSTSacima) em vez de depender do diretório home do usuário que executa o script
- Mantenha
-
Gerenciamento de chaves SSH:
- Faça a rotação das chaves SSH regularmente
- O bundle fixado inclui as gems opcionais necessárias para chaves Ed25519 no Net::SSH 7.3.0
- Restrinja o acesso ao arquivo da chave privada à conta do importador
-
Segurança de rede:
- Restrinja o acesso SFTP a faixas de IP específicas
- Use cifras e algoritmos de troca de chaves fortes
- Monitore transferências que travam; o tempo limite da conexão inicial não é um prazo para a transferência
-
Acesso a arquivos:
- Use permissões mínimas tanto para arquivos locais quanto para remotos
- Implemente verificações de integridade de arquivos
- Limpe os arquivos temporários corretamente
-
Monitoramento:
- Configure alertas para downloads com falha e problemas de conexão
- Monitore o uso de espaço em disco
- Acompanhe métricas de processamento
Conclusão
Este script Ruby é um ponto de partida para importações automatizadas de arquivos via SFTP. Ele cobre os casos que costumam causar problemas primeiro: verificação da chave de host, nomes de entrada controlados pelo servidor, uma gravação local que está completa ou ausente e uma falha que não pode encerrar o loop de polling. Deduplicação entre reinicializações, limites de espaço em disco e alertas para um poller que parou de dar sinais de vida ainda ficam por sua conta.
Se você já usa a Transloadit, consulte a documentação de importação via SFTP.
