Compression des fichiers embarqués dans Go

Vincent Bernat

La fonctionnalité embed de Go permet d’intégrer des ressources statiques dans un exécutable, mais elle les stocke non compressées. Cela gaspille de l’espace : une interface web avec de la documentation peut faire gonfler un binaire de plusieurs mégaoctets. Une proposition pour activer optionnellement la compression a été déclinée car il est difficile de gérer tous les cas d’usage. Une solution ? Mettre toutes les ressources dans une archive ZIP ! 🗜️

Code#

La bibliothèque standard de Go inclut un module pour lire et écrire des archives ZIP. Il contient une fonction qui transforme une archive ZIP en une structure io/fs.FS. Cette dernière peut remplacer embed.FS dans la plupart des cas1.

package embed

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

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

var dataOnce = sync.OnceValue(func() *zip.Reader {
  r, err := zip.NewReader(bytes.NewReader(embeddedZip), int64(len(embeddedZip)))
  if err != nil {
    panic(fmt.Sprintf("cannot read embedded archive: %s", err))
  }
  return r
})

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

Pour construire l’archive embed.zip, nous pouvons utiliser une règle dans un Makefile. Les fichiers à y placer sont spécifiés comme des dépendances pour s’assurer que les changements sont détectés. La variable automatique $@ est la cible de la règle, tandis que $^ est remplacée par la liste des dépendances, modifiées ou non.

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 $@ $^

Gain d’espace#

Akvorado, un collecteur de flux écrit en Go, embarque plusieurs ressources statiques :

  • des fichiers CSV pour traduire les numéros de ports, les protocoles ou les numéros d’AS ;
  • du HTML, CSS, JS et des images pour l’interface web ;
  • la documentation.
Répartition de l'espace utilisé par chaque paquet avant et après l'introduction de
embed.zip. Il est affiché comme une carte arborescente et nous pouvons voir de nombreux fichiers embarqués
remplacés par un plus gros.
Répartition de l'espace utilisé par chaque composant avant (gauche) et après (droite) l'introduction de embed.zip.

L’intégration de ces ressources dans une archive ZIP a réduit la taille de l’exécutable de plus de 4 Mio :

$ unzip -p common/embed/data/embed.zip | wc -c | numfmt --to=iec
7.3M
$ ll common/embed/data/embed.zip
-rw-r--r-- 1 bernat users 2.9M Dec  7 17:17 common/embed/data/embed.zip

Perte de performance#

Lire depuis une archive compressée n’est pas aussi rapide que lire un fichier à plat. Un benchmark simple montre que c’est plus de 4 fois plus lent. De plus, la lecture de l’archive alloue de la mémoire2.

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

Chaque accès à une ressource nécessite une étape de décompression, comme on peut le voir dans ce graphique :

🖼 Graphique en flammes lors de la lecture de données depuis embed.zip comparé à la lecture directe
Graphique en flammes du CPU comparant le temps passé sur le CPU lors de la lecture des données depuis embed.zip (à gauche) et lors de la lecture directe (à droite). Parce que Go exécute le benchmark pour les données non compressées 4 fois plus souvent, il utilise le même espace horizontal que le benchmark pour les données compressées. Le graphique est interactif.

Bien qu’une archive ZIP ait un index pour trouver rapidement le fichier demandé, se déplacer à l’intérieur d’un fichier compressé n’est actuellement pas possible3. Par conséquent, les fichiers retournés depuis une archive compressée n’implémentent pas les interfaces io.ReaderAt ou io.Seeker, contrairement aux fichiers directement embarqués. Cela empêche certaines fonctionnalités du serveur HTTP, comme servir des fichiers partiels ou détecter le type MIME.


Pour Akvorado, c’est un compromis acceptable pour économiser quelques mébioctets d’un exécutable de presque 100 Mio. La semaine prochaine, je continuerai cette aventure futile en expliquant comment j’ai empêché Go de désactiver l’élimination de code mort ! 🦥


  1. Elle autorise la lecture de plusieurs fichiers simultanément. Cependant, elle n’implémente pas les méthodes ReadDir() et ReadFile()↩︎

  2. Il serait possible de conserver en mémoire les ressources fréquemment consultées. Cela réduirait l’usage du CPU et échangerait la mémoire cache contre de la mémoire résidente. ↩︎

  3. SOZip est un profil qui permet de réaliser un accès aléatoire rapide dans un fichier compressé. Cependant, le module archive/zip de Go ne sait pas l’utiliser. ↩︎