Exportar arquivos em lote para Amazon S3 com curl e AWS CLI
Use um pequeno assinador com o AWS SDK para gerar URLs PUT pré-assinadas e deixe
curl fazer o upload de cada arquivo para Amazon S3. O script Bash abaixo
exporta os arquivos regulares no nível superior de um diretório, incluindo arquivos ocultos e
vazios, e informa uma falha se qualquer arquivo selecionado falhar. A AWS CLI fornece verificações
de credenciais e uma verificação independente por download; aws s3 presign em si
assina downloads, não uploads.
Pré-requisitos
Este exemplo se destina ao Linux com Bash, GNU stat, file, curl, AWS CLI v2 e Corepack instalados.
Use Node.js 24.15 ou posterior da linha 24.x, ou Node.js 26.x. Esses são os pré-requisitos do
exemplo, e não os requisitos mínimos de execução do AWS SDK; o Node executa o assinador TypeScript
sem uma etapa de compilação. Se a AWS CLI não estiver instalada, siga seu
guia de instalação.
O assinador foi verificado com Node.js 24.15.0, 26.0.0, 26.5.0 e 26.8.1. O upload completo usa AWS SDK 3.1148.0, Yarn 4.12.0, AWS CLI 2.37.10 e curl 8.22.0. As versões fixadas das dependências abaixo tornam essa configuração reproduzível; elas não exigem instalar uma versão anterior de uma CLI já instalada.
Você também precisa de um bucket S3 de uso geral já existente, da região dele e de um perfil AWS
autenticado com acesso ao prefixo de destino. Use o processo de login existente da sua organização.
Tanto a CLI quanto o SDK respeitam AWS_PROFILE; as credenciais de sessão
fornecidas por variáveis de ambiente devem incluir AWS_SESSION_TOKEN. Não coloque
credenciais nesses scripts.
Crie um projeto separado com as duas dependências do assinador. Este bloco mantém seu shell no diretório original e não prossegue se o diretório do projeto já existir:
(
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
)
O arquivo yarn.lock vazio torna este um projeto Yarn separado. O arquivo de
configuração nomeado impede que a instalação herde arquivos .yarnrc.yml dos
diretórios superiores. Se a instalação falhar após a criação, inspecione o novo diretório e tente
novamente o mesmo comando YARN_RC_FILENAME=… corepack yarn add dentro dele;
não execute mkdir novamente nem exclua um projeto existente. Após a
instalação bem-sucedida, entre no diretório para executar os exemplos restantes:
cd -- s3-batch-export
Entender os componentes
Amazon S3 é um armazenamento de objetos escalável. Uma
pre-signed URL concede permissão por tempo limitado para realizar
um tipo específico de requisição ao S3 sem expor suas credenciais AWS a quem a realiza.
A assinatura abrange o método HTTP, o bucket e a chave, a expiração e quaisquer cabeçalhos que você
escolher assinar, portanto uma URL assinada para GET não pode ser reutilizada como PUT.
Ela não é um token de uso único. Você pode reutilizar a URL até que ela expire, e um PUT bem-sucedido substitui o objeto nessa chave. Mantenha a URL fora de logs, do rastreamento de comandos do shell e da saída compartilhada do terminal. A AWS descreve essas permissões e o comportamento de substituição em seu guia de upload com URLs pré-assinadas.
O comando presign da AWS CLI
autoriza requisições GET. Sua saída não pode autorizar curl -T. Para assinar
um PUT, você precisa de um assinador que permita especificar a operação, que é o que
@aws-sdk/s3-request-presigner
faz. A AWS CLI verifica sua identidade e recupera os bytes enviados por upload;
curl realiza a transferência.
Fornecer as permissões IAM necessárias
A assinatura em si é calculada localmente, portanto nenhuma permissão é verificada durante a geração da URL. As permissões que importam são as do próprio assinador, pois o S3 avalia a política da identidade que assina quando a URL pré-assinada é usada. Peça ao administrador do bucket uma permissão restrita ao seu destino, por exemplo:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::your-bucket-name/exports/*"
}
]
}
O upload exige s3:PutObject. A verificação independente por download abaixo
também precisa de s3:GetObject nesse prefixo. As políticas do bucket e das
chaves de criptografia podem impor requisitos adicionais.
Substitua your-bucket-name pelo seu bucket e exports/ pelo
prefixo escolhido.
Gerar URLs PUT pré-assinadas com o AWS SDK
Salve este código como presign-put.ts. Ele imprime uma URL e encerra o processo,
o que facilita chamá-lo posteriormente em um laço do shell:
// 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)
Execute:
node presign-put.ts \
--bucket your-bucket-name \
--key exports/localfile.txt \
--region your-region \
--content-type text/plain \
--expires-in 3600
Substitua o bucket, a chave e a região pelos seus valores. A URL contém a assinatura e sua expiração na string de consulta; ela não contém uma chave secreta, mas qualquer pessoa que a tenha pode escrever nesse único objeto repetidamente até que ela expire, portanto mantenha a URL fora de logs.
--expires-in é um limite máximo, não uma garantia. Se você assinar com credenciais
temporárias de uma função IAM, de uma sessão SSO ou de sts:AssumeRole, a URL para de
funcionar no momento em que essas credenciais expiram, o que costuma ocorrer muito antes da
expiração solicitada.
X-Amz-SignedHeaders na URL gerada contém content-type;host. Essa é a lista
que curl deve reproduzir exatamente. Enviar um
Content-Type diferente ou omitir o cabeçalho invalida a assinatura.
A expiração máxima do SigV4 é de 7 dias (604.800 segundos).
Fazer upload de arquivos com curl
Use a opção curl -T para especificar o arquivo local a enviar por upload.
Adicionar --retry torna o upload mais resistente a problemas transitórios de
rede. O cabeçalho Content-Type não é opcional aqui: ele é um dos cabeçalhos
assinados e, portanto, deve corresponder ao --content-type que você passou ao
assinador. O subshell mantém o tratamento rigoroso de erros restrito a este exemplo, mesmo quando
ele é colado em uma sessão 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
)
Substitua ./localfile.txt pelo caminho do seu arquivo e o bucket e a região pelos
seus próprios valores. --disable ignora o .curlrc de quem
faz a chamada; --globoff mantém chaves e colchetes em nomes de arquivos como
caracteres literais. -f faz curl encerrar com
código de saída diferente de zero em caso de erro HTTP, em vez de imprimir o corpo XML do erro do
S3 como se fosse um sucesso, e -sS suprime o indicador de progresso,
mas continua mostrando erros reais. A verificação explícita de 2xx também rejeita redirecionamentos,
que -f sozinho não trata como erros. Não adicione
-L: seguir um redirecionamento altera a requisição assinada. Cada
tentativa tem um prazo de 300 segundos, incluindo a transferência de dados, portanto um arquivo
válido cuja transferência seja lenta pode falhar. O agendamento de novas tentativas também para
após 300 segundos; uma nova tentativa já iniciada pode continuar por mais 300 segundos. Inspecione
o destino antes de tentar novamente após uma resposta incerta.
O manual do curl explica essas opções de transferência, novas
tentativas e nomes de arquivos.
Automatizar exportações de arquivos em lote
Salve este código como batch-export.sh ao lado de presign-put.ts.
Ele processa um arquivo por vez, continua após uma falha individual e encerra com código de saída
diferente de zero se qualquer arquivo selecionado falhar. Ele ignora diretórios e links simbólicos,
aceita nomes de arquivos em UTF-8 e trata um diretório vazio como um lote bem-sucedido de zero
arquivos. Mantenha o diretório de entrada inalterado durante a execução.
#!/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
Substitua o bucket, o diretório de entrada e a região no script e execute-o a partir deste projeto:
bash batch-export.sh
Para dois arquivos processados com sucesso, o resumo é Batch export completed: 2 uploaded, 0 failed. Uma
transferência que falha entra na contagem de falhas mesmo que os arquivos seguintes sejam
processados com sucesso. Um diretório de entrada inexistente ou sem permissão de leitura causa
uma falha antes de qualquer transferência. S3_PREFIX sobrescreve
exports/; inclua a barra final.
Os nomes mantêm espaços, colchetes, sinais de porcentagem, emojis e quebras de linha. Nomes com
UTF-8 malformado são contabilizados como falhas porque não podem ser mapeados de forma confiável
por meio dos argumentos de string do Node.
Este script limita os arquivos a 5 GiB e usa os mesmos limites de 300 segundos para transferência e
novas tentativas do upload individual. Aumente S3_TRANSFER_SECONDS para transferências
mais lentas. Cada arquivo tem no máximo quatro tentativas, incluindo a requisição inicial. Uma nova
tentativa envia o arquivo inteiro novamente; ela não retoma um PUT interrompido. Se for
interrompido, o script retorna um código diferente de zero sem um resumo de conclusão. Os objetos
concluídos permanecem, e uma resposta perdida pode deixar um objeto já gravado, portanto inspecione
o prefixo antes de reiniciar.
Os uploads substituem objetos existentes com a mesma chave. Use um novo prefixo para separar os lotes; os diretórios aninhados são ignorados, em vez de terem seus arquivos reunidos no nível superior. Repetir um lote substitui os objetos das chaves processadas com sucesso e não exclui chaves sem relação com ele.
Verifique um arquivo conhecido de forma independente fazendo seu download e comparando os bytes. Isso cria um novo diretório local e mantém os arquivos existentes intactos:
(
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.'
)
Compare cada arquivo esperado ao verificar um lote completo. Uma contagem de sucessos, por si só, não consegue detectar um arquivo omitido ou um objeto incorreto. Se o download falhar, inspecione e remova apenas o diretório de download parcial desta verificação antes de executar o bloco novamente.
Aplicar boas práticas de segurança
Restrinja o acesso da identidade que assina ao prefixo do lote e escolha um tempo de validade da
URL suficiente para a transferência. Credenciais temporárias podem expirar antes de
--expires-in, como a AWS explica em seu
guia de URLs pré-assinadas.
Os scripts não alteram as políticas do bucket nem as configurações de criptografia. Para um bucket
que usa uma chave KMS gerenciada pelo cliente, confirme que o administrador concedeu o acesso
necessário à chave antes de fazer o upload.
Assinar Content-Type vincula esse cabeçalho, não o conteúdo ou a segurança do
arquivo. Qualquer pessoa que tenha a URL pode enviar bytes diferentes com esse mesmo cabeçalho.
A verificação independente por download evita que você compare acidentalmente o objeto errado;
ela não transforma a URL em uma política de validação de conteúdo.
Resolver problemas comuns
- A verificação de credenciais falha: Renove seu login existente e verifique
AWS_PROFILEou as variáveis de ambiente da sessão. Uma execução bem-sucedida deaws sts get-caller-identityidentifica a identidade autenticada pelas credenciais resolvidas pela CLI; ela não comprova que o S3 permite uma escrita. O assinador também resolve suas próprias credenciais. - O upload retorna HTTP 403: Verifique a região do bucket, a chave, a expiração da URL, as
permissões de assinatura e o valor exato de
Content-Typeassinado. O bloco de upload descarta deliberadamente o corpo da resposta do S3, portanto sua falha genérica não consegue identificar qual dessas causas se aplica. Não registre a URL assinada em logs durante o diagnóstico. As orientações da AWS para resolver problemas de upload abordam essas verificações. - A transferência excede o tempo limite: O prazo também se aplica a arquivos válidos abaixo do limite de tamanho. Inspecione o objeto, ajuste o prazo de transferência se for adequado e execute novamente com uma nova URL. Erros de autenticação não são corrigidos com novas tentativas.
- O arquivo é ignorado por ser grande demais: Este script impõe um limite máximo de 5 GiB para um único PUT. Use um cliente de upload em múltiplas partes para um arquivo maior.
Para arquivos maiores, aws s3 cp realiza
uploads em múltiplas partes. Essa é uma transferência separada que usa credenciais, não uma
requisição curl pré-assinada:
aws s3 cp ./large_file.zip s3://your-bucket-name/exports/large_file.zip --region your-region
Conclusão
Use um novo prefixo para sua primeira exportação de arquivos, faça o download de objetos conhecidos para verificar seus bytes e confira o código de saída do script antes de considerar o lote concluído. Este fluxo de trabalho de código aberto mantém a assinatura e a transferência separadas e torna as falhas parciais visíveis.
Se seus arquivos já passam pelo processamento da Transloadit, o 🤖 /s3/store Robot pode exportar os resultados de Step para o S3 dentro da mesma Assembly.
