REGISTRY pour Docker & Kubernetes
Mis à jour le 12/12/2024

Ici, nous allons faire un un registry de containers via un fichier docker-compose.
Dans ce docker-compose, il y aura les directives pour la communication HTTPS et une authentification via htpasswd (basic authentication). Ces directives ne sont pas obligatoires pour le bon fonctionnement du registry.
Si nous voulons une communication HTTPS entre docker et un registry privé, il faut que docker ait confiance en une AC privée (ou, ici, un certificat auto-signé). Nous verrons comment faire.
Enfin, comme Kubernetes exige que le registry soit en HTTPS, nous allons faire un playbook Ansible qui permet d’avoir confiance dans notre AC privée avec une spécification yaml équivalente au docker-compose.

Création d’un certificat auto-signé avec SAN en utilisant openssl

Pour un registry public, on peut toujours acheter un certificat ou utiliser letsencrypt (par exemple via pfSense+HA-Proxy, ce sera l’objet d’un futur article).
Mais dans un contexte interne et privée, voici un un exemple de certificat valable 100 ans.

A partir d’ici, on considère que l’adresse IP de l’hôte Docker est 172.22.22.1

openssl req -nodes -x509 -sha256 -newkey ec:<(openssl ecparam -name secp384r1) \
  -keyout server.key \
  -out server.crt \
  -days 35600 \
  -subj "/C=FR/ST=Loire Atlantique/L=Nantes/O=IASC/OU=Infrastructure/CN=Grand Dub"  \
  -addext "subjectAltName = DNS: granddub.lan, IP: 172.22.22.1" \
  -addext "extendedKeyUsage = 1.3.6.1.5.5.7.3.1, 1.3.6.1.5.5.7.3.2"

Lorsque la version du registry utilisée était 2.7.1, si le paramètre -newkey était ED25519 ou ec:..., il semble que le registry ne comprenait pas ces algorithmes ! Donc j’utilisais rsa:4096.
ED25519 ne fonctionne pas dans les navigateurs web (j’ai testé Chrome & Firefox). Il semble réservé à IKE.

Vérification du contenu du certificat

openssl x509 -text -noout -in server.crt

Configurer docker pour avoir confiance dans une AC privée

Ici, il s’agit du certificat auto-signé créé précédemment.

  • créer le répertoire /etc/docker/certs.d/172.22.22.1:5000/
  • y copier le fichier server.crt
  • Le répertoire 172.22.22.1:5000 s’appelle comme ça, car ce sera le préfixe des noms d’image pour les docker push
  • Inutile de redémarrer le daemon docker

Authentification dans le registry avec local basic authentication

source: https://docs.docker.com/registry/deploying/#native-basic-auth
On peut aussi s’authentifier avec LDAP, OAUTH… (voir la documentation)

Nous allons créer un fichier htpasswd local puis l’utiliser dans le docker-compose. Il faut bien sûr installer l’utilitaire htpasswd (il est dans le dépôt Linux classique: apache2-utils (du moins sur une distribution à base de Debian)).

htpasswd -Bbn granddub 'Pa$$w0rd' > htpasswd

docker-compose d’un registry avec HTTPS, authentification avec htpasswd et interface Web de gestion

Ici, on utilise les fichiers créés précédemment.
La version de l’image registry est à adapter selon les besoins (et donc à tester).

Tout est inspiré de https://github.com/Joxit/docker-registry-ui.

Cette interface Web de gestion est publiée en https, avec le même certificat que le registry, en modifiant la configuration nginx. Le site de l’auteur indiique de mettre un reverse proxy en amont pour obtenir cette fonctionnalité, mais j’ai trouvé en faisant le script nginx-ssl-conf.sh avec le bit x (eXecute) positionné.

docker-compose.yaml

version: "3.5"

volumes:
  registry-volume: # stockage des images et des métadonnées
    name: registry-volume

services:
  registry:
    image: registry:2.8.3
    ports:
      - 5000:5000
    restart: always
    volumes:
      - registry-volume:/var/lib/registry
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro

      - ./config.yml:/etc/docker/registry/config.yml:ro # configuration modifiée
      - ./server.crt:/cert/server.crt:ro # certificat pour HTTPS
      - ./server.key:/cert/server.key:ro # clef privée associée
      - ./htpasswd:/etc/docker/registry/htpasswd:ro # base de comptes utilisateurs

  gui:
    image: joxit/docker-registry-ui:2.5.7
    ports:
      - 443:80
    restart: always
    depends_on:
      - registry
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      # SSL
      - ./server.crt:/server.crt:ro # certificat pour HTTPS
      - ./server.key:/server.key:ro # clef privée associée
      - ./nginx-ssl-conf.sh:/docker-entrypoint.d/1000-nginx-ssl-conf.sh:ro # ATTENTION: mettre le bit x (eXecute) sur ce fichier
    environment:
      - SINGLE_REGISTRY=true # pour ne pas avoir à configurer la page de browsing
      - REGISTRY_TITLE=Docker Registry UI
      - DELETE_IMAGES=true
      - SHOW_CONTENT_DIGEST=true
      - NGINX_PROXY_PASS_URL=https://registry:5000
      - CATALOG_MIN_BRANCHES=1
      - CATALOG_MAX_BRANCHES=1
      - TAGLIST_PAGE_SIZE=100
      - REGISTRY_SECURED=true # pour Basic Authentication
      - CATALOG_ELEMENTS_LIMIT=1000
      - SHOW_CATALOG_NB_TAGS=true # déconseillé sur les gros Registry (génère une requête supplémentaire par tag d'image)
      - HISTORY_CUSTOM_LABELS=true

config.yml

# version originale de la version 2.7.1 avec modifications indiquées

version: 0.1
log:
  fields:
    service: registry
storage:
  cache:
    blobdescriptor: inmemory
  filesystem:
    rootdirectory: /var/lib/registry
# modification: 2 lignes -> DELETE
  delete:
    enabled: true
http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]
    # modifications: 5 lignes -> CORS '*' pour joxit/docker-registry-ui par exemple
    Access-Control-Allow-Origin: ['*']
    Access-Control-Allow-Headers: ['Authorization', 'Accept', 'Cache-Control']
    Access-Control-Allow-Methods: ['HEAD', 'GET', 'OPTIONS', 'DELETE']
    Access-Control-Expose-Headers: ['Docker-Content-Digest']
    Access-Control-Allow-Credentials: [true] # autorise l'authentification mais incompatible avec Access-Control-Allow-Origin à '*' avec fetch() (à creuser)
  # modifications: 3 lignes -> HTTPS
  tls:
    certificate: /cert/server.crt
    key: /cert/server.key

# modifications: 4 lignes -> Basic Authentication
auth:
  htpasswd:
    realm: basic-realm
    path: /etc/docker/registry/htpasswd

health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

nginx-ssl-conf.sh

#!/bin/sh

# Passe le port définit dans les scripts précédents (souvent 80) en ssl
# => comme ça le EXPOSE reste "cohérent"

#CONF=./work.conf
CONF=/etc/nginx/conf.d/default.conf

sed -i -E 's/^( *listen .+);(.*)$/\1 ssl;\2/'  $CONF
sed -i -Ee '/listen.+ssl/a\' -e '    ssl_certificate     /server.crt;' $CONF
sed -i -Ee '/ssl_certificate/a\' -e '    ssl_certificate_key /server.key;' $CONF

On peut tester le registry sans utiliser l’interface Web avec:

curl 'https://granddub:Pa$$w0rd@172.22.22.1:5000/v2/' -kI
# doit retourner HTTP 200 et:
curl 'https://granddub:pwd@172.22.22.1:5000/v2/' -kI
# doit retourner HTTP 401 car le mot de passe est erroné

DELETE

via l’API cela ne supprime que les métadonnées ! (et donc aussi via l’interface Web qui utilise l’API)

Pour supprimer le stockage:

  • être idéalement en lecture seule sur le service (comment ?)
  • /bin/registry garbage-collect /etc/docker/registry/config.yml
  • après suppression du dernier tag d’un repo:
    • supprimer physiquement le repo (son nom) dans /var/lib/registry/docker/registry/v2/repositories/
  • redémarrer le container (sinon le stockage est bien supprimé mais le service croit qu’il existe toujours)

Kubernetes

A partir d’ici, on considère que l’adresse IP partagée (VIP) du cluster Kubernetes est 172.22.22.1

Playbook Ansible pour avoir confiance dans une AC privée

On va utiliser le certificat précédent qui est auto-signé (ce qui est aussi le cas du certificat d’une AC racine privée).

Le Playbook ne prend pas en charge des nœuds Windows

# Playbook qui permet à K8S de faire confiance dans des AC privées (ou tout certificat auto-signé sans AC) testé sous Ubuntu 22.04 & RockyLinux 9
# utile pour les pull & push de registry privés hébergés dans le cluster K8S
# Il suffit de paramétrer le système et containerd (CRI de K8S) réagit de la même manière
# Dans le répertoire du projet Ansible, créer le dossier CAs et mettre dedans tous les fichiers de certificats d'AC
- name: confiance dans des AC privées pour K8S (donc containerd qui est le CRI sous-jacent)
  become: yes
  hosts: localhost # ou un groupe de l'inventaire tel que k8s_all (je teste sur un cluster à noeud unique)
  gather_facts: yes
  force_handlers: yes
  vars:
    service: containerd
  tasks:
    - name: variables famille Debian
      when: ansible_facts.os_family == 'Debian'
      set_fact:
        storageDirectory: /usr/local/share/ca-certificates
        command: update-ca-certificates
    - name: variables famille RedHat
      when: ansible_facts.os_family == 'RedHat'
      set_fact:
        storageDirectory: /etc/pki/ca-trust/source/anchors/
        command: update-ca-trust

    - name: détection service k3s
      become: no
      failed_when: no
      shell: kubectl get nodes {{ansible_facts.fqdn}} -o json|grep node.kubernetes.io/instance-type|grep k3s
      register: k3s
      changed_when: no
    - name: détection service k3s (2)
      when: k3s.rc==0
      set_fact:
        service: k3s
        
    - name: copie des fichiers CA
      copy:
        src: CAs/
        dest: "{{storageDirectory}}"
        backup: yes
      notify:
        - update-ca
        - restart-containerd # pendant ce temps les containers existants continuent de fonctionner, mais on ne peut plus faire de modifications sur les containers via kubectl ou crictl (mais le restart est rapide).

  handlers:
    - name: update-ca
      command: "{{command}}"
    - name: restart-containerd
      service:
        name: "{{service}}"
        state: restarted

Déploiement d’un registry dans k8s

On va réutiliser le certificat, la base de comptes utilisateurs et le fichier de configuration du registry nommé ici config-registry.yaml.

deployment-registry.yaml

# Déploiement d'un registry dans k8s

apiVersion: v1
kind: Namespace
metadata:
  name: registry

---

# pvc de StorageClass local-path, donc dans k3s
# à adapter si cette classe n'existe pas
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: registry
  namespace: registry
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 20Gi

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: registry
  namespace: registry
  labels:
    app: registry
spec:
  selector:
    matchLabels:
      app: registry
  template:
    metadata:
      labels:
        app: registry
    spec:
      containers:
      - name: registry-container
        image: registry:2.8.2
        imagePullPolicy: IfNotPresent
        volumeMounts:
        # heure locale
        - name: timezone # ATTENTION, sous RedHat, timezone n'existe pas, il suffit de mapper localtime
          mountPath: /etc/timezone
          readOnly: true
        - name: localtime
          mountPath: /etc/localtime
          readOnly: true
        # configuration
        - name: configs
          mountPath: /etc/docker/registry/config.yml
          subPath: config-registry.yaml
          readOnly: true
        # https
        - name: configs
          mountPath: /cert/server.crt
          subPath: server.crt
          readOnly: true
        - name: secrets
          mountPath: /cert/server.key
          subPath: server.key
          readOnly: true
        # Basic authentication
        - name: secrets
          mountPath: /etc/docker/registry/htpasswd
          subPath: htpasswd
          readOnly: true
        # stockage des images
        - name: registry-storage
          mountPath: /var/lib/registry
          
      volumes:
      - name: timezone # ATTENTION, sous RedHat, timezone n'existe pas, il suffit de mapper localtime
        hostPath:
          path: /etc/timezone
          type: File
      - name: localtime
        hostPath:
          path: /etc/localtime
          type: File
        
      - name: configs
        configMap:
          name: registry
      - name: secrets
        secret:
          secretName: registry

      - name: registry-storage
        persistentVolumeClaim:
          claimName: registry

---

apiVersion: v1
kind: Service
metadata:
  labels:
    app: registry
  name: registry
  namespace: registry
spec:
  ports:
  - port: 5000
    protocol: TCP
    targetPort: 5000
    nodePort: 32345
  selector:
    app: registry
  type: NodePort

---

# Interface WEB

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gui
  namespace: registry
  labels:
    app: gui
spec:
  selector:
    matchLabels:
      app: gui
  template:
    metadata:
      labels:
        app: gui
    spec:
      containers:
      - name: gui-container
        image: joxit/docker-registry-ui:2.5.6
        imagePullPolicy: IfNotPresent
        env:
        - name: SINGLE_REGISTRY
          value: "true"
        - name: REGISTRY_TITLE
          value: Docker Registry UI
        - name: DELETE_IMAGES
          value: "true"
        - name: SHOW_CONTENT_DIGEST
          value: "true"
        - name: NGINX_PROXY_PASS_URL
          value: https://registry:5000
        - name: CATALOG_MIN_BRANCHES
          value: "1"
        - name: CATALOG_MAX_BRANCHES
          value: "1"
        - name: TAGLIST_PAGE_SIZE
          value: "100"
        - name: REGISTRY_SECURED
          value: "true"
        - name: CATALOG_ELEMENTS_LIMIT
          value: "1000"
        - name: SHOW_CATALOG_NB_TAGS
          value: "true"
        - name: HISTORY_CUSTOM_LABELS
          value: "true"

        volumeMounts:
        # heure locale                                                                                                             
        - name: timezone # ATTENTION, sous RedHat, timezone n'existe pas, il suffit de mapper localtime
          mountPath: /etc/timezone
          readOnly: true
        - name: localtime
          mountPath: /etc/localtime
          readOnly: true

      volumes:
      - name: timezone # ATTENTION, sous RedHat, timezone n'existe pas, il suffit de mapper localtime
        hostPath:
          path: /etc/timezone
          type: File
      - name: localtime
        hostPath:
          path: /etc/localtime
          type: File
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: gui
  name: gui
  namespace: registry
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
    nodePort: 32346
  selector:
    app: gui
  type: NodePort

Ce déploiement référence une configMap et un secret non existants mais dont le contenu est dans des fichiers (certificat, clef privée, htpasswd…).
On va donc utiliser Kustomize pour déployer, il faut un fichier nommé kustomization.yaml contenant:

# fichier à appliquer avec: kubectl apply -k .

resources:
  - deployment-registry.yaml

configMapGenerator:
- name: registry
  namespace: registry
  files:
  - config-registry.yaml
  - server.crt

secretGenerator:
- name: registry
  namespace: registry
  files:
  - htpasswd
  - server.key
  
generatorOptions:
  disableNameSuffixHash: true
  labels:
    type: kustomize-generated
  annotations:
    remarque: kustomize-generated

On déploit avec (comme le dit le commentaire):

kubectl apply -k .

Il faut aussi créer un secret qui permet de s’authentifier sur ce registry:

kubectl create secret docker-registry my-registry-auth \
        --docker-server=172.22.22.1:32345 --docker-username=granddub --docker-password='Pa$$w0rd' \
        -n registry

Ce secret n’est accessible que dans le namespace où il est défini.
On peut créer un script qui crée celui-ci dans les tous les namespace en s’inspirant de: https://stackoverflow.com/questions/74759857/kubectl-create-secret-docker-registry-for-all-namespaces ou envisager autre chose (Custom Resource).

Test du registry dans k8s

Après avoir pousser une image alpine:latest dans ce registry, faire le pod suivant:

apiVersion: v1
kind: Pod
metadata:
  labels:
    run: test
  name: test-registry
  namespace: registry
spec:
  imagePullSecrets:
  - name: my-registry-auth
  containers:
  - name: test-registry
    image: 172.22.22.1:32345/alpine
    args:
    - sleep
    - infinity

Outil intéressant de transfert d’images entre registry (ou autres transports tel que les répertoires)

skopeo : https://github.com/containers/skopeo/tree/main

Dans cet exemple, on va utiliser 2 registry hébergés sur le même cluster k8s, exposés via nodeport (sur 2 ports différents bien sûr), utilsant le même certificat, et la même base de comptes pour l’authentification.

Utilisation de skopeo via docker:

docker run -it --rm quay.io/skopeo/stable copy --src-tls-verify=false --src-creds 'granddub:Pa$$w0rd' \
  --dest-tls-verify=false --dest-creds 'granddub:Pa$$w0rd' \
  docker://172.22.22.1:32345/alpine:latest docker://172.22.22.1:32340/alpine:latest

On peut faire une configuration afin d’avoir confiance dans des AC et utiliser le fichier d’authentification de docker (config).
Donc via ce fichier d’authentification, on peut, par exemple, transférer des images entre docker.io et quay.io.

results matching ""

    No results matching ""