Wie automatisiere ich das Updaten meiner Infrastruktur

Warum sind Updates noch immer Handarbeit? Warum schauen Leute komisch, wenn ich erzähle, dass meine Updates automatisiert sind? Warum ist da immer noch Angst in den Leuten? Und was können wir dagegen tun?
Lass uns etwas nutzen, das wir im Software Engineering schon fast seit Ewigkeiten verwenden: Test Umgebungen, Continuous Integration und noch wichtiger - Tests! Wir kennen diese Konzepte schon lange. Wir machen "DevOps" seit den späten 2000ern, vor allem, wenn es um Developer-Plattformen wie Kubernetes und Co. geht. Wir benutzen Continuous Delivery, um unsere Software bereitzustellen, sodass Admins diese nicht mehr von Hand deployen müssen, und wir verwenden Automatisierung, um Konfigurationen zu verteilen und VMs bereitzustellen. Warum setzen wir diese Dinge nicht auch für die tatsächliche Infrastruktur ein?
Ganz wichtig: Wenn ich von Infrastruktur rede, spreche ich nicht von der Hardware. Die muss leider immer noch von Hand ins Rack geschraubt werden. Ich rede von allem, was Software ist, genauer gesagt vom Betriebssystem aufwärts. Software, die sich updaten lässt, können wir automatisiert updaten. Ich sehe das in Unternehmen mit WSUS-Servern, in denen Updates auf Windows Client PCs in der gesamten Domäne ausgerollt werden. Ich kenne es aus Infrastructure as Code-Umgebungen, in denen Updates durch ein einfaches Triggern des Playbooks (oder Cookbooks oder Scripts) durchgeführt werden.
Das Automatisieren von Updates nimmt unseren Admins extrem viel Arbeit ab. Der fixe Patchday einmal im Quartal wird zur Geschichte. Und die lästige Handarbeit, um die Updates herum, das Erstellen von Snapshots und Backups, das Stummschalten der Monitoring-Lösung vergessen wir auch nicht mehr. Wenn wir unsere Updates automatisieren, stellen wir sicher, dass wir keine offenen Türen durch bereits gepatchte Sicherheitslücken in unserem System stehen haben. Aktualität in unseren Betriebssystemen und der Software, die wir benutzen, ist ein extrem wichtiger Teil von Security.
Soweit, so klar. Trotzdem sind Updates von Betriebssystemen und allem, was nicht in der Cloud oder in Orchestrierungssystemen läuft, oft noch Handarbeit. Wieso? Weil bei Updates wichtige Dinge kaputtgehen können. Und darüber möchten wir die Kontrolle behalten. An automatisierten Updates hängt immer noch viel Angst. Was können wir gegen diese Angst tun?
Validierung von Infrastruktur
Mir bot sich die Chance, interne und Kundeninfrastruktur von Grund auf neu zu gestalten. Mein Plan war von Anfang an klar: Alles sollte per Ansible automatisiert werden und zwar so, wie ich es aus der Softwareentwicklung kenne - mit Tests von Beginn an. Unit Tests sind für mich selbstverständlich; sie laufen schnell, lassen sich während des Entwickelns leicht schreiben und geben mir die Sicherheit, dass ich später nichts kaputt mache. Also suchte ich nach einem Test Framework (bzw. einem Tool, um Tests zu schreiben) für Ansible und stieß auf Molecule. Damit lassen sich jede Rolle und sogar ganze Playbooks in einer isolierten Umgebung prüfen.
Dann kam die Realität. Bei manchen Kunden musste ich mit angeblich fertig eingerichteten Servern arbeiten, ohne Kontrolle über deren Konfiguration. Nach einer längeren Debugging Session fragte ich mich: "Warum prüfe ich nicht einfach vorab, ob der Server bereit ist?" So entstand ein Validierungs-Playbook, das nichts konfiguriert, sondern nur testet. Es prüft Konfigurationen wie: Ist die richtige Java Version installiert? Sind die richtigen JVM-Parameter gesetzt? Ist systemd so konfiguriert, wie die Software es braucht? Jede Frage ist eine Assertion.
Die Software benötigt Elasticsearch, eine kleine Assertion überprüfte, ob Elasticsearch lief und der Status "grün" war. War das nicht der Fall, schlug der Test fehl und gab die Rückgabe der Health-Abfrage zurück. Ich habe es so weit gebaut, dass vor jedem Deployment ein kleines Playbook zur Überprüfung des Systems lief.
Mit dem Validierungs-Playbook habe ich angefangen, die Prüfungen auch für meine anderen Rollen zu nutzen. Ich baute immer mehr Assertions direkt in den Ansible Code ein. Wait_for Tasks und gezielte API-Aufrufe waren mein Go-to, und ich fand schneller heraus, wo es hakt. Das Debugging wurde deutlich präziser und einfacher. Durch die Validierung, die direkt auf meinem zu provisionierenden System lief und nicht mehr nur im isolierten Container, wurde das Aufsetzen und Updaten meiner Systeme viel detaillierter. Ich habe direkt Fehler gefunden, die ich in isolierten Umgebungen nicht entdeckt habe und ich verstand das System am Ende auch besser.
Wenn ich doch einmal einen Fehler entdeckte, schrieb ich zuerst einen Test, der den Fehler reproduzierte und passte dann das Playbook an. So blieb ich dicht an den Prinzipien der Softwareentwicklung.
Mein Zwischenfazit bleibt: Molecule Tests sind sinnvoll, aber eben begrenzt. Molecule startet Container und führt die Ansible-Rolle darin aus. So kann ich sicherstellen, dass der Code auf verschiedenen Betriebssystemen läuft und der gewünschte Zustand erreicht wird. Was in diesen Tests fehlt, sind allerdings Themen wie DNS, TLS oder die Anbindung an andere Tools. Für die Frage "Funktioniert meine Rolle?" ist Molecule perfekt. Für "Funktioniert das komplette System?" braucht es mehr und genau da hilft mir das Validierungs-Playbook in der echten Umgebung.
Infrastructure as Code ist nicht nur Ansible
Nach einiger Zeit landete ich in einer deutlich komplexeren Umgebung: Kubernetes Cluster, ein Message Broker, API Gateway sowie ein Monitoring Stack mit Prometheus und OpenTelemetry. Schnell merkte ich: Ansible allein reicht hier nicht. Beim Implementieren eines Terraform Providers für unsere Produkte gewann ich Einblick in Terraform und erkannte, dass sich komplexe Infrastrukturen besser über mehrere Werkzeuge steuern lassen.
Das Deklarative von Terraform war ein Game Changer für mich, abgesehen von Netzwerk und VMs konnte ich auch die installierten Anwendungen deklarativ konfigurieren. Für Dienste wie Keycloak fand ich es sehr spannend, meine Realms, Benutzer und Benutzergruppen zu definieren und anzulegen und wenn ich sie nicht mehr brauchte, konnte ich den Code einfach entfernen, Terraform laufen lassen - und es wurde gelöscht. An Keycloak und Grafana habe ich mich, was die Konfiguration von Anwendungen angeht, ausgetobt. Das hat mir fast noch mehr Spaß gemacht, als VMs, Netzwerk und DNS zu konfigurieren.
Gleichzeitig haben Freunde von mir verstärkt nur auf Terraform für ihre Cloud Only-Umgebung gesetzt und diese mit Terratest getestet. Beim ersten Draufschauen gefiel mir, wie einfach sich Tests in Go schreiben lassen und wie sauber sie vom Infrastruktur-Code getrennt sind. Genau wie Integrations Tests in der Softwareentwicklung. Bei seinen Tests rollt Terraform komplette Umgebungen aus, Terratest ruft per HTTP die bereitgestellte API auf und prüft den Statuscode. Nach dem Test wird alles wieder abgerissen. Das war für mich die perfekte Nutzung von Cloud! Für reine Cloud Setups ist das genial, aber in meiner hybriden Welt aus BareMetal, VMs und Containern reicht Terratest leider nicht.
Die Idee, ein separates Test-Tool zu nutzen, entstand also aus der Mischung von Terratest und dem Einsatz von Terraform. Nach einiger Recherche landete ich beim Robot Framework. Es bietet unzählige Plugins und ist offen für Erweiterungen, wie etwa Bibliotheken für SSH, HTTP oder Datenbanken. In einem Prototyp habe ich damit sogar einen Kubernetes Deploy Workflow automatisiert: Der Test loggt sich per SSH auf dem Cluster ein, prüft via kubectl, ob alle Pods laufen und ruft anschließend das Prometheus-Health_Endpoint ab. Doch die Syntax war so gewöhnungsbedürftig, dass ich mich damit nicht anfreunden konnte.
Für alle, die das Robot Framework nicht kennen, ist hier ein Codebeispiel. In dem Beispiel wird eine Konfiguration ausgelesen, mit Kubernetes-Clustern, Namen von Pods, deren Namespace und wie viele Pods erforderlich sind, healthyzu laufen (die Einrückung ist vom Robot Framework vorgegeben):
*** Settings ***
Library KubeLibrary
Library Collections
Variables config.yml
*** Test Cases ***
Check Kubernetes Clusters Healthy
Check Health of K8S
Check Pods Running
Check Pod Existence And Health
*** Keywords ***
Check Health of K8S
FOR ${cluster} IN @{kubernetes_cluster}
Reload Kubeconfig ${cluster}
Get Healthcheck
${healthy_nodes}= Get Healthy Nodes Count
Should Be Equal ${healthy_nodes} ${cluster.number_of_nodes}
END
Reload Kubeconfig
[Arguments] ${cluster}
Reload Config ${cluster['kubeconfig']}
Check Pod Existence And Health
FOR ${cluster} IN @{kubernetes_cluster}
Reload Kubeconfig ${cluster}
FOR ${expected_pod} IN @{cluster['pods']}
${running_pods}= List Namespaced Pod By Pattern ${expected_pod['name_pattern']} ${expected_pod['namespace']}
Length Should Be ${running_pods} ${expected_pod['required']}
FOR ${single_running_pod} IN @{running_pods}
${status}= Read Namespaced Pod Status ${single_running_pod.metadata.name} ${single_running_pod.metadata.namespace}
Should Be True '${status.phase}'=='Running'
END
END
ENDhttps://gist.github.com/ngotzmann/c49c3cbcbc7cc87e5efd064aa7e6828a
Natürlich stellt sich hier die Frage: Warum habe ich nicht einfach Ansible genommen, um Tests zu schreiben in separaten Playbooks und Rollen? Ansible gibt mir auch alles, was ich brauche, um funktionierende Tests zu schreiben. Aber für mich als Softwareentwickler fühlt sich Ansible zu starr an, Code ist deutlich flexibler und gibt mir mehr Möglichkeiten.
Also machte ich mich weiter auf die Suche nach einem Werkzeug, das Tests für gemischte Infrastrukturen einfacher macht.
k6 strikes back, wie ich Tests schreibe
Also habe ich ein Tool ausgepackt, das ich sowieso schon kannte und das Grafana in letzter Zeit immer stärker nach vorne schiebt: k6. Eigentlich wurde k6 mal gebaut, um APIs unter Last zu testen. Heute ist es längst mehr als nur ein Load Testing Tool. Das offene Plugin System hat k6 zu einem echten Allzweck-Werkzeug gemacht, auch für Infrastruktur-Tests. Es gibt Erweiterungen, die direkt die Kubernetes API ansprechen, SSH-Verbindungen aufbauen, SQL Queries absetzen oder sogar UI Tests fahren. Damit passte k6 für meine Infrastruktur wie die Faust aufs Auge.
Gerade in komplexeren Setups mit vielen Komponenten, die sauber ineinandergreifen müssen, war für mich sofort klar: Die Integrationspunkte sind das, was ich wirklich testen muss.
Ein Beispiel: Wir haben Grafana Dashboards, die Metriken aus unseren Services visualisieren. Diese Metriken gehen zuerst an einen OpenTelemetry Collector, der ihnen ein "Tenant" Label verpasst und landen dann in Prometheus. Von dort zieht Grafana sie wieder heraus und stellt sie dar. Klingt einfach, ist aber eine ganze Kette, bei der Metriken unterwegs verloren gehen oder verfälscht werden können. Jede einzelne Station Microservice - Collector, Prometheus, Grafana - kann ausfallen oder falsche Daten liefern. Und genau das will ich überprüfen: Läuft die Komponente? Nimmt sie Metriken an? Sind die Daten am Ende im Dashboard zu sehen?
Das Schöne daran: Mit einem einzigen Test kann ich erst den kompletten Ablauf prüfen und danach jede Komponente so isoliert wie möglich. So finde ich viel schneller heraus, ob das Problem beim Collector liegt, bei Prometheus oder doch am Dashboard. Die Zeit für die Fehlersuche schrumpft massiv, weil ich nicht mehr blind suchen muss, sondern sofort weiß, welche Stelle in der Kette hakt.
Beispiel-Tests in k6
Ein Test in k6 sieht dann so aus: Zuerst wird der Test vorbereitet, indem meine Konfiguration eingelesen und ein Kubernetes Client initialisiert wird. Danach überprüfe ich, ob die benötigten Systeme überhaupt laufen, also der OpenTelemetry Collector, Prometheus und Grafana.
export default async function () {
const config = ansibleConfigLoader.getConfig('./configs/prod.yml');
const k8sGroup = getGroupConfig(config, 'kubernetes');
const ungroupedConfig = getUngroupedConfig(config);
describe('Validate otel collector, prometheus and grafana is running on k8s', () => {
const k8s = new Kubernetes({
config_path: ungroupedConfig.k8s_admin_cert_path
});
validateDeployment({
k8sClient: k8s,
name: 'grafana',
namespace: k8sGroup.group_vars.grafana_namespace
});
validateDeployment({
k8sClient: k8s,
name: 'prometheus-server',
namespace: k8sGroup.group_vars.prometheus_namespace
});
validateDeployment({
k8sClient: k8s,
name: 'opentelmetry-collector',
namespace: k8sGroup.group_vars.otel_namespace
});
});
describe('Send dummy metrics to otel collector', () => {
sendDummyMetric(k8sGroup);
});
// sleep to wait until otel processed our call
sleep(5);
describe('Validate dummy metrics inside prometheus with added tenant', () => {
checkPrometheusContainsMetrics(k8sGroup);
});
describe('Validate grafana, works and contains metrics', async () => {
await validateGrafanaLoginWorks(k8sGroup.group_vars.grafana_hostname, {
username: k8sGroup.group_vars.grafana_username,
password: k8sGroup.group_vars.grafana_password
});
checkPromMetricsViaGrafanaApi(k8sGroup);
});
}
function validateDeployment(config: DeploymentConfig) {
expect(config.name).to.not.be.empty;
expect(config.namespace).to.not.be.empty;
const deployment = config.k8sClient.get('Deployment.apps', config.name, config.namespace);
if (config.replicas) {
expect(deployment.status.readyReplicas).to.equal(config.replicas);
} else {
expect(deployment.status.readyReplicas).to.equal(deployment.status.replicas);
}
}https://gist.github.com/ngotzmann/c8b3ac62527fa0f6f1923ed3a1c06403
Als Nächstes pushe ich eine Dummy-Metrik in unseren OpenTelemetry Collector, um sie später aus Prometheus auszulesen und zu überprüfen.
Am Ende checke ich, ob ich einen HTTP 200 Statuscode zurückbekomme, um sicherzugehen, dass der Collector die Metrik angenommen hat:
function sendDummyMetric(config: any) {
const otelRequest = {
resource_metrics: [
{
resource: {
attributes: [
{ key: 'service.name', value: { string_value: 'k6-otlp-test' } },
],
},
scope_metrics: [
{
scope: { name: 'k6' },
metrics: [
{
name: 'k6_dummy_metric',
description: 'a test metric sent by k6 test',
unit: '1',
gauge: {
data_points: [
{
attributes: [{ key: 'env', value: { string_value: 'k6-dev' } }],
as_double: 42.0
}
],
},
},
],
},
],
},
],
};
const headers = { headers: { 'Content-Type': 'application/json' } };
const res = http.post(config.group_vars.otel_url, JSON.stringify(otelRequest), headers);
expect(res.status).to.be.equal(200);
}https://gist.github.com/ngotzmann/2f1368e63dfd7129a76ef13bd3051a24
Damit ich nicht in Prometheus nach der Metrik suche, bevor sie da ist, warte ich fünf Sekunden… Mir ist klar, dass Polling schöner wäre als ein "Sleep". Die Abfrage in Prometheus ist dank PromQL aber ziemlich einfach:
function checkPrometheusContainsMetrics(k8sGroup: any) {
expect(k8sGroup).to.not.be.null;
const promBasicAuthPw = k8sGroup.group_vars.prometheus_basic_auth_password;
const promBasicAuthUser = k8sGroup.group_vars.prometheus_basic_auth_user;
k8sGroup.hosts.forEach((h) => {
const promConfig = {
auth: {
username: promBasicAuthUser,
password: promBasicAuthPw
} as BasicAuth,
hostname: k8sGroup.group_vars.prometheus_hostname,
query: `dummy_metric{env="k6-dev"}`
} as PromQueryConfig;
validatePrometheusQuery(promConfig);
});
}
export function validatePrometheusQuery(config: PromQueryConfig) {
expect(config).to.not.be.null;
expect(config.hostname).to.not.be.empty;
expect(config.query).to.not.be.empty;
let request;
if (isAuthSet(config?.auth)) {
request = `https://${config.hostname}/api/v1/query?query=${encodeURIComponent(config.query)}`;
} else {
request = `https://${config.auth.username}:${config.auth.password}@${config.hostname}/api/v1/query?query=${encodeURIComponent(config.query)}`;
}
const rawRes = http.get(request);
expect(rawRes.status).to.equal(200);
const body = JSON.parse(rawRes.body);
expect(body.data.result[0].value).to.not.empty;
} https://gist.github.com/ngotzmann/4d93caa3817aadf0ed549f2bd581a1b7
Zu guter Letzt prüfe ich, ob Grafana wirklich funktioniert, indem ich mich testweise einlogge und dann über die API noch einmal unsere Metrik abrufe und überprüfe:
export async function validateGrafanaLoginWorks(hostname: string, creds: Credentials) {
expect(hostname).to.not.be.empty;
expect(creds.username).to.not.be.empty;
expect(creds.password).to.not.be.empty;
const url = `https://${hostname}/explore`;
const username = creds.username;
const password = creds.password;
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(url);
await page.locator('input[name="user"]').type(username);
await page.locator('input[name="password"]').type(password);
await Promise.all([page.waitForNavigation(), page.locator('button[type="submit"]').click()]);
} finally {
await page.close();
}
}
function checkPromMetricsViaGrafanaApi(config: any) {
expect(config).to.not.be.null;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.group_vars.grafana_k6_api_token}`
};
const grafanaUrl = config.group_vars.grafana_hostname;
let res = http.get(
`https://${grafanaUrl}/api/datasources/name/${config.group_vars.prometheus_datasource_name}`,
{ headers }
);
expect(res.status).to.be.equal(200);
const datasource = res.json();
expect(datasource.uid).to.not.be.empty;
expect(datasource.type).to.not.be.empty;
let now = new Date();
let from = new Date(now.getTime() - 60 * 60 * 1000); // 1h ago
config.hosts.forEach((h) => {
const q = {
queries: [
{
refId: 'A',
expr: `dummy_metric{env="k6-dev"}`,
range: true,
instant: false,
datasource: {
type: datasource.type,
uid: datasource.uid
},
datasourceId: datasource.id,
intervalMs: 15000,
maxDataPoints: 2010
}
],
from: from.toISOString(),
to: now.toISOString()
};
let queryRes = http.post(`https://${grafanaUrl}/api/ds/query`, JSON.stringify(q), { headers });
expect(queryRes.status).to.be.equal(200);
expect(queryRes.status).to.be.equal(200);
const body = JSON.parse(queryRes.body);
expect(body.results.A.frames[0].data.values).to.not.be.empty;
});
}https://gist.github.com/ngotzmann/4200e61931b4809f8cd69e25e1d454e7
Ach, übrigens! Damit ich meine Variablen nicht doppelt pflegen muss, lese ich diese aus Ansible ein, mithilfe einer k6 Extension.
Plattform-Checks über die Anwendungsebene hinaus
Bisher haben wir uns in unseren Beispielen vor allem auf die Anwendungsebene konzentriert. Unsere Infrastruktur-Tests sollten sich aber nicht darauf beschränken, im Gegenteil! Auch unsere Plattform (zum Beispiel Kubernetes) und das darunterliegende Betriebssystem (Linux) verdienen automatisierte Checks.
Für Kubernetes gibt es mit Sonobuoy ein Tool, das standardisierte Conformance Tests ausführt, um zu prüfen, ob ein Cluster korrekt aufgesetzt ist und zentrale APIs sowie Netzwerk, Speicher und Scheduling funktionieren. Da meine Erfahrung mit dem Tool noch begrenzt ist, bleibe ich im Moment bei einfachen, aber effektiven Tests. Konkret teste ich:
- Node Readiness: Sind die einzelnen Nodes "ready" und "healthy"? Hat eine Node zu wenig Speicherplatz, eine zu hohe RAM-Belegung oder laufen zu viele Prozesse?
- Konnektivität zwischen den Nodes: Mit einem simplen Ping prüfe ich, ob jede Node die anderen Nodes erreichen kann.
- Kubernetes-Netzwerk-Readiness: Zuerst überprüfe ich, ob mein Netzwerk (CNI) und die CoreDNS Pods bereit sind und nicht crashen.
Das sieht so aus: Ich bereite meinen Test vor, indem ich meine Konfiguration einlese, den Kubernetes Client initialisiere und die einzelnen Testcases triggere:
export default async function () {
const config = ansibleConfigLoader.getConfig('./configs/prod.yml');
const k8sGroup = getGroupConfig(config, 'kubernetes');
const ungroupedConfig = getUngroupedConfig(config);
const k8s = new Kubernetes({
config_path: ungroupedConfig.k8s_admin_cert_path
});
describe('Validate that all nodes are healthy', () => {
checkNodeHealth(k8s);
});
describe('Validate that all k8s hosts can ping each other', () => {
checkNodeConnection(k8sGroup, ungroupedConfig);
});
describe('Validate that all pods in kube-system namespace are healthy', () => {
validatePodsHealthInNamespace('kube-system', k8s);
});
}https://gist.github.com/ngotzmann/afbc52d8901505b85923bc411a2f96d7
Zuerst wird checkNodeHealth ausgeführt, das über die Nodes unseres Clusters iteriert und für jede einzelne Node den Health Status abfragt. Die Kubernetes k6 Extension nimmt uns hier viel Arbeit ab. Wir bekommen die Werte zurück und können einfach prüfen, ob der Zustand dem entspricht, was wir erwarten:
function checkNodeHealth(k8sClient: any) {
const nodes = k8sClient.list('Node');
for (const node of nodes) {
validateNodeHealth(node);
}
}
function validateNodeHealth(node: any) {
expect(node).to.not.be.null;
expect(node.status).to.not.be.null;
expect(node.status.conditions).to.not.be.null;
const conditions = node.status.conditions;
const conditionMap = conditions.reduce((map, cond) => {
map[cond.type] = cond.status;
return map;
}, {});
expect(conditionMap.Ready).to.equal('True');
expect(conditionMap.MemoryPressure).to.equal('False');
expect(conditionMap.DiskPressure).to.equal('False');
expect(conditionMap.PIDPressure).to.equal('False');
expect(conditionMap.NetworkUnavailable).to.equal('False');
}https://gist.github.com/ngotzmann/00ba1b75da1700fe34009fda4dfc5c64
Als Nächstes überprüfe ich, ob die Nodes sich untereinander erreichen können. Dafür benutze ich die SSH Extension von k6. Wir iterieren wieder über jeden Host, verbinden uns per SSH und führen einen simplen Ping aus, um zu sehen, ob er erfolgreich ist:
function checkNodeConnection(config: any, ungroupedConfig: any) {
expect(config).to.not.be.null;
expect(ungroupedConfig).to.not.be.null;
config.hosts.forEach((h) => {
config.hosts.forEach((hostToPing) => {
validateHostCanPingHosts(
{
host: h.host_name as string,
port: ungroupedConfig.ssh_port as number,
user: ungroupedConfig.ssh_user as string,
sshKey: ungroupedConfig.ssh_key as string,
sshKeyPassphrase: ungroupedConfig.ssh_passphrase as string
} as SshConfig,
hostToPing.host_name
);
});
});
}
function validateHostCanPingHosts(config: SshConfig, hostToPing: string) {
const ssh = establishSSHConnection(config);
const result = ssh.run(
`ping -c 3 -W 3 ${hostToPing} >/dev/null 2>&1 && echo "success" || echo "failed"`
);
expect(result).to.include('success');
}https://gist.github.com/ngotzmann/e57a6bf0ee1e682dd6c4eae626aa7dff
Zuletzt überprüfe ich, ob meine Netzwerk- und CoreDNS Pods bereit sind. Sie befinden sich alle im "kube-system" Namespace. Da im kube-system Namespace nur Pods sind, die benötigt werden. damit das Cluster läuft, erwarte ich, dass kein Pod im Status CrashLoopBackOff landet:
function validatePodsHealthInNamespace(namespace: string, k8sClient: any) {
const allPods = k8sClient.list('Pod', namespace);
const notRunningPods = allPods.filter((pod: any) => pod.status.phase !== 'Running');
notRunningPods.length > 0
? notRunningPods.forEach((pod: any) =>
console.log(`- ${pod.metadata.name}: ${pod.status.phase}`)
)
: console.log('All pods are in Running state.');
}https://gist.github.com/ngotzmann/d32add47298550b38add134b3bbc23e8
Und weil Kubernetes am Ende auf Linux läuft, werfe ich auch einen Blick auf die Hosts selbst. Welche systemd Services müssen laufen? Eine Kubernetes Node braucht mindestens eine CRI (Container Runtime Daemon), z. B. containerd oder CRI-O und den kubelet. Ich prüfe, ob diese Dienste aktiv sind. Wenn mein Kubernetes Cluster andere Server erreichen muss, zum Beispiel ein Datenbank-Cluster, baue ich Tests ein, die versuchen, das Datenbank-Cluster zu erreichen.
Ich bereite meinen Test wieder vor, indem ich meine Konfiguration einlese und die einzelnen Testcases triggere:
export default async function () {
const config = ansibleConfigLoader.getConfig('./configs/prod.yml');
const k8sGroup = getGroupConfig(config, 'kubernetes');
const ungroupedConfig = getUngroupedConfig(config);
describe('Validate on all k8s hosts that node exporter is running', () => {
checkCrioProcess(k8sGroup, ungroupedConfig);
checkKubeletProcess(k8sGroup, ungroupedConfig);
});
}https://gist.github.com/ngotzmann/3c331a7871e1a87fca87c317ae02c179
Die beiden Tests sehen fast gleich aus: Wir loggen uns per SSH auf die Server ein und überprüfen, ob ein Service mit dem Namen kubelet und crio läuft:
function checkKubeletProcess(config: any, ungroupedConfig: any) {
expect(config).to.not.be.null;
expect(ungroupedConfig).to.not.be.null;
const cp = config.hosts.find((h) => {
return h.name.includes('control-plane');
});
validateSystemdProcessRunning(
{
host: cp.host_name as string,
port: ungroupedConfig.ssh_port as number,
user: ungroupedConfig.ssh_user as string,
sshKey: ungroupedConfig.ssh_key as string,
sshKeyPassphrase: ungroupedConfig.ssh_passphrase as string
} as SshConfig,
'kubelet'
);
}
function checkCrioProcess(config: any, ungroupedConfig: any) {
expect(config).to.not.be.null;
expect(ungroupedConfig).to.not.be.null;
config.hosts.forEach((h) => {
validateSystemdProcessRunning(
{
host: h.host_name as string,
port: ungroupedConfig.ssh_port as number,
user: ungroupedConfig.ssh_user as string,
sshKey: ungroupedConfig.ssh_key as string,
sshKeyPassphrase: ungroupedConfig.ssh_passphrase as string
} as SshConfig,
'crio'
);
});
}
export function validateSystemdProcessRunning(config: SshConfig, process: string) {
expect(config).to.not.be.null;
expect(config.host).to.not.be.empty;
expect(config.port).to.not.be.null;
expect(process).to.not.be.empty;
expect(config.user).to.not.be.empty;
const ssh = establishSSHConnection(config);
const isSubStateRunning = ssh.run(`sudo systemctl show --no-pager ${process} | grep SubState`);
expect(isSubStateRunning).to.include('SubState=running');
}https://gist.github.com/ngotzmann/5d4bf259ccb344e2d4cc2f0d234d7abc
Es gibt keinen goldenen Test-Hammer
k6 fühlt sich für mein aktuelles Setup genau richtig an. Das Tool erlaubt es mir, Last- und Integrationstests als Code zu schreiben und damit schon früh im Entwicklungsprozess verlässlich auf Performance- und Stabilitätsprobleme zu prüfen. Einzelne Rollen, die auf mehreren Betriebssystemen laufen sollen, teste ich weiterhin mit Molecule. Doch wie so oft in der IT gibt es keine Allzweckwaffe: In einer reinen Cloud-Umgebung mit Terraform reicht dir wahrscheinlich Terratest. Arbeitet ihr vor allem in Windows-Umgebungen? Dann braucht ihr vermutlich anderes Werkzeug. Mich würde interessieren, wie weit man den hier beschriebenen Testansatz unter Windows tragen kann.
Was mich beim Aufsetzen neuer Systeme immer wieder beschäftigt, ist die Frage: Was genau soll ich testen? In der Softwareentwicklung will man alle Edge Cases abdecken. Findet man später einen Bug, schreibt man erst einen Test und repariert dann den Code. Bei Infrastruktur ist das anders: Es ist nicht meine Software, ich steuere nur, wie sie läuft. Natürlich prüfe ich, ob eine Anwendung grundsätzlich funktioniert, zum Beispiel ob Grafana startet, aber ich halte mich dabei bewusst zurück. Mein Fokus liegt auf den Integrationspunkten: Reden die Dienste richtig miteinander? Bei Kubernetes Deployments nutze ich dafür die eingebauten Health Checks, wenn ein Pod "ready" meldet, kann ich das in meinen Tests abfragen, statt immer die API oder UI anzupingen.
Aber was ist jetzt mit den Updates?
Jetzt habt ihr viel über Tests, Infrastructure as Code, Automatisierung und Testsysteme gelesen. Aber wo bleiben die Updates bei dem Ganzen? Automatisiertes Aufsetzen von Umgebungen, flüchtigen oder dauerhaften Testumgebungen, und vor allem die automatisierten Tests sind die Grundlage für automatisierte Updates.
Für das Updaten müssen wir leider immer noch das Changelog durchschauen und unsere Config anpassen, vor allem bei größeren Versionssprüngen. Da Doku aber nicht immer korrekt ist oder ihr Fälle von Edge Cases habt, muss jedes Update sowieso ausführlich getestet werden. Das Updaten von Anwendungen ist am Ende, wenn richtig gemacht, nur das Erhöhen der Version, die in einer Variablen steht, das Anpassen der Config und zu guter Letzt das Ausführen eines IaC Tools. Die Magie sind die Tests, die uns die Zeit abnehmen, um die Anwendung auf ihre Funktionalität zu prüfen, das Debugging zu verkürzen und den möglichen Fehler einzugrenzen. Bei kleineren Versionssprüngen und mir gut bekannter Software lasse ich auch das Lesen des Changelogs weg oder scrolle nur einmal drüber, bis dann tatsächlich etwas kaputt geht.
Eine Herausforderung bleibt: Mit Tests wächst der Infrastruktur-Code-Stack. Terraform provisioniert die VMs, Ansible konfiguriert sie und installiert Kubernetes und Prometheus, Terraform richtet zusätzliche Dienste wie Keycloak ein und ganz am Ende kommen die Tests. All diese Schritte müssen in der richtigen Reihenfolge ablaufen. Je mehr IaC-Bausteine ich nutze, desto mehr hätte ich gerne ein Orchestrierungstool, das die Abfolge koordiniert.
Am Ende bleibt: Automatisierte Tests sind kein Selbstzweck. Sie sind das Werkzeug, das uns erlaubt, Änderungen zu wagen und Updates angstfrei zu implementieren. Wer Infrastruktur wie Code behandelt und sie wie Software testet, spart nicht nur Zeit, sondern gewinnt auch Vertrauen in das eigene System. Und vielleicht schauen dann auch weniger Leute komisch, wenn man erzählt, dass die Updates automatisiert sind.












