DEV Community

Automação de Backups para SFTP Externo

Visão Geral

Este tutorial documenta uma solução completa e robusta para a transferência automática de backups do Amazon S3 (arquivos dump do PostgreSQL, entre 5 GB e 10 GB) para servidores SFTP externos utilizando uma infraestrutura serverless da AWS em conjunto com uma instância EC2 como ponte de processamento.

Arquitetura da Solução

O sistema implementa um fluxo de trabalho event-driven que:

  1. Detecta automaticamente novos backups depositados no S3
  2. Valida e filtra arquivos baseado em critérios específicos (prefixo, sufixo, nome)
  3. Aciona uma função Lambda que dispara comandos remotos via AWS Systems Manager
  4. Processa o download seguro do S3 para uma instância EC2 (EC2 evita o timeout de execução da Lambda)
  5. Transfere os arquivos para o servidor SFTP com verificação de integridade (EC2 para evitar o timeout)
  6. Garante confiabilidade através de retry automático, validação MD5 e logs detalhados

Características Principais

  • Upload resiliente: Retry automático com backoff configurável
  • Integridade garantida: Verificação MD5 e validação de tamanho
  • Upload multipart: Transferência otimizada para arquivos grandes (chunks de 64KB)
  • Temporary naming: Upload com extensão .part antes da finalização
  • Logs completos: Rastreamento detalhado em /var/log/sftp_uploader/
  • Configuração flexível: Variáveis de ambiente via .env
  • Autenticação múltipla: Suporte para senha ou chave SSH (RSA/OpenSSH)

Fluxo de Operação

S3 Event (PUT) → Lambda (filtro) → SSM Run Command → EC2 (download S3) 
→ Validação Local → Upload SFTP (com retry) → Verificação MD5 → Conclusão
Enter fullscreen mode Exit fullscreen mode

1. Criação da Lambda

  1. No console AWS Lambda: Create function → Author from scratch
    • Name: BackupTransferLambda
    • Runtime: Python 3.13
    • Role: Crie uma role com permissões SSM SendCommand e S3 read.
  2. Configure environment variables se necessário.
  3. Faça upload do código da Lambda (arquivo lambda_function.py fornecido).

2. Configuração da Trigger do S3

  1. No bucket S3 company-backups-bucketProperties → Event notifications → Create Event Notification
  2. Configurações:
    • Name: TriggerBackupUpload
    • Event type: PUT
    • Prefix: db/daily/
    • Suffix: .dump
    • Destination: Lambda functionBackupTransferLambda
  3. Salve.

3. Permissões Necessárias

EC2 Role (Alterar o nome do bucket)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject","s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::company-backups-bucket",
        "arn:aws:s3:::company-backups-bucket/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": ["ssm:SendCommand","ssm:GetCommandInvocation"],
      "Resource": "*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Lambda Role (Alterar id da conta AWS, região e instancia)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ssm:SendCommand",
        "ssm:GetCommandInvocation"
      ],
      "Resource": [
        "arn:aws:ec2:us-east-1:123456789012:instance/i-XXXXXXXXXX",
        "arn:aws:ssm:us-east-1::document/AWS-RunShellScript"
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

4. Preparação da EC2 Ubuntu 24.04 e Testes

sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-pip python3-venv unzip curl
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
aws --version

cd /opt
sudo mkdir sftp_uploader_app
sudo chown ubuntu:ubuntu sftp_uploader_app
cd sftp_uploader_app
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install paramiko python-dotenv boto3
python3 -c "import paramiko; import boto3; import dotenv; print('Módulos OK')"
Enter fullscreen mode Exit fullscreen mode

5. Estrutura de Pastas

/opt/sftp_uploader_app/
├── sftp_uploader.py
├── sftp_uploader_main.py
├── logrotate_sftp_uploader
├── sftp-uploader.service
├── sftp-uploader.timer
├── id_rsa
├── .env
└── venv/
Enter fullscreen mode Exit fullscreen mode
  • /var/tmp/sftp_uploader → temporário
  • /var/log/sftp_uploader → logs
sudo mkdir -p /var/tmp/sftp_uploader
sudo mkdir -p /var/log/sftp_uploader
sudo chown -R ubuntu:ubuntu /var/tmp/sftp_uploader /var/log/sftp_uploader
Enter fullscreen mode Exit fullscreen mode

6. Configuração do Arquivo .env

Crie o arquivo /opt/sftp_uploader_app/.env com o seguinte conteúdo:

# Configuração SFTP
SFTP_HOST=sftp.external-server.com
SFTP_PORT=22
SFTP_USER=transfer_user
SFTP_PASSWORD=your_secure_password_here
SFTP_KEY_PATH=/opt/sftp_uploader_app/id_rsa
SFTP_KEY_PASSPHRASE=your_key_passphrase

# Pasta local (temporária para download S3)
LOCAL_DIR=/var/tmp/sftp_uploader

# Pasta remota SFTP
REMOTE_DIR=/

# Apagar arquivos locais após envio
DELETE_AFTER_UPLOAD=True

# Número de tentativas em caso de falha
RETRY_ATTEMPTS=3
RETRY_DELAY_SECONDS=10

# Logs
LOG_DIR=/var/log/sftp_uploader

# MD5 para verificação de integridade
VERIFY_MD5=True

# Bucket S3
S3_BUCKET_NAME=company-backups-bucket
Enter fullscreen mode Exit fullscreen mode

7. Como descompactar o zip atualizado

cd /opt
sudo unzip sftp_uploader_app.zip -d /opt/sftp_uploader_app
sudo chown -R ubuntu:ubuntu /opt/sftp_uploader_app
Enter fullscreen mode Exit fullscreen mode

Conteúdo do ZIP:

  • env (renomear para .env)
  • id_rsa (chave SSH privada)
  • lambda_handler.py
  • logrotate_sftp_uploader
  • sftp_uploader_main.py
  • sftp_uploader.py
  • sftp-uploader.service
  • sftp-uploader.timer

8. Teste manual e logs

source /opt/sftp_uploader_app/venv/bin/activate
python3 /opt/sftp_uploader_app/sftp_uploader_main.py
Enter fullscreen mode Exit fullscreen mode
  • Logs em /var/log/sftp_uploader/sftp_uploader.log
  • Run Command history na Lambda console

Exemplo de log:

2026-01-07 09:00:01 [INFO] Conectado ao SFTP sftp.external-server.com:22
2026-01-07 09:00:02 [INFO] Processando arquivo: /var/tmp/sftp_uploader/backup.dump -> /
2026-01-07 09:00:05 [INFO] Upload concluído (temp): backup.dump -> /backup.dump.part
2026-01-07 09:00:06 [INFO] Arquivo movido para destino: /backup.dump
2026-01-07 09:00:06 [INFO] Processamento concluído
Enter fullscreen mode Exit fullscreen mode

Log Completo:

2026-01-07 10:54:12,561 [INFO] Baixando s3://company-backups-bucket/db/daily/database_client_backup.dump para /var/tmp/sftp_uploader/database_client_backup.dump
2026-01-07 10:54:12,581 [INFO] Found credentials from IAM Role: ssm
2026-01-07 10:54:36,407 [INFO] Connected (version 2.0, client AWS_SFTP_1.2)
2026-01-07 10:54:36,519 [INFO] Auth banner: b'Welcome to External SFTP Server!\n'
2026-01-07 10:54:36,520 [INFO] Authentication (publickey) successful!
2026-01-07 10:54:36,690 [INFO] [chan 0] Opened sftp connection (server version 3)
2026-01-07 10:54:36,690 [INFO] Conectado ao SFTP sftp.external-server.com:22 (attempt 1)
2026-01-07 10:54:36,691 [INFO] Processando arquivo: /var/tmp/sftp_uploader/database_client_backup.dump -> /
2026-01-07 10:54:42,737 [INFO] database_client_backup.dump - 50.0 MB enviados (1.69%)
2026-01-07 10:54:47,352 [INFO] database_client_backup.dump - 100.0 MB enviados (3.39%)
2026-01-07 10:54:52,081 [INFO] database_client_backup.dump - 150.0 MB enviados (5.08%)
[...]
2026-01-07 10:59:36,365 [INFO] Upload concluído (temp): /var/tmp/sftp_uploader/database_client_backup.dump -> /database_client_backup.dump.part
2026-01-07 11:26:07,525 [INFO] Arquivo movido para destino: /database_client_backup.dump
2026-01-07 11:26:07,676 [INFO] Arquivo local removido após upload: /var/tmp/sftp_uploader/database_client_backup.dump
2026-01-07 11:26:07,676 [INFO] Processamento concluído
2026-01-07 11:26:07,676 [INFO] [chan 0] sftp session closed.
Enter fullscreen mode Exit fullscreen mode

9. Troubleshooting

  • Chave PPK não funciona: Paramiko precisa de chave OpenSSH (.pem ou sem conversão). Converta: puttygen chave.ppk -O private-openssh -o id_rsa
  • Permissões: chmod 600 id_rsa
  • Upload falha: Verifique prefix/suffix S3 e paths locais/remotos
  • SSM não executa: Confirme role da Lambda e permissões da EC2
  • Timeout: Ajuste TimeoutSeconds na Lambda ou banner_timeout no Paramiko
  • MD5 não bate: Verifique se o arquivo foi completamente transferido ou se houve corrupção

Detalhes Técnicos da Implementação

Processo de Upload Seguro

  1. Download do S3: Arquivo baixado para /var/tmp/sftp_uploader/
  2. Filtro: Apenas arquivos contendo palavra-chave no nome são processados (configurável)
  3. Upload temporário: Arquivo enviado com extensão .part
  4. Validações:
    • Comparação de tamanho (bytes locais vs remotos)
    • Checksum MD5 (local vs remoto)
  5. Rename atômico: .part → nome final
  6. Cleanup opcional: Remoção do arquivo local se DELETE_AFTER_UPLOAD=True

Mecanismo de Retry

  • Tentativas configuráveis via RETRY_ATTEMPTS (padrão: 3)
  • Delay entre tentativas: RETRY_DELAY_SECONDS (padrão: 10s)
  • Retry aplicado em:
    • Conexão SFTP
    • Upload de arquivo
    • Validação MD5

Logs e Monitoramento

  • Logs da Lambda: CloudWatch Logs
  • Logs SSM: Run Command history no console AWS Systems Manager
  • Logs EC2: /var/log/sftp_uploader/sftp_uploader.log
  • Formato de log: timestamp [LEVEL] mensagem

Opção 2: Lambda com Conexão Direta SSH à EC2

Esta opção remove o SSM Run Command e estabelece uma conexão SSH direta da Lambda para a EC2 através de uma interface de rede na mesma VPC.

1. Configurar SSH no EC2

  1. Criar arquivo de configuração para liberar login por senha apenas para o usuário sftp_user:
sudo tee /etc/ssh/sshd_config.d/10-password-auth.conf > /dev/null <<'EOF'
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes

Match User sftp_user
    PasswordAuthentication yes
    AuthenticationMethods password
EOF
Enter fullscreen mode Exit fullscreen mode
  1. Reiniciar o serviço SSH:
sudo systemctl restart ssh
Enter fullscreen mode Exit fullscreen mode
  1. Validar se as diretivas foram aplicadas:
sudo sshd -T | grep -E 'password|challenge|usepam'
Enter fullscreen mode Exit fullscreen mode

2. Criar usuário sftp_user

#!/bin/bash
#############################################
# Script de Criação de Usuário Administrativo
#############################################

set -e

if [ "$EUID" -ne 0 ]; then
    echo "❌ Este script precisa ser executado como root (sudo)"
    exit 1
fi

echo "📝 Criando novo usuário administrativo..."
read -p "Nome do usuário: " username

if [ -z "$username" ]; then
    echo "❌ Nome de usuário não pode ser vazio!"
    exit 1
fi

if id "$username" &>/dev/null; then
    echo "⚠️ Usuário já existe."
    USER_EXISTS=true
else
    USER_EXISTS=false
fi

echo ""
echo "🔐 Configuração de senha"
while true; do
    read -sp "Senha: " password
    echo ""
    read -sp "Confirme: " confirm
    echo ""
    [ "$password" = "$confirm" ] && break
    echo "Senhas não coincidem!"
done

if [ "$USER_EXISTS" = false ]; then
    useradd -m -s /bin/bash "$username"
fi

echo "$username:$password" | chpasswd

for grp in sudo wheel adm systemd-journal; do
    if getent group "$grp" >/dev/null; then
        usermod -aG "$grp" "$username"
    fi
done

SUDOERS_FILE="/etc/sudoers.d/$username"
cat > "$SUDOERS_FILE" <<INNER_EOF
$username ALL=(ALL:ALL) NOPASSWD: ALL
INNER_EOF

chmod 0440 "$SUDOERS_FILE"
visudo -c -f "$SUDOERS_FILE" >/dev/null && echo "✓ Sudo configurado"

echo "✅ Usuário $username criado com sucesso!"
Enter fullscreen mode Exit fullscreen mode

Execute o script /tmp/create_user.sh para gerar o usuário.


3. Configuração de rede e segurança

  1. Adicione política à Lambda para conseguir gerar uma interface de rede:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateNetworkInterface",
                "ec2:DescribeNetworkInterfaces",
                "ec2:DeleteNetworkInterface"
            ],
            "Resource": "*"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode
  1. Criar interface de rede (ENI) da Lambda na mesma VPC da EC2.
  2. No Security Group da EC2, liberar porta 22 somente para o SG da Lambda.

4. Preparar Lambda

  1. Alterar no handler os dados de conexão:
EC2_HOST = "<IP_PRIVADO_DA_EC2>"
EC2_USER = "sftp_user"
EC2_PASSWORD = "<SENHA_DO_USUARIO>"
Enter fullscreen mode Exit fullscreen mode
  1. Deploy da Lambda usando o arquivo ZIP com as bibliotecas necessárias (paramiko, etc)

Resumo

  • SSH do EC2 permite login por senha apenas para sftp_user.
  • Lambda conecta usando usuário e senha definidos no handler.
  • Porta 22 liberada apenas para a Lambda na VPC.

Arquivos de Código

lambda_function.py (Opção 1: SSM)

import boto3
import logging

# Configura logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Configurações
EC2_INSTANCE_ID = "i-XXXXXXXXXX"
S3_BUCKET = "company-backups-bucket"
S3_PREFIX = "db/daily/"

# Cliente SSM
ssm = boto3.client('ssm')

def lambda_handler(event, context):
    try:
        # Obter arquivo do evento S3
        record = event['Records'][0]
        bucket = record['s3']['bucket']['name']
        key = record['s3']['object']['key']

        logger.info(f"Evento recebido do S3: bucket={bucket}, key={key}")

        # Verifica se é do bucket e prefixo correto
        if bucket != S3_BUCKET or not key.startswith(S3_PREFIX):
            logger.info(f"Ignorando arquivo fora do prefixo/bucket: {key}")
            return {"status": "ignored", "key": key}

        # Verifica se contém palavra-chave
        if 'client_backup' not in key:
            logger.info(f"Ignorando arquivo (não contém 'client_backup'): {key}")
            return {"status": "ignored", "key": key}

        # Verifica EC2 definido
        if not EC2_INSTANCE_ID:
            logger.error("EC2_INSTANCE_ID não definido!")
            return {"status": "error", "message": "EC2_INSTANCE_ID não definido"}

        # Comando a ser executado na EC2
        command = f"/opt/sftp_uploader_app/venv/bin/python3 /opt/sftp_uploader_app/sftp_uploader.py --s3-file s3://{bucket}/{key}"
        logger.info(f"Enviando comando SSM para EC2 {EC2_INSTANCE_ID}: {command}")

        # Envia comando SSM
        response = ssm.send_command(
            Targets=[{'Key': 'InstanceIds', 'Values': [EC2_INSTANCE_ID]}],
            DocumentName="AWS-RunShellScript",
            Parameters={'commands': [command]},
            TimeoutSeconds=900  # 15 minutos
        )

        command_id = response['Command']['CommandId']
        logger.info(f"Comando enviado com sucesso! CommandId: {command_id}")
        return {"status": "command_sent", "command_id": command_id, "key": key}

    except Exception as e:
        logger.exception(f"Erro ao processar evento S3: {e}")
        return {"status": "error", "message": str(e)}
Enter fullscreen mode Exit fullscreen mode

lambda_function.py (Opção 2: SSH Direto)

import json
import paramiko
import time

# ============================
# CONFIGURAÇÕES FIXAS
# ============================
EC2_HOST = "10.0.1.100"  # IP privado da EC2
EC2_USER = "sftp_user"
EC2_PASSWORD = "your_secure_password"

# Caminhos no servidor EC2
PYTHON_PATH = "/opt/sftp_uploader_app/venv/bin/python3"
SCRIPT_PATH = "/opt/sftp_uploader_app/sftp_uploader.py"
LOG_PATH = "/var/log/sftp_trigger.log"
SFTP_LOG_DIR = "/var/log/sftp_uploader"
SFTP_LOG_FILE = "/var/log/sftp_uploader/sftp_uploader.log"
TEMP_DIR = "/var/tmp/sftp_uploader"

def should_process_file(filename):
    """
    Verifica se o arquivo deve ser processado.
    """
    filename_lower = filename.lower()

    if not filename_lower.endswith('.dump'):
        return False, "Arquivo não termina com .dump"

    if 'client_backup' not in filename_lower:
        return False, "Arquivo não contém 'client_backup' no nome"

    return True, "OK"

def lambda_handler(event, context):
    ssh = None
    try:
        # Extrai informações do evento S3
        record = event['Records'][0]
        bucket = record['s3']['bucket']['name']
        key = record['s3']['object']['key']
        s3_file = f"s3://{bucket}/{key}"

        filename = key.split('/')[-1]

        print(f"[INFO] Arquivo detectado: {s3_file}")
        print(f"[INFO] Nome do arquivo: {filename}")

        # Validação
        should_process, reason = should_process_file(filename)

        if not should_process:
            message = f"Arquivo ignorado: {reason}"
            print(f"[INFO] {message}")
            return {
                "statusCode": 200,
                "body": json.dumps({
                    "message": message,
                    "s3_file": s3_file,
                    "filename": filename,
                    "processed": False
                })
            }

        print(f"[INFO] Conectando na EC2 {EC2_HOST}...")

        # Conexão SSH
        ssh = paramiko.SSHClient()
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        ssh.connect(
            hostname=EC2_HOST,
            username=EC2_USER,
            password=EC2_PASSWORD,
            timeout=20,
        )

        print("[INFO] Conexão SSH estabelecida com sucesso")

        # Preparar diretórios
        setup_commands = f"""
mkdir -p {SFTP_LOG_DIR}
chmod 777 {SFTP_LOG_DIR}
touch {SFTP_LOG_FILE}
chmod 666 {SFTP_LOG_FILE}
mkdir -p {TEMP_DIR}
chmod 777 {TEMP_DIR}
"""

        print("[INFO] Configurando diretórios...")
        stdin, stdout, stderr = ssh.exec_command(setup_commands)
        stdout.channel.recv_exit_status()

        # Monta comando
        command = (
            f"nohup {PYTHON_PATH} {SCRIPT_PATH} "
            f"--s3-file '{s3_file}' >> {LOG_PATH} 2>&1 & "
            f"echo $!"
        )

        print(f"[INFO] Executando comando: {command}")

        # Executa comando e captura PID
        stdin, stdout, stderr = ssh.exec_command(command)
        pid = stdout.read().decode().strip()

        if pid and pid.isdigit():
            print(f"[INFO] Processo iniciado com PID: {pid}")
            status_message = f"Processo iniciado com sucesso (PID: {pid})"
        else:
            print(f"[ERROR] Falha ao obter PID")
            status_message = "Falha ao iniciar processo"

        ssh.close()

        return {
            "statusCode": 200,
            "body": json.dumps({
                "message": status_message,
                "s3_file": s3_file,
                "filename": filename,
                "pid": pid if pid and pid.isdigit() else None,
                "processed": True
            })
        }

    except Exception as e:
        error_msg = f"Erro: {str(e)}"
        print(f"[ERROR] {error_msg}")
        import traceback
        print(f"[ERROR] Traceback:\n{traceback.format_exc()}")
        return {
            "statusCode": 500,
            "body": json.dumps({"error": error_msg})
        }

    finally:
        if ssh:
            try:
                ssh.close()
            except:
                pass
Enter fullscreen mode Exit fullscreen mode

sftp_uploader.py

#!/usr/bin/env python3
"""
sftp_uploader.py - Upload de arquivos grandes para SFTP com download do S3
"""
import os
import sys
import time
import hashlib
import logging
from pathlib import Path
from dotenv import load_dotenv
import paramiko
import argparse
import boto3

# Carrega .env
ENV_PATH = Path(__file__).parent / '.env'
if not ENV_PATH.exists():
    ENV_PATH = Path('/opt/sftp_uploader_app/.env')
load_dotenv(dotenv_path=str(ENV_PATH))

# Configurações
SFTP_HOST = os.getenv('SFTP_HOST')
SFTP_PORT = int(os.getenv('SFTP_PORT', '22'))
SFTP_USER = os.getenv('SFTP_USER')
SFTP_PASSWORD = os.getenv('SFTP_PASSWORD') or None
SFTP_KEY_PATH = os.getenv('SFTP_KEY_PATH') or None
SFTP_KEY_PASSPHRASE = os.getenv('SFTP_KEY_PASSPHRASE') or None
LOCAL_DIR = Path(os.getenv('LOCAL_DIR', '/var/tmp/sftp_uploader'))
REMOTE_DIR = os.getenv('REMOTE_DIR', '/')
DELETE_AFTER_UPLOAD = os.getenv('DELETE_AFTER_UPLOAD', 'False').lower() in ('1','true','yes')
RETRY_ATTEMPTS = int(os.getenv('RETRY_ATTEMPTS', '3'))
RETRY_DELAY_SECONDS = int(os.getenv('RETRY_DELAY_SECONDS', '10'))
LOG_DIR = Path(os.getenv('LOG_DIR', '/var/log/sftp_uploader'))
TMP_DIR = Path(os.getenv('TMP_DIR', '/var/tmp/sftp_uploader'))
VERIFY_MD5 = os.getenv('VERIFY_MD5', 'True').lower() in ('1','true','yes')

LOG_DIR.mkdir(parents=True, exist_ok=True)
TMP_DIR.mkdir(parents=True, exist_ok=True)
LOCAL_DIR.mkdir(parents=True, exist_ok=True)

# Logging
LOG_FILE = LOG_DIR / 'sftp_uploader.log'
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler(LOG_FILE),
        logging.StreamHandler(sys.stdout)
    ]
)
logger = logging.getLogger('sftp_uploader')

# Helpers
def md5_of_file(path: Path):
    h = hashlib.md5()
    with open(path, 'rb') as f:
        for chunk in iter(lambda: f.read(65536), b''):
            h.update(chunk)
    return h.hexdigest()

def md5_of_remote(sftp, remote_path):
    h = hashlib.md5()
    try:
        with sftp.open(remote_path, 'rb') as rf:
            for chunk in iter(lambda: rf.read(65536), b''):
                h.update(chunk)
        return h.hexdigest()
    except Exception as e:
        logger.warning(f'Falha ao calcular MD5 remoto {remote_path}: {e}')
        return None

def connect_sftp():
    last_exc = None
    for attempt in range(1, RETRY_ATTEMPTS + 1):
        try:
            transport = paramiko.Transport((SFTP_HOST, SFTP_PORT))
            transport.banner_timeout = 600
            transport.connect_timeout = 600
            if SFTP_KEY_PATH and Path(SFTP_KEY_PATH).exists():
                key = paramiko.RSAKey.from_private_key_file(SFTP_KEY_PATH, password=SFTP_KEY_PASSPHRASE)
                transport.connect(username=SFTP_USER, pkey=key)
            else:
                transport.connect(username=SFTP_USER, password=SFTP_PASSWORD)
            sftp = paramiko.SFTPClient.from_transport(transport)
            sftp.get_channel().settimeout(600)
            logger.info(f'Conectado ao SFTP {SFTP_HOST}:{SFTP_PORT} (attempt {attempt})')
            return transport, sftp
        except Exception as e:
            last_exc = e
            logger.warning(f'Falha conexão SFTP (attempt {attempt}): {e}')
            time.sleep(RETRY_DELAY_SECONDS)
    raise last_exc

def ensure_remote_dir(sftp, remote_path):
    parts = Path(remote_path).parts
    cur = ''
    for p in parts:
        if p == '/':
            continue
        cur = cur + '/' + p
        try:
            sftp.stat(cur)
        except IOError:
            try:
                sftp.mkdir(cur)
                logger.info(f'Criado dir remoto: {cur}')
            except Exception as e:
                logger.warning(f'Não foi possível criar {cur}: {e}')

def upload_file(sftp, local_path: Path, remote_dir: str):
    remote_name = local_path.name
    remote_tmp = remote_dir.rstrip('/') + '/' + remote_name + '.part'
    remote_final = remote_dir.rstrip('/') + '/' + remote_name
    last_exc = None
    for attempt in range(1, RETRY_ATTEMPTS + 1):
        try:
            filesize = local_path.stat().st_size
            with open(local_path, 'rb') as f:
                with sftp.open(remote_tmp, 'wb') as rf:
                    transferred = 0
                    while True:
                        data = f.read(65536)
                        if not data:
                            break
                        rf.write(data)
                        transferred += len(data)
                        if transferred % (50*1024*1024) < 65536:
                            logger.info(f"{local_path.name} - {transferred/1024/1024:.1f} MB enviados ({transferred/filesize*100:.2f}%)")
            logger.info(f'Upload concluído (temp): {local_path} -> {remote_tmp}')
            r_size = sftp.stat(remote_tmp).st_size
            if r_size != filesize:
                raise IOError(f'Tamanho diferente após upload (local={filesize}, remoto={r_size})')
            if VERIFY_MD5:
                local_md5 = md5_of_file(local_path)
                remote_md5 = md5_of_remote(sftp, remote_tmp)
                if remote_md5 != local_md5:
                    raise IOError(f'MD5 inválido (local={local_md5}, remoto={remote_md5})')
            try:
                sftp.rename(remote_tmp, remote_final)
            except Exception:
                try: sftp.remove(remote_final)
                except Exception: pass
                sftp.rename(remote_tmp, remote_final)
            logger.info(f'Arquivo movido para destino: {remote_final}')
            return True
        except Exception as e:
            last_exc = e
            logger.warning(f'Falha upload (attempt {attempt}): {e}')
            try: sftp.remove(remote_tmp)
            except Exception: pass
            time.sleep(RETRY_DELAY_SECONDS)
    logger.error(f'Upload definitivamente falhou: {local_path} - {last_exc}')
    return False

# Argumentos CLI
parser = argparse.ArgumentParser()
parser.add_argument("--s3-file", help="Arquivo S3 no formato s3://bucket/key")
args = parser.parse_args()

# Se fornecido --s3-file, baixa o arquivo
if args.s3_file:
    if not args.s3_file.startswith("s3://"):
        logger.error(f"Formato S3 inválido: {args.s3_file}")
        sys.exit(1)
    bucket, key = args.s3_file[5:].split("/", 1)
    local_path = LOCAL_DIR / Path(key).name
    logger.info(f"Baixando {args.s3_file} para {local_path}")
    s3 = boto3.client("s3")
    s3.download_file(bucket, key, str(local_path))

# Processamento
def process_once():
    transport = None
    sftp = None
    try:
        transport, sftp = connect_sftp()
        ensure_remote_dir(sftp, REMOTE_DIR)
        for root, dirs, files in os.walk(LOCAL_DIR):
            for fname in files:
                if "client_backup" not in fname:
                    logger.info(f"Arquivo ignorado (não contém 'client_backup'): {fname}")
                    continue
                local_path = Path(root) / fname
                rel = local_path.relative_to(LOCAL_DIR)
                remote_subdir = REMOTE_DIR.rstrip('/') + '/' + str(rel.parent).replace('\\','/') if str(rel.parent) != '.' else REMOTE_DIR
                ensure_remote_dir(sftp, remote_subdir)
                logger.info(f'Processando arquivo: {local_path} -> {remote_subdir}')
                ok = upload_file(sftp, local_path, remote_subdir)
                if ok and DELETE_AFTER_UPLOAD:
                    try:
                        local_path.unlink()
                        logger.info(f'Arquivo local removido após upload: {local_path}')
                    except Exception as e:
                        logger.warning(f'Falha ao remover local {local_path}: {e}')
        logger.info('Processamento concluído')
    except Exception as e:
        logger.error(f'Erro no processo: {e}')
    finally:
        if sftp: 
            try: sftp.close()
            except Exception: pass
        if transport:
            try: transport.close()
            except Exception: pass

if __name__ == '__main__':
    process_once()
Enter fullscreen mode Exit fullscreen mode

logrotate_sftp_uploader

/var/log/sftp_uploader/sftp_uploader.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    copytruncate
}
Enter fullscreen mode Exit fullscreen mode

sftp-uploader.service

[Unit]
Description=SFTP Uploader Service
After=network.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/sftp_uploader_app/sftp_uploader.py
User=root
Group=root
EnvironmentFile=/opt/sftp_uploader_app/.env
TimeoutStartSec=1800
Enter fullscreen mode Exit fullscreen mode

sftp-uploader.timer

[Unit]
Description=Run SFTP uploader periodically

[Timer]
OnBootSec=2min
OnUnitActiveSec=60min
Unit=sftp-uploader.service

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

Conclusão

Este sistema fornece uma solução robusta e escalável para transferência automatizada de backups do S3 para servidores SFTP externos, com alta confiabilidade através de validação MD5, retry automático e logs detalhados.

As duas opções apresentadas (SSM Run Command e SSH direto) oferecem flexibilidade dependendo das necessidades de segurança e arquitetura da sua infraestrutura.

Top comments (0)