1 Aug 2023

Première expérience avec Nomad

Table of contents


Contexte

Bien que k8s soit très popularisé et majoritairement utilisé dans le rôle d’orchestration de containers, il n’est pas le seul. Une alternative proposé par Hashicorp, Nomad, permet également une orchestration de ce type qui se dit plus simple et répondant à des besoins parfois différents types batch.

Pour ma part je me suis retrouvé confronté au besoin de monter plusieurs petits services nécessitant l’utilisation de containers. Afin de ne pas avoir encore un nouveau serveur avec un Docker standelone, et ne souhaitant pas encore aborder le sujet k8s dans ce contexte, je me suis rapproché de Nomad.

L’objectif étant de mettre en place un serveur Nomad de manière très simple et de construire et faire évoluer celui petit à petit s’il correspond bien à mon besoin. Les applications que j’ai a installer sont principalements des pods Stateful.

Pour ça j’aurais à disposition:

  • Une instance chez scaleway
  • Ansible
  • Terraform
  • un Vault (de manière assez limitée)

Les composants de nomad

La CNI

L’installation de la CNI (Container Network Interface) se fait très facilement dans Nomad. Contrairement à Kub où l’on trouve pas mal de choix, la documentation ne fera référence que d’un seul choix.

Si vous souhaitez faire des configurations plus poussez, il existe également la possibilité d’utiliser des alternatives à cette CNI, notamment avec Cilium, exemple ici.

La CSI

Au même titre que la CNI, la CSI (Container Storage Interface) vas vous permettre la gestion du stockage, et ça ce complique un petit peu à partir d’ici.

Avec une documentation claire et très simple sur la CNI, je m’attendais à la même chose pour la CSI, mais j’ai été un peu déçu. La documentation nous indique qu’elle suit les standards et qu’il faut se référer à la documentation Kubernetes. Le problème est que certaines CSI, tel qu’OpenEBS, ne supportent pas/plus Nomad, et c’est à nous le savoir en lisant les specs de toutes les CSI.

Bien que ma machine nomad était dans un contexte Scaleway et qu’il existe une CSI, je souhaitais avoir un stockage persistant local dans un premier temps, histoire de ne pas se faire facturer une deuxième fois. Sans OpenEBS j’ai testé trois alternatives.

  1. Portworx est une solution de Pure Sotage. L’installation se fait en deux parties, un node et un controller, une solution assez intéressante et séduisante. Mais bien que ça s’appuie sur une lib opensource openstorage, il y a un modèle B2B que j’ai du mal à comprendre et je n’ai pas pris le temps de plus creuser.

  2. Hostpath, utilisé comme exemple dans la documentation, ça me semblait assez facile d’utilisation. Tout fonctionnais bien jusqu’au restart du serveur, où mes volumes deviennent inutilisables. Après quelques recherches il est fort probable que je sois tombé sur un bug en cours de correction lors de mes tests.

  3. Nomad, le stockage propre à Nomad, où il faut déclarer notre volume dans la conf client. Pour le coup très simple à mettre en place et relativement bien documenté.

Le principe de toutes ces solutions restent les mêmes. On créer un volume, on le réclame dans notre groupe, puis on l’attache à notre/nos task(s).

Les ingress controller

Un ingress controller est une ressource qui permet de centraliser, controller et d’exposer un ou plusieurs service(s). Au final il s’agit ni plus ni moins d’un reverse proxy avec les features que la solution dispose (loadbalancer, controller des headers http, forward tcp ou udp, restriction d’accès…). Habitué à l’ingress controller nginx dans k8s, j’ai voulu partir sur la même solution. Bon il m’a fallu peu de temps pour comprendre que c’était vite compliqué et que malgrès nomad pack (j’en parle juste après) je n’allais pas vraiment pouvoir faire tout ce que je souhaitais.

C’est par la suite que je suis partie sur traefik. Avec une intégration native de nomad et un support de letsencrypt, j’avais tout ce que souhaitais et je ne pense pas qu’il soit nécessaire de tester plus de solutions sur nomad.

Nomad pack

L’un des plus gros problème (à mes yeux) est qu’il n’y ait pas de capitalisation de connaissance et d’outils pour simplifier le déploiement de briques qui peuvent être complexe (csi, cni, ingress…). À chaque fois il est nécessaire de faire son propre job contrairement à k8s qui dispose de helm avec une grande quantité de charts disponibles, notamment grâce aux charts bitnami.

C’est là que Nomad pack intervient, il s’agit d’un produit plutôt équivalent pour nomad. Dans les utilisations ça semble équivalent, une registry par default est à disposition et plusieurs packs sont déjà prêts à consommer. Le seul problème est que c’est une technical-release et que la release actuelle ne correspond plus au contenue de la registry. Ça fail et tout est inutilisable (cc #346. Malgrès un patch en local (dispo sur la master) il existe un autre bug, du fait de cette instabilité j’ai écarté totalement cette solution (mais disponible dans mon role ansible).

Les jobs

Les jobs nomad seront la configuration nécessaire pour mettre en place un service. Dans ces derniers on va définir un ou plusieur group(s) disposant de la configuration des volumes, des services mais aussi d’une ou plusieur task(s).

Use case

Bon après tout ça il était intéressant de partir sur un vrai use-case. On va mettre en place 2 services web, citizen qui une registry terraform et un forum discourse.

Installation de nomad

Dans un premier temps il est nécessaire de mettre en place le serveur Nomad. J’avais à ma disposition uniquement Ansible pour m’appuyer sur ce besoin. Après avoir fait le tour de ce qui se passe au niveau des rôles, je n’ai rien trouvé de pertinent. Peu, voir pas, maintenu sur l’existant j’ai pris la décision de faire le mien avec un scope assez réduit car je prendrais en compte que Ubuntu 22. L’installation se fait assez rapidement en suivant la documentation. Les étapes seront les suivants:

  • Installation du repo Apt
  • Installation du package nomad
  • Mise en place de la configuration
  • Installation des plugins
  • Configuration syctl

J’ai mis à disposition le code source de ce que j’ai fait sur github fe80/fe80.nomad. Je vais utiliser les variables suivantes.

---
# List of require host volume
nomad_host_volumes:
  postgresql:
    path: /var/lib/postgresql
  discourse:
    path: /srv/discourse
  citizen:
    path: /srv/citizen
  acme:
    path: /etc/acme

# Vault
nomad_vault:
  enabled: true
  address: https://my.vault.io
  task_token_ttl: 12h
  create_from_role: nomad-servers
  token: "<my token>"

# Tls
nomad_tls:
  ca_file: /etc/ssl/certs/consul-ca.pem
  cert_file: /etc/ssl/certs/nomad.pem
  key_file: /etc/ssl/private/nomad.key

Il est bon de noter que je vais faire une pre-task qui va aller créer un certificat dans une pki vault. Ce qui donne une task du style:

---
- name: Check if private key already exist
  ansible.builtin.stat:
    path: "{{ ssl_path.private_key }}"
  register: private_key

- name: Create tls context
  when: not private_key.stat.exists
  block:
    - name: Generate tls certificat
      community.hashi_vault.vault_pki_generate_certificate:
        role_name: "{{ pki.role }}"
        engine_mount_point: "{{ pki.mount }}"
        url: "{{ lookup('ansible.builtin.env', 'VAULT_ADDR') }}"
        token: "{{ lookup('ansible.builtin.env', 'VAULT_TOKEN') }}"
        common_name: nomad.my.cluster
        ttl: 3650d
        alt_names: "alt_names"
        ip_sans: "{{ ['127.0.0.1'] + ansible_all_ipv4_addresses }}"
      register: tls
      delegate_to: localhost
      become: false

    - name: Copy content for "{{ item }}"
      ansible.builtin.copy:
        content: "{{ tls.data.data[item] }}"
        dest: "{{ ssl_path[item] }}"
        owner: root
        group: root
        mode: '0444'
      no_log: true
      loop: "{{ ssl_path.keys() | list }}"

- name: Fix private key permission
  ansible.builtin.file:
    path: "{{ ssl_path.private_key }}"
    state: file
    mode: '0400'
    owner: root
    group: root

Il est important de mettre en place les ip du serveur dans ip_sans, puisque ce sera avec les IP que mon traefik va intérroger nomad.

Inspiré de Capistrano j’ai mis en place le déploiement et la gestion de ma CNI dans mon role (cc plugins.yml).

Une fois le playbook appliqué vous devriez avoir accès à https://<ip>:4646 qui sera votre console nomad/

Après ça, il me reste plus qu’à utiliser le provider nomad de terraform pour pouvoir gérer la création de mes jobs. À noter que si vous utilisez une CA propre à votre cluster (comme ce qui est expliqué dans la documentation) il sera nécessaire de soit l’inclure dans votre bibliothèque de CA, ou d’avoir la variable d’environnement NOMAD_SKIP_VERIFY=true (ou skip_verify pour le provider >= 2.0.0).

Installation des jobs

Traefik

La configuration traefik se distingue du fait qu’elle peut être écrite à plusieurs niveaux, statique, dynamique et enfin (pour le cas de nomad) avec les tags ou les metadatas des services. Et on va utiliser les trois types. La configuration par default sera déposé dans /etc/traefik/traefik.yml. Elle disposera de la des entrypoints, des providers et de la conf letsencrypt (je mettrais du route53 et du http challenge pour l’exemple). Dans la liste des providers sera mit en place l’endroit de la configuration dynamique afin de mettre plusieurs routers par default pour l’accès à la consol nomad et au dashboard traefik. Et enfin j’utiliserais les tags pour configurer mes ingress avec mes services.

Suivant les concepts, traefik utilisera le chemin suivant lors de l’arriver d’une requête: EntryPoints (comme le port http 80) -> Routers (utilisé pour savoir sur quel service envoyé la requête), Middlewares (optionnel, permet de définir des traitements particulier à sa requête tel qu’une redirection), Services (contenant la définition du service où la requête doit aller)

Dans le job suivant je vais créer mon ingress traefik (il s’agira de mon job le plus complexe en terme de feature). La sécurisation des accès se fera par filtrage ipv4 et 1.2.3.4 sera notre ip d’admin et nomad.p0l.io notre accès public nomad. Lire les commentaires pour les détails.

Ingress job

job "ingress" {
  # Using my nomad DC (default value)
  datacenters = ["dc1"]

  # Define the scheduler type
  # Refere to citizen job for more informations
  type        = "system"

  group "ingress" {
    # We require only one pod
    count = 1

    # Claim volume acme defined from client configuration
    volume "acme" {
      type      = "host"
      source    = "acme"
      read_only = false
    }

    # Creat my network configuration
    network {
      # Http port use by traefik for public trafic map on port 80 on the host
      port "http" {
        static = 80
      }

      # Https port use by traefik for public trafic map on port 443 on the host
      port "https" {
        static = 443
      }

      # Traefik dashboard's port used for rescue map on port 8081 on the host
      port "traefik" {
        static = 8081
      }
    }

    # Expose my diffrents services
    # I don't use consul, so I declare the provider name `nomad`
    service {
      provider = "nomad"
      name     = "http"
      port     = "http"
    }

    service {
      provider = "nomad"
      name     = "https"
      port     = "https"
    }

    service {
      provider = "nomad"
      port     = "traefik"
    }

    # Create my task whith my container configuration
    task "traefik" {
      # Define the driver
      driver = "docker"

      # Claim my vault policy I need to get my secrets
      vault {
        policies = ["route53"]
      }

      # Set up some cpu/ram limitation
      resources {
        cpu    = 200
        memory = 128
      }

      # Mount my volume acme on my pod in /etc/acme
      volume_mount {
        volume      = "acme"
        destination = "/etc/acme"
      }

      # Container configuration
      config {
        # Image name
        image = "traefik:v2.10.3"

        # Exposed port (used by services)
        ports = ["https", "http", "traefik"]

        # Mount my volumes
        volumes = [
          "/etc/ssl/certs:/certs", # This volume is directly on the host, require for nomad CA
          "local/traefik.yml:/etc/traefik/traefik.yml",
        ]
      }

    # Create configuration file shared on the container
     template {
       data        = <<EOH
---
http:
  # Default routers
  routers:
    # Set up traefk public dashboard access
    api:
      # Use tls
      tls: true

      # Limited on nomad.p0l.io/traefik and nomad.p0l.io/api for IP 1.2.3.4
      rule: Host(`nomad.p0l.io`) && (PathPrefix(`/traefik`) || PathPrefix(`/api`)) && ClientIP(`1.2.3.4`)

      # Use service api@internal
      # This is a intenal service created by traefik when api is on
      service: api@internal

      # Use custom middlewares traefik
      middlewares: traefik

    # Set up nomad consol access (pretty same configuration)
    nomad:
      tls: true
      rule: Host(`nomad.p0l.io`) && (PathPrefix(`/v1`) || PathPrefix(`/ui`)) && ClientIP(`1.2.3.4`)
      # Use my service created on my file provider
      service: nomad@file
      middlewares: nomad

  # Services
  services:
    # Create my service nomad, it's can be used by service `nomad@file`
    nomad:
      loadBalancer:
        servers:
          - url: https://{{ env "NOMAD_IP_traefik" }}:4646

  # Adding my nomad CA for my service
  serversTransports:
    mytransport:
      rootCAs:
        - /certs/consul-ca.pem

  # Middlewares
  middlewares:
    # Create a stripprefix to allo my dashboard on /traefik
    # https://doc.traefik.io/traefik/middlewares/http/stripprefix
    traefik:
      stripprefix:
        prefixes: /traefik
    nomad:
      stripprefix:
        prefixes: /nomad
...
EOH
       # My template location
       destination = "/local/dynamic.yml"
     }

      template {
        data = <<EOF
---
# Setup default endpoints
entryPoints:
  # Enable http trafic on port 80
  http:
    address: ':80'
    http:
      # Redirect all my trafic on entryPoints https
      redirections:
        entryPoint:
          to: https
          scheme: https
          permanent: true
  # Enable https trafic on port 443
  https:
    address: ':443'
  # Enable traefik consol on port 8081
  traefik:
    address: ':8081'

log:
  level: INFO

# Enable acces log
accessLog: {}

# Enable api/dashboard
api: {}

# Enable healtthcheck endpoint
ping: {}

providers:
  providersThrottleDuration: 5s
  # Create my file provider for dynamic configuration
  file:
    watch: true
    # Related with my previous template location
    filename: '/local/dynamic.yml'

  # Enable nomad provider
  nomad:
    refreshInterval: 5s

    # Define nomad endpoint to scrape
    endpoint:
      address: https://{{ env "NOMAD_IP_traefik" }}:4646
      # Require if you have a custom CA.
      # The file location is related by
      #  * My volume shared "/etc/ssl/certs:/certs"
      #  * My tls nomad configuration
      tls:
        ca: /certs/consul-ca.pem

    # Use a tag filter to enable traefik ingress
    constraints: "Tag(`traefik.enable=true`)"

# Creaticate configuration
# acme = letsencrypt
# https://doc.traefik.io/traefik/https/acme/#the-different-acme-challenges
certificatesResolvers:
  # My resolver name
  route53:
    acme:
      # Set up email address used for your LE certificat
      email: fake@p0l.io

      # Add additional chain
      preferredChain: 'ISRG Root X1'

      # Define a cache storage (require if you destroy you container)
      # This directory is related by a host volume
      storage: /etc/acme/acme.json

      # Use dns challenge with route 53 provider
      dnsChallenge:
        provider: route53

  # Add a additional resolver
  http:
    acme:
      email: fake@p0l.io
      preferredChain: 'ISRG Root X1'

      # Use a diffrent cache file
      storage: /etc/acme/http.json

      # User http challenge
      httpChallenge:
        entryPoint: http
EOF

        destination = "local/traefik.yml"
      }

      # Create a environment variable configuration
      template {
        data = <<EOF
# Use my vault secret `from/vault/path`
{{ with secret "from/vault/path" }}
AWS_ACCESS_KEY_ID={{ .Data.data.id }}
AWS_SECRET_ACCESS_KEY={{ .Data.data.secret }}
AWS_REGION={{ .Data.data.region }}
{{ end }}
EOF
        # Should be on a diffrent destination path
        destination = "secrets/file.env"
        # Define is a environment file
        env         = true
      }
    }
  }
}

Une fois votre task en place:

  • vous avez acceès à https://nomad.p0l.io/traefik qui contiendra votre accès au dashboard traefik (ou http://<ip>:8081 en cas de rescue)
  • nomad job status ingress, contiendra le status de votre job
  • nomad alloc fs -job ingress traefik/local, vous permettras de lister et d’accéder en lecture à votre configuration
  • nomad alloc fs -job ingress traefik/secrets, listera vos secrets sans accès en lecture
  • nomad service list devrait vous retourner la list des 3 services
    • nomad service info http, l’accès http
    • nomad service info https, l’accès https
    • nomad service info ingress-ingress, l’accès à la consol traefik

Citizen

Citizen sera notre premier job que l’on va exposer sur notre serveur. Il s’agit d’une registry terraform en nodejs. Pour nos besoins le stockage des datas et la base sqlite se feront sur le même volume.

Citizen job

job "citizen" {
  datacenters = ["dc1"]
  type        = "service"

  group "citizen" {
    count = 1

    volume "citizen" {
      type      = "host"
      source    = "citizen"
      read_only = false
    }

    network {
      port "citizen" {
        to = 3000
      }
    }

    service {
      provider = "nomad"

      tags = [
        "citizen", "app",
        "traefik.enable=true",
        "traefik.http.routers.citizen.rule=(Host(`terraform.p0l.io`) && Method(`GET`)) || (Host(`terraform.p0l.io`) && ClientIP(`1.2.3.4`))",
        "traefik.http.routers.citizen.tls=true",
        "traefik.http.routers.citizen.tls.certresolver=http"
      ]

      port = "citizen"

      check {
        type     = "http"
        name     = "citizen"
        path     = "/health"
        interval = "10s"
        timeout  = "5s"

        check_restart {
          limit = 2
          grace = "20s"
        }
      }
    }

    task "citizen" {
      driver = "docker"

      env {
        CITIZEN_DATABASE_TYPE = "sqlite"
        CITIZEN_DATABASE_URL = "/citizen/data"
        CITIZEN_STORAGE = "file"
        CITIZEN_STORAGE_PATH = "/citizen/data"
      }

      config {
        image        = "ghcr.io/outsideris/citizen:0.5.2"
        ports        = ["citizen"]
      }

      resources {
        cpu        = 128
        memory     = 128
        memory_max = 1024
      }

      volume_mount {
        volume      = "citizen"
        destination = "/citizen/data"
      }

      restart {
        attempts = 3
        interval = "5m"
        delay    = "2m"
        mode     = "delay"
      }

    }

    update {
      max_parallel     = 1
      health_check     = "checks"
      min_healthy_time = "5s"
      healthy_deadline = "30s"
      auto_revert      = true
      canary           = 0
    }
  }
}

Dans ce job plusieurs blocks sont ajoutés ou modifiés.

Dans un premier temps notre type de scheduler change. Sur le job d’ingress il est définit en tant que system, il sera déployé systématiquement sur touts les clients et aura une priorité sur le déploiement. En étant sur service, notre job indique que notre task sera continuellement en running et le nombre de container sera équivalent à ce que l’on définis dans count.

type = "service"

Puis, notre network utilise le bridge local de Docker (par default) avec notre CNI. Ensuite, il n’y a pas de port de définit sur le host mais un port de référence du container (3000) qui correspond au port d’écoute du service citizen.

network {
  port "citizen" {
    to = 3000
  }
}

En soit on n’a pas besoin de connaitre le port qui sera utilisé, ça sera le boulot de notre ingress controller de faire ça. Il est néanmoins possible de récupérer le port utilisé avec la commande nomad service info citizen-citizen.

Ensuite, notre service dispose de tags qui servent à la configuration de notre ingress. Le provider nomad de treafik va lire ces tags afin de créer sa configuration. Les tags en place sont l’équivalent de la config yaml suivante:

http:
  routers:
    citizen:
      rule: (Host(`terraform.p0l.io`) && Method(`GET`)) || (Host(`terraform.p0l.io`) && ClientIP(`1.2.3.4`))
      tls:
        certresolver: http

Avec cette configuration notre service sera disponible:

  • À any en GET sur https://terraform.p0l.io
  • À toutes les methods pour notre IP d’admin 1.2.3.4

Enfin un certificat sera créé par un challenge http. Ce challenge sera pris en compte par le router acme-http@internal et le service acme-http@internal mit en place par la configuration traefik.

Un check de mon service est aussi en place sur le endpoint /health de mes containers afin de s’assurer qu’il soit fonctionnel.

Plusieurs variables d’environnement sont en places avec le block env. Définis dans la tasks elle seront propres à mes containers.

env {
  CITIZEN_DATABASE_TYPE = "sqlite"
  CITIZEN_DATABASE_URL = "/citizen/data"
  CITIZEN_STORAGE = "file"
  CITIZEN_STORAGE_PATH = "/citizen/data"
}

Enfin un block update et restart sont en place pour le lifecycle de notre task/job.

...
  restart {
    attempts = 3
    interval = "5m"
    delay    = "2m"
    mode     = "delay"
  }

}

update {
  max_parallel     = 1
  health_check     = "checks"
  min_healthy_time = "5s"
  healthy_deadline = "30s"
  auto_revert      = true
  canary           = 0
}

Discourse

Discourse est un forum écrit en Rails. La documentation et les procédures d’installation de l’app fournit pas l’éditeur ne sont pas très utilisable sur des infrastructure moderne (tout les services sont dans une image docker à buildé soit même). Heureusement Bitnami à fait une image docker plus légère et une Chart helm pour le déploiement dont je me suis gandement inspiré.

L’application se déploie en 3 parties.

Redis

Un Redis est nécessaire pour discourse. Il n’y a rien de très compliqué à effectuer pour ce dernier, mais deux nouveaux blocks et paramètres apparaisent:

  • La présence d’un ephemeral_disk, afin de stocker les dumps de redis et ne pas consommer de la RAM
  • La mise en place d’une priorité de déploiement, permet de définir que la task est prioritaire par rapport à la valeur par default, car notre application rails aurat besoin que le redis soit up avant de se lancer.

Redis job

job "redis" {
  datacenters = ["dc1"]
  priority    = 55

  type = "service"

  update {
    max_parallel     = 1
    min_healthy_time = "10s"
    healthy_deadline = "3m"
    auto_revert      = false
    canary           = 0
  }

  group "cache" {
    count = 1

    restart {
      attempts = 10
      interval = "5m"
      delay    = "25s"
      mode     = "delay"
    }

    ephemeral_disk {
      size = 300
    }

    network {
      port "redis" {
        to = 6379
      }
    }

    service {
      provider = "nomad"
      tags = ["redis", "cache"]
      port = "redis"

      check {
        name     = "redis"
        type     = "tcp"
        interval = "10s"
        timeout  = "1s"
      }
    }

    task "redis" {
      driver = "docker"
      config {
        image = "redis:7.0.11-alpine3.18"
        ports = ["redis"]

        labels {
          app     = "discourse"
          service = "cache"
        }
      }

      resources {
        cpu        = 500
        memory     = 128
        memory_max = 256
      }
    }
  }
}

PostgresQL

Ensuite, un PostgresQL est nécessaire en tant que base de données. Là non plus pas de grande subtilité. Néamoins il est possible de configurer son postgresql avec un combo assez intéressant nomad + vault + consul. Pour ma part je n’ai pas consul à disposition et mon vault n’est pas fait pour accéder à mon nomad mais l’inverse. Je suis donc passé par un script de bootstrap disponible avec l’image docker.

config {
  image   = "postgres:15.3-alpine3.18"
  ports   = ["postgresql"]
  volumes = [
    "local/bootstrap.sql:/docker-entrypoint-initdb.d/bootstrap.sql",
  ]
}

template {
  data = <<EOF
{{ with secret "my/vault/path" }}
CREATE DATABASE discourse;
CREATE USER discourse WITH ENCRYPTED PASSWORD '{{ .Data.data.discourse }}';
GRANT ALL PRIVILEGES ON DATABASE discourse TO discourse;
ALTER DATABASE discourse OWNER TO discourse;
GRANT ALL ON schema public TO discourse;
{{ end }}
EOF
  destination = "local/bootstrap.sql"
}

La mise en place de variable d’environnement par template sera également utilisé pour définir le mot de passe root. Le hcl complet:

Postgresql job

job "postgres" {
  datacenters = ["dc1"]
  type        = "service"
  priority    = 60

  group "database" {
    count = 1

    volume "data" {
      type      = "host"
      source    = "postgresql"
      read_only = false
    }

    network {
      port "postgresql" {
        to = 5432
      }
    }

    service {
      provider = "nomad"
      tags = ["postgresql", "database"]
      port = "postgresql"

      check {
        name     = "postgres"
        type     = "tcp"
        interval = "10s"
        timeout  = "2s"
      }
    }

    task "postgres" {
      driver = "docker"

      vault {
        policies = ["nomad-postgres"]
      }

      config {
        image   = "postgres:15.3-alpine3.18"
        ports   = ["postgresql"]
        volumes = [
          "local/bootstrap.sql:/docker-entrypoint-initdb.d/bootstrap.sql",
        ]
      }

      template {
        data = <<EOF
{{ with secret "my/vault/path" }}
CREATE DATABASE discourse;
CREATE USER discourse WITH ENCRYPTED PASSWORD '{{ .Data.data.discourse }}';
GRANT ALL PRIVILEGES ON DATABASE discourse TO discourse;
ALTER DATABASE discourse OWNER TO discourse;
GRANT ALL ON schema public TO discourse;
{{ end }}
EOF
        destination = "local/bootstrap.sql"
      }

      template {
        data = <<EOF
{{ with secret "my/vault/path" }}
POSTGRES_USER=root
POSTGRES_PASSWORD="{{ .Data.data.root }}"
{{ end }}
EOF
        destination = "secret/file.env"
        env         = true
      }

      logs {
        max_files     = 5
        max_file_size = 15
      }

      resources {
        cpu        = 500
        memory     = 256
        memory_max = 512
      }

      volume_mount {
        volume      = "data"
        destination = "/var/lib/postgresql/data"
      }
    }
  }
}

Si vous souhaitez ajouter des utilisateurs ou bases de données, je vous conseille de mettre à jour le fichier de bootstrap pour de le reload en cli.

nomad alloc exec -i -t -task postgres -job postgres sh
/ $ psql -f /docker-entrypoint-initdb.d/bootstrap.sql

Il est également possible d’intéragir directement dans le postgresql avec la commande nomad alloc exec -i -t -task postgres -job postgres psql.

Dans le cas où vous souhaiteriez mettre en place des backup de votre postgresql, je vous invite à lire cet article.

Rails

Enfin la dernière partie est l’application rails ainsi que le container sidekiq. En suivant la Chart Helm, j’ai mis en place un job contenant deux tasks séparées.

Il reste une configuration particulière à prendre en compte. Comme expliqué précédemment, mes services (hors mis l’ingress controller) ont des ports attribués dynamiquement. Pour mettre en place les variables d’environnements avec les bonnes valeurs il va falloir regarder les services nomad en place et récupérer les informations qui nous intéressent. Pour ça, et vus qu’on n’utilise pas Consul, on va utiliser nomadService disponible dans en tant que template.

{{- range nomadService "postgres-database" }}
DISCOURSE_DATABASE_HOST="{{ .Address}}"
DISCOURSE_DATABASE_PORT_NUMBER="{{ .Port }}"
{{ end -}}
{{- range nomadService "redis-cache" }}
DISCOURSE_REDIS_HOST="{{ .Address}}"
DISCOURSE_REDIS_PORT_NUMBER="{{ .Port }}"
{{ end -}}

J’ai également mis en place grace = "10m" dans le block check_restart, qui va permettre de laisser le temps à task de démarrer avant de la considérer comme unhealthy.

Le hcl complet donnera quant à lui:

Discourse job

job "discourse" {
  datacenters = ["dc1"]
  type = "service"

  group "discourse" {
    count = 1

    vault {
      policies = ["nomad-postgres", "apps"]
    }

    volume "discourse" {
      type      = "host"
      source    = "discourse"
      read_only = false
    }

    network {
      port "discourse" {
        to = 3000
      }
    }

    service {
      provider = "nomad"
      tags = [
        "discourse", "app",
        "traefik.enable=true",
        "traefik.http.routers.discourse.rule=(Host(`discourse.p0l.io`) && ClientIP(`1.2.3.4`))",
        "traefik.http.routers.discourse.tls=true",
        "traefik.http.routers.discourse.tls.certresolver=route53"
      ]
      port = "discourse"

      check {
        type     = "http"
        name     = "rails"
        path     = "/srv/status"
        interval = "10s"
        timeout  = "5s"
      }

      check_restart {
        limit = 3
        grace = "10m"
      }
    }

    task "sidekiq" {
      driver = "docker"

      config {
        image   = "bitnami/discourse:3.0.4"
        command = "/opt/bitnami/scripts/discourse-sidekiq/run.sh"
      }

      env {
        # Apps
        DISCOURSE_HOST         = "discourse.p0l.io"
        DISCOURSE_ENABLE_HTTPS = "no"
        DISCOURSE_ENV          = "production"
        # Database
        DISCOURSE_DATABASE_NAME = "discourse"
        DISCOURSE_DATABASE_USER = "discourse"
        # Redis
        ALLOW_EMPTY_PASSWORD = "yes"
      }

      template {
        data = <<EOH
{{ with secret "my/vault/path" }}
DISCOURSE_EMAIL="{{ .Data.data.mail }}"
DISCOURSE_USERNAME="{{ .Data.data.name }}"
DISCOURSE_PASSWORD="{{ .Data.data.password }}"
{{ end -}}
{{ with secret "my/vault/path" }}
DISCOURSE_DATABASE_PASSWORD="{{ .Data.data.discourse }}"
{{ end }}
{{- range nomadService "postgres-database" }}
DISCOURSE_DATABASE_HOST="{{ .Address}}"
DISCOURSE_DATABASE_PORT_NUMBER="{{ .Port }}"
{{ end -}}
{{- range nomadService "redis-cache" }}
DISCOURSE_REDIS_HOST="{{ .Address}}"
DISCOURSE_REDIS_PORT_NUMBER="{{ .Port }}"
{{ end -}}
EOH
        destination = "secrets/file.env"
        env         = true
     }

      volume_mount {
        volume      = "discourse"
        destination = "/bitnami/discourse"
      }

      resources {
        cpu        = 250
        memory     = 512
        memory_max = 1024
      }
    }

    task "rails" {
      driver = "docker"

      env {
        BITNAMI_DEBUG = "true"
        # Apps
        DISCOURSE_PRECOMPILE_ASSETS = "no"
        DISCOURSE_HOST              = "discourse.p0l.io"
        DISCOURSE_ENABLE_HTTPS      = "no"
        DISCOURSE_ENV               = "production"
        # Database
        DISCOURSE_DATABASE_NAME = "discourse"
        DISCOURSE_DATABASE_USER = "discourse"
        # Redis
        ALLOW_EMPTY_PASSWORD = "yes"
      }


      template {
        data = <<EOH
{{ with secret "my/vault/path" }}
DISCOURSE_EMAIL="{{ .Data.data.mail }}"
DISCOURSE_USERNAME="{{ .Data.data.name }}"
DISCOURSE_PASSWORD="{{ .Data.data.password }}"
{{ end -}}
{{ with secret "my/vault/path" }}
DISCOURSE_DATABASE_PASSWORD="{{ .Data.data.discourse }}"
{{ end -}}
{{- range nomadService "postgres-database" }}
DISCOURSE_DATABASE_HOST="{{ .Address}}"
DISCOURSE_DATABASE_PORT_NUMBER="{{ .Port }}"
{{ end -}}
{{- range nomadService "redis-cache" }}
DISCOURSE_REDIS_HOST="{{ .Address}}"
DISCOURSE_REDIS_PORT_NUMBER="{{ .Port }}"
{{ end -}}
EOH
        destination = "secrets/file.env"
        env         = true
      }

      config {
        image        = "bitnami/discourse:3.0.4"
        ports        = ["discourse"]
      }

      resources {
        cpu    = 800
        memory = 512
      }

      volume_mount {
        volume      = "discourse"
        destination = "/bitnami/discourse"
      }

      restart {
        attempts = 3
        interval = "1m"
        delay    = "2m"
        mode     = "delay"
      }
    }

    update {
      max_parallel     = 1
      min_healthy_time = "5s"
      healthy_deadline = "5m"
      auto_revert      = false
      canary           = 0
    }
  }
}

Conclusion

Nomad s’affiche comme plus simple dans sa mise en place que Kubernetes. Bien qu’il soit vrai que l’installation reste facile d’accès, dès que l’on souhaite mettre en place un service ça devient tout de suite tout aussi complexe. La gestion des CNI et CSI restent assez peu documenté et c’est assez compliqué de trouver des jobs tout prêt.

À mes yeux le vrai problème se trouve ici; le manque de capitalisation de jobs. Bien qu’il semble y avoir une volonté de partir sur ce sujet avec Nomad Pack, on est très loin de ce qu’on trouve en chart Helm.

C’est également une grosse déception sur le combo ingress controller/génération de certificats qui sont la base pour mettre à disposition un service. Heureusement que Traefik est là pour nous offrir ces features.

Évidemment je n’avais pas utilisé Consul qui semble nous offrir tout un tas de possibilités intéressantes sur la consommation et l’exposition de nos services. Il n’est d’ailleurs pas évident de trouver des documentations sans Consul.


Tags: