Linux CLI + Go: Comandos de Automatización que Todo Dev Debe Dominar

Linux CLI + Go: Comandos de Automatización que Todo Dev Debe Dominar

Domina los comandos CLI de Linux para automatización y aprende a construir herramientas Go que integran cron, systemd, pipes y file watchers en flujos reales.

Por Omar Flores

Imagina tu sistema Linux como una planta de manufactura. El shell es la banda transportadora — mueve datos de una estación a la siguiente. Cada comando es una máquina que transforma el material que pasa por ella. Un ingeniero hábil no reconstruye una máquina para cada tarea. Conecta las máquinas existentes entre sí, y construye nuevas máquinas especializadas solo cuando las estándar no son suficientes.

Este es el modelo mental detrás de automatizar con Linux y Go. La mayor parte de la automatización del día a día ya está cubierta por find, xargs, awk, cron y systemd. Cuando esas herramientas llegan a sus límites — cuando necesitas config tipada, salida estructurada, reintentos o llamadas HTTP — escribes un binario de Go y lo conectas a la misma banda transportadora. El resultado es un sistema que es rápido, auditable y corre sin runtime.

Este post cubre ambas capas. Primero, los comandos de Linux que vale la pena internalizar. Luego, los patrones de Go para construir herramientas CLI que se integran con ellos.


La Base: Pipes y Redirecciones

Antes de cualquier comando específico, el mecanismo que los hace componibles merece una explicación clara. Cada proceso Linux tiene tres flujos estándar: stdin (entrada), stdout (salida) y stderr (errores). El operador pipe | conecta el stdout de un proceso al stdin del siguiente. Las redirecciones (>, >>, 2>) envían flujos a archivos.

# stdout a archivo, stderr descartado
go build ./... > build.log 2>/dev/null

# stderr al mismo archivo que stdout
go test ./... > test.log 2>&1

# añadir a archivo existente
echo "$(date): job iniciado" >> /var/log/my-app/cron.log

# stdin desde archivo
psql -U app -d mydb < schema.sql

Entender estos primitivos significa que puedes componer cualquier cadena de comandos en un pipeline. Cada herramienta de este post opera a través de estos tres flujos.


find: Localiza Archivos con Precisión

find recorre un árbol de directorios y aplica predicados. Es más capaz de lo que la mayoría de los desarrolladores lo usa.

La forma básica es find <ruta> <predicados>. Los predicados se combinan con AND implícito por defecto.

# encontrar todos los archivos .go modificados en las últimas 24 horas
find ./src -name "*.go" -mtime -1

# encontrar archivos mayores a 10MB
find /var/log -name "*.log" -size +10M

# encontrar y eliminar archivos temporales de más de 7 días
find /tmp -name "*.tmp" -mtime +7 -delete

# encontrar solo directorios
find . -type d -name "vendor"

# encontrar archivos que no pertenecen al usuario actual
find /var/app -not -user $(whoami) -type f

El flag -exec ejecuta un comando en cada resultado. El placeholder {} es el archivo encontrado, y \; termina el comando.

# comprimir cada log antiguo
find /var/log -name "*.log" -mtime +30 -exec gzip {} \;

# imprimir tamaño y nombre de cada binario
find ./bin -type f -exec du -sh {} \;

Usa + en lugar de \; para pasar todos los resultados como argumentos a la vez — más eficiente cuando el comando acepta múltiples entradas:

find . -name "*.go" -exec gofmt -w {} +

xargs: Convierte Líneas en Argumentos

xargs lee líneas desde stdin y las pasa como argumentos a un comando. Cierra la brecha entre comandos que emiten listas de archivos y comandos que aceptan argumentos de archivos.

# lintear todos los archivos Go encontrados por find
find . -name "*.go" | xargs golangci-lint run

# eliminar todos los archivos listados en un archivo de texto
cat archivos-a-eliminar.txt | xargs rm -f

# ejecutar con 4 procesos paralelos (-P)
find ./images -name "*.png" | xargs -P 4 -I{} convert {} -resize 800x {}-resized.png

El flag -I{} define un placeholder, permitiéndote controlar dónde aparece el argumento en el comando. El flag -P controla el paralelismo. Juntos, convierten una lista secuencial en una cola de trabajos paralelos — sin escribir un planificador.

# ejecutar go test en 4 paquetes en paralelo
echo -e "pkg/auth\npkg/user\npkg/billing\npkg/notify" | xargs -P 4 -I{} go test ./{} -v

awk: Transforma Texto Estructurado

awk procesa texto línea por línea, dividiendo cada línea en campos. Es la herramienta correcta cuando necesitas extraer columnas de la salida de comandos, calcular sumas o reformatear reportes.

# imprimir el segundo campo (nombre del proceso) desde la salida de ps
ps aux | awk '{print $11}'

# sumar la columna de tamaño desde la salida de du
du -sh ./logs/*.log | awk '{sum += $1} END {print "Total:", sum}'

# imprimir líneas donde el uso de memoria supera 100MB
ps aux | awk '$6 > 102400 {print $2, $11, $6/1024 "MB"}'

# extraer solo el código HTTP desde log de nginx
cat access.log | awk '{print $9}' | sort | uniq -c | sort -rn

awk también lee archivos directamente. Esto es útil cuando procesas salida estructurada guardada en disco:

# extraer builds fallidos de un archivo de log de CI
awk '/FAIL/{print NR, $0}' ci-output.log

sed: Sustitución de Texto In-Place

sed aplica patrones de sustitución a flujos de texto. El uso más común es búsqueda y reemplazo, pero también maneja inserción, eliminación y extracción de líneas.

# reemplazar todas las ocurrencias de "localhost" con el hostname real
sed 's/localhost/db.internal/g' config.template > config.env

# edición in-place (modifica el archivo directamente)
sed -i 's/DEBUG=true/DEBUG=false/g' .env.production

# eliminar líneas que coinciden con un patrón
sed -i '/^#/d' config.env   # eliminar líneas de comentarios

# imprimir solo las líneas 10 a 20
sed -n '10,20p' archivo-grande.log

# insertar una línea antes de una coincidencia
sed -i '/^ENV=/i # Configuración de entorno' .env

Combinando sed con find y xargs obtienes una herramienta de refactoring:

# renombrar un paquete en todos los archivos Go
find . -name "*.go" | xargs sed -i 's/package oldname/package newname/g'

cron: Trabajos Programados

cron ejecuta comandos en un horario definido en un crontab. El formato del horario tiene cinco campos: minuto, hora, día-del-mes, mes, día-de-la-semana.

# editar el crontab del usuario actual
crontab -e

# listar el crontab actual
crontab -l

Patrones de horario comunes:

# ejecutar a las 2:30 AM todos los días
30 2 * * * /usr/local/bin/backup.sh

# ejecutar cada 15 minutos
*/15 * * * * /usr/local/bin/health-check

# ejecutar cada lunes a las 9 AM
0 9 * * 1 /usr/local/bin/weekly-report

# ejecutar el primer día de cada mes
0 0 1 * * /usr/local/bin/monthly-cleanup

Un job de cron que llama a un binario de Go se ve exactamente igual. Compila el binario y apunta a él:

# /etc/cron.d/my-app-jobs
0 */6 * * * app-user /usr/local/bin/my-app sync --config /etc/my-app/config.yaml >> /var/log/my-app/sync.log 2>&1

Buenas prácticas de cron:

  • Siempre usa rutas absolutas. cron no hereda el PATH de tu shell.
  • Redirige tanto stdout como stderr a un archivo de log con >> log 2>&1.
  • Prueba los comandos manualmente antes de añadirlos al crontab.

Timers de systemd: cron con Supervisión

Los timers de systemd son la alternativa moderna a cron. Se integran con el gestor de servicios, proveen logging via journalctl y soportan ordenamiento por dependencias.

Un timer requiere dos archivos de unidad: un .service y un .timer.

# /etc/systemd/system/sync-data.service
[Unit]
Description=Job de sincronización de datos

[Service]
Type=oneshot
User=app
ExecStart=/usr/local/bin/my-app sync --config /etc/my-app/config.yaml
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/sync-data.timer
[Unit]
Description=Ejecutar sync-data cada 6 horas

[Timer]
OnCalendar=*-*-* 00,06,12,18:00:00
Persistent=true

[Install]
WantedBy=timers.target
# habilitar e iniciar
systemctl enable --now sync-data.timer

# verificar estado
systemctl status sync-data.timer

# ver logs
journalctl -u sync-data.service -f

El flag Persistent=true significa que si el sistema estaba apagado durante el horario programado, el timer se ejecuta inmediatamente en el siguiente arranque. Esto lo hace más seguro que cron para jobs que no pueden omitirse.


inotifywait: Reacciona a Eventos del Sistema de Archivos

inotifywait (del paquete inotify-tools) vigila eventos del sistema de archivos — creación, modificación, eliminación — y los emite a stdout. Es la base para file watchers, scripts de hot-reload y pipelines reactivos.

# vigilar un directorio para cualquier cambio
inotifywait -m -r /var/app/uploads

# vigilar solo archivos nuevos, salida en formato parseable
inotifywait -m -e create --format '%w%f' /var/app/uploads

# disparar un comando cuando cambia un archivo de config
inotifywait -m -e modify /etc/my-app/config.yaml | while read path event file; do
    echo "Config cambiada, recargando..."
    systemctl reload my-app
done

Envolviendo esto en un loop de shell obtienes un trigger reactivo. El patrón es: vigilar el evento, hacer pipe a while read, reaccionar.

# procesar cada archivo subido a medida que llega
inotifywait -m -e create --format '%f' /var/app/uploads | while read filename; do
    /usr/local/bin/process-upload "$filename"
done

Construyendo Herramientas CLI en Go

Los comandos de Linux anteriores manejan transformación de datos. Go maneja lógica demasiado compleja para el shell — llamadas HTTP, config estructurada, reintentos, errores tipados, workers concurrentes. La clave es construir binarios de Go que se comporten como herramientas Unix correctas: leen de stdin, escriben a stdout, aceptan flags y salen con códigos significativos.

Leyendo Flags y Argumentos

El paquete flag de la biblioteca estándar de Go cubre herramientas simples. Para CLIs complejas con subcomandos, usa github.com/spf13/cobra.

// cmd/sync/main.go
package main

import (
	"flag"
	"fmt"
	"log"
	"os"
)

func main() {
	configPath := flag.String("config", "/etc/my-app/config.yaml", "ruta al archivo de config")
	dryRun := flag.Bool("dry-run", false, "imprimir acciones sin ejecutarlas")
	verbose := flag.Bool("v", false, "salida detallada")
	flag.Parse()

	if *verbose {
		log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
	} else {
		log.SetFlags(0)
	}

	cfg, err := loadConfig(*configPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	if err := run(cfg, *dryRun); err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
}

El código de salida 1 señala fallo al shell que lo llama. Cron, systemd y xargs verifican códigos de salida y actúan en consecuencia.

Leyendo de Stdin y Escribiendo a Stdout

Un binario de Go que lee de stdin y escribe a stdout se compone perfectamente con pipes.

// cmd/filter-logs/main.go — lee líneas de log desde stdin, escribe líneas coincidentes a stdout
package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
	"strings"
)

func main() {
	level := flag.String("level", "ERROR", "nivel de log a filtrar")
	flag.Parse()

	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.Contains(line, "["+*level+"]") {
			fmt.Println(line)
		}
	}

	if err := scanner.Err(); err != nil {
		fmt.Fprintf(os.Stderr, "error de lectura: %v\n", err)
		os.Exit(1)
	}
}

Compila y úsalo en un pipeline:

go build -o /usr/local/bin/filter-logs ./cmd/filter-logs
cat /var/log/my-app/app.log | filter-logs --level=ERROR | mail -s "Errores" ops@empresa.com

Config Estructurada con YAML

Los valores hardcodeados en herramientas CLI crean problemas cuando el mismo binario corre en dev, staging y producción. Lee config desde un archivo y sobrescribe con variables de entorno.

// internal/config/config.go
package config

import (
	"fmt"
	"os"

	"gopkg.in/yaml.v3"
)

type Config struct {
	Database DatabaseConfig `yaml:"database"`
	Storage  StorageConfig  `yaml:"storage"`
	Log      LogConfig      `yaml:"log"`
}

type DatabaseConfig struct {
	DSN      string `yaml:"dsn"`
	MaxConns int    `yaml:"max_conns"`
}

type StorageConfig struct {
	BasePath  string `yaml:"base_path"`
	MaxSizeMB int    `yaml:"max_size_mb"`
}

type LogConfig struct {
	Level  string `yaml:"level"`
	Format string `yaml:"format"`
}

func Load(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("leyendo config: %w", err)
	}

	// expandir variables de entorno en el YAML
	expanded := os.ExpandEnv(string(data))

	var cfg Config
	if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
		return nil, fmt.Errorf("parseando config: %w", err)
	}

	return &cfg, nil
}

La llamada os.ExpandEnv reemplaza los placeholders ${VAR} en el YAML con las variables de entorno reales. Un solo archivo de config funciona en todos los entornos cambiando las variables de entorno, no el archivo.

# /etc/my-app/config.yaml
database:
  dsn: "${DATABASE_URL}"
  max_conns: 10
storage:
  base_path: "/var/app/data"
  max_size_mb: 1024
log:
  level: "${LOG_LEVEL:-info}"
  format: "json"

Workers Concurrentes con WaitGroup

Las goroutines de Go hacen trivial el procesamiento paralelo. El patrón para procesar una lista de items concurrentemente usa un worker pool: un canal con buffer para los jobs, un número fijo de goroutines consumiendo de él, y un sync.WaitGroup para esperar la completación.

// internal/worker/pool.go
package worker

import (
	"sync"
)

type Job func() error

func RunPool(jobs []Job, concurrency int) []error {
	jobCh := make(chan Job, len(jobs))
	for _, j := range jobs {
		jobCh <- j
	}
	close(jobCh)

	var mu sync.Mutex
	var errs []error
	var wg sync.WaitGroup

	for range concurrency {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for job := range jobCh {
				if err := job(); err != nil {
					mu.Lock()
					errs = append(errs, err)
					mu.Unlock()
				}
			}
		}()
	}

	wg.Wait()
	return errs
}

Uso — procesando una lista de archivos con 8 workers concurrentes:

files, _ := filepath.Glob("/var/app/uploads/*.csv")
jobs := make([]worker.Job, len(files))
for i, f := range files {
    path := f
    jobs[i] = func() error {
        return processCSV(path)
    }
}

errs := worker.RunPool(jobs, 8)
if len(errs) > 0 {
    for _, e := range errs {
        log.Printf("error: %v", e)
    }
    os.Exit(1)
}

File Watcher en Go

En lugar de depender de inotifywait, puedes escribir un file watcher directamente en Go usando github.com/fsnotify/fsnotify. Funciona multiplataforma y se integra limpiamente con tu config y manejo de errores existentes.

// cmd/watch/main.go
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/fsnotify/fsnotify"
)

func main() {
	dir := "/var/app/uploads"
	if len(os.Args) > 1 {
		dir = os.Args[1]
	}

	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatalf("creando watcher: %v", err)
	}
	defer watcher.Close()

	if err := watcher.Add(dir); err != nil {
		log.Fatalf("vigilando %s: %v", dir, err)
	}

	fmt.Printf("vigilando %s\n", dir)

	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}
			if event.Has(fsnotify.Create) {
				handleNewFile(event.Name)
			}
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			log.Printf("error de watcher: %v", err)
		}
	}
}

func handleNewFile(path string) {
	ext := filepath.Ext(path)
	fmt.Printf("nuevo archivo: %s (ext: %s)\n", path, ext)
	// procesar según extensión
}

Ejecuta esto como un servicio de systemd (tipo simple en lugar de oneshot) y corre continuamente, reaccionando a cada archivo nuevo sin hacer polling.

Apagado Graceful

Una herramienta CLI que corre como servicio de larga duración debe manejar SIGTERM y SIGINT limpiamente — drenando el trabajo en progreso antes de salir.

// cmd/service/main.go
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	svc := NewService()
	go svc.Run(ctx)

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	log.Println("apagando...")
	cancel()

	// dar a los workers hasta 10 segundos para terminar
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer shutdownCancel()

	if err := svc.Shutdown(shutdownCtx); err != nil {
		log.Printf("error de apagado: %v", err)
		os.Exit(1)
	}

	log.Println("detenido limpiamente")
}

Cuando systemctl stop my-service corre, systemd envía SIGTERM. Este patrón asegura que el trabajo en vuelo se complete antes de que el proceso salga.


Integrando Todo: Un Pipeline de Automatización Real

Las herramientas individuales son más útiles cuando ves cómo se combinan en un flujo real de trabajo. Considera un pipeline de datos que corre cada 6 horas: descarga reportes, procesa cada CSV, sube resultados y envía un resumen.

El timer de systemd llama al binario de Go. El binario de Go lee config desde YAML, procesa archivos con un worker pool, escribe logs estructurados que journalctl captura, y sale con código no-cero ante fallos — lo que systemd marca como unidad fallida, disparando una alerta.

# Makefile — compilar e instalar el binario
.PHONY: build install

build:
	go build -ldflags="-s -w" -o ./bin/pipeline ./cmd/pipeline

install: build
	install -m 755 ./bin/pipeline /usr/local/bin/pipeline
	install -m 644 ./deploy/pipeline.service /etc/systemd/system/
	install -m 644 ./deploy/pipeline.timer /etc/systemd/system/
	systemctl daemon-reload
	systemctl enable --now pipeline.timer
// cmd/pipeline/main.go
package main

import (
	"context"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"time"

	"myorg/pipeline/internal/config"
	"myorg/pipeline/internal/downloader"
	"myorg/pipeline/internal/processor"
	"myorg/pipeline/internal/uploader"
)

func main() {
	configPath := flag.String("config", "/etc/pipeline/config.yaml", "archivo de config")
	flag.Parse()

	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load(*configPath)
	if err != nil {
		logger.Error("carga de config fallida", "error", err)
		os.Exit(1)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
	defer cancel()

	start := time.Now()
	logger.Info("pipeline iniciado")

	files, err := downloader.FetchReports(ctx, cfg.Source)
	if err != nil {
		logger.Error("descarga fallida", "error", err)
		os.Exit(1)
	}

	logger.Info("reportes descargados", "cantidad", len(files))

	results, err := processor.RunAll(ctx, files, cfg.Processing.Concurrency)
	if err != nil {
		logger.Error("procesamiento fallido", "error", err)
		os.Exit(1)
	}

	if err := uploader.Push(ctx, results, cfg.Destination); err != nil {
		logger.Error("subida fallida", "error", err)
		os.Exit(1)
	}

	logger.Info("pipeline completo",
		"duración", time.Since(start).Round(time.Second),
		"procesados", len(results),
	)
}

La salida de slog.NewJSONHandler va a stdout, que systemd captura en el journal. Consultala con:

journalctl -u pipeline.service --since "6 hours ago" -o json | jq '.MESSAGE | fromjson | select(.level == "ERROR")'

El Makefile como Centro de Automatización

Un Makefile en la raíz del proyecto es el punto de entrada para toda la automatización local. Documenta los comandos disponibles y asegura que corran de la misma forma en cada entorno.

.PHONY: build test lint fmt install clean run

BIN := ./bin/my-app
SRC := ./cmd/my-app

build:
	go build -ldflags="-s -w" -o $(BIN) $(SRC)

test:
	go test ./... -race -count=1

lint:
	golangci-lint run ./...

fmt:
	gofmt -w .
	goimports -w .

install: build
	install -m 755 $(BIN) /usr/local/bin/my-app

clean:
	rm -f $(BIN)
	find . -name "*.log" -delete

run: build
	$(BIN) --config ./config/local.yaml

# generar archivos templ antes de compilar
generate:
	templ generate
	go generate ./...

Llamar make build siempre produce el mismo resultado. Nadie tiene que recordar los ldflags. Los nuevos miembros del equipo ejecutan make install y listo.


Medir lo que Automatizas

La automatización que corre en silencio es automatización en la que no puedes confiar. Cada job programado debe producir salida observable: líneas de log con timestamps y contexto, campos estructurados que herramientas como jq puedan filtrar, y códigos de salida que los sistemas de supervisión puedan monitorear.

El paquete log/slog de Go (estándar desde Go 1.21) produce logs JSON estructurados sin dependencias de terceros:

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

logger.Info("job iniciado", "job", "sync", "env", os.Getenv("APP_ENV"))
logger.Error("base de datos inalcanzable", "error", err, "dsn", cfg.Database.DSN)

Salida que puedes consultar:

journalctl -u sync-data.service -o json \
  | jq 'select(.PRIORITY == "3") | .MESSAGE | fromjson'

La disciplina de la automatización observable es lo que separa los scripts que funcionan una vez de los sistemas en los que confías a las 3 AM cuando suena una alerta.


La línea de comandos de Linux y Go ocupan diferentes capas del mismo stack. El shell sobresale conectando herramientas existentes — es rápido de escribir y fácil de leer. Go sobresale con lógica compleja — está chequeado en tipos, es testeable y compila a un binario único. El desarrollador que internaliza ambas capas no elige entre ellas. Usa el shell cuando el shell es suficiente, usa Go cuando la lógica lo exige, y construye pipelines donde ambos colaboran.

La automatización más mantenible es la que permite rastrear exactamente qué ocurrió, cuándo ocurrió y por qué tuvo éxito o falló — sin tener que ejecutarla de nuevo.

Tags

#golang #go #devops #best-practices #tutorial #guide #tips #backend