Exporta archivos por lotes a Amazon S3 con curl y AWS CLI
Usa un pequeño programa de firma con el AWS SDK para generar URL PUT prefirmadas y deja que
curl suba cada archivo a Amazon S3. El script Bash siguiente exporta los
archivos regulares del nivel superior de un directorio, incluidos los ocultos y vacíos, e informa
un fallo si falla cualquier archivo seleccionado. AWS CLI permite comprobar las credenciales y
verificar la descarga de forma independiente; aws s3 presign firma descargas, no subidas.
Requisitos previos
Este ejemplo está pensado para Linux con Bash, GNU stat,
file, curl, AWS CLI v2 y Corepack instalados.
Usa Node.js 24.15 o posterior de la línea 24.x, o Node.js 26.x. Estos son los requisitos del ejemplo,
no los mínimos de ejecución del AWS SDK; Node ejecuta el programa de firma en TypeScript sin una
fase de compilación. Si no tienes AWS CLI, sigue su
guía de instalación.
El programa de firma se verificó con Node.js 24.15.0, 26.0.0, 26.5.0 y 26.8.1. La subida completa usa AWS SDK 3.1148.0, Yarn 4.12.0, AWS CLI 2.37.10 y curl 8.22.0. Las versiones de dependencias fijadas a continuación permiten reproducir esta configuración; no requieren instalar una versión anterior de la CLI que ya tienes.
También necesitas un bucket S3 de uso general existente, su región y un perfil de AWS autenticado
con acceso al prefijo de destino. Usa el proceso de inicio de sesión existente de tu organización.
Tanto la CLI como el SDK respetan AWS_PROFILE; las credenciales de sesión
proporcionadas mediante variables de entorno deben incluir AWS_SESSION_TOKEN.
No incluyas credenciales en estos scripts.
Crea un proyecto independiente con las dos dependencias del programa de firma. Este bloque deja tu shell en su directorio original y se niega a usar un directorio de proyecto existente:
(
set -euo pipefail
for tool in node corepack aws curl file stat; do
command -v "$tool" >/dev/null || { echo "Missing prerequisite: $tool" >&2; exit 1; }
done
mkdir s3-batch-export
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' \
> s3-batch-export/package.json
printf 'nodeLinker: node-modules\n' > s3-batch-export/.s3-batch-export.yarnrc.yml
touch s3-batch-export/yarn.lock
cd -- s3-batch-export
YARN_RC_FILENAME=.s3-batch-export.yarnrc.yml corepack yarn add --exact \
@aws-sdk/client-s3@3.1148.0 @aws-sdk/s3-request-presigner@3.1148.0
)
El archivo yarn.lock vacío convierte este proyecto en un proyecto Yarn
independiente. El archivo de configuración indicado evita que la instalación herede archivos
.yarnrc.yml de directorios superiores. Si la instalación falla después de la
creación, inspecciona el nuevo directorio y vuelve a ejecutar el mismo comando
YARN_RC_FILENAME=… corepack yarn add dentro de él; no vuelvas a ejecutar mkdir
ni elimines un proyecto existente. Una vez completada la instalación, entra en el directorio
para los ejemplos restantes:
cd -- s3-batch-export
Comprende los componentes
Amazon S3 es un sistema escalable de almacenamiento de objetos.
Una pre-signed URL concede permiso por tiempo limitado para realizar
un tipo específico de solicitud a S3 sin exponer tus credenciales de AWS a quien la realiza.
La firma abarca el método HTTP, el bucket y la clave, la caducidad y cualquier encabezado que elijas
firmar, por lo que una URL firmada para un GET no se puede reutilizar como un PUT.
No es un token de un solo uso. Puedes reutilizar la URL hasta que caduque, y un PUT exitoso reemplaza el objeto asociado a esa clave. Evita que aparezca en registros, en el trazado del shell y en la salida compartida de la terminal. AWS describe estos permisos y el comportamiento de reemplazo en su guía de subida con URL prefirmadas.
El comando presign de AWS CLI
autoriza solicitudes GET. Su salida no puede autorizar curl -T.
Para firmar un PUT necesitas un programa de firma que te permita indicar la operación, que es
lo que hace @aws-sdk/s3-request-presigner.
AWS CLI comprueba tu identidad y recupera los bytes subidos; curl
realiza la transferencia.
Proporciona los permisos IAM necesarios
La firma se calcula localmente, por lo que no se comprueba ningún permiso mientras se genera la URL. Los permisos que importan son los de la identidad que firma, porque S3 evalúa la política de esa identidad cuando se usa la URL prefirmada. Pide al administrador del bucket permisos limitados a tu destino, por ejemplo:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::your-bucket-name/exports/*"
}
]
}
La subida requiere s3:PutObject. La comprobación independiente de descarga que
aparece más abajo también necesita s3:GetObject para ese prefijo. Las políticas
del bucket y de las claves de cifrado pueden imponer requisitos adicionales.
Reemplaza your-bucket-name por tu bucket y exports/
por el prefijo que hayas elegido.
Genera URL PUT prefirmadas con el AWS SDK
Guarda esto como presign-put.ts. Imprime una URL y termina, lo que facilita
llamarlo desde un bucle del shell más adelante:
// presign-put.ts - print a pre-signed URL that authorizes PUT, and only PUT.
import { parseArgs } from 'node:util'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const { values } = parseArgs({
options: {
bucket: { type: 'string' },
key: { type: 'string' },
region: { type: 'string' },
'content-type': { type: 'string', default: 'application/octet-stream' },
'expires-in': { type: 'string', default: '3600' },
},
})
const { bucket, key, region } = values
if (!bucket || !key || !region) {
throw new Error('Usage: node presign-put.ts --bucket B --key K --region R [--content-type T]')
}
const expiresIn = Number(values['expires-in'])
// SigV4 caps a pre-signed URL at 7 days. Reject longer values here rather than
// relying on the SDK's generic expiry error; fractional values are rejected too.
if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 604800) {
throw new Error(`--expires-in must be between 1 and 604800 seconds, got: ${values['expires-in']}`)
}
const client = new S3Client({
region,
maxAttempts: 1,
// curl supplies the body later; omit optional checksum metadata here.
requestChecksumCalculation: 'WHEN_REQUIRED',
})
const url = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: values['content-type'] }),
{
expiresIn,
// Require the uploader to send the Content-Type used when signing.
signableHeaders: new Set(['content-type']),
},
)
console.log(url)
Ejecútalo:
node presign-put.ts \
--bucket your-bucket-name \
--key exports/localfile.txt \
--region your-region \
--content-type text/plain \
--expires-in 3600
Reemplaza el bucket, la clave y la región por tus valores. La URL lleva la firma y su caducidad en la cadena de consulta; no contiene ninguna clave secreta, pero cualquiera que la tenga puede escribir ese objeto repetidamente hasta que caduque, así que evita que aparezca en registros.
--expires-in es un límite máximo, no una garantía. Si firmas con credenciales
temporales de un rol IAM, una sesión SSO o sts:AssumeRole, la URL deja de funcionar
en cuanto caducan esas credenciales, lo que suele ocurrir mucho antes de la caducidad solicitada.
X-Amz-SignedHeaders en la URL generada tiene el valor content-type;host.
Esa es la lista que curl debe reproducir exactamente. Enviar un
Content-Type diferente u omitir el encabezado invalida la firma.
La caducidad máxima de SigV4 es de 7 días (604.800 segundos).
Sube archivos con curl
Usa curl con la opción -T para especificar
el archivo local que vas a subir. Añadir --retry hace que la subida resista
mejor los problemas transitorios de red. El encabezado Content-Type no es
opcional aquí: es uno de los encabezados firmados, por lo que debe coincidir con el valor de
--content-type que pasaste al programa de firma. El subshell mantiene el manejo
estricto de errores limitado a este ejemplo, incluso si lo pegas en una sesión Bash existente.
(
set -euo pipefail
CONTENT_TYPE=$(file -b --mime-type -- ./localfile.txt)
# Sign for that exact type, then send that exact type
URL=$(node presign-put.ts \
--bucket your-bucket-name \
--key exports/localfile.txt \
--region your-region \
--content-type "$CONTENT_TYPE")
if status=$(curl --disable -fsS --globoff --retry 3 --retry-delay 2 \
--connect-timeout 10 --max-time 300 --retry-max-time 300 \
-T ./localfile.txt \
-H "Content-Type: $CONTENT_TYPE" \
-o /dev/null -w '%{http_code}' \
"$URL") && [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo 'Upload completed.'
else
echo 'Upload failed: expected an HTTP 2xx response.' >&2
exit 1
fi
)
Reemplaza ./localfile.txt por la ruta de tu archivo y el bucket y la región por tus
propios valores. --disable ignora el .curlrc de quien
lo ejecuta; --globoff conserva literalmente las llaves y los corchetes en los
nombres de archivo. -f hace que curl termine
con un código distinto de cero ante un error HTTP, en lugar de imprimir el cuerpo XML del error
de S3 como si fuera un éxito, y -sS oculta el indicador de progreso
sin dejar de mostrar los errores reales. La comprobación explícita de códigos 2xx también rechaza
las redirecciones, que -f por sí solo no trata como errores. No añadas
-L: seguir una redirección cambia la solicitud firmada. Cada intento
tiene un plazo máximo de 300 segundos, incluida la transferencia de datos, por lo que la subida
de un archivo válido puede fallar si es lenta. La programación de reintentos también se detiene
tras 300 segundos; un reintento ya iniciado puede ejecutarse durante otros 300 segundos.
Inspecciona el destino antes de reintentar tras una respuesta incierta.
El manual de curl explica estas opciones de transferencia,
reintentos y nombres de archivo.
Automatiza las exportaciones de archivos por lotes
Guarda esto como batch-export.sh junto a presign-put.ts.
Procesa un archivo a la vez, continúa tras un fallo individual y termina con un código distinto
de cero si falla cualquier archivo seleccionado. Omite directorios y enlaces simbólicos, admite
nombres de archivo UTF-8 y trata un directorio vacío como un lote exitoso de cero archivos.
Mantén el directorio de entrada sin cambios durante la ejecución.
#!/usr/bin/env bash
set -euo pipefail # Exit on error, undefined variable, or pipe failure
export LC_ALL=C AWS_MAX_ATTEMPTS=1 AWS_PAGER=""
trap 'echo "Batch interrupted; inspect uploaded objects before retrying." >&2; exit 130' INT
trap 'echo "Batch interrupted; inspect uploaded objects before retrying." >&2; exit 143' TERM
BUCKET="your-bucket-name"
EXPIRE=3600 # URL validity in seconds (1 hour)
FILES_DIR="/path/to/your/files" # Directory containing files to upload
REGION="your-region" # Your S3 bucket region
PRESIGN="./presign-put.ts" # The signer from the previous section
PREFIX="${S3_PREFIX:-exports/}" # Use a separate prefix for this batch
TRANSFER_SECONDS="${S3_TRANSFER_SECONDS:-300}"
# --- pre-flight checks ---
if [[ ! -d "$FILES_DIR" || ! -r "$FILES_DIR" || ! -x "$FILES_DIR" ]]; then
echo "Error: Directory '$FILES_DIR' must exist and be readable/searchable." >&2
exit 1
fi
if [[ ! -f "$PRESIGN" ]]; then
echo "Error: Signer '$PRESIGN' not found. See the previous section." >&2
exit 1
fi
for tool in aws node curl file stat; do
command -v "$tool" >/dev/null || { echo "Missing prerequisite: $tool" >&2; exit 1; }
done
if [[ ! "$TRANSFER_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: S3_TRANSFER_SECONDS must be a positive integer." >&2
exit 1
fi
# Verify the credentials the signer will pick up are present and valid.
if ! caller_arn=$(aws sts get-caller-identity --query Arn --output text 2>/dev/null); then
echo "Error: AWS credentials are not configured properly or are invalid." >&2
echo "Check your selected AWS profile or session credentials." >&2
exit 1
fi
echo "AWS credentials verified for: $caller_arn"
echo "Starting batch export from '$FILES_DIR' to bucket '$BUCKET' in region '$REGION'..."
# --- processing loop ---
failed=0
uploaded=0
# dotglob: a hidden file is an ordinary object to S3, and silently leaving
# .env.example behind is data loss. nullglob: an empty directory must not run
# the loop once with a literal '*'.
shopt -s dotglob nullglob
for file in "$FILES_DIR"/*; do
filename=${file##*/}
# Test for a symlink before -f, which follows them: a link inside FILES_DIR
# would otherwise upload bytes from outside it, under a name from inside it.
if [[ -L "$file" ]]; then
echo " Skipping '$filename': symbolic link."
continue
fi
# Skip directories and anything else that is not a regular file.
[[ -f "$file" ]] || continue
echo "Processing '$filename'..."
# Node's argv decoding is lossy for malformed UTF-8; reject before signing a key.
if ! printf '%s' "$filename" | node --input-type=module -e \
'import { readFileSync } from "node:fs"; new TextDecoder("utf-8", {fatal:true}).decode(readFileSync(0))' \
2>/dev/null; then
echo " Error: Filename is not valid UTF-8." >&2
failed=$((failed + 1))
continue
fi
if [[ ! -r "$file" ]] || ! file_size=$(stat -c%s -- "$file"); then
echo " Error: Cannot read or inspect '$filename'." >&2
failed=$((failed + 1))
continue
fi
# This example limits each single PUT to 5 GiB; use multipart for larger files.
if ((file_size > 5368709120)); then
echo " Skipping '$filename': $((file_size / 1024 / 1024)) MiB exceeds this script’s 5 GiB limit." >&2
echo " Use 'aws s3 cp' instead, which switches to multipart automatically." >&2
failed=$((failed + 1))
continue
fi
if ! content_type=$(file -b --mime-type -- "$file"); then
echo " Error: Cannot determine Content-Type for '$filename'." >&2
failed=$((failed + 1))
continue
fi
echo " Signing a PUT for '$filename' (Content-Type: $content_type)..."
if ! url=$(node "$PRESIGN" \
--bucket "$BUCKET" \
--key "$PREFIX$filename" \
--region "$REGION" \
--content-type "$content_type" \
--expires-in "$EXPIRE"); then
echo " Error: Failed to sign a URL for '$filename'." >&2
failed=$((failed + 1))
continue
fi
echo " Uploading '$filename'..."
# The Content-Type must match the signature. curl -f does not reject redirects;
# require a completed 2xx upload without replaying a signed body at another URL.
if status=$(curl --disable -fsS --globoff --retry 3 --retry-delay 2 \
--connect-timeout 10 --max-time "$TRANSFER_SECONDS" --retry-max-time "$TRANSFER_SECONDS" \
-T "$file" \
-H "Content-Type: $content_type" \
-o /dev/null -w '%{http_code}' \
"$url") && [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo " Successfully uploaded '$filename'."
uploaded=$((uploaded + 1))
else
echo " Error: Failed to upload '$filename'." >&2
failed=$((failed + 1))
fi
done
echo "Batch export completed: $uploaded uploaded, $failed failed."
# Surface partial failure to the caller: 'set -e' cannot do this for us, because
# every failure above was deliberately caught so the loop could continue.
if ((failed > 0)); then
exit 1
fi
Reemplaza el bucket, el directorio de entrada y la región en el script y ejecútalo desde este proyecto:
bash batch-export.sh
Para dos archivos subidos con éxito, el resumen es Batch export completed: 2 uploaded, 0 failed.. Una transferencia
fallida se suma al recuento de fallos aunque los archivos posteriores se suban con éxito.
Si el directorio de entrada no existe o no se puede leer, el script falla antes de cualquier
transferencia. S3_PREFIX prevalece sobre exports/;
incluye la barra final. Los nombres conservan espacios, corchetes, signos de porcentaje, emojis
y saltos de línea. Los nombres con UTF-8 mal formado se cuentan como fallos porque no pueden
representarse de forma fiable mediante los argumentos de cadena de Node.
Este script limita los archivos a 5 GiB y usa los mismos límites de transferencia y reintentos
de 300 segundos que la subida individual. Aumenta S3_TRANSFER_SECONDS para
transferencias más lentas. Cada archivo tiene como máximo cuatro intentos, incluida la solicitud
inicial. Un reintento vuelve a enviar el archivo completo; no reanuda un PUT interrumpido.
Si se interrumpe, el script devuelve un código distinto de cero sin un resumen de finalización.
Los objetos completados permanecen, y una respuesta perdida puede dejar un objeto guardado,
así que inspecciona el prefijo antes de reiniciar.
Las subidas reemplazan los objetos existentes con la misma clave. Usa un prefijo nuevo para separar los lotes; los directorios anidados se omiten en lugar de aplanarse. Repetir un lote reemplaza sus claves exitosas y no elimina las claves ajenas al lote.
Verifica un archivo conocido de forma independiente descargándolo y comparando sus bytes. Esto crea un directorio local nuevo y deja intactos los archivos existentes:
(
set -euo pipefail
command -v cmp >/dev/null || { echo 'Missing prerequisite: cmp' >&2; exit 1; }
mkdir s3-download-check || exit $?
aws s3api get-object --bucket your-bucket-name --key exports/localfile.txt \
--region your-region s3-download-check/localfile.txt >/dev/null || exit $?
cmp -- ./localfile.txt s3-download-check/localfile.txt || exit $?
echo 'Downloaded bytes match.'
)
Compara cada archivo esperado al comprobar un lote completo. El recuento de éxitos por sí solo no puede detectar un archivo omitido o un objeto incorrecto. Si la descarga falla, inspecciona y elimina únicamente el directorio de descarga parcial de esta comprobación antes de volver a ejecutar el bloque.
Aplica buenas prácticas de seguridad
Limita el acceso de la identidad que firma al prefijo del lote y elige una duración de la URL
suficiente para la transferencia. Las credenciales temporales pueden caducar antes de lo indicado
por --expires-in, como explica AWS en su
guía de URL prefirmadas.
Los scripts no cambian las políticas del bucket ni la configuración de cifrado. Si el bucket usa
una clave KMS administrada por el cliente, confirma que el administrador haya concedido el acceso
necesario a la clave antes de subir archivos.
Firmar Content-Type vincula ese encabezado, no el contenido ni la seguridad
del archivo. Cualquiera que tenga la URL puede enviar bytes diferentes con ese mismo encabezado.
La comprobación independiente de descarga evita que tu verificación compare accidentalmente
el objeto equivocado; no convierte la URL en una política de validación de contenido.
Soluciona problemas comunes
- Falla la comprobación de credenciales: Renueva tu sesión existente y comprueba
AWS_PROFILEo las variables de entorno de sesión. Una ejecución exitosa deaws sts get-caller-identityidentifica a quien realiza la llamada desde la CLI; no demuestra que S3 permita una escritura. El programa de firma también resuelve sus propias credenciales. - La subida devuelve HTTP 403: Comprueba la región del bucket, la clave, la caducidad de la URL,
los permisos de firma y el valor exacto de
Content-Typefirmado. El bloque de subida descarta deliberadamente el cuerpo de respuesta de S3, por lo que su fallo genérico no permite identificar cuál de esas causas se aplica. No registres la URL firmada durante el diagnóstico. La guía de AWS para solucionar problemas de subida aborda estas comprobaciones. - Se agota el tiempo de transferencia: El plazo también se aplica a archivos válidos por debajo del límite de tamaño. Inspecciona el objeto, ajusta el plazo de transferencia si corresponde y vuelve a ejecutar con una URL nueva. Añadir reintentos no soluciona los errores de autenticación.
- Se omite el archivo por ser demasiado grande: Este script impone un límite de 5 GiB por PUT individual. Usa un cliente de subida multiparte para un archivo más grande.
Para archivos más grandes, aws s3 cp
gestiona la subida multiparte. Esta es una transferencia independiente que usa credenciales,
no una solicitud curl prefirmada:
aws s3 cp ./large_file.zip s3://your-bucket-name/exports/large_file.zip --region your-region
Conclusión
Usa un prefijo nuevo para tu primera exportación de archivos, descarga objetos conocidos para comprobar sus bytes y comprueba el código de salida del script antes de considerar que el lote está completo. Este flujo de trabajo de código abierto mantiene separadas la firma y la transferencia y permite detectar los fallos parciales.
Si tus archivos ya pasan por el procesamiento de Transloadit, 🤖 /s3/store es un Robot que puede exportar los resultados de un Step a S3 dentro de la misma Assembly.
