Aujourd’hui, nous procéderons au déploiement d’un cluster Kubernetes sur Raspberry PI.

On ne présente plus Kubernetes, solution de conteneurisation open-source, simplifiant le déploiement, la mise à l’échelle, et la gestion en général, d’applications conteneurisées. Ni Raspberry-Pi, ordinateur ARM, de la taille d’une carte de crédit, idéal pour vos projets domotique, IoT, rétro-gaming, …

Notre cluster sera composé d’une douzaine de noeuds, et d’un LoadBalancer. Nous le déploierons à l’aide de Kubespray, outil de déploiement faisant partie de l’écosystème Kubernetes, modulaire, fiable, et relativement exhaustif.

Préparatifs Raspbian

Commençons par télécharger Raspbian.

Pour les noeuds master, il est impératif d’utiliser une image 64b, sans quoi Kubespray ne sait pas installer etcd. Nous retrouverons une image beta du projet Raspbian, fournissant une version arm64. Le problème étant que les mainteneurs du projet etcd ne publient pas de version 32b. Notons qu’il reste possible d’installer une version 32b d’etcd, fournie par Debian, quoi que Kubespray ne supporte pas cette combinaison.

Pour nos autres noeuds, nous pourrons utiliser la dernière image officiel Raspbian.

Une fois ces archives téléchargées et extraites, nous pourrons flasher les cartes micro-sd de nos Raspberry Pi :

$ dd if=./2020-08-20-raspios-buster-arm64-lite.img | pv | dd of=/dev/mmcblk0

Cluster-rapsi

Démarrer le Raspberry, avec écran et clavier, afin d’y configurer un mot de passe root :

$ sudo -i
# passwd

Une addresse IP statique :

# cat <<EOF >/etc/network/interfaces
auto eth0
iface eth0 inet static
    address x.y.z.a
    netmask 255.255.255.0
    gateway x.y.z.b
EOF

Serveur DNS :

# cat <<EOF >/etc/resolv.conf
nameserver 10.255.255.255
domain friends.intra.example.com
search friends.intra.example.com
EOF

Désactiver le fichier d’échange swap :

# sed -i 's|CONF_SWAPSIZE=.*|CONF_SWAPSIZE=0|' /etc/dphys-swapfile
# systemctl disable dphys-swapfile

Configurer le serveur SSH :

# sed -i -e 's|#PermitRootLog.*|PermitRootLogin yes|' -e 's|#PasswordAuth|PasswordAuthentication yes|' /etc/ssh/sshd_config
# systemctl enable ssh

Désactiver les services réseaux que nous n’utiliserons pas :

# systemctl disable dhcpcd
# systemctl disable wpa_supplicant
# systemctl disable bluetooth

Configurer le nom de la machine :

# cat <<EOF >/etc/hostname
EOF
# cat <<EOF >/etc/hosts
x.y.z.a <fqdn> <hostname>
127.0.0.1 <fqdn> <hostname>
127.0.0.1 localhost
::1 localhost6 localhost6.localdomain
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
EOF

Activer les cgroups mémoire :

# sed -i 's|rootwait$|rootwait cgroup_enable=cpuset cgroup_enable=memory|' /boot/cmdline.txt
# cat /boot/cmdline.txt
console=serial0,115200 console=tty1 root=PARTUUID=dcca89ee-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait cgroup_enable=cpuset cgroup_enable=memory

Redémarrer, et vérifier les mots de passe root, IP, gateway, groups mémoire, … puis mettre le système à jour :

# cat /proc/cgroups
#subsys_name hierarchy num_cgroups enabled
...
memory 9 186 1
....
# hostname -s
# hostname -f
# ip a
# ip r
# apt-get update --allow-releaseinfo-change
# apt-get install python-apt cgroupfs-mount ceph-common rbd-nbd
# apt-get upgrade
# apt-get dist-upgrade

LoadBalancer Kubernetes

Raspberry 3 et 4 conviendront parfaitement au déploiement de Kubernetes. En revanche, les modèles plus anciens seront incapables de lancer certaines composantes Kubernetes, faute d’images adaptées à leur ARM v6.

Profitons d’un Raspberry 2b, pour y déployer le LoadBalancer, qui se trouvera devant le service API des masters Kubernetes :

# apt-get install haproxy hatop
# cat <<EOF >/etc/haproxy/haproxy.cfg
global
    log /dev/log local0
    log /dev/log local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
    stats timeout 30s
    user haproxy
    group haproxy
    daemon
    ca-base /etc/ssl/certs
    crt-base /etc/ssl/private
    ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
    ssl-default-bind-options no-sslv3

defaults
    log global
    option dontlognull
    timeout connect 5000
    timeout client 50000
    timeout server 50000
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 408 /etc/haproxy/errors/408.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

listen kubernetes-apiserver-https
    bind 0.0.0.0:6443
    mode tcp
    option log-health-checks
    timeout client 3h
    timeout server 3h
    server master1 10.42.253.40:6443 check check-ssl verify none inter 10s
    server master2 10.42.253.41:6443 check check-ssl verify none inter 10s
    server master3 10.42.253.42:6443 check check-ssl verify none inter 10s
    balance roundrobin
EOF
# cat <<EOF >/etc/profile.d/haproxy.sh
alias hatop='hatop -s /run/haproxy/admin.sock'
EOF
# systemctl start haproxy
# systemctl enable haproxy
# . /etc/profile.d/haproxy.sh
# hatop

Préparatifs Ansible

Il nous faudra ensuite préparer le noeud Ansible.

Nous pourrions déployer depuis un laptop, ou n’importe quel poste. L’utilisation d’une machine dédié simplifiera les interventions futures sur le cluster, s’assurant que les mêmes versions de kubespray, python, ansible, … soient utilisées, si l’on veut, par exemple, rajouter un noeud.

Commençons par installer les playbooks Kubespray, et Ansible :

$ sudo apt-get install python-pip git ca-certificates
$ git clone https://github.com/kubernetes-sigs/kubespray
$ cd kubespray
$ sudo pip install -r requirements.txt

Nous devrons alors préparer l’inventaire, listant les machines composant notre cluster :

$ mkdir -p inventory/rpi/group_vars/all inventory/rpi/group_vars/all/k8s-cluster
$ cat <<EOF >inventory/rpi/hosts.yaml
all:
  hosts:
    pandore.friends.intra.example.com:
      etcd_member_name: pandore
      node_labels:
        my.topology/rack: rpi-rack1
        my.topology/row: rpi-row2
    hellenes.friends.intra.example.com:
      etcd_member_name: hellenes
      node_labels:
        my.topology/rack: rpi-rack2
        my.topology/row: rpi-row2
    epimethee.friends.intra.example.com:
      etcd_member_name: epimethee
      node_labels:
        my.topology/rack: rpi-rack3
        my.topology/row: rpi-row2
    pyrrha.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack1
        my.topology/row: rpi-row3
        node-role.kubernetes.io/infra: "true"
    calliope.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack2
        my.topology/row: rpi-row3
        node-role.kubernetes.io/infra: "true"
    clio.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack3
        my.topology/row: rpi-row3
        node-role.kubernetes.io/infra: "true"
    erato.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack1
        my.topology/row: rpi-row4
        node-role.kubernetes.io/worker: "true"
    euterpe.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack2
        my.topology/row: rpi-row4
        node-role.kubernetes.io/worker: "true"
    melpomene.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack3
        my.topology/row: rpi-row4
        node-role.kubernetes.io/worker: "true"
    polyhymnia.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack3
        my.topology/row: rpi-row1
        node-role.kubernetes.io/worker: "true"
    terpsichore.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack2
        my.topology/row: rpi-row1
        node-role.kubernetes.io/worker: "true"
    thalia.friends.intra.example.com:
      node_labels:
        my.topology/rack: rpi-rack1
        my.topology/row: rpi-row1
        node-role.kubernetes.io/worker: "true"
  children:
    calico-rr:
      hosts: {}
    etcd:
      hosts:
        pandore.friends.intra.example.com:
        hellenes.friends.intra.example.com:
        epimethee.friends.intra.example.com:
    kube-infra:
      hosts:
        pyrrha.friends.intra.example.com:
        calliope.friends.intra.example.com:
        clio.friends.intra.example.com:
    kube-master:
      hosts:
        pandore.friends.intra.example.com:
        hellenes.friends.intra.example.com:
        epimethee.friends.intra.example.com:
    kube-workers:
      hosts:
        erato.friends.intra.example.com:
        euterpe.friends.intra.example.com:
        melpomene.friends.intra.example.com:
        polyhymnia.friends.intra.example.com:
        terpsichore.friends.intra.example.com:
        thalia.friends.intra.example.com:
    kube-node:
      children:
        kube-master:
        kube-infra:
        kube-workers:
    k8s-cluster:
      children:
        calico-rr:
        kube-node:
EOF

Cet inventaire décrit un cluster composé de 3 noeuds masters+etcd, 3 noeuds “infra”, et 6 noeuds “workers”.

Ensuite, nous voudrons créer un premier fichier de variables globales :

$ cat <<EOF >inventory/rpi/group_vars/all/all.yaml
ansible_user: root
etcd_data_dir: /var/lib/etcd
etcd_kubeadm_enabled: false
bin_dir: /usr/local/bin
apiserver_loadbalancer_domain_name: api-k8s-arm.intra.example.com
loadbalancer_apiserver:
address: 10.42.253.52
port: 6443
loadbalancer_apiserver_localhost: false
loadbalancer_apiserver_port: 6443
upstream_dns_servers:
- 10.255.255.255
no_proxy_exclude_workers: false
cert_management: script
download_container: true
deploy_container_engine: true
EOF

Nous désignons ici le ou les serveurs DNS existants que Kubernetes devra interroger pour la résolution de noms hors clusters. Ainsi que la VIP d’un LoadBalancer, et le nom DNS correspondant, ceux-ci pointant sur l’HAProxy que nous venons de déployer.

Il faudra ensuite créer un fichier de configuration avec les variables relatives au cluster etcd :

$ cat <<EOF >inventory/rpi/group_vars/etcd.yaml
etcd_compaction_retention: 8
etcd_metrics: basic
etcd_memory_limit: 2GB
etcd_quota_backend_bytes: "2147483648"
etcd_peer_client_auth: true
etcd_deployment_type: host
EOF

On rajoute une limite mémoire et un quota de 2GB, pour etcd, qui doit rentrer sur nos masters, disposant de 4GB de mémoire. Si l’on ne veut pas utiliser docker, comme container runtime, alors on devra changer le type déploiement etcd. Par défaut conteneurisé, son déploiement dépend de commandes docker. Le déploiement de type host permettant l’utilisation d’un runtime crio, ou containerd.

Nous devrons alors créer un nouveau de fichier de variables :

cat <<EOF >inventory/rpi/group_vars/k8s-cluster/k8s-cluster.yaml
kube_config_dir: /etc/kubernetes
kube_script_dir: "{{ bin_dir }}/kubernetes-scripts"
kube_manifest_dir: "{{ kube_config_dir }}/manifests"
kube_cert_dir: "{{ kube_config_dir }}/ssl"
kube_token_dir: "{{ kube_config_dir }}/tokens"
kube_api_anonymous_auth: true
kube_version: v1.19.5
local_release_dir: /tmp/releases
retry_stagger: 5
kube_cert_group: kube-cert
kube_log_level: 2
credentials_dir: "{{ inventory_dir }}/credentials"
kube_oidc_auth: false
kube_token_auth: true
kube_network_plugin: flannel
kube_network_plugin_multus: false
kube_service_addresses: 10.233.128.0/18
kube_pods_subnet: 10.233.192.0/18
kube_network_node_prefix: 24
kube_apiserver_ip: "{{ kube_service_addresses|ipaddr('net')|ipaddr(1)|ipaddr('address') }}"
kube_apiserver_port: 6443
kube_apiserver_insecure_port: 0
kube_proxy_mode: ipvs
authorization_modes: ['Node', 'RBAC']
kube_proxy_strict_arp: false
kube_encrypt_secret_data: false
cluster_name: cluster.local
ndots: 2
dns_mode: coredns
enable_nodelocaldns: true
nodelocaldns_ip: 169.254.25.10
nodelocaldns_health_port: 9254
enable_coredns_k8s_external: false
enable_coredns_k8s_endpoint_pod_names: false
resolvconf_mode: none
deploy_netchecker: false
skydns_server: "{{ kube_service_addresses|ipaddr('net')|ipaddr(3)|ipaddr('address') }}"
skydns_server_secondary: "{{ kube_service_addresses|ipaddr('net')|ipaddr(4)|ipaddr('address') }}"
dns_domain: "{{ cluster_name }}"
container_manager: containerd
kata_containers_enabled: false
kubeadm_certificate_key: "{{ lookup('password', credentials_dir + '/kubeadm_certificate_key.creds length=64 chars=hexdigits') | lower }}"
k8s_image_pull_policy: IfNotPresent
kubernetes_audit: false
dynamic_kubelet_configuration: false
default_kubelet_config_dir: "{{ kube_config_dir }}/dynamic_kubelet_dir"
dynamic_kubelet_configuration_dir: "{{ kubelet_config_dir | default(default_kubelet_config_dir) }}"
podsecuritypolicy_enabled: true
kubeconfig_localhost: true
kubectl_localhost: false
system_reserved: true
system_memory_reserved: 128Mi
system_cpu_reserved: 250m
system_master_memory_reserved: 256Mi
system_master_cpu_reserved: 250m
volume_cross_zone_attachment: false
persistent_volumes_enabled: false
event_ttl_duration: 1h0m0s
force_certificate_regeneration: false
minimal_node_memory_mb: 896
kube_proxy_nodeport_addresses: >-

Beaucoup de variables ci-dessus reprennent des défaults. Entre autre changements, notons le plugin réseau, “flannel“, et la définition des “kube_service_addresses” et “kube_pods_subnet” ou le “resolvconf_mode“. Le runtime conteneur : “containerd“. L’activation des PodSecurityPolicy. Les “system_*” vont réserver un peu de CPU et mémoire pour l’OS des noeuds. Le “minimal_node_memory” sera indispensable, lorsque certains noeuds ont moins d’1Gi de mémoire – les RPI 3b+ remontent 924Mi.

Il sera possible de configurer le runtime conteneur du cluster, en créant un fichier tel que le suivant :

$ cat <<EOF >inventory/rpi/group_vars/all/containerd.yaml
containerd_config:
  grpc:
    max_recv_message_size: 16777216
    max_send_message_size: 16777216
  debug:
    level: ""
  registries:
    docker.io: "https://registry-1.docker.io"
    katello: "https://katello.vms.intra.example.com:5000"
    "registry.registry.svc.cluster.local:5000": "http://registry.registry.svc.cluster.local:5000"
  max_container_log_line_size: -1
  metrics:
    address: ""
    grpc_histogram: false
EOF

Kubespray permet le déploiement de diverses applications optionnelles :

$ cat <<EOF >inventory/rpi/group_vars/k8s-cluster/addons.yaml
helm_enabled: false
registry_enabled: false
metrics_server_enabled: true
metrics_server_kubelet_insecure_tls: true
metrics_server_metric_resolution: 60s
metrics_server_kubelet_preferred_address_types: "InternalIP"
local_path_provisioner_enabled: false
local_volume_provisioner_enabled: false
cephfs_provisioner_enabled: false
rbd_provisioner_enabled: false
ingress_nginx_enabled: true
ingress_ambassador_enabled: false
ingress_alb_enabled: false
cert_manager_enabled: false
metallb_enabled: false
EOF

Dans notre cas, nous activerons le metrics server, et l’Ingress Controller Nginx.

Enfin, générer une clé SSH sur le noeud Ansible, et l’installer sur tous les noeuds composant le cluster :

$ ssh-keygen -t rsa -b 4096 -N '' -f ~/.ssh/id_rsa
$ for i in pandore hellenes .... terpsichore thalia
do ssh-copy-id -i ~/.ssh/id_rsa.pub root@$i.friends.intra.example.com; done
...
$ ansible -m ping -i inventory/rpi/hosts.yaml all

Déploiement Kubernetes

Nous pourrons enfin procéder au déploiement :

$ ansible-playbook -i inventory/rpi/hosts.yaml cluster.yml 2>&&1 | tee -a deploy.$(date +%s).log

Si le déploiement échoue, nous pourrons corriger notre inventaire et relancer cette même commande, à moins que l’erreur ne se soit produite lorsqu’ansible lance l’initialisation du cluster Kubernetes (kubeadm init), auquel cas nous devrions d’abord lancer le playbook de reset, pour réinitialiser les noeuds :

$ ansible-playbook -i inventory/rpi/hosts.yaml reset.yml

Si tout se passe bien, le déploiement ne prendra pas plus d’une trentaine de minutes.

...
PLAY RECAP *******************************************
calliope.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
clio.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
epimethee.friends.intra.example.com : ok=440 changed=67 unreachable=0 failed=0 skipped=864 rescued=0 ignored=1
erato.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
euterpe.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
hellenes.friends.intra.example.com : ok=442 changed=68 unreachable=0 failed=0 skipped=862 rescued=0 ignored=1
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
melpomene.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
pandore.friends.intra.example.com : ok=497 changed=86 unreachable=0 failed=0 skipped=924 rescued=0 ignored=1
pyrrha.friends.intra.example.com : ok=308 changed=30 unreachable=0 failed=0 skipped=604 rescued=0 ignored=1
polyhymnia.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
terpsichore.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1
thalia.friends.intra.example.com : ok=285 changed=29 unreachable=0 failed=0 skipped=514 rescued=0 ignored=1


$ kubectl get nodes
NAME                                    STATUS   ROLES    AGE     VERSION
calliope.friends.intra.example.com      Ready    infra    7m14s   v1.19.5
clio.friends.intra.example.com          Ready    infra    7m14s   v1.19.5
epimethee.friends.intra.example.com     Ready    master   9m26s   v1.19.5
erato.friends.intra.example.com         Ready    worker   7m14s   v1.19.5
euterpe.friends.intra.example.com       Ready    worker   7m1s    v1.19.5
hellenes.friends.intra.example.com      Ready    master   9m24s   v1.19.5
melpomene.friends.intra.example.com     Ready    worker   7m1s    v1.19.5
pandore.friends.intra.example.com       Ready    master   9m59s   v1.19.5
pyrrha.friends.intra.example.com        Ready    infra    7m14s   v1.19.5
polyhimnia.friends.intra.example.com    Ready    worker   7m1s    v1.19.5
terpsichore.friends.intra.example.com   Ready    worker   7m13s   v1.19.5
thalia.friends.intra.example.com        Ready    worker   7m      v1.19.5

A ce stade, nous pourrions intégrer le cluster avec une solution de stockage externe – au plus simple, un serveur NFS. Notons en revanche que l’on reste limité par les modules kernels fournis avec Raspbian : pour s’interfacer avec avec un cluster Ceph, il faudra recompiler votre kernel, ou s’intéresser à rbd-ndb. Nous reviendrons sur ce point dans un article ultérieur.

Conclusion

Cluster-raspi2

Le support ARM par Kubespray est relativement récent, mais fonctionne bien, contrairement à ce que l’on peut lire sur quelques articles de blogs, plus anciens. Il faudra en revanche s’assurer qu’au moins vos noeuds disposent de processeurs armv7, et qu’au moins vos master puissent lancer des binaires 64b.

En règle générale, il faudra s’assurer que les applications que l’on compte déployer dans notre cluster offrent bien des images ARM, ce qui n’est pas toujours le cas. Ceci dit, libre à vous d’assembler vous-mêmes vos images.

Contrairement à OpenShift 4, un cluster Kubernetes demande moins de ressources, offre une plus grande portabilitée, tandis que Kubespray n’a rien à envier aux outils de déploiements d’OpenShift que sont openshift-ansible, ou openshift-install.