SFTP-Dateiabfragen mit Ruby automatisieren
Fragen Sie mit Net::SFTP ein vertrauenswürdiges Eingangsverzeichnis regelmäßig ab und stellen Sie vollständige Dateien in einem lokalen Verzeichnis bereit. Das folgende Skript prüft den Host-Schlüssel des Servers, lädt Dateien zunächst temporär herunter und ersetzt erst danach lokale Dateien. Remote-Dateien bleiben erhalten, sofern Sie das Löschen nicht explizit aktivieren. Nach jeder Wartezeit von 60 Sekunden fragt es alle infrage kommenden Dateien erneut ab; es merkt sich nicht, welche Dateien Sie bereits importiert haben.
Voraussetzungen und Einrichtung
Dieses Beispiel verwendet Linux, Ruby 3.4.10 mit OpenSSL-Unterstützung und Bundler 2.6.9. Ruby 3.4 ist eine gepflegte Versionsreihe; Ruby 3.1 und Ruby 3.2 haben das Ende ihres Lebenszyklus erreicht. Halten Sie einen Compiler und Ruby-Entwicklungsheader für die nativen Erweiterungen der Gems bereit.
Sie benötigen ein bestehendes SFTP-Konto, dessen Administrator Ihren öffentlichen Schlüssel
autorisiert und Lese- sowie Auflistungsrechte für ein Eingangsverzeichnis erteilt hat. Hinweise zur
Serverkonfiguration und Schlüsselautorisierung finden Sie im
OpenSSH-Serverhandbuch.
Dieses unbeaufsichtigt laufende Beispiel verwendet einen dedizierten, unverschlüsselten privaten
Schlüssel, der durch Dateisystemberechtigungen geschützt ist. Beziehen Sie den öffentlichen
Host-Schlüssel oder Fingerabdruck des Servers über einen vertrauenswürdigen Kanal vom Administrator.
Wenn Sie einen möglichen Schlüssel mit ssh-keyscan erfassen, verifizieren Sie ihn,
bevor Sie ihn einer dedizierten Datei known_hosts hinzufügen; ein Scan allein schafft
kein Vertrauen. Bei einem vom Standard abweichenden Port muss der Host-Eintrag
[hostname]:port verwenden.
Beginnen Sie in einem neuen Verzeichnis und speichern Sie Folgendes als
Gemfile. Dies sind die hier verwendeten Versionen, einschließlich der
Ed25519-Abhängigkeiten von 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'
Installieren Sie das Bundle in diesem Verzeichnis und bewahren Sie anschließend die erzeugte Datei
Gemfile.lock zusammen mit dem Skript auf:
bundle install
Verwenden Sie ein lokales Ziel, das ausschließlich diesem Importer gehört. Erzeugende Prozesse müssen den Upload unter einem Namen mit vorangestelltem Punkt abschließen, die Datei dann auf ihren endgültigen Namen umbenennen und die bereitgestellten Bytes anschließend unverändert lassen. Dieses Skript sperrt keine Remote-Dateien und erkennt nicht, wenn ein erzeugender Prozess eine Datei während des Lesens verändert.
Den Datei-Poller erstellen
Speichern Sie Folgendes als sftp_poller.rb neben
Gemfile. Es importiert reguläre Dateien direkt aus
REMOTE_DIR und überspringt Punktdateien, Unterverzeichnisse und symbolische Links.
Jeder erfolgreiche Download ersetzt eine gegebenenfalls vorhandene lokale Datei gleichen Namens.
Bei einem fehlgeschlagenen Download bleibt die bisherige lokale Datei erhalten.
Dieser Poller kann jede Datei nach dem Download vom Remote-Server löschen. Das ist unumkehrbar
und für ein gemeinsam genutztes Eingangsverzeichnis ungeeignet. Dieser Schritt ist standardmäßig
deaktiviert; setzen Sie DELETE_AFTER_DOWNLOAD=true nur, wenn das Remote-Verzeichnis diesem
Skript gehört. Die Aktivierung allein reicht nicht aus: Wenn ein erzeugender Prozess zwischen
Download und Löschen denselben Dateinamen erneut beschreibt, löschen Sie eine Version, die Sie
nie importiert haben. Aktivieren Sie dies nur, wenn erzeugende Prozesse jede Datei genau einmal
schreiben und dafür einen Namen verwenden, den sie nie wiederverwenden.
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' })
Ersetzen Sie die Verbindungsdaten und absoluten Pfade unten und führen Sie dies dann aus dem
Verzeichnis des Skripts aus. SSH_KEY_PATH bezeichnet die Datei mit dem privaten
Schlüssel; speichern Sie den Inhalt des Schlüssels nicht in einer Umgebungsvariable.
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
Nach einem erfolgreichen Verzeichnisdurchlauf enthalten die JSON-Logs
connection_established, für jede importierte Datei ein Ereignis
download_complete sowie polling_wait. Prüfen Sie die genannten
Zieldateien einschließlich ihrer Bytes, bevor Sie einen verarbeitenden Prozess anbinden. Ein
Abschlussereignis bedeutet, dass die lokale Umbenennung erfolgreich war; es bedeutet nicht, dass
die nachgelagerte Verarbeitung oder das optionale Löschen auf dem Remote-Server erfolgreich war.
Leere Dateien sind gültige Importe.
Das Skript verstehen
Dieses Skript implementiert mehrere wichtige Funktionen:
-
Konfiguration über Umgebungsvariablen: Verwendet Umgebungsvariablen für sensible Konfigurationsdaten und folgt damit bewährten Sicherheitsverfahren.
-
Strukturierte Protokollierung: Gibt pro Ereignis ein JSON-Objekt aus. Die Ereignisfelder sind unter
messageverschachtelt statt in einen String serialisiert, damit Log-Aggregatoren sie indizieren können. -
Atomare Dateioperationen: Speichert jeden Download zunächst temporär neben seinem Ziel und benennt ihn dann auf den endgültigen Namen um. Die Umbenennung ist nur innerhalb eines einzelnen Dateisystems atomar. Deshalb wird die temporäre Datei in
LOCAL_DIRstatt in/tmperstellt. Verarbeitende Prozesse müssen Namen ignorieren, die mitsftp-downloadbeginnen, da es sich um temporäre Dateien handelt. Reservieren Sie dieses Präfix und verwenden Sie es nicht für Eingabedateinamen. Nur die endgültigen Dateinamen werden atomar bereitgestellt. Beachten Sie, dassTempfile.createden Modus0600verwendet und die Umbenennung ihn beibehält. Fügen Sie deshalb vor der Umbenennung einFile.chmodhinzu, wenn ein anderes Konto Ihre importierten Dateien lesen muss. Dies gilt nur, wennLOCAL_DIRausschließlich dem Skript gehört: Wählen Sie ein Verzeichnis, in das nichts anderes schreibt, und lassen Sie verarbeitende Prozesse Dateien daraus verschieben, statt darin Unterverzeichnisse anzulegen. Die Atomarität erstreckt sich auch nicht auf die Remote-Seite. Wenn ein erzeugender Prozess eine Datei während des Lesens neu schreibt, erhalten Sie eine Mischung aus zwei Versionen. Lassen Sie erzeugende Prozesse daher unter einem temporären Namen hochladen und die Datei nachREMOTE_DIRumbenennen, sobald alle Bytes vorliegen. Atomares Ersetzen garantiert keine dauerhafte Speicherung bei Stromausfall: Das Skript ruftfsyncnicht auf. Die Tempfile-Dokumentation von Ruby erläutert die hier verwendeten Berechtigungen und das explizite Aufräumen. -
Nicht vertrauenswürdige Eintragsnamen:
plain_basename?weist alle vom Server aufgelisteten Einträge zurück, die keine einfachen Dateinamen sind. Ohne diese Prüfung besteht ein Name wiea/../../escaped.txtsowohl die Punktdateiprüfung als auchattributes.file?, verlässt nach dem Anfügen jedochLOCAL_DIR, liest einen Pfad außerhalb vonREMOTE_DIRund entfernt bei aktiviertem Löschen eine Datei, die Sie nie angefordert haben. -
Fehlerbehandlung: Wiederholt bei abgelehnter Verbindung oder Verbindungs-Timeout den Verbindungsaufbau bis zu insgesamt drei Versuchen, mit einer Wartezeit von einer und dann zwei Sekunden. SFTP-Statusfehler und lokale Dateisystemfehler bei einem einzelnen Download werden protokolliert; der betroffene Eintrag wird übersprungen, sodass der nächste weiterhin importiert werden kann. Unbekannte oder geänderte Host-Schlüssel, fehlgeschlagene Authentifizierung, eine fehlgeschlagene Verzeichnisauflistung und eine unterbrochene SSH-Verbindung beenden den Prozess. Beheben Sie die Ursache vor dem Neustart; umgehen Sie die Host-Prüfungen nicht.
-
Beenden: Strg+C oder
SIGTERMfordert das Beenden an. Während der Wartezeit zwischen Abfragen prüft das Skript diese Anforderung einmal pro Sekunde. Während einer Übertragung schließt es die aktuelle Datei und ein gegebenenfalls aktiviertes Löschen ab und stoppt dann vor dem nächsten Eintrag. Das Verbindungs-Timeout begrenzt den anfänglichen Verbindungsaufbau, nicht die gesamte Übertragung. Ein nicht reagierender Server kann das Beenden daher verzögern. Bei einer gewöhnlichen Ausnahme werden temporäre Dateien aufgeräumt; beiSIGKILLoder einem Rechnerabsturz ist das nicht möglich. Entfernen Sie nach einem erzwungenen Stopp zurückgebliebene Dateien mit dem Mustersftp-download*nur, während der Importer gestoppt ist. -
Optionales Aufräumen auf dem Remote-Server: Entfernt erfolgreich heruntergeladene Dateien vom Remote-Server, um eine doppelte Verarbeitung zu verhindern, aber nur bei
DELETE_AFTER_DOWNLOAD=true. Das Löschen bezieht sich auf einen Namen, nicht auf die heruntergeladene Version. Es setzt daher dieselbe Disziplin des einmaligen Schreibens voraus wie in der obigen Warnung beschrieben. Wenn die Funktion deaktiviert bleibt, benötigen Sie eine andere Methode, um erneute Verarbeitung zu vermeiden, etwa ein lokales Verzeichnis der bereits importierten Dateinamen.
Bereitstellung für den Produktivbetrieb
Das ausführbare Beispiel oben läuft als Vordergrundprozess. Bevor Sie es von einer Prozessaufsicht verwalten lassen, legen Sie fest, wie verarbeitende Prozesse doppelte Verarbeitung vermeiden, überwachen Sie sowohl Fehler pro Datei als auch Prozessbeendigungen und stellen Sie dauerhaften Speicher für Downloads bereit. Führen Sie pro Ziel nur einen Importer aus. Die folgenden Hinweise betreffen die Paketierung und sind keine vollständigen Bereitstellungskonfigurationen.
Systemd verwenden
Das Dienstkonto benötigt Lesezugriff auf den Schlüssel, die Datei mit vertrauenswürdigen Hosts, das Skript und das installierte Bundle sowie Schreibzugriff auf das Ziel. Verwenden Sie das Verzeichnis des Skripts als Arbeitsverzeichnis und rufen Sie es mit Bundler auf. Bemessen Sie die Schonfrist beim Stoppen passend zu Ihren Übertragungen; eine Anforderung zum Beenden bricht keinen aktiven Download ab. Wenn eine Prozessaufsicht einen hängenden Importer schließlich zwangsweise beendet, kann eine temporäre Datei zurückbleiben. Die Wiederherstellung muss dies berücksichtigen.
docker verwenden
Verwenden Sie ein gepflegtes Ruby-Image, installieren Sie dasselbe Bundle mit festgeschriebenen
Versionen und halten Sie Zugangsdaten außerhalb des Images. Binden Sie den Schlüssel und die Datei
mit vertrauenswürdigen Hosts schreibgeschützt ein und speichern Sie das Download-Verzeichnis
dauerhaft auf einem Volume. Stellen Sie sicher, dass Ruby das Stoppsignal erhält. Die standardmäßige
Schonfrist von Docker unter Linux beträgt nur 10 Sekunden; danach sendet es
SIGKILL. Konfigurieren Sie die Schonfrist passend zu Ihrer Arbeitslast und
testen Sie eine aktive Übertragung, nicht nur einen untätigen Container. Siehe
Stoppverhalten von Docker.
Bewährte Sicherheitsverfahren
-
Host-Schlüssel verifizieren:
- Behalten Sie
verify_host_key: :alwaysbei, damit ein unbekannter oder geänderter Host-Schlüssel die Verbindung abbricht - Erstellen Sie
known_hostsanhand eines Fingerabdrucks, den Sie über einen unabhängigen Kanal bestätigt haben, nicht anhand dessen, was die erste Verbindung gerade liefert - Liefern Sie
known_hostsmit der Bereitstellung aus (siehe die VariableSSH_KNOWN_HOSTSoben), statt sich auf das Home-Verzeichnis des ausführenden Benutzers zu verlassen
- Behalten Sie
-
SSH-Schlüssel verwalten:
- Rotieren Sie SSH-Schlüssel regelmäßig
- Das Bundle mit festgeschriebenen Versionen enthält die optionalen Gems, die Net::SSH 7.3.0 für Ed25519-Schlüssel benötigt
- Beschränken Sie den Zugriff auf die private Schlüsseldatei auf das Konto des Importers
-
Netzwerksicherheit:
- Beschränken Sie den SFTP-Zugriff auf bestimmte IP-Bereiche
- Verwenden Sie starke Verschlüsselungs- und Schlüsselaustauschalgorithmen
- Überwachen Sie stockende Übertragungen; das anfängliche Verbindungs-Timeout ist keine Zeitgrenze für die Übertragung
-
Dateizugriff:
- Verwenden Sie minimale Berechtigungen für lokale und Remote-Dateien
- Implementieren Sie Prüfungen der Dateiintegrität
- Räumen Sie temporäre Dateien ordnungsgemäß auf
-
Überwachung:
- Richten Sie Warnmeldungen für fehlgeschlagene Downloads und Verbindungsprobleme ein
- Überwachen Sie die Speicherplatzbelegung
- Erfassen Sie Verarbeitungsmetriken
Fazit
Dieses Ruby-Skript ist ein Ausgangspunkt für automatisierte SFTP-Dateiimporte. Es deckt die Fälle ab, die häufig zuerst Probleme bereiten: Host-Schlüssel-Verifizierung, vom Server kontrollierte Eintragsnamen, lokale Schreibvorgänge, die entweder vollständig oder gar nicht sichtbar sind, und Fehler, die die Abfrageschleife nicht beenden dürfen. Deduplizierung über Neustarts hinweg, Speicherplatzgrenzen und Warnmeldungen bei einem verstummten Poller müssen Sie noch ergänzen.
Wenn Sie Transloadit bereits nutzen, lesen Sie die Dokumentation zum SFTP-Import.
