Un serveur HTTP de moins de 20 Ko
Lecture 35 min. âą
Table des matiĂšres
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/somethingJe 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.
main.rs
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(())
}
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}")
}
}
}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
OKSuccĂšs âïž
Et la taille?
⯠du -sh target/debug/http-okay
3.9M target/debug/http-okayAh 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 --releaseEt câest cette fois-ci bien plus acceptable.
⯠du -sh target/release/http-okay
448K target/release/http-okayNous 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:
Cargo.toml
[profile.release]
# exécute la commande strip sur le binaire pour retirer les symboles d'informations
# 448K => 368K
strip = true
# on optimise en taille le binaire
# 368K => 368K
opt-level = "z"
# on active le LTO : optimisation pendant le linkage (suppression de code mort)
# 368K => 320K
lto = true
# on empĂȘche le compilateur de parallĂ©liser pour lui ouvrir des chemins d'optimisation
# 320K => 320K
codegen-units = 1
# on enlÚve la possibilité d'unwind la stacktrace
# 320K => 316K
panic = "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.
main.rs
fn main() {}⯠cargo build --release
⯠du -sh target/release/http-okay
300K target/release/http-okayOn 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.
main.rs
// on indique Ă Rust que l'on ne veut pas de main
#![no_main]
// on indique à rust d'appeler "main" réellement "main" dans le binaire
#[no_mangle]
fn main() {}Cela nous donne
⯠du -sh target/release/http-okay
284K target/release/http-okayOui, 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
main.rs
#![no_std]
#![no_main]
extern crate libc;
use core::panic::PanicInfo;
#[no_mangle]
fn main() -> u8 {
0
}
// Cette fonction est nécessaire, sinon cela ne compile pas
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}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.
Cargo.toml
libc = { version = "0.2.158" , default-features = 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 $?
0Ok, ç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.
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}")
}
}
}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-okayOn 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
OKDocker
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.
Dockerfile
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 --releaseOn 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
OKParfait. đ
Maintenant que jâai le binaire, plus quâĂ faire lâimage FROM scratch.
Dockerfile
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 directoryAh! 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.
Dockerfile
FROM rust AS builder
# ... <le reste du stage>
RUN cargo build --releaseOn 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.
Dockerfile
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
OKEt 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.56MB3 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'.
Dockerfile
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
OKBon, 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.22MBEt 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.
Dockerfile
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
OKEt 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.14MBUPX
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
upx -9 --ultra-brute /path/to/binaryCe qui donne le Dockerfile suivant.
Dockerfile
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 440kB440 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
main.go
package main
import (
"fmt"
"net"
"os"
)
// Fermeture du socket
func closeConnection(conn net.Conn) {
fmt.Printf("Closing connection %s\n", conn.RemoteAddr())
err := conn.Close()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Unable to accept connection : %s\n", err)
os.Exit(1)
}
}
// Logique de gestion du socket
func handleConnection(conn net.Conn, err error) {
// fonction lancée à la sortie de la fonction
defer closeConnection(conn)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Unable to accept connection : %s\n", err)
// il faut l'appelée explicitement ici car les defer ne sont pas pris en compte en cas d'exit
closeConnection(conn)
os.Exit(1)
}
// on écrit dans le socket
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length:2\r\n\r\nOK"))
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Unable to write to connection : %s\n", err)
}
}
func main() {
address := "0.0.0.0:8080"
// on créé le listener
listener, err := net.Listen("tcp", address)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Unable to listen to port : %s => %s\n", address, err)
// on quitte sinon c'est segfault
os.Exit(1)
}
// on boucle sur les évÚnements à venir
for {
// on accepte les connexions entrantes
conn, err := listener.Accept()
handleConnection(conn, err)
}
}On build!
go build -o http-okayLe code fonctionne.
⯠./http-okay
Closing connection 127.0.0.1:48006
⯠curl localhost:8080
OKEt arrive Ă :
⯠du -sh http-okay
3.0M http-okayCâ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 droppedIl 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 := new(Ressource)
defer x.Clean()
x.Init()
// on fait des trucs ...
return
} // 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-okayOn 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-okayLes 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-okayDans 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-okayEt 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-okayFaire 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.
Dockerfile
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 699kBQue 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 $?
127En 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
OKCâ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.83MBAouch ! 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
OKEt la taille?
⯠docker images http-okay-go
REPOSITORY TAG IMAGE ID CREATED SIZE
http-okay-go latest 1de49c02ca76 About a minute ago 683kBAllez! 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 setPourquoi? 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.
main.v
module main
import net
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 da3112eOn build, vous aller voir que V va droit au but đ
⯠v main.vVoilĂ câest compilĂ©.
Il devrait vous avoir créé ce quâil faut
⯠tree
.
âââ main
âââ main.v
âââ v.modSi vous voulez renommer le programme créé vous pouvez:
⯠v main.v -o http-okay
⯠tree
.
âââ http-okay
âââ main.v
âââ v.modEst-ce quâil fonctionne?
⯠./http-okay
⯠curl localhost:8080
OKVisiblement oui.
Du coup, le poids du binaire est de combien ?
⯠du -sh http-okay
1.3M http-okayBon, 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-staticOptimisations
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-staticAh 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-okay4Ko, 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-okayMais du coup le binaire est coupĂ© en deux đ€©
Et je nâai pas rĂ©ussis Ă descendre plus bas.
Docker
Fabriquons notre container
Dockerfile
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
OKOui! Et le poids ?
⯠docker images | grep http-okay-vlang
http-okay-vlang latest 9ce570aed25e 6 minutes ago 213kBOh 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.
main.zig
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
OKAu moins ça fonctionne !
Et le poids ?
⯠du -sh http-okay
2.2M http-okayCâest un peu beaucoup ⊠đ„ Jâavoue ma dĂ©ception.
Mais attendez, câest du dynamique ou du statique ?
⯠ldd http-okay
not a dynamic executableAh! 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
OKJe⊠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-okayEt 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
Dockerfile
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.1kBEt 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.
main.c
void main() {}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 !
main.c
#include <sys/socket.h>
void main() {
const int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
}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.main.c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
void main() {
// demande du socket fd
const int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
// vérification que tout s'est bien passé
if (socket_fd == -1) {
fprintf(stderr,"Unable to create socket file descriptor: %s", strerror(errno));
// on quitte en erreur
exit(1);
}
}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.
struct sockaddr_in {
short sin_family; // e.g. AF_INET
unsigned short sin_port; // e.g. htons(3490)
struct in_addr sin_addr; // see struct in_addr, below
};
struct in_addr {
unsigned long s_addr; // load with inet_aton()
};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.
bzero(&server_address, sizeof(server_address));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 = htons(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 = htonl(INADDR_ANY);Ici le
htonlne sert Ă rien car0x00000000lu dans un sens ou dans un autre reste0x00000000.Mais câest une bonne pratique de le faire.
Pour résumer:
#include <netinet/in.h>
void main() {
// demande du socket fd
// ...
// On déclare deux structure de type `sockaddr_in`
// l'une pour le serveur, l'autre pour les clients
// qui arriverons
struct sockaddr_in server_address, client_address;
// On nettoie les données précédentes
bzero(&server_address, sizeof(server_address));
// On définie les caractéristiques voulues pour notre socket
server_address.sin_family = AF_INET;
server_address.sin_port = htons(PORT);
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
}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 setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen)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 TCPEn 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_TCPcomme â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 = setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (reuse_addr_response == -1) {
fprintf(stderr, "Unable to set SO_REUSEADDR to socket");
exit(1);
}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 bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);Premier paramĂštre est connu.
Le second est plus Ă©nigmatique struct sockaddr*, il sâagit de lâadresse dâune structure sockaddr.
struct sockaddr {
unsigned short int sa_family;
char sa_data[14];
}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:
struct sockaddr_in {
short sin_family; // 2 case
unsigned short sin_port; // 2 cases
unsigned long sin_addr.s_addr; // 4 cases
char sin_zero[8] // 8 cases <= des zéros rajoutés automatiquement
}; //----------
// 16 casesNotre 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.
struct sockaddr {
unsigned short int sa_family; // 2 cases
char sa_data[14]; // 14 cases
//--------
} // 16 casesNotre 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.
(struct sockaddr *)&server_addressLe 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 = bind(socket_fd, (struct sockaddr *)&server_address, sizeof(server_address));
if (bind_response == -1) {
fprintf(stderr, "Unable to bind the socket to file descriptor : %s", strerror(errno));
exit(1);
}Mais pourquoi on ne peut pas lui donner le
sockaddr_indirectement?Parce que
sockaddr_innâest pas la seule structure que lâon peutbind.Il existe une version
sockaddr_in6pour manipuler de lâIPV6 qui est plus grosse.struct sockaddr_in6 { sa_family_t sin6_family; // 2 in_port_t sin6_port; // 2 uint32_t sin6_flowinfo; // 4 struct in6_addr sin6_addr; // 16 uint32_t sin6_scope_id; // 4 // ----- // 28 }; struct in6_addr { uint8_t s6_addr[16]; // 16 * 1 = 16 };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
sockaddrverssockaddr_inousockaddr_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 listen(int sockfd, int backlog);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 = listen(socket_fd, 5);
if (listen_response != 0) {
fprintf(stderr, "Unable to listen to port %d: %s", PORT, strerror(errno));
exit(1);
}
FĂ©licitation vous avez un listener TCP đ
On résume
Câest le moment de rassembler les bouts.
main.c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
const unsigned short PORT = 8080;
void main() {
// Création du socket cÎté kernel
const int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd == -1) {
fprintf(stderr,"Unable to create socket file descriptor: %s", strerror(errno));
exit(1);
}
// Création des structures qui vont acceuillir la connexion
// d'écoute et les connexions entrantes
struct sockaddr_in server_address, client_address;
// On nettoie les données précédemment existantes
bzero(&server_address, sizeof(server_address));
// On indique que l'on veut une IPV4 sur 0.0.0.0:8080
server_address.sin_family = AF_INET;
server_address.sin_port = htons(PORT);
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
// On demande de pouvoir réutiliser le port si c'est possible
const int reuse = 1;
const int reuse_addr_response = setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (reuse_addr_response == -1) {
fprintf(stderr, "Unable to set SO_REUSEADDR to socket");
exit(1);
}
// On lie notre socket avec la structure dans notre code
const int bind_response = bind(socket_fd, (struct sockaddr *)&server_address, sizeof(server_address));
if (bind_response == -1) {
fprintf(stderr, "Unable to bind the socket to file descriptor : %s", strerror(errno));
exit(1);
}
// On demander de bufferiser 5 connexions entrantes
const int listen_response = listen(socket_fd, 5);
if (listen_response != 0) {
fprintf(stderr, "Unable to listen to port %d: %s", PORT, strerror(errno));
exit(1);
}
}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 accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);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(client_address);
const int connection_fd = accept(socket_fd, (struct sockaddr*)&client_address, &client_socket_len);
if (connection_fd == -1) {
fprintf(stderr, "Unable to accept incoming connection : %s", strerror(errno));
}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[1024] = {0};Puis nous allons utiliser le syscall read
Qui a comme prototype:
ssize_t read(int fd, void *buf, size_t count);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[1024] = {0};
while (1) {
const int read_size = read(socket_fd,&buf, sizeof(buf));
if (read_size == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to read socket : %s\n", strerror(errno));
exit(1);
}
break;
}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 write(int fd, const void *buf, size_t nbyte);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 (offset < strlen(msg)) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
offset += write_response;
}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 (offset < strlen(msg)) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
if (write_response == -1) {
printf("Unable to write socket : %s\n", stderr(errno));
exit(1);
}
offset += write_response;
}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 (offset < strlen(msg)) {
// Boucle d'interruption
while (1) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
// Gestion d'erreur
if (write_response == -1) {
// On rejoue l'écriture
if (errno == EINTR) {
continue;
}
// Ou on quitte
printf("Unable to write socket : %s\n", stderr(errno));
exit(1);
}
offset += write_response;
break;
}
}On rassemble tout
Voici Ă la fois la lecture et lâĂ©criture.
// On attend au moins un paquet de l'utilisateur
char buf[1024] = {0};
while (1) {
const int read_size = read(connection_fd,&buf, sizeof(buf));
if (read_size == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to read socket : %s\n", strerror(errno));
exit(1);
}
break;
}
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 (offset < strlen(msg)) {
printf("offset : %d\n", offset);
while (1) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
// replay writing
if (write_response == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to write socket : %s\n", strerror(errno));
exit(1);
}
offset += write_response;
break;
}
}On va enfin pouvoir compiler !main.c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
const unsigned short PORT = 8080;
void main() {
const int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd == -1) {
fprintf(stderr,"Unable to create socket file descriptor: %s\n", strerror(errno));
exit(1);
}
// Socket struct
struct sockaddr_in server_address, client_address;
// Fill with zeros the structure
bzero(&server_address, sizeof(server_address));
// Define socket characteristics
server_address.sin_family = AF_INET;
server_address.sin_port = htons(PORT);
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
const int reuse = 1;
const int reuse_addr_response = setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (reuse_addr_response == -1) {
fprintf(stderr, "Unable to set SO_REUSEADDR to socket\n");
exit(1);
}
// Bind struct to fd
const int bind_response = bind(socket_fd, (struct sockaddr *)&server_address, sizeof(server_address));
if (bind_response == -1) {
fprintf(stderr, "Unable to bind the socket to file descriptor : %s\n", strerror(errno));
exit(1);
}
// Listen for connections
const int listen_response = listen(socket_fd, 5);
if (listen_response != 0) {
fprintf(stderr, "Unable to listen to port %d: %s", PORT, strerror(errno));
exit(1);
}
unsigned int client_socket_len = sizeof(client_address);
const int connection_fd = accept(socket_fd, (struct sockaddr*)&client_address, &client_socket_len);
if (connection_fd == -1) {
fprintf(stderr, "Unable to accept incoming connection : %s\n", strerror(errno));
}
// Wait for client message
char buf[1024] = {0};
while (1) {
const int read_size = read(connection_fd,&buf, sizeof(buf));
if (read_size == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to read socket : %s\n", strerror(errno));
exit(1);
}
break;
}
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 (offset < strlen(msg)) {
while (1) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
// replay writing
if (write_response == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to write socket : %s\n", strerror(errno));
exit(1);
}
offset += write_response;
break;
}
}
// on oublie pas de fermer le socket lorsque l'on en a plus besoin
close(connection_fd);
close(socket_fd);
}
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
OKBon, 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 response_to_client(int connection_fd) {
// Wait for client message
char buf[1024] = {0};
while (1) {
const int read_size = read(connection_fd,&buf, sizeof(buf));
if (read_size == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to read socket : %s\n", strerror(errno));
exit(1);
}
break;
}
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 (offset < strlen(msg)) {
while (1) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
// replay writing
if (write_response == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to write socket : %s\n", strerror(errno));
exit(1);
}
offset += write_response;
break;
}
}
}Puis boucler sur les acceptations.
while (1) {
unsigned int client_socket_len = sizeof(client_address);
const int connection_fd = accept(socket_fd, (struct sockaddr*)&client_address, &client_socket_len);
if (connection_fd == -1) {
fprintf(stderr, "Unable to accept incoming connection : %s\n", strerror(errno));
}
response_to_client(connection_fd);
close(connection_fd);
}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
OKSortie 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 signal_int_handler() {
printf("\nExiting\n");
close(socket_fd);
exit(0);
}Ensuite, au début du programme, nous enregistrons ce handler pour le signal SIGINT.
void main() {
signal(SIGINT, signal_int_handler);
// ...
}Désormais, le programme se quittera sans erreurs.main.c
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
const unsigned short PORT = 8080;
int socket_fd;
static void signal_int_handler() {
printf("\nExiting\n");
close(socket_fd);
exit(0);
}
void response_to_client(int connection_fd) {
// Wait for client message
char buf[1024] = {0};
while (1) {
const int read_size = read(connection_fd,&buf, sizeof(buf));
if (read_size == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to read socket : %s\n", strerror(errno));
exit(1);
}
// Si on ne lit rien c'est que le client est parti
if (read_size == 0) {
return;
}
break;
}
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 (offset < strlen(msg)) {
while (1) {
int write_response = write(connection_fd, msg+offset, strlen(msg+offset));
// replay writing
if (write_response == -1) {
if (errno == EINTR) {
continue;
}
printf("Unable to write socket : %s\n", strerror(errno));
exit(1);
}
offset += write_response;
break;
}
}
}
void main() {
signal(SIGINT, signal_int_handler);
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd == -1) {
fprintf(stderr,"Unable to create socket file descriptor: %s\n", strerror(errno));
exit(1);
}
// Socket struct
struct sockaddr_in server_address, client_address;
// Fill with zeros the structure
bzero(&server_address, sizeof(server_address));
// Define socket characteristics
server_address.sin_family = AF_INET;
server_address.sin_port = htons(PORT);
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
const int reuse = 1;
const int reuse_addr_response = setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
if (reuse_addr_response == -1) {
fprintf(stderr, "Unable to set SO_REUSEADDR to socket\n");
exit(1);
}
// Bind struct to fd
const int bind_response = bind(socket_fd, (struct sockaddr *)&server_address, sizeof(server_address));
if (bind_response == -1) {
fprintf(stderr, "Unable to bind the socket to file descriptor : %s\n", strerror(errno));
exit(1);
}
// Listen for connections
const int listen_response = listen(socket_fd, 5);
if (listen_response != 0) {
fprintf(stderr, "Unable to listen to port %d: %s", PORT, strerror(errno));
exit(1);
}
while (1) {
unsigned int client_socket_len = sizeof(client_address);
const int connection_fd = accept(socket_fd, (struct sockaddr*)&client_address, &client_socket_len);
if (connection_fd == -1) {
fprintf(stderr, "Unable to accept incoming connection : %s\n", strerror(errno));
}
response_to_client(connection_fd);
close(connection_fd);
}
}
Bon on a enfin notre programme. đ€Ł
Cela a necessitĂ© un peu plus de casse-tĂȘte que prĂ©vu.
⯠gcc main.c -o http-okayBon 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.6La 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 executableC 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 symbolsOn 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-okayRemplacer 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-okayChanger 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
OKLe 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
OKEt 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-okayOk, ç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-okayEt 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.1kBOn 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Ă©. đ
http.asm
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.oLe elf32 signifie âarchitecture 32 bitsâ.
⯠file http-okay.o
http-okay.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not strippedCe 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
dunâ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
OKld 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 socketcallEt 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 smallUPX rend les armes.
Et hop, binaire coupe 2 ! đȘ 376 octets đ€©đ€©đ€©
⯠./http-okay
⯠curl localhost:8080
OK
⯠curl localhost:8080
OKEt il continue Ă tourner. đ
Docker
Notre beau docker.
Dockerfile
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
OKOn 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 683kBLâ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. â€ïž
