Das in der Vorlesung gezeigte voll automatisierte Deployment für beliebig viele IoT-Devices hat uns sehr begeistert. Daher entstand die Idee, etwas äquivalentes auch für die Backend-Komponenten des Projekts zu schaffen. Wie die Balena Cloud verwenden wir dafür Docker. Es wird jedoch kein kommerzielles Angebot verwendet, sondern ein Open-Source Tool, das selbst wiederum unter Verwendung von Docker deployed wurde. Die Rede ist von Gitlab. Hier wird allerdings nicht das angebot von gitlab.com verwendet, sondern eine selbst verwaltete Instanz der Community Edition. Im Gegensatz zum bekannten GitHub ist die Software Gitlab frei verfügbar und kann so auf eigenen Servern installiert werden. Gitlab integriert eine mächtige CI/CD Infrastruktur, die wir zum Automatisieren der Backend-Komponenten verwenden. Gitlab ermöglicht die Definition von Pipelines, die zu einem bestimmten Zeitpunkt ausgeführt werden. Bei uns wird nach jedem Push in den master-Branch eine Pipeline ausgeführt, die das Deployment übernimmt. Dabei werden momentan noch keine Tests ausgeführt, sondern lediglich die folgenden Schritte:
  • Docker Image erstellen
  • Erstelltes Image in die eigene Docker Registry pushen
  • Das Docker Image aus der Registry pullen und einen Container starten
Damit das Docker Image erstellt werden kann, beinhaltet das Repository ein entsprechendes Dockerfile. Daneben sind die Quellcodes enthalten und außerdem eine YAML-Datei zur Definition der CI/CD Pipeline für Gitlab.

Die Beispiel-Anwendung

Ziel ist es eine Pipeline zum automatisierten Deployment zu entwickeln. Aus diesem Grund beschränkt sich die hier verwendete Beispiel-Anwendung auf ein Minimalbeispiel eines MQTT-Empfängers. Die empfangenen Nachrichten werden zunächst nicht in einer Datenbank gespeichert, sondern über ein Websocket gesendet. Dies dient zur Veranschaulichung und zum einfacheren Testen, ob das Deployment funktioniert. Es wird das Python-Framework FastAPI verwendet. Als Grundlage dient ein Beispiel aus der entsprechenden Doku-Seite. Zusätzlich dazu wird die MQTT-Erweiterung für FastAPI zum Empfangen der MQTT-Nachrichten verwendet.
from fastapi import FastAPI, WebSocket
from fastapi.responses import HTMLResponse
from asyncio import Queue
from fastapi_mqtt import FastMQTT, MQTTConfig

queues = []

app = FastAPI()

mqtt_config = MQTTConfig(
	host = "193.41.237.111",
	port= 1883,
	keepalive = 60
)


mqtt = FastMQTT(
	config=mqtt_config
)

mqtt.init_app(app)



@mqtt.on_connect()
def connect(client, flags, rc, properties):
	mqtt.client.subscribe("iot-projekt/#")
	print("MQTT: Connected: ", client, flags, rc, properties)

@mqtt.on_message()
async def message(client, topic, payload, qos, properties):
	msg = "Received message: " + topic + payload.decode() #+ qos + properties
	for queue in queues:
		await queue.put(msg)
	print(msg)
	print("Writing to {} queues".format(len(queues)))

@mqtt.on_disconnect()
def disconnect(client, packet, exc=None):
	print("MQTT: Disconnected")

@mqtt.on_subscribe()
def subscribe(client, mid, qos, properties):
	print("MQTT: subscribed", client, mid, qos, properties)



html = """
<!DOCTYPE html>
<html>
	<head>
		<title>MQTT-Empfänger</title>
	</head>
	<body>
		<h1>MQTT Nachrichten</h1>
		<ul id='messages'>
		</ul>
		<script>
			console.log("Starting...");
			let ws = new WebSocket("ws://193.41.237.111:8000/ws");
			ws.onmessage = (event) => {
				let messages = document.getElementById('messages')
				let message = document.createElement('li')
				let content = document.createTextNode(event.data)
				message.appendChild(content)
				messages.appendChild(message)
			};
		</script>
	</body>
</html>
"""


@app.get("/")
async def get():
	return HTMLResponse(html)


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
	await websocket.accept()
	try:
	    queue = Queue()
	    queues.append(queue)
	    while True:
	        msg = await queue.get()
	        await websocket.send_text(msg)
	except:
		print("Verbindung durch Client getrennt")
		queues.remove(queue)
Zunächst wird der MQTT-Client instanziiert. Es werden die schon bekannten Parameter verwendet, die in früheren Beiträgen mit paho-mqtt zum Einsatz kamen. Ebenso wird bei erfolgreicher Verbindung das Abo für alle Topics, die mit iot-projekt/# beginnen abgeschlossen. Beim Empfang einer Nachricht, wird diese in alle registrierten Queues geschrieben. Dieses Vorgehen ist notwendig, damit alle Clients die Nachricht angezeigt bekommen, also jeder, der die Seite in seinem Browser geöffnet hat. Es wird die Queue aus asyncio verwendet, damit das Skript nicht beim Warten auf eine Nachricht festhängt, sondern alle Operationen asynchron ausgeführt werden. Die auszuliefernde HTML-Seite wird für dieses Minimalbeispiel als String definiert. Per JavaScript wird die Verbindung zum Websocket hergestellt und neue Nachrichten an eine Liste angehängt. Die im unteren Teil definierten Funktionen bilden die Endpunkte der API ab. Bei GET-Anfragen auf das obere Verzeichnis wird eine HTML-Antwort ausgeliefert, die die oben beschriebene Funktionalität enthält. Im Endpunkt /ws wird das Websocket definiert. Hier ist zu sehen, dass zunächst eine neue Queue angelegt und der Liste aller Queues hinzugefügt wird. Die Liste enthält für jedes geöffnete Browserfenster eine Queue. Sobald eine Nachricht in die Queue geschrieben wurde, wird diese als Textnachricht über das Websocket versendet. Die wenigen Zeilen JavaScript-Code innerhalb des HTML sorgen dafür, dass die Nachricht im Browserfenster angezeigt wird. Weiter soll die Beispiel-Anwendung nicht erklärt werden, da für den tatsächlichen Einsatz im Projekt die Daten in eine Datenbank geschrieben und nicht direkt an Clients ausgeliefert werden.

Das Dockerfile

Um das oben gezeigte Skript in ein Docker Image zu verpacken, wird das offizielle Image von FastAPI verwendet. In dem Image läuft ein für Python optimierter Webserver, der die programmierte API bereitstellt und über Port 80 erreichbar ist. Wie aus früheren Dockerfiles bekannt, werden zuvor noch die in requirements.txt gelisteten Abhängigkeiten installiert. Hier ist unbedingt das Paket websockets einzutragen, da das Websocket von FastAPI sonst nicht funktioniert. Die App muss in der Datei main.py in der Variablen app initiiert sein, damit der Webserver das Skript richtig ausführt. Daher ist hier kein Startbefehl mit CMD anzugeben.
FROM tiangolo/uvicorn-gunicorn:python3.8-alpine3.10

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY ./src /app

Die Pipeline

Da in der Entwicklung von Pipelines für Gitlab keine Vorkenntnisse vorhanden waren, wurde dieser Blogbeitrag als Grundlage verwendet und an notwendigen Stellen Veränderungen vorgenommen. Die dort gezeigte und übernommene Pipeline teilt sich in drei Schritte auf, die die oben beschriebenen Aufgaben abbilden. Zu jedem Schritt wird in diesem Fall nur eine Aufgabe definiert, mehrere wären möglich. Die Schritte werden als stages definiert und sind in diesem Fall image, transfer und deploy. Bei der Definition von Aufgaben wird einer der Schritte angegeben, zu dem die Aufgabe gehört und diesem somit zugeordnet. Außerdem wird mit only angegeben, dass die Aufgabe nur für den master-Branch ausgeführt werden soll. Die Einträge unter script sind die Befehle, die ausgeführt werden. Zum Erstellen des Docker Image ist dies der bekannte Befehl docker build. Die verwendeten Variablen sind teilweise durch Gitlab automatisch festgelegt oder wurden manuell über die Weboberfläche für dieses Projekt deklariert. Im zweiten Schritt wird das erstellte Docker Image in die Registry von Gitlab hochgeladen. Die Docker Registry ist nicht die offizielle Registry Docker Hub, sondern ebenfalls auf dem Server unter der Verwendung von Docker selbst gehostet. Der Login funktioniert, da die Registry Ihre Authentifizierungsinformationen dynamisch von Gitlab über einen anderen Kanal erhält. Um dies abzusichern wurde bei der Installation der beiden Komponenten ein Zertifikat ausgetauscht. Im dritten Schritt muss nun auf einem Server ein Container aus dem erstellten Image erstellt und gestartet werden. Dazu wird eine SSH-Verbindung hergestellt und mit docker run ein Container gestartet. Da die Ausführung der Aufgabe selbst ebenfalls in einem Docker Container geschieht und die Voraussetzungen für SSH-Verbindungen nicht gegeben sind, wird unter dem Eintrag before script der SSH-Agent initialisiert und diesem der für den Server passende Private-Key übergeben. Der Private-Key kann über die Weboberfläche von Gitlab in einer Variablen gespeichert werden. Nachdem die SSH-Verbindung hergestellt wurde, wird zunächst der Container iot-receiver gestoppt und gelöscht, damit anschließend ein neuer Container mit selbem Namen erstellt werden kann. Das Skript läuft also nur, wenn vorher ein Container mit dem Namen existiert. Hier ist deutlicher Verbesserungsbedarf. Bei Erstellung des Containers wird der Port 80 des im Container laufenden Webservers an den externen Port 8000 ggebunden, da der Port 80 auf dem Zielserver bereits verwendet wird. Im Folgenden der Inhalt der Datei .gitlab-ci.yml. Große Teile davon sind aus dem verlinkten Blogbeitrag übernommen worden.
image: docker:latest

variables:
 DOCKER_DRIVER: overlay2
 DOCKER_TLS_CERTDIR: ""
 APP_VERSION: "1.3.0"

services:
 - docker:19.03.5-dind
 
stages:
  - image
  - transfer
  - deploy
  
build_image:
  stage: image
  only:
    - master
  script:
    - docker build --no-cache=true -f ./dockerfile -t $CI_REGISTRY_IMAGE:$APP_VERSION .
    
transfer_image:
  stage: transfer
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker push $CI_REGISTRY_IMAGE:$APP_VERSION
  only:
    - master
    
    
deploy image:
  stage: deploy
  variables:
    DOCKER_HOST: ssh://user@$DESTINATION_HOST
  before_script:
    - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | base64 -d | tr -d '\r' | ssh-add - > /dev/null
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -t rsa $DESTINATION_HOST >> ~/.ssh/known_hosts
  script:
    - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker pull $CI_REGISTRY_IMAGE:$APP_VERSION
    - docker container stop iot-receiver
    - docker container rm iot-receiver
    - docker container run --name iot-receiver -d -p 8000:80 $CI_REGISTRY_IMAGE:$APP_VERSION
  only:
    - master
Screenshot von der Gitlab-Weboberfläche zeigt die erfolgreich durchlaufene Pipeline.
Für den hier durchgeführten Versuch laufen alle erwähnten Komponenten (Gitlab, Docker Registry, das deployte Image und der MQTT-Broker) auf einer Maschine. Dies ist für den produktiven Einsatz sicherlich nicht sinnvoll, reicht aber zum Testen. Die in einer Gitlab-Pipeline ausgeführten Aufgaben, werden durch den sog. Gitlab-Runner ausgeführt. Dieser lässt sich ebenfalls mit Docker verwenden. Dieser läuft also ebenfalls auf dem Server.

Fazit

Die Implementierung unseres Projekts ist durch das hier gezeigte Vorgehen keinen Schritt voran gegangen. Es wurde aber eine sehr komfortable Umgebung geschaffen, um alle verwendeten Backend-Komponenten deployen zu können. Nach aktuellem Stand würde für jede Komponente ein eigenes Repository erstellt und jeweils eine eigene Pipeline konfiguriert werden. Ob es sinnvoll ist, alle Komponenten wie bei der vorgeschlagenen Architektur für die IoT-Devices, in einem Repository zusammenzufassen und mit docker-compose zu orchestrieren bleibt abzuwägen, da dann bei jedem push alle Komponenten aktualisiert würden. Im jetzigen Stand dauert die Ausführung der Pipeline bereits etwa eine Minute, sodass es hier zu langen Wartezeiten kommen könnte.
Automatisiertes Deployment von Backend-Komponenten

3 Kommentare zu „Automatisiertes Deployment von Backend-Komponenten

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.