On-Premise Backup mit Raspberry Pi

Für unsere Software as a Service Produkt Kanry, das unseren Kunden hilft ihr Unternehmen zu organisieren, mussten wir ein Backup-System implementiert werden, da viele und wichtige Unternehmensdaten über längere Zeiträume in Kanry gespeichert werden und ein Datenverlust fatal wäre.

Neben der Möglichkeit Backups durch den Hosting-Anbieter ausführen zu lassen, war die Überlegung ein eigenes Backup System On-Premise zu implementieren. Natürlich ist ein enormer Vorteil beim Backup durch den Hosting-Anbieter, dass man sich technisch um nichts kümmern muss. Ein Nachteil sind die kontinuierlichen Kosten, die monatlich abhängig des Volumens berechnet werden.

Da wir uns bei Kitto immer weiter entwickeln möchten und an unterschiedlichen technischen Lösungen in jeglichem Umfang interessiert sind, haben wir uns dazu entschlossen, ein eigenes Backup-System zu implementieren, dass On-Premise, also lokal, Backups speichert. Dadurch haben wir für unser Backup-System zunächst nur Anschaffungskosten für Hardware, die im Vergleich zu den monatlichen Gebühren eines Hosting-Anbieters zunächst deutlich höher liegen, jedoch, sofern es keine Hardware-Defekte gibt, ab einem gewissen Zeitpunkt amortisieren. Zudem kann das Backup-System repliziert und beliebig skaliert werden. Zum einen können weitere Datenträger angebunden werden (vgl. einem RAID-System) und zum anderen kann das System an verschiedenen Standorten aufgebaut und implementiert werden, um Redundanz zu schaffen. In der IT gilt die Faustregel 1 = 0, 2 = 1. Wieso? Haben wir nur ein Backup-Standort und genau an diesem bricht bspw. ein Brand aus, haben wir keine weitere Datensicherung. Deshalb ist zumindest bei Backups Redundanz gewünscht.

Das Konzept

Folgendes Szenario ist gegeben: Wir haben einen Linux-basierten Webserver auf dem unsere SaaS Kanry betrieben wird. Unsere Software speichert zum einen Daten in einer MySQL-Datenbank ab und zum anderen Uploads von Benutzern im Dateiverzeichnis. Die Software an sich ist bereits durch Git gesichert. Allein die dynamischen Daten müssen durch ein Backup gesichert werden, um im Falle eines Ausfalls das System wiederherstellen zu können.

Zusätzlich zu unserem Webserver benötigen wir ein dediziertes System, auf dem die Backups abgelegt werden sollen. Dieses soll an einem anderen Standort aufgebaut werden, um die Sicherheit zu erhöhen.

Der Webserver soll täglich einen Cron Job ausführen, der das Backup ausführt und ablegt. Das dedizierte System soll diese Daten abholen und sie lokal abspeichern.

Die Hardware

Folgende Hardware haben wir für ein Backup-System beschafft (Amazon Affiliate Links):

Dabei spielt die Wahl des Gehäuses keine Rolle. Es kann ggf. sogar komplett weg gelassen werden. Wichtig ist dann natürlich, dass ein Netzteil beschafft werden muss, da dieses in unserem Fall beim Gehäuse inklusive war. Ob man eine, zwei oder mehr Festplatten verwenden möchte, ist ebenfalls jedem selbst überlassen. Mit zwei Festplatten stellen wir sicher, dass im Fall eines Defekts die Wahrscheinlichkeit des Datenverlusts verringert wird.

Die Kosten für die Hardware betrugen brutto 257,47 EUR. Natürlich kann das Backup-System auch auf einem anderen Server, bzw. anderer Hardware eingerichtet werden.

Die Software

Als Betriebssystem haben wir Ubuntu Desktop 21.04 installiert. Dieses lässt sich einfach über den Raspberry Pi Imager installieren. Wir haben bewusst auf eine reine Server-Installation ohne GUI verzichtet, um das mounten der externen Festplatten zu vereinfachen. Die Desktop-Variante von Ubuntu hatte für uns den Vorteil, dass die externen Festplatten einfach per Plug & Play funktionsfähig waren, was bei einer Ubuntu-Variante, die ohne GUI ausgeliefert wurde, nicht der Fall war. Da unser System sowieso nur für das Erstellen des Backups zuständig ist, ist diese „Ressourcen-Verschwendung“ durch eine grafische Oberfläche irrelevant.

Das Backup-Script auf dem Server

Um das Backup-Script auf dem Server einzurichten haben wir uns an folgendem Artikel orientiert: „5 EASY STEPS ON SCHEDULING MYSQL DATABASE BACKUP USING CRON„. Wir haben dieses jedoch angepasst, um die Zugangsdaten zur Datenbank nicht im Script abzulegen.

Dazu haben wir uns an folgendem Stack Overflow Artikel orientiert: „How to perform a mysqldump without a password prompt?„. Mit dem Benutzer, der das Backup auf dem Webserver ausführen soll, muss man sich dazu per SSH auf dem Server anmelden. Anschließend muss eine Datei „~/.my.cnf“ erstellt werden. In diese Datei können die Zugangsdaten für die Datenbank hinterlegt werden:

[mysqldump]
user=mysqluser
password=secret

Natürlich können diese Daten auch ausgelesen werden. Es ist also empfehlenswert hier einen separaten Datenbank-Benutzer zu erstellen, der nur die nötigsten Rechte für ein Backup besitzt. Diese Konfiguration erlaubt es uns „mysqldump“ ohne die Eingabe von Zugangsdaten auszuführen. Das Backup Script sieht dann folgendermaßen aus:

#!/bin/bash
DATE=$(date +%d%m%Y-%H)
mysqldump --all-databases > /BACKUP_PFAD/database-backup-"$DATE".sql
tar -czvf /BACKUP_PFAD/database-backup-"$DATE".tar.gz /BACKUP_PFAD/database-backup-"$DATE".sql
rm /BACKUP_PFAD/database-backup-"$DATE".sql
tar -czvf /BACKUP_PFAD/file-backup-"$DATE".tar.gz /PFAD_ZU_DATEIEN

chmod 777 /BACKUP_PFAD/*.tar.gz

# Delete old backups (older then 1 day)
find /BACKUP_PFAD/*.tar.gz -mtime +0 -delete

Das Script legt man am besten im Home-Verzeichnis des Benutzers ab und nennt es beispielsweise „system_backup.sh“.

Das Script erstellt ein Datenbank-Backup und komprimiert dieses. Zusätzlich erstellt es ein Backup eines Dateipfades und komprimiert dieses ebenfalls. Zum Schluss löscht es noch Backups, die älter als 1 Tag sind. Die Pfade müssen entsprechend an das eigene System angepasst werden! Um zu testen, ob alles korrekt funktioniert, kann das Script direkt aufgerufen werden, indem man „./system_backup.sh“ in die Kommandozeile eingibt.

Dieses Script muss dann via Crontab regelmäßig ausgeführt werden. Dazu gibt man „crontab -e“ ein, solange man mit dem SSH-Benutzer angemeldet ist. Um täglich um 1 Uhr morgens ein Backup auszuführen, gibt man folgendes ein und speichert anschließend die Datei:

0 1 * * * /PFAD_ZUM_HOME_VERZEICHNIS/system_backup.sh >/dev/null 2>&1 >/dev/null 2>&1

Dadurch ist das Backup aktiviert und wird nun täglich um 1 Uhr morgens ausgeführt. Die Zeit kann beliebig angepasst werden. Die Seite crontab guru hilft euch beim Schreiben der Zeitangaben.

Damit ist das Backup auf dem Server eingerichtet und ihr könnt die Verbindung trennen.

Das Backup-System auf dem Raspberry Pi

Nun muss unser Raspberry Pi die erzeugten Backups noch abholen. Dafür haben wir eine Anwendung in .NET 5.0 geschrieben, die auf Windows, Linux oder Mac funktionsfähig ist. Diese Anwendung ist sehr simpel und macht folgendes:

  • Verbindung per SSH zu einem Server aufbauen
  • Ein vordefiniertes Verzeichnis nach Dateien mit einer bestimmten Dateiendung durchsuchen
  • Diese Dateien, falls sie lokal nicht bereits existieren, herunterladen
  • Die Dateien lokal ggf. replizieren, um Redundanz zu schaffen
  • Die Dateien auf dem Server ggf. löschen

Um das Programm compilieren und entsprechend ausführen zu können, benötigt man Visual Studio mit dem Framework .NET 5.0. In der Entwicklungsumgebung muss eine Konsolen-Anwendung erstellt werden. Nennt diese „SftpDownload“. Erstellt nun folgende Dateien (sofern diese nicht schon existieren, dann passt deren Inhalt an):

AppConfig.cs

namespace SftpDownload
{
    public class AppConfig
    {
        public bool DeleteFilesAfterDownload { get; set; }
        public string Host { get; set; }
        public int Port { get; set; }
        public string Username { get; set; }
        public string Password { get; set; }
        public string SourcePath { get; set; }
        public string[] TargetPaths { get; set; }
        public bool IsLoggingEnabled { get; set; }
        public string LogPath { get; set; }
        public string[] FileExtensions { get; set; }
    }
}

ILogger.cs

using System;

namespace FtpDownload
{
    public interface ILogger
    {
        void LogError(Exception ex, string message);
        void LogInformation(string message);
    }
}

Program.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;

namespace SftpDownload
{
    class Program
    {
        static bool isLoggingEnabled = true;
        static string logPath;

        static void Main(string[] args)
        {
            if (args.Length < 1)
            {
                Log("Please provide a path to a config file as the first parameter.");
                SaveLog();
                return;
            }

            var configFile = args[0];

            if (!File.Exists(configFile))
            {
                Log($"The config file {configFile} does not exist.");
                SaveLog();
                return;
            }

            var jsonConfig = File.ReadAllText(configFile);
            var config = JsonSerializer.Deserialize<AppConfig>(jsonConfig);
            isLoggingEnabled = config.IsLoggingEnabled;
            logPath = config.LogPath;

            if (isLoggingEnabled && !File.Exists(logPath))
                File.Create(logPath);

            if (config.TargetPaths.Length == 0)
            {
                Log($"You need to define at least one target path in the {configFile}");
                return;
            }

            var sftpConfig = new SftpConfig
            {
                Host = config.Host,
                Port = config.Port,
                UserName = config.Username,
                Password = config.Password
            };

            var logger = new StringBuilderLogger(logBuilder);
            var sftpService = new SftpService(logger, sftpConfig);

            var remoteFiles = sftpService.ListAllFiles(config.SourcePath);

            var downloadedFiles = new List<string>();

            foreach (var file in remoteFiles)
            {
                if (config.FileExtensions.Length > 0)
                {
                    var fileExtension = Path.GetExtension(file.Name);
                    if (Array.IndexOf(config.FileExtensions, fileExtension) == -1)
                    {
                        Log($"File: {file.Name}; Status: Skipped; Reason: File extension is not in config");
                        continue;
                    }
                }

                var targetPath = config.TargetPaths[0] + Path.DirectorySeparatorChar + file.Name;

                if(File.Exists(targetPath))
                {
                    Log($"File: {file.Name}; Status: Skipped; Reason: Already downloaded");
                    continue;
                }

                sftpService.DownloadFile(file.FullName, targetPath);
                downloadedFiles.Add(targetPath);

                if (config.DeleteFilesAfterDownload)
                    sftpService.DeleteFile(file.FullName);
            }

            for (int i = 1; i < config.TargetPaths.Length; i++)
            {
                foreach (var file in downloadedFiles)
                {
                    var fileName = Path.GetFileName(file);
                    var targetPath = config.TargetPaths[i] + Path.DirectorySeparatorChar + fileName;
                    if (File.Exists(file))
                    {
                        File.Copy(file, targetPath);
                        Log($"File: {fileName}; Status: Copied; Target Path: {targetPath}");
                    }
                }
            }

            Log("Done");
            SaveLog();
        }

        static StringBuilder logBuilder = new StringBuilder();
        static void Log(string logMessage)
        {
            if (isLoggingEnabled)
                logBuilder.AppendLine($"[{DateTime.Now}] {logMessage}");
        }

        static void SaveLog()
        {
            if (!File.Exists(logPath))
                using (var sw = File.CreateText(logPath)) { }

            if (isLoggingEnabled)
                File.AppendAllText(logPath, logBuilder.ToString());
        }
    }
}

SftpConfig.cs

namespace SftpDownload
{
    public class SftpConfig
    {
        public string Host { get; set; }
        public int Port { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
    }
}

SftpService.cs

using Renci.SshNet;
using Renci.SshNet.Sftp;
using System;
using System.Collections.Generic;
using System.IO;

namespace FtpDownload
{
    public class SftpService
    {
        private readonly ILogger _logger;
        private readonly SftpConfig _config;

        public SftpService(ILogger logger, SftpConfig sftpConfig)
        {
            _logger = logger;
            _config = sftpConfig;
        }

        public IEnumerable<SftpFile> ListAllFiles(string remoteDirectory = ".")
        {
            using var client = new SftpClient(_config.Host, _config.Port == 0 ? 22 : _config.Port, _config.UserName, _config.Password);
            try
            {
                client.Connect();
                return client.ListDirectory(remoteDirectory);
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, $"Failed in listing files under [{remoteDirectory}]");
                return null;
            }
            finally
            {
                client.Disconnect();
            }
        }

        public void UploadFile(string localFilePath, string remoteFilePath)
        {
            using var client = new SftpClient(_config.Host, _config.Port == 0 ? 22 : _config.Port, _config.UserName, _config.Password);
            try
            {
                client.Connect();
                using var s = File.OpenRead(localFilePath);
                client.UploadFile(s, remoteFilePath);
                _logger.LogInformation($"Finished uploading file [{localFilePath}] to [{remoteFilePath}]");
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, $"Failed in uploading file [{localFilePath}] to [{remoteFilePath}]");
            }
            finally
            {
                client.Disconnect();
            }
        }

        public void DownloadFile(string remoteFilePath, string localFilePath)
        {
            using var client = new SftpClient(_config.Host, _config.Port == 0 ? 22 : _config.Port, _config.UserName, _config.Password);
            try
            {
                client.Connect();
                using var s = File.Create(localFilePath);
                client.DownloadFile(remoteFilePath, s);
                _logger.LogInformation($"Finished downloading file [{localFilePath}] from [{remoteFilePath}]");
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, $"Failed in downloading file [{localFilePath}] from [{remoteFilePath}]");
            }
            finally
            {
                client.Disconnect();
            }
        }

        public void DeleteFile(string remoteFilePath)
        {
            using var client = new SftpClient(_config.Host, _config.Port == 0 ? 22 : _config.Port, _config.UserName, _config.Password);
            try
            {
                client.Connect();
                client.DeleteFile(remoteFilePath);
                _logger.LogInformation($"File [{remoteFilePath}] deleted.");
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, $"Failed in deleting file [{remoteFilePath}]");
            }
            finally
            {
                client.Disconnect();
            }
        }
    }
}

StringBuilderLogger.cs

using System;
using System.Text;

namespace FtpDownload
{
    public class StringBuilderLogger : ILogger
    {
        StringBuilder stringBuilder;

        public StringBuilderLogger(StringBuilder stringBuilder)
        {
            this.stringBuilder = stringBuilder;
        }

        public void LogError(Exception ex, string message)
        {
            stringBuilder.AppendLine($"[{DateTime.Now}] {ex.Message}");
            stringBuilder.AppendLine($"[{DateTime.Now}] {message}");
        }

        public void LogInformation(string message)
        {
            stringBuilder.AppendLine($"[{DateTime.Now}] {message}");
        }
    }
}

Außerdem müsst ihr das NuGet Package „Renci.SshNet“ installieren.

Die hier erstellte Anwendung benötigt eine Konfigurations-Datei in JSON-Format. Diese kann bspw. so aussehen (natürlich müssen alle Werte an den korrekten Server und die Pfade auf dem Raspberry Pi angepasst werden):

{
	"Host":"123.456.890.100",
	"Port":22,
	"Username":"user",
	"Password":"password",
	"DeleteFilesAfterDownload":false,
	"SourcePath":"/home/backup",
	"TargetPaths": ["/media/external1/backup", "/media/external2/backup"],
	"IsLoggingEnabled": true,
	"LogPath": "/var/log/backup/log.txt",
	"FileExtensions": [".gz"]
}

Die Anwendung erwartet als ersten Parameter den Pfad zu dieser JSON-Konfigurationsdatei. Buildet nun die Anwendung in Visual Studio und bringt alle Dateien, die dabei erzeugt werden, auf euer Raspberry Pi (z.B. via FTP, USB Stick, Git o.ä.).

Raspberry Pi vorbereiten

Die allgemeine Einrichtung des Raspberry Pi wird hier nicht beschrieben, da diese an vielen Stellen bereits nachgelesen werden kann und sich mit der Zeit auch wandelt.

Um die Anwendung auf eurem Raspberry Pi, bzw. auf Linux ausführen zu können, muss „dotnet“ auf diesem installiert werden. Dazu haben wir folgende Installationsanleitung verwendet: Install and setup .NET 5 on Raspberry Pi. Verifiziert die Installation am besten, indem ihr „dotnet –version“ in die Kommandozeile eingibt.

Als nächstes müssen natürlich die Festplatten an das Raspberry Pi angeschlossen werden. Da die Stromversorgung des Raspberry Pi nicht sonderlich stark ist, haben wir ein USB-Hub mit eigener Stromversorgung gewählt. Sollte andere Hardware verwendet werden, erübrigt sich das evtl. und die Festplatten können direkt angeschlossen werden.

Wie bereits oben geschrieben muss die Anwendung auf das Raspberry Pi gebracht werden und eine Konfigurations-Datei erstellt werden, die die Server-Zugangsdaten enthält und angibt, wo das Backup lokal abgelegt werden soll.

Schlussendlich wollen wir auch auf unserem Raspberry Pi einen Cron Job einrichten, der regelmäßig Backups herunterlädt und auf seinem internen Speicher ablegt. Bevor wir dies machen, sollten wir jedoch das Programm testen. Zunächst müssen wir herausfinden, wo genau unser „dotnet“ liegt. Dazu geben wir folgendes in der Kommandozeile ein:

whereis dotnet

Die Ausgabe sollte ungefähr folgendes anzeigen:

dotnet: /home/BENUTZER/.dotnet/dotnet

Kopiert diesen Pfad und führt nun folgendes in der Kommandozeile aus:

/home/BENUTZER/.dotnet/dotnet /home/BENUTZER/SftpDownload/SftpDownload.dll /home/BENUTZER/SftpDownload/config.json

Der erste Wert ist die „dotnet“-Anwendung, der zweite unsere Anwendung (achtet darauf hier die *.dll zu verwenden!) und der dritte der Pfad zu unserer Konfigurations-Datei. Ist das Programm fehlerfrei durchgelaufen, sollten sich nun Dateien auf euren Ziellaufwerken befinden. Habt ihr den Log aktiviert, könnt ihr euch die Log-Datei anschauen.

Schlussendlich müssen wir hierfür nur noch einen Cron Job einrichten. Wir geben dazu in die Kommandozeile folgendes ein:

crontab -e

Und fügen folgenden Eintrag hinzu:

25 * * * * /home/BENUTZER/.dotnet/dotnet /home/BENUTZER/SftpDownload/SftpDownload.dll /home/BENUTZER/SftpDownload/config.json

Dadurch wird unser Backup-Download stündlich um „25 nach“ ausgeführt. Wenn es keine neuen Backup-Daten gibt, läuft das Programm zügig durch, so dass es nichts ausmacht, dass unser Backup-Client (das Raspberry Pi) deutlich häufiger nach verfügbaren Backups prüft, als sie beim Server erzeugt werden. Das gibt uns aber mehr Sicherheit, sollte bspw. ein zusätzliches Backup auf dem Server erzeugt worden sein oder ein vorheriger Download aus diversen Gründen gescheitert sein.

Zusammenfassung

Wir haben hier ein simples Backup-System geschaffen, das man beliebig erweitern und verbessern kann. Die initialen Investitionskosten sind zunächst hoch, doch haben wir hier die volle Kontrolle über unsere Daten.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.