build_securite_img_kubernetes

28 avril 2022

Docker est une solution incontournable aujourd'hui pour standardiser et facilement déployer ses applications. C’est particulièrement vrai dans le monde du Cloud. Si vous avez besoin d’un petit rappel sur Docker, cet article sur la conteneurisation d’une application legacy peut vous aider.

 

Étant donné cette large utilisation de Docker, il est essentiel d’être capable de construire ses images Docker directement en CI/CD pour fluidifier le déploiement des applications. Cela repose souvent sur docker-in-docker, c’est-à-dire l’exécution de commande Docker dans un conteneur. En effet, le Runner exécutant les jobs de CI est souvent déployé en tant que conteneur dans Kubernetes par exemple.

 

Au vu de la complexité de Docker et des droits importants qu’il requiert sur le système pour fonctionner (par défaut root), on peut se demander : est-il sécurisé de builder des images de conteneur dans Kubernetes ?

 

Spoiler alert : la réponse est évidemment négative avec docker-in-docker (sinon cet article aurait peu de sens). Cependant, une solution basée sur l’outil img existe et sa mise en place dans Kubernetes est décrite plus bas.

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

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 pour img, 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 un img push pour chaque tag.
  • docker rmi devient img 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.