Parallele Dateisynchronisierung mit Go und MinIO
Laden Sie einen MinIO-Bucket mit fünf Go-Workern in ein lokales Verzeichnis herunter. Das Beispiel bewahrt verschachtelte Pfade, speichert jede Datei vor dem Ersetzen einer vorhandenen Kopie temporär und gibt einen Fehler zurück, wenn eine Übertragung fehlschlägt oder Sie Ctrl+C drücken. Der Download erfolgt nur in eine Richtung: Jeder Durchlauf lädt jedes Objekt erneut herunter, und Löschungen auf dem Server entfernen keine lokalen Dateien.
MinIO-Server einrichten
Verwenden Sie Bash unter Linux mit einem lokalen Dateisystem, das Groß- und Kleinschreibung
unterscheidet, und Go 1.26.8 in Ihrem
PATH. Installieren Sie außerdem curl und
diff. Diese Anleitung wurde mit Go 1.26.8 und dem MinIO Go SDK
v7.0.95 getestet. Die Ports 9000 und 9001 müssen frei sein.
Stand 3. Oktober 2026 ist das MinIO-Community-Repository archiviert und wird nicht mehr gepflegt. Die Community-Distribution ist nur als Quellcode verfügbar. Der unten auf eine feste Version gesetzte Server ist eine isolierte lokale Lernumgebung, keine Empfehlung für eine neue Produktivbereitstellung. Er nutzt die von MinIO veröffentlichte Quellcode-Installation für diese Version statt eines Container-Images ohne feste Versionsbindung.
Fügen Sie Folgendes in Ihre Shell ein, ausgehend von einem Verzeichnis, in dem Sie ein neues
Verzeichnis minio-sync anlegen möchten. Die Subshell lässt Ihr aktuelles
Verzeichnis und Ihre Shell-Optionen unverändert. Ein bereits vorhandenes Ziel stoppt die Einrichtung
vor der Installation. Das Kompilieren des Servers kann mehrere Minuten dauern:
(
set -e
mkdir minio-sync
cd minio-sync
GOBIN="$PWD/bin" GOWORK=off GOTOOLCHAIN=local \
go install github.com/minio/minio@RELEASE.2025-10-15T17-29-55Z
)
Starten Sie den Server in diesem Terminal und lassen Sie ihn laufen:
(
cd minio-sync &&
MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin \
./bin/minio server ./data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001
)
Beide Listener sind an die Loopback-Adresse gebunden. Diese allgemein bekannten Zugangsdaten und
unverschlüsseltes HTTP gehören ausschließlich in diese private Sandbox. Drücken Sie zum Abschluss
Ctrl+C in diesem Terminal. Der Server stoppt, und sein Verzeichnis data
bleibt für einen weiteren Durchlauf verfügbar.
Projekt initialisieren
Beginnen Sie in einem zweiten Terminal im selben übergeordneten Verzeichnis. Bestätigen Sie die
Betriebsbereitschaft, initialisieren Sie dann das Modul und installieren Sie die festgelegte
SDK-Version. GOWORK=off verhindert, dass ein umgebender Go-Workspace die
Modulauswahl des Beispiels verändert:
(
set -e
curl -fsS http://127.0.0.1:9000/minio/health/ready
cd minio-sync
GOWORK=off GOTOOLCHAIN=local go mod init minio-sync
GOWORK=off GOTOOLCHAIN=local go get github.com/minio/minio-go/v7@v7.0.95
)
Bucket befüllen
Erstellen Sie minio-sync/_seed/ und speichern Sie Folgendes als
_seed/main.go. Das Programm schreibt bekannte Eingabedateien nach
sample-input/ und lädt sie in my-sync-bucket hoch: 300 Textdateien,
eine Textdatei in einem Unterverzeichnis, eine leere Datei und eine Binärdatei. Das Verzeichnis mit
dem Unterstrich hält dieses separate ausführbare Programm aus dem Hauptpaket heraus.
// _seed/main.go
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func main() {
if err := seed(); err != nil {
log.Fatal(err)
}
log.Println("Uploaded 303 sample objects")
}
func seed() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, err := minio.New("localhost:9000", &minio.Options{
Creds: credentials.NewStaticV4("minioadmin", "minioadmin", ""),
Secure: false,
})
if err != nil {
return err
}
const bucket = "my-sync-bucket"
exists, err := client.BucketExists(ctx, bucket)
if err != nil {
return err
}
if !exists {
if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
return err
}
}
files := map[string][]byte{
"notes/hello.txt": []byte("Hello from MinIO\n"),
"empty.bin": {},
"binary.bin": {0, 255, 128, 13, 10, 42, 0, 10},
}
for i := 0; i < 300; i++ {
files[fmt.Sprintf("logs/object-%03d.txt", i)] = []byte(fmt.Sprintf("object %d\n", i))
}
for key, body := range files {
filename := filepath.Join("sample-input", filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
return err
}
if err := os.WriteFile(filename, body, 0o600); err != nil {
return err
}
if _, err := client.FPutObject(ctx, bucket, key, filename, minio.PutObjectOptions{}); err != nil {
return fmt.Errorf("upload %q: %w", key, err)
}
}
return nil
}
Führen Sie das Programm zum Befüllen aus dem übergeordneten Verzeichnis aus:
(
cd minio-sync &&
GOWORK=off GOTOOLCHAIN=local go run ./_seed
)
Es meldet Uploaded 303 sample objects. Ein erneuter Durchlauf ersetzt diese Beispielschlüssel
und Eingabedateien. Verwenden Sie es nur mit diesem eigens dafür eingerichteten Server und
Verzeichnis. Lassen Sie die Serverdaten und sample-input/ unverändert, während
Sie die Dateien herunterladen und die Ergebnisse vergleichen.
MinIO-Client einrichten
Speichern Sie Folgendes als minio-sync/client.go. Der lokale Server verwendet HTTP, daher
hat useSSL den Wert false.
Die minimale TLS-Version des Transports greift nur, wenn useSSL den Wert
true hat und der Endpunkt HTTPS bereitstellt.
BucketExists muss sowohl den Wahrheitswert true als auch keinen Fehler
zurückgeben, bevor der Download beginnt:
// client.go
package main
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// bucketName matches the bucket populated by _seed/main.go.
const bucketName = "my-sync-bucket"
func createMinioClient(ctx context.Context) (*minio.Client, error) {
endpoint := "localhost:9000"
accessKeyID := "minioadmin" // Use environment variables in production
secretAccessKey := "minioadmin" // Use environment variables in production
useSSL := false // true once your endpoint terminates TLS
// Only exercised when useSSL is true
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
IdleConnTimeout: 90 * time.Second,
}
// Initialize minio client
opts := &minio.Options{
Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
Secure: useSSL,
Transport: transport,
}
client, err := minio.New(endpoint, opts)
if err != nil {
return nil, err
}
// BucketExists reports a bucket that is simply absent as (false, nil), so the boolean
// has to be checked too. Ignoring it turns a typo in the bucket name into a sync that
// quietly downloads nothing.
exists, err := client.BucketExists(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to reach bucket %q: %w", bucketName, err)
}
if !exists {
return nil, fmt.Errorf("bucket %q does not exist", bucketName)
}
return client, nil
}
Parallele Dateidownloads implementieren
Speichern Sie Folgendes als minio-sync/sync.go. Fünf Worker lesen Aufträge, während
eine separate Goroutine Ergebnisse entgegennimmt. Würden Ergebnisse erst nach Abschluss der
Auflistung entgegengenommen, entstünde bei einem Bucket, dessen Inhalt die Channel-Puffer
übersteigt, ein Deadlock. Jedes Objekt erhält ein Zeitlimit von 10 Minuten. Fehler werden gezählt;
der Schlüssel des ersten betroffenen Objekts erscheint in der abschließenden Diagnose.
Die GetObject-Referenz des SDK erklärt, dass Fehler oft erst beim
Lesen des Streams auftreten. Die Prüfung von io.Copy ist daher genauso
wichtig wie die von GetObject. Bei einer fehlgeschlagenen Übertragung wird
die temporäre Datei entfernt und die zuvor vollständig heruntergeladene Kopie bleibt erhalten.
// sync.go
package main
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/minio/minio-go/v7"
)
type DownloadResult struct {
ObjectName string
Error error
}
func downloadFiles(ctx context.Context, client *minio.Client, bucketName string, outputDir string) error {
// 0700, because these are private copies. The process umask would otherwise usually
// leave them group- and world-readable.
if err := os.MkdirAll(outputDir, 0o700); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Resolve the directory once so the per-object checks below compare against a path
// that has no symlinks left in it.
root, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("failed to resolve output directory: %w", err)
}
// Cancelling here unblocks every worker if we bail out early
ctx, cancel := context.WithCancel(ctx)
defer cancel()
jobs := make(chan string, 100)
results := make(chan DownloadResult, 100)
var wg sync.WaitGroup
workerCount := 5
// Start workers
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for objectName := range jobs {
err := downloadObject(ctx, client, bucketName, objectName, root)
select {
case results <- DownloadResult{ObjectName: objectName, Error: err}:
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
// Collect results while the producer is still queueing. Draining only after
// close(jobs) deadlocks as soon as the buffers fill: workers block on
// `results <-`, stop reading `jobs`, and the producer blocks on `jobs <-`.
//
// Only a count and the first error are kept. A bucket can hold millions of objects,
// and appending every failure to a slice would grow without bound in exactly the run
// where things are going worst.
type failures struct {
count int
first error
}
collected := make(chan failures, 1)
go func() {
var seen failures
for result := range results {
if result.Error == nil {
continue
}
seen.count++
if seen.first == nil {
seen.first = fmt.Errorf("%s: %w", result.ObjectName, result.Error)
}
}
collected <- seen
}()
// List and queue objects. The deferred close lets the workers drain and exit
// on every path, including the early returns below.
listErr := func() error {
defer close(jobs)
opts := minio.ListObjectsOptions{Recursive: true}
for obj := range client.ListObjects(ctx, bucketName, opts) {
if obj.Err != nil {
// Cancel immediately so in-flight downloads stop now rather than running
// to completion behind a listing that has already failed.
cancel()
return fmt.Errorf("error listing objects: %w", obj.Err)
}
// Empty directory markers contain no file bytes to download.
if obj.Size == 0 && strings.HasSuffix(obj.Key, "/") {
continue
}
select {
case jobs <- obj.Key:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}()
// Always drain first, so every worker has finished before we report anything.
seen := <-collected
if listErr != nil {
return listErr
}
// A cancelled context ends the listing loop without an error of its own, and workers
// that were cut off never deliver a result. Without this check a cancelled sync would
// be indistinguishable from an empty bucket that synced cleanly.
if err := ctx.Err(); err != nil {
return fmt.Errorf("sync did not complete: %w", err)
}
if seen.count > 0 {
return fmt.Errorf("encountered %d download errors, first: %w", seen.count, seen.first)
}
return nil
}
// resolveOutputPath maps a server-supplied object key onto a path inside outputDir, which
// must already be symlink-free (see filepath.EvalSymlinks above).
//
// Keys are rejected rather than cleaned. `a/./b.txt` and `a/b/../b.txt` are different keys
// that both clean to `a/b.txt`, so cleaning them would let one object silently overwrite
// another, and lexical checks alone cannot see a symlink that is already on disk.
func resolveOutputPath(outputDir, objectName string) (string, error) {
if objectName == "" || objectName != path.Clean(objectName) || strings.Contains(objectName, `\`) {
return "", fmt.Errorf("refusing non-canonical object key: %q", objectName)
}
if !filepath.IsLocal(filepath.FromSlash(objectName)) {
return "", fmt.Errorf("refusing unsafe object key: %q", objectName)
}
current := outputDir
for _, element := range strings.Split(objectName, "/") {
current = filepath.Join(current, element)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
continue // Nothing here yet, so nothing can redirect the write
}
return "", fmt.Errorf("failed to inspect %q: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("refusing object key through a symlink: %q", objectName)
}
}
return current, nil
}
func downloadObject(ctx context.Context, client *minio.Client, bucket, objectName, outputDir string) error {
outputPath, err := resolveOutputPath(outputDir, objectName)
if err != nil {
return err
}
// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
// Get object
obj, err := client.GetObject(ctx, bucket, objectName, minio.GetObjectOptions{})
if err != nil {
return fmt.Errorf("failed to get object: %w", err)
}
defer obj.Close()
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return fmt.Errorf("failed to create directories: %w", err)
}
// Download to a sibling temp file so a failed sync never truncates the copy
// that a previous run completed, and never leaves a half-written file behind.
// os.CreateTemp already creates the file 0600.
temp, err := os.CreateTemp(filepath.Dir(outputPath), filepath.Base(outputPath)+".part-*")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
tempPath := temp.Name()
defer func() {
temp.Close()
os.Remove(tempPath) // No-op once the rename below succeeded
}()
if _, err := io.Copy(temp, obj); err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
if err := temp.Sync(); err != nil {
return fmt.Errorf("failed to flush file: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
// Rename is atomic within a filesystem, so readers see either the old file or
// the complete new one
if err := os.Rename(tempPath, outputPath); err != nil {
return fmt.Errorf("failed to publish file: %w", err)
}
return nil
}
Fehlerbehandlung und Fehlerbehebung
Leere Verzeichnismarker mit null Bytes und einem Schlüssel, der auf /
endet, werden übersprungen. Eine gewöhnliche leere Datei wird heruntergeladen. Schlüssel mit
Punktsegmenten, Backslashes, absoluten Pfaden oder einem vorhandenen symbolischen Link im Ziel
werden abgewiesen. Ein Dateischlüssel wie reports kollidiert zudem mit
einem verschachtelten Schlüssel wie reports/january.txt. Geben Sie solchen Objekten
unterschiedliche Namen im Bucket.
Wenn der Start fehlschlägt, prüfen Sie, ob die Ports 9000 oder 9001 belegt sind. Wenn der Client den Bucket nicht erreicht, stellen Sie sicher, dass der Server noch läuft und das Befüllen erfolgreich war. Ein Fehler bei der Auflistung oder den Objektberechtigungen liefert einen Status ungleich null. Auch ein Berechtigungsfehler am Ziel oder eine Kollision zwischen Datei und Verzeichnis lässt den Durchlauf fehlschlagen. Abgeschlossene Downloads anderer Worker bleiben auf dem Datenträger.
Implementierung testen
Speichern Sie den folgenden Einstiegspunkt als minio-sync/main.go neben
client.go und sync.go. Sein Signalkontext bricht
laufende Anfragen bei Ctrl+C oder SIGTERM ab. Die Erfolgsmeldung wird erst ausgegeben, nachdem
die Auflistung und alle Worker fehlerfrei abgeschlossen sind:
// main.go
package main
import (
"context"
"log"
"os/signal"
"syscall"
)
func main() {
// Ctrl-C cancels the context, which stops the workers and makes downloadFiles
// report the interruption instead of exiting as if the sync had finished.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Create MinIO client
client, err := createMinioClient(ctx)
if err != nil {
log.Fatalf("Failed to create MinIO client: %v", err)
}
// Start download. bucketName comes from client.go.
if err := downloadFiles(ctx, client, bucketName, "./downloads"); err != nil {
log.Fatalf("Download failed: %v", err)
}
log.Println("Download completed successfully")
}
Führen Sie das Downloadprogramm aus dem übergeordneten Verzeichnis aus:
(
cd minio-sync &&
GOWORK=off GOTOOLCHAIN=local go run .
)
Es protokolliert Download completed successfully und schreibt Dateien unter
minio-sync/downloads/. Prüfen Sie bei diesem frisch angelegten Beispiel-Bucket jeden
Pfad und jedes Byte mit:
(
cd minio-sync &&
diff -r sample-input downloads
)
Keine Ausgabe und der Status null bedeuten, dass die Verzeichnisse übereinstimmen, einschließlich
der leeren und binären Dateien. Ein abweichendes Byte oder eine fehlende Datei lässt
diff fehlschlagen. Die 303 Objekte übersteigen die Kapazität beider
Channel-Puffer mit jeweils 100 Einträgen. Führen Sie das Downloadprogramm erneut aus, um die lokalen
Kopien zu ersetzen. Es lädt alle Objekte erneut herunter. Dateien, die keinem aktuellen
Bucket-Schlüssel entsprechen, bleiben in downloads/ erhalten.
Sicherheitsaspekte
Überlassen Sie downloads/ ausschließlich diesem Prozess. Die Pfadprüfungen
weisen vorhandene symbolische Links ab, verhindern aber nicht, dass ein anderer Prozess einen Pfad
zwischen Prüfung und Schreiben verändert. Sie sind keine Dateisystem-Sandbox. Neue Verzeichnisse
verwenden den Modus 0700, temporäre Dateien den Modus
0600. Bestehende Verzeichnisberechtigungen werden nicht verschärft.
Lassen Sie immer nur ein Downloadprogramm gleichzeitig auf dieses Verzeichnis zugreifen.
Ersetzen Sie für einen entfernten Dienst die Sandbox-Zugangsdaten durch ein Konto, das auf die erforderlichen Bucket-Vorgänge beschränkt ist, und verwenden Sie HTTPS. Die festen lokalen Root-Zugangsdaten in diesem Beispiel sind nur für den kurzzeitig genutzten Testserver bestimmt. Ein unterstützter Speicherdienst und seine Bereitstellungsanforderungen sind von dieser Testumgebung mit dem archivierten Community-Server getrennt zu betrachten.
Fazit
Jeder erfolgreiche Download ersetzt eine lokale Datei mithilfe einer temporären Datei im selben Verzeichnis und einer Umbenennung auf demselben lokalen Dateisystem. Auch ein fehlgeschlagener Durchlauf kann erfolgreiche Downloads enthalten; er setzt den Bucket nicht zurück. Das Programm vergleicht keine Zeitstempel, überspringt keine unveränderten Objekte, löscht keine veralteten lokalen Dateien und erstellt keinen Snapshot eines Buckets, dessen Inhalt sich gerade ändert. Lassen Sie den Bucket während eines Durchlaufs unverändert, wenn das Ergebnis einen konsistenten Objektbestand abbilden soll.
