5 avril 2023
Docker est une solution incontournable pour standardiser et facilement déployer ses applications. Au vu de la complexité de Docker, on peut se demander : est-il sécurisé de builder des images de conteneur dans Kubernetes ? Pas vraiment avec Docker-in-docker, mais une solution existe : l’outil img.
Docker-in-Docker : la mauvaise pratique sécurité
Par défaut, Docker requiert des privilèges très élevés pour fonctionner correctement, que ce soit pour le build d’image ou l’exécution de ces dernières dans des conteneurs. En effet, il nécessite d’être exécuté en tant que root et cela s’explique par son besoin d’exécuter des actions critiques sur le système :
- créer et manipuler des namespaces (run)
- monter et manipuler des filesystems (run + build)
En plus de cette exécution en tant que root, les commandes Docker utilisent ce qu’on appelle la socket Docker (/var/run/docker.sock
). Cette socket est en réalité l’API qu’utilise Docker pour chacune de ses actions. Elle fait l’interface entre la CLI et le deamon Docker qui tourne en arrière-plan sur le système.
L’accès à cette socket permet de communiquer avec le daemon Docker et donc d’exécuter pratiquement n’importe quelle action de la CLI : lister les conteneurs, lancer un conteneur privilégié, ...
Les éléments de fonctionnement de Docker qui viennent d’être détaillés ont des implications sur la sécurité quand on veut exécuter Docker dans un conteneur. Par exemple dans Kubernetes :
- le conteneur doit s’exécuter en tant que root ;
- le conteneur doit être privilégié ;
- le conteneur doit avoir accès à la socket Docker du système hôte.
Chacun de ces points est extrêmement critique pour la sécurité d’un cluster Kubernetes puisque cela implique l’existence d’un conteneur très privilégié sur le cluster. La compromission de ce seul conteneur impliquera directement la compromission totale du cluster Kubernetes.
De plus, ce conteneur critique exécute des commandes qui lui sont fournies par les développeurs via la CI. Un développeur peut facilement manipuler le code de la CI pour faire exécuter le code de son choix au conteneur docker et ainsi compromettre le cluster.
La solution sécurisée
Afin de résoudre le problème énoncé ci-dessus et builder des images dans Kubernetes de manière sécurisée, nous proposons une solution basée sur img et fuse-overlayfs.
Img est un outil en CLI qui permet de réaliser les mêmes commandes que le CLI Docker mais avec quelque changement qui vous nous permettre de résoudre notre problème de sécurité :
- rootless
- ça ne requiert pas de conteneur privilégié
- ça n’a pas besoin d’accès au socket Docker de l’hôte
Le seul défaut de img est qu’il requiert l’accès au device /dev/fuse
afin de profiter pleinement de fuse-overlayfs pour ses manipulations du filesystem des images de conteneur. Sans fuse-overlayfs, img utilise énormément d'espace sur le disque (plusieurs centaines de Go pour un build !) car il doit recréer tous le filesystem de l'image à chaque étape du Dockerfile.
Mise en place
Prérequis
- Un cluster Kubernetes fonctionnel
- Kyverno doit être déployé dans le cluster
- Gitlab avec un runner configuré et déployé dans Kubernetes
Création d’une image contenant img et fuse-overlayfs
La première étape consiste à créer l’image docker contenant tous les outils nécessaires pour builder nos futures images avec img, automatiquement sur le Runner Gitlab dans Kubernetes.
# Based on: https://github.com/genuinetools/img/blob/master/Dockerfile
# ----- img ------
FROM golang:1.13-alpine AS img
RUN apk add --no-cache \
bash \
build-base \
gcc \
git \
libseccomp-dev \
linux-headers \
make
WORKDIR /img
RUN go get github.com/go-bindata/go-bindata/go-bindata
RUN git clone https://github.com/genuinetools/img \
&& cd img \
&& git checkout 16d3b6cad7e72f4cd9c8dad0e159902eeee00898 \
&& make static \
&& mv img /usr/bin/img
# ----- idmap ------
FROM alpine:3.11 AS idmap
RUN apk add --no-cache autoconf automake build-base byacc gettext gettext-dev gcc git libcap-dev libtool libxslt
RUN git clone https://github.com/shadow-maint/shadow.git /shadow
WORKDIR /shadow
RUN git checkout 59c2dabb264ef7b3137f5edb52c0b31d5af0cf76
RUN ./autogen.sh --disable-nls --disable-man --without-audit --without-selinux --without-acl --without-attr --without-tcb --without-nscd \
&& make \
&& cp src/newuidmap src/newgidmap /usr/bin
# ----- img and idmap -----
FROM alpine:3.11 AS base
RUN apk add --no-cache git pigz
COPY --from=img /usr/bin/img /usr/bin/img
COPY --from=idmap /usr/bin/newuidmap /usr/bin/newuidmap
COPY --from=idmap /usr/bin/newgidmap /usr/bin/newgidmap
RUN chmod u+s /usr/bin/newuidmap /usr/bin/newgidmap \
&& adduser -D -u 1000 user \
&& mkdir -p /run/user/1000 \
&& chown -R user /run/user/1000 /home/user \
&& echo user:100000:65536 | tee /etc/subuid | tee /etc/subgid
# ----- add fuse-overlayfs and tools -----
FROM base AS final
WORKDIR /build
RUN apk add git make gcc libc-dev musl-dev glib-static gettext eudev-dev \
linux-headers automake autoconf cmake meson ninja clang go-md2man
RUN git clone https://github.com/libfuse/libfuse && \
cd libfuse && \
mkdir build && \
cd build && \
LDFLAGS="-lpthread -s -w -static" meson --prefix /usr -D default_library=static .. && \
ninja && \
ninja install
RUN git clone https://github.com/containers/fuse-overlayfs \
&& cd fuse-overlayfs \
&& git checkout v1.8.2
RUN cd fuse-overlayfs && \
./autogen.sh && \
LIBS="-ldl" LDFLAGS="-s -w -static" ./configure --prefix /usr && \
make clean && \
make && \
make install
RUN apk add --no-cache \
bash \
jq \
py3-pip \
&& pip3 install --no-cache-dir awscli \
&& rm -rf /var/cache/apk/*
# ----- rootless -----
FROM final AS release
USER user
ENV USER user
ENV HOME /home/user
ENV XDG_RUNTIME_DIR=/run/user/1000
WORKDIR /home/user
DaemonSet pour le device fuse
Nous avons ensuite besoin de faire en sorte que le device /dev/fuse
soit accessible par les pods du runners Gitlab qui exécuteront img. Pour cela, nous déployons un DamonSet dans Kubernetes qui va mettre à disposition le /dev/fuse
de chaque node sous forme d’une ressource. Le device fuse sera ainsi automatiquement monté sur les pods contenant une limits du type squat.ai/fuse: 1
.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: generic-device-plugin
namespace: gitlab
labels:
app.kubernetes.io/name: generic-device-plugin
spec:
selector:
matchLabels:
app.kubernetes.io/name: generic-device-plugin
template:
metadata:
labels:
app.kubernetes.io/name: generic-device-plugin
spec:
priorityClassName: system-node-critical
nodeSelector:
kube/nodetype: gitlab
containers:
- image: squat/generic-device-plugin
# count specifies that 15 pod are allowed to use the device simulteously
args:
- --device
- '{"name": "fuse", "groups": [{"count": 15, "paths": [{"path": "/dev/fuse"}]}]}'
name: generic-device-plugin
ports:
- containerPort: 8080
name: http
securityContext:
privileged: true
volumeMounts:
- name: device-plugin
mountPath: /var/lib/kubelet/device-plugins
- name: dev
mountPath: /dev
volumes:
- name: device-plugin
hostPath:
path: /var/lib/kubelet/device-plugins
- name: dev
hostPath:
path: /dev
updateStrategy:
type: RollingUpdate
Mutation policy Kyverno
Les deux étapes suivantes sont un petit trick pour ajouter la limits pour le device fuse
aux pods du Runner Gitlab dans notre cluster Kubernetes. En effet, le template helm générique des Runners Gitlab ne permet pas de modifier les limitss des pods. Nous allons donc créer à une policy Kyverno pour modifier à la volée nos pods et rajouter la limits squat.ai/fuse: 1
pour le device fuse à tous les pods posédant le label mount-fuse: "true"
apiVersion: kyverno.io/v1
kind: Policy
metadata:
name: add-fuse-device
namespace: gitlab
spec:
rules:
- name: add-fuse-device
match:
any:
- resources:
kinds:
- Pod
selector:
matchLabels:
mount-fuse: "true"
mutate:
patchesJson6902: |-
- op: add
path: "/spec/containers/0/resources/limits"
value: {"squat.ai/fuse":"1"}
Configuration des Runners Gitlab
Il suffit ensuite de modifier la configuration des Runners Gitlab pour ajout le label mount-fuse: "true"
aux pods dans Kubernetes.
runners:
config: |
[[runners]]
[runners.kubernetes]
[runners.kubernetes.pod_labels]
mount-fuse = "true"
Utilisation
Pour builder nos images de manière sécurisée, il suffit maintenant de remplacer docker
par img
dans notre CI, à quelques exceptions près :
- Le paramètre
--network
n’existe pas pourimg
, mais est actif par défaut - Pour les
build-args
il faut obligatoirement préciser le nom et la valeur de la variable :--build-arg "MYVAR=$MYVAR"
img push
ne pousse pas plusieurs tags d’une même image en même temps (contrairement àdocker push
), il faut unimg push
pour chaque tag.docker rmi
devientimg rm
Exemple d’un job de CI
.release-java:
stage: release
image: my-repo/img-aws:1.0.0
before_script:
- cp -r configuration $WORKDIR
- cd $WORKDIR
- aws ecr get-login-password --region eu-west-3 | img login --username AWS --password-stdin ${DOCKER_URL}
- if [[ ! -z $CI_COMMIT_TAG ]]; then export DOCKER_TAG=$(echo
$CI_COMMIT_REF_NAME | tr @ _); fi;
script:
- img build
--cache-from ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${TARGET_ENV}-latest
-t ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${DOCKER_TAG}
-t ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${TARGET_ENV}-latest
-f docker/ci.Dockerfile
--build-arg "MY_VAR=$MYVAR"
- img push ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${DOCKER_TAG}
- img push ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${TARGET_ENV}-latest
- img rm ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${DOCKER_TAG}
- img rm ${DOCKER_URL}/${DOCKER_REPO}/${DOCKER_IMAGE}:${TARGET_ENV}-latest
variables:
DOCKER_TAG: ${CI_COMMIT_SHA}
Conclusion
Nous venons de voir pourquoi la méthode docker-in-docker n’est pas sécurisée pour le build d’image dans Kubernetes. Nous avons ensuite exploré une solution basée sur img mais qui nécessite pas mal d’actions sur Kubernetes pour obtenir de bonnes performances.
Une autre solution possible sécurisée que nous n’avons pas détaillé ici est d’utiliser Kaniko. Nous n’avons pas choisi cette solution car, à notre sens, elle est moins souple que img. En effet, l’image kaniko ne prend pas en charge l’ajout d’autres étapes que le build d’image.
Pourtant, il s'avère souvent intéressant d’effectuer des actions après le build comme un scan de l’image par exemple.