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