/ continuous deployment

Mein erster Task - Continuous Deployment auf AWS

In diesem Blogpost soll es um die Erkenntnisse und Erfahrungen aus meinem ersten Task bei Europace in der PKU (Privatkredit Unit) gehen. Ziel ist es mit diesem Blogpost natürlich, mir eine Erinnerungsstütze zu sein ;), aber auch vielleicht zukünftigen neuen Kollegen aus der Perspektive des "Neuen" Information bereitzustellen, die dem einen oder anderen nützlich sein können.

Als der "Neue" ist es am Anfang nicht immer einfach, aber in der PKU hat man für die ersten 6 Monate einen Onboarder. Die Idee dabei ist für organisatorische und technische Fragen jemanden zur Seite zu haben. Um einen vernünftigen Start in der Unit zu haben, haben wir uns darauf geeinigt, dass ich in der Story starte, an der auch mein Onboarder gerade arbeitet. So hatte ich die Chance sehr viele Fragen direkt beantwortet zu bekommen und sehr nah an echter Story-Arbeit zu sein.
Bei der Story ging es darum Events, die in KreditSmart produziert werden, in ein Datawarehouse in AWS zu exportieren. Das dNa-Team (data and analysis) betreibt seit ca. 2 Jahren ein auf der RedShift Datenbank basierendes Datawarehouse für ganz Europace. Unsere Architektur sieht im groben folgendermaßen aus. Bestimmte Events die in KreditSmart produziert werden, werden auf eine SNS geschrieben. Jedes Event auf der SNS führt dazu, dass ein Lambda mit der entsprechenden Payload aufgerufen wird. Das Lambda schreibt als Erstes die Payload 1:1 nach S3 (wir halten uns die Daten im Rohformat vor) und transformiert die Daten, die dann in eine RedShift-Staging Tabelle geschrieben werden. Eine Cloudwatch Event-Rule (crontab Konfiguration) ruft dann ein weiteres Lambda auf, diese gruppiert die Einträge entsprechend auf der Staging-Tabelle und schreibt diese in die endgültige RedShift Reporting-Tabelle. Das folgende Schaubild
visualisiert das nochmal.
ad2a8523-8c07-4ed2-9846-45bf71b86653--1-

Bevor ich mich an den Deployment-Task gemacht habe, hatte ich die Möglichkeit ein wenig an den Lambdas mitzuarbeiten, so konnte ich mich langsam an AWS herantasten. Zwar hatte ich bis dato Erfahrung mit Google Cloud sammeln können, aber die Amazon Webservices waren für mich komplett neu. Bevor es an den Deployment Task geht, will ich in ein paar Worten kurz die Services beschreiben, die für uns relevant waren.

  • SNS (Simple Notification Service): ist ein einfacher Messaging Service, der auf dem Publish-Subscribe Protokoll basiert
  • S3: ist ein hochverfügbarer und verteilter Data-Store. Er ist preisgünstig und eignet sich für größere Datenmengen
  • Lambda: ist ein Service, der als Antwort auf bestimmte Events Code ausführt
  • Cloudwatch: ist ein Monitoring Service für andere AWS Ressourcen

Lokale Entwicklungsumgebung

Die Entwicklung von Lambdas, die von SNS Einträgen getriggert werden, ist auf der lokalen Entwicklungsumgebung nicht ganz einfach. Daher haben wir die Lambdas gegen AWS entwickelt, sprich wir mussten zum Testen, ob die Verarbeitungskette sauber funktioniert, immer auf AWS deployen. Damit wir unsere Lambdas überhaupt deployen konnten, mussten aber alle involvierten AWS Ressourcen entsprechend konfiguriert sein. Die einfachste Methode "ich klicke mir die Umgebung in der AWS Konsole zusammen und uploade meine Lambdas" ist natürlich nur zum Ausprobieren gut, denn dieses Vorgehen entspricht genau dem Snowflake Server Antipattern im klassischen Hosting und steht im starken Widerspruch zum "Infrastructure as Code" Ansatz. Um nicht in so ein Problem zu laufen, haben wir das nette Tool namens serverless eingesetzt. dNa hatte damit schon Erfahrung und setzt es massiv in ihrer Entwicklung ein. Mit serverless ist es relativ einfach seine Umgebung in AWS deklarativ zu beschreiben, der Dreh- und Angelpunkt dabei ist die serverless.yml Datei. Die serverless.yml ist ein gutes Beispiel für Infrastructure as Code, darüber lassen sich AWS Ressourcen deklarieren und entsprechend auch der Lambda Quellcode referenzieren. Wir haben uns bei den Lambdas für TypeScript entschieden, aber auch Sprachen wie Python oder einfach plain JavaScript sind unter anderem möglich. Unsere serverless.yml ist im gleichen Quellcode-Projekt wie die Lambdas, wenn wir also aus unserer lokalen Entwicklungsumgebung deployen wollen führen wir einfach serverless deploy im Root-Verzeichnis unseres Quellcode-Projektes aus und überlassen die ganze Magie serverless.
So wie es auch bei allen anderen Projekten in Europace Standard ist, musste auch bei diesem Projekt Continuous Deployment umgesetzt werden. Wir wollten, dass bei jedem Push auf das Git-Repository die Unit-Tests ausgeführt, alle neu deklarierten AWS-Ressourcen erstellt und entsprechend deployed werden.

Die schrittweise Erarbeitung

Da die PKU bei all ihren Deployments auf Ansible setzt, habe ich als Erstes auch nach einer Möglichkeit gesucht, wie ich unseren AWS "Stack" mit Ansible deployen kann. Und tatsächlich bietet Ansible verschiedene Möglichkeiten AWS Ressourcen zu verwalten. In der entsprechenden Ansible – Module Dokumentation sind etliche AWS Module gelistet. Ansible bietet genauso wie serverless die Möglichkeit AWS Ressourcen zu managen. Auf einem AWS Test Account habe ich ein paar Playbooks ausprobiert und es war mir relativ schnell gelungen eine SNS anzulegen. Auch das Anlegen einer S3 war kein Problem. Aber die Konfiguration, dass die entsprechenden Ressourcen zu einem Stack gehören hat sich als nicht so trivial dargestellt. Das "subscriben" auf eine SNS beispielsweise war nicht einfach zu bewerkstelligen. Nach etwas lesen und ausprobieren kam ich auf das Module Cloudformation-Stack, dieses Module bietet sich an Ressourcen als Einheit zu verwalten.

Was ist Cloudformation?

Cloudformation ist die AWS Lösung für "Infrastructure as Code". Es bietet die Möglichkeit zusammenhängende Ressourcen als solche zu deployen und zu aktualisieren. Ein Cloudformation-Stack ist genau das, was wir haben wollten. Was Cloudformation auch bietet, ist z.B.: Rollback – wenn beim Deployment eine Ressource erstellt wird und eine andere aus irgendwelchen Gründen nicht, dann stellt Cloudformation den Stand vor dem Deployment wieder her. Unter anderem konvertiert auch Serverless eine serverless.yml zu einem einem Cloudformation-Stack.

Am Anfang hat sich dieser Ansatz sehr charmant angefühlt, ich konnte Ansible nutzen und trotzdem unseren Stack komplett abbilden – zumindest theoretisch ;). Konkret sah dieser Ansatz wie folgt aus, ich hatte ein sehr kleines Playbook, welches auf ein Cloudformation-Template referenziert hat. Dafür war dieses Cloudformation-Template etwas komplexer und länger, aber es war alles noch im akzeptablen Bereich. Ich war echt glücklich mit dieser Version bis ich es in TeamCity eingebaut hatte und an die Grenzen dieses Ansatzes gestoßen bin (mit meinen ein paar Tagen AWS-Erfahrung).
Bei den Lambdas und dem entsprechenden Lambda-Quellcode muss man grob verstehen wie der Quellcode gespeichert wird – kurz: man erstellt ein ZIP-Archive mit allen benötigten Libraries, entsprechendem Quellcode und lädt es nach S3. Aus dem Lambda referenziert man dann auf dieses Bucket und gibt die Lambda-Funktion an.
Als ich in TeamCity dann den entsprechenden Git VCS Trigger angelegt hatte, sollte eigentlich nichts mehr Continuous-Deployment im Wege stehen. Nach einem Push auf das Repository und dem erfolgreichen Durchlaufen des Build-Jobs in TeamCity war meine Quellcode-Änderung leider nicht aktiv. Nach langem Suchen hat sich herausgestellt, dass das reine updaten des S3-Buckets nicht ausreicht. Man muss ein weiteres Lambda haben, was das Update auf S3 mitbekommt und das eigentliche Lambda zum "reload animiert" (siehe Blogeintrag). Nachdem ich dann dieses "extra"-Lambda angelegt hatte – man ahnt es – hat leider immer noch etwas gefehlt. Oben hatte ich ja schon erwähnt, dass wir TypeScript nutzen. Nun es ist so, dass das Lambda mit TypeScript direkt nicht viel anfangen kann, es erwartet eigentlich plain JavaScript, wenn man als Runtime Node6.10 angibt. Serverless, was wir lokal einsetzen, kompiliert TypeScript zu plain JavaScript, daher war mir dieser Sachverhalt bis dahin gar nicht klar gewesen. In den Build-Job musste noch ein weiterer Schritt eingebaut werden, der entsprechend den TypeScript Quellcode kompiliert. Die Suche nach diesen Fehlern, bzw. das Untersuchen dieser Probleme hat unheimlich viel Zeit gekostet. Das Ansible Playbook und das Cloudformation Template sind immer größer geworden, alles in Allem hat es sich einfach nicht gut angefühlt, da immer wieder neue Probleme auftauchten - mal größer mal kleiner.
Ich hatte diesen Unmut in unserem AWS-Story Slack-Channel gepostet und habe unter anderem als Antwort "vielleicht sollten wir aus Ansible heraus Serverless aufrufen" als Kommentar bekommen. Bis dahin wollte ich eigentlich, das was wir lokal mit serverless machen – auf TeamCity mit Ansible machen. Der Grund warum ich mich für Ansible entschieden hatte, war zum einen, dass wir als PKU alle Deployments in unserem RZ mit Ansible steuern. Der andere Grund war, das kann ich nach mehreren Wochen reflektieren nun sagen, dass ich serverless innerlich am Anfang etwas abgelehnt hatte. Ich hatte bis dahin NodeJS nur als Server-Teil von Webanwendung kennengelernt, aber ein Tool was ich für meine lokale Entwicklungsumgebung brauche, welches NodeJS erfordert (nvm + npm) war einfach ungewohnt. Auf den Kommentar hin ging mir folgender Gedanke durch den Kopf "serverless funktioniert lokal eigentlich sehr gut und es gibt für Ansible nichts natürlicheres, als ein anderes Programm zu starten". Ich habe für den ersten Test (lokal auf einen AWS Test-Account) deutlich weniger Zeit gebraucht als mit den anderen Varianten und es hat auf Anhieb alles funktioniert. Was nicht verwunderlich war, denn das serverless.yml war ja schon längst fertig, wir haben es während der lokalen Entwicklungsphase komplett fertiggestellt.

Serverless aus Ansible heraus

Die Variante, die nun schlussendlich umgesetzt wurde, ist die zuletzt umrissene "Serverless aus Ansible heraus". Im Folgenden werde ich im Detail alle Schritte beschreiben, die dafür gemacht wurden. Dies soll alle PKU Kollegen dazu befähigen, ohne große Vorkenntnisse in der Zukunft sich in die Deployment Pipeline einzuarbeiten. Und für alle anderen Leser ein konkreter Vorschlag zum Thema "Deployment auf AWS" sein.

Docker Image

Um nicht alle abhängigen Tools auf dem BuildServer nativ installieren zu müssen ist es gängige Praxis, dass die Builds in Docker Containern ausgeführt werden. Wir hatten schon ein Ansible Image das wir auf Docker Hub pflegen, dieses habe ich um serverless und boto3 erweitert – https://hub.docker.com/r/hypoport/docker-ansible-aws/~/dockerfile/. Für dieses Image ist "Automated Build" eingerichtet, das bedeutet bei einem Push auf das Repository https://github.com/hypoport/docker-ansible-aws wird automatisch ein neues Image erstellt.

Vault

Bevor es an das finale Playbook geht, will ich ganz kurz Vault vorstellen. Vault speichert sicher sensible Daten wie Passwörter und ermöglicht es zur Laufzeit bzw. zum Deployment Credentials beizusteuern. Für das AWS Reporting haben wir vier sensible Einträge, die wir in Vault speichern. Zum einen sind das der AWS-Account ACCESS-KEY und der entsprechende SECRET-KEY und zum anderen der Datenbank-User und das Datenbank-Passwort für die RedShift. Um aus Ansible heraus auf Vault zuzugreifen, setzen wir auf den Lookup-Plugin Mechanismus von Ansible. Die entsprechende Implementierung des Vault Lookup-Plugins wurde von mir 1:1 vom bisherigen RZ-Deployment Projekt übernommen. Das Auslesen des AWS-ACCESS-KEYS aus Ansible heraus sieht folgendermaßen aus: '{{ lookup('vault', 'secret/aws-reporting/dev token=' + vault_token).aws_access_key_id }}'.

Ansible Playbook

Das finale Ansible Playbook ist unten abgebildet.

---
- hosts: localhost
  connection: local
  vars:
    aws_region: 'eu-central-1'
    s3_bucket: 'xxx.xxx.xxx'
    aws_access_key: "{{ lookup('vault', 'secret/aws-reporting/{{ service_flavor }} token=' + vault_token).aws_access_key_id }}"
    aws_secret_key: "{{ lookup('vault', 'secret/aws-reporting/{{ service_flavor }} token=' + vault_token).aws_secret_access_key }}"
    db_user : "{{ lookup('vault', 'secret/aws-reporting/{{ service_flavor }} token=' + vault_token).db_user }}"
    db_password : "{{ lookup('vault', 'secret/aws-reporting/{{ service_flavor }} token=' + vault_token).db_password }}"
    service_flavor: '{{ service_flavor }}'
    db_host: 'xxx.xxx.xxx.xxx'
    db_port: xxx
    db_database: 'xxx_test'

  tasks:
    - name: install node modules
      command: npm install --production

    - name: create s3 bucket
      aws_s3:
        aws_access_key: '{{ aws_access_key }}'
        aws_secret_key: '{{ aws_secret_key }}'
        mode: create
        region: '{{ aws_region }}'
        bucket: '{{ s3_bucket }}'

    - name: create profile for serverless
      command: serverless config credentials --provider aws --key {{ aws_access_key }} --secret {{ aws_secret_key }} --profile pku_serverless
      no_log: true

    - name: setting env DB_HOST DB_PORT AND DB_DATABASE if service_flavor is prod
      set_fact:
        db_host: 'xxx.xxx.xxx.xxx'
        db_port: xxx
        db_database: 'xxx_prod'
      when: service_flavor == 'prod'

    - name: deploy with serverless
      command: serverless deploy --stage {{ service_flavor }} --db_host={{ db_host }} --db_database={{ db_database }} --db_port={{ db_port }} --db_user={{ db_user }} --db_password={{ db_password }}
- hosts

Für Ansible ist es etwas ungewöhnlich hier 'localhost' stehen zu haben, aber an sich verbinden wir uns auf AWS natürlich nicht mit SSH und führen dort keine Kommandos aus, das was Ansible sonst klassischerweise macht. localhost ist in unserem Fall der Docker Container auf dem Ansible und serverless installiert sind.

- vars

In diesem Bereich werden Variablen deklariert und initialisiert, man sieht hier auch die lookups auf Vault.

- task: install node modules

In TeamCity besteht die AWS-Reporting-Deployment Pipeline aus zwei Build-Konfigurationen. Zuerst wird Quellcode "gepulled" und die Unittests ausgeführt, dabei wird ein "shared-Artifakt" erstellt. Konkret handelt es sich dabei um den Quellcode, package.json und die serverless.yml als ZIP-Archive. Das ZIP-Archive wird der zweiten Build-Konfiguration weitervererbt. Die zweite Build-Konfiguration besteht aus zwei Einzelschritten, als Erstes wird das "shared-Artifakt" entpackt und dann wird das Ansible Playbook ausgeführt.
Der Task in diesem Playbook "install node modules" - installiert die NodeJS Abhängigkeiten für unser Lambda. Diese werden dann später von serverless zusammen mit dem kompilierten TypeScript als ZIP-Archive nach S3 hochgeladen.

- task: create s3 bucket

Hier wird das Ansible-Module aws_s3 eingesetzt, um das S3 Bucket zu erstellen. Warum diese AWS-Ressource außerhalb von serverless erstellt wird, hat folgenden Grund: Serverless erstellt alle Ressourcen in einem Cloudformation-Stack. Eine besondere Eigenschaft von einem Cloudformation-Stack ist, dass wenn er aus irgendwelchen Gründen mal gelöscht wird, werden auch alle zu diesem Stack gehörigen Ressourcen gelöscht. Da wir aber in diesem S3-Bucket außer dem Quellcode-ZIP auch noch all unsere Rohdaten speichern, wäre es sehr unglücklich wenn all diese Daten verloren gehen, weil wir uns vielleicht irgendwann in der Zukunft dafür entscheiden den Stack zu löschen.

- task: create profile for serverless

Serverless braucht, um ausgeführt werden zu können, ein lokal installiertes serverless Profil. Natürlich gibt es auch andere Möglichkeiten serverless auszuführen, aber die Variante mit lokal installiertem Profil entspricht der Methode wie wir es auch in unserer lokalen Entwicklungsumgebung handhaben.

- task: setting env DB_HOST DB_PORT AND DB_DATABASE if service_flavor is prod

Wir haben uns dafür entschieden, dass wir auf einem AWS-Account die DEV- und PROD-Umgebungen laufen lassen. Wir ziehen also zwei identische Stacks in einem AWS-Account hoch. Da die Stacks identisch sind, werden für das DEV- und PROD-Deployment ein und dasselbe Ansible Playbook verwendet. Die einzige Unterscheidung passiert beim Aufruf von Ansible, TeamCity ist so konfiguriert, dass es für DEV Ansible mit der Option '-e service_flavor=dev' aufruft und den 1:1 identischen Build-Job für PROD mit der Option '-e service_flavor=prod'. Die Unterscheidung für die Datenbank passiert hier in diesem Task. Per Default stehen im Ansible Playbook die DEV Datenbank Host-Informationen drin, falls es sich aber um PROD handelt werden die Werte mit den entsprechenden PROD Datenbank Host-Informationen überschrieben.

- task: deploy with serverless

Der letzte Task ist der wichtigste, hier wird serverless aufgerufen. Die Ansible Option 'service_flavor' wird serverless als '--stage' Parameter übergeben. Es ist also auch bei unserer serverless.yml so, dass wir nur eine Datei pflegen und die Unterscheidung zwischen DEV und PROD auch in der serverless.yml passiert. Alle anderen sensiblen Daten aus Vault, wie die Datenbank Credentials, werden serverless als Parameter mitgegeben.

Serverless

Die finale (mit irrelevanten Kürzungen) serverless.yml ist im Folgenden abgebildet.

service:
name: puk-aws-reporting

provider:
  name: aws
  runtime: nodejs6.10
  region: eu-central-1
  stage: ${opt:stage, self:custom.defaultStage}
  profile: ${self:custom.profiles.${self:provider.stage}}
  role: arn:aws:iam::${self:custom.awsAccountId}:role/pku_xxx
  memorySize: 256
  timeout: 120
  versionFunctions: true
  deploymentBucket:
    name: xxx.xxx.xxx
  stackTags:
    service: ${ self:service }
  environment:
    DB_HOST: ${opt:db_host}
    DB_DATABASE: ${opt:db_database}
    DB_PORT: ${opt:db_port}
    DB_USER: ${opt:db_user}
    DB_PASSWORD: ${opt:db_password}

plugins:
  - serverless-plugin-typescript

custom:
  awsAccountId: xxx
  git-repo: https://github.com/europace-privatkredit/xxx
  defaultStage: dev
  profiles:
    dev: pku_serverless
    prod: pku_serverless
  sns:
    pku_aws_reporting_sns_topic: aws-reporting

package:
  include:
    - src/**/!(*.spec).js
    - node_modules/**/*

functions:
  copyMessageToS3AndRedshift:
    name: ${ self:service }_copyMessageToS3AndRedshift_${self:provider.stage}
    handler: src/copyMessageToS3AndRedshift.copyMessageToS3AndRedshift
    timeout: 300
    reservedConcurrency: 100
    events:
          - sns: ${self:custom.sns.pku_aws_reporting_sns_topic}_${self:provider.stage}
          - topicName: ${self:custom.sns.pku_aws_reporting_sns_topic}_${self:provider.stage}
          - displayName: ${self:custom.sns.pku_aws_reporting_sns_topic} for ${self:provider.stage}
  moveStagingDataToProductionTable:
    name: ${ self:service }_moveStagingDataToProductionTable_${self:provider.stage}
    handler: src/moveStagingDataToProductionTable.moveStagingDataToProductionTable
    timeout: 300
    events:
      - schedule:
          name: move-staging-data-to-production-table_${self:provider.stage}
          description: Move staging data to production table
          rate: cron(0 6 * * ? *)

Ich möchte hier nochmal hervorheben, dass diese serverless.yml mit Unterstützung unseres dNa-Teams zur Entwicklungsphase entstand. Es ist deshalb auch im Quellcode-Projekt der Lambdas angesiedelt und wird dem Deployment als "shared-Artifakt" zugesteuert. Im Folgenden werden ganz kurz ein paar Elemente von serverless anhand unserer serverless.yml vorgestellt, weil ich sie sehr elegant bzw. sehr hilfreich finde.

- plugins:

Serverless bietet unzählige Plugins, die einem bei diversen Problemstellungen helfen. Wir nutzen das Plugin 'serverless-plugin-typescript', um TypeScript Support zu haben und nicht wie oben aufgeführt selbst von TypeScript zu plain JavaScript kompilieren zu müssen.

- package:

Serverless macht es extrem einfach den Lambda-Quellcode zu paketieren. Es lässt sich angeben, welche Dateien im Quellcode ZIP-Archive enthalten sein sollen. Die package Directive spielt mit provider.deploymentBucket zusammen. Serverless "weiß" durch diese Angaben was wohin deployed werden soll.

- functions:

Die echte Eleganz liegt aber bei der Deklarierung der Lambdas. Wie an unseren zwei Lambdas zu sehen, ist die Deklaration sehr kurz und fast selbsterklärend. Wenn man beispielsweise sich anschaut wie einfach die Verknüpfung der SNS und dem Lambda copyMessageToS3AndRedshift ist. Es sind nur 4 Zeilen, die die SNS anlegen, den Trigger und die Subscription setzen. Die entsprechende Cloudformation Konfiguration wäre definitiv komplizierter.

Fazit

Meine Idee serverless durch Ansible zu ersetzen war nicht gut und ich bin noch gerade rechtzeitig davon abgekommen. Ich hätte hier vielmehr auf unser dNa-Team zugehen müssen, da sie AWS schon seit ein paar Jahren kennen und natürlich hier ein tiefes Wissen aufgebaut haben. Ideal wäre es, wenn wir als PKU an einer Story zusammen mit dNa arbeiten würden, vielleicht ergibt sich das in der Zukunft. Serverless aus Ansible heraus aufzurufen finde ich keine schlechte Idee, es wirkt für mich im Nachgang immer noch valide. Hier würde ich mich über Feedback sehr freuen.
Das Arbeiten bei Europace und in der PKU ist Technologie-begeistert und begeisternd, so dass ich auch noch nach meinem ersten Task sehr froh bin hier zu sein ;). Ich möchte mich hier nochmal sehr herzlich bei allen Kollegen für die Hilfestellungen und aktiven Diskussionen bedanken, die im Vorlauf und auch während der Umsetzung dieses Tasks gemacht wurden.