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 !
Pour référence, les conteneurs créés ont les tailles suivantes :
alpine | 146MB |
alpine-scratch | 14kB |
alpine-scratch-explicit | 51.3kB |
debian | 349MB |
debian-scratch | 14.4kB |
debian-scratch-explicit | 14.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 ?