https://lafor.ge/feed.xml

Un serveur HTTP de moins de 20 Ko

2024-08-31

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:

  • 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
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
missing alt

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

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:

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

missing alt

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

missing alt

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.

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

  • socket
  • setsockopt
  • bind
  • listen
  • accept
  • read
  • write

Et quelques structures comprises par le kernel. Mais nous embarquons la terre entière.

Cette problématique je ne suis pas le seul à l'avoir, d'autre l'on eu et cela à déboucher au projet musl, qui n'est ni plus ni moins qu'une réimplémentation de la libc de manière opinated pour la rendre la plus légère possible.

Elle vient avec son propre compilateur.

❯ musl-gcc main.c -static -Oz -o http-okay
❯ du -sh http-okay
32K     http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK

Le code est parfaitement fonctionnel et ne fait plus que 32Ko. 😍

UPX

Mais on peut encore descendre en compressant le bianaire via UPX.

❯ upx -9 --ultra-brute http-okay
❯ du -sh http-okay 
20K     http-okay
❯ ./http-okay
❯ curl localhost:8080
OK
❯ curl localhost:8080
OK

Et on descends à 20ko comme promis dans le titre de l'article. 😎

Utiliser zig pour compiler le C

Mais du coup, si zig est si efficace à compiler du C, pourquoi ne pas le faire compiler notre programme ?

Il clame

Zig is better at using C libraries than C is at using C libraries

Et bien vérifions leurs dires.

❯ zig build-exe main.c --library c --name http-okay
❯ du -sh http-okay
8.0K    http-okay
❯ ldd http-okay
        linux-vdso.so.1 (0x00007ffe5a7da000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f23b9c54000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f23b9e6f000)
❯ gcc main.c -o http-okay
❯ du -sh http-okay
16K    http-okay

Ok, ça compile bien et ça créé bien un binaire 2 fois inférieur en taille à celui produit par gcc.

Mais pour le moment c'est du dynamique.

Pour passer en statique, il faut lui dire d'utiliser musl à la place de glibc. Pour cela nous utilisons la -target x86_64-linux-musl

❯ zig build-exe main.c --library c --name http-okay -target x86_64-linux-musl
❯ du -sh http-okay
24K     http-okay
❯ ldd http-okay
        not a dynamic executable
❯ upx -9 --ultra-brute http-okay
❯ du -sh http-okay
20K     http-okay

Et on retombe sur les 20ko.

Docker

Je fais le build avec zig mais ça aurait également pu se faire avec musl-gcc

FROM chainguard/zig  AS zig_build

WORKDIR /opt

COPY void.c main.c

RUN zig build-exe main.c --library c --name http-okay -target x86_64-linux-musl

FROM gcc as upx

WORKDIR /opt

COPY --from=zig_build /opt/http-okay http-okay

RUN wget https://github.com/upx/upx/releases/download/v4.2.4/upx-4.2.4-amd64_linux.tar.xz
RUN tar -xf upx-4.2.4-amd64_linux.tar.xz
RUN cp upx-4.2.4-amd64_linux/upx /usr/bin/upx
RUN strip http-okay

FROM scratch

COPY --from=upx /opt/http-okay /usr/bin/http-okay

ENTRYPOINT ["/usr/bin/http-okay"]
❯ docker build -t http-okay-c .
❯ docker run -p 8080:8080 http-okay-c
❯ curl localhost:8080
OK
❯ docker images | grep http-okay-c
http-okay-c                    latest           708eb4985347   About a minute ago   22.1kB

On a frôlé les 20ko, mais je n'ai pas trouvé comment descendre.

J'ai tenté de remplacer le main pour une méthode _start, mais je n'ai pas réussi à compiler après. Je me retrouvais avec des duplications de symboles, impossibles à régler.

ASM

Cette section c'est juste la récréation, je ne vais pas essayer d'expliquer tout ^^'

L'assembleur est au plus prêt de la machine, c'est ici que l'on peut faire les plus grosse optimisations.

On va donc complètement se débarasser de la libc ou de musl et réécrire à la main les instructions que la libc génère pour le CPU.

Implémentation et construction du binaire

Le code vient de ce repo. Il est entièrement commenté. 😎

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

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.