CI/CD für statische Websites mit AWS CDK
December 4, 2020Bereits 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.