Dem GitLab-Runner auf der Spur

Mit GitLabs CI/CD-Pipelines lassen sich Projekte sehr komfortabel bauen und deployen. Dass die Beschreibung der Pipelines als Teil des Projektes automatisch unter Versionskontrolle steht, ist sehr praktisch. Pipelines werden über die Config-Datei „.gitlab-ci.yml“ im Rootverzeichnis des Git-Repositorys definiert.

Im konkreten Fall wollte ich eine Pipeline für das auch schon unter Monitoring einer Scala-Anwendung mit Icinga2 auffällig gewordene Projekt konfigurieren. Die Pipeline sollte die Anwendung bauen, dockern und auf einem Remote-Server deployen. Praktischer Weise gab es in unserem GitLab bereits einen Runner, der laut seiner Tagliste mit „sbt, shell, hosted“ genau die relevanten Fähigkeiten mitbrachte. Der Runner hörte auf den für mich kryptischen Namen „bdp“. Aber das störte mich erstmal nicht weiter.

Das Scala Build Tool sbt lief problemlos, so dass das Docker-Image per sbt:docker-publish schnell gebaut und in die Docker-Registry gepusht war.
Auch die Shell tat was sie sollte, brach dann aber leider beim Versuch per ssh auf den Remote-Server zuzugreifen mit dem Fehler „Host key verification failed.“ ab.

runner_build_failed

Kein Problem, dazu braucht man ja normaler Weise nur per „ssh-copy-id user@remote-server“ den Public-Key des users, der die Verbindung aufbaut, auf den Remote-Server zu kopieren.

Nach einiger Suche, wurde mir klar, dass der Runner nicht auf dem GitLab-Server lief. Wo war mir aber unklar.

Sag mir, wo du läufst!

GitLab-Runner können auf beliebige Server verteilt werden und dezentral ihre Arbeit verrichten. Die Runner werden unter Verwendung eines Token mit der GitLab-Ci Coordinator Url verbunden. Ein super Ansatz.

In der GitLab-UI finden sich leider nur wenige Informationen über die Runner. Die IP-Adresse des Runners gehört nicht dazu.

runner_infos_in_gitlab_ui

Mit einem kleinen Trick lassen sich Informationen vom Runner selbst über die Jobs der Pipeline auslesen. Dazu trägt man in „.gitlab-ci.yml“folgendes ein:

gitlab-ci-yml

(Getestet mit CentOS7. Abhängig vom OS muss die korrekte IP-Adresse ggf. anders ermittelt werden.)

Nachdem der Job gelaufen ist, findet sich in seinem Log folgendes:

runner_infos_from_pipeline_job

Damit kennen wir den Namen des Benutzers, dessen Public-Key wir kopieren müssen, und die IP-Adresse des Rechners auf dem der Runner läuft.

Die folgenden Schritte sind dann Standard

  1. Auf der 192.168.0.168 zum user „gitlab-runner“ werden
  2. Falls noch nicht vorhanden, ein Public- / Privat-Key-Pair für den user „gitlab-runner“ erstellen
  3. Per ssh-copy-id den Public-Key des users „gitlab-runner“ auf den Remote-Server kopieren. Dabei ist es wichtig den user zu verwenden, der später aus dem CI-Job den ssh-Zugriff nutzen wird, z.B deployment_user@remote-server-ip

Nun kann sich der Runner als „deployment_user“ per ssh direkt mit dem Remote-Server verbinden und dort das Deployment vornehmen.

runner_build_succeeded

Voilà

Health-Check für Meteor-Apps

Mit der JavaScript Meteor Platform lassen sich sehr einfach Mobile- und Web-Anwendungen bauen.

Natürlich soll ihre Verfügbarkeit im Produktivbetrieb sichergestellt sein. Über etwaige Ausfälle wollen wir direkt per Email informiert werden.

Dabei helfen Tools wie Icinga2. Mit dessen Plugin „http_check“ lassen sich kontinuierlich HTTP-Anfragen verschicken und die Antworten auswerten.

http_check auf die Meteor-App?

Mhh, dann fragen wir doch einfach die auf Meteor-Standard-Port 3000 gestartete App per „http_check“ an. Das geht natürlich, weil, sofern die Anwendung läuft, ein HTTP-Status 200 OK zurückkommt. Leider ist das nicht alles. Als Content wird der Client der Meteor-App mitgeschickt. Und das kann dann schon mal so aussehen

<!DOCTYPE html>
<html>
<head>
	<link rel="stylesheet" type="text/css" class="__meteor-css__" href="/merged-stylesheets.css?hash=39916f721d8df2e161d119b4f0df09952926949a">
<title>myApp</title>
</head>
<body>
<script type="text/javascript">__meteor_runtime_config__ = ...</script>
<script type="text/javascript" src="/packages/underscore.js?hash=cde485f60699ff9aced3305f70189e39c665183c"></script>
 <script type="text/javascript" src="/packages/meteor.js?hash=27829e936d09beae3149ecfbf3332c42ccb1596f"></script>
<script type="text/javascript" src="/packages/meteor-base.js?hash=a4d07a6b394e56bbe6ccc773c95e7cdb3434960d"></script>
 <script type="text/javascript" src="/packages/mobile-experience.js?hash=8ded3e69a3e367f321ab9a2b52e3ecdd2661a365"></script>
<script type="text/javascript" src="/packages/modules-runtime.js?hash=0969a3165abf9612f4fb6ca11e39ddbae2c52756"></script>
 <script type="text/javascript" src="/packages/modules.js?hash=54fc414151b814f48e49420f1959ebfa8acefcc6"></script>
<script type="text/javascript" src="/packages/es5-shim.js?hash=adc3c6270d5697523fe2a72e73428390b7eba83a"></script>
 <script type="text/javascript" src="/packages/promise.js?hash=423beeba1d7cf6ade8d8d90a88cc89f8684857f4"></script>
<script type="text/javascript" src="/packages/ecmascript-runtime.js?hash=fffe20ae20ade1111a96909b90dde7ba91923e92"></script>
 <script type="text/javascript" src="/packages/babel-compiler.js?hash=a9546d4e245cfe40b406e08d40bf106241f01683"></script>
<script type="text/javascript" src="/packages/ecmascript.js?hash=370a8752194bcf73be7fffa3635715d0fbf7853d"></script>
 <script type="text/javascript" src="/packages/base64.js?hash=0053489bb30bb5c0e3545df151f83e41150344b0"></script>
<script type="text/javascript" src="/packages/ejson.js?hash=0f17ced99d522d48cd8f8b2139167fd06babd969"></script>
 <script type="text/javascript" src="/packages/id-map.js?hash=c7aea8dfa2bf46ff2ae0aa6c6cf09e36abc61d07"></script>
<script type="text/javascript" src="/packages/ordered-dict.js?hash=bacdd1852075630a01f7de783e5e8e8aa8541cdc"></script>
 <script type="text/javascript" src="/packages/tracker.js?hash=9f8a0cec09c662aad5a5e224447b2d4e88d011ef"></script>
<script type="text/javascript" src="/packages/babel-runtime.js?hash=81591737f14ce07f210dfca6637615da6aca10aa"></script>
 <script type="text/javascript" src="/packages/random.js?hash=a3be1ee923a6fc933f063c7f8de3e15243e12f47"></script>
<script type="text/javascript" src="/packages/mongo-id.js?hash=345d169d517353f8146292b4abd24061721f8b26"></script>
 <script type="text/javascript" src="/packages/diff-sequence.js?hash=15014d7b1e11c05111a386992e684ab1d3cc4158"></script>
<script type="text/javascript" src="/packages/geojson-utils.js?hash=b204c7d4caf119e6883522fb87c6cce060724bf0"></script>
 <script type="text/javascript" src="/packages/minimongo.js?hash=8645fc685d558a15e6207c847f5709d20f6a14d9"></script>
<script type="text/javascript" src="/packages/check.js?hash=87c633843915b879a0c9676ea81f1cd351296e41"></script>
 <script type="text/javascript" src="/packages/retry.js?hash=1e409617b538ff3e2b0238b15e45b3380c51a224"></script>
<script type="text/javascript" src="/packages/ddp-common.js?hash=d42359bcace6c66ac90e2782193494253ee68155"></script>
 <script type="text/javascript" src="/packages/reload.js?hash=628b069673bffbc7390ba84ece8809c8c88c2eed"></script>
<script type="text/javascript" src="/packages/ddp-client.js?hash=89f721bb437611dfd558156033a1367eb42686c0"></script>
 <script type="text/javascript" src="/packages/ddp.js?hash=25dc3f428447c81620c91c4245dbc6e4f7d32fb7"></script>
<script type="text/javascript" src="/packages/ddp-server.js?hash=1beefbc7bd033ea687e7ab8fbd5694df072662af"></script>
 <script type="text/javascript" src="/packages/allow-deny.js?hash=9651dba61aa212828975b89e7c889af540c6a5da"></script>
<script type="text/javascript" src="/packages/insecure.js?hash=a0e5f17c280f4c7b05178d36a7ceb07cb7b086c6"></script>
 <script type="text/javascript" src="/packages/mongo.js?hash=90f037f47abee1e74ba80360e6b3f3dbaa792260"></script>
<script type="text/javascript" src="/packages/blaze-html-templates.js?hash=6e8335ce66460e45f00da73c7497654c5e26e236"></script>
 <script type="text/javascript" src="/packages/reactive-var.js?hash=ec712fa3ae588c4a1e7017f0bb4507c725391225"></script>
<script type="text/javascript" src="/packages/standard-minifier-css.js?hash=cfe82682f4394d3ffc6335555c1f9f3f73294507"></script>
 <script type="text/javascript" src="/packages/standard-minifier-js.js?hash=041bab58c8a89172eaab795deb5d96e38b64ec37"></script>
<script type="text/javascript" src="/packages/shell-server.js?hash=6ff1313e4bf7618e577eb2604a580b2ea9b7631f"></script>
 <script type="text/javascript" src="/packages/autopublish.js?hash=073bd4c42d2fb6182c944501b4f30e8d17bcceb3"></script>
<script type="text/javascript" src="/packages/meteorhacks_picker.js?hash=56585121bc9cb10cb03f03abc33aef5266599360"></script>
 <script type="text/javascript" src="/packages/jquery.js?hash=c57b3cfa0ca9c66400d4456b6f6f1e486ee10aad"></script>
<script type="text/javascript" src="/packages/twbs_bootstrap.js?hash=2ee228e6c80c1d9a4b1e67e10006f8a5a425ddda"></script>
 <script type="text/javascript" src="/packages/url.js?hash=137892484d84f8a5ae5824e6fc7ac12745fa43e9"></script>
<script type="text/javascript" src="/packages/http.js?hash=9355a65a433bea87be60bc1fd90e0ef608af93e4"></script>
 <script type="text/javascript" src="/packages/bshamblen_json-schema-generator.js?hash=4a38a545be36ec28833612763968a0d05f93e329"></script>
<script type="text/javascript" src="/packages/momentjs_moment.js?hash=fb823255c21127031b96960fe36a34bce112a48d"></script>
 <script type="text/javascript" src="/packages/webapp.js?hash=8024f6bce97bd768bcff7fc9d76449e74f051e36"></script>
<script type="text/javascript" src="/packages/livedata.js?hash=7cf1831a60b48e304b054aee1ae0f7e38ff35d09"></script>
 <script type="text/javascript" src="/packages/hot-code-push.js?hash=2e864a0bdd0d5f686115099f8c48eb6c866b5b14"></script>
<script type="text/javascript" src="/packages/observe-sequence.js?hash=8fe58036c6ba00c458f54c360a21fd0e41fb7ee0"></script>
 <script type="text/javascript" src="/packages/deps.js?hash=7313f5a2685c6c2c673c78c15c8ce86ff59ab0c9"></script>
<script type="text/javascript" src="/packages/htmljs.js?hash=1ac878018eee6c53ed1375dc7ee75fc6865666ae"></script>
 <script type="text/javascript" src="/packages/blaze.js?hash=813922cefaf3c9d7388442268c14f87d2dde795f"></script>
<script type="text/javascript" src="/packages/spacebars.js?hash=ebf9381e7fc625d41acb0df14995b7614360858a"></script>
 <script type="text/javascript" src="/packages/templating-compiler.js?hash=a71883cdec50e95ca135291415990753ed6d57fc"></script>
<script type="text/javascript" src="/packages/templating-runtime.js?hash=c18de19afda6e9f0db7faf3d4382a4c953cabe18"></script>
 <script type="text/javascript" src="/packages/templating.js?hash=c2cf38de06efb47f67affb2dff9320e5eef33893"></script>
<script type="text/javascript" src="/packages/launch-screen.js?hash=2f56943306c7e900ed9f4d894b87f534ebffeaeb"></script>
 <script type="text/javascript" src="/packages/ui.js?hash=039c55a98376abd03d9d8cd4100895861b897643"></script>
<script type="text/javascript" src="/packages/autoupdate.js?hash=1fd9cf3472adaa6887170d88ab5ea1ddabf695fa"></script>
 <script type="text/javascript" src="/packages/global-imports.js?hash=c22a9ef6aa88d73cec0b451c0742a5a82a720ad7"></script>
<script type="text/javascript" src="/app/app.js?hash=da0bb5641b812e31cf841c1426b403fb04e21df9"></script>
</body>
</html>

Wow, das ist doch etwas viel des Guten und schon gar nicht die Art von Meta-Informationen die wir von einem Health-Check erwarten.

Eine Nummer kleiner bitte!

HTTP-Health-Checks werden oft unter einem separaten URL-Pfad wie z.B. „http:localhost/health“ bereitgestellt. Sie geben unter dieser Adresse Auskunft über den Zustand der Anwendung.

Um eine Meteor-App mit einem einfachen Health-Check zu versehen, muss eine entsprechende Route definiert werden. Da wir nicht über die Client-Seite gehen wollen, bietet sich der Server-seitige Router meteorhacks:picker an. Über die Konsole lässt sich das Package einfach nachinstallieren.

meteor add meteorhacks:picker

Die Route selbst wird im „server“-Verzeichnis in der Datei „routes.js“ definiert.

Picker.route('/health', function(params, req, res, next) {
  res.setHeader( 'Content-Length', 0 );
  res.statusCode = 200;
  res.end();
});

Im Bespiel geben wir beim Aufruf von „/health“ den HTTP-Status 200 OK ohne Content zurück. Kompakter kann das Lebenszeichen unserer Anwendung kaum ausfallen.

Kontext, Baby!

Um mehr über die Anwendung und ihren Zustand zu erfahren, lassen sich beliebige Meta-Informationen als Content zurückgeben. Im Beispiel geben wir den Namen der Anwendung und wann seit wann sie am Start ist zurück.

Um herauszubekommen, wann die App gestartet wurde, erweitern wir die Methode „Meteor.startup()“ im „server“-Verzeichnis in der Datei „main.js“ um eine globale Variable „startDate“ und weisen ihr das Startdatum zu.

Meteor.startup(() => {
    startDate = new Date();
});

In unserem Router greifen wir auf das „startDate“ zu, formatieren es und verpacken es zusammen mit dem Namen der App in einem JSON-Objekt.

Mit Hilfe des Packages „momentjs:moment“ lassen sich Date-Objekte sehr einfach parsen, manipulieren, validieren und anzeigen.

Picker.route('/health', function(params, req, res, next) {
    let context = {
      "app-name" : "myApp",
      "up-since" : moment(startDate).format('DD.MM.YYYY HH:mm:ss')
    }

    res.setHeader( 'Content-Type', 'application/json' );
    res.statusCode = 200;
    res.end( JSON.stringify( context) );
});

Nun ist das Ergebnis unseres Health-Checks nicht nur sehr kompakt, sondern liefert auch erste sinnvolle Informationen über den Zustand der Anwendung.

{
  "app-name": "myApp",
  "up-since": "19.03.2017 02:01:41"
}

Voilà.

Monitoring einer Scala-Anwendung mit Icinga2

Kannst du das mal kurz checken?

Die Verfügbarkeit einer Scala-Anwendung soll überwacht werden. Für das Monitoring setzen wir Icinga2 ein. Mit Icingas „http_check“ Plugins lassen sich health-Endpoints von Server-Anwendungen sehr einfach überwachen.

Ja, aber …

… die Anwendung ist nicht per HTTP erreichbar und stellt keinen health-Endpoint bereit.

Mhh, da gibt es doch was von Akka?!

Mit Scalas Akka HTTP lässt sich mit wenig aufwand ein leichtgewichtiger HTTP/1.1 Server in die Anwendung integrieren und ein rudimentärer health-Endpoint bereitstellen.

package myPackage

import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.HttpMethods._
import akka.http.scaladsl.model._
import akka.stream.ActorMaterializer
import akka.stream.scaladsl.Sink

import scala.concurrent.Future

object myApp extends App {

  implicit val system = ActorSystem()
  implicit val materializer = ActorMaterializer()

  private val http = Http()

  //do something

  val serverSource = http.bind(interface = "0.0.0.0", port = 1234)

  val requestHandler: HttpRequest => HttpResponse = {
    case HttpRequest(GET, Uri.Path("/health"), _, _, _) =>
      HttpResponse(200, entity = "I'm alive!")
    case _: HttpRequest =>
      HttpResponse(404, entity = "Unknown resource")
  }

  val bindingFuture: Future[Http.ServerBinding] =
    serverSource.to(Sink.foreach { connection =>
      println("Accepted new connection from " + connection.remoteAddress)
      connection handleWithSyncHandler requestHandler
  }).run()
}

Im Browser lässt sich nun unter http://localhost:1234/health der health-Endpoint der Anwendung aufrufen und antwortet mit einem „HTTP 200  OK“ und der Nachricht „I’m alive!“. Sehr schön!

Und was sagt Icinga dazu?

Leider wird Icingas „http_check“ auf „localhost:1234/health“ vom Akka HTTP Server immer mit einem „HTTP 400 Bad Request“ quittiert. Der „http_check“ steckt damit dauerhaft im Status „Warning“ fest. Natürlich ist unser Ziel auch in Icinga der Status „OK“, da die Anwendung läuft und im Browser das erwartete Ergebnis ausgegeben wurde.

Ich bin OK. Du bist nicht OK.

Ein Blick in die Logs der Scala Anwendung bringt Licht ins Dunkel:

[WARN] ... Illegal request, responding with status '400 Bad Request': 
  Request is missing required `Host` header: Cannot establish effective 
  URI of request to `/health`, request has a relative URI and is missing 
  a `Host` header; consider setting `akka.http.server.default-host-header`

Oha, Icinga setzt beim „http_check“ keinen host-Header. Akkas HTTP Server erwartet diesen aber in der von uns verwendeten Default-Konfiguration. Wer keine Icinga-Installation zur Hand hat, kann das Verhalten auch ganz einfach über die Shell mittels curl reproduzieren. ( explainshell.com )

curl -0 -H 'Host:' localhost:9494/health

Eine gemeinsame Sprache finden

Wie in der Warnung vorgeschlagen, kann der Wert für akka.http.server.default-host-header auf einen nicht leeren Wert gesetzt werden.

# If this setting is empty the server only accepts requests that carry a
# non-empty `Host` header. Otherwise it responds with `400 Bad Request`.
# Set to a non-empty value to be used in lieu of a missing or empty `Host`
# header to make the server accept such requests.
# Note that the server will never accept HTTP/1.1 request without a `Host`
# header, i.e. this setting only affects HTTP/1.1 requests with an empty
# `Host` header as well as HTTP/1.0 requests.
# Examples: `www.spray.io` or `example.com:8080`
default-host-header = ""

Die Scala-Anwendung lässt sich über eine Config-Datei namens „application.conf“ im Classpath z.B. unter „/src/main/resources“ konfigurieren. Für unsere Zwecke ergänzen wir die Einstellung „akka.http.server.default-host-header“ und setzen sie auf einen nicht leeren String, z.B.

akka.http.server.default-host-header = "non-empty-host-header"

Nach dem Neustart der Anwendung antwortet der Akka HTTP Server auch bei fehlendem Host-Header mit einem „HTTP 200 OK“ auf Anfragen an den health-Endpoint. Damit geht der „http_check“ in Icinga wie erwartet in den Status „OK“ über.

Voilà

Aller Blog-Anfang ist schwer

Das muss nicht sein! Meint Pablo Juan in seinem Blogpost Want to blog? Read this

Inspiriert von Pablos Post lege ich einfach mal los. Selbst wenn ich es nur für mich schreibe.

Was ist mir heute so passiert, welche Herausforderungen habe ich gemeistert, welche Probleme gelöst?

Das kann doch nicht so schwer sein. Da muss sich doch ein Thema finden lassen.

Und siehe da, wenn auch sicherlich etwas speziell, war da doch gerade das Monitoring einer Scala Anwendung mit Icinga2 ein Thema diese Woche.