CI/CD für statische Websites mit AWS CDK

Bereits in React SPA und server-side rendering (SSR) mit AWS Lambda, CloudFront und dem CDK haben wir gezeigt, wie mit dem AWS CDK Infrastruktur provisioniert werden kann. Ein weiterer wichtiger Baustein in der agilen Softwareentwicklung ist eine CI/CD Pipeline, mit der Änderungen kontinuierlich deployed werden.

Mit CDK Pipelines stellt AWS ein CDK Construct bereit, mit dem eine sich selbst aktualisierende Codepipeline angelegt werden kann. Darauf aufbauend lassen sich weitere CodePipeline Aktionen definieren, die z.B. eine statische Website bauen und deployen.

Setup

In diesem Beispiel nutzen wir Typescript als Programmiersprache der Wahl. Weiterhin gehen wir davon aus, dass ein AWS Account vorhanden ist und aus dem Terminal auf diesen zugegriffen werden kann und das eine Domain registriert ist, unter der die Seite später erreicht werden soll, für welche eine HostedZone in Route53 angelegt ist.

Das gesamte Setup trennt sich in Infrastrukturcode und Frontendcode. Starten wir mit dem Ordner, der beides enthalten wird: mkdir static-site && cd static-site.

Das statische Frontend

Zuerst legen wir eine statische Webseite als frontend an, im Beispiel mit create-react-app, welche dann mit der Pipeline gebaut und deployed werden soll.

yarn create-react-app frontend --template typescript

Das war es auch schon, da der uns hier wichtige Teil die Pipeline und Infrastruktur ist.

Pipeline setup mit cdk-pipelines

Als nächstes fügen wir das AWS CDK inklusive CDK Pipelines hinzu. Dafür legen wir einen eigenen unterordner an, in dem alle CDK spezifischen Dinge liegen werden und setzen das CDK auf:

mkdir infrastructure && cd infrastructure

npx cdk init --language=typescript
yarn

Um CDK-Pipelines nutzen zu können, muss in infrastructure/cdk.json unter dem punkt context noch der Wert "@aws-cdk/core:newStyleStackSynthesis": true eingetragen werden. Anschließend können wir unseren AWS Account zur Nutzung mit dem CDK initialisieren: yarn cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess.

Infrastruktur Definieren

Jetzt können wir unsere Infrastruktur definieren, wobei die Pipeline, die die Infrastruktur provisionieren wird, selbst ein Teil dieser Infrastruktur ist. Zuerst fügen wir schon einmal alle später genutzten CDK Packete in infrastructure/ hinzu:

yarn add @aws-cdk/pipelines @aws-cdk/aws-codepipeline @aws-cdk/aws-codepipeline-actions @aws-cdk/aws-codebuild \
  @aws-cdk/aws-s3 @aws-cdk/aws-cloudfront @aws-cdk/aws-cloudfront-origins \
  @aws-cdk/aws-certificatemanager @aws-cdk/aws-route53 @aws-cdk/aws-route53-targets

Anschließend erstellen wir infrastructure/lib/stacks/pipeline.ts mit dem Minimalsetup für eine Pipeline:

import * as Codepipeline from "@aws-cdk/aws-codepipeline";
import * as CodepipelineActions from "@aws-cdk/aws-codepipeline-actions";
import { Construct, SecretValue, Stack, StackProps } from "@aws-cdk/core";
import { CdkPipeline, SimpleSynthAction } from "@aws-cdk/pipelines";

/**
 * The stack that defines the pipeline
 */
export class Pipeline extends Stack {
  // Die domain, unter der die Seite erreichbar sein soll, und die per Route53 verwaltet werden können muss.
  readonly domainName = "meine.statische.site";
  readonly hostedZoneId = "Route53HostedZoneId";

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const sourceArtifact = new Codepipeline.Artifact();
    const buildArtifact = new Codepipeline.Artifact();
    const cloudAssemblyArtifact = new Codepipeline.Artifact();

    const pipeline = this.buildCDKPipeline(
      cloudAssemblyArtifact,
      sourceArtifact
    );

    // Weitere Dinge werden hier zur Pipeline hinzugefügt
  }

  private buildCDKPipeline(
    cloudAssemblyArtifact: Codepipeline.Artifact,
    sourceArtifact: Codepipeline.Artifact
  ) {
    return new CdkPipeline(this, "Pipeline", {
      // The pipeline name
      pipelineName: "StaticWebsitePipeline",
      cloudAssemblyArtifact,

      // Where the source can be found
      sourceAction: new CodepipelineActions.GitHubSourceAction({
        actionName: "GitHub",
        output: sourceArtifact,
        oauthToken: SecretValue.secretsManager("github-token"),
        owner: "superluminar-io",
        repo: "static-site",
        branch: "main",
      }),

      synthAction: SimpleSynthAction.standardYarnSynth({
        sourceArtifact,
        cloudAssemblyArtifact,
      }),
    });
  }
}

Hiermit haben wir nun eine CodePipeline angelegt, die sich selbst aktualisieren kann. Damit diese funktionieren kann, nutzt die sourceAction ein oauthToken, welches von Hand im AWS SecretsManager unter dem Schlüssel github-token abgelegt werden muss. Eine Anleitung zum Anlegen des Tokens ist am Ende dieses Posts zu finden. Weiterhin sind die beiden Attribute domainName und hostedZoneId entsprechend anzupassen. Diese Pipeline erweitern wir um die Schritte die nötig sind, um unsere statische Seite zu bauen und zu deployen.

Dafür fügen wir zuerst eine neue Stage an der im Code markierten Stelle zur Pipeline hinzu und dieser Stage zwei entsprechende Actions:

// ...
import * as CodeBuild from "@aws-cdk/aws-codebuild";
import { Bucket } from "@aws-cdk/aws-s3";

export class Pipeline extends Stack {
  // Die domain, unter der die Seite erreichbar sein soll, und die per Route53 verwaltet werden können muss.
  readonly domainName = "meine.statische.site";
  readonly hostedZoneId = "Route53HostedZoneId";

  constructor(scope: Construct, id: string, props?: StackProps) {
    // Code von vorher

    const websiteBuildAndDeployStage = pipeline.addStage(
      "WebsiteBuildAndDeployStage"
    );

    websiteBuildAndDeployStage.addActions(
      this.buildAction(
        sourceArtifact,
        buildArtifact,
        websiteBuildAndDeployStage.nextSequentialRunOrder()
      ),
      this.deployAction(
        buildArtifact,
        this.domainName,
        websiteBuildAndDeployStage.nextSequentialRunOrder()
      )
    );
  }

  private buildAction(
    sourceArtifact: Codepipeline.Artifact,
    buildArtifact: Codepipeline.Artifact,
    runOrder: number
  ): CodepipelineActions.CodeBuildAction {
    return new CodepipelineActions.CodeBuildAction({
      input: sourceArtifact,
      outputs: [buildArtifact],
      runOrder: runOrder,
      actionName: "Build",
      project: new CodeBuild.PipelineProject(this, "StaticSiteBuildProject", {
        projectName: "StaticSiteBuildProject",
        buildSpec: CodeBuild.BuildSpec.fromSourceFilename(
          "frontend/buildspec.yml"
        ),
        environment: {
          buildImage: CodeBuild.LinuxBuildImage.STANDARD_4_0,
        },
      }),
    });
  }

  private deployAction(
    input: Codepipeline.Artifact,
    bucketName: string,
    runOrder: number
  ): CodepipelineActions.S3DeployAction {
    const bucket = Bucket.fromBucketName(this, "WebsiteBucket", bucketName);

    return new CodepipelineActions.S3DeployAction({
      actionName: "Deploy",
      runOrder: runOrder,
      input: input,
      bucket: bucket,
    });
  }
}

Die BuildAction erwartet eine buildspec.yml Datei im frontend Ordner, mit welcher CodeBuild unsere statische Seite bauen soll. Diese sieht in einer minimalform wie folgt aus:

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 12
    commands:
      - cd frontend
      - yarn install
  build:
    commands:
      - yarn build

artifacts:
  base-directory: frontend/build
  files:
    - "**/*"

cache:
  paths:
    - "frontend/node_modules/**/*"

Hiermit ist die Pipeline fertig, allerdings fehlt noch ein entscheidender Teil, damit das Deplyment funktionieren kann.

Definition der Webseiten Infrastruktur mit dem CDK

Damit die Pipeline ein Ziel für das Deployment hat, und damit die Webseite auch sicher erreichbar ist, fehlen noch einige Infrastrukturkomponenten. Diese legen wir auch per CDK in unserer Pipeline an. Dies geschieht über einen eigenen Stack. Zuerst legen wir also diesen in infrastructure/lib/stacks/frontend.ts an, zuerst nur mit dem Bucket in den die Seite deployed wird:

import { Stack, Construct, StackProps } from "@aws-cdk/core";
import { Bucket, BlockPublicAccess } from "@aws-cdk/aws-s3";

export interface FrontendStackProps extends StackProps {
  domainName: string;
  hostedZoneId: string;
}

export class FrontendStack extends Stack {
  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);

    const bucket = new Bucket(this, "FrontendBucket", {
      bucketName: props.domainName,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
    });

    // Hier kommt noch mehr.
  }
}

Damit die Seite per HTTPS unter einer eigenem Domain erreicht werden kann, muss eine CloudFront Distribution angelet werden, die den Inhalt aus diesem Bucket ausliefert. Daher ist der Bucket auch auf BlockPublicAccess.BLOCK_ALL gesetzt: Er selber ist nicht direkt erreichbar, alle Zugriffe erfolgen über CloudFront. Wir erweitern also infrastructure/lib/stacks/frontend.ts um eine CloudFront Distribution, eine Route53 Zone samt Record, welcher auf die Distribution zeigt und ein Zertifikat für die Domain:

// Importe von vorher
import { Distribution, ViewerProtocolPolicy } from "@aws-cdk/aws-cloudfront";
import { S3Origin } from "@aws-cdk/aws-cloudfront-origins";
import { DnsValidatedCertificate } from "@aws-cdk/aws-certificatemanager";
import * as Route53 from "@aws-cdk/aws-route53";
import { CloudFrontTarget } from "@aws-cdk/aws-route53-targets";

export class FrontendStack extends Stack {
  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    // Code von vorher

    const zone = Route53.PublicHostedZone.fromHostedZoneAttributes(
      this,
      "HostedZone",
      {
        hostedZoneId: props.hostedZoneId,
        zoneName: props.domainName,
      }
    );

    const certificate = new DnsValidatedCertificate(this, "FrontendCert", {
      domainName: props.domainName,
      region: "us-east-1",
      hostedZone: zone,
    });

    const distribution = new Distribution(this, "FrontendDistribution", {
      defaultBehavior: {
        origin: new S3Origin(bucket),
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      domainNames: [props.domainName],
      certificate: certificate,
      defaultRootObject: "index.html",
    });

    new Route53.ARecord(this, "AliasRecord", {
      zone,
      target: Route53.RecordTarget.fromAlias(
        new CloudFrontTarget(distribution)
      ),
    });
  }
}

Das DnsValidatedCertificate ist ein Konstrukt, welches dieses Setup besonders einfach macht: Normalerweise müssen von CloudFront genutzte Zertifikate in us-east-1 deployed werden und ein CloudFormation Stack kann nur Resourcen in eine einzelne Region deployen. Da unsere sonstige Infrastruktur eventuell aber in einer anderen Region deployed werden soll, bräuchte man 2 Stacks: einen für das Zertifikat und einen für die restliche Infrastruktur. Mit dem DnsValidatedCertificate wird einem dieses komplexere Setup abgenommen, da es intern mithilfe einer Lambda direkt in die richtige Region deployed wird.

Zusammenfügen der Bausteine

Nun müssen wir den FrontendStack noch in unsere Pipeline bringen. Dazu nutzt CDK-Pipelines sogenannte ApplicationStages, die mehrere Stacks enthalten können. Eine ApplicationStage kann mehrfach genutzt werden, zum Beispiel um seine Infrastruktur ausfallsicher in mehrere Regionen zu deployen oder um einen Dev/Test/Prod Workflow über mehrere Accounts abzubilden.

Unsere ApplicationStage beinhaltet nur unseren eben definierten Stack und wird auch nur einmal der Pipeline hinzugefügt. Wir definieren sie in infrastructure/lib/stages/website.ts:

import { Construct, Stage, StageProps } from "@aws-cdk/core";
import { FrontendStack } from "../stacks/frontend";

export interface WebsiteStageProps extends StageProps {
  domainName: string;
  hostedZoneId: string;
}

export class WebsiteStage extends Stage {
  constructor(scope: Construct, id: string, props: WebsiteStageProps) {
    super(scope, id, props);

    new FrontendStack(this, "FrontendStack", props);
  }
}

Hätten wir noch weitere Stacks (zum Beispiel wenn wir für das Zertifikat einen eigenen Stack bräuchten), kőnnten wir sie hier direkt anfügen.

Diese Stage fügen wir unserem PipelineStack in infrastructure/lib/pipeline.ts hinzu, jedoch vor der buildAndDeployStage von vorher, schließlich nutzen diese den Bucket als Deplyment Ziel, sprich er muss vorerst schon erstellt worden sein.

// Importe von vorher
import { WebsiteStage } from "../stages/website";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    // Code zum erstellen der Pipeline

    const websiteInfrastructureStage = new WebsiteStage(
      this,
      "WebsiteInfrastructureStage",
      {
        domainName: this.domainName,
        ...props,
      }
    );

    pipeline.addApplicationStage(websiteInfrastructureStage);

    // Code zum anlegen der WebsiteBuildAndDeployStage
  }

  // Funktionen von vorher
}

Ausserdem müssen wir noch infrastructure/bin/infrastructure anpassen, da wir unsere Resourcen anders genannt haben, als es der vom CDK erstelle Code erwatet:

#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "@aws-cdk/core";
import { PipelineStack } from "../lib/stacks/pipeline";

const app = new cdk.App();
new PipelineStack(app, "InfrastructureStack");

Release it

Das war es quasi schon, nun können wir unseren Code deployen. Dafür müssen wir ihn zuerst (wenn nicht schon geschehen) in ein Github Repo bringen, für welches wir dann noch ein Github Access Token mit repo und admin:repo_hook Berechtigungen anlegen und es im SecretsManager in unserer Region unter github-token speichern. Für eine Anleitung, wie man so ein Token anlegt, siehe hier.

Initial müssen wir den Stack jetzt einmal per Hand in den Account provisionieren, ab dann aktualisiert er sich selbst. Hierfür muss sichergestellt werden, dass in dem aktuellen Terminal der gewünschte AWS Account in der gewünschten Region konfiguriert ist. Dann kann der Stack mit yarn cdk deploy deployed werden.

Das gesamte Setup kann auch auf https://github.com/superluminar-io/static-site eingesehen werden, die Seite ist auf https://static-site.alst.superluminar.io erreichbar.

Alexander ist Cloud Consultant bei superluminar, AWS Certified DevOps Engineer – Professional, AWS Certified Solutions Architect – Associate und AWS Certified Security – Specialty, und weiterhin Google Cloud Certified Professional Cloud Architect. Hier schreibt er über AWS spezifische Themen, auf seinem privaten Blog unsubstantiated.blog über GCP, Kubernetes, Terraform und andere Technologien. Auf Twitter ist er unter @m1raD zu finden.