Subir archivos a OpenStack Swift en Go con Gophercloud
El almacenamiento y la gestión eficientes de archivos son cruciales para las aplicaciones modernas. OpenStack Swift, un sistema de almacenamiento de objetos de código abierto, ofrece una solución escalable para almacenar y recuperar grandes cantidades de datos en la nube. Este DevTip explica cómo subir archivos a OpenStack Swift con Go y la biblioteca Gophercloud, un SDK de Go robusto para interactuar con las API de OpenStack.
Requisitos previos
Antes de comenzar, asegúrate de tener:
- Go instalado (versión 1.22 o superior)
- Conocimientos básicos de programación en Go
- Acceso a un entorno de OpenStack Swift
- Credenciales de OpenStack (configurables mediante clouds.yaml)
Configurar el entorno de Go
Primero, crea un nuevo directorio para tu proyecto e inicializa un módulo de Go:
mkdir swift-upload-example
cd swift-upload-example
go mod init example.com/swift-upload
Instalar Gophercloud
Gophercloud es un SDK de Go de código abierto para trabajar con las API de OpenStack. Instala la
versión v2, junto con la línea v2 del módulo complementario utils y el limitador de velocidad
que se usa más abajo:
go get github.com/gophercloud/gophercloud/v2
go get github.com/gophercloud/utils/v2/openstack/clientconfig
go get golang.org/x/time/rate
Tanto el módulo utils como el módulo x/time
mantienen sus paquetes en subdirectorios, así que pedir las rutas de paquete anteriores es lo que
quieres. El módulo utils también tiene su propia versión mayor:
obtener la ruta v1 junto con Gophercloud v2 te da un *gophercloud.ServiceClient de un paquete
diferente, que no pasará la verificación de tipos con nada de lo que viene a continuación.
Autenticarse con OpenStack Swift
Crea un nuevo archivo llamado main.go. Cada bloque de código Go de abajo
es un fragmento de ese único archivo, por lo que las líneas package main
repetidas y los bloques de importación se fusionan; pega cada nueva función y mantén un único
bloque de importación fusionado. A continuación se muestran dos métodos de autenticación: con
credenciales directas y con 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
}
Subir archivos a contenedores de Swift
Asegurar que el contenedor existe
Antes de subir, verifica que el contenedor de destino existe. Si no es así, créalo con un manejo
de errores robusto. Ten en cuenta que en v2 cada función de solicitud recibe un
context.Context como primer argumento, y que Gophercloud incluye
ResponseCodeIs para que no tengas que hacer coincidencias de patrones en cadenas
de error:
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
}
Subir un archivo
Usa la siguiente función para subir un archivo con el tipo de contenido y el manejo de errores adecuados.
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 es un io.Reader, pero Gophercloud no lo
transmite sin más. A menos que establezcas NoETag o proporciones tu
propio ETag, CreateOpts.ToObjectCreateParams calcula primero una suma
de verificación MD5 del cuerpo. Para un io.ReadSeeker como el
*os.File anterior, calcula el hash y luego vuelve al inicio,
por lo que el archivo se lee dos veces; para cualquier otro lector llama a
io.ReadAll y mantiene todo el cuerpo en memoria. Por lo tanto, pasar un
pipe o una respuesta de red aquí no tiene un consumo de memoria acotado. Establece
NoETag: true cuando quieras un verdadero flujo de una sola pasada, y acepta
que Swift entonces no tiene ninguna verificación de integridad del lado del cliente.
Lógica de reintentos para las subidas
Implementa un mecanismo de reintentos para manejar errores transitorios al subir archivos.
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 vuelve a abrir el archivo en cada intento, que es lo que hace
que sea seguro reintentarlo. La versión genérica que aparece a continuación tiene que ser más
cuidadosa.
Subir un objeto con reintentos (usando createopts)
Para subir segmentos u objetos que no provienen de rutas de archivo, usa esta función de reintento
generalizada. Toma la carga útil como un slice de bytes y construye un lector nuevo en cada
intento: un CreateOpts cuyo lector Content ya fue
vaciado por un intento fallido subiría cero bytes de forma silenciosa en el siguiente, y todas las
comprobaciones de error seguirían pasando.
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)
}
Manejar archivos grandes con subidas segmentadas
Para archivos grandes, divide el archivo en segmentos y sube cada segmento por separado. Después,
crea un objeto de manifiesto para ensamblar esos segmentos. Esto construye un Dynamic Large Object:
el manifiesto es un objeto de cero bytes cuya cabecera X-Object-Manifest nombra un
contenedor y un prefijo, y Swift concatena todo lo que hay bajo ese prefijo al leerlo.
CreateOpts tiene un campo ObjectManifest dedicado para ello;
poner la cabecera en Metadata en su lugar envía
X-Object-Meta-X-Object-Manifest y produce un manifiesto que no ensambla nada, sin que falle
ninguna comprobación de error.
Como Swift concatena todo lo que hay bajo ese prefijo, el prefijo tiene que ser único por cada
subida. Un prefijo <objectName>/ fijo se rompe en cuanto subes el mismo objeto
dos veces: reemplazar un archivo de 11 MB por uno de 2 MB sobrescribe el segmento
00000000 y deja atrás 00000001 y
00000002, por lo que el manifiesto entonces sirve 2 MB del archivo nuevo
seguidos de 6 MB del antiguo. Sobrescribir los segmentos en el mismo lugar también es visible de
inmediato para los lectores, porque el manifiesto activo ya apunta a ellos. En su lugar, dale a
cada subida su propio prefijo de versión aleatorio.
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
}
Hay dos límites que conviene conocer antes de llevar esto a producción. Una ejecución fallida deja
atrás los segmentos que ya había subido, así que necesitas un barrido aparte del contenedor
_segments. Ese barrido no debe limitarse a borrar todo lo que hay bajo
<objectName>/: el prefijo de versión al que apunta el manifiesto activo también
vive ahí. Lee primero la cabecera X-Object-Manifest del manifiesto
(objects.Get(...).Extract() te la devuelve como GetHeader.ObjectManifest), conserva
ese único prefijo y borra solo los prefijos de versión que no estén referenciados y sean más
antiguos que tu subida más larga posible, para que nunca recojas una ejecución que todavía está
en curso.
Y borrar el manifiesto no borra sus segmentos. No hay una sola llamada que haga ambas cosas para un
Dynamic Large Object: ?multipart-manifest=delete pertenece a los Large Objects estáticos, y
Swift la ignora aquí, así que el manifiesto desaparece mientras sigues pagando por cada segmento.
En su lugar, haz la limpieza en dos pasos: lista <container>_segments con el prefijo
exacto que nombra el manifiesto, borra esos objetos (objects.BulkDelete acepta un
lote de nombres) y después borra el manifiesto. La CLI de swift ofrece
swift delete <container> <objectName>, que hace el mismo recorrido por ti, leyendo la cabecera del
manifiesto en lugar de adivinar el prefijo. Consulta la
documentación de objetos grandes de Swift para ver
en qué se diferencian los dos tipos de manifiesto.
Manejo de errores y buenas prácticas
Limitación de velocidad
Implementa una limitación de velocidad para no saturar la API de Swift. Este ejemplo usa el limitador de velocidad de 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)
}
Conclusión
Subir archivos a OpenStack Swift con Go y Gophercloud v2 ofrece una forma flexible y eficiente de gestionar el almacenamiento en la nube. Al incorporar un manejo de errores adecuado, lógica de reintentos y limitación de velocidad, puedes construir sistemas fiables que manejen con elegancia diversos escenarios.
Para flujos de trabajo más avanzados, Transloadit ofrece un conjunto de servicios de manejo de archivos. Explora nuestro servicio de exportación de archivos para conocer más opciones con las que agilizar el procesamiento de tus archivos.
¡Feliz programación!
