Intégration Gitlab dans Kubernetes pour automatiser ses déploiements


08/06/2020 gitlab kubernetes deployment service-account gitlab-ci

Depuis que j’ai migré des sites sous kubernetes, j’avais perdu l’automatisation du déploiement de mes conteneurs. Pour ce site, je modifiais donc le site et une fois le git push realisé, j’attendais que Gitlab-CI crée mon conteneur. Je récupérai alors le tag du conteneur que je mettais dans le dépôt git où je stocke mes fichiers de configuration pour kubernetes. Une fois le tag mis à jour, je pouvais procéder au déploiement de mon conteneur. Il était temps d’améliorer ce workflow.

Gitlab propose depuis un moment une intégration avec kubernetes mais je lui trouve quelques inconvénients au regard de mes besoins :

  • Il faut créer un compte avec un ClusterRole cluster-admin et je ne suis pas super à l’aise avec cette idée,
  • Il est nécessaire de déployer Helm encore en version 2 alors que je suis passé en version 3 pour les rares projets où je l’utilise,
  • L’ingress s’appuie sur nginx-ingress, alors que j’utilise Traefik,
  • Je n’ai pas l’usage des autres fonctionnalités fournies par Gitlab dans mon contexte de “cluster de test” hébergeant quelques sites et applications web.

Mon besoin pourrait se résumer à pouvoir interagir avec mon cluster au travers de kubectl et de pouvoir y déployer la nouvelle version du conteneur que je viens de créer. Cela suppose alors d’avoir 3 choses :

  • le binaire kubectl accessible sous la forme d’un conteneur ou directement en shell dans le runner,
  • un fichier kubeconfig pour m’authentifier auprès du cluster et interagir avec,
  • la référence de l’image docker fraichement crée par Gitlab-CI à appliquer sur un Deployment kubernetes.

Création d’un compte de service avec authentification par token

Utilisant le service managé d’OVH, je n’ai pas accès à tous les certificats du cluster permettant de créer de nouveaux comptes utilisateurs. Par ailleurs, pour les intégrations comme Gitlab, il est recommandé d’utiliser des Service Accounts. C’est ce que nous allons faire.

En plus du Service Account, il nous faut donner un rôle à notre compte pour qu’il puisse réaliser des actions sur le cluster. Par simplicité pour ce billet, je vais lui donner les droits d’admin au sein d’un namespace. Le compte de service pourra alors faire ce qu’il veut mais uniquement au sein du namespace en question. En cas de fuite du compte, les dégats potentiels sont donc moindres qu’avec un compter qui est admin global du cluster. Le rôle admin existe déjà sous kubernetes, il s’agit du ClusteRole admin mais qui est restreint à un namespace via le RoleBinding.

Créons le fichier gitlab-integtration.yml avec ces éléments :

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: gitlab-example
  namespace: example
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: RoleBinding
metadata:
  name: gitlab-admin
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: admin
subjects:
- kind: ServiceAccount
  name: gitlab-example
  namespace: example

Déployons notre configuration sur le cluster :

# Apply yml file on the cluster
kubectl apply -f gitlab-integration.yml
serviceaccount/gitlab-example created
rolebinding.rbac.authorization.k8s.io/gitlab-admin created

Pour alimenter notre fichier kubeconfig, il nous faut récupérer le token :

# Get secret's name from service account
SECRETNAME=`kubectl -n example get sa/gitlab-example -o jsonpath='{.secrets[0].name}'`
# Get token from secret, encoded in base64
TOKEN=`kubectl -n example get secret $SECRETNAME -o jsonpath='{.data.token}'`
# Decode token
CLEAR_TOKEN=`echo $TOKEN |base64 --decode`

En prenant votre fichier kubeconfig de référence, vous pouvez alors créer une copie sous le nom kubeconfig-gitlab-example.yml et l’éditer de la façon suivante :

apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: <Existing certificate in a base64 format from your original kubeconfig file>
    server: <url of your k8s http endpoint like https://localhost:6443/ >
  name: kubernetes # adjust your cluster name
contexts:
- context:
    cluster: kubernetes # adjust your cluster name
    namespace: example # adjust your namespace
    user: gitlab-example # adujust your user
  name: kubernetes-ovh # adujust your context
current-context: kubernetes-ovh # adujust your context
kind: Config
preferences: {}
users:
- name: gitlab-example # adujust your user
  user:
    token: <Content of the CLEAR_TOKEN variable>

Vous pouvez tester son bon fonctionnement via :

# Fetch example resources if any:
kubectl --kubeconfig=./kubeconfig-gitlab-example.yml get all
...
# Check you can't access other namespaces information, like kube-system:
kubectl --kubeconfig=./kubeconfig-gitlab-example.yml get all -n kube-system
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "pods" in API group "" in the namespace "kube-system"
Error from server (Forbidden): replicationcontrollers is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "replicationcontrollers" in API group "" in the namespace "kube-system"
Error from server (Forbidden): services is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "services" in API group "" in the namespace "kube-system"
Error from server (Forbidden): daemonsets.apps is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "daemonsets" in API group "apps" in the namespace "kube-system"
Error from server (Forbidden): deployments.apps is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "deployments" in API group "apps" in the namespace "kube-system"
Error from server (Forbidden): replicasets.apps is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "replicasets" in API group "apps" in the namespace "kube-system"
Error from server (Forbidden): statefulsets.apps is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "statefulsets" in API group "apps" in the namespace "kube-system"
Error from server (Forbidden): horizontalpodautoscalers.autoscaling is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "horizontalpodautoscalers" in API group "autoscaling" in the namespace "kube-system"
Error from server (Forbidden): jobs.batch is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "jobs" in API group "batch" in the namespace "kube-system"
Error from server (Forbidden): cronjobs.batch is forbidden: User "system:serviceaccount:example:gitlab-example" cannot list resource "cronjobs" in API group "batch" in the namespace "kube-system"

Integration Gitlab : stocker le kubeconfig

Gitlab permet de stocker des variables. Dans le cas d’un fichier kubeconfig, on va vouloir ne jamais afficher son contenu dans les logs ou autre. Pour cela il est possible de masquer vos variables en respectant quelques contraintes et notamment que la valeur de la variable tienne sur une seule ligne.

Nous allons donc encoder le fichier en base64 et rajouter un argument pour que tout soit sur une seule ligne (et non pas sur plusieurs lignes par défaut):

# create a one line base64 version of kubeconfig file
cat kubeconfig-gitlab-example.yml | base64 -w 0

Copier le contenu obtenu dans une variable que nous appelerons KUBECONFIG et dont on cochera bien la case “Mask variable”. Une fois la variable sauvée, vous avez ceci :

gitlab ci masked variable

Intégration Gitlab : passer la référence de l’image au job de déploiement

Soit le fichier .gitlab-ci.yml suivant:

---
stages:
  - publish
  - image
  - deploy

publish:
  image:  $CI_REGISTRY/nsteinmetz/hugo:latest
  artifacts:
    paths:
      - public
    expire_in: 1 day
  only:
    - master
    - web
  script:
    - hugo
  stage: publish
  tags:
    - go

docker:
  stage: image
  image: docker:stable
  services:
  - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_DRIVER: overlay2
    RELEASE_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA-$CI_PIPELINE_ID-$CI_JOB_ID
  before_script:
    - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
  script:
    - echo "IMAGE=${RELEASE_IMAGE}" >> docker.env
    - docker build --pull -t $RELEASE_IMAGE .
    - docker push $RELEASE_IMAGE
  when: on_success
  tags:
    - go
  artifacts:
    reports:
      dotenv: docker.env

kube:
  stage: deploy
  script:
    - echo $KUBECONFIG | base64 --decode > kubeconfig
    - export KUBECONFIG=`pwd`/kubeconfig
    - sed -i -e "s|IMAGE|${IMAGE}|g" deployment.yml
    - kubectl apply -f deployment.yml
  needs:
    - job: docker
      artifacts: true
  when: on_success
  tags:
    - shell

Petite explication rapide :

  • l’étape publish va générer la version html du site et la stocker sous la forme d’un artefact qui sera passé aux jobs suivants,
  • l’étape docker va créer l’image en mettant l’artefact du job précédent dans un conteneur nginx et le publier dans la registry gitlab avec le nom suivant gitlab.registry/group/project:<short commit>-<pipeline id>-<job id>
  • l’étape kube va récupérer le contenu de la variable KUBECONFIG, le décoder et créer un fichier kubeconfig. On initialise la variable d’environnement KUBECONFIG pour que kubectl puisse l’utiliser. On met à jour la référence de l’image docker obtenue précédemment dans le fichier deployment.yml qui sert de modèle de déploiement. On applique le fichier obtenu sur le cluster kubernetes pour mettre à jour le déploiement.

Le point d’attention ici est que le passage de la variable RELEASE_IMAGE se fait via un dotenv qui est créé sous la forme d’un artefact à l’étape docker et est donc disponible à l’étape kube. Cela devrait être automatique mais j’ai ajouté une dépendance explicite via la directive needs. Lors de l’étape kube, le contenu du fichier docker.env est disponible sous la forme de variables d’environnement. On peut alors faire la substitution de notre placeholder par la valeur voulue dans deployment.yml.

Tout ce mécanisme est expliqué dnas la doc des variables gitlab et sur les variables d’environnement héritées. Attention, il vous faut Gitlab 13.0+ pour avoir cette fonctionnalité et en plus, il faut préalablement activer ce feature flag.

sudo gitlab-rails console
Feature.enable(:ci_dependency_variables)

En conclusion, nous avons vu comment :

  • Créer un compte de service (Service Account) sous kubernetes avec un rôle d’administrateur de namespace,
  • Stocker le fichier kubeconfig utilisant notre service account dans Gitlab sous la forme d’une variable masquée,
  • Passer une référence d’un job à un autre via les dotenv au niveau du job amont et les variables d’environnement au niveau du job en aval,
  • Récupérer le contenu de la variable kubeconfig pour créer une variable d’environnement et être en mesure d’utiliser kubectl.

Ainsi, toute mise à jour de master conduira à une mise à jour du déploiement associé au sein du cluster kubernetes et ne nécessitera plus d’interventions manuelles comme précédemment. Avec un service account lié à un namespace, on évite aussi de s’exposer inutilement en cas de fuite des identifiants.

Kubernetes @ OVH - Traefik en Deployment et intégration des Load Balancers


23/01/2019 kubernetes traefik ovh deployment load-balancer ingress

Pour faire suite au billet sur le déploiement de Traefik sous la forme d’un DaemonSet chez OVH, j’ai profité de la sortie en mode beta des Load Balancers pour revoir ma copie :

  • Déploiement de Traefik sous la forme d’un Deployment plutôt qu’un DaemonSet,
  • Intégration des Load Balancers,
  • Utilisation d’un namespace “traefik” plutôt que de tout mettre dans kube-system.

Par simplicité, je n’ai toujours qu’une node en plus du master fourni par OVH. Cela m’évite la problématique du stockage distribué des certificats. Cela fera l’objet d’un autre billet.

Créons le namespace traefik :

# Create namespace
kubectl create ns traefik
# Change context to this namespace so that all commands are by default run for this namespace
# see https://github.com/ahmetb/kubectx
kubens traefik

Commençons par traefik/rbac.yml - le fichier défini le compte de service (Service Account), le rôle au niveau du cluster (Cluster Role) et la liaison entre le rôle et le compte de service (Cluster Role Binding)

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: traefik-ingress-controller
  namespace: traefik
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: traefik-ingress-controller
rules:
  - apiGroups:
      - ""
    resources:
      - services
      - endpoints
      - secrets
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - extensions
    resources:
      - ingresses
    verbs:
      - get
      - list
      - watch
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: traefik-ingress-controller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: traefik-ingress-controller
subjects:
- kind: ServiceAccount
  name: traefik-ingress-controller
  namespace: traefik

Ensuite, pour Traefik, j’ai besoin d’un fichier traefik.toml avec la configuration que je mets à disposition sous la forme d’une ConfigMap dans un fichier traefik/traefik-toml-configmap.yml :

apiVersion: v1
kind: ConfigMap
metadata:
  name: traefik-conf
data:
  traefik.toml: |
    defaultEntryPoints = ["http", "https"]

    logLevel = "INFO"

    insecureSkipVerify = true

    [entryPoints]
      [entryPoints.http]
        address = ":80"
        [entryPoints.http.redirect]
          entryPoint = "https"
      [entryPoints.https]
        address = ":443"
        [entryPoints.https.tls]
      [entryPoints.api]
        address = ":8080"

    [acme]
    email = "contact@cerenit.fr"
    storage = "/acme/acme.json"
    entryPoint = "https"
    onHostRule = true
    [acme.httpChallenge]
      entryPoint = "http"

    [api]
    entryPoint = "api"
    dashboard = true
    debug = false

    [kubernetes]    

Le dashboard est à protéger par une authentification pour éviter tout accès non souhaité. Je l’ai supprimé de la configuration par simplicité.

Ensuite, pour stocker mes certificats, il me faut un volume que je défini via le fichier traefik/traefik-certificates-pvc.yml :

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: traefik-certificates
spec:
  accessModes:
    - ReadWriteOnce
  volumeMode: Filesystem
  resources:
    requests:
      storage: 1Gi
  storageClassName: cinder-classic

1 Go pour des certificats, c’est clairement trop mais il n’est pas possible pour le moment d’avoir un stockage plus réduit.

Je peux donc enfin déployer Traefik via le fichier traefik/traefik-deployment.yml :

---
kind: Deployment
apiVersion: extensions/v1beta1
metadata:
  name: traefik-ingress-controller
  labels:
    k8s-app: traefik-ingress-lb
spec:
  replicas: 1
  selector:
    matchLabels:
      k8s-app: traefik-ingress-lb
  template:
    metadata:
      labels:
        k8s-app: traefik-ingress-lb
        name: traefik-ingress-lb
    spec:
      serviceAccountName: traefik-ingress-controller
      terminationGracePeriodSeconds: 60
      containers:
      - image: traefik:1.7.7
        name: traefik-ingress-lb
        volumeMounts:
        - mountPath: /config
          name: traefik-config
        - mountPath: /acme
          name: certificates
        ports:
        - name: http
          containerPort: 80
        - name: admin
          containerPort: 8080
        - name: secure
          containerPort: 443
        args:
        - --configfile=/config/traefik.toml
      volumes:
        - name: traefik-config
          configMap:
            name: traefik-conf
        - name: certificates
          persistentVolumeClaim:
            claimName: traefik-certificates

Nous déployons donc :

  • Traefik en Deployment
  • Les ports 80, 443 et 8080 sont définis
  • La configuration est une ConfigMap
  • Les certificats sont à déployer dans un volume

Pour permettre au cluster d’accéder aux différents ports, il faut définir un service via le fichier traefik-service-clusterip.yml :

---
kind: Service
apiVersion: v1
metadata:
  name: traefik-ingress-service-clusterip
spec:
  selector:
    k8s-app: traefik-ingress-lb
  ports:
    - protocol: TCP
      port: 80
      name: web
    - protocol: TCP
      port: 8080
      name: admin
    - protocol: TCP
      port: 443
      name: secure
  type: ClusterIP

Et pour avoir un accès de l’extérieur, il faut instancier un load-balancer via le fichier traefik/traefik-service-loadbalancer.yml

kind: Service
apiVersion: v1
metadata:
  name: traefik-ingress-service-lb
spec:
  selector:
    k8s-app: traefik-ingress-lb
  ports:
    - protocol: TCP
      port: 80
      name: web
    - protocol: TCP
      port: 443
      name: secure
  type: LoadBalancer

Pour donner l’accès au dashboard via une url sécurisée par un certificat Let’s Encrypt, il faut déclarer un Ingress, dans le fichier traefik/traefik-api-ingress.yml :

---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: traefik-web-ui
spec:
  rules:
  - host: traefik.k8s.cerenit.fr
    http:
      paths:
      - path: /
        backend:
          serviceName: traefik-ingress-service
          servicePort: admin

Il ne nous reste plus qu’à faire :

# Create k8s ressources for traefik
kubectl create -f traefik/
# Watch service to get IPs
kubectl get svc -w

Une fois votre IP obtenue, il suffit de faire pointer votre entrée DNS vers cette IP ou de tester via :

curl -H "Host: traefik.k8s.cerenit.fr" https://xxx.xxx.xxx.xxx/

Pour l’obtention du certificat Let’s Encrypt, il faut que votre enregistrement DNS soit à jour préalablement. Sinon vous aurez un certificat autosigné par Traefik en attendant.

Dès lors, vous pouvez accéder au dashboard de Traefik via l’url définie. Pour donner accès à d’autres sites, il faut déclarer d’autres ingress sur le même modèle et le tour est joué.

Comparativement au dernier tutoriel :

  • Nous n’exposons plus le port 8080 au niveau de l’hôte,
  • Nous respectons plus les guidelines kubernetes à savoir de donner accès à une ressource via un service de type Load-Balancer ou NodePort
  • Nous utilisons une seule IP externe et nous appuyons sur les ingress pour mutualiser le load balancer et éviter d’avoir une IP publique par service à exposer
  • Nous ne sommes pas sur d’avoir un pod traefik par noeud mais nous gagnons en flexibilité - il faudra jouer avec les replicas dès qu’on ajoutera des nodes dans le cluster.

Il reste encore le problème des stockage des certificats à résoudre pour passer à un contexte multi-nodes. Ce sera l’objet d’un prochain billet avec idéalement l’intégration de Traefik avec cert-manager (plutôt que de devoir déployer une base clé/valeur comme etcd ou consul pour y stocker les infos de traefik).

N’hésitez pas à me faire part de vos retours.

Syndication

Restez informé(s) de notre actualité en vous abonnant au flux du blog (Atom)

Nuage de tags

kubernetes docker timeseries influxdb warp10 grafana traefik elasticsearch kafka postgres python ansible aws sécurité terraform mysql redis telegraf git ovh tick chronograf cloud dashboard docker-compose hashicorp timescaledb cassandra helm podman ptsm swarm test vector flux iot kapacitor rancher timescale cérénit influxdata log machine-learning monitoring postgresql raspberrypi s3 spark sql vscode arm bilan comptabilité confluent devops gitlab gitlab-ci iac java ksql microservice nomad perspective prometheus serverless service-mesh angularjs api bigdatahebdo cli cncf consul container discovery dns flows gcp gke graphql influxace ingress javascript npm opensource operator rook scaleway ssh stream vault warpscript windows architecture cert-manager containerd csp documentation elastic forecast geospatial golang hpkp json kafka-streams kibana kubedb lambda lean licence maesh mariadb microsoft mqtt nginx orientdb quasardb redhat registry rest rethinkdb reverse-proxy rgpd warpstudio wireguard agile anomalie apm arima azure bash big-data ceph certificat challenge cluster co2 continous-delivery continous-integration cookie datatask dataviz dbt deployment diff django edge esp32 facebook fec fluxlang gdpr google-analytics grav hsts http/3 https hypriot ia influxdays istio jq k3s lets-encrypt linux load-balancer longhorn meetup metabase mobile molecule mongodb nosql nvidia openebs openhab openssh ovhcloud pandas parquet percona performance php pip pipeline questdb reaper replication rootless rpi rsyslog runc résilience scale secrets société solr sre systemd tempo timezone tinygo tls virtualenv vitess vue.js wagtail warpfleet yarn accessibilité acme adoptopenjdk agpl akka alerte alertes alibaba amazon-emr amqp anonymisation anthos apache-pulsar ara arduino arrow artefact asgi automation automatisation automl awstats banque bastion beam beat bi bme680 bootstrap bounded-context branche brigade browser buildah buildkit calico cd cdc cdk centos certificats cgroups chart check checklist chrome ci cilium cio circuitpython clever-cloud clickhouse cloud-init cloud-native cloud-storage cloudflare clusterip cnab cni cockroachdb code codeurs-en-seine commit confluence conftest consul-connect context continous-deployment conventional-commit coreos cors covid19 cqrs crash cri cron crontab csi csrf css cto curl d3.js daemonset data data-engineer data-pipelining data.gouv.fr databricks datacenter date date-scientist ddd debezium debian delta deprek8 desktop devoxx dig distributed-systems dive docker-app docker-hub docker-registry dockerfile dockershim documentdb dog dokcer données-personnelles draft dredd drop-in dsi duckdb duration déploiement ebs ec2 elassandra electron elk engineering entreprise etcd euclidia event-sourcing faas falco falcor feature-policy fedora feed filebeat firebase firefox fish flash flask fleet flink flovea fluentd font foundation framework frenchtech frontend fsync fugue fullstack git-filter-repo github gitignore gitpod glacier glowroot goaccess google google-cloud-next gpg gpu grep grid géospatial hacker hadoop haproxy harbor hdfs header holt-winters html html5 http httpx hue iaac ibm iiot immutable incident index indluxdata influxcloud infrastructure-as-code ingénierie inspec jenkins jless jquery jvm jwt k3d k6 k8s k9s kaniko katz kubeadm kubecon kubectl label laravel leap-second lens letsencrypt libssh linky linter liste-de-diffusion lmap loadbalancer logstash logstatsh loi loki lstm mailing-list management matomo maturité mesh mesos message metallb micro-service minio mot-de-passe multi-cloud médecine métrique n8n nebula network newsletter nodejs nodeport notebook notifications nrtsearch null numérique object-storage observability observabilité opa opendata openmetrics openshit openstack openweb opnsense over-engineering packaging partiql password persistent-volume-claim pico pipenv pivot pod portainer portworx prediction prescience privacy-shield production promql prophet prévision psp ptyhon publicité pubsub pulsar push pyenv pérénnité qualité quay queue quic ram rambleed raml react readme recaptcha recherche redistimeseries reindex reinvent reliability remote-execution repository responsive retention-policy revocation revue-de-code rexec rhel rkt robotframework rolespec root rpo rto rust rwd réseau résultat safe-harbor sarima scanner schema scp search select semiconducteur serverless-architecture service service-account service-worker setuptools sftp sha1 shard shard-duration shard-group sharding shell shipyard sidecar singer socket souveraineté-numérique spectre spinnaker sqlite sri ssh-agent ssl stabilité stash statistique stm32 storage sudo superset suse sympa sysdig syslog-ng sérénité task tavern template terracost terrascan test-unitaire thingspeak tidb tiers time timecale timer timestream training transformation travail trésorerie tsfel tsfr tsl ubuntu unikernel unit ux velero vendredi victoria-metrics vie-privée virtualbox virtualisation vm vnc volume voxxeddays vpc vpn wasm workflow yaml yield yq yubikey zip