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
étaitED25519
ouec:...
, il semble que le registry ne comprenait pas ces algorithmes ! Donc j’utilisaisrsa: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 lesdocker 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)
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.