Rust in Openshift deployen

Ich mag keinen Bloat. Programme, die essentiell nichts tun und ein Gigabyte Speicher und eine halbe CPU belegen gehen mir nicht in den Sinn. Jedes belegte Byte und jeder ausgeführte Takt sind ein potentielles Versteck für Bugs. Je weniger ausgeführt wird, desto sicherer kann man sich sein, dass das Programm auch in jedem Fall das Richtige (tm) tut. Code ist kein Asset, sondern eine Liability: kleiner ist besser. Und Code in den man nicht reingucken kann ist der Schlimmste. Ich will also unbedingt weg von Java und der unsäglichen proprietären Oracle JVM mit ihren Unwägbarkeiten, seltsamem GC Verhalten und ihrem perversen Speicherhunger. Kurz: meine Quest ist Rust! Ergo habe ich mich beim gestrigen 24h-Sprint auch dieses Themas angenommen, dieses Mal allerdings mit dem Fokus darauf, die Anwendung auch tatsächlich im Netzwerk des Kunden zu deployen, um meine Pitches mit direkten Vergleichen von Performance und Ressourcennutzung untermauern zu können.

Das Programm

Für dieses Mal habe ich einen simplen Netzwerkservice gebaut und auf Rocket verzichtet, da ich für Produktionscode den stabilen Releasekanal von Rust verwenden möchte. Das Programm nutzt daher Actix Web als Webframework. Es ist ausgesprochen minimal und kann mit

rustup self update
rustup update stable
cargo init rust-service
cd rust-service

aufgesetzt werden. Anschließend kann die Cargo.toml editiert werden, um Actix hinzuzufügen:

[package]
name = "rust-service"
version = "0.1.0"
authors = ["Fischer, Maxi <maxi.fischer@senacor.com>"]
[dependencies]
actix-web = "0.7"

Das eigentliche Programm besteht in unserem Fall aus einer sehr schlanken main.rs:

extern crate actix_web;
use actix_web::{server, App, HttpRequest};

fn hello(_req: &HttpRequest) -> &'static str {
    "Hello world!\n"
}

fn main() {
    server::new(|| App::new().resource("/hello", |r| r.f(hello)))
        .bind("127.0.0.1:6000")
        .unwrap()
        .run();
}

Hierbei ist /hello unser Endpunkt, der lediglich einen String zurück liefert. Mit dem Schnipsel Gluecode in fn main teilen wir Actix mit, dass die Funktion fn hello der Handler dafür sein soll und dass wir auf TCP/IP-Port 6000 auf HTTP-Anfragen warten wollen. Mit .unwrap() sagen wir dem Programm, es möge sich ordentlich beenden, falls dabei ein Fehler (wie z.B. dass der Port schon belegt ist) auftritt. Die letzte Zeile in fn main schließlich startet den Server.

Ausprobieren kann man dieses simple Programm jetzt ganz leicht mit

cargo run

Beim ersten Ausführen wird Cargo automatisch alle Abhängigkeiten herunterladen und kompilieren. Für erfahrene Javaprogrammierer wird das Kompilieren etwas länger dauern als sie gewohnt sind, aber dafür erhalten wir auch eine Binärdatei, die nativ auf unserem System läuft und keine zig Megabyte große Runtime benötigt, auf deren Quellcode wir keinen Zugriff haben. Als zusätzlicher Bonus ist die Speicherverwaltung komplett statisch und unsere Performance daher besser vorhersagbar.

Bauen in Openshift

Da der Jenkins meines Kunden keine Rust-Toolchain zur Verfügung stellt, habe ich mich entschieden, den Service direkt in Openshift zu bauen. Um im produktiv deployten Pod jedoch keine Toolchain herumschleppen zu müssen, die nutzlos Platz verschwendet und ein potentielles Sicherheitsrisiko darstellt, würde ich am liebsten intermediäre Dockercontainer im Build nutzen. Da OpenShift 3.9 aber leider kein Docker 17.05 und damit keine Multi-Stage Builds unterstützt, müssen wir zwei Dockerfiles und entsprechend verkettete Buildkonfigurationen nutzen.

Bauen

Um Rust in unserem privaten Namespace nutzen zu können, können wir einen ImageStream erstellen und dem offiziellen RedHat-Stream folgen lassen.

apiVersion: image.openshift.io/v1
kind: ImageStream
metadata:
  name: rust
  namespace: build
spec:
  dockerImageRepository: registry.access.redhat.com/devtools/rust-toolset-7-rhel7

Momentan kann Openshift diese noch nicht automatisch synchronisieren; dazu müssen wir ein Kommando mit dem Kommandozeilenclient ausführen:

oc import-image rust

Dockerfile.build:

FROM devtools/rust-toolset-7-rhel7:1.26.2-7

USER 0

COPY ./ ./

RUN scl enable rust-toolset-7 'cargo build --release'

RUN mkdir -p /build-out && cp target/release/rust-service /build-out/app

RUN ls -lh /build-out/

In Redhat müssen wir die Buildtools mit scl enable rust-toolset-7 erst aktivieren, bevor wir sie nutzen können. Das fertige Artefakt kopieren wir nach /build-out/app, von wo der nächste Schritt es abholen wird. Die FROM Zeile wird dabei von Openshift ignoriert und durch den entsprechenden Eintrag in unserer Buildconfig ersetzt. Diese sieht so aus:

apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
  labels:
    build: rust-service-build
  name: rust-service-build
  namespace: my-namespace
spec:
  output:
    to:
      kind: ImageStreamTag
      name: 'rust-service:latest'
  resources:
    requests:
      cpu: 250m
      memory: 1Gi
    limits:
      cpu: 2000m
      memory: 4Gi
  runPolicy: Serial
  source:
    contextDir: rust-service
    git:
      ref: master
      uri: 'https://git.company.com/rust.git'
    sourceSecret:
      name: builder-secret
    type: Git
  strategy:
    dockerStrategy:
      dockerfilePath: Dockerfile.build
      from:
        kind: ImageStreamTag
        name: 'rust:latest'
        namespace: build
    type: Docker

Ein entsprechender ImageStream rust-service muss dabei ebenfalls existieren. Wichtig ist hier, die Resource-Limits nicht zu klein anzusetzen, rustc ist doch recht speicherhungrig. Diese Kosten zahlt man dafür nur beim Bauen und nicht beim Betreiben der Anwendung!

Deployen

Nun haben wir einen ImageStream mit unseren fertigen Artefakten. Diese müssen wir jetzt noch mit Hilfe eines Deploymentbuilds in einen schlanken Container verpacken. Dazu benötigen wir noch ein (sehr schlankes) Dockerfile.run,

FROM scratch

COPY ./build-out/app /

CMD ["/app"]

sowie eine dazugehörige Buildkonfiguration:

apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
  labels:
    build: rust-deployment
  name: rust-deployment
  namespace: build
spec:
  failedBuildsHistoryLimit: 5
  output:
    to:
      kind: ImageStreamTag
      name: 'rust-deployment:latest'
  resources:
    limits:
      cpu: '1'
      memory: 1Gi
    requests:
      cpu: 250m
      memory: 1Gi
  runPolicy: Serial
  source:
    contextDir: rust-service
    git:
      ref: master
      uri: 'https://git.company.com/rust.git'
    images:
      - from:
          kind: ImageStreamTag
          name: 'rust-service:latest'
          namespace: build
        paths:
          - destinationDir: rust-service
            sourcePath: /build-out
    type: Git
  strategy:
    dockerStrategy:
      dockerfilePath: Dockerfile.run
      from:
        kind: ImageStreamTag
        name: 'nn-rhel:7.4-129'
        namespace: build
    type: Docker
  successfulBuildsHistoryLimit: 5
  triggers:
    - imageChange:
      type: ImageChange

Der Trigger sorgt dafür, dass der Deploymentbuild angestoßen wird, wenn der „Buildbuild“ erfolgreich durchgelaufen ist. Mit dem from Teil importieren wir den Stream aus dem vorherigen Kapitel und stellen dessen Inhalt dem neuen Dockerbuild zur Verfügung. Das destinationDir berücksichtigt dabei allerdings nicht das contextDir, dieses muss also nochmal explizit angegeben werden.

Nun aber wirklich wirklich deployen

Jetzt haben wir tatsächlich einen echten, nutzbaren Container. Fehlt nur noch die Deploymentkonfiguration:

apiVersion: apps.openshift.io/v1
kind: DeploymentConfig
metadata:
  labels:
    app: rust-app
    deploymentconfig: rust-app
  name: rust-app
  namespace: prod
spec:
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    app: rust-app
    deploymentconfig: rust-app
  strategy:
    activeDeadlineSeconds: 21600
    recreateParams:
      timeoutSeconds: 100
    resources:
      requests:
        cpu: 300m
        memory: 128Mi
    rollingParams:
      intervalSeconds: 1
      maxSurge: 1
      maxUnavailable: 0
      timeoutSeconds: 100
      updatePeriodSeconds: 1
    type: Rolling
  template:
    metadata:
      labels:
        app: rust-app
        deploymentconfig: rust-app
    spec:
      containers:
        - image: 'docker-registry.default.svc:5000/build/rust-deployment:latest'
          imagePullPolicy: Always
          livenessProbe:
            failureThreshold: 10
            httpGet:
              path: /rust
              port: 6000
              scheme: HTTP
            initialDelaySeconds: 20
            periodSeconds: 15
            successThreshold: 1
            timeoutSeconds: 20
          name: rusty-advk-mock
          ports:
            - containerPort: 8080
              protocol: TCP
          readinessProbe:
            failureThreshold: 5
            httpGet:
              path: /rust
              port: 6000
              scheme: HTTP
            initialDelaySeconds: 20
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 20
          resources:
            limits:
              cpu: '1'
              memory: 256Mi
            requests:
              cpu: 200m
              memory: 128Mi
          securityContext:
            capabilities: {}
            privileged: false
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
  triggers:
    - type: ConfigChange

Where does the newborn go from here

Nun da wir eine erfolgreich laufende Anwendung haben, sollten wir sie mit Leben füllen. Wir setzen hier im Projekt auf Schnittstellenbeschreibungen mit Swagger und sind es aus dem Spring Boot Umfeld gewohnt, uns Servercode mittels Delegatepattern generieren zu lassen, da generierte Stubs zu viele Nachteile mit sich bringen. Momentan gibt es zwar Codegeneratoren für Rust, diese sind aber in einem so jämmerlichen Zustand, dass wir hier noch viel Arbeit vor uns haben. Doch das wird Inhalt eines zukünftigen Artikels sein.