Blog

Wie wir eine serverlose Version von cfn-flip erstellt haben

Dennis Vink

Aktualisiert Oktober 21, 2025
12 Minuten

Die ursprüngliche Version von cfn-flip ist ein Befehlszeilen-Tool, das CloudFormation-Vorlagen von JSON in YAML und umgekehrt konvertiert. Dieses Tool gibt es schon seit einiger Zeit und erledigt seine Aufgabe hervorragend. Die meisten Leute finden YAML besser lesbar. Ich gehöre zu der Minderheit, die JSON einfacher zu lesen findet. Es wurde von Steve Engledow geschrieben und Sie finden das ursprüngliche cfn-flip Github Repository . Meiner Erfahrung nach entstehen neue Ideen nicht dadurch, dass man versucht, sich etwas auszudenken, was es noch nicht gibt, sondern dadurch, dass man einen Mangel feststellt und etwas darum herum baut. Dies war definitiv der Fall für das serverlose cfn-flip. Die Idee, eine serverlose Version des Tools zu erstellen, die mehr kann als nur die beiden Formate zu konvertieren, wurde geboren, als ich bemerkte, dass cfnfip.com nicht registriert war.

Zielsetzung

Bevor ich eine einzige Zeile Code geschrieben habe, habe ich mir überlegt, was ich erreichen wollte, und habe es auf das Folgende eingegrenzt:

  1. So wenig wie möglich Interaktion mit der AWS-Konsole!
  2. Vollständig in CloudFormation erstellt.
  3. Da ich ein Ruby-Fanatiker bin, muss es auch in CfnDsl ausgegeben werden.
  4. Jede Konvertierungsfunktion in ihrer eigenen Lambda-Funktion.
  5. Mit hübschen Farben (Syntaxhervorhebung)
  6. Natürlich unterstützt durch API Gateway.
  7. Wir geben der Gemeinschaft etwas zurück, indem wir das gesamte Projekt als Open Source anbieten.

Architektur

CfnFlip besteht aus einem S3-Bucket, vier Lambda-Funktionen, einem API-Gateway, einer CloudFront-Verteilung und Route53. Ich habe die Architektur so gestaltet, dass Sie einen visuellen Überblick erhalten:

Wie wir eine serverlose Version von cfn-flip erstellt haben

CloudFormation-Abhängigkeitsstapel

Mir gefällt sehr, wie das serverlose Framework einen S3-Bucket im Handumdrehen erstellt. Es kann Ihre Artefakte dort speichern, ohne dass Sie einen Bucket manuell in der Konsole oder mit der CLI erstellen müssen. Inspiriert von dieser einfachen und doch beredten Großartigkeit beschloss ich, dass auch dieses Projekt diese Art von Magie braucht. Dies geschah in Form eines Abhängigkeitsstapels, bei dem ich einige unveränderliche Ressourcen erstelle, die von nachfolgenden Stapeln importiert werden. Der Abhängigkeitsstapel nahm diese Form an:

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  DomainName:
    Type: String
    content: Domain name for the hosted zone
    Default: cfnflip.com
  ProjectName:
    Type: String
    content: Name of your project, e.g. cfnflip
    Default: CfnFlip

Ich habe hier Parameter verwendet, damit Sie dieses Snippet leicht für ein anderes Projekt wiederverwenden können. Er nimmt den Domänennamen und den Projektnamen als Parameter. Dann zu den Ressourcen:

  CloudformationBucket:
    Type: AWS::S3::Bucket

Wir lassen CloudFormation einen Bucket mit einem dynamischen Namen erstellen. Der Name wird ausgegeben und exportiert. Dann lassen wir CloudFormation unsere HostedZone erstellen.

  HostedZone:
    Type: AWS::Route53::HostedZone
    Properties:
      HostedZoneConfig:
        Comment: !Join
          - ''
          - - 'HostedZone for '
            - !Ref 'DomainName'
      Name: !Ref 'DomainName'

Als nächstes ist eine Lambda-Funktion an der Reihe. Ich wollte den Route53-Domainnamen (cfnflip.com) automatisch mit den DNS-Servern der dynamisch erstellten Route53 HostedZone konfigurieren, damit ich so wenig wie möglich mit der AWS-Konsole interagieren muss. Eine benutzerdefinierte Ressource ruft diese Lambda-Funktion auf und übergibt die DNS-Server. Anschließend wird der Domainname mithilfe des NodeJS AWS SDK konfiguriert:

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt 'LambdaFunctionRole.Arn'
      Code:
        ZipFile: !Join
          - "n"
          - - var AWS = require('aws-sdk');
            - 'AWS.config.update({region: ''us-east-1''});'
            - var response = require('cfn-response');
            - exports.handler = function(event, context) {
            - '    var nameservers = event.ResourceProperties.Nameservers;'
            - '    var domainname  = event.ResourceProperties.HostedZone;'
            - ''
            - '    console.log(nameservers);'
            - '    console.log(domainname);'
            - '    if (event.RequestType == ''Delete'') {'
            - '        var responseData = { Value: ''Go ahead.'' };'
            - '        response.send(event, context, response.SUCCESS, responseData);'
            - '    }'
            - '    else'
            - '    {'
            - ''
            - '        var route53domains = new AWS.Route53Domains();'
            - '        var ServerList = [];'
            - '        nameservers.forEach(function(server){'
            - '          ServerList.push({'
            - '            Name: server'
            - '          })'
            - '        });'
            - '        route53domains.updateDomainNameservers('
            - '            {'
            - '                DomainName: domainname,'
            - '                Nameservers: ServerList'
            - '            }, function(err, data) {'
            - '                if (err) console.log(err, err.stack);'
            - '                else     console.log(data);'
            - '                var responseData = { Value: ''Hurray!'' };'
            - '                response.send(event, context, response.SUCCESS, responseData);'
            - '            }'
            - '        )'
            - '    }'
            - '};'
      Runtime: nodejs6.10
      MemorySize: '128'
      Timeout: '25'
    DependsOn: LambdaFunctionRole

Natürlich benötigt unsere Lambda-Funktion Berechtigungen. Unsere Ausführungsrolle erlaubt der Lambda-Funktion die Aktualisierung der DNS-Server und die Anmeldung bei CloudWatch:

  LambdaFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Effect: Allow
                Resource: arn:aws:logs:*:*:*
              - Action:
                  - route53domains:UpdateDomainNameservers
                Effect: Allow
                Resource: '*'
          PolicyName: Route53LambdaUpdateRole

Schließlich definieren wir eine benutzerdefinierte Ressource und geben die DNS-Server an unsere Lambda-Funktion weiter:

  UpdateRoute53:
    Type: Custom::UpdateRoute53
    Properties:
      HostedZone: !Ref 'DomainName'
      Nameservers: !GetAtt 'HostedZone.NameServers'
      ServiceToken: !GetAtt 'LambdaFunction.Arn'
    DependsOn:
      - LambdaFunction
      - HostedZone

Wir haben jetzt eine HostedZone und einen S3-Bucket, die sich nie ändern sollten. Wir geben diese unveränderlichen Werte aus und exportieren sie, damit unser Anwendungsstack sie verwenden kann:

Outputs:
  HostedZoneId:
    Value: !Ref 'HostedZone'
    Export:
      Name: !Join
        - ''
        - - !Ref 'ProjectName'
          - HostedZoneId
  HostedZoneName:
    Value: !Ref 'DomainName'
    Export:
      Name: !Join
        - ''
        - - !Ref 'ProjectName'
          - HostedZoneName
  CloudformationBucket:
    Value: !Ref 'CloudformationBucket'
    Export:
      Name: !Join
        - ''
        - - !Ref 'ProjectName'
          - CloudformationBucket

Sie können einen exportierten Wert in einen anderen Stapel importieren, indem Sie ihn wie folgt referenzieren: !ImportValue 'MyExportedValueName'

Ein Wort der Warnung: Wenn Sie einen exportierten Wert in einem anderen Stapel verwenden, können Sie den Wert des exportierten Wertes erst dann ändern, wenn kein Stapel mehr darauf verweist. Mit anderen Worten: Verwenden Sie Exporte nur für Daten, die sich niemals ändern.

Die Lambda-Funktionen

cfn-flip ist ein Python-Skript, das normalerweise eine flache Datei als Argument nimmt und sie entweder in YAML oder JSON umwandelt, während cfn2dsl ein Ruby-Skript ist, das etwas Ähnliches tut, aber stattdessen Ruby-Code ausgibt, den das cfndsl-Gem verwendet und eine CloudFormation-Vorlage ausgibt, wenn Sie es in einen Ruby-Interpreter einspeisen. Das bedeutete, dass ich zwei benutzerdefinierte Lambda-Bereitstellungspakete erstellen musste: Eines für cfn-flip und eines für cfn2dsl. Die Erstellung eines Deployment-Pakets für Python ist ein unkomplizierter Prozess. Sie können einfach das cfn-flip-Repository klonen und die Abhängigkeiten wie folgt in Ihr Deployment-Paketverzeichnis installieren:

pip install -r requirements.txt -t ~/your/deployment_package Unter MacOS X funktioniert dies jedoch nicht ohne Weiteres. Sie müssen im geklonten Verzeichnis eine setup.cfg mit folgendem Inhalt erstellen (oder hinzufügen):

[install]
prefix=

Standardmäßig rufen Sie das Tool cfn-flip mit einigen Argumenten auf, um anzugeben, welche Datei Sie konvertieren möchten. In diesem Fall wollte ich, dass cfn-flip den Inhalt verwendet, der der Lambda-Funktion zur Verfügung gestellt wird. Also im Wesentlichen: den Code, den der Benutzer auf der Website eingegeben hat. Wenn ich mir den Quellcode ansehe, gibt es nur eine einzige Funktion in cfn-flip, die ich benötigte, nämlich flip. Meine Lambda-Funktion sah dann wie folgt aus:

import json
import sys
import cStringIO
import urlparse
from cfn_flip import flip

def lambda_handler(event, context):
    data = urlparse.parse_qsl(event["body"])
    for k, v in data:
        if k == 'code':
            plaintext = v
        else:
            plaintext = ""

    stdout_ = sys.stdout
    stream = cStringIO.StringIO()
    sys.stdout = stream
    hndlr = sys.stdout
    hndlr.write(flip(
        plaintext
    ))
    sys.stdout = stdout_
    data = stream.getvalue()

    return {
        'statusCode': 200,
        'body': json.loads(json.dumps(data)),
        'headers': {
                'Content-Type': 'plain/text',
                'Access-Control-Allow-Origin': '*',
                'Cache-control': 'private, max-age=0, no-cache'
            },
        }

Der Ereigniskörper erwartet, dass der Körper der Vorlage mit Code als Schlüssel gepostet wird. Die Lambda-Funktion fängt dann die Ausgabe der Funktion flip von cfn-flip ein und gibt schließlich die umgedrehte Version zurück. Die Portierung von "cfn-dsl" in Ruby war eine ganz andere Herausforderung. Ich habe eine portable Version von Ruby 2.2.x namens Traveling Ruby verwendet, um mein Deployment-Paket in einer Linux-Umgebung zu erstellen. Der cfn2dsl-Gem war (zu Recht) auf Ruby 2.3 als Mindestversion festgelegt, also musste ich kreativ werden. Ich begann mit der Installation von Ruby mit einer identischen Version von Traveling Ruby unter Verwendung von rbenv. Dann erstellte ich eine Gemfile mit den Abhängigkeiten von cfn2dsl, aber ohne die an eine Mindestversion gebundenen Versionen. So konnte ich alle Gem-Abhängigkeiten von cfndsl erhalten, die mit Ruby 2.2 kompatibel waren. Ich habe dann alle Abhängigkeiten in das gem-Verzeichnis des reisenden Ruby verschoben und ein cfn2dsl_lib-Verzeichnis erstellt und den cfn2dsl-Quellcode dort abgelegt. Nachdem das erledigt war, kopierte ich das Deployment-Paket auf mein treues Macbook und begann mit der Arbeit an der Lambda-Funktion. Da AWS Ruby nicht nativ unterstützt, besteht das Deployment-Paket aus einer NodeJS-Funktion, die das Ruby-Skript aufruft, und dem Ruby-Skript zur Umwandlung der Vorlage in CfnDsl-Code. Der Ruby-Code sah wie folgt aus:

#!./bin/ruby

require "uri"
require_relative 'cfn2dsl_lib/cfn2dsl'

payload = URI.decode(JSON.parse(ARGV[0])["body"].tr('+', ' ')).gsub(/^code=/,'')
template = JSON.parse(payload)
cfndsl   = CloudFormation.new(template)
dsl = Render.new(cfndsl).cfn_to_cfndsl

dsl = "require 'cfndsl'nn" 
      "# Generated by https://cfnflip.com/n" 
      "# The 'cfndsl' gem is required. Type gem install cfndsl to install it.nn" 
      "#{dsl.gsub(/^CloudFormation do/, 'template = CloudFormation do')}n" 
      "puts template.to_json"

puts dsl

Es gibt eloquentere Möglichkeiten, POST-Daten zu analysieren, aber ich habe die erforderlichen Edelsteine nicht in das Deployment-Paket aufgenommen, um so viel Blähung wie möglich zu vermeiden. Ruby kann notorisch langsam sein. Ich habe die cfn2dsl-Funktionalität darauf reduziert, eine Vorlage aus einem gegebenen String zu rendern und das Ergebnis auszugeben. Die NodeJS-Wrapper-Funktion sieht folgendermaßen aus:

const exec = require('child_process').exec;

exports.handler = function(event, context, callback) {
    const child = exec('./lambdaRuby.rb ' + ''' + JSON.stringify(event) + ''', function(error, stdout, stderr) {
        const response = {
          statusCode: 200,
          headers: {
            'Access-Control-Allow-Origin': '*',
            'Content-Type': 'text/html'
          },
          body: stdout
        };
        callback(null, response);
    });
    child.stdout.on('data', console.log);
    child.stderr.on('data', console.error);
}

Die NodeJS-Funktion startet das Ruby-Skript als Kindprozess und übergibt einen JSON-kodierten String als Argument an das Ruby-Skript, wobei eine Funktion darauf wartet, dass der Kindprozess beendet wird und stdout abruft. Die Ausgabe wird dann so modifiziert, dass sie sofort ausgeführt und zurückgegeben werden kann. Zu guter Letzt musste ich noch das Frontend erstellen, das aus einer statischen HTML-Seite und einer Lambda-Funktion besteht, die den Inhalt zurückgibt:

'use strict';

var fs = require('fs');

module.exports.frontPage = (event, context, callback) => {
  var html = fs.readFileSync("index.html", "utf8");
  const response = {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Content-Type': 'text/html',
    },
    body: html
  };

  callback(null, response);
};

Die Lambda-Funktion liest den Inhalt von index.html und gibt ihn zurück.

Anatomie eines API-Gateways

Nachdem die Lambda-Funktionen eingerichtet sind, ist es an der Zeit, ein API-Gateway zu erstellen, das die verschiedenen Endpunkte zu den entsprechenden Lambda-Funktionen weiterleitet. Wir haben einen Lambda für die Hauptseite und zwei Lambdas zur Konvertierung der eingereichten Vorlagen in ein anderes Format. Ich werde einige Teile der CloudFormation-Vorlage hervorheben, die wichtig sind. Wenn Sie an der vollständigen Vorlage interessiert sind, finden Sie sie auf GitHub. Zuerst erstellen wir unser API Gateway:

  ApiGatewayRestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: cfnflip

Wir erstellen unseren Endpunkt /cfn2dsl/ wie folgt:

  ApiGatewayResourceRuby:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt 'ApiGatewayRestApi.RootResourceId'
      PathPart: cfn2dsl
      RestApiId: !Ref 'ApiGatewayRestApi'

Für jeden Lambda habe ich eine Protokollgruppe erstellt, die an einem vorhersehbaren und konsistenten Ort protokolliert. In diesem Fall ist der Name der Protokollgruppe identisch mit dem Namen der Lambda-Funktion. Im Falle der Ruby-Lambda-Funktion sieht die Loggruppen-Ressource wie folgt aus:

  CfnFlipRubyLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 7
      LogGroupName: /aws/lambda/cfn2dsl

Der LogGroupName bestimmt, wo in CloudWatch Sie die Protokolldateien wiederfinden können. Da wir sie nicht ewig aufbewahren wollen, werfen wir die Protokolle nach 7 Tagen weg. Merken Sie sich den LogGroupName. Wir werden darauf zurückkommen, wenn wir die IAM AssumeRole-Richtlinie erstellen. Um die Lambda-Funktion bereitzustellen, müssen wir eine AWS::Lambda::Function-Ressource erstellen:

  CfnFlipRubyLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: !ImportValue 'CfnFlipCloudformationBucket'
        S3Key: cfn2dslb538066ac5e095501bfe89eccc015a7f.zip
      Handler: ruby.handler
      FunctionName: cfn2dsl
      MemorySize: 256
      Role: !GetAtt 'IamRoleLambdaExecution.Arn'
      Runtime: nodejs6.10
      Timeout: 10
    DependsOn:
      - IamRoleLambdaExecution
      - CfnFlipRubyLogGroup

Wie Sie sehen, importieren wir den Bucket aus dem Dependency Stack und übergeben ihn an S3Bucket. In unserem Deployment-Paket haben wir ruby.js erstellt, das wir als Handler für die Funktion verwenden. Bei MemorySize geht es um Ausgewogenheit. Je höher die Speichergröße, desto mehr CPU steht der Funktion zur Verfügung, es geht also um das Gleichgewicht zwischen Geschwindigkeit und Kosten. Für unsere Ruby-Lambda-Funktion boten 256 MB Speicher das beste Gleichgewicht. Bei der Dimensionierung von Lambda-Funktionen müssen Sie bedenken, dass eine geringere Größe mehr Kosten pro Aufruf bedeuten kann und dass eine hohe Größe bedeuten kann, dass die höhere Geschwindigkeit die zusätzlichen Kosten nicht rechtfertigt. Schließlich müssen wir noch eine POST-Methode für unseren Endpunkt erstellen:

  ApiGatewayMethodRubyPost:
    Type: AWS::ApiGateway::Method
    Properties:
      HttpMethod: POST
      RequestParameters: {}
      ResourceId: !Ref 'ApiGatewayResourceRuby'
      RestApiId: !Ref 'ApiGatewayRestApi'
      AuthorizationType: NONE
      Integration:
        RequestTemplates:
          application/x-www-form-urlencoded: "#set($allParams = $input.params())n
            {n  "params" : {n    #foreach($type in $allParams.keySet())n    #set($params
             = $allParams.get($type))n    "$type" : {n      #foreach($paramName
             in $params.keySet())n      "$paramName" : "$util.escapeJavaScript($params.get($paramName))"
            n      #if($foreach.hasNext),#endn      #endn    }n    #if($foreach.hasNext),#endn
                #endn  }n}n"
        IntegrationHttpMethod: POST
        Type: AWS_PROXY
        Uri: !Join
          - ''
          - - 'arn:aws:apigateway:'
            - !Ref 'AWS::Region'
            - :lambda:path/2015-03-31/functions/
            - !GetAtt 'CfnFlipRubyLambdaFunction.Arn'
            - /invocations
      MethodResponses: []

Das RequestTemplate im obigen Schnipsel wandelt die Anfrage in einen Ereigniskörper um, den wir in der Funktion verwenden können, und gibt ihn dann an die Lambda-Funktion weiter. Haben Sie also an LogGroupName gedacht? Er kommt jetzt ins Spiel. Wie Sie in dem Lambda-Funktionsschnipsel sehen können, gibt es einen Verweis auf die Rolle IamRoleLambdaExecution. Diese Rolle stattet die Lambda-Funktion mit den entsprechenden Berechtigungen aus, um Aktionen in Ihrem Namen durchzuführen. Sie möchten diese Berechtigungen auf das Mindestmaß beschränken, das für die Funktion erforderlich ist. Unsere Lambdas benötigen glücklicherweise nicht viele Berechtigungen. Sie interagieren weder mit einem AWS-Service noch benötigen sie eine VPC, mit der sie kommunizieren können. Was braucht er also? Es erstellt CloudWatch-Protokollströme und schreibt in sie! Schauen wir uns das mal an:

  IamRoleLambdaExecution:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action:
              - sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
        Version: '2012-10-17'
      Policies:
        - PolicyDocument:
            Statement:
              - Action:
                  - logs:CreateLogStream
                Effect: Allow
                Resource:
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/cfnflip:*'
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/pyflip:*'
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/cfn2dsl:*'
              - Action:
                  - logs:PutLogEvents
                Effect: Allow
                Resource:
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/cfnflip:*:*'
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/pyflip:*:*'
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/cfn2dsl:*:*'
            Version: '2012-10-17'
          PolicyName: !Join
            - '-'
            - - cfnflip
              - lambda
      Path: /
      RoleName: !Join
        - '-'
        - - cfnflip
          - eu-west-1
          - lambdaRole

Wir erlauben der Lambda-Funktion, diese Rolle mit allen im PolicyDocument aufgeführten Berechtigungen zu übernehmen. Wir gewähren den LogGroups, die wir zuvor erstellt haben, die Berechtigung CreateLogStream und PutLogEvents. Das ist alles, was unsere Funktionen benötigen.

Epilog

Es ist 2:27 Uhr nachts und ich tippe gerade diesen Epilog. Ich habe länger gebraucht, um diesen Blogbeitrag zu schreiben, als ich für die Erstellung des MVP für cfnflip.com gebraucht habe. Es hat mir großen Spaß gemacht und ich brüte bereits über einigen neuen Ideen. Mein geschätzter Kollege Mark spielt mit dem Gedanken, einen Terraform Transformer zu entwickeln. Wenn Sie eine coole Idee haben, lassen Sie es uns wissen, oder noch besser: Forken Sie das Projekt und reichen Sie einen PR für Ihre Verbesserungen ein.

Verfasst von

Dennis Vink

Crafting digital leaders through innovative AI & cloud solutions. Empowering businesses with cutting-edge strategies for growth and transformation.

Contact

Let’s discuss how we can support your journey.