<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jonathan Bouligny</title>
    <description>The latest articles on DEV Community by Jonathan Bouligny (@jonathanbouligny).</description>
    <link>https://dev.to/jonathanbouligny</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F234318%2F318b9477-d4b9-4f81-973d-4d9eb4b34374.jpeg</url>
      <title>DEV Community: Jonathan Bouligny</title>
      <link>https://dev.to/jonathanbouligny</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonathanbouligny"/>
    <language>en</language>
    <item>
      <title>Phase 5, Part 1: Prometheus, PromQL and My First SLO Alert</title>
      <dc:creator>Jonathan Bouligny</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/jonathanbouligny/prometheus-promql-and-my-first-slo-alert-6e0</link>
      <guid>https://dev.to/jonathanbouligny/prometheus-promql-and-my-first-slo-alert-6e0</guid>
      <description>&lt;p&gt;So I installed kube-prometheus-stack, which includes Alertmanager, Prometheus, Grafana and everything else the chart comes with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jon@leela:~/super-dumb-repos/aws-lab$ kubectl get pods,pvc,svc -n monitoring
NAME                                                         READY   STATUS    RESTARTS   AGE
pod/alertmanager-prometheus-kube-prometheus-alertmanager-0   2/2     Running   0          42h
pod/prometheus-grafana-6c5d845bc5-2bhmw                      3/3     Running   0          18h
pod/prometheus-kube-prometheus-operator-6c6dbff88-5b4bh      1/1     Running   0          42h
pod/prometheus-kube-state-metrics-6bfc9c7c48-79zr7           1/1     Running   0          42h
pod/prometheus-prometheus-kube-prometheus-prometheus-0       2/2     Running   0          42h
pod/prometheus-prometheus-node-exporter-mxgkb                1/1     Running   0          42h
pod/prometheus-prometheus-node-exporter-p8dmv                1/1     Running   0          42h

NAME                                              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
service/alertmanager-operated                     ClusterIP   None            &amp;lt;none&amp;gt;        9093/TCP,9094/TCP,9094/UDP   42h
service/prometheus-grafana                        ClusterIP   10.43.164.175   &amp;lt;none&amp;gt;        80/TCP                       42h
service/prometheus-kube-prometheus-alertmanager   ClusterIP   10.43.143.112   &amp;lt;none&amp;gt;        9093/TCP,8080/TCP            42h
service/prometheus-kube-prometheus-operator       ClusterIP   10.43.241.200   &amp;lt;none&amp;gt;        443/TCP                      42h
service/prometheus-kube-prometheus-prometheus     ClusterIP   10.43.246.45    &amp;lt;none&amp;gt;        9090/TCP,8080/TCP            42h
service/prometheus-kube-state-metrics             ClusterIP   10.43.18.188    &amp;lt;none&amp;gt;        8080/TCP                     42h
service/prometheus-operated                       ClusterIP   None            &amp;lt;none&amp;gt;        9090/TCP                     42h
service/prometheus-prometheus-node-exporter       ClusterIP   10.43.110.128   &amp;lt;none&amp;gt;        9100/TCP                     42h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting the Grafana password in
&lt;/h2&gt;

&lt;p&gt;The next thing is getting the values file together, specifically for a password. That was as easy as finding the value in the values.yml file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;## Name of the secret. Can be templated.&lt;/span&gt;
    &lt;span class="na"&gt;existingSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grafana-admin"&lt;/span&gt;
    &lt;span class="na"&gt;userKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin-user&lt;/span&gt;
    &lt;span class="na"&gt;passwordKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin-password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then creating a secret matching that value and adding it to the secret preloader.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure bootstrap secrets&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubeconfig_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.kube/config"&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;argocd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/home/jon/super-dumb-repos/password-files/secret-argo-forgejo.yml&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;cert-manager&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/home/jon/super-dumb-repos/password-files/cloudflare-api-token.yml&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;haproxy-ingress-internal&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/home/jon/super-dumb-repos/password-files/wehaveaproblem-net.yml&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/home/jon/super-dumb-repos/password-files/grafana-admin.yml&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Make sure namespaces for the secrets exist&lt;/span&gt;
      &lt;span class="na"&gt;kubernetes.core.k8s&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kubeconfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kubeconfig_path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bootstrap_secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Apply secrets in loop&lt;/span&gt;
      &lt;span class="na"&gt;kubernetes.core.k8s&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kubeconfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kubeconfig_path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bootstrap_secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;no_log&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alright! After that we added ingresses for Grafana, Alertmanager and Prometheus so we can get at all three GUIs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;haproxy-internal&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grafana.wehaveaproblem.net"&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus-grafana&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alert-manager&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;haproxy-internal&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertmanager.wehaveaproblem.net"&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus-kube-prometheus-alertmanager&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9093&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;haproxy-internal&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prometheus.wehaveaproblem.net"&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus-kube-prometheus-prometheus&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9090&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Messing with Prometheus
&lt;/h2&gt;

&lt;p&gt;From there I started messing with Prometheus.&lt;/p&gt;

&lt;p&gt;A little about Prometheus first. Prometheus is a monitoring suite that collects time series data about an endpoint. I got to work looking at the data Prometheus collects by default before creating any new data.&lt;/p&gt;

&lt;p&gt;First we stuck with the tried and true &lt;code&gt;up&lt;/code&gt; metric. In Prometheus an instance is any monitored endpoint, specifically could we scrape from it, and a job is a collection of endpoints associated with an object, say the kubelet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;job&lt;/th&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;metrics_path / container&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;192.168.1.20:10250&lt;/td&gt;
&lt;td&gt;/metrics&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;192.168.1.20:10250&lt;/td&gt;
&lt;td&gt;/metrics/cadvisor&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;192.168.1.20:10250&lt;/td&gt;
&lt;td&gt;/metrics/probes&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;192.168.1.21:10250&lt;/td&gt;
&lt;td&gt;/metrics&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;192.168.1.21:10250&lt;/td&gt;
&lt;td&gt;/metrics/cadvisor&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;192.168.1.21:10250&lt;/td&gt;
&lt;td&gt;/metrics/probes&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apiserver&lt;/td&gt;
&lt;td&gt;192.168.1.20:6443&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-prometheus&lt;/td&gt;
&lt;td&gt;10.42.1.15:9090&lt;/td&gt;
&lt;td&gt;prometheus&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-prometheus&lt;/td&gt;
&lt;td&gt;10.42.1.15:8080&lt;/td&gt;
&lt;td&gt;config-reloader&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-alertmanager&lt;/td&gt;
&lt;td&gt;10.42.1.14:9093&lt;/td&gt;
&lt;td&gt;alertmanager&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-alertmanager&lt;/td&gt;
&lt;td&gt;10.42.1.14:8080&lt;/td&gt;
&lt;td&gt;config-reloader&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-operator&lt;/td&gt;
&lt;td&gt;10.42.1.13:10250&lt;/td&gt;
&lt;td&gt;kube-prometheus-stack&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kube-state-metrics&lt;/td&gt;
&lt;td&gt;10.42.0.12:8080&lt;/td&gt;
&lt;td&gt;kube-state-metrics&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coredns&lt;/td&gt;
&lt;td&gt;10.42.0.4:9153&lt;/td&gt;
&lt;td&gt;coredns&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-grafana&lt;/td&gt;
&lt;td&gt;10.42.0.20:3000&lt;/td&gt;
&lt;td&gt;grafana&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;17 series. Every row also carries &lt;code&gt;namespace&lt;/code&gt;, &lt;code&gt;pod&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt; and &lt;code&gt;endpoint&lt;/code&gt; labels, trimmed here.&lt;/p&gt;

&lt;p&gt;This list from running &lt;code&gt;up&lt;/code&gt; just tells us which endpoints we're able to scrape from or not.&lt;/p&gt;

&lt;p&gt;So we can use &lt;code&gt;count by (job)&lt;/code&gt; to group the instances by job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;count by (job) (up)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;job&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apiserver&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-alertmanager&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-prometheus&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kube-state-metrics&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coredns&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-operator&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-grafana&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This says there are 6 instances associated with the kubelet job, and if you look at the last screenshot there are 3 endpoints per node and 2 nodes.&lt;/p&gt;

&lt;p&gt;Next is a more useful metric, &lt;code&gt;node_cpu_seconds_total&lt;/code&gt;. Node exporter can be scraped for mode and time series data into &lt;code&gt;node_cpu_seconds_total&lt;/code&gt;. On its own the data is a little useless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_cpu_seconds_total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;cpu&lt;/th&gt;
&lt;th&gt;mode&lt;/th&gt;
&lt;th&gt;value (seconds)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;idle&lt;/td&gt;
&lt;td&gt;218658.53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;iowait&lt;/td&gt;
&lt;td&gt;154.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;irq&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;nice&lt;/td&gt;
&lt;td&gt;1.84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;softirq&lt;/td&gt;
&lt;td&gt;518.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;steal&lt;/td&gt;
&lt;td&gt;15.52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;system&lt;/td&gt;
&lt;td&gt;1080.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;3230.68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;idle&lt;/td&gt;
&lt;td&gt;218684.56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;iowait&lt;/td&gt;
&lt;td&gt;160.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;64 series: 8 modes x 4 cpus x 2 nodes.&lt;/p&gt;

&lt;p&gt;But if we add the &lt;code&gt;rate&lt;/code&gt; function we can get some very useful information. &lt;code&gt;rate&lt;/code&gt; calculates how fast whatever it is counting climbs per second. In this case it outputs a fraction and tells us how much of a 5 minute window the mode is taking up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate(node_cpu_seconds_total[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;cpu&lt;/th&gt;
&lt;th&gt;mode&lt;/th&gt;
&lt;th&gt;value (per second)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;idle&lt;/td&gt;
&lt;td&gt;0.9756&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;iowait&lt;/td&gt;
&lt;td&gt;0.0009&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;irq&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;64 series again, one per cpu and mode. The idle row says cpu 0 spent 97.6% of the last 5 minutes idle.&lt;/p&gt;

&lt;p&gt;Now if we add a selection for a specific mode like idle we can see how much time each core spends in idle mode on each node.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate(node_cpu_seconds_total{mode="idle"}[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;cpu&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.9738&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.9730&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.9731&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.9718&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.9826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.9840&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.9829&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.9834&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the full expression averages the cores by instance. It averages the rate the cpu spent in idle per second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0.9739&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;0.9835&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then if we calculate this we actually get average cpu usage over a 5 minute window, the percentage of time spent outside of idle mode!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;cpu used (%)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;2.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;1.67&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Next we did percentage of unused memory. Memory is a gauge, so there is no &lt;code&gt;rate&lt;/code&gt; here, just a division.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;available (fraction)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0.6516&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;0.8102&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then memory used, which is just 1 minus that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;used (fraction)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.20:9100&lt;/td&gt;
&lt;td&gt;0.3584&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;192.168.1.21:9100&lt;/td&gt;
&lt;td&gt;0.2034&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a subtle one. I did bytes available on a file system divided by total bytes on a file system and filtered out the tmpfs file system types.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;node_filesystem_avail_bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;node_filesystem_size_bytes&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;fstype&lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="s2"&gt;"tmpfs"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing is it worked. Something subtle happens here. When you divide one metric by another, PromQL pairs up each of the values by matching identical labels. Since I excluded tmpfs from one side it excluded it from the other, because it only tried to divide the pairs. The explicit version is this, which just makes it clear.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;node_filesystem_avail_bytes&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;fstype&lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="s2"&gt;"tmpfs"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;node_filesystem_size_bytes&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;fstype&lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="s2"&gt;"tmpfs"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next we learned about &lt;code&gt;increase&lt;/code&gt;. The explanation is weird but what it actually means is it calculates how much a given value went up in a given timeframe. So when we run this it literally gives us the number of container restarts in the last hour.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;increase(kube_pod_container_status_restarts_total[1h])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;namespace&lt;/th&gt;
&lt;th&gt;pod&lt;/th&gt;
&lt;th&gt;container&lt;/th&gt;
&lt;th&gt;restarts in 1h&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;monitoring&lt;/td&gt;
&lt;td&gt;prometheus-prometheus-node-exporter-mxgkb&lt;/td&gt;
&lt;td&gt;node-exporter&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd&lt;/td&gt;
&lt;td&gt;argocd-redis-c4fdd947-4ml4t&lt;/td&gt;
&lt;td&gt;redis&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager&lt;/td&gt;
&lt;td&gt;cert-manager-webhook-558c6d4f4d-htrjf&lt;/td&gt;
&lt;td&gt;cert-manager-webhook&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;monitoring&lt;/td&gt;
&lt;td&gt;prometheus-kube-prometheus-operator-6c6dbff88-5b4bh&lt;/td&gt;
&lt;td&gt;kube-prometheus-stack&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd&lt;/td&gt;
&lt;td&gt;argocd-repo-server-7fd96b6d87-k7rhl&lt;/td&gt;
&lt;td&gt;repo-server&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;guestbook-ui-5d6468fd55-57nbp&lt;/td&gt;
&lt;td&gt;guestbook-ui&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kube-system&lt;/td&gt;
&lt;td&gt;coredns-54996dc9b4-d86db&lt;/td&gt;
&lt;td&gt;coredns&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd&lt;/td&gt;
&lt;td&gt;argocd-application-controller-0&lt;/td&gt;
&lt;td&gt;application-controller&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;td&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;37 series, all 0. Nothing has restarted in the last hour.&lt;/p&gt;

&lt;p&gt;The next one is memory per pod. This gives us the total amount of memory used at an instant of time per pod. We do this by summing the memory of all the containers by pod. &lt;code&gt;sum&lt;/code&gt; always grabs an instant of time, it is not summing over time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (pod) (container_memory_working_set_bytes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;pod&lt;/th&gt;
&lt;th&gt;bytes&lt;/th&gt;
&lt;th&gt;MiB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-prometheus-kube-prometheus-prometheus-0&lt;/td&gt;
&lt;td&gt;1033609216&lt;/td&gt;
&lt;td&gt;986&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-application-controller-0&lt;/td&gt;
&lt;td&gt;685572096&lt;/td&gt;
&lt;td&gt;654&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager-cainjector-6468bc96c7-bbwg4&lt;/td&gt;
&lt;td&gt;87932928&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-repo-server-7fd96b6d87-k7rhl&lt;/td&gt;
&lt;td&gt;84955136&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;alertmanager-prometheus-kube-prometheus-alertmanager-0&lt;/td&gt;
&lt;td&gt;70520832&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-server-7647b49864-nq5nc&lt;/td&gt;
&lt;td&gt;57892864&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager-76ffbfcbfc-2tj5w&lt;/td&gt;
&lt;td&gt;51552256&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-kube-prometheus-operator-6c6dbff88-5b4bh&lt;/td&gt;
&lt;td&gt;50356224&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-applicationset-controller-7c7f547b68-5gmk2&lt;/td&gt;
&lt;td&gt;46415872&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-dex-server-7d7dcff6f4-ssrv5&lt;/td&gt;
&lt;td&gt;44691456&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-notifications-controller-fd7558bd5-d92v2&lt;/td&gt;
&lt;td&gt;42352640&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager-webhook-558c6d4f4d-htrjf&lt;/td&gt;
&lt;td&gt;27357184&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-prometheus-node-exporter-p8dmv&lt;/td&gt;
&lt;td&gt;22110208&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prometheus-prometheus-node-exporter-mxgkb&lt;/td&gt;
&lt;td&gt;22102016&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;argocd-redis-c4fdd947-4ml4t&lt;/td&gt;
&lt;td&gt;21618688&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;guestbook-ui-5d6468fd55-57nbp&lt;/td&gt;
&lt;td&gt;18366464&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;local-path-provisioner-77b9867795-b44nc&lt;/td&gt;
&lt;td&gt;16564224&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;25 series, sorted by size here. Prometheus itself is the biggest thing on the cluster at almost 1 GiB.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I learned
&lt;/h3&gt;

&lt;p&gt;I learned that an instance represents an endpoint and a job represents a collection of instances. Each line in Prometheus reflects a combination of job and instance, it's a unique row. Each row represents a set of time series data. There are three types of data, counters which can only go up or be reset to zero, gauges which can go up and down arbitrarily, and histograms which are buckets of data.&lt;/p&gt;

&lt;p&gt;We started by looking at the Prometheus GUI and looking at the data set for &lt;code&gt;up&lt;/code&gt;. &lt;code&gt;up&lt;/code&gt; is the basic one, it checks to see if monitored endpoints have been scraped or not. Then we can check things like &lt;code&gt;node_cpu_seconds_total&lt;/code&gt;, which tells us how long the cpu spent in a specific mode. &lt;code&gt;node_cpu_seconds_total&lt;/code&gt; is a counter because you can't take back time, it can only go up. Then we can use that to calculate used and free cpu percentages. We learned to use &lt;code&gt;rate&lt;/code&gt;, which only takes counters like &lt;code&gt;node_cpu_seconds_total&lt;/code&gt;. Rate is literally the rate per second of something increasing. We can do the same with memory, which is a gauge. We can calculate the available memory percentage and then calculate used from there. We can look at available storage for file systems and we learned how to use filters, which allowed us to filter out the tmpfs file systems. We learned how to use the &lt;code&gt;increase&lt;/code&gt; function, which takes in a set of time filtered data, for example 1h for 1 hour, and shows how much the number has increased over that time period. We learned to check which pods are not running by also using filters. We learned to sum gauges with the aggregate function by label syntax, in this case &lt;code&gt;sum by (pod)&lt;/code&gt;, and we used that to calculate the memory used by all the containers in a pod.&lt;/p&gt;

&lt;p&gt;Then we learned about histograms, which use buckets to group metrics, and this is where we combined everything together. First we got the rate of change per second over 5 minutes for each instance, then we summed all the rates that match up with the different buckets using the &lt;code&gt;le&lt;/code&gt; label, which is the bucket boundary, then we grabbed the 95th percentile value. The metric was &lt;code&gt;prometheus_http_request_duration_seconds_bucket&lt;/code&gt;, so the whole thing told us how slow the slowest 5% of requests were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alright, time for SLIs and SLOs
&lt;/h2&gt;

&lt;p&gt;We're going to be measuring the uptime of grafana.wehaveaproblem.net (which is my internal domain, not externally reachable). To do that we need blackbox exporter, a monitoring system that mimics what it's like to access the cluster from the outside, from inside the cluster. We install blackbox with helm, specifically we make an Argo CD application, that's easy enough. Then we set up a Probe, which is a Prometheus Operator CRD that defines how to scrape metrics using an external exporter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.coreos.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Probe&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-blackbox&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
  &lt;span class="na"&gt;module&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http_2xx&lt;/span&gt;
  &lt;span class="na"&gt;prober&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;blackbox-prometheus-blackbox-exporter:9115&lt;/span&gt;   &lt;span class="c1"&gt;# match the service name (host:port, no https://)&lt;/span&gt;
  &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;staticConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;static&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;https://grafana.wehaveaproblem.net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I defined the exporter in a folder in my app of apps folder so that later when the cluster is rebuilt it will be rebuilt with the rest of the pieces.&lt;/p&gt;

&lt;p&gt;We go to Prometheus and...&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;probe_success{instance="https://grafana.wehaveaproblem.net"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;job&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://grafana.wehaveaproblem.net" rel="noopener noreferrer"&gt;https://grafana.wehaveaproblem.net&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;probe/monitoring/grafana-blackbox&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SUCCESS!&lt;/p&gt;

&lt;h2&gt;
  
  
  Next we need to configure an alert
&lt;/h2&gt;

&lt;p&gt;We configure an alert using a PrometheusRule, a CRD that defines alerting and recording rules to be evaluated by Prometheus. Recording rules are precomputed metrics computed on an interval, and alerting rules are rules that... alert.&lt;/p&gt;

&lt;p&gt;So we create this PrometheusRule file along with the Google SRE Workbook.&lt;/p&gt;

&lt;p&gt;First we need to state our SLI and SLO.&lt;/p&gt;

&lt;p&gt;Our SLI is: the proportion of HTTP probes to &lt;a href="https://grafana.wehaveaproblem.net" rel="noopener noreferrer"&gt;https://grafana.wehaveaproblem.net&lt;/a&gt; that return 2xx within 5 seconds, measured from inside the cluster every 30 seconds.&lt;/p&gt;

&lt;p&gt;Our SLO is: 99% of probes succeed, measured over a rolling 30 days.&lt;/p&gt;

&lt;p&gt;By default blackbox has a 5 second timeout for HTTP requests, that's where the 5 seconds comes from, and the 30 seconds is the interval in our Probe. The 30 days is the standard SLO window from the SRE Workbook, and it's baked into the math below. (Prometheus only keeps 10 days of data here. That's the kube-prometheus-stack chart default, a standard Prometheus install keeps 15. It doesn't matter for the alerts because the rules only ever look back 6 hours.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.coreos.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PrometheusRule&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-slo&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-slo.rules&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probe:grafana:error_ratio5m&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 - avg_over_time(probe_success{instance="https://grafana.wehaveaproblem.net"}[5m])&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probe:grafana:error_ratio1h&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 - avg_over_time(probe_success{instance="https://grafana.wehaveaproblem.net"}[1h])&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probe:grafana:error_ratio30m&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 - avg_over_time(probe_success{instance="https://grafana.wehaveaproblem.net"}[30m])&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probe:grafana:error_ratio6h&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 - avg_over_time(probe_success{instance="https://grafana.wehaveaproblem.net"}[6h])&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GrafanaSLOFastBurn&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probe:grafana:error_ratio1h &amp;gt; 0.144 and probe:grafana:error_ratio5m &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0.144&lt;/span&gt;
          &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
          &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grafana&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SLO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fast&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;burn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(&amp;gt;14.4x&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;budget)"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GrafanaSLOSlowBurn&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probe:grafana:error_ratio6h &amp;gt; 0.06 and probe:grafana:error_ratio30m &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0.06&lt;/span&gt;
          &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ticket&lt;/span&gt;
          &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grafana&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SLO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slow&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;burn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(&amp;gt;6x&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;budget)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Where the numbers come from
&lt;/h3&gt;

&lt;p&gt;OK SO we use this file. This file and its values start from the standard Google SRE Workbook. We declare an SLO of 99% of probes to Grafana succeed over a rolling 30 days. A probe counts as successful if grafana.wehaveaproblem.net returns a 2xx response within 5 seconds. We chose the 30 days because it's standard and baked into the math in the manifest file. We are probing grafana.wehaveaproblem.net with the blackbox exporter and it is using a 5 second timeout for probing. That corresponds to the within 5 seconds. If blackbox exporter times out then the probe returns zero.&lt;/p&gt;

&lt;p&gt;Now the math. We use the standard Google SRE Workbook targets of 2% of our error budget consumed in 1 hour and 5% of our error budget consumed in 6 hours. We need to translate this into actual alert thresholds, and in order to do that we turn the 2% in 1 hour into a burn rate. The burn rate equation is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;burn rate = (share of the error budget consumed * SLO window in hours) / (alert window in hours)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns into 0.02 (the 2%), 720 hours for 30 days, and the window Google declared, which is 1 hour. That's (0.02 * 720) / 1, which turns into 14.4. The numerator is how many months' worth of the budget you'd burn in one month (720 hours) if you lost 2% every single hour, where the 2% comes from the Google SRE Workbook and the 720 comes from our 30 day SLO. The denominator is the alert window. 720 divided by it is how many of those windows fit in a month, so dividing by 1 hour leaves it at 14.4, and the 6 hour alert divides by 6 instead, which gives (0.05 * 720) / 6 = 6.&lt;/p&gt;

&lt;p&gt;We then take that burn rate and multiply it by the error budget, which is 0.01 or 1%, because we set our SLO to 99% uptime so 1% downtime. Prometheus only sees the fraction of the probes that failed, so we convert the burn rate into that fraction by multiplying it by the error budget. A burn rate of 14.4 means we are failing 14.4 times faster than allowed, and the allowed rate is 1% of probes, so 14.4 times 1% is 14.4% of probes failing, or 0.144. That is the number Prometheus can compare against. The slow burn alert is the same thing with 6, so 6 * 0.01 = 0.06.&lt;/p&gt;

&lt;p&gt;Each alert has two windows because of what happens after a fix. If the fast burn alert only looked at the 1 hour window, it would keep firing for about 50 minutes after I fixed the problem, because the failed probes stay inside the window until they age out. The 5 minute window only stays true while failures are still happening. Both have to be true, so the alert clears about 4.5 minutes after the fix instead of 50. The &lt;code&gt;for&lt;/code&gt; is a debounce on top of that. The condition has to hold for 2 minutes (or 15 for slow burn) before the alert goes from Pending to Firing.&lt;/p&gt;

&lt;h2&gt;
  
  
  It actually happened
&lt;/h2&gt;

&lt;p&gt;While I was writing this up I moved the HAProxy ingress controller to a new namespace. I wanted it in &lt;code&gt;haproxy-ingress-internal&lt;/code&gt; instead of &lt;code&gt;haproxy-controller&lt;/code&gt;, which meant changing &lt;code&gt;spec.destination.namespace&lt;/code&gt; on the Argo CD Application, moving the cert-manager Certificate, and copying the TLS secret across. Argo CD pruned the old controller before the new one had bound ports 80 and 443 (k3s ServiceLB only lets one LoadBalancer service hold a host port), and for a few minutes nothing behind the ingress was reachable, including Grafana. The probe caught it.&lt;/p&gt;

&lt;p&gt;For that hour I asked Prometheus how many probes it had and how many succeeded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;count_over_time(probe_success{instance="https://grafana.wehaveaproblem.net"}[1h])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://grafana.wehaveaproblem.net" rel="noopener noreferrer"&gt;https://grafana.wehaveaproblem.net&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avg_over_time(probe_success{instance="https://grafana.wehaveaproblem.net"}[1h])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://grafana.wehaveaproblem.net" rel="noopener noreferrer"&gt;https://grafana.wehaveaproblem.net&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.65&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;120 probes is right for a 30 second interval over an hour. A success average of 0.65 is 78 successes and 42 failures, an error ratio of 0.35.&lt;/p&gt;

&lt;p&gt;Running the numbers from above, the burn rate is 0.35 / 0.01 = 35, so we were burning budget 35 times faster than the SLO allows. Over one hour that is 35 * (1 / 720) = 4.9% of the whole month's error budget. In minutes, 42 failed probes * 30 seconds is about 21 minutes of downtime, out of the 432 minutes (1% of 30 days) the SLO allows. And 0.35 is well above the 0.144 threshold, so the fast burn alert would have paged.&lt;/p&gt;

&lt;p&gt;Would have, because I hadn't committed the PrometheusRule yet when it happened. The rules file was still sitting untracked in my repo. So the first real incident on this platform is also the reason the alerts are live now.&lt;/p&gt;




&lt;p&gt;This is part of my &lt;a href="https://github.com/JonathanBouligny/fleet-platform" rel="noopener noreferrer"&gt;fleet-platform&lt;/a&gt; project, a GitOps k3s platform on a home Proxmox cluster. The rules file and the blackbox Probe are in the repo.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>prometheus</category>
      <category>sre</category>
      <category>devops</category>
    </item>
    <item>
      <title>Phase 0, Part 1: Turning Copy-Pasted Terraform Into a Reusable Proxmox Module</title>
      <dc:creator>Jonathan Bouligny</dc:creator>
      <pubDate>Tue, 06 Oct 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/jonathanbouligny/phase-0-part-1-turning-copy-pasted-terraform-into-a-reusable-proxmox-module-1a3k</link>
      <guid>https://dev.to/jonathanbouligny/phase-0-part-1-turning-copy-pasted-terraform-into-a-reusable-proxmox-module-1a3k</guid>
      <description>&lt;p&gt;My original infrastructure is three bare-metal Proxmox machines clustered together. The clustering just makes administration a bit easier. Those run the VMs that hold my k3s nodes and other lab systems like an Ansible Automation Platform. All of it was hand-configured.&lt;/p&gt;

&lt;p&gt;My goal is to have the foundation infrastructure come up from code too, the same way the cluster does, so I can add machines or spin everything down and back up without clicking through a UI. It's also a good exercise on its own. This piece is the DNS setup using Technitium.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why an LXC Container for DNS
&lt;/h2&gt;

&lt;p&gt;The plan for DNS is to spin up an LXC container, provision it, and back it up. I chose an LXC container because it's lighter than a full VM while keeping the features I care about.&lt;/p&gt;

&lt;p&gt;The reason it's not just the containerized (Docker) version of Technitium: in that version the logs show every request coming from the bridge, so you can't tell who actually asked. A real OS install fixes that, but I don't want to hand a whole VM's worth of resources to one small service.&lt;/p&gt;

&lt;p&gt;I also don't want all my services on one VM. If that VM goes down, that's one big blast surface, so I'd rather have a bunch of small machines. LXC containers act like smaller VMs for services you treat like pets instead of cattle. I can run them on Proxmox, on bare metal with &lt;code&gt;systemd-nspawn&lt;/code&gt;, or on another hypervisor through Incus. I still get backups, and if I ever need to pull a service out, the migration path is easier because the backup is just the filesystem, so I can mount it somewhere else later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Centralizing the Common Values
&lt;/h2&gt;

&lt;p&gt;I worked in a strange order, doing things as they came to me. One was centralizing the important values, so I made a &lt;code&gt;common-outputs&lt;/code&gt; folder that just exports the things every foundation system needs.&lt;/p&gt;

&lt;p&gt;The thing that pushed me to it was upstream DNS versus the DNS server. Most machines need to use the &lt;code&gt;dns_server&lt;/code&gt;, and then the DNS server itself needs to use the &lt;code&gt;upstream_dns_server&lt;/code&gt;. Keeping those in one place beat redefining them in every repo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_endpoint"&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://10.0.0.10:8006/"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"gateway"&lt;/span&gt;             &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.1"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"dns_server"&lt;/span&gt;          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.31"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# Technitium, guests resolve here&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"upstream_dns_server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.1"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c1"&gt;# router, what the resolver forwards to&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"provider_ssh_user"&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"machine_ssh_user"&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"jon"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"ssh_keys"&lt;/span&gt;            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;trimspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh/id_ed25519.pub"&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Code I Kept Copying
&lt;/h2&gt;

&lt;p&gt;I wanted to stop copying the same block of Terraform around. This resource has spun up VMs across at least three of my repos, probably more, and every copy was one more place to fix when something changed. Here's the block that was living in every repo, reaching into a common module for the shared values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_environment_vm"&lt;/span&gt; &lt;span class="s2"&gt;"vms"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vms&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;node_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_name&lt;/span&gt;

  &lt;span class="nx"&gt;clone&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;vm_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;template_vm_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dedicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;initialization&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dns&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dns_server&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ip_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ipv4&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip_address&lt;/span&gt;
        &lt;span class="nx"&gt;gateway&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;user_account&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"jon"&lt;/span&gt;
      &lt;span class="nx"&gt;keys&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;trimspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh/id_ed25519.pub"&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;disk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interface&lt;/span&gt;
    &lt;span class="nx"&gt;size&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Making It a Module
&lt;/h2&gt;

&lt;p&gt;A module is just a few files: &lt;code&gt;main.tf&lt;/code&gt; for the resource, &lt;code&gt;variables.tf&lt;/code&gt; for the inputs, &lt;code&gt;outputs.tf&lt;/code&gt; for what it returns, a &lt;code&gt;versions.tf&lt;/code&gt; to pin the provider, and a &lt;code&gt;README&lt;/code&gt;. The resource itself barely changes. Every hardcoded or &lt;code&gt;module.common.*&lt;/code&gt; value becomes a &lt;code&gt;var.*&lt;/code&gt;, so the block stops knowing anything about my specific setup. The real edits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  for_each  = local.vms
&lt;/span&gt;&lt;span class="gi"&gt;+  for_each  = var.input_vms
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-    dns { servers = [module.common.dns_server] }
&lt;/span&gt;&lt;span class="gi"&gt;+    dns { servers = [var.dns_server] }
&lt;/span&gt;&lt;span class="gd"&gt;-        gateway = module.common.gateway
&lt;/span&gt;&lt;span class="gi"&gt;+        gateway = var.gateway
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-      username = "jon"
-      keys     = [trimspace(file("~/.ssh/id_ed25519.pub"))]
&lt;/span&gt;&lt;span class="gi"&gt;+      username = var.ssh_user
+      keys     = var.ssh_keys
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the inputs those vars come from. &lt;code&gt;ssh_user&lt;/code&gt; and &lt;code&gt;gateway&lt;/code&gt; get defaults so a caller only overrides them when they differ. &lt;code&gt;dns_server&lt;/code&gt; has no default on purpose, because the whole point was that some machines get the resolver and the resolver gets upstream, so the caller must say which. &lt;code&gt;input_vms&lt;/code&gt; is the typed shape of the machine map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"dns_server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"the dns server to use, can be upstream or downstream"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"input_vms"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;node_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;template_vm_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
    &lt;span class="nx"&gt;cores&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
    &lt;span class="nx"&gt;memory&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
    &lt;span class="nx"&gt;ip_address&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;disk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
      &lt;span class="nx"&gt;size&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The list of vms to be created"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One output change worth calling out: the old version grabbed &lt;code&gt;ipv4_addresses[1][0]&lt;/code&gt;, which assumes the second NIC entry is the real one and the first is loopback. That's fragile. I changed it to filter loopback out explicitly instead of trusting the index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  value = { for k, v in proxmox_virtual_environment_vm.vms : k =&amp;gt; v.ipv4_addresses[1][0] }
&lt;/span&gt;&lt;span class="gi"&gt;+  value = {
+    for k, v in proxmox_virtual_environment_vm.vms :
+    k =&amp;gt; [for addr in flatten(v.ipv4_addresses) : addr if addr != "127.0.0.1"][0]
+  }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The LXC Version
&lt;/h2&gt;

&lt;p&gt;Once the VM module existed, the container version was only a few changes off it. The resource type changes, a hostname shows up, there's no &lt;code&gt;agent&lt;/code&gt; block, the VM has a name the container doesn't, and the disk loses its interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-resource "proxmox_virtual_environment_vm" "vms" {
&lt;/span&gt;&lt;span class="gi"&gt;+resource "proxmox_virtual_environment_container" "containers" {
&lt;/span&gt;&lt;span class="gd"&gt;-  for_each  = var.input_vms
-  name      = each.key
&lt;/span&gt;&lt;span class="gi"&gt;+  for_each  = var.input_containers
&lt;/span&gt;   node_name = each.value.node_name
&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-  agent { enabled = true }
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;   initialization {
&lt;span class="gi"&gt;+    hostname = each.key
&lt;/span&gt;     dns { servers = [var.dns_server] }
&lt;span class="err"&gt;
&lt;/span&gt;     user_account {
&lt;span class="gd"&gt;-      username = var.ssh_user
&lt;/span&gt;       keys     = var.ssh_keys
     }
&lt;span class="err"&gt;
&lt;/span&gt;   disk {
&lt;span class="gd"&gt;-    interface = each.value.disk.interface
&lt;/span&gt;     size      = each.value.disk.size
   }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;input_containers&lt;/code&gt; variable is the same shape as &lt;code&gt;input_vms&lt;/code&gt; minus the disk interface, and the output reads &lt;code&gt;.ipv4&lt;/code&gt; off the container instead of filtering the VM's address list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  value = {
-    for k, v in proxmox_virtual_environment_vm.vms :
-    k =&amp;gt; [for addr in flatten(v.ipv4_addresses) : addr if addr != "127.0.0.1"][0]
-  }
&lt;/span&gt;&lt;span class="gi"&gt;+  value = { for k, v in proxmox_virtual_environment_container.containers : k =&amp;gt; v.ipv4 }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Using the Module in the Foundation Repo
&lt;/h2&gt;

&lt;p&gt;With the module written, each foundation system just describes its machines and calls it. Here's the DNS one, &lt;code&gt;2-dns/terraform/technitium.tf&lt;/code&gt;. It pulls shared values from common, defines the one VM, and hands it to the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"common"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../common-outputs/"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vms&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;technitium&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;node_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nibbler"&lt;/span&gt;
      &lt;span class="nx"&gt;template_vm_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt;
      &lt;span class="nx"&gt;cores&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
      &lt;span class="nx"&gt;memory&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;
      &lt;span class="nx"&gt;ip_address&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.31/24"&lt;/span&gt;
      &lt;span class="nx"&gt;disk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"scsi0"&lt;/span&gt;
        &lt;span class="nx"&gt;size&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_create_vms"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../terraform-common/proxmox_create_vms"&lt;/span&gt;

  &lt;span class="nx"&gt;input_vms&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vms&lt;/span&gt;
  &lt;span class="nx"&gt;ssh_user&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;machine_ssh_user&lt;/span&gt;
  &lt;span class="nx"&gt;ssh_keys&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_keys&lt;/span&gt;
  &lt;span class="nx"&gt;dns_server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upstream_dns_server&lt;/span&gt;
  &lt;span class="nx"&gt;gateway&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"vm_ipv4_address"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;proxmox_create_vms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vm_ipv4_address&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider block is tiny too, since it also reads from common:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"proxmox"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;proxmox_endpoint&lt;/span&gt;
  &lt;span class="nx"&gt;insecure&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;ssh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;agent&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider_ssh_user&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Forgejo system (&lt;code&gt;3-git/terraform/forgejo.tf&lt;/code&gt;) is the same file with a different machine map, pointing at the same module. That's the whole payoff. Adding a new foundation service is now a &lt;code&gt;locals&lt;/code&gt; block and a module call, not another copy of the resource.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Git Subtree Instead of a Submodule or a Registry
&lt;/h2&gt;

&lt;p&gt;One decision I made about how the module actually lives in the foundation repo: the module code is its own thing, but I pulled it into the foundation repo with &lt;code&gt;git subtree&lt;/code&gt; rather than referencing it as a submodule or a remote source.&lt;/p&gt;

&lt;p&gt;The reason is backups. This foundation repo, state included, is going on a USB stick as a recovery copy. It also lives in Forgejo, but I want to be able to rebuild foundation infra from just the USB, with nothing else reachable. A submodule or a remote module source would mean the USB copy is incomplete without pulling from somewhere. A subtree copies the module's files directly into this repo, so the one repo on the stick has everything.&lt;/p&gt;

&lt;p&gt;The state going on the stick is a nice-to-have, not critical. If I lost it I could &lt;code&gt;terraform import&lt;/code&gt; my way back, but that's work I'd rather avoid, so it rides along.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>proxmox</category>
      <category>devops</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Phase 2, Part 1: cert-manager, Rate Limits, and the Restore Race</title>
      <dc:creator>Jonathan Bouligny</dc:creator>
      <pubDate>Wed, 30 Sep 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/jonathanbouligny/cert-manager-rate-limits-and-the-restore-race-3944</link>
      <guid>https://dev.to/jonathanbouligny/cert-manager-rate-limits-and-the-restore-race-3944</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on order:&lt;/strong&gt; I started this devlog partway through, around the cert-manager and ingress work, so the posts aren't showing up in the order I actually built things. Phase 0 (the Proxmox/Terraform/Ansible foundation and the k3s bootstrap) and Phase 1 (Argo CD and ingress) came first — I'm backfilling those writeups now. The phase numbers in the titles are the real order to read them in.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The full platform is on GitHub: &lt;a href="https://github.com/JonathanBouligny/fleet-platform" rel="noopener noreferrer"&gt;github.com/JonathanBouligny/fleet-platform&lt;/a&gt;. This post covers the cert-manager piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  cert-manager
&lt;/h2&gt;

&lt;p&gt;Setting up cert-manager was pretty easy with a Helm chart. I did spend a lot of time on this step but it was mostly googling and figuring out what to do about bootstrap secrets which I detail below. The most interesting thing I learned was part of the values file, specifically &lt;code&gt;installCRDs&lt;/code&gt; (or &lt;code&gt;crds.enabled&lt;/code&gt;, depending on chart version). CRDs, Custom Resource Definitions, are objects that teach the Kubernetes API server about new kinds of resources, like &lt;code&gt;Certificate&lt;/code&gt; and &lt;code&gt;ClusterIssuer&lt;/code&gt;. They're not backed by Go directly, they're schema definitions, and a controller (cert-manager's own pod, running Go code) is what actually watches for objects of that kind and acts on them. So a CRD without its controller running is just an inert schema. The controller is the operator, the CRD is the vocabulary it teaches the cluster.&lt;/p&gt;

&lt;p&gt;I needed these CRDs present before cert-manager's controller could start successfully. Without them, the install would fail, or the controller would come up but be unable to do anything, since the kinds it needs wouldn't exist yet. The chart handles this by installing the CRDs and the controller together, which is convenient, but it comes with a real downside: if I ever uninstall the Helm release, the CRDs get removed too, and when a CRD is removed, every object of that kind is deleted with it, cascade, immediately. That means an uninstall could silently delete every &lt;code&gt;Certificate&lt;/code&gt; and &lt;code&gt;ClusterIssuer&lt;/code&gt; in the cluster, not just cert-manager's own pods.&lt;/p&gt;

&lt;p&gt;To guard against that, I set &lt;code&gt;prune: false&lt;/code&gt; on the Argo Application that installs cert-manager. That way, if the Application is ever removed from Git, Argo won't prune the CRDs (or anything else it manages) along with it. Tradeoff: if I ever do want a real teardown, I have to remove those resources by hand instead of Argo doing it automatically. But that's the point. It means an accidental deletion doesn't take my certs with it, and a real, intentional teardown still works, it's just not automatic. Left a comment in the file explaining why. (The chart actually has its own protection for this too. &lt;code&gt;crds.keep&lt;/code&gt; stops a helm uninstall from taking the CRDs with it but that's a different path than Argo's prune.)&lt;/p&gt;

&lt;p&gt;The actual cert-issuing part: I created a &lt;code&gt;ClusterIssuer&lt;/code&gt; since I'm not doing multi-tenancy. One issuer, cluster-wide, no need to scope it per-namespace. I issued a cert for &lt;code&gt;argocd.wehaveaproblem.net&lt;/code&gt; against Let's Encrypt's staging environment first to confirm the whole DNS-01/Cloudflare flow worked, then switched to production once it did. But I hit Let's Encrypt's rate limit fast. It only allows a handful of identical certs per week, and every full rebuild was requesting a fresh one for the same name. So instead of a per-hostname cert, I switched to a single wildcard (&lt;code&gt;*.wehaveaproblem.net&lt;/code&gt;), which covers every service I'll ever put behind the ingress with one certificate instead of one per host. I exported the issued cert and saved it in my local secrets folder (which moves to OpenBao eventually) so a rebuild can restore the existing cert instead of requesting a new one from Let's Encrypt every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I have a &lt;code&gt;Certificate&lt;/code&gt; manifest that always points at the same saved object, &lt;code&gt;secretName: wehaveaproblem-net-tls&lt;/code&gt;, in the &lt;code&gt;haproxy-controller&lt;/code&gt; namespace. It requests the wildcard, pinned to that &lt;code&gt;ClusterIssuer&lt;/code&gt;, 90-day duration with a 15-day renewal window. (Not Before/Not After are just the cert's validity window, issuance time and expiry, and &lt;code&gt;renewBefore&lt;/code&gt; tells cert-manager how far ahead of expiry to start renewing so it never waits until the last second.) The manifest itself never changes between rebuilds. Same file every time, declaring "this is the cert I want."&lt;/p&gt;

&lt;p&gt;What makes the reuse actually work is a bootstrap step that runs before that manifest gets applied. My Ansible playbook that stands up the cluster has a task that loads a handful of secrets, including the saved cert, into the right namespaces first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure bootstrap secrets&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubeconfig_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.kube/config"&lt;/span&gt;
    &lt;span class="na"&gt;secrets_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.secrets/fleet-platform"&lt;/span&gt; &lt;span class="c1"&gt;# Stored locally outside the repo&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;argocd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets_dir&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/secret-argo-forgejo.yml"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;cert-manager&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets_dir&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/cloudflare-api-token.yml"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;haproxy-controller&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets_dir&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/wehaveaproblem-net.yml"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Make sure namespaces for the secrets exist&lt;/span&gt;
      &lt;span class="na"&gt;kubernetes.core.k8s&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kubeconfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kubeconfig_path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bootstrap_secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Apply secrets in loop&lt;/span&gt;
      &lt;span class="na"&gt;kubernetes.core.k8s&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kubeconfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kubeconfig_path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
        &lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bootstrap_secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
      &lt;span class="na"&gt;no_log&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time Argo brings up the Certificate manifest, the Secret it's asking for already exists, restored from my saved copy, not freshly issued. When cert-manager reconciles, it sees a valid cert sitting there and does nothing. That ordering is the whole trick. The saved cert has to land before the Certificate object does, or cert-manager finds nothing and requests a new one from Let's Encrypt anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof
&lt;/h2&gt;

&lt;p&gt;After the rebuild, I checked the live Certificate object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metadata:
  Creation Timestamp:  2026-09-23T21:48:08Z
...
Status:
  Conditions:
    Message:  Certificate is up to date and has not expired
    Reason:   Ready
  Not Before:  2026-09-23T14:29:47Z
  Not After:   2026-12-22T14:29:46Z
Events:  &amp;lt;none&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Certificate object itself was created fresh at 21:48. This rebuild spun it up from nothing, like everything else in the cluster. But the cert it's pointing at was issued at 14:29, over seven hours earlier. cert-manager looked at the Secret my bootstrap step had already restored, confirmed it was still valid ("Certificate is up to date and has not expired"), and did nothing. No &lt;code&gt;CertificateRequest&lt;/code&gt;, no &lt;code&gt;Order&lt;/code&gt;, no &lt;code&gt;Challenge&lt;/code&gt;. &lt;code&gt;Events: &amp;lt;none&amp;gt;&lt;/code&gt; says it plainly: nothing happened, because nothing needed to.&lt;/p&gt;

&lt;p&gt;That's the whole point of the exercise. The cert survived a full teardown-and-rebuild without touching Let's Encrypt at all, which means I can rebuild this cluster as many times as I want during development without ever burning down my weekly rate limit.&lt;/p&gt;

&lt;p&gt;I'm not entirely sure how this workflow will look when I switch to a secrets manager. It's still an order issue, so I think I still need to load bootstrap secrets the way I do. I'll see if there's a better way to load bootstrap secrets.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>certmanager</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
