Mastodon S3 Storage von self-hosted Minio zu Hetzner S3 übertragen

Im Auftrag eines Kunden hatte ich kürzlich die Aufgabe, ein S3 Bucket für eine Mastodon-Instanz von einer selbst gehosteten Minio-Instanz zu Hetzner S3 zu übertragen. Das Vorgehen und meine genutzten Konfigurationen will ich hier erklären.

Die Mastodon-Instanz des Kunden wurde bisher durch eine Minio-Instanz auf demselben Server mit Medien und Mediencache versorgt. Allerdings wuchs der Mediencache mit zunehmender Vernetzung der Mastodon-Instanz immer weiter an. Selbst durch eine Reduzierung der Cache-Haltedauer von wenigen Tagen waren die Kosten für den S3-Speicher zu hoch. Denn dieser befand sich auf einem leistungsstarken, aber verhältnismäßig teuren externen Volume eines Hetzner Cloudservers.

Ende 2024 führte Hetzner den Hetzner S3 Speicher in sein Produktportfolio ein - somit eröffnete sich eine attraktive Alternative zur Minio-Instanz. Eine Alternative, die vor allem durch geringe Kosten glänzen kann. Die Kosten für ein 300 GB Volume beliefen sich auf knapp 16 € / Monat. Nicht sehr viel für ein Business - aber sehr wohl für eine kleine, spendenfinanzierte Mastodon-Instanz. Der Preis für 1000 GB (!) Hetzner S3 Speicher liegt im Vergleich nur bei knapp 6 € / Monat. Die Umstellung war also schnell beschlossen.

Schritt 1: Parallelbetrieb zweier Buckets

Um den Umstieg auf den neuen Speicher für die Nutzer so angenehm wie möglich zu gestalten und die Downtime gering zu halten, entschloss ich mich dazu, beide Buckets für die Zeit der Datenmigration parallel laufen zu lassen.

  • Altes Bucket “minio”: Liefert die bisher gespeicherten Mediendateien aus (readonly)
  • Neues Bucket “hetzner-s3”: Nimmt neue Mediendateien entgegen, speichert diese und liefert diese aus

Der Vorgeschaltete Nginx-Proxy wurde so konfiguriert, dass bei einer Dateianfrage zuerst das neue Bucket befragt wurde. Konnte die angefragte Datei dort nicht gefunden werden, wurde das alte S3 Bucket befragt.

Proxykonfiguration vorher:

upstream minio {
    server 127.0.0.1:9000;
}

server {
    [...]

    location / {
        proxy_set_header Host 'mastodon-media.s3.domain.tld';
        proxy_set_header Connection '';
        proxy_set_header Authorization '';
        proxy_hide_header Set-Cookie;
        proxy_hide_header 'Access-Control-Allow-Origin';
        proxy_hide_header 'Access-Control-Allow-Methods';
        proxy_hide_header 'Access-Control-Allow-Headers';
        proxy_hide_header x-amz-id-2;
        proxy_hide_header x-amz-request-id;
        proxy_hide_header x-amz-meta-server-side-encryption;
        proxy_hide_header x-amz-server-side-encryption;
        proxy_hide_header x-amz-bucket-region;
        proxy_hide_header x-amzn-requestid;
        proxy_ignore_headers Set-Cookie;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 300;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        chunked_transfer_encoding off;

        add_header 'Access-Control-Allow-Origin' '*';
        add_header X-Cache-Status $upstream_cache_status;
        add_header X-Content-Type-Options nosniff;
        add_header Content-Security-Policy "default-src 'none'; form-action 'none'";

        proxy_pass https://minio;      
    }
}

Proxykonfiguration nachher - mit Parallelbetreib beider Buckets (Fallback auf altes Bucket):

upstream hetzner {
        server mastodon-media.nbg1.your-objectstorage.com;
}

upstream minio {
    server 127.0.0.1:9000;
}

server {
    [...]
    
    resolver 9.9.9.9;

    location / {
        proxy_set_header Host 'mastodon-media.nbg1.your-objectstorage.com';
        proxy_set_header Connection '';
        proxy_set_header Authorization '';
        proxy_hide_header Set-Cookie;
        proxy_hide_header 'Access-Control-Allow-Origin';
        proxy_hide_header 'Access-Control-Allow-Methods';
        proxy_hide_header 'Access-Control-Allow-Headers';
        proxy_hide_header x-amz-id-2;
        proxy_hide_header x-amz-request-id;
        proxy_hide_header x-amz-meta-server-side-encryption;
        proxy_hide_header x-amz-server-side-encryption;
        proxy_hide_header x-amz-bucket-region;
        proxy_hide_header x-amzn-requestid;
        proxy_ignore_headers Set-Cookie;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 300;
        # Default is HTTP/1, keepalive is only enabled in HTTP/1.1
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        chunked_transfer_encoding off;

        add_header 'Access-Control-Allow-Origin' '*';
        add_header X-Cache-Status $upstream_cache_status;
        add_header X-Content-Type-Options nosniff;
        add_header Content-Security-Policy "default-src 'none'; form-action 'none'";

        proxy_pass https://hetzner; # This uses the upstream directive definition to load balance
        
        # Fallback to oldbucket
        proxy_intercept_errors on;
        error_page 404 403 = @oldbucket;       
    }

    location @oldbucket {
        proxy_set_header Host 'mastodon-media.s3.domain.tld';
        proxy_set_header Connection '';
        proxy_set_header Authorization '';
        proxy_hide_header Set-Cookie;
        proxy_hide_header 'Access-Control-Allow-Origin';
        proxy_hide_header 'Access-Control-Allow-Methods';
        proxy_hide_header 'Access-Control-Allow-Headers';
        proxy_hide_header x-amz-id-2;
        proxy_hide_header x-amz-request-id;
        proxy_hide_header x-amz-meta-server-side-encryption;
        proxy_hide_header x-amz-server-side-encryption;
        proxy_hide_header x-amz-bucket-region;
        proxy_hide_header x-amzn-requestid;
        proxy_ignore_headers Set-Cookie;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 300;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        chunked_transfer_encoding off;

        add_header Cache-Control public;
        add_header 'Access-Control-Allow-Origin' '*';
        add_header X-Cache-Status $upstream_cache_status;
        add_header X-Content-Type-Options nosniff;
        add_header Content-Security-Policy "default-src 'none'; form-action 'none'";

        proxy_pass http://minio;
    }
}

Maßgeblich sind hier der neue “location” Block “@oldbucket” und der verweis darauf, falls Dateien um neuen Bucket nicht gefunden werden können:

# Fallback to oldbucket
proxy_intercept_errors on;
error_page 404 403 = @oldbucket;

Wichtig ist an der Stelle, nicht nur 404-Fehler abzufangen, sondern auch 403-Fehler (“forbidden”). Der Hetzner S3 Storage reagiert offenbar mit diesem Fehlercode, wenn eine Ressource angefragt wird, deren Elternverzeichnisse (noch) nicht existieren.

Schritt 2: Speichern neuer Medien in dem neuen Bucket

Bis hierher dürften die Nutzer der Mastodon-Instanz kaum etwas mitbekommen haben. Die Proxy-Änderung ist in Sekundenbruchteilen aktiv und ist für den User völlig transparent. Im Folgenden kommt es allerdings zu einer kurzen, mehrsekündigen Downtime, denn die Konfiguration von Mastodon wird angepasst und daraufhin müssen alle Mastodon Services neu gestartet werden.

Für das neue Bucket wurden beispielhaft folgende Einstellungen in der .env.production von Mastodon getroffen:

S3_ENABLED=true
S3_BUCKET=mastodon-media
AWS_ACCESS_KEY_ID=<myaccessid>
AWS_SECRET_ACCESS_KEY=<myaccesskey>
S3_ALIAS_HOST=media.domain.tld
S3_REGION=nbg1
S3_PROTOCOL=https
S3_HOSTNAME=media.domain.tld
S3_ENDPOINT=https://nbg1.your-objectstorage.com
S3_SIGNATURE_VERSION=v4

Durch einen Neustart der Mastodon-Dienste werden die Änderungen aktiv. Neu eintreffende Mediendateien werden nun nun an ausschließlich in dem neuen S3-Bucket abgelegt:

cd /etc/systemd/system
systemctl restart mastodon-*

Nach wenigen Sekunden steht Mastodon wieder bereit.

Schritt 3: Alte Daten zu neuem Bucket migrieren

Die Kuh ist vom Eis - der kritische Teil ist erledigt. Nun können ganz entspannt die alten Daten ebenfalls in das neue Bucket transportiert werden. Dazu nutze ich das Tool “rclone”, mit dem sich zwei S3-Buckets sehr einfach miteinander synchronisieren lassen. Dazu werden für die beiden S3-Provider zunächst Konfigurationen unter ~/.config/rclone/rclone.conf anlegegt. Hier wieder exemplarisch:

[hetzner-s3]
type = s3
provider = Other
access_key_id = <accessid>
secret_access_key = <accesskey>
endpoint = nbg1.your-objectstorage.com
acl = public-read
region = nbg1

[minio-s3]
type = s3
provider = Minio
access_key_id = <accessid>
secret_access_key = <accesskey>
endpoint = s3.domain.tld
acl = public-read
region = server1

Endpoint, Region und Access-ID sowie Access-Key müssen natürlich auf das jeweilige Bucket angepasst werden. Wichtig ist noch die Einstellung acl = public-read, die dafür sorgt, dass übertragene Dateien die korrekten Berechtigungen tragen. Wird die Einstellung nicht korrekt gesetzt, sind die Dateien u.U. nicht öffentlich lesbar und der Medienspeicher kann nicht im Sinne von Mastodon verwendet werden.

Nun folgt eine Reihe von rclone-Kommandos, mit denen die Mediendateien vom alten minio-s3 zum neuen hetzner-s3 Speicher umgezogen werden. Zu beachten ist, dass hier das copy Subcommand von rclone verwendet wird. Dieses synchronisiert keine Löschungen, sondern transportiert nur nicht-existierende Dateien von A nach B. Anders als sync! Dieses würde inzwischen neu eingetroffene Dateien im neuen Bucket löschen. Es ist also Vorsicht geboten. copy ist das richtige Kommando für unseren Zweck.

Die wichtigsten Dateien zuerst:

rclone copy --progress --transfers=8 minio-s3:mastodon-media/custom_emojis/         hetzner-s3:mastodon-media/custom_emojis/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/accounts/              hetzner-s3:mastodon-media/accounts/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/site_uploads/          hetzner-s3:mastodon-media/site_uploads/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/media_attachments/     hetzner-s3:mastodon-media/media_attachments/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/imports/               hetzner-s3:mastodon-media/imports/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/cache/accounts/        hetzner-s3:mastodon-media/cache/accounts/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/cache/custom_emojis/   hetzner-s3:mastodon-media/cache/custom_emojis/

Danach folgen die weniger wichtigen Mediendateien. Dabei handelt es sich nur um zwischengespeicherte Medien und Vorschaubilder für Webseiten. Wer Zeit und Traffic sparen will, kann diese auch weglassen (oder die Vorhaltezeit für diese Medien vorab in Mastodon auf wenige Tage reduzieren - dann fällt die zu übertragende Datenmenge deutlich kleiner aus!).

rclone copy --progress --transfers=8 minio-s3:mastodon-media/cache/preview_cards/         hetzner-s3:mastodon-media/cache/preview_cards/
rclone copy --progress --transfers=8 minio-s3:mastodon-media/cache/media_attachments/     hetzner-s3:mastodon-media/cache/media_attachments/

Dieser Vorgang kann durchaus einen halben Tag dauern - je nach Leistung der beteiligten Server und der Größe des Buckets.

Schritt 4: Altes Bucket abklemmen

Sobald alle Daten übertragen sind, empfiehlt es sich, das alte Bucket noch eine Weile zu behalten, falls Daten nicht korrekt übertragen wurden. Man kann das alte Bucket allerdings schon einmal testhalber vom Proxy abklemmen und prüfen, ob denn die Mastodon-Instanz korrekt ohne das alte Bucket läuft. Dazu wird der @oldbucket location-Block entfernt sowie die folgenden Zeilen auskommentiert (oder entfernt):

#upstream minio {
#    server 127.0.0.1:9000;
#}

[...]

# Fallback to oldbucket
#proxy_intercept_errors on;
#error_page 404 403 = @oldbucket;

Nach einem systemctl reload nginx wird nur noch das neue Bucket genutzt und die Funktion der Website lässt sich überprüfen. Nicht vergessen: Für ernstzunehmende Testa auch einmal den Browsercache leeren!

Läuft auch nach ein paar Tagen noch alles prima, kann das alte Bucket gelöscht werden. Fertig!