Un serveur HTTP de moins de 20 Ko
Bonjour à toutes et à tous 😀
Avant propos
Contexte
Un peu de contexte du pourquoi et du comment.
Lorsque l'on créé une application sur Clever Cloud, il faut qu'elle soit capable de répondre en HTTP une OK 200, sinon le déploiement est considéré comme échoué.
Pour un usage qu'il n'est pas nécessaire de détailler ici (mais ce n'est pas la prod, rangez vos fourches 😅), je possède une application qui est incapable de répondre à cette appel HTTP.
En vrai, c'est surtout répondre à l'ouverture d'un socket plus que répondre de l'HTTP.
Cette application est légèrement trop complexe à faire tourner sur un runtime classique ce qui m'amène à utiliser un runtime Docker. Celui-ci n'a besoin que d'un Dockerfile pour fonctionner.
L'application que je dois dockerisé requiert un environnement complexe et très lourd (20 Go !!!), c'est beaucoup trop gros!
Le plan est alors de construire dans cet environnement démentiel et venir utiliser le multi-stage building.
Nous allons donc avoir des Dockerfiles de la forme suivante.
FROM environement_trop_lourd AS builder
# on construit ce que l'on veut
FROM scratch
COPY --from=builder /path/in/builder/something /path/to/something
Je veux alors quelque chose qui réponde en HTTP le plus léger possible et qui a le moins de dépendance également.
Je souhaite avoir un serveur HTTP minimal de moins de 1 Mo. Mais on va voir que l'on peut descendre bien plus bas! 😄
Je ne vais pas reprendre des produits déjà existants car ce n'est pas le but de la manœuvre, à la place je vais le coder, c'est bien plus amusant. 😎
J'ai choisi 5 langages de programmation:
Mention honorables aux langages "C compatible" mais que je ne détaillerai pas:
Et pour l'intérêt pédagogique, on finira par l'assembleur x86.
Pour chacune des implémentations je vais construire le serveur HTTP et vérifier la taille finale de l'image docker résultante.
Disclaimer
Je NE suis PAS expert dans les différents langages, il est donc probable que je fasse des erreurs dans les implémentations.
Le contenu de cet article n'est pas fait pour finir en prod !!!
Je suis juste en train de tirer une pelote pour comprendre les choses.
Cela étant maintenant dis, on peut commencer !
Rust
Rust est le langage que je connais le mieux, et celui qui donne les meilleur garantie de sécurité, par contre cette assurance à un coût en taille de binaire. Je ne m'attends pas à descendre en dessous du méga-octet.
Voici une implémentation en Rust de notre serveur HTTP, il gère une connexion à la fois.
use io;
use Write;
use ;
Profil de debug
On lance:
❯ cargo run
Compiling http-okay v0.1.0 (/mnt/f/Projets/Travail/Clever/Rust/http-okay)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/http-okay`
Et on teste.
❯ curl localhost:8080
OK
Succès ☑️
Et la taille?
❯ du -sh target/debug/http-okay
3.9M target/debug/http-okay
Ah ouais quand même !! 😅
Profil de release
Comme le [unoptimized + debuginfo]
l'indique la compilation est réalisée en mode debug et donc contient tout un tas d'informations supplémentaires que l'on appelle des symboles ayant pour but de faciliter la compréhension des problèmes. La contrepartie c'est que ces symboles viennent largement alourdir le binaire final.
Heureusement, il existe un mode de production qui va venir nettoyer tout ça.
❯ cargo build --release
Et c'est cette fois-ci bien plus acceptable.
❯ du -sh target/release/http-okay
448K target/release/http-okay
Nous avons déjà descendu sous la barre des 1Mo! 🥳
On peut même encore diminuer la taille du binaire en suivant repo github.
On applique en rajoutant une section [profile.release]
au Cargo.toml:
[]
# exécute la commande strip sur le binaire pour retirer les symboles d'informations
# 448K => 368K
= true
# on optimise en taille le binaire
# 368K => 368K
= "z"
# on active le LTO : optimisation pendant le linkage (suppression de code mort)
# 368K => 320K
= true
# on empêche le compilateur de paralléliser pour lui ouvrir des chemins d'optimisation
# 320K => 320K
= 1
# on enlève la possibilité d'unwind la stacktrace
# 320K => 316K
= "abort"
Le stripping du binaire est essentielle, on réduit de plus de 20% la taille du binaire final.
Ensuite vient le LTO qui arrive à grapiller 40Ko de code mort, puis le panic=abort qui récupère 4Ko supplémentaire.
Optimisations
Pour y voir un peu plus, je pars d'un programme vide, et je vais optimiser son poids jusqu'à arriver au limite du possible.
Puis faire machine arrière et utiliser les optimisation sur http-okay
notre répondeur HTTP.
❯ cargo build --release
❯ du -sh target/release/http-okay
300K target/release/http-okay
On en déduit que le poids minimal d'une application Rust est de 300 Ko.
Enfin ça c'est si on s'arrête à la surface des choses.
On peut réduire tout ça biiiiiiiieeeeen plus loin! 🤩
no_main
On peut empêcher Rust d'émettre son symbole "main".
Cela diminue très légèrement le poids du binaire.
// on indique à Rust que l'on ne veut pas de main
// on indique à rust d'appeler "main" réellement "main" dans le binaire
Cela nous donne
❯ du -sh target/release/http-okay
284K target/release/http-okay
Oui, vous avez bien lu! Emettre le main augmente de 26Ko la taille du binaire.
Ce 284Ko est l'ultime fontière la dernière est inutile pour nous, mais intéressante pour la culture.
no_std
On a alors un code encore plus étrange
extern crate libc;
use PanicInfo;
// Cette fonction est nécessaire, sinon cela ne compile pas
!
Et contre-intuivement, il faut rajouter une dépendance à la libc. En effet, le compilateur recherche le symbole __libc_start_main
, et il n'est pas trivial du tout de le reconstruire.
= { = "0.2.158" , = false}
Et moteur!
❯ cargo build --release
Compiling http-okay v0.1.0 (/mnt/f/Projets/Travail/Clever/Rust/http-okay)
Finished `release` profile [optimized] target(s) in 0.40s
❯ du -sh target/release/http-okay
8K target/release/http-okay
❯ target/release/http-okay
❯ echo $?
0
Ok, ça ne fait rien à part un exit(0), mais sur 8ko, il ne fallait pas demander la lune. 😄
http-okay en no_main
La version no_std
est trop extrême pour nous, nous avons besoin de l'API de haut niveau de manipulation des sockets.
use io;
use Write;
use ;
Et on fini sur 300 Ko.
❯ cargo build --release
Compiling http-okay v0.1.0 (/mnt/f/Projets/Travail/Clever/Rust/http-okay)
Finished `release` profile [optimized] target(s) in 2.61s
❯ du -sh target/release/http-okay
300K target/release/http-okay
On gagne cette fois-ci 16 Ko sur les 316 Ko que l'on avait précédement.
Bien-sûr, rien n'a changé sur le comportement final.
❯ curl -v localhost:8080
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length:2
<
* Connection #0 to host localhost left intact
OK
Docker
Je suis à la moitié du chemin. J'ai le binaire, mais pas encore l'image docker.
Build dynamique
Pour construire le Dockerfile, je vais utilisé un stage rust
.
FROM rust
# le Cargo.toml
RUN cat <<EOF > Cargo.toml
[package]
name = "http-okay"
version = "0.1.0"
edition = "2021"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
EOF
# le fichier source
RUN mkdir src && cat <<EOF > src/main.rs
#![no_main]
use std::io;
use std::io::Write;
use std::net::{TcpListener, TcpStream};
fn handle_connection(acceptation_result: io::Result<TcpStream>) -> io::Result<()> {
let mut stream = acceptation_result?;
stream.write_all("HTTP/1.1 200 OK\r\nContent-Length:2\r\n\r\nOK".as_bytes())?;
Ok(())
}
#[no_mangle]
fn main() {
let server = TcpListener::bind("0.0.0.0:8080").unwrap();
for acceptation_result in server.incoming() {
if let Err(response_error) = handle_connection(acceptation_result) {
println!("An error occurred {response_error}")
}
}
}
EOF
# on build
RUN cargo build --release
On build et on exécute l'image.
❯ docker build -t http-okay-rust .
❯ docker run -it -p 8080:8080 http-okay-rust target/release/http-okay
❯ curl localhost:8080
OK
Parfait. 😎
Maintenant que j'ai le binaire, plus qu'à faire l'image FROM scratch
.
FROM rust AS builder
# ... <le reste du stage>
RUN cargo build --release
FROM scratch
COPY --from=builder target/release/http-okay /usr/bin/http-okay
❯ docker build -t http-okay-rust .
❯ docker run -it -p 8080:8080 http-okay-rust /usr/bin/http-okay
exec /usr/bin/http-okay: no such file or directory
Ah! Comment ça ???!
Vous vous souvenez quand on a fait du no_std
, j'ai du rajouter la libc
comme dépendance externe. Et bien si elle n'est pas dans le binaire dans notre version, elle est forcément quelque part.
Ce quelque part, c'est l'OS lui-même. Il existe un fichier qui contient la "libc".
Mais c'est quoi la "libc"?
Pour discuter avec l'OS un programme a besoin d'une API qui permet à un programme de demander l'accès à une ressource. Et nous notre ressource que l'on veut accéder c'est la carte réseau et plus particulièrement un socket TCP.
Tout ce travail extrêmement complexe est prémâché par la libc qui donne tous les moyens de demander (entre autre) et de manipuler un socket. Nous avons donc besoin de la libc dans notre stage FROM scratch
.
On appelle ces demandes des "appels systèmes" ou syscalls
.
Et du coup elle est où ?
Si on garde que le stage rust
.
FROM rust AS builder
# ... <le reste du stage>
RUN cargo build --release
On peut utiliser la commande ldd qui permet d'afficher les liens vers les morceaux de codes nécessaires au bon fonctionnement du binaire. On appelle ça des bibliothèques de liens dynamiques.
❯ docker run -it http-okay-rust ldd target/release/http-okay
linux-vdso.so.1 (0x00007ffe657c9000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f8cc223f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8cc205e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8cc22b3000)
Les
(0x.........)
sont des addresses dans la mémoire.
On obtient une liste de 4 éléments:
- linux-vdso.so.1
- /lib/x86_64-linux-gnu/libgcc_s.so.1
- /lib/x86_64-linux-gnu/libc.so.6
- /lib64/ld-linux-x86-64.so.2
Le linux-vdso.so.1
est cas particulier, elle est automatiquement mappé au processus par le kernel du système d'exploitation.
On a donc 3 chemins:
- /lib/x86_64-linux-gnu/libgcc_s.so.1
- /lib/x86_64-linux-gnu/libc.so.6
- /lib64/ld-linux-x86-64.so.2
On peut alors les copier dans le stage FROM scratch
.
FROM rust AS builder
# ... <le reste du stage>
RUN cargo build --release
FROM scratch
COPY --from=builder target/release/http-okay /usr/bin/http-okay
COPY --from=builder /lib/x86_64-linux-gnu/libgcc_s.so.1 /lib/x86_64-linux-gnu/libgcc_s.so.1
COPY --from=builder /lib/x86_64-linux-gnu/libc.so.6 /lib/x86_64-linux-gnu/libc.so.6
COPY --from=builder /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
ENTRYPOINT ["/usr/bin/http-okay"]
❯ docker build -t http-okay-rust .
❯ docker run -p 8080:8080 http-okay-rust
❯ curl localhost:8080
OK
Et cette fois-ci c'est bon ! 🍾🎉🥳
Bon, et la taille ?
❯ docker images http-okay-rust
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-rust latest 1e3876fe8aee 7 minutes ago 2.56MB
3 Mo, joli !!
Via l'outil dive on peut avoir une vision du contenu de l'image
❯ dive http-okay-rust
On y voit les 2 Mo de la libc et aussi qu'il n'y a rien de superflu.
ldd ne voit que les liens dynamiques, il est incapables de détecter les dlopen.
Cette technique ne marche que si vous êtes certain que votre binaire n'utilise pas l'ouverture à la volée de DLL
Build statique
Une dernière optimisation que l'on peut réaliser et qui n'a de sens que dans notre cas est de builder statiquement le binaire. C'est à dire faire rentrer la libc et libc++ dans le binaire.
Pour cela, je rajoute un env juste avant le cargo build
: RUSTFLAGS='-C target-feature=+crt-static'
.
FROM rust AS builder
# ... <le reste du stage>
RUN RUSTFLAGS='-C target-feature=+crt-static' cargo build --release
FROM scratch
COPY --from=builder target/release/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Le stage FROM scratch
est alors bien amaigri.
❯ docker build -t http-okay-rust .
❯ docker run -p 8080:8080 http-okay-rust
❯ curl localhost:8080
OK
Bon, tout marche.
Un petit coup de dive.
❯ dive http-okay-rust
Il n'y a plus que notre binaire+libc_estropié dans l'image.
et le poids ?
❯ docker images http-okay-rust
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-rust latest 82c6fdabed5f 27 seconds ago 1.22MB
Et bien, 2 fois moins !
Quelle est cette sorcellerie ?!!
Et bien c'est le LTO qui fait tout le travail, ce n'est pas la libc qui est embarquée, seulement les bouts intéressants.
Et si on veut grapiller les dernière miettes, on peut passer en nightly et recompiler la rust-std nous même pour espérer l'optimiser.
FROM rust AS builder
# ... <le reste du stage>
# on ajoute le channel nightly
RUN rustup install nightly
# on récupère les source de la lib std pour l'architecture linux x64
RUN rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
# on compile la lib-std pour l'architecture linux x64 en même temps que le binaire
RUN RUSTFLAGS='-C target-feature=+crt-static' cargo +nightly build -Zbuild-std=core,std,panic_abort --target x86_64-unknown-linux-gnu --release
FROM scratch
# La source dans le builder est différente
COPY --from=builder target/x86_64-unknown-linux-gnu/release/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Toujours debout!
❯ docker build -t http-okay-rust .
❯ docker run -p 8080:8080 http-okay-rust
❯ curl localhost:8080
OK
Et on racle les fonds de tiroirs ^^
❯ docker images http-okay-rust
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-rust latest b6df1c6617f4 7 minutes ago 1.14MB
UPX
UPX est compresseur binaire. Son rôle est de réduire la taille d'un binaire. Et pas qu'un peu.
On va l'utiliser avec les réglages les plus violents
Ce qui donne le Dockerfile suivant.
FROM rust AS builder
# ... <le reste du stage>
RUN RUSTFLAGS='-C target-feature=+crt-static' cargo +nightly build -Zbuild-std=core,std,panic_abort --target x86_64-unknown-linux-gnu --release
# exécution de UPX
RUN <<EOF
wget "https://github.com/upx/upx/releases/download/v4.2.4/upx-4.2.4-amd64_linux.tar.xz"
tar -xf upx-4.2.4-amd64_linux.tar.xz
mv upx-4.2.4-amd64_linux/upx /usr/bin/upx
upx -9 --ultra-brute target/release/http-okay
EOF
FROM scratch
# La source dans le builder est différente
COPY --from=builder target/x86_64-unknown-linux-gnu/release/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Et donc, ça tourne toujours? et la taille ?
❯ docker build -t http-okay-rust .
❯ docker run -p 8080:8080 http-okay-rust
❯ curl localhost:8080
OK
❯ docker images http-okay-rust
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-rust latest a3a324028a28 About a minute ago 440kB
440 Ko !! Joli ! Et là c'est libc comprise ❤️Le Dockerfile complet
FROM rust AS builder
RUN cat <<EOF > Cargo.toml
[package]
name = "http-okay"
version = "0.1.0"
edition = "2021"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
EOF
RUN mkdir src && cat <<EOF > src/main.rs
#![no_main]
use std::io;
use std::io::Write;
use std::net::{TcpListener, TcpStream};
fn handle_connection(acceptation_result: io::Result<TcpStream>) -> io::Result<()> {
let mut stream = acceptation_result?;
stream.write_all("HTTP/1.1 200 OK\r\nContent-Length:2\r\n\r\nOK".as_bytes())?;
Ok(())
}
#[no_mangle]
fn main() {
let server = TcpListener::bind("0.0.0.0:8080").unwrap();
for acceptation_result in server.incoming() {
if let Err(response_error) = handle_connection(acceptation_result) {
println!("An error occurred {response_error}")
}
}
}
EOF
RUN rustup install nightly
RUN rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
RUN RUSTFLAGS='-C target-feature=+crt-static' cargo build --release
RUN <<EOF
wget "https://github.com/upx/upx/releases/download/v4.2.4/upx-4.2.4-amd64_linux.tar.xz"
tar -xf upx-4.2.4-amd64_linux.tar.xz
mv upx-4.2.4-amd64_linux/upx /usr/bin/upx
upx -9 --ultra-brute target/release/http-okay
EOF
FROM scratch
COPY --from=builder target/release/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
On en a fini avec Rust qui m'a largement surpris dans la légèreté du binaire créé.
Golang
On peut s'attaquer à son "concurrent" : le Golang.
Alors qu'en vrai le Rust et le Go n'ont rien à voir entre eux, Rust est langage de haut niveau pour manipuler du bas niveau, il est très proche de la machine.
Contrairement au Go qui possède un runtime au même titre que du javascript par exemple.
Ce runtime gère une foultitude de choses:
- liaison avec l'OS au travers de la libc
- scheduler : ordonnance les tâches à réaliser (très proche d'un tokio pour Rust)
- allocation mémoire
- cryptographie
- garbage collector
- ...
Bref, avant d'arriver à notre code, une montagne de choses se passent.
Ce runtime va nous freiner très fort dans notre optimisation du binaire.
Mais bon, j'ai été surpris du résultat en Rust, alors pourquoi pas en Go ^^
Implémentation
Voici une implémentation en Go
package main
import (
"fmt"
"net"
"os"
)
// Fermeture du socket
func closeConnection(conn net.Conn)
// Logique de gestion du socket
func handleConnection(conn net.Conn, err error)
func main()
On build!
go build -o http-okay
Le code fonctionne.
❯ ./http-okay
Closing connection 127.0.0.1:48006
❯ curl localhost:8080
OK
Et arrive à:
❯ du -sh http-okay
3.0M http-okay
C'est moins pire que ce que je ne pensais. Mais en Rust aussi au premier coup on était sur du 3.9 Mo, soit même plus.
Remarques
Avant d'optimiser, j'ai quelques que remarque de noob en Go, qui passe sa vie en Rust.
Go a des petites surprises, on lui donne comme réputation d'être un langage simple, mais en fait il y a surtout des commodités dont j'ai trop pris l'habitude en Rust qui m'ont manqué.
Par exemple, il est assez facile de by-pass les vérifications d'erreurs et de créer des états inconsistents dans le programme. Bon souvent ça finit en segfault et donc ça dead mais ça dead sale.
Voici ma première tentative quand je ne faisais pas de os.Exit(1)
après que le net.Listen
m'ait renvoyé son erreur que j'avais copieusement ignorée ^^
Ensuite, des choses dont je ne préoccupe plus car c'est le langage qui le fait pour moi en Rust, c'est la libération des ressources système. En Go, elle est explicite.
En Rust c'est implicite. Et bien pratique. 😃
The connection will be closed when the value is dropped
Il existe un mot-clef defer
qui a le même comportement que le RAII de Rust.
En Go. il faut créé le RAII car le mécanisme d'ownership n'existe pas.
func toto() // x sort du scope de toto -> x.Clean() est appelée et x marquée comme nettoyable par le GC
ça y est, j'ai fini de me plaindre 🤣
Optimisations
Comme pour Rust, je vais suivre un cookbook d'optimisation.
Le premier conseil est de supprimer les symboles de débug DWARF.
❯ go build -o http-okay -ldflags "-w"
❯ du -sh http-okay
2.2M http-okay
On descend déjà.
Le seconde est d'enlever la table de symboles, ceci fait perdre aux profiler la possibilités de comprendre ce qui se passe mais réduit encore un peu la taille.
❯ go build -o http-okay -ldflags "-w -s"
❯ du -sh http-okay
2.0M http-okay
Les optimisations suivantes sont plus tricky.
La première désactive l'inlining, ce qui veut dire que l'on perd des performances pour gagner de la taille de binaire.
❯ go build -o http-okay -ldflags "-w -s"
❯ du -s http-okay
2000 http-okay
❯ go build -o http-okay -ldflags "-w -s" -gcflags=all="-l"
❯ du -s http-okay
1952 http-okay
Dans notre cas cela ne change pas grand chose, mais on voit 50 octets de moins.
La dernière optimisation c'est n'importe quoi, on casse le langage en deux en désactivant le bound check, c'est à dire que l'on ne quitte pas une slice (coucou buffer overflow 👋).
❯ go build -o http-okay -ldflags "-w -s" -gcflags=all="-l -B"
❯ du -s http-okay
1916 http-okay
Et on peut même faire complètement n'importe quoi en désactivant les Writes Barrier 🤣
❯ go build -o http-okay -ldflags "-w -s" -gcflags=all="-l -B -wb=false"
❯ du -s http-okay
1872 http-okay
Faire cela empêche Go de fonctionner correctement en concurrence. Ne savant pas ce que cela peut donner, je vais rester au -ldflags "-w -s"
et ses 2 Mo de binaire compilé.
Docker
Même principe que pour Rust, mais on va directement passer par UPX cette fois-ci.
FROM golang as builder
COPY main.go .
RUN cat <<EOF > go.mod
module http-okay
EOF
RUN go build -o http-okay -ldflags "-w -s"
RUN <<EOF
apt update
apt install -y xz-utils
wget "https://github.com/upx/upx/releases/download/v4.2.4/upx-4.2.4-amd64_linux.tar.xz"
tar -xf upx-4.2.4-amd64_linux.tar.xz
mv upx-4.2.4-amd64_linux/upx /usr/bin/upx
upx -9 --ultra-brute http-okay
EOF
FROM scratch
COPY --from=builder /go/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Résultat 🥁
❯ docker build . -t http-okay-go
❯ docker images http-okay-go
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-go latest 64684cbf3426 5 minutes ago 699kB
Que 700 Ko! 🤩
Franchement, je ne pensais pas que l'on descendrait aussi bas 😅
Mais wait a minute
❯ docker run -it -p 8080:8080 http-okay-go
❯ echo $?
127
En fait ça crash parce que comme pour Rust, on a besoin de la libc qui s'est fait la malle dans le builder.
❯ ldd http-okay
linux-vdso.so.1 (0x00007fffce7c7000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f706d33a000)
/lib64/ld-linux-x86-64.so.2 (0x00007f706d555000)
Du coup nouvelle essaie avec la libc.
FROM golang AS builder
# le reste du stage
RUN upx -9 --ultra-brute http-okay
FROM scratch
COPY --from=builder /go/http-okay /usr/bin/http-okay
COPY --from=builder /lib/x86_64-linux-gnu/libc.so.6 /lib/x86_64-linux-gnu/libc.so.6
COPY --from=builder /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
ENTRYPOINT ["/usr/bin/http-okay"]
Et maintenant?
❯ docker run -it -p 8080:8080 http-okay-go
Closing connection 172.17.0.1:60978
❯ curl localhost:8080
OK
C'est mieux! Et la taille?
❯ docker images http-okay-go
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-go latest d055eae0f1cb 10 minutes ago 2.83MB
Aouch ! Ah oui ça coûte cher!
Heureusement, on peut utiliser le même trick que pour Rust et compiler statiquement le binaire.
Pour cela, on rajoute la variable d'environnement CGO_ENABLED=0
, qui va désactiver le pont vers le C et donc forcer à tout faire rentrer dans le Go.
FROM golang AS builder
ENV CGO_ENABLED=0
RUN go build -o http-okay -ldflags "-w -s"
# le reste du stage
RUN upx -9 --ultra-brute http-okay
FROM scratch
COPY --from=builder /go/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Résultat ?
❯ docker build . -t http-okay-go
❯ docker run -it -p 8080:8080 http-okay-go
Closing connection 172.17.0.1:52148
❯ curl localhost:8080
OK
Et la taille?
❯ docker images http-okay-go
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-go latest 1de49c02ca76 About a minute ago 683kB
Allez! 700 Ko tout frais compris 🤩
TinyGo
TinyGo est une variante de Go faite pour l'embarqué et le WASM et donc a comme promesse d'être plus léger et plus efficace.
Malheureusement, cette légèreté vient avec un coût bien trop important: il n'y a pas de possibilité de discuter avec la carte réseau !!
En effet, la lib net classique a été entièrement réécrite.
Compiler notre code va provoquer cette erreur.
Unable to listen to port : 0.0.0.0:8080 => Lookup of host name '0.0.0.0' failed: Netdev not set
Pourquoi? Et bien parce que tiny-go ne fourni pas d'implémentation de "Netdev", à part une qui renvoie systématiquement une erreur.
Les implémentations sont trouvables dans le repo drivers, la rtl8720dn par exemple est une antenne wifi.
Mais rien qui ressemble de près ou de loin à une implémentation générique passant par la libc et délégant le travail au kernel de gestion de drivers.
Donc pas de tiny-go pour nous. 😥
Vlang
Alors Vlang, on a vu le Rust, on a vu le Golang, et bien en très gros vlang c'est la fusion de Rust et de Go mélangé à plein d'autres influences et qui ce compile très bien C, mais en WASM et plus curieusement en JS !!!
Il possède une librairie standard pantagruélique, un garbage collector débrayable et le modèle de concurrence des go routines.
Nous allons effleurer ses capacités avec notre sujet.
Implémentation
Voici une implémentation de notre répondeur http en V.
module main
fn main() {
mut listener := net.listen_tcp(.ip, '0.0.0.0:8080') or {
panic('Unable to listen to port 8080')
}
for {
mut conn := listener.accept() or { panic('Unable to accept connection') }
conn.write('HTTP/1.1 200 OK\r\nContent-Length:2\r\n\r\nOK'.bytes()) or {
conn.close() or { panic('Unable to close connection') }
continue
}
conn.close() or { panic('Unable to close connection') }
}
}
Pour la suite des opérations je vais utiliser la version actuelle de V, c'est un projet qui évolue très vite donc je préfère le préciser.
❯ v version
V 0.4.8 da3112e
On build, vous aller voir que V va droit au but 😄
Voilà c'est compilé.
Il devrait vous avoir créé ce qu'il faut
❯ tree
.
├── main
├── main.v
└── v.mod
Si vous voulez renommer le programme créé vous pouvez:
Est-ce qu'il fonctionne?
❯ ./http-okay
❯ curl localhost:8080
OK
Visiblement oui.
Du coup, le poids du binaire est de combien ?
❯ du -sh http-okay
1.3M http-okay
Bon, début mais pas fantastique.
Et build statique ou dynamique ?
❯ ldd http-okay
linux-vdso.so.1 (0x00007ffead9e9000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2d7ed0f000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2d7ef2a000)
Dynamique, donc.
Si l'on veut du static on peut faire
❯ v main.v -cflags "-static" -o http-okay-static
❯ ldd http-okay-static
not a dynamic executable
❯ du -sh http-okay-static
2.0M http-okay-static
Optimisations
C'est cool, mais ça ne nous avance pas beaucoup. C'est le moment d'optimiser !
Vlang fourni un livre de recettes pour diminuer tout ça.
Et comment l'on fait ça ?
Comme d'habitude, on retire la première étapes consiste à retirer les symboles de debug.
❯ v main.v -prod -o http-okay
❯ du -sh http-okay
200K http-okay
❯ v main.v -prod -cflags "-static" -o http-okay-static
❯ du -sh http-okay-static
1.4M http-okay-static
Ah oui tout de suite on est sur un autre game.
On va couper d'autres branches.
❯ v main.v -prod -skip-unused -o http-okay
❯ du -sh http-okay
196K http-okay
4Ko, c'est mieux que rien...
Celui-là c'est presque de la triche 😄. C'est un UPX intégré.
❯ v main.v -prod -skip-unused -compress -o http-okay
❯ du -sh http-okay
84K http-okay
❯ v main.v -cflags "-static" -prod -skip-unused -compress -o http-okay-static
❯ du -sh http-okay
456K http-okay
Mais du coup le binaire est coupé en deux 🤩
Et je n'ai pas réussis à descendre plus bas.
Docker
Fabriquons notre container
FROM thevlang/vlang as builder
WORKDIR /opt
COPY main.v .
ENV CGO_ENABLED=0
RUN v main.v -cflags "-static" -prod -skip-unused -compress -o http-okay
FROM scratch
COPY --from=builder /opt/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Est-ce que ça continue fonctionner?
❯ docker build -t http-okay-vlang .
❯ docker run -p 8080:8080 http-okay-vlang
❯ curl localhost:8080
OK
Oui! Et le poids ?
❯ docker images | grep http-okay-vlang
http-okay-vlang latest 9ce570aed25e 6 minutes ago 213kB
Oh on atteint les 200ko tout frais compris!! 😍
Mais préparez-vous à faire un bond de géant dans la prochaine partie.
Ziglang
Je vais vous laisser découvrir le langage.
J'en ai réellement très peu fait, je ne vais pas pouvoir vous donner beaucoup de détails sur le langage en lui-même. Je peux simplement vous dire qu'il est compatible avec le C/C++ et donc que l'on peut mixer du zig avec du C.
Il fourni tout un tas de garantie sur la manipulation de la mémoire et de son allocation via le même système de defer
que Golang.
Implémentation
Voici une implémentation possible.
const std = @import("std");
const net = std.net;
pub fn main() anyerror!void {
const localhost = net.Address.parseIp("0.0.0.0", 8080) catch unreachable;
var server = try localhost.listen(.{});
while (server.accept()) |client| {
try client.stream.writer().print("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK", .{});
client.stream.close();
} else |err| {
return err;
}
}
On build!
❯ zig build-exe main.zig --name http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
Au moins ça fonctionne !
Et le poids ?
❯ du -sh http-okay
2.2M http-okay
C'est un peu beaucoup ... 😥 J'avoue ma déception.
Mais attendez, c'est du dynamique ou du statique ?
❯ ldd http-okay
not a dynamic executable
Ah! Mais ça change tout, on peut alors optimiser !
Optimisations
Il existe un petit guide, nous allons le suivre. 😎
Nous allons rajouter les paramètres un à un et voir ce qui se passe.
❯ zig build-exe main.zig -O ReleaseSmall --name http-okay
❯ du -sh http-okay
20K http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
Je... hein !! De quoi ?! Bon ben fin du game 🤣 20 ko !!!!!!!!!
On va rajouter la suite mais je suis déjà très satisfait.
❯ zig build-exe main.zig -O ReleaseSmall -fstrip -fsingle-threaded --name http-okay
❯ du -sh http-okay
12K http-okay
Et ben OK, 8ko de moins, arriver à ce niveau, on accepte. 😄
Comment une telle sorcellerie est possible ?
Je vous est dit que l'on pouvait mixer du C et du zig. C'est justement ce qui se passe, la libc est mixé avec le zig et complètement dépouillé de ce qui n'est pas appellé dedans.
Résultat, le binaire possède les syscalls nécessaire et rien de plus ! Ou en tout cas du code compréhensible comme tel.
Docker
FROM chainguard/zig AS builder
WORKDIR /opt
COPY main.zig .
RUN zig build-exe main.zig -O ReleaseSmall -fstrip -fsingle-threaded --name http-okay
FROM scratch
COPY --from=builder /opt/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
❯ docker build -t http-okay-zig .
❯ docker run -p 8080:8080 http-okay-zig
❯ curl localhost:8080
OK
❯ docker images | grep http-okay-zig
http-okay-zig latest 708eb4985347 About a minute ago 12.1kB
Et du coup notre image finale fonctionnelle, fait 12 ko. 😍
C
La question est donc. Est ce que l'on peut mieux faire ?
C'est à ce moment que je risque de me prendre des coups de fourches dans le derge ^^'
Je vais tenter une implémentation en C qui ne sera pas parfaite mais qui sera suffisante.
Implémentation
Tout étant très compliqué, je vais y aller pas à pas pour ne pas vous paumer.
Parce que clairement, là on va faire du bas-niveau: jusqu'à maintenant la libc était un mirage au loin, on en avait besoin mais on ne la voyait pas directement.
On commence par un main tout simple.
void
Pour construire notre executable on utilise gcc.
❯ gcc main.c -o http-okay
❯ du -sh http-okay
16K http-okay
❯ ldd http-okay
linux-vdso.so.1 (0x00007ffc059e5000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f539e48f000)
/lib64/ld-linux-x86-64.so.2 (0x00007f539e6af000)
Il est comme vous pouvez le voir dynamique et fait 16ko.
Mais pour le moment, il ne fait rien.
Nous allons remédier à ça.
Listener TCP
La première étape est de récupérer un listener TCP.
Mais cela ne va pas être si simple.
Dans les autres langages, il y avait des net.Listen("tcp", "0.0.0.0:8080")
qui facilitait la création du listener.
Cette fois-ci, il faut le faire à la main et donc comprendre les rouages.
Ce qu'il faut comprendre c'est que dans un linux/windows/macos, le programme que vous exécutez n'a pas tous les droits, il ne peut pas atteidre la carte réseau par exemple.
Il doit demander des accès au système d'exploitation et plus particulièrement à une partie privilégiée appelée le kernel
.
Comme dit précédemment, on parle avec lui au-travers des syscalls fournis par la libc.
Pour ranger les choses, le kernel défini des enclaves qui restraignent les processus utilisateurs dans leurs actions.
Une carte réseau ne peut-faire qu'une chose à la fois. Si deux processus y accédaient directement, ils se marcheraient sur les pieds et casserait le fonctionnement de la carte réseau.
Pour pallier à ce problème, un seul processus ne peut y accéder, et ce processus est le kernel, les autres processus n'ont droit que de lui demander gentillement de faire des choses.
Le kernel lui-même, ne pouvant faire qu'une chose à la fois, il va pour satisfaire tout le monde faire ce que l'on a dans le monde réel: des guichets et des files d'attentes.
Il faut imaginer un fast-food ou l'on passe commande, on vous fourni un ticket et vous patientez jusqu'à ce que ça soit votre tour. La cuisine c'est le kernel, le comptoir ou la borne c'est les syscalls.
Socket FD
Du coup, passons commande à la cuisine !
void
Nous allons lui demander de nous fournir un objet spécial qui se nomme un socket
et nous allons lui demander que la ressource ressemble à addresse:port
avec AF_INET
et du TCP via SOCK_STREAM
. Le 0
signifie, "choisi le protocole".
Le retour de la fonction est un entier, littérallement le numéro de guichet où se rendre pour sa commande. En programmation système, on nomme ça un file descriptor
ou "fd". D'où le nom de ma variable. En linux tout est fichier.
Mais il se peut que la cuisine soit débordé, qu'il n'y ait pas le plat ou que l'on ne veuille pas vous servir pour une raison ou pour une autre.
socket
marche de même:
socket() renvoie un descripteur référençant la socket créée en cas de réussite. En cas d'échec -1 est renvoyé, et errno contient le code d'erreur.
Liste des errurs possible
EACCES
La création d'une socket avec le type et le protocole indiqués n'est pas autorisée.
EAFNOSUPPORT
L'implémentation ne supporte pas la famille d'adresses indiquée.
EINVAL
Protocole inconnu, ou famille de protocole inexistante.
EINVAL
Attributs incorrects dans type.
EMFILE
La table des fichiers est pleine.
ENFILE
La limite du nombre total de fichiers ouverts sur le système a été atteinte.
ENOBUFS ou ENOMEM
Pas suffisamment d'espace pour allouer les tampons nécessaires. La socket ne peut être créée tant que suffisamment de ressources ne sont pas libérées.
EPROTONOSUPPORT
Le type de protocole, ou le protocole lui-même n'est pas disponible dans ce domaine de communication.
void
Je vous fais grâce du if
.
fprintf
permet d'écrire dans un fd, et nous utilisons un le fd stderr
pour avertir d'un problème, en linux tout est fichier. Ensuite on formate une chaîne de caractères avec l'erreur du errno
mais au lieu d'afficher un code d'erreur il existe une méthode strerror
qui converti le code en erreur lisible par un humain.
Si ça se passe mal, on coupe tout avec exit(1)
.
Socket Addr
Bon nous avons notre socket, mais pour le moment il ne fait rien, on a juste dit qu'on passerait commande à la cuisine, mais on a pas encore choisi les détails du repas.
Pour cela nous avons besoin de créer deux structures sockaddr_in
. Celle représente un socket Internet avec un port en adresse.
;
;
Une fois que l'on a déclaré le server_address
, il faut maintenant le configurer, mais avant cela, il faut faire le ménage.
En c et en fait dans n'importe quel langage, même si c'est caché, une structure est un espace contigue de données. Ces données en mémoire sont des cases. Et ces cases sont régulièrement réutilisée sans faire le nettoyage derrière. (ouais comme des soirées BDE craignos...).
Or, le kernel n'accepte que les choses bien propre et impose que notre structures se termine par des zéros.
Pour cela, il existe une méthode bzero qui écrit des zéro pour nous. Il lui faut l'adresse de départ et le nombre de zéros à écrire.
Pour l'adresse de départ, c'est facile, c'est celle de la structure server_address
, donc &server_address
.
Pour le nombre de zéros on utilise la taille de la structure via sizeof
fourni par le langage.
Au final, cela donne.
;
Bon, on a une structure toute propre, maintenant nous pouvons définir ses champs.
D'abord le sin_family
server_address.sin_family = AF_INET;
C'est le même que pour la déclaration du socket_fd
, pas de difficultés.
Ensuite le sin_port
.
Cela se complique un peu.
Pour des raisons de lisibilité, on déclare une constante qui contient le port d'écoute.
const unsigned short PORT = 8080;
Puis l'on défini le champ de la structure, mais attention, il y a un piège, le 8080
ne veut rien dire pour la carte réseau qui va faire n'importe quoi!
Pour cela il existe une série de fonctions qui va convertir notre nombre en un autre en changeant l'ordre de lecture des bits.
Notre PORT
étant un unsigned short
, nous utilisons la variante htons
.
server_address.sin_port = ;
Pour l'adresse sin_addr
, on s'aperçoit que c'est elle même une structure du type in_addr
qui possède un unique champ s_addr
qui est cette fois-ci un unsigned long
.
Nous utiliserons donc la méthode htonl
pour faire la conversion.
Mais la conversion de quoi?
Nous vous lui dire: "écoute sur n'importe quel adresse", ce qui se traduit par 0.0.0.0
pour les humains et 0x0
pour la machine, mais au-lieu d'écrire zéro, nous allons utiliser une constante fourni par la libstd: INADDR_ANY
.
server_address.sin_addr.s_addr = ;
Ici le
htonl
ne sert à rien car0x00000000
lu dans un sens ou dans un autre reste0x00000000
.Mais c'est une bonne pratique de le faire.
Pour résumer:
void
Port reuse
Mais nous avons un problème, un port ne peut être attibué qu'à une seul processus.
Ce sont les fameuses erreurs address already in use
que vous avez dans vos langages préférés.
Mais le kernel est bête et discipliné, si on a dit qu'on voulait le port, il nous le donne, et si personne ne lui dit le contraire, il gardera cette information en mémoire et refusera que tout autre processus ne puisse l'utiliser.
Y compris notre processus si on le redémarre. 🙄
Nous allons devoir demander gentillement au kernel de pouvoir réutiliser le port si on n'est plus là.
Pour cela il existe une méthode setsockopt qui permet de définir une variable SO_REUSEADDR
qui peut valoir 1
ou 0
.
Le prototype de setsockopt
est un peu complexe.
int
Il demande un fd, ça on a.
Puis un "level", assez énigmatique, mais la description est assez claire.
When manipulating socket options, the level at which the option resides and the name of the option must be specified. To manipulate options at the sockets API level, level is specified as SOL_SOCKET. To manipulate options at any other level the protocol number of the appropriate protocol controlling the option is supplied. For example, to indicate that an option is to be interpreted by the TCP protocol, level should be set to the protocol number of TCP
En gros toutes les options qui commmence par SO_*
sont du SOL_SOCKET
, SOL
signifiant socket_level
On aurait très bien pu modifier des options sur le TCP (section Socket options), on aurait alors utilisé
IPPROTO_TCP
comme "level" à la place.
Ensuite, on nous demande une référence vers une valeur const void *optval
, (le void*
peut-être n'importe quoi, c'est juste une adresse).
Et finalement, la taille de la valeur.
Le retour lui est un nombre qui vaut -1
si l'opération a échoué. Genre réutiliser un port déjà utilisé.
Cela donne avec la gestion d'erreur.
const int reuse = 1;
const int reuse_addr_response = ;
if
Voilà, nous avons demandé poliment de pouvoir réutiliser le port! ❤️
Bind
C'est bien beau d'avoir décrit ce que l'on désirait via server_address
, mais pour l'instant c'est comme si c'était sur le bon commande non transmis à la cuisine: votre plat n'arrivera jamais !
Il faut créer une commande à partir de ce bon de commande.
Pour cela il existe un syscall nommé bind qui vient lier notre structure avec le socket créé précédemment.
Voici, son prototype.
int ;
Premier paramètre est connu.
Le second est plus énigmatique struct sockaddr*
, il s'agit de l'adresse d'une structure sockaddr
.
Mais on ne possède pas cette structure, notre type est sockaddr_in
et non sockaddr
. Du coup, comment on peut faire?
En C, on raisonne en case mémoires, chaque type possède une taille en mémoire qui est fournie par sizeof
.
- char => 1 case
- short => 2 cases
- long => 4 cases
Pour rappel, sa déclaration est:
; //----------
// 16 cases
Notre structure, rentre dans 16 cases.
sa_data[14]
défini un tableau de 14 éléments, ces 14 éléments sont de type char
, pour avoir sa taille on fait donc 1*14 = 14
.
// 16 cases
Notre structure rentre dans les 16 cases. Mais pourquoi faire ses calculs d'apothicaires?
Et bien au lieu de refabriquer une strucure à la main, et de concaténer (sin_port, sin_addr, sin_zero), on va dire au C, "tkt frère c'est bien l'adresse d'un sockaddr
".
Cette opération se nomme un cast
.
&server_address
Le troisième paramètre, permet d'éviter de lire plus loin que prévu et ne pas dépasser les zéros que l'on a initialisé via bzero
précédemment.
On rassemble le tout et en réalisant la gestion d'erreur
const int bind_response = ;
if
Mais pourquoi on ne peut pas lui donner le `sockaddr_in` directement?
Parce que
sockaddr_in
n'est pas la seule structure que l'on peutbind
.Il existe une version
sockaddr_in6
pour manipuler de l'IPV6 qui est plus grosse.; ;
C'est de cette manière que l'on gère la généricité en C. On donne une adresse de début et de fin, et le kernel se débrouille avec ce tableau de cases qui n'a plus son sens mais qui retransformable en la bonne donnée au besoin via un cast inverse de
sockaddr
verssockaddr_in
ousockaddr_in6
.La taille fournie, permettant de dépasser les limites des 16 cases.
Listen
Nous avons enfin envoyé notre structure socket "entrante" dans le kernel.
Mais si on ne fait rien, il ne se passera... rien.
Il faut maintenant activement demander au kernel "d'écouter" les connection entrantes.
Pour cela il existe un autre syscall qui se nomme listen.
Son prototype est beaucoup plus simple:
int ;
Le paramètre backlog
est le nombre de connexions entrantes que le kernel va bufferiser avant de les refuser. C'est en gros la taille de la file d'attente.
On va mettre de manière arbitraire, 5 connexions max.
Ce qui nous donne le bout de code suivant:
const int listen_response = ;
if
Félicitation vous avez un listener TCP 😄
On résume
C'est le moment de rassembler les bouts.
const unsigned short PORT = 8080;
void
Si on graphe nos appels systèmes, cela donne:
flowchart TD bind("bind()") socket("socket()") setoptsock("setsockopt()") listen("listen()") socket -. optionel .-> setoptsock setoptsock --> bind socket --> bind bind --> listen
Boucle d'acceptation et de réponses
Actuellement le kernel est abonné aux événement sur le 0.0.0.0:8080
, bufferise les demandes jusqu'à 5, puis commence à les refuser.
Accept
Il manque une pièce de puzzle dans notre implémentation: nous devons accepter les connexion entrantes.
Pour cela il existe un autre syscall qui se nomme accept.
Son prototype est
int ;
Premier paramètre, notre bon vieux socket.
Deuxième paramètre, le cast de l'adresse de la structure qui va accueillir la connexion entrante.
Troisième paramètre, la taille de la structure pour les raisons exprimées précédemment.
Le retour est également un entier qui si différent de -1, sera un autre socket FD créé par le kernel pour l'occasion.
La structure "entrante" sera la client_address
.
unsigned int client_socket_len = sizeof;
const int connection_fd = ;
if
Read
Maintenant que l'on sait que quelqu'un tape à la porte, et que l'on a ouvert la porte, nous allons attendre de savoir s'il y a réellement quelqu'un derrière ou si c'est juste une plaisanterie.
Pour vérifier cela, nous allons attendre que notre interlocuteur ne parle le premier.
Pour cela, nous allons avoir besoin de place pour acceillir sa réponse.
Nous allons nous contenter de 1ko de message en définissant un tableau de 1024 char
.
char buf = ;
Puis nous allons utiliser le syscall read
Qui a comme prototype:
ssize_t ;
Le premier paramètre est le socket de la connexion entrante.
Le deuxième l'adresse du tableau de 1ko appelé buffer de lecture.
void*
veut dire : "adresse de quelque chose, quelque soit son type"
Et le troisième paramètre, la taille de ce quelque chose, ici de notre buffer buf
.
La valeur de retour si différente de -1 est la taille à lire vu de notre perspective et la taille écrite vu de la perspective du kernel.
Si on lit plus loin, c'est potentiellement n'importe quoi qui peut s'y trouver: un précédent appel, des données qui n'ont rien à voir, etc ...
Si c'est -1 le retour, alors il y a eu une erreur. La plupart des erreurs sont des échecs de lecture, sauf un qui se nomme EINTR. Le kernel, n'est pas à votre service exclusif, il fait d'autres choses et des choses potentiellement urgentes.
Il peut décider en plein milieu de l'appel que finalement: "ouais, nan, j'ai plus envie de te répondre".
Il faut alors lui refaire la demande.
On va donc boucler sur l'appel et le retenter si ça se passe mal.
char buf = ;
while
A l'instant ou vous sortirez de la boucle, vous saurez que le client est toujours là. (oui on s'en fiche de ce qu'il raconte 🤣).
Write
Maintenant que l'on a la certitude que l'on ne va pas parler dans le vide, il est temps de répondre !
Pour cela on va, vous commencer à connaître la chanson, utiliser un syscall write, qui comme son nom l'indique permet d'écrire. Et d'écrire où? Sur la socket du client bien-sûr.
Son prototype est:
ssize_t ;
Premier paramètre, le socket, deuxième le buffer de données à écrire, troisième, le nombre de bytes à écrire depuis ce buffer.
Le retour si ce n'est pas -1 sera la taille écrite, si la taille écrite est inférieur à la taille voulu alors il faut écrire la taille manquante.
On va d'abord se faire notre message à écrire
const char msg = "HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: 2\r\n\r\n"
"OK\r\n";
Ensuite on créé notre boucle d'écriture.
int offset = 0;
while
On définie un offset
à 0, et si on a pas tout écrit au tour courant, on recommence en se déplaçant d'autant de caractères écrits.
On rajoute la gestion d'erreur.
int offset = 0;
while
Même principe que pour read
, le kernel à peut-être d'autre chose à faire et peu interrompre le syscall.
On wrap notre boucle dans une autre.
int offset = 0;
// Boucle d'écriture
while
On rassemble tout
Voici à la fois la lecture et l'écriture.
// On attend au moins un paquet de l'utilisateur
char buf = ;
while
const char msg = "HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: 2\r\n\r\n"
"OK\r\n";
int offset = 0;
while
On va enfin pouvoir compiler !main.c
const unsigned short PORT = 8080;
void
Ce close est nécessaire pour expliquer au programme que l'on n'a plus besoin du socket, sinon il restera réservé.
Et au bout de quelques milliers de requêtes. Le système plantera.
❯ gcc main.c -o http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
Bon, c'est prometteur 😍
Mais petit soucis, le programme s'arrête à la première réponse donnée, on accepte une connexion, on répond et on meurt.
Boucler sur les connexions entrantes
Il est temps de boucler.
Pour commencer on vient wrapper notre système de réponse dans une fonction qui prend en paramètre le socket de la connexion entrante.
void
Puis boucler sur les acceptations.
while
On peut désormais lancer autant de requête que l'on veut. 🤩
❯ gcc main.c -o http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
Sortie du programme
Si vous faite un ctrl+c pour quitter le programme, il sort avec un code d'erreur différent de 0.
C'est un peu sale, on peut améliorer les choses en rajoutant ce que l'on appel un signal handler, qui a pour tâche de faire ce qui correct pour gérer le signal, ici de sortie.
Puion déclare s une fonction sans paramètre.
// on déclare le socket_fd en global pour pouvoir le fermer
int socket_fd;
static void
Ensuite, au début du programme, nous enregistrons ce handler pour le signal SIGINT.
void
Désormais, le programme se quittera sans erreurs.main.c
const unsigned short PORT = 8080;
int socket_fd;
static void
void
void
Bon on a enfin notre programme. 🤣
Cela a necessité un peu plus de casse-tête que prévu.
❯ gcc main.c -o http-okay
Bon et sa taille et son linkage ?
❯ du -sh http-okay
20K http-okay
❯ ldd http-okay
linux-vdso.so.1 (0x00007ffd5916e000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f45f6c83000)
/lib64/ld-linux-x86-64.so.2 (0x00007f45f6ea3000)
Okay ! 20ko et dynamique.
Optimisations
Maintenant il est temps d'optimiser la taille.
Compilation statique
On sait que lib.c.so.6
eest gros, bien plus que ce que l'on désire.
❯ du -sh /lib/x86_64-linux-gnu/libc.so.6
2.0M /lib/x86_64-linux-gnu/libc.so.6
La somme de la libc et du binaire, va forcément être largement supérieur au 1 Mo, or, nous on veut descendre sous les 20ko.
Nous savons que le C est suffisamment intelligent pour optimiser ce dont il a besoin. Comme nous ne partageront avec personne la libc puisque notre image docker sera le seul binaire de notre image docker. Il n'est pas déconnant de la faire rentrer dans notre binaire.
❯ gcc main.c -o http-okay
❯ du -sh http-okay
764K http-okay
❯ ldd http-okay
not a dynamic executable
C fait son boulot et nettoie ce qui doit l'être, la somme des deux fait largement moins que 2 Mo. On progresse 😃
Supression des symboles de debug
Afin que les outils puisse marcher lors du debug, le compilateur va créer ce que l'on nomme une table des symboles qui peut-être visualisé via nm.
❯ nm ./http-okay | head
00000000004845e0 r CSWTCH.100
0000000000484600 r CSWTCH.97
00000000004845e0 r CSWTCH.98
0000000000484600 r CSWTCH.99
00000000004a8108 V DW.ref.__gcc_personality_v0
000000000047c008 R PORT
0000000000497d94 W _.stapsdt.base
000000000041c000 W _Exit
00000000004a7fe8 d _GLOBAL_OFFSET_TABLE_
00000000004a8140 D _IO_2_1_stderr_
C'est bien pour le debug en dev, mais ça rajoute du poids supplémentaire donc "AFUERA!!" 🪒
Pour cela on utilise un autre utilitaire qui se nomme strip
❯ strip http-okay
❯ du -sh http-okay
684K http-okay
❯ nm ./http-okay
nm: ./http-okay: no symbols
On gagne 80ko. 😃
Désormais toutes les tailles seront strippées.
Optimisation par le compilateur
Le compilateur est capable de tout un tas d'optimisations.
Malheureusement, elles n'ont aucun impact sur notre programme qui est très simple et déjà très compact 😥
❯ gcc main.c -static -o http-okay
❯ du -sh http-okay
684K http-okay
❯ gcc main.c -static -O1 -o http-okay
❯ du -sh http-okay
684K http-okay
❯ gcc main.c -static -O3 -o http-okay
❯ du -sh http-okay
684K http-okay
❯ gcc main.c -static -Os -o http-okay
❯ du -sh http-okay
684K http-okay
❯ gcc main.c -static -Oz -o http-okay
❯ du -sh http-okay
684K http-okay
Remplacer le compilateur
Il existe d'autre compilateur nommé Clang qui possède également son set d'optimisations.
En théorie, il peut créer des binaires plus petits.
En théorie...
❯ clang main.c -static -Oz -o http-okay
❯ du -sh http-okay
684K http-okay
Changer de libc
La libc est lourde, elle fait 2 Mo, elle contient toute l'histoire de l'informatique, jamais on ne battera zig en essayant d'optimiser ça.
Nous avons besoin que de syscalls très rudimentaires.
- socket
- setsockopt
- bind
- listen
- accept
- read
- write
Et quelques structures comprises par le kernel. Mais nous embarquons la terre entière.
Cette problématique je ne suis pas le seul à l'avoir, d'autre l'on eu et cela à déboucher au projet musl, qui n'est ni plus ni moins qu'une réimplémentation de la libc de manière opinated pour la rendre la plus légère possible.
Elle vient avec son propre compilateur.
❯ musl-gcc main.c -static -Oz -o http-okay
❯ du -sh http-okay
32K http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
Le code est parfaitement fonctionnel et ne fait plus que 32Ko. 😍
UPX
Mais on peut encore descendre en compressant le bianaire via UPX.
❯ upx -9 --ultra-brute http-okay
❯ du -sh http-okay
20K http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
Et on descends à 20ko comme promis dans le titre de l'article. 😎
Utiliser zig pour compiler le C
Mais du coup, si zig est si efficace à compiler du C, pourquoi ne pas le faire compiler notre programme ?
Il clame
Zig is better at using C libraries than C is at using C libraries
Et bien vérifions leurs dires.
❯ zig build-exe main.c --library c --name http-okay
❯ du -sh http-okay
8.0K http-okay
❯ ldd http-okay
linux-vdso.so.1 (0x00007ffe5a7da000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f23b9c54000)
/lib64/ld-linux-x86-64.so.2 (0x00007f23b9e6f000)
❯ gcc main.c -o http-okay
❯ du -sh http-okay
16K http-okay
Ok, ça compile bien et ça créé bien un binaire 2 fois inférieur en taille à celui produit par gcc.
Mais pour le moment c'est du dynamique.
Pour passer en statique, il faut lui dire d'utiliser musl à la place de glibc. Pour cela nous utilisons la -target x86_64-linux-musl
❯ zig build-exe main.c --library c --name http-okay -target x86_64-linux-musl
❯ du -sh http-okay
24K http-okay
❯ ldd http-okay
not a dynamic executable
❯ upx -9 --ultra-brute http-okay
❯ du -sh http-okay
20K http-okay
Et on retombe sur les 20ko.
Docker
Je fais le build avec zig mais ça aurait également pu se faire avec musl-gcc
FROM chainguard/zig AS zig_build
WORKDIR /opt
COPY void.c main.c
RUN zig build-exe main.c --library c --name http-okay -target x86_64-linux-musl
FROM gcc as upx
WORKDIR /opt
COPY --from=zig_build /opt/http-okay http-okay
RUN wget https://github.com/upx/upx/releases/download/v4.2.4/upx-4.2.4-amd64_linux.tar.xz
RUN tar -xf upx-4.2.4-amd64_linux.tar.xz
RUN cp upx-4.2.4-amd64_linux/upx /usr/bin/upx
RUN strip http-okay
FROM scratch
COPY --from=upx /opt/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
❯ docker build -t http-okay-c .
❯ docker run -p 8080:8080 http-okay-c
❯ curl localhost:8080
OK
❯ docker images | grep http-okay-c
http-okay-c latest 708eb4985347 About a minute ago 22.1kB
On a frôlé les 20ko, mais je n'ai pas trouvé comment descendre.
J'ai tenté de remplacer le main
pour une méthode _start
, mais je n'ai pas réussi à compiler après. Je me retrouvais avec des duplications de symboles, impossibles à régler.
ASM
Cette section c'est juste la récréation, je ne vais pas essayer d'expliquer tout ^^'
L'assembleur est au plus prêt de la machine, c'est ici que l'on peut faire les plus grosse optimisations.
On va donc complètement se débarasser de la libc ou de musl et réécrire à la main les instructions que la libc génère pour le CPU.
Implémentation et construction du binaire
Le code vient de ce repo. Il est entièrement commenté. 😎
GLOBAL _start
%define AF_INET 2
%define SOCK_STREAM 1
%define IPPROTO_TCP 6
%define SYS_SOCKETCALL 102
%define SYS_WRITE 4
%define SYS_CLOSE 6
%define CALL_SOCKET 1
%define CALL_BIND 2
%define CALL_LISTEN 4
%define CALL_ACCEPT 5
%define QUEUE_SIZE 0x7f ;Keep it 7-bit
%macro MAKE_PORT 1
db %1 >> 8, %1 & 0xff
%endm
%macro MAKE_INTERFACE 4
db %1, %2, %3, %4
%endm
%macro smov 2
%defstr reg %1
%substr reg_id reg 2
%strcat byte_reg_str reg_id, "l"
%deftok byte_reg byte_reg_str
xor %1, %1
mov byte_reg, %2
%endm
SECTION .text
_start:
;--- Create the socket ---
push IPPROTO_TCP ;Making DWORDs from 8-bit constants using the
push SOCK_STREAM ;stack is 2*n_const vs 4*n_const of using dd
push AF_INET
smov ebx, CALL_SOCKET ;Shorter form of mov r32, imm8 using xor + mov
call socketcall
;--- Bind the socket ---
push 0x10 ;Using the stack here is again a win
push inet_addr
push eax
mov bl, CALL_BIND
call socketcall ;NOTE: this will put the first parameter in edi
;In this case it will put eax (socket descriptor)
;in edi
;--- Make the socket listen ---
_listen:
push QUEUE_SIZE ;Again using the stack we get away with
push edi ;just 4 bytes
mov bl, CALL_LISTEN
call socketcall
_server_loop:
;--- Accept ---
push 0
push 0
push edi
mov bl, CALL_ACCEPT
call socketcall
push eax ;Save for later
;--- Write the response ---
mov ebx, eax
smov eax, SYS_WRITE
mov ecx, html
smov edx, html_len
int 0x80
;--- Close the new socket ---
pop ebx
mov al, SYS_CLOSE ;eax[31:8] was 0 from the previous syscall (if no errors)
int 0x80
jmp _server_loop
;====================================
socketcall:
;ebx must be set by the caller
smov eax, SYS_SOCKETCALL
lea ecx, [esp+4]
int 80h
mov edi, DWORD [esp+4] ;Return in edi the first parameter of syscall
;This is usually the socket descriptor
ret 4 ;We clean up only ONE parameter
;This is ok since every use has at least one
;parameter and in the server_loop spares us from
;rebalancing the stack
;====================================
;We can put read only data in the code section. It's bad for performance but
;we only care about space
html: db "HTTP/1.0 200 OK", 13, 10, "Content-Length: 2", 13, 10, 13, 10, "Ok"
html_len EQU $-html
inet_addr:
dw AF_INET
MAKE_PORT 8080
MAKE_INTERFACE 0, 0, 0, 0
;The MAKE_INTERFACE is commented because on x86 a page is always allocated
;and zeroed by the kernel. So we know there are zeros after our code.
;Thus we get a free MAKE_INTERFACE 0, 0, 0, 0
;One can also fuse AF_INET and the port, if this is lower than 256, using:
;
;db AF_INET, 0, <port-number>
Contrairement a du C, l'assembleur ne se compile pas. Il se traduit.
On utilise pour cela un utilitaire nommé nasm qui va traduire pour nous cet assembleur en un fichier appelé objet.
❯ nasm -f elf32 http.asm -o http-okay.o
Le elf32
signifie "architecture 32 bits".
❯ file http-okay.o
http-okay.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
Ce fichier n'est pas encore executable, il a besoin d'être "lié" via l'utilitaire ld.
Il est nécessaire de lui fournir l'architecture ici du 32 bits symbolisé par elf_i386
.
❯ ld -m elf_i386 http-okay.o -o http-okay
❯ du -b http-okay
4756 http-okay
❯ ldd http-okay
not a dynamic executable
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
du
n'est plus assez fin pour déterminer la taille du fichier, c'est pour ça qu'on le passe en "compte d'octets" car sinon il renverra 4 Ko pour tout nos essais.C'est dû à la taille des bloc dans le disque
Et bien ça fait 4ko et on a même pas optimisé. 🤣
Optimisations
On a deux optimisations.
Une est assez violente, on va faire sauter une sécu qui va rendre inscrivible une partie de mémoire qui devrait être "read-only".
Et fait sauter tout un tas de garde-fous qui rende définitivement notre monstre de frankenstein très dangereux pour sa petite taille. 😅
--omagic
Set the text and data sections to be readable and writable. Also, do not page-align the data segment, and disable linking against shared libraries. If the output format supports Unix style magic numbers, mark the output as "OMAGIC". Note: Although a writable text section is allowed for PE-COFF targets, it does not conform to the format specification published by Microsoft.
Mais nous ne sommes pas là pour ça. Donc on brûle, on pille, on détruit "ARHH".
❯ ld -m elf_i386 --omagic http-okay.o -o http-okay
ld: warning: http-okay has a LOAD segment with RWX permissions
❯ du -b http-okay
756 http-okay
❯ ldd http-okay
not a dynamic executable
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
ld
fait clairement la gueule, mais laisse passer et notre binaire toujours fonctionnel descend sous le 1ko.
Si on fait un coup de nm
, on s'aperçoit qu'il y a du monde. Très peu certes. Mais comme notre unité est maintenant l'octet, ça commence à se sentir.
❯ nm ./http-okay
080480ee T __bss_start
080480ee T _edata
080480f0 T _end
0804807e t _listen
08048088 t _server_loop
08048060 T _start
080480be t html
00000028 a html_len
080480e6 t inet_addr
080480ad t socketcall
Et oui, ce sont les étiquettes de notre assembleur, écrit en toutes lettres dans le binaire.
On continue notre épuration.
❯ strip http-okay
❯ nm http-okay
nm: http-okay: no symbols
❯ du -b http-okay
376 http-okay
❯ upx -9 --ultra-brute http-okay
upx: http-okay: IOException: file is too small
UPX rend les armes.
Et hop, binaire coupe 2 ! 🔪 376 octets 🤩🤩🤩
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
Et il continue à tourner. 😎
Docker
Notre beau docker.
FROM emilienmottet/nasm AS builder
WORKDIR /opt
COPY http.asm .
RUN nasm -felf32 http.asm -o http-okay.o
RUN ld --strip-all --omagic -melf_i386 http-okay.o -o http-okay
FROM scratch
COPY --from=builder /opt/http-okay /usr/bin/http-okay
ENTRYPOINT ["/usr/bin/http-okay"]
Que l'on build et exécute.
❯ docker build -t http-okay-asm .
❯ docker images | grep http-okay-asm
http-okay-asm latest 20664594c5a5 18 seconds ago 376B
❯ docker run -it -p 8080:8080 http-okay-asm
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK
On arrive donc à une image de 376 octets qui fait aussi bien que des images 1000 fois plus lourde.
N'UTILISEZ PAS CA EN PROD !!!
Conclusion
On résume
❯ docker images | grep http-okay
http-okay-asm latest 20664594c5a5 19 minutes ago 376B
http-okay-zig latest 708eb4985347 7 days ago 12.1kB
http-okay-c latest c8e644f8d1ec 5 hours ago 22.1kB
http-okay-vlang latest 9ce570aed25e 7 days ago 213kB
http-okay-rust latest a3a324028a28 6 weeks ago 440kB
http-okay-go latest 8d401a6283f4 7 days ago 683kB
L'assembleur est donc premier avec une avance absurde.
Le zig est le seul qui descend sous les 20 ko.
Puis vient le C à tout juste 20 ko.
Puis le V qui est extraordinairement efficace.
Viens ensuite le Rust qui est handicapé par son ownership et toutes les sécurités qu'il porte.
Puis le Go qui a son runtime à intégrer dans le binaire.
Je suis certain que Rust et Vlang et peut-être go peuvent descendre en utilisant musl au lieu de la glibc, mais je n'ai pas réussi.
Comme vous l'avez remarquez, le but de l'article n'était qu'un immense prétexte pour parler de pleins de choses. 😄
J'espère que vous avez pu apprendre deux ou trois choses.
Merci de votre lecture. ❤️
Ce travail est sous licence CC BY-NC-SA 4.0.