SFTP-Datei-Polling mit Ruby automatisieren
Dateien direkt von entfernten SFTP-Servern zu importieren, kann eine repetitive Aufgabe sein, wenn Sie es manuell erledigen. Statt einmalige Skripte auszuführen, können Sie diesen Vorgang automatisieren, indem Sie ein entferntes Verzeichnis kontinuierlich abfragen und neue Dateien herunterladen, sobald sie erscheinen. In diesem Beitrag zeigen wir Schritt für Schritt, wie Sie ein Ruby-Skript erstellen, das das Net::SFTP-Gem für sichere, automatisierte Dateiimporte nutzt.
Voraussetzungen und Einrichtung
Um dieser Anleitung zu folgen, stellen Sie sicher, dass Sie über Folgendes verfügen:
- Ruby 3.1 oder neuer. Das Gem
net-sftpzieht ein Release vonnet-sshnach sich, das einen aktuellen Interpreter benötigt, und das Docker-Beispiel weiter unten baut aufruby:3.2-slimauf. - Net::SFTP-Gem (Version 4.0.0 oder neuer):
gem install net-sftp -v '~> 4.0.0' - Ein gültiger SSH-Schlüssel für die passwortlose Authentifizierung
- Der Host-Key des Servers in Ihrer Datei
known_hosts:ssh-keyscan -p 22 sftp.example.com >> ~/.ssh/known_hosts, abgeglichen mit einem Fingerprint, den Sie über einen separaten Kanal erhalten haben - Grundkenntnisse in Ruby und im Umgang mit der Kommandozeile
Stellen Sie außerdem sicher, dass Ihr SFTP-Server für die Annahme Ihres SSH-Schlüssels konfiguriert wurde und dass Sie über die nötigen Berechtigungen für den Zugriff auf das Zielverzeichnis verfügen.
Den Datei-Poller erstellen
Das Ziel ist ein Ruby-Skript, das sich mit einem SFTP-Server verbindet, ein festgelegtes Verzeichnis nach Dateien durchsucht, diese in einen lokalen Ordner herunterlädt und dann wartet, bevor es erneut abfragt. Wir integrieren strukturiertes Logging, eine Fehlerbehandlung, die eine fehlerhafte Datei übersteht, und die Sicherheitsprüfungen, die ein dauerhaft laufender Importer benötigt.
Hier ist ein vollständiges Beispiel für das Ruby-Skript mit modernen bewährten Verfahren:
Dieser Poller kann jede Datei nach dem Herunterladen vom entfernten Server löschen. Das lässt sich
nicht rückgängig machen und ist für ein gemeinsam genutztes Ablageverzeichnis das falsche
Verhalten. Dieser Schritt ist standardmäßig deaktiviert; setzen Sie DELETE_AFTER_DOWNLOAD=true nur dann, wenn
dieses Skript das entfernte Verzeichnis allein verwaltet. Das Opt-in allein genügt nicht: Wenn ein
Produzent einen Namen zwischen dem Herunterladen und dem Entfernen neu schreibt, löschen Sie eine
Revision, die Sie nie importiert haben. Aktivieren Sie die Option nur, wenn Produzenten jede Datei
einmal schreiben, unter einem Namen, 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
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,
# Refuse to connect to a host whose key is not already trusted.
verify_host_key: :always,
user_known_hosts_file: KNOWN_HOSTS
) do |sftp|
logger.info({ action: 'connection_established', host: SFTP_HOST, directory: REMOTE_DIR })
sftp.dir.foreach(REMOTE_DIR) do |entry|
# 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)
logger.info({ action: 'polling_wait', delay: 60 })
sleep 60
end
logger.info({ action: 'shutdown_complete' })
Das Skript verstehen
Dieses Skript implementiert mehrere wichtige Funktionen:
-
Konfiguration über Umgebungsvariablen: Nutzt Umgebungsvariablen für sensible Konfiguration und folgt damit bewährten Sicherheitsverfahren.
-
Strukturiertes Logging: Gibt ein JSON-Objekt pro Ereignis aus, wobei die Ereignisfelder unter
messageverschachtelt sind, statt in einen String serialisiert zu werden, sodass Log-Aggregatoren sie indexieren können. -
Atomare Dateioperationen: Legt jeden Download neben seinem Zielort ab und verschiebt ihn anschließend durch Umbenennen an seinen endgültigen Platz. Das Umbenennen ist nur innerhalb eines einzelnen Dateisystems atomar; deshalb wird die temporäre Datei in
LOCAL_DIRstatt in/tmperstellt. Konsumenten sehen dadurch nie eine unvollständige Datei. Beachten Sie, dassTempfile.createden Modus0600verwendet und das Umbenennen diesen beibehält. Fügen Sie daher vor dem Umbenennen einFile.chmodein, wenn ein anderes Konto lesen können muss, was Sie importiert haben. Das gilt nur, wenn das SkriptLOCAL_DIRexklusiv besitzt: Richten Sie es auf ein Verzeichnis, in das nichts anderes schreibt, und lassen Sie Konsumenten Dateien herausbewegen, statt darin Unterverzeichnisse anzulegen. Die Atomarität endet zudem am entfernten Ende. Wenn ein Produzent eine Datei neu schreibt, während sie gelesen wird, erhalten Sie eine Mischung aus zwei Revisionen. Lassen Sie Produzenten daher unter einem temporären Namen hochladen und die Datei erst inREMOTE_DIRumbenennen, sobald die Bytes dort angekommen sind. -
Nicht vertrauenswürdige Eintragsnamen:
plain_basename?weist alles zurück, was der Server auflistet und kein einfacher Dateiname ist. Ohne diese Prüfung besteht ein Name wiea/../../escaped.txtsowohl die Dotfile-Prüfung als auchattributes.file?, verlässt nach dem ZusammenfügenLOCAL_DIR, liest einen Pfad außerhalb vonREMOTE_DIRund entfernt bei aktiviertem Löschen eine Datei, die Sie nie angefordert haben. -
Robuste Fehlerbehandlung: Verwendet spezifische Fehlertypen und wiederholt fehlgeschlagene Verbindungen mit exponentiellem Backoff. Ein fehlgeschlagener Download wird protokolliert und übersprungen, statt eine Ausnahme auszulösen, sodass eine einzelne fehlerhafte Datei die Polling-Schleife nicht beenden kann.
-
Sauberes Herunterfahren: Behandelt Terminierungssignale korrekt und sorgt so für ein sauberes Prozessmanagement.
-
Optionale Bereinigung auf dem entfernten Server: Entfernt erfolgreich heruntergeladene Dateien vom entfernten Server, um eine doppelte Verarbeitung zu vermeiden, aber nur, wenn
DELETE_AFTER_DOWNLOAD=true. Das Entfernen zielt auf einen Namen ab, nicht auf die Revision, die heruntergeladen wurde, und hängt damit von derselben Write-once-Disziplin ab wie der Warnhinweis oben. Bleibt die Option deaktiviert, brauchen Sie einen anderen Weg, um eine erneute Verarbeitung zu vermeiden, etwa ein lokales Register der importierten Dateinamen.
Deployment in der Produktion
Für Produktionsumgebungen kommen diese Deployment-Optionen infrage:
Systemd verwenden
Erstellen Sie eine systemd-Service-Datei für ein zuverlässiges Prozessmanagement:
[Unit]
Description=SFTP File Poller
After=network.target
[Service]
Type=simple
User=sftp-user
Environment=SFTP_HOST=example.com
Environment=SFTP_USER=username
Environment=REMOTE_DIR=/remote/path
Environment=LOCAL_DIR=/local/path
Environment=SSH_KEY_PATH=/path/to/key
Environment=SSH_KNOWN_HOSTS=/path/to/known_hosts
ExecStart=/usr/bin/ruby /path/to/sftp_poller.rb
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target
Docker verwenden
Erstellen Sie ein Dockerfile für das Deployment in Containern:
FROM ruby:3.2-slim
RUN gem install net-sftp -v '~> 4.0.0'
WORKDIR /app
COPY sftp_poller.rb .
CMD ["ruby", "sftp_poller.rb"]
Bewährte Verfahren für die Sicherheit
-
Host-Key-Verifizierung:
- Behalten Sie
verify_host_key: :alwaysbei, damit ein unbekannter oder geänderter Host-Key die Verbindung abbricht - Erzeugen Sie
known_hostsaus einem Fingerprint, den Sie über einen separaten Kanal bestätigt haben, und nicht aus dem, was die erste Verbindung zufällig präsentiert - Liefern Sie
known_hostsmit dem Deployment aus (siehe die VariableSSH_KNOWN_HOSTSweiter oben), statt sich auf das Home-Verzeichnis des ausführenden Benutzers zu verlassen
- Behalten Sie
-
Verwaltung von SSH-Schlüsseln:
- Rotieren Sie SSH-Schlüssel regelmäßig
- Ed25519-Schlüssel benötigen die optionalen Gems
ed25519undbcrypt_pbkdfvon Net::SSH. Das minimale Dockerfile oben installiert sie nicht; fügen Sie diese Gems und die Build-Werkzeuge für native Erweiterungen hinzu, bevor Sie diesen Schlüsseltyp verwenden, oder nutzen Sie einen unterstützten RSA-Schlüssel mit SHA-2-Signaturen. - Speichern Sie Schlüssel sicher mithilfe von Umgebungsvariablen oder sicheren Vaults
-
Netzwerksicherheit:
- Beschränken Sie den SFTP-Zugriff auf bestimmte IP-Bereiche
- Verwenden Sie starke Chiffren und Schlüsselaustauschalgorithmen
- Implementieren Sie Verbindungs-Timeouts
-
Dateizugriff:
- Verwenden Sie minimale Berechtigungen für lokale wie für entfernte Dateien
- Implementieren Sie Integritätsprüfungen für Dateien
- Räumen Sie temporäre Dateien ordnungsgemäß auf
-
Monitoring:
- Richten Sie Warnmeldungen für fehlgeschlagene Downloads und Verbindungsprobleme ein
- Überwachen Sie die Belegung des Speicherplatzes
- Verfolgen Sie Metriken zur Verarbeitung
Fazit
Dieses Ruby-Skript ist ein Ausgangspunkt für automatisierte SFTP-Dateiimporte. Es deckt die Fälle ab, die einem erfahrungsgemäß zuerst auf die Füße fallen: Host-Key-Verifizierung, Eintragsnamen, die der Server kontrolliert, einen lokalen Schreibvorgang, der entweder vollständig ist oder gar nicht stattfindet, und einen Fehler, der die Polling-Schleife nicht beenden darf. Deduplizierung über Neustarts hinweg, Grenzen für den Speicherplatz und Warnmeldungen für einen Poller, der verstummt ist, müssen Sie noch selbst ergänzen.
Wenn Sie eine verwaltete Lösung suchen: Transloadit bietet einen SFTP Import Robot, der diese Komplexität automatisch handhabt. Erfahren Sie hier mehr darüber.
