¡Hola, Nauta! Soy Leo Byte. Hoy vamos a abordar un problema práctico que muchos nos encontramos al trabajar con el lenguaje de programación Go: el tamaño de los ejecutables cuando usamos //go:embed. Aquí te muestro una técnica que he usado para comprimir archivos embed en Go y optimizar el resultado final.

Comparativa de tamaño de binario Go comprimido vs normal
La compresión ZIP puede reducir drásticamente el peso final.

El Problema: El Coste Oculto de //go:embed

La funcionalidad embed de Go te permite empaquetar activos estáticos en un ejecutable, pero los almacena sin comprimir. Esto desperdicia espacio: una interfaz web con documentación puede inflar tu binario en docenas de megabytes. Se rechazó una propuesta para habilitar la compresión opcional debido a la dificultad de gestionar todos los casos de uso. ¿Una solución? ¡Colocar todos los activos en un archivo ZIP! 🗜️

Esta es la estrategia clave si tu objetivo es reducir el tamaño de tu binario Go, ya que el almacenamiento por defecto puede ser tu mayor enemigo.

La Solución Técnica: Embeber un Archivo ZIP

La biblioteca estándar de Go incluye un módulo para leer y escribir archivos ZIP. Contiene una función que convierte un archivo ZIP en una estructura io/fs.FS que puede reemplazar a embed.FS en la mayoría de los contextos. Esta es la clave para usar go embed zip de manera eficiente.

El Código

Para mantener unas mejores prácticas en Go, el código debe ser limpio y seguro para la concurrencia.

package embed

import (
  "archive/zip"
  "bytes"
  _ "embed"
  "fmt"
  "io/fs"
  "sync"
)

//go:embed data/embed.zip
var embeddedZip []byte

// sync.OnceValue asegura que el ZIP se procese una sola vez de forma segura,
// una optimización clave para accesos concurrentes.
var dataOnce = sync.OnceValue(func() *zip.Reader {
  r, err := zip.NewReader(bytes.NewReader(embeddedZip), int64(len(embeddedZip)))
  if err != nil {
    // En producción, considera manejar este error sin pánico para permitir una degradación elegante.
    panic(fmt.Sprintf("cannot read embedded archive: %s", err))
  }
  return r
})

func Data() fs.FS {
  return dataOnce()
}

Punto Clave: Puedes leer múltiples archivos de forma concurrente con seguridad. Sin embargo, esta implementación no incluye los métodos ReadDir() y ReadFile().

Automatización con Makefile

Podemos construir el archivo embed.zip con una regla en un Makefile. Especificamos los archivos para empaquetar activos estáticos Go como dependencias para asegurar que se detecten los cambios.

common/embed/data/embed.zip: console/data/frontend console/data/docs
common/embed/data/embed.zip: orchestrator/clickhouse/data/protocols.csv 
common/embed/data/embed.zip: orchestrator/clickhouse/data/icmp.csv
common/embed/data/embed.zip: orchestrator/clickhouse/data/asns.csv
common/embed/data/embed.zip:
    mkdir -p common/embed/data && zip --quiet --recurse-paths --filesync $@ $^
  • Aclaración del Makefile: La variable automática $@ es el objetivo de la regla, mientras que $^ se expande a todas las dependencias, modificadas o no.

Análisis de Resultados: El Balance entre Tamaño y Velocidad

Toda estrategia para optimizar binarios Go tiene sus compromisos. Veamos los datos de un caso real.

Ganancia de Espacio

Para ponerlo en contexto, en Akvorado, un colector de flujo escrito en Go, se embeben varios activos estáticos:

  • Archivos CSV para traducir números de puertos, protocolos o números de sistemas autónomos (AS).
  • Archivos HTML, CSS, JS e imágenes para la interfaz web.
  • La documentación del proyecto.

Embeber estos activos en un archivo ZIP redujo el tamaño del ejecutable de Akvorado en más de 4 MiB:

# Tamaño de los archivos sin comprimir: 7.3MB
unzip -p common/embed/data/embed.zip | wc -c | numfmt --to=iec
7.3M

# Tamaño del archivo ZIP comprimido: 2.9MB
ll common/embed/data/embed.zip
-rw-r--r-- 1 bernat users 2.9M Dec  7 17:17 common/embed/data/embed.zip

Pérdida de Rendimiento y Limitaciones

Leer desde un archivo comprimido no es tan rápido como leer un archivo plano. Un benchmark simple muestra que es más de 4 veces más lento. También asigna algo de memoria.

Flame graph mostrando impacto de descompresión en Go
La descompresión consume ciclos de CPU adicionales
goos: linux
goarch: amd64
pkg: akvorado/common/embed
cpu: AMD Ryzen 5 5600X 6-Core Processor
BenchmarkData/compressed-12     2262   526553 ns/op   610 B/op   10 allocs/op
BenchmarkData/uncompressed-12   9482   123175 ns/op     0 B/op    0 allocs/op

Cada acceso a un activo requiere un paso de descompresión, como se ve en un gráfico de llamas (flame graph).

Además, si bien un archivo ZIP tiene un índice para encontrar rápidamente el archivo solicitado, realizar búsquedas (seeking) dentro de un archivo comprimido no es posible actualmente con la librería estándar. Por lo tanto, los archivos de un archivo comprimido no implementan las interfaces io.ReaderAt o io.Seeker, a diferencia de los archivos embebidos directamente. Esto impide algunas características, como servir archivos parciales o detectar tipos MIME al servir archivos a través de HTTP.

Conclusión: ¿Vale la Pena el Compromiso?

Para el proyecto Akvorado, este es un compromiso aceptable para ahorrar unos pocos mebibytes en un ejecutable de casi 100 MiB. Si te preguntabas cómo reducir ejecutable Go, la respuesta depende de tus prioridades:

  • Si priorizas un binario pequeño, esta técnica es una victoria clara.
  • Si necesitas máxima velocidad de acceso o servir archivos con seeking, es mejor usar embed de forma nativa.

Si quieres profundizar más en este ecosistema, te recomiendo revisar mi guía sobre el perfil de un desarrollador Go. ¡La próxima semana, continuaré esta inútil aventura explicando cómo impedí que Go deshabilitara la eliminación de código muerto! 🦥

Categorizado en:

Go,