Accueil ¦ Blog ! ¦ Perso ¦ Profil pro ¦ Articles ¦ Liens recommandés ¦ Préférences ¦ À propos

The most dangerous phrase in the language is, “We''ve always done it this way.”Rear Admiral Grace Murray Hopper

Un conteneur de transport de marchandises soulevé par des ballons de baudruche © Container Vectors by Vecteezy

Je me suis posé une question toute bête : est-ce que la résolution de nom fonctionne encore lorsqu'on a pas d'environnement d'exploitation ?

La base

En général, pour construire une image, on se base sur une image préexistante. Beaucoup se basent sur Ubuntu par simplicité, d'autres se battent avec leur distribution préférée, quant à moi je privilégie Alpine Linux, pour la minimisation de la taille et de la surface d'attaque.

On peut cependant aller plus loin, en basant son image sur Scratch C'est une image extrêmement dénuée. En effet, en gros elle ne contient que :

  • Un système de fichier vide
  • La définition des espaces de nom du processus

Le programme

Nous allons fabriquer une version tout à fait naïve de nslookup. On prendra une liste de noms de domaine en premier argument, et l'on imprimera la résolution vers la première IP proposée si trouvée.

#include <arpa/inet.h>
#include <stdbool.h>
#include <memory.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>

bool get_address_of(char * in, char * out, size_t size_out);

int main(int argc, char **argv)
{
	if(argc < 2)
		return EXIT_FAILURE;

	bool shit_happened = false;
	for(int position = 1; position < argc; ++position)
	{
		char * p_candidate = *(argv + position);

		char p_ip_address[100];
		bzero(p_ip_address, sizeof p_ip_address);
		int success = get_address_of(p_candidate, p_ip_address, sizeof p_ip_address);

		if(success)
			printf("%s: %s\n", p_candidate, p_ip_address);
		else
		{
			shit_happened = true;
			printf("Can not resolve '%s'\n", p_candidate);
		}
	}

	if(shit_happened)
		return EXIT_FAILURE;
}

bool get_address_of(char * p_candidate, char * p_ip_address, size_t ip_address_size)
{
	struct addrinfo * result;
	struct addrinfo hints;

	bzero(&hints, sizeof hints);
	hints.ai_family = AF_UNSPEC;
	hints.ai_socktype = SOCK_DGRAM;
	hints.ai_flags = AI_PASSIVE;

	int success = getaddrinfo(p_candidate, NULL, &hints, &result);

	if(0 != success)
	{
		fprintf(stderr, "getaddrinfo: '%s'\n", gai_strerror(success));
		return false;
	}
	
	void * p = NULL;
	switch(result->ai_family)
	{
		case AF_INET:
			p = &((struct sockaddr_in *) result->ai_addr)->sin_addr;
			break;
		case AF_INET6:
			p = &((struct sockaddr_in6 *) result->ai_addr)->sin6_addr;
			break;
	}

	inet_ntop(result->ai_family, p, p_ip_address, ip_address_size);
	freeaddrinfo(result);

	return true;
}

Vous pouvez vous amuser à compiler sur votre machine, si vous avez l'environnement de compilation qui va bien… mais on ne va pas s'embêter : Docker le fera pour nous.

L'image Docker

Créez un répertoire de travail, dans lequel vous copierez le contenu du programme dans le fichier resolve.c. Dans ce même répertoire, nous allons créer un fichier Dockerfile (sans suffixe aucun) avec le contenu suivant :

FROM debian:buster

RUN apt update \
    && apt dist-upgrade -y \
    && apt install -y gcc libc-dev \
    && apt clean \
    && apt autoclean

COPY resolve.c .
RUN cc -o /resolve resolve.c && strip resolve && rm resolve.c
RUN apt -y --purge remove gcc .*-dev > /dev/null
CMD ["/resolve"]

En français, cette recette indique sur quelle image se baser (Debian GNU/Linux Buster en l'occurrence), lance une commande qui met à jour le système et installe les paquets, construit notre binaire, réduit le binaire à sa plus pure expression puis en fait la commande par défaut du conteneur.

On peut fabriquer l'image ainsi :

docker build -t resolve:debian -f Dockerfile-debian .

Puis l'utiliser :

docker run --rm resolve:debian ./resolve lupusmic.org

Si tout se passe bien, vous devriez avoir une sortie dans ce genre :

lupusmic.org: 51.15.16.60

Ça marche !

On peut regarder la taille de l'image avec la commande suivant, et on observe une taille de 349 MB.

docker image ls lupusmichaelis/resolve

Ça fait un peu beaucoup pour déployer un petit binaire qui lui-même ne fait que 15 :kB !

Gratter la surface

Comme je l'ai dit précédemment, Docker fournit une fausse image nommée « Docker Scratch Image ». On peut y embarquer un binaire qui pourra être exécuté dans ce conteneur, sans rien d'autre. Mais ceci peut poser quelques soucis :

  • aucune bibliothèque n'est accessible
  • aucun fichier de configuration accessible
  • aucun système de paquet, pas d'environnement de compilation

Il y a évidemment des solutions à chacun de ces problèmes. Le premier est résolu en compilant statique le programme. Ainsi, l'ensemble du code exécutable des bibliothèques dont notre programme dépend, tel que la libc, est intégré dans le binaire. Pour le second, il faut créer ou copier les fichiers dont nous avons besoin. Pour le troisième, on construira l'image grâce à la fonctionnalité de fabrication par étape de Docker.

L'idée est assez élégante, il s'agit de nommer des blocs de fabrication, de les manipuler comme des recettes de fabrication classique, puis de passer à une autre unité de construction et d'y copier les fichiers dont nous avons besoin depuis une étape précédente. Par exemple :

FROM debian:buster as build

RUN apt update \
    && apt dist-upgrade -y \
    && apt install -y gcc libc-dev \
    && apt clean \
    && apt autoclean

COPY resolve.c .
RUN cc -o /resolve resolve.c && strip resolve && rm resolve.c
RUN apt -y --purge remove gcc .*-dev > /dev/null

FROM scratch
COPY --from=build resolve /resolve
CMD ["/resolve"]

Donc ici, on dit qu'on fabrique depuis Debian en tant que build, on suit les mêmes étapes que pour l'image fabriquée précédemment. Là on déclare une nouvelle image à partir de « rien », dans laquelle on copie le fichier resolve à la racine du conteneur vide et on le fait commande par défaut (en utilisant bien le chemin absolu).

On fabrique, on teste, et on vérifie la taille de l'image : elle ne fait plus que 14,4 kB ! Et elle fonctionne comme précédemment. Pour être tout à fait honnête, je m'attendais à devoir créer un fichier /etc/hosts pour configurer la libc, mais non, ça marche directement.

Encore optimiser ?

Nous nous sommes basés sur Debian et GCC pour nos essais. Ce ne sont pas forcément les plateformes les plus adaptées pour créer les conteneurs les plus optimisés pour l'espace mémoire. On aurait pu considérer Alpine Linux pour le système de build, et Clang pour le build. Il y a évidemment plein d'options sur le compilateur qu'on pourrait considérer, mais ici nous sortons de la portée de l'article, c'est un peu ridicule pour un programme en C de 72 lignes.

Pour aller plus loin, je mets à disposition un dépôt de code source sur GitHub, n'hésitez pas à forker et explorer !

Resolve repository on GitHub.

Pour référence, les conteneurs créés ont les tailles suivantes :

alpine146MB
alpine-scratch14kB
alpine-scratch-explicit51.3kB
debian349MB
debian-scratch14.4kB
debian-scratch-explicit14.4kB
Nom de l'image Taille

Ceci dit, j'ai une grosse question à explorer : comment le serveur à interroger est déterminé, alors qu'il n'y a pas de configuration réseau ?

Haut de la page
0.756s