Kube-Prometheus – A Complete Monitoring Stack Using Jsonnet

Kube-Prometheus – A Complete Monitoring Stack Using Jsonnet

As we discussed in a previous post, monitoring your application and its platform is basic hygiene for running a healthy software-as-a-service business. But how exactly do you monitor your application? In this post, we will illustrate how to achieve that using kube-prometheus.

Kube-prometheus collects all the components necessary for monitoring a Kubernetes cluster in one package. At the core, we find the Prometheus operator that makes it easy to deploy and configure, not only Prometheus, but also Alertmanager. Add to this Grafana, some metrics collectors and a default set of dashboards and alerting rules and you have a complete monitoring solution.

There is no one-size-fits-all solution for monitoring your Kubernets cluster however. But fear not, kube-prometheus is based on Jsonnet and made in the form of a library that you build your own custom solution from. Anything and everything can be changed, replaced or added. And you won’t need to wade through Helm templates in order to do it either.

Prerequisites

You will need a Kubernetes cluster running v1.19 or v1.20. See for example compliantkubernetes.io for how to set up a production ready cluster. If you just want something simple to test things out locally, you may use KIND or Minikube. For more details on kube-prometheus prerequisites see the documentation.

In addition to this, you will need to install Jsonnet, Jsonnet-bundler (aka jb or the Jsonnet package manager) and gojsontoyaml. Alternatively, you may use a container with these tools pre-installed, e.g. quay.io/coreos/jsonnet-ci. If you want to use the container, just prepend the following to any jb or jsonnet command (or scripts involving them): docker run --user $(id -u):$(id -g) --rm -v $(pwd):$(pwd) --workdir $(pwd). Here is an example:

# A normal jsonnet command
jsonnet -J vendor example.jsonnet
# Run the same command in a container using docker
docker run --user $(id -u):$(id -g) --rm -v $(pwd):$(pwd) --workdir $(pwd) quay.io/coreos/jsonnet-ci jsonnet -J vendor example.jsonnet

The --user flag is not strictly needed, but it makes sure that any files generated by the command are owned by your normal user account instead of root.

Installing the kube-prometheus library

There has been significant changes in kube-prometheus since the last release, so to keep up with the latest and greates, we will use the very latest code on main.

$ mkdir monitoring-stack; cd monitoring-stack
$ version=main
$ # Initialize jsonnet (creates empty jsonnetfile.json)
$ jb init
$ # Install a specific version of kube-prometheus
$ jb install github.com/prometheus-operator/kube-prometheus/jsonnet/kube-prometheus@${version}

$ # Fetch an example and a build script
$ wget https://raw.githubusercontent.com/prometheus-operator/kube-prometheus/${version}/example.jsonnet -O example.jsonnet
$ wget https://raw.githubusercontent.com/prometheus-operator/kube-prometheus/${version}/build.sh -O build.sh

After this you should have the following: – A folder called monitoring-stack to work in. – A couple of files in the folder: jsonnetfile.json and jsonnetfile.lock.json. These define the jsonnet dependencies, i.e. kube-prometheus. – A vendor folder where kube-prometheus is installed. – An example on how to use kube-prometheus: example.jsonnet. – A simple build script: build.sh.

Compile the manifests and deploy

To make sure that your setup is working, let’s compile the example and see what we get “by default”.

./build.sh example.jsonnet

You should get another folder manifests with a lot of yaml files ready to be applied in your cluster. If you run into problems, you may want to check the kube-prometheus readme for more details.

Deploy everything with two simple commands:

kubectl apply -f manifests/setup
kubectl apply -f manifests

The first command will create all the CustomResourceDefinitions and deploy the prometheus-operator. Once it is up and running, the second command will add all the dashboards, alerts, prometheus, grafana and alertmanager. It should look something like this when done:

$ kubectl -n monitoring get pods
NAME                                                    READY   STATUS                       RESTARTS   AGE
alertmanager-main-0                                     2/2     Running                      0          116m
alertmanager-main-1                                     2/2     Running                      0          116m
alertmanager-main-2                                     2/2     Running                      0          116m
grafana-7767599644-gztq5                                1/1     Running                      0          116m
kube-state-metrics-544cf87cfd-2m8xd                     3/3     Running                      0          116m
prometheus-adapter-767f58977c-hrgk5                     1/1     Running                      0          116m
prometheus-k8s-0                                        2/2     Running                      1          116m
prometheus-k8s-1                                        2/2     Running                      1          116m
prometheus-operator-764cb46c94-wcvln                    2/2     Running                      0          116m

If you have PodSecurityPolicies enforced in your cluster, you may get CreateContainerConfigError for kube-state-metrics. This is due to this issue and can luckily be easily fixed by adding a securityContext to the relevant container.

Fix kube-state-metrics security context

Jsonnet allow us to easily patch anything and everything that we desire. To fix the issue with kube-state-metrics, we will need to patch the Deployment by adding the following securityContext to the kube-state-metrics container:

securityContext:
  runAsGroup: 65532
  runAsNonRoot: true
  runAsUser: 65532

In Jsonnet we can achieve this by adding a local variable that can be merged with the existing container definition. Add the following at the top of example.jsonnet:

local addSecurityContext = {
  containers: std.map(
    function(container)
      if container.name == "kube-state-metrics" then
        container + {
          "securityContext": {
             "runAsGroup": 65532,
             "runAsNonRoot": true,
             "runAsUser": 65532
          }
        }
      else
        container,
    super.containers
  )
};

This variable is not really useful on its own, but when added to another object, it can merge and patch that object by referencing super.containers (i.e. the containers field of the parent object). The map function form the standard library is then applied to this array. An inline function if used to add the security context only to the container with name equal to kube-state-metrics. All other containers are left untouched.

In order to use the variable, simply add it to the relevant object further down in example.jsonnet:

   {
     values+:: {
       common+: {
         namespace: 'monitoring',
       },
     },
+    kubeStateMetrics+: {
+      deployment+: {
+        spec+: {
+          template+: {
+            spec+: addSecurityContext
+          }
+        }
+      }
+    },
  };

Accessing dashboards

The easiest and safest way to access Prometheus, Alertmanager and Grafana is to use port-forwarding:

$ # Prometheus can be accessed at port 9090
$ kubectl --namespace monitoring port-forward svc/prometheus-k8s 9090
$ # Grafana can be accessed at port 3000 using admin:admin as for login
$ kubectl --namespace monitoring port-forward svc/grafana 3000
$ # Alertmanager can be accessed at port 9093
$ kubectl --namespace monitoring port-forward svc/alertmanager-main 9093

Extending the example

Now that we have seen what is included in the example, let’s add something! A simple alert and a dashboard should be enough to see how things can be added.

Adding an alert

Create the file my-alert.jsonnet with the following content:

{
  prometheusRule: {
    apiVersion: 'monitoring.coreos.com/v1',
    kind: 'PrometheusRule',
    metadata: {
      name: 'my-prometheus-rule',
      namespace: $.values.common.namespace,
    },
    spec: {
      groups: [
        {
          name: 'misc',
          rules: [
            {
              alert: 'Ringing ear',
              expr: 'vector(1)',
              labels: {
                severity: 'low',
              },
              annotations: {
                description: 'Maybe too many alerts made your ear ring?',
              },
            },
          ],
        },
      ],
    },
  },
}

To include this in the example.jsonnnet add the following patch:

     values+:: {
       common+: {
         namespace: 'monitoring',
       },
     },
+    // Import my-alert and set the namespace for it.
+    extraManifests: (import 'my-alert.jsonnet') +
+    {
+      prometheusRule+: { metadata +: { namespace: $.values.common.namespace } },
+    },

The alert will end up in manifests/extra-manifests-prometheusRule.yaml when rendered. It shows up in Prometheus as “Ringing ear”.

Adding a dashboard

Create a file my-dashboard.jsonnet with the following content:

local grafana = import 'grafonnet/grafana.libsonnet';
local dashboard = grafana.dashboard;
local row = grafana.row;
local prometheus = grafana.prometheus;
local template = grafana.template;
local graphPanel = grafana.graphPanel;

{
  'my-dashboard.json':
    dashboard.new('My Dashboard')
    .addTemplate(
      {
        current: {
          text: 'Prometheus',
          value: 'Prometheus',
        },
        hide: 0,
        label: null,
        name: 'datasource',
        options: [],
        query: 'prometheus',
        refresh: 1,
        regex: '',
        type: 'datasource',
      },
    )
    .addRow(
      row.new()
      .addPanel(graphPanel.new('My Panel', span=6, datasource='$datasource')
                .addTarget(prometheus.target('vector(1)')))
    ),
}

To include this in the example.jsonnnet add the following patch:

     values+:: {
       common+: {
         namespace: 'monitoring',
       },
+      grafana+: {
+        dashboards+:: (import 'my-dashboard.jsonnet'),
+      }
     },

The dashboard will end up in manifests/grafana-dashboardDefinitions.yaml in a ConfigMap when rendered. It shows up in Grafana as “My Dashboard” in the Default folder, and looks like this:

Complete example file

This is the end result with all examples added to example.jsonnet:

local addSecurityContext = {
  containers: std.map(
    function(container)
      if container.name == "kube-state-metrics" then
        container + {
          "securityContext": {
             "runAsGroup": 65532,
             "runAsNonRoot": true,
             "runAsUser": 65532
          }
        }
      else
        container,
    super.containers
  )
};

local kp =
  (import 'kube-prometheus/main.libsonnet') +
  // Uncomment the following imports to enable its patches
  // (import 'kube-prometheus/addons/anti-affinity.libsonnet') +
  // (import 'kube-prometheus/addons/managed-cluster.libsonnet') +
  // (import 'kube-prometheus/addons/node-ports.libsonnet') +
  // (import 'kube-prometheus/addons/static-etcd.libsonnet') +
  // (import 'kube-prometheus/addons/custom-metrics.libsonnet') +
  // (import 'kube-prometheus/addons/external-metrics.libsonnet') +
  {
    values+:: {
      common+: {
        namespace: 'monitoring',
      },
      grafana+: {
        dashboards+:: (import 'my-dashboard.jsonnet'),
      }
    },
    // Import my-alert and set the namespace for it.
    extraManifests: (import 'my-alert.jsonnet') +
    {
      prometheusRule+: { metadata +: { namespace: $.values.common.namespace } },
    },
    kubeStateMetrics+: {
      deployment+: {
        spec+: {
          template+: {
            spec+: addSecurityContext
          }
        }
      }
    },
  };

{ 'setup/0namespace-namespace': kp.kubePrometheus.namespace } +
{
  ['setup/prometheus-operator-' + name]: kp.prometheusOperator[name]
  for name in std.filter((function(name) name != 'serviceMonitor' && name != 'prometheusRule'), std.objectFields(kp.prometheusOperator))
} +
// serviceMonitor and prometheusRule are separated so that they can be created after the CRDs are ready
{ 'prometheus-operator-serviceMonitor': kp.prometheusOperator.serviceMonitor } +
{ 'prometheus-operator-prometheusRule': kp.prometheusOperator.prometheusRule } +
{ 'kube-prometheus-prometheusRule': kp.kubePrometheus.prometheusRule } +
{ ['alertmanager-' + name]: kp.alertmanager[name] for name in std.objectFields(kp.alertmanager) } +
{ ['blackbox-exporter-' + name]: kp.blackboxExporter[name] for name in std.objectFields(kp.blackboxExporter) } +
{ ['grafana-' + name]: kp.grafana[name] for name in std.objectFields(kp.grafana) } +
{ ['kube-state-metrics-' + name]: kp.kubeStateMetrics[name] for name in std.objectFields(kp.kubeStateMetrics) } +
{ ['kubernetes-' + name]: kp.kubernetesControlPlane[name] for name in std.objectFields(kp.kubernetesControlPlane) }
{ ['node-exporter-' + name]: kp.nodeExporter[name] for name in std.objectFields(kp.nodeExporter) } +
{ ['prometheus-' + name]: kp.prometheus[name] for name in std.objectFields(kp.prometheus) } +
{ ['prometheus-adapter-' + name]: kp.prometheusAdapter[name] for name in std.objectFields(kp.prometheusAdapter) } +
{ ['extra-manifests-' + name]: kp.extraManifests[name] for name in std.objectFields(kp.extraManifests) }

Conclusion

Kube-prometheus brings a comprehensive package for monitoring your Kubernetes cluster. It is built in the form of a library that can be used to quickly build your own unique rendering. The power of Jsonnet means that it is easy to extend, replace and modify parts as you see fit, without the limitations that comes from solutions that build on templates (e.g. Helm).

On the other hand, fully utilizing the library means that you have to actually learn the library and its abstractions, in addition to all the normal Kubernetes resources.

Lennart Jern

Lennart is a Software Engineer and Certified Kubernetes Administrator (CKA) at Elastisys where he focuses on building Compliant Kubernetes as well as managing it for customers. He has a Master's degree in Applied Mathematics, but in his spare time he rather enjoys geeking around with Raspberry Pis.