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/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.

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
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:

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-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.

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-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

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 $?
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.

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-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.

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 --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.

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 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.

Dockerfile

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:

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:

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
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'.

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
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.

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
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

upx -9 --ultra-brute /path/to/binary

Ce 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   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:

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-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 := 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-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.

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   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.

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 da3112e

On build, vous aller voir que V va droit au but 😄

❯ v main.v

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:

❯ v main.v -o http-okay
❯ tree
.
├── http-okay
├── main.v
└── v.mod

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

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
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.

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
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

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.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.

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 htonl ne sert Ă  rien car 0x00000000 lu dans un sens ou dans un autre reste 0x00000000.

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 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 = 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.

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 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.

struct sockaddr {
    unsigned short int sa_family;    // 2 cases
    char               sa_data[14];  // 14 cases
                                     //--------
}                                    // 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.

(struct sockaddr *)&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 = 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_in directement?

Parce que sockaddr_in n’est pas la seule structure que l’on peut bind.

Il existe une version sockaddr_in6 pour 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 sockaddr vers sockaddr_in ou sockaddr_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
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 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
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 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-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.

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Ă©. 😎

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.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.

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
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. ❀