Kapitel 4. Betrieb von AWS Lambda-Funktionen

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In diesem Kapitel stellen wir eine fortgeschrittene Methode zum Erstellen und Verpacken von Java-basierten AWS Lambda-Funktionen vor. Außerdem gehen wir näher auf die serverlose Version des AWS Infrastructure-as-Code-Tools SAM ein, das du in Kapitel 2 zum ersten Mal verwendet hast. Schließlich gehen wir darauf ein, wie sich Lambda-Funktionen und serverlose Anwendungen auf das AWS-Sicherheitsmodell auswirken und wie wir SAM nutzen können, um automatisch ein Least-Privilege-Sicherheitsmodell für unsere serverlose Anwendung durchzusetzen.

Bevor du fortfährst, empfehlen wir dir, falls du es noch nicht getan hast, die Codebeispiele in diesem Buch herunterzuladen.

Bauen und verpacken

Die Lambda-Plattform erwartet, dass der gesamte vom Benutzer bereitgestellte Code in Form einer ZIP-Archivdatei vorliegt. Je nachdem, welche Laufzeit du verwendest und wie deine eigentliche Geschäftslogik aussieht, kann diese ZIP-Datei aus Quellcode, Code und Bibliotheken oder, im Falle von Java, aus kompiliertem Bytecode (Klassendateien) und Bibliotheken bestehen.

In dem Java-Ökosystem verpacken wir unseren Code oft in JAR (Java ARchive)-Dateien, um sie über den Befehl java -jar auszuführen oder um sie als Bibliotheken für andere Anwendungen zu verwenden. Eine JAR-Datei ist einfach eine ZIP-Datei mit zusätzlichen Metadaten. Die Lambda-Plattform behandelt JAR-Dateien nicht besonders, sondern wie ZIP-Dateien, genau wie die anderen Lambda-Laufzeiten.

Mit und einem Tool wie Maven können wir die anderen Bibliotheken angeben, von denen unser Code abhängt, und Maven dazu bringen, die richtigen Versionen dieser Bibliotheken (und alle transitiven Abhängigkeiten, die sie haben könnten) herunterzuladen, unseren Code in Java-Klassendateien zu kompilieren und alles in eine einzige JAR-Datei (oft uberjar genannt) zu packen.

Uberjars

Obwohl den Uberjar-Ansatz in den Kapiteln 2 und 3 verwendet, gibt es ein paar Probleme damit, auf die wir hinweisen sollten, bevor wir weitermachen.

Der uberjar-Ansatz entpackt zunächst die Bibliotheken und legt sie dann in der uberjar-Zieldatei übereinander. Im folgenden Beispiel enthält Bibliothek A eine Klassendatei und eine Eigenschaftsdatei. Bibliothek B enthält eine andere Klassendatei und eine Eigenschaftsdatei mit demselben Namen wie die Eigenschaftsdatei aus Bibliothek A.

$ jar tf LibraryA.jar
book/
book/important.properties
book/A.class

$ jar tf LibraryB.jar
book/
book/important.properties
book/B.class

Wenn diese JAR-Dateien verwendet würden, um ein Uberjar zu erstellen (wie wir es in den vorherigen Kapiteln getan haben), würde das Ergebnis zwei Klassendateien und eine Eigenschaftsdatei enthalten - aber die Eigenschaftsdatei aus welcher JAR-Quelle?

$ jar tf uberjar.jar
book/
book/important.properties # Which properties file is this?
book/A.class
book/B.class

Da die JAR-Dateien entpackt und überlagert werden, schafft es nur eine dieser Eigenschaftsdateien in das endgültige Uberjar, und es kann schwierig sein, herauszufinden, welche davon gewinnt, ohne in die dunklen Künste der Maven-Ressourcentransformatoren einzutauchen.

Das zweite große Problem des Uberjar-Ansatzes liegt in der Erstellung einer JAR-Datei - die Tatsache, dass JAR-Dateien auch ZIP-Dateien sind, die von der Lambda-Laufzeitumgebung verwendet werden können, ist aus Sicht des Maven-Build-Prozesses nebensächlich. Aus dieser JAR- versus ZIP-Situation ergeben sich zwei spezifische Probleme. Zum einen werden alle JAR-spezifischen Metadaten von der Lambda-Laufzeit nicht genutzt (und sogar ignoriert). Dinge wie das Attribut Main-Class in einer MANIFEST.MF-Datei - ein Teil der Metadaten, die für JAR-Dateien üblich sind - sind im Kontext einer Lambda-Funktion bedeutungslos.

Außerdem bringt der JAR-Erstellungsprozess selbst ein gewisses Maß an Unbestimmtheit in den Build-Prozess ein. Beispielsweise werden Tool-Versionen und Build-Zeitstempel in den Dateien MANIFEST.MF und pom.properties aufgezeichnet - und das macht es unmöglich, jedes Mal dieselbe JAR-Datei aus demselben Quellcode zu erstellen. Diese Unbestimmtheit wirkt sich verheerend auf nachgelagerte Caching-, Deployment- und Sicherheitsprozesse aus, weshalb wir sie nach Möglichkeit vermeiden wollen.

Da wir eigentlich nicht an der JAR-Eigenschaft einer uberjar-Datei interessiert sind, macht es Sinn, den uberjar-Prozess überhaupt nicht zu verwenden. Natürlich ist der uberjar-Prozess selbst nicht unbedingt die einzige Quelle von Unbestimmtheit in unserem Build-Prozess, aber mit dem Rest beschäftigen wir uns später.

Trotz dieser Nachteile ist das uberjar-Verfahren in einfachen Fällen einfacher zu konfigurieren und zu verwenden, vor allem, wenn eine Lambda-Funktion nur wenige (oder gar keine) Abhängigkeiten von Dritten hat. Das war in den Beispielen in Kapitel 2 und 3 der Fall, weshalb wir bis zu diesem Punkt die uberjar-Technik verwendet haben, aber für jeden realen Einsatz von Java und Lambda in größerem Umfang empfehlen wir den ZIP-Datei-Ansatz, den wir im Folgenden beschreiben.

Zusammenstellen einer ZIP-Datei

In der Java-Welt ist unsere Alternative zur Verwendung einer uberjar-Datei der Rückgriff auf eine bewährte ZIP-Datei. In diesem Szenario wird das Archivlayout etwas anders aussehen, aber wir werden sehen, wie wir mit einem vorsichtigen Ansatz die Probleme mit uberjar vermeiden und ein Artefakt erhalten können, das die Lambda-Plattform verwenden kann. Wir werden besprechen, wie wir dies mit Maven erreichen, aber natürlich kannst du diese Methode auch auf dein bevorzugtes Build-Tool übertragen - das Ergebnis ist wichtiger als der Prozess selbst.

Um ein interessanteres Beispiel zu erstellen, fügen wir zunächst eine Abhängigkeit vom AWS SDK für DynamoDB zu unserem Maven-Build für die Lambda-Funktion aus "Lambda Hello World (the Proper Way)" hinzu .

Füge der Datei pom.xml einen Abschnitt dependencies hinzu:

    <dependencies>
      <dependency>
        <groupId>com.amazonaws</groupId>
        <artifactId>aws-java-sdk-dynamodb</artifactId>
        <version>1.11.319</version>
      </dependency>
    </dependencies>

Mit der hinzugefügten Abhängigkeit sieht das gewünschte ZIP-Dateilayout für unsere einfache Lambda-Funktion und die Abhängigkeiten folgendermaßen aus:

$ zipinfo -1 target/lambda.zip
META-INF/
book/
book/HelloWorld.class
lib/
lib/aws-java-sdk-core-1.11.319.jar
lib/aws-java-sdk-dynamodb-1.11.319.jar
lib/aws-java-sdk-kms-1.11.319.jar
lib/aws-java-sdk-s3-1.11.319.jar
lib/commons-codec-1.10.jar
lib/commons-logging-1.1.3.jar
lib/httpclient-4.5.5.jar
lib/httpcore-4.4.9.jar
lib/ion-java-1.0.2.jar
lib/jackson-annotations-2.6.0.jar
lib/jackson-core-2.6.7.jar
lib/jackson-databind-2.6.7.1.jar
lib/jackson-dataformat-cbor-2.6.7.jar
lib/jmespath-java-1.11.319.jar
lib/joda-time-2.8.1.jar

Zusätzlich zu unserem Anwendungscode(book/HelloWorld.class) sehen wir ein lib-Verzeichnis voller JAR-Dateien, eine für das AWS DynamoDB SDK und eine für jede seiner transitiven Abhängigkeiten.

Wir können diese ZIP-Ausgabe mit dem Maven Assembly Plug-in erstellen. Dieses Plug-in ermöglicht es uns, einem bestimmten Teil des Maven-Builds (in diesem Fall der package Phase, in der die Ergebnisse des Java-Kompilierungsprozesses zusammen mit anderen Ressourcen in eine Reihe von Ausgabedateien verpackt werden) ein spezielles Verhalten hinzuzufügen.

Zunächst haben wir das Maven Assembly Plug-in in der pom.xml-Datei für das Projekt im Abschnitt build konfiguriert:

<build>
  <plugins>
    <plugin>
      <artifactId>maven-assembly-plugin</artifactId>
      <version>3.1.1</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals>
            <goal>single</goal>
          </goals>
        </execution>
      </executions>
      <configuration>
        <appendAssemblyId>false</appendAssemblyId>
        <descriptors>
          <descriptor>src/assembly/lambda-zip.xml</descriptor>
        </descriptors>
        <finalName>lambda</finalName>
      </configuration>
    </plugin>
  </plugins>
</build>

Die beiden wichtigsten Teile dieser Konfiguration sind die Assembly descriptor, die einen Pfad zu einer anderen XML-Datei in unserem Projekt angibt, und finalName, die das Plug-in anweist, unsere Ausgabedatei lambda.zip zu benennen. Wir werden später sehen, wie die Wahl eines einfachen finalName die schnelle Iteration unseres Projekts erleichtert, insbesondere wenn wir anfangen, Maven-Submodule zu verwenden.

Der größte Teil der Konfiguration für unsere ZIP-Datei befindet sich in der Datei descriptor, die zuvor in der Datei pom.xml referenziert wurde. Diese assembly Konfiguration ist eine Beschreibung der genauen Inhalte, die in unsere Ausgabedatei aufgenommen werden sollen:

<assembly>
  <id>lambda-zip</id> 1
  <formats>
    <format>zip</format> 2
  </formats>
  <includeBaseDirectory>false</includeBaseDirectory> 3
  <dependencySets>
    <dependencySet> 4
      <includes>
        <include>${project.groupId}:${project.artifactId}</include>
      </includes>
      <unpack>true</unpack>
      <unpackOptions>
        <excludes>
          <exclude>META-INF/MANIFEST.MF</exclude>
          <exclude>META-INF/maven/**</exclude>
        </excludes>
      </unpackOptions>
    </dependencySet>
    <dependencySet> 5
      <useProjectArtifact>false</useProjectArtifact>
      <unpack>false</unpack>
      <scope>runtime</scope>
      <outputDirectory>lib</outputDirectory> 6
    </dependencySet>
  </dependencySets>
</assembly>
1

Wir haben der Baugruppe einen eindeutigen Namen gegeben: lambda-zip.

2

Das Ausgabeformat selbst ist vom Typ zip.

3

Die Ausgabedatei wird kein Basisverzeichnis haben - das bedeutet, dass der Inhalt unserer ZIP-Datei beim Entpacken in das aktuelle Verzeichnis und nicht in ein neues Unterverzeichnis entpackt wird.

4

Der erste Abschnitt dependencySet enthält explizit unseren Anwendungscode, indem er auf die Eigenschaften groupId und artifactId des Projekts verweist. Wenn wir anfangen, Maven-Submodule zu verwenden, muss dies geändert werden. Unser Anwendungscode wird "ausgepackt". Das heißt, er wird nicht in einer JAR-Datei enthalten sein, sondern nur in einer normalen Verzeichnisstruktur und Java-Klassendateien. Außerdem haben wir das unnötige META-INF-Verzeichnis explizit ausgeschlossen.

5

Der zweite Abschnitt dependencySet behandelt die Abhängigkeiten unserer Anwendung. Wir schließen das Artefakt des Projekts aus (wie im ersten Abschnitt dependencySet ). Wir schließen nur die Abhängigkeiten ein, die im Bereich runtime liegen. Wir entpacken die Abhängigkeiten nicht, sondern lassen sie einfach als JAR-Dateien gepackt.

6

Anstatt alle JAR-Dateien in das Stammverzeichnis unserer Ausgabedatei aufzunehmen, legen wir sie alle in einem lib-Verzeichnis ab.

Wie hilft uns diese komplizierte neue Maven-Konfiguration dabei, die Probleme mit uberjars zu vermeiden?

Zuerst haben wir einige unnötige META-INF-Informationen entfernt. Du wirst feststellen, dass wir etwas selektiv vorgegangen sind - es gibt einige Fälle, in denen META-INF-Informationen (wie z. B. "Dienste") immer noch wertvoll sind, daher wollen wir sie nicht komplett entfernen.

Zweitens haben wir alle unsere Abhängigkeiten als einzelne JAR-Dateien in ein lib-Verzeichnis aufgenommen. Dadurch wird das Problem des Überschreibens von Dateien und Pfaden vollständig vermieden. Jedes Abhängigkeits-JAR bleibt in sich geschlossen. Laut der Dokumentation zu den bewährten Methoden von AWS Lambda zahlt sich dieser Ansatz auch in Bezug auf die Leistung aus, da die Lambda-Plattform eine ZIP-Datei schneller entpacken und die JVM Klassen aus JAR-Dateien schneller laden kann.

Reproduzierbare Builds

Wenn sich unser Quellcode oder unsere Abhängigkeiten ändern, erwarten wir, dass sich auch der Inhalt des Deployment-Pakets (die Uberjar- oder ZIP-Datei) ändert (nachdem unser Build- und Paketierungsprozess ausgeführt wurde). Wenn sich unser Quellcode und unsere Abhängigkeiten jedoch nicht ändern, sollte der Inhalt des Deployment-Pakets gleich bleiben, auch wenn der Build- und Paketierungsprozess erneut ausgeführt wird. Die Ausgabe des Builds sollte reproduzierbar sein (z. B. determiniert), Das ist wichtig, weil nachgelagerte Prozesse (wie z. B. Deployment-Pipelines) oft davon abhängen, ob sich der Inhalt eines Deployment-Pakets geändert hat, was durch den MD5-Hash des Inhalts angezeigt wird, und wir wollen vermeiden, dass diese Prozesse unnötig ausgelöst werden.

Auch wenn wir die automatisch generierten Dateien MANIFEST.MF und pom.properties mit dem lambda-zip Assembly Descriptor eliminiert haben, haben wir immer noch nicht alle potenziellen Quellen von Unbestimmtheit im Build-Prozess beseitigt. Wenn wir zum Beispiel unseren Anwendungscode bauen (z. B. HelloWorld), kann sich der Zeitstempel der kompilierten Java-Klassendateien ändern. Diese geänderten Zeitstempel werden in die ZIP-Datei übertragen, und dann ändert sich der Hash des Inhalts der ZIP-Datei, obwohl der Quellcode nicht geändert wurde.

Glücklicherweise gibt es ein einfaches Maven Plug-in, um diese Quellen von Unbestimmtheit aus unserem Build-Prozess zu entfernen. reproducible-build-maven-plugin kann während des Build-Prozesses ausgeführt werden und macht unsere ZIP-Datei vollständig deterministisch. Es kann als plugin im Abschnitt build unserer pom.xml-Datei konfiguriert werden:

<plugin>
  <groupId>io.github.zlika</groupId>
  <artifactId>reproducible-build-maven-plugin</artifactId>
  <version>0.10</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals>
        <goal>strip-jar</goal>
      </goals>
    </execution>
  </executions>
</plugin>

Wenn wir nun unsere Deployment-Pakete mehrmals mit demselben unveränderten Quellcode neu erstellen, ist der Hash immer derselbe. Wie sich das auf den Deployment-Prozess auswirkt, erfährst du im nächsten Abschnitt.

Bereitstellen

Es gibt viele Möglichkeiten, Lambda-Code bereitzustellen. Bevor wir uns damit befassen, sollten wir jedoch klären, was wir mit " bereitstellen" meinen. In diesem Fall geht es einfach darum, den Code oder die Konfiguration für eine bestimmte Lambda-Funktion oder eine Gruppe von Lambda-Funktionen und zugehörigen AWS-Ressourcen mithilfe von APIs oder anderen Services zu aktualisieren. Wir erweitern die Definition nicht um die Bereitstellungsorchestrierung (wie AWS CodeDeploy).

In keiner bestimmten Reihenfolge sind die Methoden zur Bereitstellung von Lambda-Code wie folgt:

  • AWS Lambda Web-Konsole

  • AWS CloudFormation/Serverless Application Model (SAM)

  • AWS CLI (das die AWS API verwendet)

  • AWS Cloud Development Kit (CDK)

  • Andere von AWS entwickelte Frameworks, wie Amplify und Chalice

  • Frameworks von Drittanbietern für serverlose Komponenten, die hauptsächlich auf CloudFormation aufbauen, wie das Serverless Framework

  • Tools und Frameworks von Drittanbietern für serverlose Komponenten, die hauptsächlich auf der AWS-API aufbauen, wie Claudia.js und lambda-maven-plugin von Maven

  • Allzweck-Infrastruktur-Tools von Drittanbietern, wie Ansible oder Terraform

In diesem Buch befassen wir uns mit den ersten beiden (und haben die AWS Lambda-Webkonsole und SAM bereits in den Kapiteln 2 und 3 behandelt). Wir verwenden auch die AWS CLI, allerdings nicht als Bereitstellungstool. Mit einem soliden Verständnis dieser Methoden solltest du in der Lage sein, die anderen Optionen zu bewerten und zu entscheiden, ob eine von ihnen für deine Umgebung und deinen Anwendungsfall besser geeignet ist.

Infrastruktur als Code

Wenn wir über die Webkonsole oder die CLI mit AWS interagieren, erstellen, aktualisieren und zerstören wir die Infrastruktur manuell. Wenn wir zum Beispiel eine Lambda-Funktion über die AWS Webkonsole erstellen, müssen wir beim nächsten Mal, wenn wir eine Lambda-Funktion mit denselben Parametern erstellen wollen, immer noch dieselben manuellen Aktionen über die Webkonsole durchführen. Diese Eigenschaft gilt auch für die CLI.

Für die anfängliche Entwicklung und das Experimentieren ist dies ein vernünftiger Ansatz. Wenn unsere Projekte jedoch an Fahrt gewinnen, wird diese manuelle Herangehensweise an die Verwaltung der Infrastruktur zu einem Hindernis. Ein bewährter Weg, dieses Problem zu lösen, heißt Infrastruktur als Code.

Statt manuell über die Webkonsole oder die Befehlszeilenschnittstelle mit AWS zu interagieren, können wir unsere gewünschte Infrastruktur deklarativ in einer JSON- oder YAML-Datei angeben und diese Datei an den Infrastructure-as-Code-Service von AWS übermitteln: CloudFormation. Der CloudFormation-Service nimmt unsere Eingabedatei entgegen und nimmt in unserem Namen die notwendigen Änderungen an der AWS-Infrastruktur vor. Dabei berücksichtigt er die Abhängigkeiten von Ressourcen, den aktuellen Stand der zuvor bereitgestellten Versionen unserer App sowie die Eigenheiten und spezifischen Anforderungen der verschiedenen AWS-Services. Ein Satz von AWS-Ressourcen, der aus einer CloudFormation-Vorlagendatei erstellt wird, wird Stack genannt.

CloudFormation ist der AWS-eigene Infrastructure-as-Code-Dienst, aber er ist nicht die einzige Option in diesem Bereich. Andere beliebte Optionen, die mit AWS funktionieren, sind Terraform, Ansible und Chef. Jeder Dienst hat seine eigenen Konfigurationssprachen und -muster, aber alle erreichen im Wesentlichen das gleiche Ergebnis - eine Cloud-Infrastruktur, die aus Konfigurationsdateien bereitgestellt wird.

Ein entscheidender Vorteil der Verwendung von Konfigurationsdateien (anstelle des Zeigens und Klickens in der Konsole) ist, dass diese Dateien, die unsere Anwendungsinfrastruktur darstellen, zusammen mit unserem Anwendungsquellcode versionskontrolliert werden können. Wir können eine vollständige Zeitleiste der Änderungen an unserer Infrastruktur sehen, indem wir dieselben Versionskontrolltools verwenden, die wir auch für die anderen Teile unserer Anwendung einsetzen. Außerdem können wir diese Konfigurationsdateien in unsere kontinuierlichen Bereitstellungspipelines einbinden, so dass wir bei Änderungen an unserer Anwendungsinfrastruktur diese Änderungen mit branchenüblichen Tools zusammen mit unserem Anwendungscode sicher ausrollen können.

CloudFormation und das serverlose Anwendungsmodell

Während die Vorteile eines Infrastructure-as-Code-Ansatzes auf der Hand liegen, hat CloudFormation selbst den Ruf, langatmig, unhandlich und unflexibel zu sein. Die Konfigurationsdateien selbst für die einfachsten Anwendungsarchitekturen können leicht Hunderte oder Tausende von JSON- oder YAML-Zeilen umfassen. Wenn man mit einem bestehenden CloudFormation-Stack dieser Größe arbeitet, ist die Versuchung verständlich, auf die AWS Web Console oder CLI zurückzugreifen.

Glücklicherweise haben wir als AWS Serverless-Entwickler das Glück, eine andere Variante von CloudFormation nutzen zu können, das Serverless Application Model (SAM), das wir in den Kapiteln 2 und 3 verwendet haben. Dabei handelt es sich im Wesentlichen um eine Obermenge von CloudFormation, die es uns ermöglicht, einige spezielle Ressourcentypen und Abkürzungen zu verwenden, um gängige Serverless-Komponenten und Anwendungsarchitekturen darzustellen. Es enthält auch einige spezielle CLI-Befehle, die die Entwicklung, das Testen und die Bereitstellung erleichtern.

Hier ist die SAM-Vorlage, die wir zuerst in "Erstellen der Lambda-Funktion" verwendet haben . Sie wurde aktualisiert, um unser neues ZIP-Bereitstellungspaket zu verwenden (beachte, dass sich das Suffix CodeUri von .jar in .zip geändert hat):

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Chapter 4

Resources:
  HelloWorldLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.HelloWorld::handler
      CodeUri: target/lambda.zip

Wir können die neue ZIP-basierte Lambda-Funktion mit demselben SAM-Befehl einsetzen, den du in Kapitel 2 gelernt hast:

$ sam deploy \
  --s3-bucket $CF_BUCKET \
  --stack-name chapter4-sam \
  --capabilities CAPABILITY_IAM

sam deploy startet, indem es unser Deployment-Paket auf S3 hochlädt, aber nur, wenn sich der Inhalt des Pakets geändert hat. Zu Beginn des Kapitels haben wir uns damit beschäftigt, einen reproduzierbaren Build einzurichten, damit Vorgänge wie dieser Upload-Prozess nicht ausgeführt werden müssen, wenn sich eigentlich nichts geändert hat.

Hinter den Kulissen erstellt sam deploy auch eine geänderte Version unserer Vorlage (die ebenfalls in S3 gespeichert ist), um auf die neu hochgeladenen S3-Speicherorte unserer Artefakte zu verweisen, anstatt auf die lokalen Speicherorte. Dieser Schritt ist notwendig, weil CloudFormation verlangt, dass alle Artefakte, auf die in einer Vorlage verwiesen wird, zum Zeitpunkt der Bereitstellung in S3 verfügbar sind.

Tipp

Die Dateien, die s3 deploy in S3 speichert, sollten lediglich als Staging-Versionen im Rahmen eines Bereitstellungsprozesses betrachtet werden und nicht als aufzubewahrende Anwendungsartefakte. Aus diesem Grund empfehlen wir dir, für deinen SAM S3-Bucket eine "Lifecycle Policy" festzulegen, die die Deployment-Artefakte nach einer bestimmten Zeit automatisch löscht - in der Regel nach einer Woche, wenn er für nichts anderes verwendet wird.

Nach dem Upload-Schritt erstellt der Befehl sam deploy einen neuen CloudFormation-Stack, wenn noch keiner mit dem angegebenen Namen in diesem AWS-Konto und dieser Region existiert. Wenn der Stack bereits existiert, erstellt der Befehl sam deploy ein CloudFormation-Änderungsset, in dem aufgelistet ist, welche Ressourcen erstellt, aktualisiert oder gelöscht werden , bevor Maßnahmen ergriffen werden. Der Befehl sam deploy wendet dann das Änderungsset an, um den CloudFormation-Stack zu aktualisieren.

Wenn wir die Stack-Ressourcen auflisten, sehen wir, dass CloudFormation nicht nur unsere Lambda-Funktion erstellt hat, sondern auch die unterstützenden IAM-Rollen und -Richtlinien (auf die wir später eingehen werden), ohne dass wir sie explizit angeben mussten:

$ aws cloudformation list-stack-resources --stack-name chapter4-sam
{
  "StackResourceSummaries": [
    {
      "LogicalResourceId": "HelloWorldLambda",
      "PhysicalResourceId": "chapter4-sam-HelloWorldLambda-1HP15K6524D2E",
      "ResourceType": "AWS::Lambda::Function",
      "LastUpdatedTimestamp": "2019-07-26T19:16:34.424Z",
      "ResourceStatus": "CREATE_COMPLETE",
      "DriftInformation": {
        "StackResourceDriftStatus": "NOT_CHECKED"
      }
    },
    {
      "LogicalResourceId": "HelloWorldLambdaRole",
      "PhysicalResourceId":
        "chapter4-sam-HelloWorldLambdaRole-1KV86CI9RCXY0",
      "ResourceType": "AWS::IAM::Role",
      "LastUpdatedTimestamp": "2019-07-26T19:16:30.287Z",
      "ResourceStatus": "CREATE_COMPLETE",
      "DriftInformation": {
        "StackResourceDriftStatus": "NOT_CHECKED"
      }
    }
  ]
}

Zusätzlich zu den Lambda-Funktionen enthält SAM Ressourcentypen für DynamoDB-Tabellen (AWS::Serverless::SimpleTable) und API-Gateways (AWS::Serverless::Api). Diese Ressourcentypen sind auf beliebte Anwendungsfälle ausgerichtet und eignen sich möglicherweise nicht für alle Anwendungsarchitekturen. Da SAM jedoch eine Obermenge von CloudFormation ist, können wir in unseren SAM-Vorlagen auch ganz normale CloudFormation-Ressourcentypen verwenden. Das bedeutet, dass wir serverlose und "normale" AWS-Komponenten in unseren Architekturen mischen und anpassen können, um die Vorteile beider Ansätze und die idempotente CLI-Semantik des SAM-Befehls sam deploy zu nutzen. Beispiele für die Kombination von SAM- und CloudFormation-Ressourcen in einer Vorlage findest du in Kapitel 5.

Sicherheit

Sicherheit durchdringt jeden Aspekt von AWS. Wie du in Kapitel 2 gelernt hast, müssen wir uns von Anfang an mit der Sicherheitsebene von AWS, dem Identitäts- und Zugriffsmanagement (IAM), auseinandersetzen. Anstatt jedoch die Details zu beschönigen, indem wir einfach alles mit dem breitestmöglichen, unsichersten Satz von IAM-Berechtigungen ausführen, werden wir in diesem Abschnitt etwas tiefer eintauchen und erklären, wie der Zugriff auf die Lambda-Plattform durch IAM kontrolliert wird, wie sich das auf die Interaktionen unserer Funktionen mit anderen AWS-Ressourcen auswirkt und wie SAM die Entwicklung sicherer Anwendungen etwas einfacher macht.

Der Grundsatz der geringsten Privilegierung

Im Gegensatz zu in einer herkömmlichen monolithischen Anwendung könnte eine serverlose Anwendung potenziell Hunderte von einzelnen AWS-Komponenten haben, von denen jede ein anderes Verhalten zeigt und Zugriff auf verschiedene Informationen hat. Wenn wir einfach die größtmöglichen Sicherheitsberechtigungen anwenden würden, hätte jede Komponente Zugriff auf jede andere Komponente und jede Information in unserem AWS-Konto. Jede Lücke, die wir in einer Sicherheitsrichtlinie lassen, bietet die Möglichkeit, dass Informationen durchsickern, verloren gehen oder verändert werden oder dass das Verhalten unserer Anwendung verändert wird. Und wenn eine einzelne Komponente kompromittiert wird, ist auch das gesamte AWS-Konto (und alle anderen darin eingesetzten Anwendungen) gefährdet.

Wir können diesem Risiko begegnen, indem wir das Prinzip der "geringsten Rechte" auf unser Sicherheitsmodell anwenden. Kurz gesagt besagt dieses Prinzip, dass jede Anwendung und jede Komponente einer Anwendung den geringstmöglichen Zugriff haben sollte, den sie zur Erfüllung ihrer Funktion benötigt. Nehmen wir zum Beispiel eine Lambda-Funktion, die aus einer DynamoDB-Tabelle liest. Die weitestgehenden Berechtigungen würden es dieser Lambda-Funktion erlauben, jede andere Komponente und Information im AWS-Konto zu lesen, zu schreiben oder anderweitig mit ihr zu interagieren. Sie könnte aus S3-Buckets lesen, neue Lambda-Funktionen erstellen oder sogar EC2-Instanzen starten. Wenn der Lambda-Code einen Fehler oder eine Schwachstelle hätte (z. B. beim Parsen von Benutzereingaben), könnte sein Verhalten geändert werden, um diese Dinge zu tun, und er wäre nicht durch seine IAM-Rolle eingeschränkt.

Das Prinzip der geringsten Rechte, angewandt auf diese spezielle Lambda-Funktion, würde zu einer IAM-Rolle führen, die der Funktion nur den Zugriff auf den DynamoDB-Dienst erlaubt. Wenn wir noch einen Schritt weiter gehen, könnten wir der Funktion nur erlauben, Daten aus DynamoDB zu lesen und ihr die Möglichkeit nehmen, Daten zu schreiben oder Tabellen zu erstellen oder zu löschen. In diesem Fall können wir sogar noch weiter gehen und den Lesezugriff der Funktion auf die einzige DynamoDB-Tabelle beschränken, die sie benötigt. Auf die Spitze getrieben, können wir sogar einschränken, welche Elemente in der Tabelle die Funktion lesen kann, je nachdem, welcher Benutzer die Funktion überhaupt ausgeführt hat.

Durch die Anwendung des Prinzips der geringsten Rechte auf unsere Lambda-Funktion haben wir ihren Zugriff auf die Ressourcen beschränkt, die sie zur Erfüllung ihrer Aufgabe benötigt. Sollte die Lambda-Funktion kompromittiert oder gehackt werden, wäre sie aufgrund ihrer Sicherheitsrichtlinien immer noch darauf beschränkt, bestimmte Elemente aus einer einzigen DynamoDB-Tabelle zu lesen. Das Prinzip der geringsten Rechte ist jedoch nicht nur zur Verhinderung von Kompromittierungen geeignet, sondern auch ein wirksames Mittel, um den "Radius" von Fehlern in deinem Anwendungscode zu begrenzen.

Nehmen wir an, unsere Lambda-Funktion hat einen Fehler, der zum Beispiel den falschen Wert zum Löschen von Daten verwendet. In einem offenen Sicherheitsmodell könnte dieser Fehler dazu führen, dass die Lambda-Funktion die Daten des falschen Benutzers löscht! Da wir jedoch den "Radius" von Fehlern durch das Prinzip der geringsten Berechtigung für unsere Lambda-Funktion begrenzt haben, wird dieses spezielle Problem dazu führen, dass sie einfach nichts tut oder einen Fehler ausgibt.

Identitäts- und Zugangsmanagement

Wie im vorherigen Abschnitt erläutert, ist die effektive Anwendung des Prinzips der geringsten Rechte bei der Entwicklung einer serverlosen Anwendung noch wichtiger. IAM ist ein komplexer, vielschichtiger Dienst, und wir werden hier nicht annähernd alle Aspekte behandeln. Vielmehr werden wir in diesem Abschnitt IAM aus der Perspektive des Aufbaus von serverlosen Anwendungen betrachten. IAM kommt bei serverlosen Anwendungen am häufigsten in den Ausführungsrollen, in den mit diesen Rollen verbundenen Richtlinien und in den Richtlinien für bestimmte AWS-Ressourcen zum Tragen.

Rollen und Strategien

Eine IAM-Rolle ist eine Identität, die von einer AWS-Komponente (z. B. einer Lambda-Funktion) angenommen werden kann. Eine Rolle unterscheidet sich von einem IAM-Benutzer dadurch, dass eine Rolle von jedem (oder allem) angenommen werden kann, der sie benötigt, und dass eine Rolle keine langfristigen Zugangsdaten hat. In diesem Sinne können wir eine IAM-Rolle als eine annehmbare Identität mit einer Reihe von Rechten definieren.

Der Ausdruck annehmbare Identität könnte den Eindruck erwecken, dass jeder oder alles eine IAM-Rolle übernehmen kann. Wenn das der Fall wäre, würde die Verwendung von Rollen keinen wirklichen Nutzen bringen, da es keine Beschränkungen für die Übernahme einer Rolle gäbe und somit auch keine Beschränkungen für die Aktionen, die ein bestimmter Benutzer oder eine bestimmte Komponente durchführen könnte. Glücklicherweise können IAM-Rollen nicht von jedem übernommen werden. Bei der Erstellung einer Rolle muss festgelegt werden, wer (oder was) diese Rolle übernehmen kann. Wenn wir beispielsweise eine Rolle für eine Lambda-Funktion erstellen, müssen wir dem Lambda-Dienst (in diesem Fall der Datenebene) ausdrücklich die Erlaubnis erteilen, diese Rolle zu übernehmen, indem wir die folgende " Vertrauensbeziehung" festlegen :

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Diese Anweisung legt einen Effekt (Allow) fest, der auf eine Aktion (sts:Assume​Role) angewendet wird. Am wichtigsten ist jedoch die Angabe eines Principals, also der Identität, die die Rolle übernehmen darf. In diesem Fall erlauben wir der Datenebene des Lambda-Dienstes (lambda.amazonaws.com), diese Rolle zu übernehmen. Wenn wir versuchen würden, diese Rolle mit einem anderen Dienst wie EC2 oder ECS zu verwenden, würde es nicht funktionieren, es sei denn, wir ändern den Principal.

Nachdem wir nun festgelegt haben, wer die Rolle übernehmen kann, müssen wir die Berechtigungen hinzufügen. IAM-Rollen haben von Haus aus keine Berechtigungen, um auf Ressourcen zuzugreifen oder Aktionen auszuführen. Außerdem verweigert IAM standardmäßig die Berechtigung, es sei denn, diese Berechtigung ist in einer Richtlinie explizit erlaubt. Diese Berechtigungen sind in Richtlinien enthalten, die die Berechtigungen mit den folgenden Konstrukten festlegen:

  • Ein Effekt (wie Allow oder Deny)

  • Eine Reihe von Aktionen, die in der Regel einem bestimmten AWS-Service zugeordnet sind (z. B. logs:PutLogEvents)

  • Eine Gruppe von Ressourcen, in der Regel Amazon Resource Names (ARNs), die bestimmte AWS-Komponenten definieren. Verschiedene Services unterstützen unterschiedliche Spezifitätsgrade für Ressourcen. DynamoDB-Richtlinien können zum Beispiel bis auf die Ebene einer Tabelle angewendet werden.

Hier ist eine Beispielrichtlinie, die eine Reihe von Aktionen für den Dienst "Logs" (auch bekannt als CloudWatch Logs) zulässt und diese Aktionen nicht auf eine bestimmte Ressource "Logs" beschränkt:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    }
  ]
}

Wir haben bereits festgelegt, wer die Rolle übernehmen kann (die Datenebene des Lambda-Dienstes, wie durch den Principal Identifier lambda.amazonaws.com angegeben) und welche Rechte die Rolle hat. Diese Rolle wird jedoch erst verwendet, wenn sie an eine Lambda-Funktion angehängt ist, die wir explizit konfigurieren müssen. Das heißt, wir müssen dem Lambda-Dienst mitteilen, dass er diese Rolle verwenden soll, wenn er eine bestimmte Lambda-Funktion ausführt.

Lambda-Ressourcenpolitik

Als wäre die Welt der Sicherheit und des IAM nicht schon komplex genug, verwendet AWS gelegentlich auch IAM-Richtlinien, die auf Ressourcen (und nicht auf Identitäten) angewendet werden, um Aktionen und Zugriffe zu kontrollieren. Ressourcenrichtlinien kehren die Kontrolle im Vergleich zu einer identitätsbasierten IAM-Richtlinie um: Eine Ressourcenrichtlinie legt fest, was andere Principals mit der betreffenden Ressource tun dürfen. Dies ist insbesondere dann nützlich, wenn Principals in verschiedenen Konten Zugriff auf bestimmte Ressourcen (wie Lambda-Funktionen oder S3-Buckets) haben sollen.

Eine Lambda-Funktionsaufruf-Ressourcenrichtlinie besteht aus einer Reihe von Anweisungen, die jeweils einen Principal, eine Liste von Aktionen und eine Liste von Ressourcen angeben. Diese Richtlinien werden von der Lambda-Datenebene verwendet, um zu bestimmen, ob ein Aufrufer (z. B. ein Principal) eine Funktion erfolgreich aufrufen darf. Hier ist ein Beispiel für eine Lambda-Ressourcenrichtlinie (auch Funktionsrichtlinie genannt), die dem API-Gateway-Dienst den Aufruf einer bestimmten Funktion erlaubt:

{
  "Version": "2012-10-17",
  "Id": "default",
  "Statement": [
    {
      "Sid": "Stmt001",
      "Effect": "Allow",
      "Principal": {
        "Service": "apigateway.amazonaws.com"
      },
      "Action": "lambda:invokeFunction",
      "Resource":
        "arn:aws:lambda:us-east-1:555555555555:function:MyLambda",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:execute-api:us-east-1:
            555555555555:xxx/*/GET/locations"
        }
      }
    }
  ]
}

In dieser Richtlinie haben wir auch eine Bedingung hinzugefügt, die die zulässige Quelle der Aktion auf API-Gateway-Einsätze mit der ID "xxx" beschränkt, die den Pfad "/GET/locations" enthalten. Bedingungen sind dienstspezifisch und hängen davon ab, welche Informationen der Aufrufer zur Verfügung stellt.

Gehen wir das Szenario durch, in dem API Gateway eine Lambda-Funktion aufruft (siehe Abbildung 4-1).

images/ch04_image01.png
Abbildung 4-1. Überblick über die Sicherheit von Lambda und IAM
  1. Hatte der Anrufer die Erlaubnis, die API aufzurufen? In diesem Szenario gehen wir davon aus, dass die Antwort "Ja" lautet. Weitere Informationen findest du in der API-Gateway-Dokumentation.

  2. Die API Gateway-API versucht, die Lambda-Funktion aufzurufen. Erlaubt der Lambda-Dienst dies? Dies wird durch eine Ressourcenrichtlinie für den Aufruf der Lambda-Funktion gesteuert.

  3. Welche Berechtigungen sollte der Lambda-Funktionscode haben, wenn er ausgeführt wird? Das wird durch die Lambda-Ausführungsrolle gesteuert, und diese Rolle wird durch eine Vertrauensbeziehung mit dem Lambda-Dienst übernommen.

  4. Der Lambda-Code versucht, ein Element in eine DynamoDB-Tabelle zu stellen. Darf er das? Das wird durch eine Berechtigung gesteuert, die aus einer IAM-Richtlinie stammt, die der Lambda-Ausführungsrolle zugeordnet ist.

  5. DynamoDB verwendet keine Ressourcenrichtlinien, sodass Aufrufe von jedem (einschließlich Lambda-Funktionen) erlaubt sind, solange ihre Rolle (z. B. die Lambda-Ausführungsrolle) dies zulässt.

SAM IAM

Leider steht die Komplexität von IAM ( ) im Widerspruch zu einem schnellen Prototyping-Workflow. Bei einer serverlosen Anwendungsarchitektur ist es kein Wunder, dass viele Lambda-Ausführungsrollen völlig offene Richtlinien haben, die alle Formen des Zugriffs auf jede Ressource im AWS-Konto zulassen. Auch wenn es leicht ist, dem Prinzip der geringsten Privilegien zuzustimmen, das wertvolle Vorteile bietet, entscheiden sich viele ansonsten gewissenhafte Ingenieure angesichts der entmutigenden Aufgabe, es mit IAM für Dutzende oder Hunderte von AWS-Ressourcen zu implementieren, dafür, die Sicherheit zugunsten der Einfachheit aufzugeben.

Automatisch generierte Ausführungsrollen und Ressourcenrichtlinien

Glücklicherweise löst das Serverless Application Model dieses Problem auf verschiedene Weise. Im einfachsten Fall erstellt es automatisch die entsprechenden Lambda-Ausführungsrollen und Funktionsrichtlinien, die auf den verschiedenen Funktionen und Ereignisquellen basieren, die in der SAM-Infrastrukturvorlage konfiguriert sind. So werden die Berechtigungen für die Ausführung von Lambda-Funktionen und deren Aufruf durch andere AWS-Services sauber gehandhabt.

Wenn du zum Beispiel eine einzelne Lambda-Funktion ohne Trigger konfiguriert hast, generiert SAM automatisch eine Lambda-Ausführungsrolle für diese Funktion, die es ihr erlaubt, in CloudWatch Logs zu schreiben. Wenn du dann einen API Gateway-Trigger zu dieser Lambda-Funktion hinzugefügt hast, generiert SAM eine Lambda-Funktionsaufruf-Ressourcenrichtlinie, die es der Lambda-Funktion erlaubt, von der API Gateway-Plattform aufgerufen zu werden. Das wird unser Leben im nächsten Kapitel ein wenig einfacher machen!

Gemeinsame Vorlagen für Richtlinien

Wenn deine Lambda-Funktion im Code mit anderen AWS-Services interagieren muss (z. B. um in eine DynamoDB-Tabelle zu schreiben), benötigt sie wahrscheinlich zusätzliche Berechtigungen. Für diese Fälle bietet SAM eine Auswahl an gängigen IAM-Richtlinienvorlagen, mit denen wir Berechtigungen und Ressourcen präzise festlegen können. Diese Vorlagen werden dann während des SAM-Bereitstellungsprozesses erweitert und zu vollständig spezifizierten IAM-Richtlinienanweisungen. Hier haben wir eine DynamoDB-Tabelle zu unserer SAM-Vorlage hinzugefügt. Wir haben eine SAM-Richtlinienvorlage verwendet, um unserer Lambda-Funktion zu erlauben, Aktionen zum Erstellen, Lesen, Aktualisieren und Löschen (auch CRUD genannt) dieser DynamoDB-Tabelle durchzuführen.

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Chapter 4

Resources:

  HelloWorldLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.HelloWorld::handler
      CodeUri: target/lambda.zip
      Policies:
        — DynamoDBCrudPolicy:
          TableName: !Ref HelloWorldTable 1

  HelloWorldTable:
    Type: AWS::Serverless::SimpleTable
1

Hier haben wir die CloudFormation Intrinsic Function Refverwendet, mit der wir die logische ID einer Ressource (in diesem Fall HelloWorldTable) als Platzhalter für die physische ID der Ressource verwenden können (die etwa stack-name-HelloWorldTable-ABC123DEF lauten würde). Der CloudFormation-Dienst löst die logischen IDs in physische IDs auf, wenn ein Stack erstellt oder aktualisiert wird.

Zusammenfassung

In diesem Kapitel haben wir uns mit dem Erstellen und Verpacken von Lambda-Code und Abhängigkeiten auf reproduzierbare, deterministische Weise beschäftigt. Wir haben damit begonnen, AWS SAM zu nutzen, um unsere Infrastruktur (z. B. unsere Lambda-Funktion und später eine DynamoDB-Tabelle) als YAML-Code zu spezifizieren, (z. B. unsere Lambda-Funktion und später eine DynamoDB-Tabelle) als YAML-Code zu spezifizieren - darauf gehen wir in Kapitel 5 näher ein. Dann haben wir uns mit den zwei verschiedenen Arten von IAM-Konstrukten beschäftigt, die sich auf Lambda-Funktionen auswirken: Ausführungsrollen und Ressourcenrichtlinien. Die Verwendung von SAM anstelle von CloudFormation bedeutete schließlich, dass wir nicht viel zusätzlichen YAML-Code hinzufügen mussten, um das Prinzip der geringsten Privilegien auf die IAM-Rollen und -Richtlinien für unsere Lambda-Funktion anzuwenden.

Wir haben jetzt fast alle grundlegenden Bausteine, um mit Lambda und den dazugehörigen Tools komplette Anwendungen zu erstellen. In Kapitel 5 zeigen wir, wie man Lambda-Funktionen mit Ereignisquellen verknüpft und bauen dann zwei Beispielanwendungen.

Übungen

  1. die Lambda-Funktion in diesem Kapitel absichtlich falsch konfigurieren, indem du die Eigenschaft Handler auf book.HelloWorld::foo setzt. Was passiert, wenn die Funktion eingesetzt wird? Was passiert, wenn du die Funktion aufrufst?

  2. Lies das IAM-Referenzhandbuch, um zu erfahren, welche AWS-Services (und Aktionen) granulare IAM-Berechtigungen haben können.

  3. Wenn du eine zusätzliche Herausforderung suchst, ersetze AWS::Serverless::Function durch AWS::Lambda::Function in der Datei template.yaml. Welche weiteren Änderungen musst du vornehmen, damit CloudFormation deine Funktion bereitstellen kann? Wenn du nicht weiterkommst, kannst du dir die Post-Transform-Vorlage (für den ursprünglichen Stack) über die CloudFormation-Webkonsole ansehen.

Get Programmierung von AWS Lambda now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.