FTP-Backup-Skript in PHP

Achtung: Diese Backup Lösung habe ich im nächsten Artikel noch einmal stark erweitert. Ich empfehle diese komplexere Lösung zu benutzen und die darin enthaltenen Sicherheitshinweise zu beachten.

FTP Backups (bisher)

Mein Blog läuft auf dem Hoster ALL-INKL.com, den ich nebenbei gesagt nach mehreren Jahren immernoch empfehlen kann.
Bisher habe ich halbwegs (un)regelmäßig manuell, von Hand, FTP Backups erstellt. Also alle paar Monate habe ich über einen FTP Tool wie FileZilla bestimmte Ordner heruntergeladen, Datei für Datei, anschließend in ein Archiv gepackt und irgendwo verstaut. Dieser Prozess ist natürlich in vielerlei Hinsicht nicht zuverlässig, zeitintensiv und aufwändig.
Auch das Sichern der Ordner automatisiert mit Tools wie SyncBack (mein Artikel dazu) wird immer zeitintensiver, je größer die zu sichernde Datenmenge wird. Außerdem muss immer ein PC, auf dem die Tools laufen, an sein.

FTP Backups via PHP Skript

Sinnvoller ist es, diese Backups auf dem Server des Webhosters erstellen zu lassen. So wird der heimische PC nicht belastet.
All-Inkl bietet dafür ein recht einfaches PHP Skript zum Sichern eines einzelnen FTP Ordners oder des gesamten All-Inkl FTP Accounts.

Ich habe dieses Skript ausgebaut und um verschiedene Funktionen ergänzt:

  • beliebig viele Ordner des All-Inkl Accounts in einzelne .tar.gz Archive sichern
  • Einschränkung der Anzahl aufgehobener Backups – älteste Backups werden automatisch gelöscht
  • detaillierte Ausgabe inklusive benötigter Zeit
  • E-Mail Benachrichtigung

Screenshot

Das Bild zeigt die Ausgaben des Backup Skripts und die versendete E-Mail Benachrichtigung

Code

Achtung: Dieses Backup Skript habe ich in Version 1.1 noch einmal stark erweitert. Ich empfehle diese komplexere Lösung zu benutzen. Besser wäre sogar die Version 1.2, mit der sich zusätzlich MySQL Datenbanken mitsichern lassen.

Code anzeigenDen Code könnt ihr bequem mit den Links/Rechts Pfeiltasten horizontal bewegen.

<?
  // PHP-Konfiguration optimieren
  // if no errors are shown, please check htaccess restrictions by "php_flag display_errors off"
  // in this or parent folders
  @error_reporting(E_ALL);
  @ini_set("max_execution_time", 300);
  @ini_set("memory_limit", "256M");
  header('Content-Type: text/html; charset=utf-8');
  include "Archive/Tar.php";
  $pfad = preg_replace('/(\/www\/htdocs\/\w+\/).*/', '$1', realpath(__FILE__));
  $alltime = 0;

  // ########## EDIT THIS VARIABLES ###################
  $foldertobackup = array("bonnie", "tests", "locationmap", "blog"); // which root folders should get backed up?
  $backupfilemaximum = 2; // how many archives should be stored?
  $dir = $pfad."backup/"; // in which subfolder is this backup php file? this would be: "root/backup/"
  $sendmail = 1; // send notification mail when all backups are done - should be 1/"yes"/"ja" or 0/"no"/"nein"
  $sendmailto = "admin@yourdomain.com"; // valid mail address to send the mail to
  // ##################################################

  foreach ($foldertobackup as $verzeichnis) {
    $jobtime = time();
    echo "<br><br>########################################<br>";
    echo "<strong>Verzeichnis ".$verzeichnis." wird gesichert...</strong><br>";
    flush();

    // Name: [verzeichnis]_[Datum]_[Uhrzeit].tar.gz
    $archivname = $verzeichnis.date('_Y-m-d_His').".tar.gz";
    // Name: [All-Inkl-Accountname]_[Datum]_[Uhrzeit].tar.gz
    //$archivname = preg_replace('/.+\/(.+)\/$/', '$1', $pfad).date('_Y-m-d_His').".tar.gz";

    // Auszuschließende Ressourcen
    $ignorieren = array("*.sql.gz", "*.tar.gz", "usage", "logs");

    // ######### create backup
    $archiv = new Archive_Tar($archivname, true);
    $archiv->setIgnoreList($ignorieren);
    $archiv->createModify($pfad.$verzeichnis, "", $pfad);
    $backuptime = time() - $jobtime;
    if (is_int($backuptime)) {
      echo "Backup fertig: ".$archivname." (Dauer: ".$backuptime." Sekunden)<br>";
    } else {
      echo "Backup fertig: ".$archivname."<br>";
    }

    echo "Aufräumen der Backups...<br>";
    flush();
    // integer starts at 0 before counting
    $i = 0;
    $backupfiles = array();
    // ######### collect valid backup files
    if ($handle = opendir($dir)) {
      while (($file = readdir($handle)) !== false) {
        if (  is_int(strpos($file, $verzeichnis)) == true &&
              preg_match('/\.tar.gz$/i', $file) &&
              !in_array($file, array('.', '..')) &&
              !is_dir($dir.$file)
        ) {
          $backupfiles[$dir.$file] = filectime($dir.$file);
        }
      }
    }
    echo count($backupfiles)." valide Backups dieses Ordners gefunden, ";
    echo $backupfilemaximum." Backups sollen behalten werden. ";
    $backupcountdif = count($backupfiles)-$backupfilemaximum;
    if ($backupcountdif<=0) {
      echo "Kein Backup wird gelöscht.<br>";
    } else if ($backupcountdif==1) {
      echo "1 Backup wird gelöscht:<br>";
    } else if ($backupcountdif>=2) {
      echo $backupcountdif." Backups werden gelöscht:<br>";
    }
    flush();

    // ######### sort and delete oldest backups
    // sort backup files by date
    arsort($backupfiles);
    // reset counter variable
    $i = 0;
    // delete oldest files
    foreach ($backupfiles as $key => $value) {
      if($i>=$backupfilemaximum) {
        echo $key." wird gelöscht...<br>";
        if (unlink($key)) {
          echo "Datei erfolgreich gelöscht.<br>";
        } else {
          echo "Fehler beim Löschen der Datei.<br>";
        }
      }
      $i++;
    }
    $jobendtime = time() - $jobtime;
    if (is_int($jobendtime)) {
      echo "######################################## (Dauer: ".$jobendtime." Sekunden)<br>";
      $alltime += $jobendtime;
    } else {
      echo "########################################<br>";
    }
  }

  echo "<br><br>Die automatische Sicherung des FTP-PHP-Backup-Skripts '".pathinfo(__FILE__, PATHINFO_BASENAME)."' hat ".count($foldertobackup)." Verzeichnisse in insgesamt ".$alltime." Sekunden gesichert.<br><br>";

  // ######### send mail
  if (!isset($sendmail) || $sendmail== 0 && in_array($sendmail, array("no", "nein"))) {
    echo "Benachrichtigungsmail wurde nicht verschickt.";
  } else {
    if(!preg_match( '/^([a-zA-Z0-9])+([.a-zA-Z0-9_-])*@([a-zA-Z0-9_-])+(.[a-zA-Z0-9_-]+)+/' , $sendmailto)) {
      echo "FEHLER: Mail konnte nicht versendet werden, da die Adresse ungültig ist!";
    } else {
      mail(
        $sendmailto,
        "Automatische FTP Sicherung abgeschlossen",
        "Die automatische Sicherung des FTP-PHP-Backup-Skripts ".pathinfo(__FILE__, PATHINFO_BASENAME)." hat ".count($foldertobackup)." Verzeichnisse in insgesamt ".$alltime." Sekunden gesichert.",
        "From: backupscript@{$_SERVER['SERVER_NAME']}\r\n" . "Reply-To: backupscript@{$_SERVER['SERVER_NAME']}\r\n" . "Content-Type: text/html\r\n"
      ) or die("FEHLER: Mail konnte wegen eines unbekannten Fehlers nicht versendet werden");
      echo "Benachrichtigungsmail wurde erfolgreich verschickt!";
    }
  }
?>

Wichtige Anmerkungen

Bitte beachtet, dass einige Zeilen angepasst werden müssen und der Code nur bei dem Webhoster All-Inkl getestet wurde. Die Zeile

include "Archive/Tar.php"

, die ein externes Modul einbindet, könnte auf anderen Websern zu Problemen führen. Am besten testet ihr es einfach.
Thema Sicherheit: Wie Kenny in den Kommentaren korrekt angemerkt hat, müssen noch Anpassungen erfolgen, wenn das Skript als tatsächliche Backup-Lösung zum Einsatz kommen soll.

  • Die Sicherungen landen bei diesem Skript direkt in dem Skript-Ordner. Die Sicherung(en) sollte(n) nach der Sicherung an einen anderen Ort, beispielsweise auf einen anderen Server oder ein NAS, kopiert werden. Oder ihr automatisiert den Download der Sicherungen nach dem Backupprozess.
  • Sowohl das Skript als auch die Sicherungen können ohne weitere Maßnahmen direkt angesprochen werden. Ihr solltet natürlich den Backup Ordner beispielsweise durch eine htpasswd absichern und den Zugriff auf die Backups über die htaccess einschränken.

Ich werde dieses Online-Backup Thema in Zukunft vermutlich nochmal aufgreifen, erweitern und optimieren. Ich verlinke das dann hier.

10 Kommentare

  1. Doofe Frage 1: Das Ding lässt sich einfach über eine URL von außen aufrufen und das ganz ohne Passwort oder ähnliches?

    Doofe Frage 2: Wo wird das Backup gespeichert? Etwa auf dem gleichen Server, auf dem auch die Originaldateien liegen?

    Doofe Frage 3: Die Backups sind ebenfalls über eine URL von außen abrufbar? Inklusive der WordPress-Datenbank, in der das Adminpasswort und der WordPress-Config-Datei, in der die Datenbank-Zugangsdaten zu finden sind?

  2. Hallo Kenny:
    1) Wenn man den Pfad zur Backup Datei wüsste, könnte man sie aufrufen. Dieser Pfad ist aber in Normalfall nicht bekannt.
    Selbstverständlich ist ein Passwortabfrage, beispielsweise über 2 Zeilen einer htpasswd, schnell ergänzt. Das wäre aber nicht Gegenstand des Sicherungsskripts.

    2) das Backup landet im gleichen Ordner. Die Verwaltung der Backups danach ist stark abhängig vom Nutzer, der Umgebung usw. Man könnte die Backups automatisiert über SyncBack herunterladen lassen, oder per 2, 3 Zeilen auf einen externen Server oder ein NAS kopieren.
    Auch das ist nicht direkter Bestandteil eines PHP Backup Skripts und da kann ich ja auch kaum etwas vorschreiben.

    3) Die Backups sind je nachdem, wie die htaccess konfiguriert ist, von außen erreichbar. Die Datenbank ist in den Backups nicht erreichbar, schließlich handelt es sich um ein FTP Backup.
    Über htaccess könnte man den Download von Dateien verbieten und somit das Handling der Backups dem Skript überlassen.

    Kenny, wie immer schätze ich deine Kritik sehr. Ich erkenne Stellen, an denen ich noch arbeiten muss wenn ich das Programm beispielsweise über längere Zeit nutzen möchte.
    Aber bitte beachte, dass es hier lediglich um ein PHP Backup Skript mit ein paar extra Funktionen geht, und nicht um eine komplette FTP Backup Lösung von Anfang bis Ende. Das Skript muss natürlich noch auf den konkreten Einsatz angepasst werden und so Sachen wie das Kopieren der Sicherungen auf andere Server oder Medien überlasse ich ebenfalls dem Geschick der Leser. Denn wer das Skript einsetzt (, vor allem in produktiven Umgebungen,) sollte die letzten Details hingekommen.

    Ich werde trotzdem deine Punkte später noch im Artikel erwähnend ergänzen.

    So denn 😉

    1. Wie gesagt, ich finds cool, dass du dir die Zeit nimmst und dein Feedback gibst. Ich habe die Punkte nochmal im Post erwähnt. Gegebenenfalls werde ich in den nächsten Tagen selber noch diese Erweiterungen ergänzen und veröffentlichen, da ich das Skript auch vorhabe auf Arbeit zu nutzen und dafür diese Punkte sowieso bearbeiten muss.

  3. Hallo Sebastian,

    $alltime = 0; (die Null sieht man aus irgendwelchen Gründen nur in der unformatierten Code-Ansicht (erreichbar über die Menüzeile der Code-Ansicht)) summiert die Sekunden der einzelnen Jobs zusammen, damit am Ende die Gesamtzeit des Backup-Prozesses angegeben werden kann.

    Dazu wird nach jedem Job mit diesem Code $alltime += $jobendtime; die aktuelle Jobzeit zu der bisherigen Gesamtzeit dazuaddiert.

  4. bekomme immer folgende Fehlermeldung ….
    Warning: require_once(PEAR.php): failed to open stream: No such file or directory in /homepages/13/xx/htdocs/Archive/Tar.php on line 45

    Fatal error: require_once(): Failed opening required ‚PEAR.php‘ (include_path=‘.:/usr/lib/php5:./modules:./lib:./inc‘) in /homepages/13/xx/htdocs/Archive/Tar.php on line 45

     

    in Zeile 191 ist auch ein Fehler im Script.

  5. Hallo codex,

    befindest du dich auf einem Webhosting Server vom Webhoster All-Inklusive? Der Pfad deines Webspace sieht nicht nach All-Inkl aus. Das Script ist für den Webhoster All-Inklusive geschrieben und setzt installierte Pakete voraus, die anscheinend nicht bei deinem Hoster installiert sind.
    Du könntest deinen Hoster bitten, PEAR zu installieren und in deinem Account zugreifbar zu machen aber vermutlich wird er solche Sonderwünsche nicht umsetzen können.

    LG

  6. Hi Hannes,

    sehr brauchbare Erweiterung des AI-Scripts.
    Leider habe ich ein Problem mit einem Onlineshop, der beim Sichern 8 – 9 GB große Dateien schreibt.
    Dann kommt die Fehlermeldung:

    Content-Encoding-Fehler

    Die Webseite, die Sie öffnen möchten, kann nicht angezeigt werden, da sie eine ungültige oder unbekannte Form der Kompression verwendet.

    Kontaktieren Sie bitte den Inhaber der Website, um ihn über dieses Problem zu informieren.

    Meiner Meinung nach hat das mit dem memory_limit zu tun.

    Hast du ne Idee, wie ich das Script in mehrere Aufteilen kann?

    Danke schon mal für das, was Du hier abgeliefert hast.

    Greetz

     

     

     

    1. Hi madmonk,

      puh, gute Anmerkung mit dem Aufsplitten zu großer Backups, allerdings nicht so easy, glaube ich. Archive_Tar, die Komprimierungsbibliothek dahinter, scheint nicht direkt von sich aus Splitarchive oder maximale Größen zu unterstützen. Du kannst ja hier nochmal selber gucken.

      Heißt also: selber programmieren. Meine mittelmäßigen Skills versuchen mal ein grobes Konstrukt (Achtung: PHP-Pseudocode) abzugeben, was ich spontan für möglich halte:

      $maxsize = 3GB;
      foreach ($foldertobackup as $verzeichnis) {
        if (größeberechnen($verzeichnis) > $maxgroesse) {
          // ordner zu groß, muss gesplittet werden
          $splitfilesize = 0;
          $splitnr = 1;
          // erstellt ein erstes teilarchiv "splitarchiv1"
          $archive = Archive_Tar("splitarchiv".$splitnr,true)
          // unterordner des zu großen ordners sammeln
          $arrsubfolders = unterordnervon($verzeichnis);
          // unterordner iterieren
          foreach ($arrsubfolders as $subfolder) {
            // größe akkumulieren
            $splitfilesize += sizeof($subfolder);
            if ($splitfilesize < $maxsize) {
              // order passt noch in das splitarchiv, hinzufügen...
              $archive->addModify($subfolder);
            } else {
              // dieser subordner passt nicht mehr ins splitarchiv, neues archiv starten
              // $archive ist ab diesem moment weiteres archiv für 1 überordner
              $splitnr += 1;
              $archive = Archive_Tar("splitarchiv".$splitnr,true)
              $archive->addModify($subfolder);
              $splitfilesize = sizeof($subfolder);
            }
          }
        } else {
          // ordnergröße normal, nicht über maximum, standardverfahren
          ...
        }
      }
      

      Damit müssten Ordner über einem festgelegten Maximum aufgesplittet werden. Aber:

      • Die Pseudofunktionen im Code müssen natürlich aufgelöst und der ganze Code dann erstmal getestet werden.
      • Funktioniert erstmal nur für Unterordner des zu großen Ordners. Man muss also eine komplette Dateiliste nehmen, dann könnte der Code, der dann für Einzeldateien des Ordners die if-Abfrage durchrödelt, etwas unperformant sein. Eventuell fügt man alle Dateien auf einmal hinzu (mit vorherigem Größencheck) und dann die Ordner nach diesem Prinzip, wäre schneller. Kommt darauf an, wie der zu sichernde Ordner aufgebaut ist.
      • Funktioniert noch nicht, falls ein Unterordner ebenfalls zu groß ist. Sagen wir der Überordner ist 20GB groß und ein Unterordner wäre 11GB groß. Dann würde dieser Unterordner trotzdem in ein Archiv gedrückt werden. Also man bräuchte noch eine Schleife für Unterordner, die nochmal das gleiche macht wie die gesamte Funktion.

      Ich denke mit 1-2 Stunden Arbeitseinsatz wäre das machbar. Die habe ich leider gerade nicht und hoffe, dass ich dir damit schonmal weiterhelfen konnte.

      LG

Schreibe einen Kommentar