Blog
dranbleiben!
Georg Dörgeloh

AWS CDK - Mono- oder Multi-Repo? Ein Vorschlag.

Bei der Frage "Mono- oder Multi-Repo?" scheiden sich meist die Geister. Das war schon schwer als man von Monolith auf Microservices umstieg. Und mit dem nächsten Schritt in die Serverless Umgebung wird es nicht leichter.

AWS hat mit CDK ein hilfreiches Tool zur Verfügung gestellt, mit welchem man Infrastructure as Code (IaC) auch im Serverless Bereich einfach umsetzen kann (wenn man es bei Serverless wirklich als IaC bezeichnen darf 😉 ). Doch nicht nur die Infrastruktur ist in diesem Code enthalten. CDK wird laut AWS Best Practices Guide für "Infra + App + Source + Config + Deploy" verwendet. Jetzt kann ich also meine App als Stack definieren, darin eine Lambda konfigurieren, den Code hinterlegen und alles mit der entsprechenden Konfiguration über eine CI auf verschiedenen Accounts deployen.

Aber wie kann ich dann noch das Prinzip "Teile und herrsche!" anweden? Wo teile ich und was muss ich bedenken? Hier ein paar Punkte, die bei der Überlegung berücksichtigt werden sollten:

  • Wo läuft die Pipeline (z.B. Gitlab Runner oder AWS CodePipeline)

    • Eine AWS CodePipeline läuft recht langsam (CodeBuild Projects benötigen häufig mehr als 20 Sekunden allein für das Provisioning)
    • Sie bietet keine Bedingungen für Pipeline-Jobs
    • Sie kann nicht ohne weiteres auf git-Tags reagieren
  • Limits für

    • die Menge der Ressourcen in einem CloudFormation Stack (500 Ressourcen)
    • die maximale Upload Größe eines CFN Templates

Für mich am entscheidendsten war aber die Definition der Abhängigkeiten. Wie oben erwähnt, ist bei CDK alles in einem code. Während man früher noch ein eigenes Repository hatte für die Infrastruktur bzw. Orchestrierung der Container, ist nun die Konfiguration der VPC eine direkte Abhängigkeit für die Lambda in die darin laufen muss. Wenn andere Lambdas in der selben VPC laufen, brauchen diese die Abhängigkeit auch.

const stack = new Stack()
const vpc = new Vpc(stack, 'MainVpc')
new NodejsFunction(stack, 'Lambda1', {
  vpc: vpc
})
new NodejsFunction(stack, 'Lambda2', {
  vpc: vpc
})

Natürlich kann ich die AWS SSM Parameter nutzen oder auch die CloudFormation Outputs, um die Abhängigkeiten zu definieren und trotzdem die Services auch im Infra-Code zu entkoppeln.

// File 1
const stack1 = new Stack();
const vpc = new ec2.Vpc(stack1, 'MainVpc');
new NodejsFunction(stack1, 'Lambda1', {
  vpc: vpc,
});

// SSM Parameter
new ssm.StringParameter(stack1, 'VpcId', {
  parameterName: '/APP/VPC/ID',
  stringValue: vpc.vpcId,
});
// or CFN Output
new cdk.CfnOutput(stack1, 'VpcId', {
  exportName: 'AppVpcId',
  value: vpc.vpcId
});

// ------------------------------------------
// File 2
const stack2 = new Stack();
// SSM Parameter
const vpcId = ssm.StringParameter.fromStringParameterAttributes(stack2, 'VpcId', {
  parameterName: '/APP/VPC/ID',
}).stringValue;
// or CFN Output
const vpcId = cdk.Fn.importValue('AppVpcId');

const vpc = ec2.Vpc.fromLookup(stack2, 'MainVpc', {
  vpcId: vpcId,
});
new NodejsFunction(stack2, 'Lambda2', {
  vpc: vpc,
});

Besonders bei mehreren geteilten Ressourcen wie z.B. entsprechenden Subnets, Gateways, Endpoints usw. wird das allerdings recht umständlich diese Parameter manuell zu definieren.

CDK hat daher die zweite Variante mit dem CFN Output schon integriert. Wenn man die Ressource einfach im anderen Stack wiederverwendet, sorgt CDK automatisch für den entsprechenden CFN Output und die Referenz auf diesen.

// File 1
const stack1 = new Stack();
export const vpc = new ec2.Vpc(stack1, 'MainVpc');
new NodejsFunction(stack1, 'Lambda1', {
  vpc: vpc,
});

// ------------------------------------------
// File 2
import { vpc } from './File 1';

const stack2 = new Stack();
new NodejsFunction(stack2, 'Lambda2', {
  vpc: vpc,
});

Aber bedeutet das jetzt, dass der gesamte Code wieder in ein Repository muss, oder sogar in ein Package? Nicht direkt!

CDK arbeitet auf der Basis von Constructs. Irgendwie ist alles ein Construct. Eine App, ein Stack, ein Bucket, eine Pipeline. Constructs beschreiben also eine Ressource oder auch eine Sammlung von Ressourcen. Aber letztlich sind Constructs Klassen, die auch in npm Packages gekapselt werden können. Meine Stacks stack1 und stack2 kann ich also unabhängig voneinander in eigenen npm-Packages definieren und füge sie in einem anderen Package zusammen, indem ich die Stacks als Dependencies definiere und das Attribut stack1.vpc z.B. als Parameter bei der initialisierung von stack2 nutze.

Wenn ich allerdings für jedes Package ein Repository erstelle, braucht auch jedes Package eine Pipeline und bis Änderungen durch diese Pipeline durch sind, sodass ich sie testen kann, dauert es eine Weile. Es wird also automatisch dazu kommen, dass Entwickler nicht die Disziplin bewahren und Code-Teile in wartbare und entkoppelte Pakete schnüren. Es läuft dann doch wieder auf eine Monoliten artige Code-Struktur hinaus. Neben der Disziplin ist also eine gute Struktur des Repositories nötig. Im oben erwähnten Best Practice Guide schlägt AWS daher die folgende Struktur vor:

code-organization

Ein Team entwickelt zunächst in einem Repository. Wenn es unterschiedliche Code Zyklen gibt, wird das Repository aufgesplittet. Jedes Repository enthält wiederum ein oder mehrere Packages. Jedes Package ist entweder eine Deploybare CDK-App, oder ein installierbares npm Package, welches ein (oder mehrere) Constructs zur Verfügung stellt. Diese können natürlich wieder aus anderen Constructs (z.B. einem Bucket und einer Lambda) zusammengesetzt sein.

Um dieses Prinzip einfach umsetzen zu können, habe ich einmal den Versuch unternommen für mich persönlich ein Template zu erstellen, welches ich für jede CDK App nutzen kann und bisher hat es sich bewährt.

Für eine saubere Umsetzung nutze ich gerne verschiedene Tools, die hier mit aufgeführt sind:

  • Typescript (tsconfig.json) für die Typisierung. Im Root-Ordner definiert. Packages erben davon und können Parameter überschreiben.
  • ESLint (.eslintrc.json, .eslintignore) für Coding-Regeln. Wird prinzipiel nur im Root-Ordner erstellt, wenn allerdings ein package andere Regeln benötigt, wie z.B. die React Rools of Hook können diese in einer eigenen Datei definiert werden, welche auch von der Root-Datei erbt.
  • Prettier (.prettierrc.json, .prettierignore) zur einheitlichen Formattierung, damit es keine unnötigen Merge-Konflikte gibt durch unterschiedliche IDEs.
  • Commitlint (.commitlintrc.json) um für einheitliche Commit-Messages zu sorgen, gemäß der Conventional Commits. Aus diesen kann ein Changelog generiert werden, oder bei der automatischen erhöhung der Versionsnummer der Release Typ (Major, Minor, Patch) ermittelt werden.

Das eigentliche Mono-Repository und die Abhängigkeiten wird mit npm workspaces verwaltet, während lerna (lerna.json) für die weitere Verwaltung, Ausführung von Scripten in allen Packages sowie die Versionierung und Publizierung verwendet wird.

Ein Repository sieht in der Struktur dann etwa so aus:

cdk-monorepo-template
├── packages
│   ├── app
│   │   ├── bin
│   │   │   └── app.ts
│   │   ├── lib
│   │   │   ├── app-stack.ts
│   │   │   └── index.ts
│   │   ├── test
│   │   ├── cdk.json
│   │   ├── jest.config.js
│   │   ├── package.json
│   │   ├── README.md
│   │   └── tsconfig.json
│   ├── ci
│   │   ├── bin
│   │   │   ├── ci.ts
│   │   │   └── config.res.ts
│   │   ├── lib
│   │   │   └── ci-stack.ts
│   │   ├── test
│   │   ├── cdk.json
│   │   ├── jest.config.js
│   │   ├── package.json
│   │   ├── README.md
│   │   └── tsconfig.json
│   ├── example-lambda
│   │   ├── lambda
│   │   │   ├── example.spec.ts
│   │   │   └── example.ts
│   │   ├── lib
│   │   │   └── index.ts
│   │   ├── test
│   │   ├── jest.config.js
│   │   ├── package.json
│   │   ├── README.md
│   │   └── tsconfig.json
│   └── example-webapp-deployment
│       ├── web (z.B. eine REACT-App)
│       │   ├── build
│       │   ├── public
│       │   ├── src
│       │   ├── .eslintrc.json
│       │   ├── package.json
│       │   ├── README.md
│       │   └── tsconfig.json
│       ├── lib
│       │   └── index.ts
│       ├── test
│       ├── jest.config.js
│       ├── package.json
│       ├── README.md
│       ├── tsconfig.json
├── .commitlintrc.json
├── .eslintignore
├── .eslintrc.json
├── .husky
├── .prettierignore
├── .prettierrc.json
├── lerna.json
├── LICENSE.md
├── package.json
├── package-lock.json
├── README.md
└── tsconfig.json

Wie man erkennen kann, hat das Repository vier Pakete auf der Hauptebene. Das fünfte Paket, der Code der React app muss vorab gebaut werden, damit der Build-Ordner durch ein BucketDeployment verwendet werden kann. Bucket Deployments oder Lambda Code können leider noch nicht einfach als npm Package refferenziert werden.

Die wichtigsten Packages sind das app Package und das ci Package. Das App Package ist der Bereich, wo, wie oben beschrieben, die verschiedenen Constructs zu einer App zusammengezogen werden. Das kann entweder in einem eigenen Stack geschehen, oder es können auch fertige Stacks in einer App zusammengebunden werden und nur noch durch Parameter angepasst werden. Dieses Package ist natürlich nur nötig, wenn das Repository eine fertige App beinhalten soll und nicht nur verwendbare Libraries.

Im CI Package nutze ich das "modern (and opinionated) aws-cdk-lib.pipelines module". ci-stack.ts definiert das CodeCommit Repository, einen einzigen CodeBuildStep, welcher das installieren, bauen, testen (Prettier, ESLint, Jest) und evtl. das Publishen der Packages übernimmt. Wenn das Repository eine App beinhaltet, wird diese je nach Anzahl der Stages (dev, test, prod) mit den entsprechenden Konfigurationen für den jeweiligen Account als Stage in die Pipeline eingebunden.

Auf diese Weise können Entwickler leicht neue Packages erstellen und in einem Repository verwalten und wenn der Bedarf da ist, ein Package in einem anderen Projekt verwendet werden soll, oder der Lifecycle des Packages sich aus sonst einem Grund ändert, kann dieses Package aus dem Repository genommen werden und in ein neues Repository mit dem selben Template verschoben werden.

Als Bonus hier noch ein paar Scripts, die bei mir in der Root-package.json liegen und gute Hilfe leisten. Die Geschichte dahinter vielleicht in einem anderen Blog-Post.

{
    "build": "lerna run build --stream",
    "increase-version": "lerna version",
    "lint": "eslint .",
    "list": "lerna list --all --long",
    "prepare": "husky install",
    "prettier:check": "prettier --check .",
    "prettier:write": "prettier --write .",
    "test": "CI=true lerna run --parallel test",
    "version": "prettier -w **/CHANGELOG.md",
    "watch": "lerna run --parallel watch -- -- --preserveWatchOutput"
}
none
Conventic Icon
Standort Bonn
Burgstraße 69
53177 Bonn
Deutschland
+49 228 76 37 69 70
Standort Braunschweig
Westbahnhof 11
38118 Braunschweig
Deutschland
+49 228 76 37 69 70
Wir sind Mitglied bei
Grouplink Icon
Hubwerk Icon
Newsletter abonnieren
Impressum, Haftungsausschluss und Datenschutzerklärung
© 2022 conventic GmbH · Alle Rechte vorbehalten.