Corexpert & TeamWork Blog

Que sont les « Cloudformation Custom Resources » et comment les utiliser

AWS Cloudformation, qu’est-ce que c’est ?

Cloudformation est un outil très important dans la vie de tous les jours des personnes travaillant sur le cloud AWS. Il permet d’implémenter toutes les ressources AWS d’une manière rapide et efficace. C’est un très bon exemple de IaC : Infrastructure as Code.

Cloudformation facilite donc les déploiements, la maintenance et les évolutions d’environnement entier, mais permet aussi de templatiser ces infrastructures.

Les différents services du catalogue AWS évoluent de manière continue, et totalement indépendante. On peut comparer la plateforme AWS à une immense architecture en micro-services.

Il se peut donc que Cloudformation ne soit pas à jour par rapport aux nouveautés des services. Une fonctionnalité ou un paramètre spécifique peut ne pas être implémenté dans Cloudformation, cela rendra inutilisable la stack Cloudformation pour la ressource en question.

La suite de cet article a pour but de montrer comment contourner cette limitation, et construire toutes les ressources AWS comme on le souhaite.

Les « Custom Resources »

Heureusement, les Custom Resources sont là pour nous aider. Elles permettent de provisionner les ressources AWS à chaque fois qu’une stack Cloudformation est créée, mise à jour ou supprimée.

Alors, comment ça marche ?

De manière très simple, sur un changement, Cloudformation appelle une fonction Lambda avec un event spécifique, et attendra un retour de cette fonction de manière à définir si la ressource a été correctement modifiée, ou non.

Nous remarquons ici la possibilité d’utiliser l’un des SDK proposés par AWS, par le biais de la fonction lambda ( Python, Java, Node.js, … ).

Voici le template de l’event envoyé par Cloudformation à la Lambda :

{
   "RequestType" : "Create",
   "ResponseURL" : "http://pre-signed-S3-url-for-response",
   "StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid",
   "RequestId" : "unique id for this create request",
   "ResourceType" : "Custom::TestResource",
   "LogicalResourceId" : "MyTestResource",
   "ResourceProperties" : {
      "Name" : "Value",
      "List" : [ "1", "2", "3" ]
   }
}

NB : Le champ ResourceProperties permet de personnaliser les paramètres de la fonction

On remarque aussi que l’event contient une URL qui correspond à l’endpoint qu’il faudra appeler quand le travail de la lambda est terminé. L’endpoint attend un statut des actions menées.

Voici un exemple de ce qu’attends l’URL :

{
   "Status" : "SUCCESS",
   "PhysicalResourceId" : "TestResource1",
   "StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid",
   "RequestId" : "unique id for this create request",
   "LogicalResourceId" : "MyTestResource",
   "Data" : {
      "OutputName1" : "Value1",
      "OutputName2" : "Value2",
   }
}

Custom Resources : use case & implémentation

Comme souvent, lorsque je travaille pour un client, je commence par construire la solution « à la main » via la console AWS, ceci me permet d’avoir rapidement une solution exploitable. Une fois cette étape terminée, je construis l’IaC via Cloudformation, afin de fournir des templates qui seront utilsés pour construire les environnements de travail jusqu’à la production.

Travaillant sur un projet AWS AppStream, j’ai eu la surprise de voir qu’il était impossible à ce jour de joindre un rôle IAM ni à l’image builder, ni à la flotte en utilisant les ressources natives Cloudformation.

J’ai pris la décision d’utiliser le SDK et ainsi développer mes propres Customs Resources.

Construire la fonction Lambda

Commençons par construire la fonction Lambda permettant de créer et détruire les ressources.

Nous retrouvons les paramètres personnalisés depuis l’event :

appstream_image_builder = event['ResourceProperties']['AppstreamImageBuilder']
appstream_fleet = event['ResourceProperties']['AppstreamFleet']

Ensuite, on vérifie dans l’event quelle est l’action à  effectuer :

    if event['RequestType'] == "Create":
    
      LOGGER.info('appstream_image_builder: \n %s', appstream_image_builder)
      
      LOGGER.info('Creating ilmage builder')
      
      response = client.create_image_builder(
          Name=appstream_image_builder['Name'],
          ImageName=appstream_image_builder['ImageName'],
          InstanceType=appstream_image_builder['InstanceType'],
          Description=appstream_image_builder['Description'],
          DisplayName=appstream_image_builder['DisplayName'],
          VpcConfig={
              'SubnetIds': appstream_image_builder['SubnetIds'],
              'SecurityGroupIds': appstream_image_builder['SecurityGroupIds']
          },
          IamRoleArn=appstream_image_builder['IamRoleArn'],
          DomainJoinInfo={
              'DirectoryName': appstream_image_builder['DirectoryName'],
              'OrganizationalUnitDistinguishedName': appstream_image_builder['OrganizationalUnitDistinguishedName']
          },
          Tags=appstream_image_builder['Tags']
      )
      
      LOGGER.info("Image bulder created")
      LOGGER.info("Creating fleet")
      
      LOGGER.info('appstream_fleet: \n %s', appstream_fleet)
      
      LOGGER.info('Creatin fleet')
      
      response = client.create_fleet(
          Name=appstream_fleet['Name'],
          ImageName=appstream_fleet['ImageName'],
          InstanceType=appstream_fleet['InstanceType'],
          FleetType=appstream_fleet['FleetType'],
          ComputeCapacity={
              'DesiredInstances': int(appstream_fleet['DesiredInstances'])
          },
          VpcConfig={
              'SubnetIds': appstream_fleet['SubnetIds'],
              'SecurityGroupIds': appstream_fleet['SecurityGroupIds']
              },
          Description=appstream_fleet['Description'],
          DisplayName=appstream_fleet['DisplayName'],
          DomainJoinInfo={
              'DirectoryName': appstream_fleet['DirectoryName'],
              'OrganizationalUnitDistinguishedName': appstream_fleet['OrganizationalUnitDistinguishedName']
          },
          Tags=appstream_fleet['Tags'],
          IamRoleArn=appstream_fleet['IamRoleArn'],
          StreamView=appstream_fleet['StreamView']
      )
      
      LOGGER.info("Fleet created")
      send_response(event, context, "SUCCESS", {"Message": "Resource creation successful!"})
elif event['RequestType'] == "Delete":
      LOGGER.info('Deleting Image builder')
      response = client.delete_image_builder(
        Name=appstream_image_builder['Name']
      )
      
      LOGGER.info('Stoping fleet')
      response = client.stop_fleet(
        Name=appstream_fleet['Name']
      )
      
      LOGGER.info('Waiting for fleet to be stopped ...')
      response = client.describe_fleets(
        Names=[
            appstream_fleet['Name'],
        ]
      )
      state=response['Fleets'][0]['State']

      while state != 'STOPPED':    
        time.sleep(5)
        response = client.describe_fleets(
            Names=[
                "adobeAndNotepadd-Fleet",
            ]
        )  
        state = response['Fleets'][0]['State']
        LOGGER.info('Waiting for fleet to be stopped ...')
      
      LOGGER.info('Waiting for fleet to be stopped ...')
      response = client.delete_fleet(
        Name=appstream_fleet['Name']
      )
      
      send_response(event, context, "SUCCESS", {"Message": "Resource deleted successful!"})

Comme expliqué, la Custom Resource attend un statut que je remplacerai par un code retour.

def send_response(event, context, response_status, response_data):
  '''Send a resource manipulation status response to CloudFormation'''
  response_body = json.dumps({
      "Status": response_status,
      "Reason": "See the details in CloudWatch Log Stream: " + context.log_stream_name,
      "PhysicalResourceId": context.log_stream_name,
      "StackId": event['StackId'],
      "RequestId": event['RequestId'],
      "LogicalResourceId": event['LogicalResourceId'],
      "Data": response_data
  })
  
  LOGGER.info('ResponseURL: %s', event['ResponseURL'])
  LOGGER.info('ResponseBody: %s', response_body)
  
  opener = build_opener(HTTPHandler)
  request = Request(event['ResponseURL'], data=response_body)
  request.add_header('Content-Type', '')
  request.add_header('Content-Length', len(response_body))
  request.get_method = lambda: 'PUT'
  response = opener.open(request)
  LOGGER.info("Status code: %s", response.getcode())
  LOGGER.info("Status message: %s", response.msg)

Construire le template Cloudformation

Ensuite, nous passons à la création du template Coudformation. On peut voir dans cet exemple que l’Image Builder et la flotte sont une seule et même ressource, de type : Custom::IBAndFleetBuilder, et que le champ ServiceToken correspond  à l’ARN de la Lamda.

ImageBuilderAndFleetCreationFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: "appstream-image-sources"
        S3Key: "ImageBuilderAndFleetCreationFunction/handler.zip"
      Handler: "handler.handler"
      Timeout: 300
      Runtime: python2.7
      Role: 
        Fn::ImportValue:
          !Sub "${Env}-LambdaAppstreamCreationRoleArn"
  
  ImageBuilderAndFleetCreationCustom:
    Type: Custom::IBAndFleetBuilder
    Properties:
      ServiceToken: !GetAtt ImageBuilderAndFleetCreationFunction.Arn
      
      AppstreamImageBuilder:
        Name: !Sub "${Project}-Image-Builder"
        ImageName: !Ref ImageBuilderBaseImageName
        InstanceType: !Ref ImageBuilderInstanceType
        Description: !Sub "Appstream Image builder for projet ${Project}"
        DisplayName: !Sub "${Project}-Image-Builder"
        SubnetIds: !Ref ImageBuilderSubnetlist
        SecurityGroupIds: !Ref ImageBuilderSecuritygroupslist
        IamRoleArn: 
          Fn::ImportValue:
            !Sub "${Env}-AppstreamImageBuilderRoleArn"
        DirectoryName: !Ref ImageBuilderDirectoryName
        OrganizationalUnitDistinguishedName: !Ref ImageBuilderOrganizationalUnitDistinguishedName
        Tags: 
            Env: !Ref Env
            Project: !Ref Project
            
      AppstreamFleet:
        Name: !Sub "${Project}-Fleet"
        ImageName: !Ref FleetDefaultImageName
        InstanceType: !Ref FleetInstanceType
        FleetType: !Ref FleetType
        DesiredInstances: !Ref FleetDesiredInstances
        SubnetIds: !Ref FleetSubnetlist
        SecurityGroupIds: !Ref FleetSecuritygroupslist
        Description: !Sub "Appstream Fleet for projet ${Project}"
        DisplayName: !Sub "${Project}-Fleet"
        DirectoryName: !Ref FleetDirectoryName
        OrganizationalUnitDistinguishedName: !Ref FleetOrganizationalUnitDistinguishedName
        Tags:
          Env: !Ref Env
          Project: !Ref Project
        IamRoleArn: 
          Fn::ImportValue:
            !Sub "${Env}-AppstreamFleetRoleArn"
        StreamView: !Ref FleetStreamView

Conclusion

Grâce aux Custom Resources, j’ai pu livrer au client un template Cloudformation opérationnel, qu’il peut utiliser pour déployer automatiquement toutes ses ressources AppStream en un clic, mais aussi pour les supprimer lorsqu’il le souhaite.

Il est important de noter que la stack Cloudformation fait un appel à la Custom Resource lorsque celle-ci est mise à jour, ainsi pour éviter ce comportement, nous pourrons donc implémenter une fonction spécifique dans la Lambda qui sera en charge d’effectuer cette partie.

Références : 

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html

Comments

comments