Dateien mit Gophercloud in Go zu OpenStack Swift hochladen
Effiziente Dateispeicherung und -verwaltung sind für moderne Anwendungen entscheidend. OpenStack Swift, ein Open-Source-Objektspeichersystem, bietet eine skalierbare Lösung, um große Datenmengen in der Cloud zu speichern und abzurufen. Dieser DevTip zeigt, wie Sie mit Go und der Gophercloud-Bibliothek, einem robusten Go-SDK für die Arbeit mit OpenStack-APIs, Dateien zu OpenStack Swift hochladen.
Voraussetzungen
Bevor Sie beginnen, stellen Sie sicher, dass Sie Folgendes haben:
- Eine Go-Installation (Version 1.22 oder höher)
- Grundkenntnisse in der Go-Programmierung
- Zugriff auf eine OpenStack-Swift-Umgebung
- OpenStack-Zugangsdaten (konfigurierbar über clouds.yaml)
Die Go-Umgebung einrichten
Erstellen Sie zunächst ein neues Verzeichnis für Ihr Projekt und initialisieren Sie ein Go-Modul:
mkdir swift-upload-example
cd swift-upload-example
go mod init example.com/swift-upload
Gophercloud installieren
Gophercloud ist ein Open-Source-Go-SDK für die Arbeit mit OpenStack-APIs. Installieren Sie die
Version v2, dazu die v2-Reihe des begleitenden Moduls utils sowie den weiter unten verwendeten Rate Limiter:
go get github.com/gophercloud/gophercloud/v2
go get github.com/gophercloud/utils/v2/openstack/clientconfig
go get golang.org/x/time/rate
Sowohl das Modul utils als auch das Modul x/time halten ihre Pakete in Unterverzeichnissen,
weshalb die oben genannten Paketpfade genau das Richtige sind. Auch das Modul utils hat eine eigene
Hauptversion: Wenn Sie den v1-Pfad neben Gophercloud v2 einbinden, erhalten Sie ein *gophercloud.ServiceClient aus einem
anderen Paket, das mit nichts von dem weiter unten Gezeigten typkompatibel ist.
Authentifizierung bei OpenStack Swift
Erstellen Sie eine neue Datei namens main.go. Jeder Go-Codeblock unten ist ein Fragment dieser einen
Datei, sodass die wiederholten Zeilen mit package main und die Import-Blöcke jeweils zu einem Block
verschmelzen; fügen Sie jede neue Funktion ein und behalten Sie einen zusammengeführten Import-Block
bei. Nachfolgend finden Sie zwei Authentifizierungsmethoden: mit direkten Zugangsdaten und mit
clouds.yaml.
package main
import (
"context"
"fmt"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/utils/v2/openstack/clientconfig"
)
func authenticateWithCredentials(ctx context.Context) (*gophercloud.ServiceClient, error) {
opts := gophercloud.AuthOptions{
IdentityEndpoint: "https://your-openstack-auth-url",
Username: "your-username",
Password: "your-password",
TenantName: "your-tenant-name",
DomainName: "your-domain-name",
}
provider, err := openstack.AuthenticatedClient(ctx, opts)
if err != nil {
return nil, fmt.Errorf("error creating OpenStack provider client: %w", err)
}
client, err := openstack.NewObjectStorageV1(provider, gophercloud.EndpointOpts{
Region: "your-region",
})
if err != nil {
return nil, fmt.Errorf("error creating Swift service client: %w", err)
}
return client, nil
}
func authenticateWithCloudsYAML(ctx context.Context) (*gophercloud.ServiceClient, error) {
opts := &clientconfig.ClientOpts{
Cloud: "openstack", // Name of the cloud in clouds.yaml
}
provider, err := clientconfig.AuthenticatedClient(ctx, opts)
if err != nil {
return nil, fmt.Errorf("error creating provider client: %w", err)
}
client, err := openstack.NewObjectStorageV1(provider, gophercloud.EndpointOpts{})
if err != nil {
return nil, fmt.Errorf("error creating Swift service client: %w", err)
}
return client, nil
}
Dateien in Swift-Container hochladen
Sicherstellen, dass der Container existiert
Vergewissern Sie sich vor dem Upload, dass der Zielcontainer existiert. Falls nicht, erstellen Sie
ihn mit robuster Fehlerbehandlung. Beachten Sie, dass in v2 jede Request-Funktion als erstes
Argument einen context.Context entgegennimmt und dass Gophercloud ResponseCodeIs mitliefert, sodass Sie keine
Fehlermeldungen per Mustervergleich auswerten müssen:
package main
import (
"context"
"fmt"
"net/http"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/objectstorage/v1/containers"
)
func ensureContainer(ctx context.Context, client *gophercloud.ServiceClient, containerName string) error {
result := containers.Get(ctx, client, containerName, nil)
if result.Err == nil {
return nil // Container exists
}
if !gophercloud.ResponseCodeIs(result.Err, http.StatusNotFound) {
return fmt.Errorf("error checking container %s: %w", containerName, result.Err)
}
// Create the container since it does not exist
_, err := containers.Create(ctx, client, containerName, containers.CreateOpts{}).Extract()
if err != nil {
return fmt.Errorf("error creating container %s: %w", containerName, err)
}
return nil
}
Eine Datei hochladen
Verwenden Sie die folgende Funktion, um eine Datei mit passendem Content-Type und sauberer Fehlerbehandlung hochzuladen.
package main
import (
"context"
"fmt"
"os"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/objectstorage/v1/objects"
)
func uploadFile(ctx context.Context, client *gophercloud.ServiceClient, containerName, objectName, filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("error getting file info: %w", err)
}
createOpts := objects.CreateOpts{
Content: file,
ContentLength: stat.Size(),
ContentType: "application/octet-stream",
}
result := objects.Create(ctx, client, containerName, objectName, createOpts)
if err := result.Err; err != nil {
return fmt.Errorf("error uploading file: %w", err)
}
return nil
}
Content ist ein io.Reader, aber Gophercloud streamt ihn nicht einfach. Sofern Sie nicht NoETag setzen
oder ein eigenes ETag angeben, berechnet CreateOpts.ToObjectCreateParams zunächst eine MD5-Prüfsumme des
Bodys. Bei einem io.ReadSeeker wie dem obigen *os.File wird gehasht und anschließend zurückgesprungen,
sodass die Datei zweimal gelesen wird; bei jedem anderen Reader wird io.ReadAll aufgerufen und der
gesamte Body im Arbeitsspeicher gehalten. Eine Pipe oder eine Netzwerkantwort an dieser Stelle ist
daher nicht speicherbegrenzt. Setzen Sie NoETag: true, wenn Sie einen echten Single-Pass-Stream wollen,
und nehmen Sie in Kauf, dass Swift dann keine clientseitige Integritätsprüfung hat.
Retry-Logik für Uploads
Implementieren Sie einen Wiederholungsmechanismus, um vorübergehende Fehler beim Hochladen von Dateien abzufangen.
package main
import (
"context"
"fmt"
"time"
"github.com/gophercloud/gophercloud/v2"
)
func uploadWithRetry(ctx context.Context, client *gophercloud.ServiceClient, containerName, objectName, filePath string) error {
backoff := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second}
var err error
for i, wait := range backoff {
if err = uploadFile(ctx, client, containerName, objectName, filePath); err == nil {
return nil
}
if i == len(backoff)-1 {
break
}
// Waiting on the context means a cancelled upload stops retrying immediately.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
return fmt.Errorf("failed after %d attempts: %w", len(backoff), err)
}
uploadFile öffnet die Datei bei jedem Versuch neu, was Wiederholungen überhaupt erst sicher macht. Die
generische Version weiter unten muss vorsichtiger sein.
Ein Objekt mit Retry hochladen (mit createopts)
Für das Hochladen von Segmenten oder Objekten, die nicht aus Dateipfaden stammen, verwenden Sie
diese verallgemeinerte Retry-Funktion. Sie nimmt die Nutzdaten als Byte-Slice entgegen und erzeugt
für jeden Versuch einen frischen Reader: Ein CreateOpts, dessen Reader im Feld Content bereits durch einen
fehlgeschlagenen Versuch geleert wurde, würde beim nächsten Versuch stillschweigend null Bytes
hochladen, und jede Fehlerprüfung würde dennoch bestehen.
package main
import (
"bytes"
"context"
"fmt"
"time"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/objectstorage/v1/objects"
)
func uploadObjectWithRetry(ctx context.Context, client *gophercloud.ServiceClient, containerName, objectName string, opts objects.CreateOpts, content []byte) error {
backoff := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second}
var err error
for i, wait := range backoff {
attemptOpts := opts
attemptOpts.Content = bytes.NewReader(content)
attemptOpts.ContentLength = int64(len(content))
if err = objects.Create(ctx, client, containerName, objectName, attemptOpts).Err; err == nil {
return nil
}
if i == len(backoff)-1 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
return fmt.Errorf("failed after %d attempts: %w", len(backoff), err)
}
Große Dateien mit segmentierten Uploads verarbeiten
Teilen Sie große Dateien in Segmente auf und laden Sie jedes Segment einzeln hoch. Erstellen Sie
anschließend ein Manifest-Objekt, um diese Segmente zusammenzusetzen. Dabei entsteht ein Dynamic
Large Object: Das Manifest ist ein Objekt mit null Bytes, dessen Header X-Object-Manifest einen Container und
ein Präfix benennt, und Swift verkettet beim Lesen alles unter diesem Präfix. CreateOpts hat dafür ein
eigenes Feld ObjectManifest; wenn Sie den Header stattdessen in Metadata unterbringen, wird X-Object-Meta-X-Object-Manifest gesendet,
und es entsteht ein Manifest, das nichts zusammensetzt, ohne dass eine Fehlerprüfung anschlägt.
Weil Swift alles unter diesem Präfix verkettet, muss das Präfix pro Upload eindeutig sein. Ein
festes Präfix <objectName>/ funktioniert nicht mehr, sobald Sie dasselbe Objekt zweimal hochladen: Wenn Sie
eine 11 MB große Datei durch eine 2 MB große ersetzen, wird das Segment 00000000 überschrieben, während
00000001 und 00000002 zurückbleiben, sodass das Manifest dann 2 MB der neuen Datei gefolgt von
6 MB der alten ausliefert. Segmente an Ort und Stelle zu überschreiben ist für Leser außerdem sofort
sichtbar, weil das aktive Manifest bereits auf sie zeigt. Geben Sie stattdessen jedem Upload sein
eigenes zufälliges Versionspräfix.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/objectstorage/v1/objects"
)
// newSegmentPrefix returns a prefix that no other upload of the same object can collide with, so
// the segments a live manifest points at are never overwritten or extended by a later run.
func newSegmentPrefix(objectName string) (string, error) {
version := make([]byte, 16)
if _, err := rand.Read(version); err != nil {
return "", fmt.Errorf("error generating segment prefix: %w", err)
}
return fmt.Sprintf("%s/%s/", objectName, hex.EncodeToString(version)), nil
}
func uploadLargeFile(ctx context.Context, client *gophercloud.ServiceClient, containerName, objectName, filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
defer file.Close()
// Both containers have to exist before any of this is worth doing: a missing destination
// container only fails at the manifest, once every segment has already been paid for.
if err := ensureContainer(ctx, client, containerName); err != nil {
return fmt.Errorf("error ensuring destination container: %w", err)
}
// Use a separate container for segments
segmentsContainerName := containerName + "_segments"
if err := ensureContainer(ctx, client, segmentsContainerName); err != nil {
return fmt.Errorf("error ensuring segments container: %w", err)
}
// One prefix per upload, chosen before the first segment is written and used even when the file
// turns out to be empty and no segment is written at all.
segmentPrefix, err := newSegmentPrefix(objectName)
if err != nil {
return err
}
buffer := make([]byte, 5*1024*1024) // 5 MB segments
segmentNum := 0
for {
// ReadFull keeps every segment except the last one exactly 5 MB, so the segment names stay
// in size order. A bare Read can return short and produce a ragged set of segments.
n, readErr := io.ReadFull(file, buffer)
if n > 0 {
segmentName := fmt.Sprintf("%s%08d", segmentPrefix, segmentNum)
segmentOpts := objects.CreateOpts{ContentType: "application/octet-stream"}
if err := uploadObjectWithRetry(ctx, client, segmentsContainerName, segmentName, segmentOpts, buffer[:n]); err != nil {
return fmt.Errorf("error uploading segment %d: %w", segmentNum, err)
}
segmentNum++
}
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
break
}
if readErr != nil {
return fmt.Errorf("error reading file: %w", readErr)
}
}
// Publish last. A brand new object 404s until this lands, and a replacement keeps serving the
// previous version in full, because the manifest it is replacing names the previous prefix.
manifestOpts := objects.CreateOpts{
ContentType: "application/octet-stream",
ObjectManifest: segmentsContainerName + "/" + segmentPrefix,
}
if err := uploadObjectWithRetry(ctx, client, containerName, objectName, manifestOpts, nil); err != nil {
return fmt.Errorf("error creating manifest for large file: %w", err)
}
return nil
}
Zwei Einschränkungen sollten Sie kennen, bevor Sie das in Produktion bringen. Ein fehlgeschlagener
Durchlauf lässt die bereits hochgeladenen Segmente zurück, sodass Sie den Container _segments separat
aufräumen müssen. Dieser Aufräumlauf darf nicht einfach alles unter <objectName>/ löschen: Auch das
Versionspräfix, auf das das aktive Manifest zeigt, liegt dort. Lesen Sie zuerst den Header X-Object-Manifest des
Manifests (objects.Get(...).Extract() gibt ihn als GetHeader.ObjectManifest zurück), behalten Sie dieses eine Präfix und
löschen Sie nur Versionspräfixe, die sowohl unreferenziert als auch älter als Ihr längstmöglicher
Upload sind, damit Sie niemals einen noch laufenden Durchlauf erfassen.
Und wenn Sie das Manifest löschen, werden seine Segmente nicht mitgelöscht. Es gibt keinen einzelnen
Aufruf, der beides für ein Dynamic Large Object erledigt: ?multipart-manifest=delete gehört zu Static Large
Objects, und Swift ignoriert es hier, sodass das Manifest verschwindet, während Sie weiterhin für
jedes Segment bezahlen. Räumen Sie stattdessen in zwei Schritten auf: Listen Sie <container>_segments mit genau dem
Präfix auf, das das Manifest benennt, löschen Sie diese Objekte (objects.BulkDelete verarbeitet einen Stapel von
Namen) und löschen Sie dann das Manifest. Das CLI von swift nimmt Ihnen mit swift delete <container> <objectName> denselben
Durchlauf ab und liest dabei den Header des Manifests, statt das Präfix zu erraten. In der
Dokumentation zu großen Swift-Objekten erfahren Sie,
wie sich die beiden Manifest-Typen unterscheiden.
Fehlerbehandlung und bewährte Verfahren
Rate Limiting
Implementieren Sie ein Rate Limiting, um die Swift-API nicht zu überlasten. Dieses Beispiel verwendet den Rate Limiter von Go.
package main
import (
"context"
"fmt"
"github.com/gophercloud/gophercloud/v2"
"golang.org/x/time/rate"
)
type RateLimitedClient struct {
client *gophercloud.ServiceClient
limiter *rate.Limiter
}
func NewRateLimitedClient(client *gophercloud.ServiceClient, rps float64) *RateLimitedClient {
return &RateLimitedClient{
client: client,
limiter: rate.NewLimiter(rate.Limit(rps), 1),
}
}
func (r *RateLimitedClient) UploadFile(ctx context.Context, containerName, objectName, filePath string) error {
if err := r.limiter.Wait(ctx); err != nil {
return fmt.Errorf("rate limit wait error: %w", err)
}
return uploadWithRetry(ctx, r.client, containerName, objectName, filePath)
}
Fazit
Dateien mit Go und Gophercloud v2 zu OpenStack Swift hochzuladen, ist eine flexible und effiziente Möglichkeit, Cloud-Speicher zu verwalten. Mit sauberer Fehlerbehandlung, Retry-Logik und Rate Limiting bauen Sie zuverlässige Systeme, die verschiedene Szenarien elegant bewältigen.
Für anspruchsvollere Workflows bietet Transloadit eine Reihe von Diensten rund um die Dateiverarbeitung. Entdecken Sie unseren Dienst für den Dateiexport für weitere Möglichkeiten, Ihre Dateiverarbeitung zu optimieren.
Viel Spaß beim Programmieren!
