¡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.
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()yReadFile().
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.zipPé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.
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/opCada 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
embedde 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! 🦥