Cet article est motivé par un problème rencontré sur l’interface Guichet de Deuxfleurs. Au moment d’intéragir avec l’API S3, on a cette erreur suivante :
Impossible d'effectuer la modification.
Put "https://garage.deuxfleurs.fr/bottin-pictures/d1e3607f-4b9c-45fa-9e11-ddf8eef48676-thumb": tls: failed to verify certificate: x509: certificate signed by unknown authority
Pour comprendre le problème, partons de cet exemple basique en Go :
package main
import (
"net/http"
"log"
)
func main() {
_, err := http.Get("https://deuxfleurs.fr")
if err != nil {
log.Fatal(err)
}
log.Println("Success")
}
Ce bloc de code fait une requête HTTPS vers deuxfleurs.fr et log l’erreur si il y en a une, sinon il affiche Success
.
On va le compiler en statique pour ne pas dépendre de la lib C locale :
CGO_ENABLED=0 go build main.go
Ce qui nous donne bien un executable statique :
$ file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=ctUIsYsrR2BtpR58vqRU/TI93T6hZlDxMBNqsplsv/QYD-xJaEDyWB0QaX6tSS/cDyvoEdvE3kZpdq8yCs3, with debug_info, not stripped
Si on le fait tourner en local, tout se passe bien :
2024/06/09 15:54:59 Success
Maintenant, puisqu’on nous avons un binaire statique qui ne dépend de rien, on va vouloir créer un conteneur Docker qui ne contient que ce fichier (alors que souvent on embarque une distribution de base comme Debian ou Alpine). Pour se faire, on écrit un Dockerfile avec deux étapes : une qui a les outils pour le build, et une qui ne contient que notre binaire :
FROM golang as builder
COPY main.go .
RUN CGO_ENABLED=0 go build -o /main main.go
FROM scratch
COPY --from=builder /main /main
CMD [ "/main" ]
On construit & lance le conteneur et… on reproduit l’erreur !
$ docker build -t tls-static-go .
$ docker run --rm -it tls-static-go
2024/06/09 14:02:37 Get "https://deuxfleurs.fr": tls: failed to verify certificate: x509: certificate signed by unknown authority
Pour comprendre la différence de comportement entre l’intérieur du conteneur et l’extérieur, on peut faire appel à strace
et voir les fichiers que notre binaire essaie d’ouvrir :
$ strace -fe openat ./main
...
[pid 9066] openat(AT_FDCWD, "/etc/ssl/certs/ca-certificates.crt", O_RDONLY|O_CLOEXEC) = 7
[pid 9066] openat(AT_FDCWD, "/etc/ssl/certs", O_RDONLY|O_CLOEXEC) = 7
[pid 9066] openat(AT_FDCWD, "/etc/ssl/certs/ca-bundle.crt", O_RDONLY|O_CLOEXEC) = 7
...
En général ce fichier est fourni par les distributions, qui à ma connaissance majoritairement se basent sur le travail de Mozilla. On peut en lire plus à propos de la gestion de ces racines de confiance sur la page dédiée du wiki de Mozilla : CA/FAQ
Une façon de faire est donc de copier le fichier de notre builder dans notre conteneur final :
--- Dockerfile.old 2024-06-09 16:13:18.457415988 +0200
+++ Dockerfile 2024-06-09 16:12:59.494182932 +0200
@@ -4,5 +4,6 @@
FROM scratch
COPY --from=builder /main /main
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
CMD [ "/main" ]
On rebuild, on relance et… ça marche !
$ docker build -t tls-static-go-2 .
$ docker run --rm -it tls-static-go-2
2024/06/09 14:14:30 Success
Pour visualiser le contenu d’une image docker, on peut utiliser l’outil dive :
À gauche, puis à droite, les commandes sont :
dive tls-static-go
dive tls-static-go-2
On voit bien l’ajout du chemin /etc/ssl/certs/ca-certificates.crt
à droite.
Maintenant, le problème avec les Dockerfile
, c’est que ce ne sont pas du tout des builds reproductibles ni précis : on ne sait pas quelles versions ou dépendances on embarque. Donc on veut plutôt construire nos conteneurs avec NixOS pour plus de contrôle.
Pour faciliter le packaging Nix, on va générer un fichier go.mod
:
go mod init tls-static-go
go mod tidy
On peut ensuite créer un fichier flake.nix
qui est notre équivalent – mais plus précis – de notre Dockerfile :
{
description = "TLS Static Golang";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/master";
};
outputs = { self, nixpkgs }:
let
# On configure "le dépôt Nix"
pkgs = import nixpkgs {
system = "x86_64-linux";
};
# On utilise le builder Go intégré à Nix pour construire notre app
tls-static = pkgs.buildGoModule {
pname = "tls-static";
version = "0.1.0";
src = ./.;
vendorHash = null;
CGO_ENABLED = 0;
};
# On construit une image Docker qui ne contient que l'app qu'on a build.
container = pkgs.dockerTools.buildImage {
name = "superboum/tls-static-go";
copyToRoot = tls-static;
config = {
Cmd = [ "/bin/tls-static" ];
};
};
in
{
# Par défaut, sous Linux amd64, on construit le conteneur
packages.x86_64-linux.default = container;
};
}
Et ensuite on peut construire / charger / lancer le conteneur Docker :
$ nix build
$ docker load <result
Loaded image: superboum/tls-static-go:zvsb5pwz25irhr90x10kpfhgsph5is1s
$ docker run --rm -it superboum/tls-static-go:zvsb5pwz25irhr90x10kpfhgsph5is1s
2024/06/09 14:37:57 Get "https://deuxfleurs.fr": tls: failed to verify certificate: x509: certificate signed by unknown authority
Et de nouveau la même erreur, on jette un coup d’oeil avec dive :
On voit qu’on a quelques dépendances nix de tirées (à propos des fuseaux horaires par exemple) mais rien lié aux certificats. On va donc injecter là aussi ces certificats dans le conteneur, et pour ce faire on réalise la modification suivante :
--- flake.nix.old 2024-06-10 09:42:48.871184290 +0200
+++ flake.nix 2024-06-10 09:41:48.011959988 +0200
@@ -24,7 +24,10 @@
# On construit une image Docker qui ne contient que l'app qu'on a build.
container = pkgs.dockerTools.buildImage {
name = "superboum/tls-static-go";
- copyToRoot = tls-static;
+ copyToRoot = pkgs.buildEnv {
+ name = "tls-static-env";
+ paths = [ tls-static pkgs.cacert ];
+ };
config = {
Cmd = [ "/bin/tls-static" ];
};
Autrement dit, on a créé notre système de fichiers racine en fusionnant le contenu de notre build tls-static
avec celui du paquet NixOS cacert
.
On peut ensuite rebuild / load / run le conteneur avec… succès !
$ nix build
$ docker load <result
Loaded image: superboum/tls-static-go:02bmar6x0q1gi8b6j5ys6j1l84mn5vwh
$ docker run --rm -it superboum/tls-static-go:02bmar6x0q1gi8b6j5ys6j1l84mn5vwh
2024/06/09 14:45:06 Success
Quant à Dive, on voit bien que le bundle de certificat a été correctement injecté au bon endroit :
Et voilà, vous avez une image Docker fonctionnelle, minimaliste, reproductible, avec des dépendances correctement déclarées, et maintenable.