AWS EC2 - Le script cfn-hup

Accueil Tags Recherche

23 Mai 2023

AWS EC2 - Le script cfn-hup

Comment déclencher des mise à jour automatiques de vos instances EC2, suite à un changement de template CloudFormation ?

Cet article s’inscrit dans un dossier sur la gestion de machines EC2 avec AWS CloudFormation. Merci de bien lire la section Introduction et disclaimers du premier article de ce dossier. Vous pouvez aussi retrouver l’intégralité du code utilisé en fin d’article.

Introduction

Le script cfn-hup

“HUP” est un raccourci pour “Hang UP”, qui est un héritage de la déjà lointaine époque des ports séries et du signal SIGHUP. Ce signal était utilisé pour indiquer la fermeture d’une ligne de communication série. Aujourd’hui le SIGHUP est surtout utilisé pour forcer les daemons à se redémarrer eux-mêmes, afin qu’ils puissent re-lire les fichiers de configuration.

Contrairement aux scripts que nous avons vus précédemment cfn-hup est en effet un script daemon. Il vous faudra le lancer une première fois lors de la création de l’instance, puis il s’exécutera en arrière-plan. Ce script va détecter les modifications apportées aux méta-données de vos instances (dans votre template CloudFormation), et va vous permettre de déclencher des actions en réaction à ces changements.

On pourrait penser que cfn-hup est superflu, et qu’il suffit de lancer cfn-init régulièrement via un cron. C’est vrai dans les cas les plus simplistes, mais cette technique ne permet pas de répondre à la grande majorité des cas d’usages, nous verrons pourquoi dans la suite de cet article.

La mise en place de ce script va donc nécessiter plusieurs étapes :

  • Le lancement du daemon
  • La configuration de cfn-hup : quel template surveiller, et à quelle fréquence ?
  • La configuration des hooks : quelles commandes lancer, et lors de quels événements ?

Les jeux de configuration

Jusqu’ici nous avons toujours utilisé la clé config dans nos méta-données AWS::CloudFormation::Init afin de définir les commandes à lancer lors de la création de notre instance, mais à présent nous allons avoir un workflow un peu plus complexe :

  • Une configuration pour Nginx
  • Une configuration pour cfn-hup

Notre objectif est que le serveur Nginx soit mis à jour automatiquement, on va donc vouloir lancer le premier ensemble de commandes à la création de l’instance, mais aussi lors de ses mises à jour. En revanche (pour l’exemple) nous souhaitons configurer cfn-hup une seule fois, lors de la création de l’instance. Nous allons donc avoir ce qu’on appelle deux jeux de configuration :

  • Un jeu à lancer suite à la création de l’instance (configuration de Nginx et de cfn-hup)
  • Un jeu à lancer suite à la mise à jour de l’instance (configuration de Nginx)

Pour répondre à ce besoin, les méta-données AWS::CloudFormation::Init supportent une clé configSets permettant de regrouper vos configurations dans des jeux (sets), afin de les lancer de manière indépendante. La clé config n’étant utilisée que si configSets n’est pas définie, nous allons commencer par renommer notre configuration config en nginx, pour ensuite ajouter la clé configSets :

instance.instance.add_metadata("AWS::CloudFormation::Init", {
  "configSets": {
    "create": [
      "nginx"]},
  "nginx": {
    # Installation du package Nginx
    "commands": {
      "01-nginx-install": {
        "command": "sudo amazon-linux-extras install -y nginx1"}},
    # Activation du service Nginx
    "services": {
      "sysvinit": {
        "nginx": {
          "enabled": True,
          "ensureRunning": True,
          "files": [
            "/etc/nginx/nginx.conf",
            "/usr/share/nginx/html/index.html"]}}}}
  })

Pour récapituler ce changement, voici ce que nous avions avant :

  • Une configuration appelée config (clé utilisée par défaut en l’absence de jeux de configuration)

Et voici ce que nous avons à présent :

  • Un jeu de configuration appelé create
  • Une configuration appelée nginx (utilisé dans le jeu create)

Il faut aussi penser à modifier l’appel à cfn-init, pour préciser quel jeu de configuration nous désirons lancer :

instance.add_user_data(dedent(f"""\
  (
    set +e
    /opt/aws/bin/cfn-init -v \
      --region eu-west-3 \
      --stack {self.stack_name} \
      --resource {instance.instance.logical_id} \
      --configsets create
    /opt/aws/bin/cfn-signal -e $? \
      --region eu-west-3 \
      --stack {self.stack_name} \
      --resource {instance.instance.logical_id}
  )"""))

Ce changement va nous permettre de mieux organiser nos configurations dans la suite de cet article, et je vous recommande de toujours utiliser les jeux de configuration ; ils permettent non seulement plus de souplesse pour vos évolutions futures, mais sont aussi beaucoup plus explicites et facilitent la lecture de ces configurations (très) verbeuses.

Mise en place de cfn-hup

Maintenant que tout est propre nous allons créer une nouvelle configuration nommée cfn-hup (là-aussi, libre à vous de choisir le nom, il s’agit juste d’une clé), et l’ajouter au jeu de configuration create :

instance.instance.add_metadata("AWS::CloudFormation::Init", {
  "configSets": {
    "create": [
      "nginx",
      "cfn-hup"]},
  "nginx": {  },
  "cfn-hup": {}
  })

Je vais à présent détailler les trois étapes évoquées pour la mise en place complète du script cfn-hup.

Lancement du daemon

Comme pour Nginx, nous allons utiliser la clé services.sysvinit pour nous assurer que le daemon cfn-hup soit lancé. Celui-ci dépendra des deux fichiers suivants, que nous allons voir juste après :

  • /etc/cfn/cfn-hup.conf : configuration du script
  • /etc/cfn/hooks.d/cfn-auto-reloader.conf : configuration des hooks
instance.instance.add_metadata("AWS::CloudFormation::Init", {
  "configSets": {  },
  "nginx": {  },
  "cfn-hup": {
    # Activation du service cfn-hup
    "services": {
      "sysvinit": {
        "cfn-hup": {
          "enabled": True,
          "ensureRunning": True,
          "files": [
            "/etc/cfn/cfn-hup.conf",
            "/etc/cfn/hooks.d/cfn-auto-reloader.conf"]}}}}
  })

Configuration du script

Ici on va préciser quel est le template CloudFormation de référence, et avec quelle fréquence cfn-hup va devoir le surveiller. C’est le fichier /etc/cfn/cfn-hup.conf qui est chargé de définir tout cela (voir la documentation pour les spécifications), voici (pour des raisons de lisibilité) à quoi nous voulons qu’il ressemble :

[main]
stack=<StackId>  
region=eu-west-3
interval=5
verbose=true

On précise bien la stack à surveiller, ainsi qu’un intervale de 5 minutes entre chaque vérification. Voici le fichier ajouté aux méta-données de notre instance :

instance.instance.add_metadata("AWS::CloudFormation::Init", {
  "configSets": {
    "create": [
      "nginx",
      "cfn-hup"]},
  "nginx": {  },
  "cfn-hup": {
    "files": {
      # Fichier de configuration de cfn-hup
      "/etc/cfn/cfn-hup.conf": {
        "content": dedent(f"""\
          [main]
          stack={self.stack_id}
          region=eu-west-3
          interval=5
          verbose=true"""),
        "encoding": "plain",
        "mode": "000400",
        "owner": "root",
        "group": "root"}},
    # Activation du service cfn-hup
    "services": {
      "sysvinit": {
        "cfn-hup": {
          "enabled": True,
          "ensureRunning": True,
          "files": [
            "/etc/cfn/cfn-hup.conf",
            "/etc/cfn/hooks.d/cfn-auto-reloader.conf"]}}}}
  })

Configuration des hooks

Nous avons déjà demandé à cfn-hup de surveiller notre template CloudFormation, il faut à présent lui préciser quoi faire, et surtout quand. Le script fonctionne avec un système de hooks qui vont permettre d’écouter les événements suivants sur une ressource :

  • post.add
  • post.update
  • post.remove

C’est le fichier /etc/cfn/hooks.d/cfn-auto-reloader.conf qui contiendra cette configuration (voir la documentation pour les spécifications), et une fois encore pour des raisons de lisibilité voici à quoi nous voulons qu’il ressemble :

[cfn-auto-reloader-hook]
triggers=post.update
path=Resources.<InstanceId>.Metadata.AWS::CloudFormation::Init
action=/opt/aws/bin/cfn-init -v \
  --region eu-west-3 \
  --stack <StackId> \
  --resource <InstanceId> \
  --configsets update
runas=root

C’est ce fichier qui est le plus important, et qui dépendra beaucoup de ce que vous voulez faire. Ici on désire réagir aux événements de mise à jour des méta-données de notre instance. Quand cet événement survient on lance le script cfn-init, mais cette fois-ci en appelant un autre jeu de configuration nommé update (qu’il va falloir créer pour l’occasion).

Voici le fichier ajouté aux méta-données de notre instance, ainsi que le changement des configSets :

instance.instance.add_metadata("AWS::CloudFormation::Init", {
  "configSets": {
    "create": [
      "nginx",
      "cfn-hup"],
    "update": [
      "nginx"]},
  "nginx": {  },
  "cfn-hup": {
    "files": {
      # Fichier de configuration de cfn-hup
      "/etc/cfn/cfn-hup.conf": {
        "content": dedent(f"""\
          [main]
          stack={self.stack_id}
          region=eu-west-3
          interval=5
          verbose=true"""),
        "encoding": "plain",
        "mode": "000400",
        "owner": "root",
        "group": "root"},
      # Commandes à lancer lors d'un signal de mise à jour
      "/etc/cfn/hooks.d/cfn-auto-reloader.conf": {
        "content": dedent(f"""\
          [cfn-auto-reloader-hook]
          triggers=post.update
          path=Resources.{instance.instance.logical_id}.Metadata.AWS::CloudFormation::Init
          action=/opt/aws/bin/cfn-init -v \
            --region eu-west-3 \
            --stack {self.stack_id} \
            --resource {instance.instance.logical_id} \
            --configsets update
          runas=root"""),
        "encoding": "plain",
        "mode": "000400",
        "owner": "root",
        "group": "root"}},
    # Activation du service cfn-hup
    "services": {
      "sysvinit": {
        "cfn-hup": {
          "enabled": True,
          "ensureRunning": True,
          "files": [
            "/etc/cfn/cfn-hup.conf",
            "/etc/cfn/hooks.d/cfn-auto-reloader.conf"]}}}}
  })

Nous pouvons à présent provisionner ces changements avec cdk deploy, et parce que nous avons modifié les données utilisateur, l’instance va à nouveau être remplacée. Profitez-en donc pour récupérer la nouvelle adresse IP publique :

# Outputs:
# WorkshopStack.InstanceLogicalId = InstanceC1063A87e4b92a55b80d62ff
# WorkshopStack.InstancePublicIp = 13.38.71.54

Déploiement d’une modification

En accédant à notre serveur en HTTP sur la nouvelle adresse IP (http://13.38.71.54 dans mon exemple), on retrouve la page d’accueil par défaut de Nginx. Maintenant que cfn-hup est en place nous allons modifier cette page via les méta-données, et voir si ce changement est bien répercuté sur notre instance. Ajoutons une section files à notre configuration nginx, pour changer le contenu de cette page d’accueil :

instance.instance.add_metadata("AWS::CloudFormation::Init", {
  "configSets": {  },
  "nginx": {
    # Installation du package Nginx
    "commands": {
      "01-nginx-install": {
        "command": "sudo amazon-linux-extras install -y nginx1"}},
    # Modification de la page par défaut de Nginx
    "files": {
      "/usr/share/nginx/html/index.html": {
        "content": "<title>Hello World</title><h1>Hello World</h1>",
        "mode": "000644",
        "owner": "root",
        "group": "root"}},
    # Activation du service Nginx
    "services": {
      "sysvinit": {
        "nginx": {
          "enabled": True,
          "ensureRunning": True,
          "files": [
            "/etc/nginx/nginx.conf",
            "/usr/share/nginx/html/index.html"]}}}},
  "cfn-hup": {  }
  })

En lançant la commande cdk diff on constate qu’il n’y a pas de changement d’instance prévu, juste un ajout de méta-données :

# Stack WorkshopStack
# Resources
# [~] AWS::EC2::Instance Instance InstanceC1063A87e4b92a55b80d62ff
#  └─ [~] Metadata
#      └─ [~] .AWS::CloudFormation::Init:
#          └─ [~] .nginx:
#              └─ [+] Added: .files

On lance donc cdk deploy. Vous pourrez voir que la mise à jour sera très rapide (une vingtaine de secondes) : comme expliqué en début d’article, une modification de méta-données n’a sur le moment aucune incidence sur notre instance, nous avons donc ajouté quelques lignes de texte dans un template CloudFormation, jusqu’ici rien de plus.

Au maximum 5 minutes plus tard, vous devriez voir cette nouvelle page d’accueil au design dans le plus pur esprit du brutalisme apparaître 🎉

Notre nouvelle page Nginx

Conclusion

Nous voila arrivés à la fin de ce dossier qui couvre les bases de la gestion d’instance EC2 avec CloudFormation. Sa rédaction aura pris beaucoup de temps, j’ai apporté un soin tout particulier à la stack CloudFormation et au code qui, je l’espère, devraient vous avoir permis de suivre l’ensemble des articles plus facilement en reproduisant toutes les opérations vous-mêmes.

Vous voici à présent en mesure de provisioner et configurer des instances EC2 sans aucune intervention manuelle. Il y aura très probablement d’autres articles sur EC2 dans le futur, qui s’attarderont plus en détails sur certaines techniques et/ou fonctionnalités, restez dans le coin…

Voir l'intégralité du code
from aws_cdk import (
  Stack,
  CfnOutput,
  CfnCreationPolicy,
  CfnResourceSignal,
  aws_ec2 as ec2,
)
from constructs import Construct
from textwrap import dedent

class WorkshopStack(Stack):

  def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
    super().__init__(scope, construct_id, **kwargs)

    vpc = ec2.Vpc(self, "Vpc",
      subnet_configuration = [
        ec2.SubnetConfiguration(
          name = "public",
          subnet_type = ec2.SubnetType.PUBLIC,
          cidr_mask = 24)],
      max_azs = 1)

    security_group = ec2.SecurityGroup(self, "InstanceSecurityGroup",
      vpc = vpc)
    security_group.add_ingress_rule(ec2.Peer.any_ipv4(), ec2.Port.tcp(22))
    security_group.add_ingress_rule(ec2.Peer.any_ipv4(), ec2.Port.tcp(80))

    cfn_key_pair = ec2.CfnKeyPair(self, "KeyPair",
      key_name = "ssh-key-workshop",
      key_type = "ed25519")

    instance = ec2.Instance(self, "Instance",
      # Type d'instance : t2.micro
      instance_type = ec2.InstanceType.of(
        instance_class = ec2.InstanceClass.T2,
        instance_size = ec2.InstanceSize.MICRO),
      # AMI à utiliser
      machine_image = ec2.MachineImage.generic_linux({
        "eu-west-3": "ami-01fde5e5b31e98551"}),
      # VPC dans lequel déployer l'instance
      vpc = vpc,
      # Groupe de sécurité pour autoriser le trafic sur le port 22
      security_group = security_group,
      # SSH key to use
      key_name = cfn_key_pair.key_name,
      # Un changement de user-data doit provoquer un changement d'instance
      user_data_causes_replacement = True)

    instance.add_user_data(dedent(f"""\
      (
        set +e
        /opt/aws/bin/cfn-init -v \
          --region eu-west-3 \
          --stack {self.stack_name} \
          --resource {instance.instance.logical_id} \
          --configsets create
        /opt/aws/bin/cfn-signal -e $? \
          --region eu-west-3 \
          --stack {self.stack_name} \
          --resource {instance.instance.logical_id}
      )"""))

    cfn_instance = instance.node.default_child
    cfn_instance.cfn_options.creation_policy = CfnCreationPolicy(
      resource_signal = CfnResourceSignal(
        count = 1,
        timeout = "PT5M"))

    instance.instance.add_metadata("AWS::CloudFormation::Init", {
      "configSets": {
        "create": [
          "nginx",
          "cfn-hup"],
        "update": [
          "nginx"]},
      "nginx": {
        # Installation du package Nginx
        "commands": {
          "01-nginx-install": {
            "command": "sudo amazon-linux-extras install -y nginx1"}},
        # Modification de la page par défaut de Nginx
        "files": {
          "/usr/share/nginx/html/index.html": {
            "content": "<title>Hello World</title><h1>Hello World</h1>",
            "mode": "000644",
            "owner": "root",
            "group": "root"}},
        # Activation du service Nginx
        "services": {
          "sysvinit": {
            "nginx": {
              "enabled": True,
              "ensureRunning": True,
              "files": [
                "/etc/nginx/nginx.conf",
                "/usr/share/nginx/html/index.html"]}}}},
      "cfn-hup": {
        "files": {
          # Fichier de configuration de cfn-hup
          "/etc/cfn/cfn-hup.conf": {
            "content": dedent(f"""\
              [main]
              stack={self.stack_id}
              region=eu-west-3
              interval=5
              verbose=true"""),
            "encoding": "plain",
            "mode": "000400",
            "owner": "root",
            "group": "root"},
          # Commandes à lancer lors d'un signal de mise à jour
          "/etc/cfn/hooks.d/cfn-auto-reloader.conf": {
            "content": dedent(f"""\
              [cfn-auto-reloader-hook]
              triggers=post.update
              path=Resources.{instance.instance.logical_id}.Metadata.AWS::CloudFormation::Init
              action=/opt/aws/bin/cfn-init -v \
                --region eu-west-3 \
                --stack {self.stack_id} \
                --resource {instance.instance.logical_id} \
                --configsets update
              runas=root"""),
            "encoding": "plain",
            "mode": "000400",
            "owner": "root",
            "group": "root"}},
        # Activation du service cfn-hup
        "services": {
          "sysvinit": {
            "cfn-hup": {
              "enabled": True,
              "ensureRunning": True,
              "files": [
                "/etc/cfn/cfn-hup.conf",
                "/etc/cfn/hooks.d/cfn-auto-reloader.conf"]}}}}
      })

    # Affiche l'identifiant logique de l'instance
    CfnOutput(self, "InstanceLogicalId",
      value = instance.instance.logical_id)

    # Affiche l'adresse IP publique de l'instance
    CfnOutput(self, "InstancePublicIp",
      value = instance.instance_public_ip)

Liens

Référence AWS - cfn-hup
Référence AWS - Jeux de configuration AWS::CloudFormation::Init
Référence AWS - UpdateStack