Exportar arquivos para o Amazon S3 em Lua com LuaSocket
Para exportar um arquivo para o Amazon S3 em Lua, deixe a libcurl assinar o upload e verificar o HTTPS. Depois, baixe o objeto para conferir seus bytes. Este exemplo chama a libcurl pela FFI do LuaJIT e usa LuaSocket para os intervalos entre retentativas. Ele lida com arquivos binários e vazios, credenciais temporárias da AWS e chaves que contêm espaços, Unicode e sinais de porcentagem literais.
Instalação e configuração
A configuração testada usa Linux com LuaJIT 2.1.1788460057, LuaRocks 3.13.0, LuaSocket 3.1.0-1,
libcurl 8.22.0 e AWS CLI v2 2.37.10. Essas são versões testadas, não versões mínimas obrigatórias.
Use uma compilação atual do LuaJIT 2.1 e uma
versão da libcurl com correções aplicadas com HTTPS e AWS SigV4 habilitados.
A presença de opções antigas da libcurl, por si só, não comprova a compatibilidade com seu mecanismo
de assinatura para S3.
Instale LuaJIT e seus cabeçalhos de desenvolvimento, LuaRocks, um compilador C, Make,
pkg-config, libcurl, um conjunto de certificados de CA do sistema,
AWS CLI v2,
cmp e mktemp pelo gerenciador de pacotes confiável do seu sistema.
O pacote de desenvolvimento do LuaJIT deve fornecer a entrada luajit do pkg-config.
Trabalhe em um diretório vazio fora de um projeto Lua existente e instale o
rock do LuaSocket em uma árvore própria:
(
set -eu
for tool in luajit luarocks cc make pkg-config aws cmp mktemp; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
case "$(aws --version)" in
aws-cli/2.*) ;;
*) printf 'AWS CLI v2 is required\n' >&2; exit 1 ;;
esac
lua_headers=$(pkg-config --variable=includedir luajit)
test -f "$lua_headers/lua.h"
luarocks --lua-version=5.1 --tree="$PWD/rocks" install luasocket 3.1.0-1 \
LUA_INCDIR="$lua_headers" LUA="$(command -v luajit)"
)
Se a instalação falhar, corrija o pré-requisito indicado e repita este bloco no mesmo diretório;
ele não sobrescreve seus arquivos de código-fonte Lua. O script de execução abaixo carrega LuaSocket de
./rocks, em vez de depender dos caminhos de busca Lua de um projeto que o contenha.
Defina LIBCURL_PATH como um caminho absoluto confiável para a biblioteca compartilhada
somente quando a libcurl estiver fora do caminho de busca padrão do carregador.
OpenResty e LuaSec não são necessários.
O módulo estabelece uma conexão HTTPS direta e desabilita proxies e redirecionamentos; sua rede
precisa permitir acesso direto ao S3.
Configuração das credenciais da AWS
Use um bucket de uso geral existente e credenciais autorizadas para s3:PutObject e
s3:GetObject no prefixo escolhido. O script de execução usa a
resolução de credenciais da AWS CLI
para passar as credenciais ao Lua na memória do processo. Primeiro, autentique-se pelo fluxo de
trabalho da AWS CLI já usado pela sua organização ou forneça credenciais pelo gerenciador de
segredos do seu ambiente de implantação. Credenciais temporárias precisam de
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY e AWS_SESSION_TOKEN; credenciais de longa duração
omitem o token. Não cole credenciais no programa nem habilite o rastreamento de comandos do shell.
Defina AWS_REGION e S3_BUCKET com a região e o nome reais do bucket.
O endpoint padrão abaixo aceita buckets convencionais das regiões comerciais da AWS cujos nomes
contêm letras minúsculas, dígitos e hífens. Outras partições, nomes com pontos, pontos de acesso e
buckets de diretório precisam de uma configuração própria de endpoint verificada. Este exemplo
não configura o IAM nem cria um bucket.
Usar um transporte de assinatura verificado
A opção CURLOPT_AWS_SIGV4 da libcurl assina a requisição.
O token de sessão é enviado como x-amz-security-token e incluído nos cabeçalhos assinados.
As chaves são codificadas como bytes UTF-8, mantendo as barras separadoras. O módulo rejeita os
componentes de caminho . e .. quando aparecem isolados,
para evitar que a normalização da URL altere a chave do objeto. O mecanismo de assinatura calcula
o hash dos bytes exatos de POSTFIELDS, embora CUSTOMREQUEST selecione o método HTTP
PUT. Isso evita manter uma implementação criptográfica separada no tutorial.
Fazer upload com LuaJIT
Salve este módulo como s3_upload.lua. Ele limita deliberadamente cada objeto a
16 MiB em memória; uploads maiores precisam de uma implementação de streaming
ou multipart verificada separadamente. Cada tentativa tem um limite de 10 segundos para conexão
e de 60 segundos para a transferência inteira, portanto até um arquivo menor pode exceder o tempo
limite em uma conexão lenta. endpoint e
cafile são configurações opcionais e confiáveis do ambiente de implantação,
úteis para testar com um ambiente de teste HTTPS.
Nunca aceite essas configurações de uma requisição não autenticada.
local ffi = require("ffi")
local socket = require("socket")
ffi.cdef[[
typedef void CURL;
struct curl_slist { char *data; struct curl_slist *next; };
int curl_global_init(long flags);
CURL *curl_easy_init(void);
void curl_easy_cleanup(CURL *handle);
int curl_easy_setopt(CURL *handle, int option, ...);
int curl_easy_perform(CURL *handle);
int curl_easy_getinfo(CURL *handle, int info, ...);
struct curl_slist *curl_slist_append(struct curl_slist *list, const char *value);
void curl_slist_free_all(struct curl_slist *list);
]]
local curl = ffi.load(os.getenv("LIBCURL_PATH") or "curl")
assert(curl.curl_global_init(3) == 0, "Cannot initialize libcurl")
-- Stable option identifiers from libcurl's public curl.h ABI.
local O = { URL=10002, PROXY=10004, WRITEFUNCTION=20011, POSTFIELDS=10015,
HTTPHEADER=10023, CUSTOMREQUEST=10036, SSL_VERIFYPEER=64, CAINFO=10065,
POSTFIELDSIZE=60, SSL_VERIFYHOST=81, CONNECTTIMEOUT=78, TIMEOUT=13,
USERNAME=10173, PASSWORD=10174, AWS_SIGV4=10305,
PROTOCOLS_STR=10318, FOLLOWLOCATION=52 }
local M = {}
function M.encode_key(key)
assert(type(key) == "string" and #key > 0 and #key <= 1024, "Invalid object key")
for part in key:gmatch("[^/]+") do
assert(part ~= "." and part ~= "..", "Dot path components are unsupported")
end
return (key:gsub("([^A-Za-z0-9/_.~-])", function(byte)
return string.format("%%%02X", byte:byte())
end))
end
local function header(value)
assert(type(value) == "string" and #value > 0 and not value:find("[%c]"), "Invalid header value")
return value
end
local function request(config, key, body, content_type)
local handle = curl.curl_easy_init()
assert(handle ~= nil, "Cannot allocate curl handle")
local headers = nil
local function append_header(value)
local updated = curl.curl_slist_append(headers, value)
assert(updated ~= nil, "Cannot allocate curl header")
headers = updated
end
local received = 0
local writer = ffi.cast("size_t (*)(char *, size_t, size_t, void *)", function(_, size, count)
local length = tonumber(size * count)
received = received + length
if received > 1024 * 1024 then return 0 end
return length
end)
local function option(name, value)
if type(value) == "number" then value = ffi.new("long", value) end
assert(curl.curl_easy_setopt(handle, O[name], value) == 0, "Unsupported curl option: " .. name)
end
local ok, result = pcall(function()
local endpoint = config.endpoint or ("https://" .. config.bucket .. ".s3." .. config.region .. ".amazonaws.com")
assert(endpoint:match("^https://[A-Za-z0-9.-]+:?%d*$"), "Expected a trusted HTTPS origin")
option("URL", endpoint .. "/" .. M.encode_key(key))
option("PROTOCOLS_STR", "https")
option("PROXY", "")
option("FOLLOWLOCATION", 0)
option("SSL_VERIFYPEER", 1)
option("SSL_VERIFYHOST", 2)
if config.cafile then option("CAINFO", config.cafile) end
option("CONNECTTIMEOUT", 10)
option("TIMEOUT", 60)
option("USERNAME", header(config.access_key))
option("PASSWORD", header(config.secret_key))
option("AWS_SIGV4", "aws:amz:" .. config.region .. ":s3")
option("CUSTOMREQUEST", "PUT")
option("POSTFIELDSIZE", #body)
option("POSTFIELDS", body)
option("WRITEFUNCTION", writer)
for _, value in ipairs({"Content-Type: " .. header(content_type), "Expect:"}) do
append_header(value)
end
if config.session_token then
append_header("x-amz-security-token: " .. header(config.session_token))
end
option("HTTPHEADER", headers)
local code = curl.curl_easy_perform(handle)
local status = ffi.new("long[1]")
assert(curl.curl_easy_getinfo(handle, 0x200002, status) == 0, "Cannot read HTTP status")
return { transport=tonumber(code), status=tonumber(status[0]) }
end)
curl.curl_easy_cleanup(handle)
curl.curl_slist_free_all(headers)
writer:free()
if not ok then error(result) end
return result
end
-- libcurl calls a Lua callback; this call path must stay outside compiled FFI traces.
jit.off(request, true)
function M.retryable(result)
if result.transport ~= 0 then
return ({[6]=true, [7]=true, [18]=true, [28]=true, [55]=true, [56]=true})[result.transport] == true
end
return ({[408]=true, [429]=true, [500]=true, [502]=true, [503]=true, [504]=true})[result.status] == true
end
function M.upload(config, file_path, key, content_type)
assert(config.region and config.region:match("^[a-z0-9-]+$"), "Invalid region")
assert(config.bucket and #config.bucket >= 3 and #config.bucket <= 63
and config.bucket:match("^[a-z0-9][a-z0-9-]*[a-z0-9]$"), "Invalid bucket name")
local file = assert(io.open(file_path, "rb"))
local body, read_error = file:read(16 * 1024 * 1024 + 1)
file:close()
if read_error then error(read_error) end
body = body or ""
assert(#body <= 16 * 1024 * 1024, "Use multipart upload for larger files")
for attempt = 1, 4 do
local result = request(config, key, body, content_type)
if result.transport == 0 and result.status == 200 then return true end
if attempt == 4 or not M.retryable(result) then
return nil, "Upload failed (transport " .. result.transport .. ", HTTP " .. result.status .. ")"
end
socket.sleep(math.min(8, 0.25 * 2^(attempt - 1)) * (0.5 + math.random()))
end
end
return M
Salve este código de chamada como upload.lua:
local s3 = require("s3_upload")
local ok, err = s3.upload({
access_key = os.getenv("AWS_ACCESS_KEY_ID"),
secret_key = os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token = os.getenv("AWS_SESSION_TOKEN"),
region = os.getenv("AWS_REGION"),
bucket = os.getenv("S3_BUCKET")
}, assert(arg[1], "local file required"), assert(arg[2], "object key required"), "application/octet-stream")
assert(ok, err)
print("Uploaded")
Salve o código a seguir como upload.sh. Execute-o no diretório que contém os dois
arquivos Lua. A exportação de credenciais vem do executável confiável da AWS CLI; o script remove
um token de sessão antigo antes de carregar as credenciais resolvidas. Suas configurações se
aplicam somente ao processo filho.
#!/usr/bin/env bash
set +x
set -eu
for tool in aws luajit; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
: "${AWS_REGION:?Set AWS_REGION to the bucket region}"
: "${S3_BUCKET:?Set S3_BUCKET to the bucket name}"
credentials=$(aws configure export-credentials --format env)
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
eval "$credentials"
export LUA_PATH='./?.lua;./rocks/share/lua/5.1/?.lua;./rocks/share/lua/5.1/?/init.lua'
export LUA_CPATH='./rocks/lib/lua/5.1/?.so'
exec luajit upload.lua "$@"
Use um arquivo regular existente de até 16 MiB ou crie um pequeno exemplo binário. Este bloco se
recusa a substituir um report.bin existente:
(
set -eu
test ! -e report.bin
printf 'September report\000\377\n' > report.bin
)
Substitua your-bucket-name pelo seu bucket. Defina AWS_PROFILE no ambiente se a
sua autenticação na AWS CLI usar um perfil nomeado. Mantenha a chave do objeto entre aspas e passe
sua grafia original, sem codificá-la previamente:
AWS_REGION=us-east-1 S3_BUCKET=your-bucket-name \
bash upload.sh report.bin 'reports/September café %.bin'
O código de chamada imprime Uploaded somente depois que o S3 retorna HTTP 200.
Fazer upload para uma chave existente substitui seu objeto atual; um bucket com versionamento
mantém outra versão. Use uma chave nova no primeiro teste.
Verificar os bytes remotos
Salve esta verificação independente de download como verify.sh. Ela usa a mesma
fonte de credenciais da AWS CLI, baixa a chave exata, compara cada byte com o arquivo local e
remove sua cópia temporária tanto em caso de sucesso quanto de falha:
#!/usr/bin/env bash
set -eu
: "${AWS_REGION:?Set AWS_REGION to the bucket region}"
: "${S3_BUCKET:?Set S3_BUCKET to the bucket name}"
test "$#" -eq 2
test -f "$1"
download=$(mktemp)
trap 'rm -f "$download"' EXIT
aws s3api get-object --bucket "$S3_BUCKET" --key "$2" --region "$AWS_REGION" \
"$download" >/dev/null
cmp -- "$1" "$download"
printf 'Verified identical bytes\n'
AWS_REGION=us-east-1 S3_BUCKET=your-bucket-name \
bash verify.sh report.bin 'reports/September café %.bin'
O resultado esperado é Verified identical bytes. Um objeto ausente, um download negado ou bytes diferentes resultam, em vez disso, no encerramento do processo com um código de saída diferente de zero. Mantenha o arquivo de origem inalterado entre o upload e a verificação. Uma operação PutObject bem-sucedida armazena o corpo completo da requisição, mas essa comparação também verifica se você forneceu o arquivo e a chave pretendidos.
Tratamento de erros e retentativas
Somente HTTP 200 confirma esta
operação PutObject.
Redirecionamentos, falhas de autenticação e outros erros HTTP permanentes interrompem a execução
imediatamente. Determinados erros transitórios de transporte, limitações de taxa e erros do serviço
permitem no máximo quatro tentativas, cada uma com os limites de tempo acima. Falhas de validação
de certificados não geram retentativas; corrija o conjunto de certificados de CA ou o endpoint,
em vez de desabilitar a validação. Uma entrada ausente ou um arquivo acima do limite causa uma
falha antes de qualquer upload. O intervalo usa
socket.sleep() com frações de segundo; ele nunca constrói um comando de shell nem formata
uma fração com um marcador para números inteiros.
Cada retentativa assina uma nova requisição com os mesmos bytes e a mesma chave. Uma resposta
perdida ainda pode criar várias versões em um bucket com versionamento ou repetir notificações.
Reconcilie resultados ambíguos quando esses efeitos forem relevantes. Este exemplo com limites
definidos não implementa limitação de taxa adaptativa, tratamento de
Retry-After, renovação automática de credenciais nem entrega exatamente uma vez.
Tratamento do tipo de conteúdo
O código de chamada usa application/octet-stream. Para armazenar o tipo de conteúdo de um PDF,
substitua essa string em upload.lua por application/pdf; os argumentos de arquivo e
chave permanecem iguais. Isso define os metadados do objeto e não valida o conteúdo do arquivo.
O módulo rejeita caracteres de controle nos valores dos cabeçalhos e nunca retorna o corpo da
resposta do serviço como erro.
Conclusão
Execute o upload e a comparação de bytes com uma chave nova no seu bucket de teste autorizado antes de integrar o módulo. Para arquivos acima do limite de memória deste exemplo, escolha um cliente de streaming ou multipart e verifique separadamente seu comportamento de conclusão; aumentar o limite ainda manteria o arquivo inteiro em um buffer na memória do Lua.
