SFTP con Go: Conexiones, Rendimiento y Estrategias de Búsqueda para Producción
Aprende a construir clientes SFTP en Go con pooling de conexiones, transferencias seguras, búsqueda eficiente de archivos y patrones production-ready. Incluye comandos CLI y mejores prácticas.
El problema: SSH está en todos lados, SFTP es mal entendido
Imagina que eres operador manejando 50 servidores. Necesitas recuperar logs de un servidor específico, encontrar archivos que matcheen un patrón y transferirlos de vuelta a tu sistema para análisis.
Podrías SSH al servidor y usar find y scp. Es lento. Es manual. Es propenso a errores.
O podrías escribir un programa en Go que se conecte a 50 servidores simultáneamente, busque archivos que matcheen en paralelo y transferir todos a la vez. Mismos datos. Experiencia vastamente diferente.
SFTP (SSH File Transfer Protocol) es la solución. Está construido sobre SSH. Está instalado en todos lados. Es seguro. Pero la mayoría de desarrolladores lo tratan como una herramienta simple de transferencia de archivos.
Usado correctamente, SFTP es una poderosa abstracción para operaciones remotas de archivos que escala a miles de conexiones concurrentes.
Esta guía te enseña cómo.
Parte 1: Entendiendo SFTP — La base
SFTP no es FTP con SSH. Es un protocolo completamente diferente construido sobre SSH.
¿Por qué SFTP, no SSH SCP?
SSH SCP (Secure Copy):
- Protocolo simple
- Una transferencia de archivo a la vez
- Usa comando SSH externo
- Bueno para movimiento ad-hoc de archivos
- Terrible para automatización
SFTP:
- Protocolo rico con operaciones de archivo
- Múltiples transferencias concurrentes
- Soporte de librería built-in (golang.org/x/crypto/ssh)
- Traversal de directorio
- Operaciones de archivo stat/chmod/delete
- Reuso de conexión
- Bueno para sistemas de producción
Cómo funciona SFTP
Tu Programa
↓
Conexión SSH (encriptada)
↓
Subsistema SFTP en servidor remoto
↓
Operaciones de archivo (leer, escribir, listar, eliminar)
La conexión SSH es el transporte. SFTP es el protocolo ejecutándose encima.
Parte 2: Construyendo tu primera conexión SFTP
El enfoque ingenuo es abrir una conexión, hacer una operación, cerrar. Funciona para tareas únicas. Es terrible para operaciones repetidas.
El enfoque ingenuo (No hagas esto)
import (
"golang.org/x/crypto/ssh"
"github.com/pkg/sftp"
)
func downloadFile(host, username, password, remotePath string) error {
// Dial SSH
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host+":22", config)
if err != nil {
return err
}
defer client.Close()
// Abrir sesión SFTP
session, err := sftp.NewClient(client)
if err != nil {
return err
}
defer session.Close()
// Descargar archivo
srcFile, err := session.Open(remotePath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create("localfile.txt")
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
Funciona. Pero crea una nueva conexión SSH para cada operación. A escala, esto es lento.
Parte 3: Connection Pooling — El enfoque correcto
Los sistemas de producción necesitan reuso de conexión. Abrir una conexión SSH es caro (intercambio de claves, autenticación, handshake). Quieres abrir una vez, usar muchas veces.
Construyendo un pool de conexiones
// sftppool/pool.go
package sftppool
import (
"fmt"
"sync"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// Connection representa una conexión SFTP reutilizable
type Connection struct {
ssh *ssh.Client
sftp *sftp.Client
closed bool
}
// ConnectionPool gestiona múltiples conexiones SFTP
type ConnectionPool struct {
host string
config *ssh.ClientConfig
mu sync.RWMutex
conns []*Connection
maxConns int
inUse int
}
// NewPool crea un nuevo pool de conexiones
func NewPool(host string, config *ssh.ClientConfig, maxConns int) *ConnectionPool {
return &ConnectionPool{
host: host,
config: config,
conns: make([]*Connection, 0, maxConns),
maxConns: maxConns,
}
}
// Get obtiene o crea una conexión del pool
func (p *ConnectionPool) Get() (*Connection, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Intenta reusar conexión existente
for i, conn := range p.conns {
if !conn.closed {
p.conns = append(p.conns[:i], p.conns[i+1:]...)
p.inUse++
return conn, nil
}
}
// Crea nueva conexión si bajo límite
if p.inUse < p.maxConns {
sshClient, err := ssh.Dial("tcp", p.host+":22", p.config)
if err != nil {
return nil, fmt.Errorf("ssh dial: %w", err)
}
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
sshClient.Close()
return nil, fmt.Errorf("sftp new client: %w", err)
}
p.inUse++
return &Connection{
ssh: sshClient,
sftp: sftpClient,
}, nil
}
return nil, fmt.Errorf("pool agotado")
}
// Return retorna una conexión al pool
func (p *ConnectionPool) Return(conn *Connection) {
p.mu.Lock()
defer p.mu.Unlock()
if conn.closed {
p.inUse--
return
}
p.conns = append(p.conns, conn)
p.inUse--
}
// Close cierra todas las conexiones
func (p *ConnectionPool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
for _, conn := range p.conns {
conn.Close()
}
p.conns = nil
return nil
}
// Close cierra una sola conexión
func (c *Connection) Close() error {
c.closed = true
c.sftp.Close()
return c.ssh.Close()
}
Ahora las operaciones reusan conexiones:
// Uso
pool := NewPool("example.com", config, 10) // Max 10 conexiones concurrentes
defer pool.Close()
// Obtén conexión del pool
conn, err := pool.Get()
if err != nil {
panic(err)
}
defer pool.Return(conn)
// Usa conexión
file, err := conn.sftp.Open("/path/to/file.txt")
// ... haz operaciones ...
Parte 4: Estrategias de búsqueda de archivos — Rápido vs profundo
Buscar 100,000 archivos en un servidor remoto es diferente de buscar localmente.
Estrategia 1: Búsqueda solo por nombre (La más rápida)
Cuando solo necesitas matchear nombres de archivo, lista directorio y filtra:
// SearchByName retorna archivos que matchean un patrón de nombre
func SearchByName(client *sftp.Client, dir, pattern string) ([]string, error) {
var results []string
// Camina árbol de directorio
err := client.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Matchea nombre de archivo contra patrón
if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
results = append(results, path)
}
return nil
})
return results, err
}
// Uso
files, err := SearchByName(sftpClient, "/var/log", "*.log")
Esto es O(n) donde n = número de archivos en el árbol. Rápido porque solo hace llamadas stat.
Estrategia 2: Búsqueda paralela en directorios
Cuando buscas múltiples directorios top-level, busca en paralelo:
// ParallelSearch busca múltiples directorios concurrentemente
func ParallelSearch(client *sftp.Client, dirs []string, pattern string, workers int) ([]string, error) {
// Crea cola de trabajo
workChan := make(chan string, 100)
resultChan := make(chan string, 1000)
errChan := make(chan error, workers)
// Inicia workers
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for dir := range workChan {
err := searchDir(client, dir, pattern, resultChan)
if err != nil {
errChan <- err
}
}
}()
}
// Envía trabajo
go func() {
for _, dir := range dirs {
workChan <- dir
}
close(workChan)
}()
// Recolecta resultados
var results []string
go func() {
wg.Wait()
close(resultChan)
}()
for result := range resultChan {
results = append(results, result)
}
return results, nil
}
func searchDir(client *sftp.Client, dir, pattern string, results chan<- string) error {
return client.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Salta errores, continúa buscando
}
if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
results <- path
}
return nil
})
}
// Uso
files, err := ParallelSearch(sftpClient, []string{"/var/log", "/home", "/opt"}, "*.txt", 4)
Esto busca 4 directorios simultáneamente. 4x más rápido que secuencial.
Estrategia 3: Búsqueda de contenido (La más lenta, la más poderosa)
Cuando necesitas buscar contenidos de archivos:
// SearchContents busca contenidos de archivos para un patrón
func SearchContents(client *sftp.Client, dir, filePattern, contentPattern string) ([]string, error) {
var results []string
re := regexp.MustCompile(contentPattern)
return results, client.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Salta directorios y nombres de archivo que no matchean
if info.IsDir() {
return nil
}
if matched, _ := filepath.Match(filePattern, filepath.Base(path)); !matched {
return nil
}
// Abre y busca archivo
file, err := client.Open(path)
if err != nil {
return nil
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if re.MatchString(scanner.Text()) {
results = append(results, path)
break // Encontrado match, muévete al siguiente archivo
}
}
return scanner.Err()
})
}
// Uso
// Encuentra archivos en *.log que contienen mensajes de error
files, err := SearchContents(sftpClient, "/var/log", "*.log", "ERROR|FAIL")
Esto lee contenidos de archivos. Lento para archivos grandes. Potente para búsquedas específicas.
Parte 5: Operaciones de archivo seguras — Atomicidad y manejo de errores
Las operaciones de archivo remotas pueden fallar en medio de una transferencia. Necesitas patrones para seguridad.
Cargas atómicas con archivos temp
// SafeUpload carga un archivo de forma atómica usando archivo temp
func SafeUpload(client *sftp.Client, localPath, remotePath string) error {
// Lee archivo local
data, err := os.ReadFile(localPath)
if err != nil {
return fmt.Errorf("read local: %w", err)
}
// Escribe a archivo temp primero
tempPath := remotePath + ".tmp"
tempFile, err := client.Create(tempPath)
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
_, err = tempFile.Write(data)
tempFile.Close()
if err != nil {
client.Remove(tempPath) // Limpia en error
return fmt.Errorf("write temp: %w", err)
}
// Rename atómico
err = client.Rename(tempPath, remotePath)
if err != nil {
client.Remove(tempPath) // Limpia en error
return fmt.Errorf("rename: %w", err)
}
return nil
}
Esto asegura que el archivo destino está completo o no existe. Nunca parcial.
Descargas seguras con checksums
// SafeDownload descarga con verificación de checksum
func SafeDownload(client *sftp.Client, remotePath, localPath string) error {
// Descarga a archivo temp
tempPath := localPath + ".tmp"
tempFile, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
defer tempFile.Close()
remoteFile, err := client.Open(remotePath)
if err != nil {
os.Remove(tempPath)
return fmt.Errorf("open remote: %w", err)
}
defer remoteFile.Close()
// Copia con hash
h := md5.New()
w := io.MultiWriter(tempFile, h)
_, err = io.Copy(w, remoteFile)
if err != nil {
os.Remove(tempPath)
return fmt.Errorf("copy: %w", err)
}
remoteHash := getRemoteChecksum(client, remotePath)
localHash := fmt.Sprintf("%x", h.Sum(nil))
if remoteHash != localHash {
os.Remove(tempPath)
return fmt.Errorf("checksum mismatch: %s != %s", remoteHash, localHash)
}
// Rename atómico
err = os.Rename(tempPath, localPath)
if err != nil {
os.Remove(tempPath)
return fmt.Errorf("rename: %w", err)
}
return nil
}
Esto previene descargas parciales de ser usadas.
Parte 6: Herramienta CLI — Un ejemplo práctico
Construye una herramienta de línea de comandos para operaciones SFTP:
// sftp-tool/main.go
package main
import (
"flag"
"fmt"
"golang.org/x/crypto/ssh"
"github.com/pkg/sftp"
)
func main() {
cmd := flag.NewFlagSet("sftp-tool", flag.ExitOnError)
host := cmd.String("host", "", "Host remoto")
user := cmd.String("user", "", "Usuario")
operation := cmd.String("op", "", "Operación: list, search, download, upload")
remotePath := cmd.String("remote", "", "Ruta remota")
localPath := cmd.String("local", "", "Ruta local")
pattern := cmd.String("pattern", "*", "Patrón de búsqueda")
cmd.Parse(flag.Args())
// Crea config SSH
config := &ssh.ClientConfig{
User: *user,
Auth: []ssh.AuthMethod{
ssh.Password("password"), // Usa auth basada en clave en producción
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
// Conecta
sshClient, err := ssh.Dial("tcp", *host+":22", config)
if err != nil {
panic(err)
}
defer sshClient.Close()
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
panic(err)
}
defer sftpClient.Close()
// Ejecuta operación
switch *operation {
case "list":
listDir(sftpClient, *remotePath)
case "search":
searchFiles(sftpClient, *remotePath, *pattern)
case "download":
downloadFile(sftpClient, *remotePath, *localPath)
case "upload":
uploadFile(sftpClient, *localPath, *remotePath)
default:
fmt.Println("Operación desconocida")
}
}
func listDir(client *sftp.Client, path string) {
files, _ := client.ReadDir(path)
for _, f := range files {
fmt.Printf("%s %d %v\n", f.Name(), f.Size(), f.ModTime())
}
}
func searchFiles(client *sftp.Client, dir, pattern string) {
client.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
fmt.Println(path)
}
return nil
})
}
func downloadFile(client *sftp.Client, remote, local string) {
file, _ := client.Open(remote)
defer file.Close()
out, _ := os.Create(local)
defer out.Close()
io.Copy(out, file)
fmt.Println("Descargado:", local)
}
func uploadFile(client *sftp.Client, local, remote string) {
file, _ := os.Open(local)
defer file.Close()
out, _ := client.Create(remote)
defer out.Close()
io.Copy(out, file)
fmt.Println("Cargado:", remote)
}
Uso:
# Listar directorio
./sftp-tool -host example.com -user admin -op list -remote /var/log
# Buscar archivos
./sftp-tool -host example.com -user admin -op search -remote /var/log -pattern "*.txt"
# Descargar archivo
./sftp-tool -host example.com -user admin -op download -remote /var/log/app.log -local ./app.log
# Cargar archivo
./sftp-tool -host example.com -user admin -op upload -local ./config.txt -remote /etc/config.txt
Parte 7: Optimización de rendimiento — Afinando tu código
Operaciones en lote
En lugar de una operación a la vez:
// LENTO: Una descarga a la vez
for _, file := range files {
downloadFile(sftpClient, file, localDir)
}
// RÁPIDO: Descargas en lote con concurrencia
func batchDownload(client *sftp.Client, files []string, dest string, workers int) error {
sem := make(chan struct{}, workers) // Semáforo para límite de concurrencia
var wg sync.WaitGroup
errChan := make(chan error, len(files))
for _, file := range files {
wg.Add(1)
go func(f string) {
defer wg.Done()
sem <- struct{}{} // Adquiere
defer func() { <-sem }() // Libera
localName := filepath.Join(dest, filepath.Base(f))
if err := SafeDownload(client, f, localName); err != nil {
errChan <- err
}
}(file)
}
wg.Wait()
close(errChan)
for err := range errChan {
if err != nil {
return err
}
}
return nil
}
Esto descarga múltiples archivos concurrentemente mientras limita concurrencia para no sobrecargar la conexión.
Keepalive de conexión
Mantén conexiones vivas sobre operaciones largas:
// Añade keepalive
config := &ssh.ClientConfig{
ClientVersion: "SSH-2.0-sftptool",
// ... métodos de auth ...
}
// Después de dial, añade keepalive
sshClient.OpenChannel("session", nil) // Canal dummy para mantener conexión viva
// O usa ping periódico
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
sshClient.SendRequest("keepalive@openssh.com", true, nil)
}
}()
Parte 8: Resumen de mejores prácticas
Gestión de conexiones:
- ✅ Usa pooling de conexiones, no una conexión por operación
- ✅ Reutiliza conexiones en múltiples operaciones
- ✅ Implementa limpieza y manejo de errores apropiado
Operaciones de archivo:
- ✅ Siempre usa archivos temp para cargas (escrituras atómicas)
- ✅ Siempre verifica descargas con checksums
- ✅ Maneja fallos parciales gracefully
Estrategias de búsqueda:
- ✅ Búsqueda solo nombre para velocidad
- ✅ Búsqueda paralela de directorio para múltiples árboles
- ✅ Búsqueda de contenido solo cuando sea necesario
Rendimiento:
- ✅ Operaciones en lote
- ✅ Usa transferencias concurrentes con semáforos
- ✅ Mantén conexiones vivas sobre operaciones largas
- ✅ Haz profile antes de optimizar
Seguridad:
- ✅ Usa autenticación basada en clave, no contraseñas
- ✅ Verifica host keys en producción
- ✅ Limita permisos de archivo después de transferencia
- ✅ Encripta datos sensibles antes de transferir
Parte 9: El costo real del código SFTP malo
Los desarrolladores a menudo tratan SFTP como una herramienta simple. “Abre conexión, transfiere archivo, cierra.”
A escala, esto es costoso:
- Autenticación por operación: 100ms cada una
- 100 archivos a descargar: 10+ segundos en serie
- Transferencias fallidas: Sin recuperación, comienza de nuevo
- Archivos parciales: Corrompen datos silenciosamente
Los patrones correctos cuestan algunos cientos de líneas de código. Ahorran horas de debugging y días de datos perdidos.
La diferencia entre código SFTP que funciona y código SFTP que escala no es complejidad. Es disciplina. Connection pooling. Operaciones atómicas. Manejo de errores apropiado. Estos no son opcionales. Son la diferencia entre código que funciona y código de producción.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.
AWS Desde la Perspectiva de un Solutions Architect: Teoría, Decisiones y Go
Una guía teórica exhaustiva sobre AWS desde la mentalidad de un Solutions Architect: cómo pensar en decisiones arquitectónicas, trade-offs fundamentales, integración con Go y otros lenguajes, patrones de diseño empresarial, y cómo aplicar esto en proyectos reales.