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.
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.
cronno hereda elPATHde 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.