my post on Multi-Tenant Prometheus youâll recognise most of the tools and methodology used for this logging setup. Just like with Prometheus, I decided on running a single cluster scope Loki instance with a custom querier frontend to provide isolated and secure access to logs. Loki has two key advantages over Prometheus in this context: 1. it is natively multi-tenant and 2. we can build the infrastructure in a way to have complete control over log ingestion.
If youâve readArchitecture Overview
Logs are collected by an Alloy DaemonSet, configured to only consider pods labeled with logging: enabled
, and ingested by a Cluster Scope Loki Instance. Tenants query logs through a central query endpoint built with kube-rbac-proxy to handle Authentication. Note this setup can also be achieved with most other log collectors like Vector or Promtail.
flowchart LR A[Tenant] -->|1| grafana(Grafana) subgraph Tenant Namespace pod(Pod) qsa([Query Service Account]) <-.-> grafana end loki subgraph Monitoring Namespace subgraph Query Frontend grafana -->|2| krp(kube-rbac-proxy) end krp --->|6| loki loki(Loki) alloy(Alloy) --> loki alloy <-.-> pod end subgraph Kubernetes krp -->|3| sar{{SubjectAccessReview}} sar <-->|4| qsa sar -->|5| krp end
Setup
Loki
Since the Loki Operator is still very enterprise oriented (1x.extra-small needs 14c & 31gb of RAM!) Iâve been using the Helm Chart to bootstrap my instances. This blog post will assume a Loki instance with multi-tenancy enabled is already set up. Should this not be the case, you can find docs & working examples in the posts accompanying GitHub Repo.
Collection with Alloy
I recently migrated away from Promtail since itâs going to reach its EOL in early 2026, should you still want to use it, you can find a config which achieves the same result as the one for Alloy below here.
Alloy has a pretty great Helm Chart which can be used to configure a log collection DaemonSet. Outlining our goals, we want a log collector capable of identifying specific Pods to collect while giving us as much metadata on the Pods context as possible. For tenant separation, I chose namespaces (specifically their names), as they most likely already serve as your tenants boundaries.
Tenant Separation
Loki in multi-tenant mode will separate ingested logs by tenant for us automatically, the only requirement from the ingesters side is to tell Loki which tenant the log belongs to which can be done with a simple label.
Selecting Pods
In the example config, only Pods with a logging: enabled
label are scraped. This allows tenants to manually select which Pods are relevant enough to require aggregated logging while also allowing admins to prevent noisy containers from increasing the storage bill per default.
Log Labels/Metadata
I like changing out the defaults to append extra labels to all logs, this makes it way easier to figure out exactly what Pod issues are occurring in. Some log collectors will already be configured to do this by default, for example Promtails Helm Chart values.yaml has an elaborate pipeline under config.snippets.common
to append metadata like pod & node names, uid, namespace, container name as labels.
Config
See the complete config here, interesting sections are outlined below.
# snippet of alloy values.yaml
# create as a daemonset to scrape every single node
controller:
type: daemonset
# we are only interested in container logs for this alloy deployment
mounts:
varlog: false
dockercontainers: true
# alloy config, unfortunately not yaml
alloy:
configMap:
create: true
content: |
// discover all pods
discovery.kubernetes "pod" {
role = "pod"
}
discovery.relabel "pod_logs" {
targets = discovery.kubernetes.pod.targets
// only scrape logs from pods with logging enabled label
rule {
source_labels = ["__meta_kubernetes_pod_label_logging"]
regex = "enabled"
action = "keep"
}
// append additional labels
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
action = "replace"
target_label = "node_name"
}
// [...] more relabel/source rules omitted, see the full config at the link above
}
// endpoint of our log ingester, forwarding rules/pipeline omitted
loki.write "default" {
endpoint {
url = "http://loki-gateway/loki/api/v1/push"
}
}
// required for multi-tenancy, we need to tell Loki which tenant the log line belongs to
// to achieve this, we add a tenant label, setting the content to the name of the
// originating namespace. Loki will interpret this for us and store the line accordingly
loki.process "pod_logs" {
stage.cri {}
// extract namespace name & set as tenant label
stage.match {
selector = "{namespace=~\".+\"}"
stage.tenant {
label = "namespace"
}
}
forward_to = [loki.write.default.receiver]
}
Once Alloy is set up & running, you should be able to query Loki with an X-Scope-OrgID
header containing a tenant namespaces name as the value. This will return all ingested logs from that specific namespace & only that namespace.
Great! Now how do we make this secure?
Querier Frontend
The querier frontend can be deployed as a single Pod and scaled to any amount of replicas, the only external dependency being SubjectAccessReviews to delegate authorization decisions to the Kubernetes API server. This enables us to treat access to Lokis stored logs like we would any other Kubernetes resource, only allowing Users or ServiceAccounts with the correct permissions to query.
Requests to the querier frontend will always have to carry a valid Bearer token and an X-Scope-OrgID
header. kube-rbac-proxy checks if the request originator (sourced from the JWT) has permissions on the pods/logs
subresource in the namespace defined in the X-Scope-OrgID
header. Should the SubjectAccessReview succeed, the request is passed to Loki, retaining all headers and confirming access rights in the process.
flowchart LR subgraph Tenant Namespace qsa([Query Service Account]) <-.-> grafana(Grafana) end subgraph Kubernetes sar{{SubjectAccessReview}} end tnt[Tenant] -->|1 - Query Logs| grafana grafana -->|2 - SA JWT & X-Scope-OrgID=tenant| krp(kube-rbac-proxy) subgraph Monitoring Namespace subgraph Query Frontend krp(kube-rbac-proxy) krp -->|3 - Validate SA Perms| sar sar -->|4 - Confirm Access| krp end krp -->|5 - Expects X-Scope-OrgID Header| loki loki(Loki) end
Config
The complete config for the querier frontend can be found here, as well as some additional information & manifests. The interesting sections are outlined below.
The entire logic for the kube-rbac-proxy is defined in just nine lines. It retrieves the X-Scope-OrgID
header & uses it to construct a SubjectAccessReview for the given namespace to make sure the requester has rights to the pods/logs
subresource. The verb will correspond the http method used for the request.
apiVersion: v1
kind: ConfigMap
metadata:
name: loki-kube-rbac-proxy
data:
config.yaml: |+
authorization:
# setup our template by extracting the value of the
# X-Scope-OrgID header (-> the tenant namespace name)
rewrites:
byHttpHeader:
name: "X-Scope-OrgID"
# define a template for the SubjectAccessReview
resourceAttributes:
apiVersion: v1
resource: pods
subresource: logs # pods/logs subresource
namespace: "{{ .Value }}" # value from rewrite above
Service Account
To create SubjectAccessReviews, the kube-rbac-proxy requires elevated rights. Fortunately Kubernetes provides a ClusterRole system:auth-delegator
for this exact use case.
apiVersion: v1
kind: ServiceAccount
metadata:
name: loki-kube-rbac-proxy
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: loki-kube-rbac-proxy
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: loki-kube-rbac-proxy
# assuming this is being deployed in the monitoring NS
namespace: monitoring
Deployment
The deployment is kept pretty simple, as we only need to pass a few arguments to the kube-rbac-proxy container. Covering the config, kube-rbac-proxy will listen (securely) on port 3100, forwarding incoming requests to localhost:8080 (the prom-label-proxy). We also need to mount the config created in the configmap above as a file. prom-label-proxy only needs four options, the listen address, the label to enforce, a flag to enable the label APIs, and the upstream (which can be a single Prometheus, a Thanos Sidecar, or a Thanos Querier.)
kind: Deployment
# [...]
# use the service account from above w/ the `system:auth-delegator` role
serviceAccountName: loki-kube-rbac-proxy
containers:
- name: kube-rbac-proxy
image: quay.io/brancz/kube-rbac-proxy:v0.18.1
args:
# use the secure listen address to make future cert expansion easier
- "--secure-listen-address=0.0.0.0:3100"
# set the upstream to your loki gateway service address
- "--upstream=http://loki-gateway.monitoring.svc.cluster.local/"
# load the config from a configmap
- "--config-file=/etc/kube-rbac-proxy/config.yaml"
volumeMounts:
- name: config
mountPath: /etc/kube-rbac-proxy
# [...]
volumes:
- name: config
configMap:
name: loki-kube-rbac-proxy
# [...]
Usage
Testing
Once Loki, your collector, and the query frontend are set up and running & youâve ingested a few logs we can start testing the setup. The easiest way to do this is with a quick Grafana instance.
Note most of this is taken straight from my multi-tenant Prometheus post, with minor exceptions regarding RBAC/Loki Datasource specifics. If you already set up Prom like this, you can skip a lot of these steps and use the same ServiceAccount.
First weâll need to do a little setup in the tenant namespace (which weâll call âshowcaseâ from this point onwards). Create a ServiceAccount & Role which matches the rights in the kube-rbac-proxy config.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role # Could also be a ClusterRole
metadata:
name: namespace-logs-viewer
rules:
- apiGroups: [""]
resources:
- pods/logs
verbs: ["get"]
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: grafana-ds-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: grafana-sa-logs-viewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: namespace-logs-viewer
subjects:
- kind: ServiceAccount
name: grafana-ds-sa
namespace: showcase
To confirm this ServiceAccount has the correct permissions, you can run kubectl auth can-i get pod --subresource=logs --as=system:serviceaccount:showcase:grafana-ds-sa
In Grafana, create a new Loki Datasource & point it to your querier frontends service, making sure to use https.
Under Authentication > TLS Settings, turn on âSkip TLS certificate validationâ & create two headers. One X-Scope-OrgID
containing the tenant namespace name, âshowcaseâ in this case. Second Authorization
containing Bearer ${token}
, ${token} being the previously created service accounts JWT. You can retrieve it by running kubectl create token grafana-ds-sa
Save the datasource & confirm correct configuration.
Finally after constructing the request in a way which fulfils all requirements defined earlier in the querier frontend, we can test the datasource. Youâll notice any changes to this config will result in the request being rejected with a 401, try using a different ServiceAccounts token or changing the value of the namespace query parameter. In the âExploreâ view, you should be able to query logs and confirm the isolation by checking which values are available for the ânamespaceâ label.
Production
For production (& to improve your tenants DevEx) weâll automate all of these steps. The Grafana Operator will make this considerably easier, with each tenant managing their own instance. I usually provide all of this as a simple helm chart, which sets up all required manifests in tenant namespaces.
To get started, bootstrap a Grafana Instance using the Operator & create the ServiceAccount/Bindings from earlier. Additionally, weâll create a secret to house the ServiceAccounts token & a GrafanaDatasource
CRD to automatically provision the Prometheus DS.
Secret
apiVersion: v1
kind: Secret
metadata:
name: grafana-ds-sa-token
annotations:
# references the ServiceAccount, kubernetes will automatically
# populate this secret for us
kubernetes.io/service-account.name: grafana-ds-sa
type: kubernetes.io/service-account-token
Grafana Datasource
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDatasource
metadata:
name: logs
spec:
# make sure this matches your Grafana CRD
instanceSelector:
matchLabels:
app: grafana
# import the token from the secret to be used in secureJsonData below
valuesFrom:
- targetPath: secureJsonData.httpHeaderValue1
valueFrom:
secretKeyRef:
key: token
name: grafana-ds-sa-token
# set up datasource, this spec the same one Grafana itself uses to manage datasources,
# so you can also extract it from the network requests when creating/editing one in the UI
datasource:
name: logs
type: loki
uid: loki1
access: proxy
# make sure this matches your services name & namespace
url: "https://loki-querier-frontend.monitoring.svc:3100/"
isDefault: false
editable: false
jsonData:
# set the auth & OrgID headers, values are populated below
httpHeaderName1: Authorization
httpHeaderName2: X-Scope-OrgID
queryTimeout: 5m
timeout: 600
manageAlerts: false
# disable once proper serving certificates for the querier frontend have been set up
tlsSkipVerify: true
secureJsonData:
# template in the Bearer token value from the secret reference above
httpHeaderValue1: "Bearer ${token}"
# specify the tenant namespace name here
httpHeaderValue2: "showcase"
After a few seconds this datasource should show up in the Grafana Instance, ready to be queried.
Wrapping up
With this setup, tenants are able to easily enable log aggregation for their services by adding a single label to their Pods. They get isolated access to Loki, securely separated from other tenants. The querier frontend can be scaled to any required amount of replicas to guarantee query performance to avoid any slowdown introduced through the extra step.
Other Notes
Thank you for reading! đ Happy (multi-tenancy enabled) logging! :)
Iâve appended some thoughts below which didnât fit the scope of this post but should still be considered in a production deployment.
Network Access
To make this a little more secure, the querier frontend could be deployed in a different namespace to isolate it further from the Loki deployment. In either case, NetworkPolicies should be used to ensure Loki can only be queried through the frontend, not directly.
Multiple Tenant Namespaces
Should your tenant have multiple namespaces, this same setup can still be used. Tenants can maintain a single Grafana Instance & simply give their Datasource ServiceAccount the required permissions in any additional namespaces. The only unfortunate downside is that a new Datasource needs to be created for every single namespace as the X-Scope-OrgID
canât be a list or dynamic*.
Serving Certificates
The kube-rbac-proxy doesnât have any serving certs defined but still uses the secure listen address. This means clients will have to skip TLS certificate validation until a certificate is added to the deployment (which can be done with --tls-cert-file
and other parameters found in the kube-rbac-proxy documentation.)
Enterprise (OpenShift)
This exact setup can also be replicated in an enterprise environment using the OpenShift Cluster Logging Operator. ClusterLogForwarder CRDs, which create collectors using Vector, can be configured the same way as Alloy to make sure logs are properly ingested.
Query Multiple Tenants at Once
To query multiple tenants, youâll need to enable the functionality in Loki using multi_tenant_queries_enabled: true
. Unfortunately, this functionality canât be supported by the query frontend, as we rely on the X-Scope-OrgID
header to be exactly one namespace name to build the SubjectAccessReview, so this is more of an admin util than anything else.
You can build a list of namespaces to query with the following commands (these can get pretty big, just make sure you stay under the 1MB limit):
Only NS w/ Pods that have logging=enabled labels: kubectl get pods --all-namespaces -o json | jq -r '.items[] | select(.metadata.labels.logging == "enabled") | .metadata.namespace' | sort -u | tr '\n' '|' | sed 's/|$//'
All NS: kubectl get namespaces --no-headers -o custom-columns=":metadata.name" | tr '\n' '|' | sed 's/|$//'
failed to create fsnotify watcher: too many open files
This is most likely related to your nodes max open file descriptors for regular users being too low. If you have your nodes in an ansible inventory you can quickly patch all of them using ansible all -m shell -a "sudo sysctl fs.inotify.max_user_instances=1024" -u root
This will raise the amount to 1024 which should be more than enough for Alloy to reliably scrape all logs, but is also more or less just a duct tape fix.