Dr. Liv Fischer
Lead Developer und Expertin für Rust
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 ™ 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 Senacor-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.