Kubernetes keeps container logs only on the node where each pod runs, and they disappear when the pod is deleted. An EFK stack collects them in one searchable place: Fluentd runs on every node, reads the container log files, enriches each line with pod and namespace metadata, and sends it to Elasticsearch, where you search it with Kibana. In this tutorial you will deploy Elasticsearch and Kibana with Elastic's ECK operator, ship logs with a Fluentd DaemonSet, check that they arrive, and add a retention policy so the cluster does not fill its disks.
Prerequisites
To follow this tutorial, you will need:
- A Kubernetes cluster with a default StorageClass, for example K3s on a CubePath VPS running Ubuntu 24.04. The nodes need at least 4 GB of RAM free for Elasticsearch and Kibana, and about 20 GB of disk for log data.
kubectlconfigured with admin access, and Helm installed (sudo snap install helm --classicon Ubuntu).- A non-root user with
sudoprivileges on each node, to adjust a kernel setting in Step 1. - The cluster uses containerd or CRI-O as runtime (the default for K3s, MicroK8s, RKE2 and kubeadm today), so container logs are written in the CRI format.
This guide builds a single-node Elasticsearch suitable for small clusters and testing. The Conclusion explains what to change for production.
Step 1 - Preparing the nodes for Elasticsearch
Elasticsearch memory-maps its index files and needs a higher vm.max_map_count than the Ubuntu default. Set it on every node that may run Elasticsearch, and persist it across reboots:
echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-elasticsearch.conf
sudo sysctl --system
Verify the value:
sysctl vm.max_map_count
vm.max_map_count = 262144
Create a namespace for the logging stack:
kubectl create namespace logging
Step 2 - Installing the ECK operator
Elastic Cloud on Kubernetes (ECK) is the operator Elastic supports for running Elasticsearch and Kibana on Kubernetes. It handles TLS certificates, the elastic superuser password, upgrades and rolling restarts. Install it from Elastic's Helm repository:
helm repo add elastic https://helm.elastic.co
helm repo update
helm install elastic-operator elastic/eck-operator \
--namespace elastic-system --create-namespace
Check that the operator is running:
kubectl -n elastic-system get pods
NAME READY STATUS RESTARTS AGE
elastic-operator-0 1/1 Running 0 40s
Step 3 - Deploying Elasticsearch
With the operator in place, you describe the cluster you want as an Elasticsearch resource. This tutorial pins the 8.19 release line, which is the version the Fluentd Elasticsearch 8 image in Step 5 is built for. Check the ECK documentation for the latest 8.19 patch release and use it in both manifests.
nano elasticsearch.yaml
apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
name: logging
namespace: logging
spec:
version: 8.19.0
nodeSets:
- name: default
count: 1
podTemplate:
spec:
containers:
- name: elasticsearch
resources:
requests:
memory: 2Gi
cpu: 500m
limits:
memory: 2Gi
volumeClaimTemplates:
- metadata:
name: elasticsearch-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
count: 1creates a single Elasticsearch node.- Elasticsearch sizes its heap automatically to about half of the container memory limit.
- The volume claim must be named
elasticsearch-data; ECK mounts it as the data directory. It uses your default StorageClass.
Apply it and watch the resource until HEALTH is green and PHASE is Ready. This takes one to three minutes:
kubectl apply -f elasticsearch.yaml
kubectl -n logging get elasticsearch -w
NAME HEALTH NODES VERSION PHASE AGE
logging green 1 8.19.0 Ready 2m
Press Ctrl+C to stop watching. ECK has created an HTTPS Service called logging-es-http, a Secret with the password of the elastic user, and a Secret with the CA certificate. Store the password in a shell variable for the next steps:
ES_PASSWORD=$(kubectl -n logging get secret logging-es-elastic-user -o go-template='{{.data.elastic | base64decode}}')
Step 4 - Deploying Kibana
Kibana connects to Elasticsearch through the elasticsearchRef field; ECK configures the credentials and TLS for you.
nano kibana.yaml
apiVersion: kibana.k8s.elastic.co/v1
kind: Kibana
metadata:
name: logging
namespace: logging
spec:
version: 8.19.0
count: 1
elasticsearchRef:
name: logging
kubectl apply -f kibana.yaml
kubectl -n logging get kibana -w
NAME HEALTH NODES VERSION AGE
logging green 1 8.19.0 90s
Press Ctrl+C once health is green.
Step 5 - Deploying Fluentd as a DaemonSet
Fluentd must run on every node to read the log files the container runtime writes under /var/log/containers. A DaemonSet does exactly that. The official fluent/fluentd-kubernetes-daemonset image already contains the plugins needed here: the CRI log parser, the Kubernetes metadata filter and the Elasticsearch output.
First, give Fluentd read access to pod and namespace metadata:
nano fluentd-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: fluentd
namespace: logging
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fluentd
rules:
- apiGroups: [""]
resources: ["pods", "namespaces"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fluentd
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fluentd
subjects:
- kind: ServiceAccount
name: fluentd
namespace: logging
kubectl apply -f fluentd-rbac.yaml
Next, write the Fluentd configuration as a ConfigMap. It tails the container logs, parses the CRI format, adds Kubernetes metadata, and writes to daily indices named k8s-YYYY.MM.DD over verified TLS:
nano fluentd-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: fluentd-config
namespace: logging
data:
fluent.conf: |
<source>
@type tail
@id in_tail_container_logs
path /var/log/containers/*.log
exclude_path ["/var/log/containers/fluentd-*.log"]
pos_file /var/log/fluentd-containers.log.pos
tag kubernetes.*
read_from_head true
<parse>
@type cri
</parse>
</source>
<filter kubernetes.**>
@type kubernetes_metadata
</filter>
<match kubernetes.**>
@type elasticsearch
host logging-es-http.logging.svc
port 9200
scheme https
ssl_verify true
ca_file /etc/es-certs/ca.crt
user elastic
password "#{ENV['ES_PASSWORD']}"
logstash_format true
logstash_prefix k8s
<buffer>
@type file
path /var/log/fluentd-buffers/kubernetes.buffer
flush_mode interval
flush_interval 5s
chunk_limit_size 8MB
retry_max_interval 30s
overflow_action block
</buffer>
</match>
exclude_pathstops Fluentd from ingesting its own logs, which would create a feedback loop.pos_filerecords how far each file has been read, so a restarted pod does not resend everything.- The file buffer on the host keeps unsent logs if Elasticsearch is temporarily unavailable.
Apply the ConfigMap:
kubectl apply -f fluentd-config.yaml
Now create the DaemonSet. It mounts the node's /var/log, the ConfigMap and the Elasticsearch CA certificate, and reads the password from the Secret ECK created. Check Docker Hub for the newest -debian-elasticsearch8- tag of the image and use it:
nano fluentd-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluentd
namespace: logging
labels:
app: fluentd
spec:
selector:
matchLabels:
app: fluentd
template:
metadata:
labels:
app: fluentd
spec:
serviceAccountName: fluentd
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: fluentd
image: fluent/fluentd-kubernetes-daemonset:v1.18-debian-elasticsearch8-1
securityContext:
runAsUser: 0
env:
- name: ES_PASSWORD
valueFrom:
secretKeyRef:
name: logging-es-elastic-user
key: elastic
resources:
requests:
cpu: 100m
memory: 200Mi
limits:
memory: 512Mi
volumeMounts:
- name: varlog
mountPath: /var/log
- name: config
mountPath: /fluentd/etc
- name: es-certs
mountPath: /etc/es-certs
readOnly: true
volumes:
- name: varlog
hostPath:
path: /var/log
- name: config
configMap:
name: fluentd-config
- name: es-certs
secret:
secretName: logging-es-http-certs-public
Container log files in /var/log/containers are symlinks into /var/log/pods, so mounting /var/log covers both. The toleration lets Fluentd also run on control-plane nodes that are tainted.
Apply it and check that one pod runs per node:
kubectl apply -f fluentd-daemonset.yaml
kubectl -n logging get daemonset fluentd
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
fluentd 1 1 1 1 1 <none> 45s
Look at the Fluentd logs for connection errors:
kubectl -n logging logs daemonset/fluentd --tail=20
A healthy start ends with a line such as fluentd worker is now running worker=0, with no repeated Could not communicate to Elasticsearch warnings.
NoteThe
elasticsuperuser keeps this tutorial short. In production, create a dedicated Elasticsearch user with a role that can only write tok8s-*indices and give Fluentd those credentials instead.
Step 6 - Adding a retention policy
Without retention, daily indices accumulate until the disk is full and Elasticsearch stops accepting writes. An index lifecycle management (ILM) policy deletes indices after a set age, and an index template applies it to every new k8s-* index.
Open a port forward to Elasticsearch in a second terminal and leave it running:
kubectl -n logging port-forward service/logging-es-http 9200
Back in the first terminal, create a policy that deletes indices seven days after creation. The -k flag skips certificate name checks because you are connecting through localhost; the traffic never leaves the server:
curl -sk -u "elastic:$ES_PASSWORD" -X PUT "https://localhost:9200/_ilm/policy/k8s-logs" \
-H 'Content-Type: application/json' \
-d '{"policy":{"phases":{"hot":{"actions":{}},"delete":{"min_age":"7d","actions":{"delete":{}}}}}}'
{"acknowledged":true}
Create an index template that attaches the policy to new k8s-* indices. number_of_replicas: 0 keeps a single-node cluster green, because there is no second node to hold replicas:
curl -sk -u "elastic:$ES_PASSWORD" -X PUT "https://localhost:9200/_index_template/k8s-logs" \
-H 'Content-Type: application/json' \
-d '{"index_patterns":["k8s-*"],"template":{"settings":{"index.lifecycle.name":"k8s-logs","number_of_replicas":0}}}'
{"acknowledged":true}
The template applies to indices created from now on. Apply the same settings to the index Fluentd has already created today:
curl -sk -u "elastic:$ES_PASSWORD" -X PUT "https://localhost:9200/k8s-*/_settings" \
-H 'Content-Type: application/json' \
-d '{"index.lifecycle.name":"k8s-logs","number_of_replicas":0}'
Adjust 7d to your retention needs and available disk.
Step 7 - Verifying that logs arrive
Start a short-lived pod that prints a few lines:
kubectl run logger --image=busybox:stable --restart=Never -- \
sh -c 'for i in 1 2 3 4 5; do echo "hello from logger $i"; sleep 1; done'
After about ten seconds, list the log indices:
curl -sk -u "elastic:$ES_PASSWORD" "https://localhost:9200/_cat/indices/k8s-*?v"
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size
green open k8s-2026.09.25 a1B2c3D4e5F6g7H8i9J0kL 1 0 15230 0 6.1mb 6.1mb
Search for the test pod's lines. The metadata filter stored the pod name in kubernetes.pod_name:
curl -sk -u "elastic:$ES_PASSWORD" \
"https://localhost:9200/k8s-*/_search?q=kubernetes.pod_name:logger&size=1&pretty"
{
...
"hits" : {
"total" : { "value" : 5, "relation" : "eq" },
"hits" : [
{
"_index" : "k8s-2026.09.25",
"_source" : {
"stream" : "stdout",
"logtag" : "F",
"message" : "hello from logger 1",
"kubernetes" : {
"namespace_name" : "default",
"pod_name" : "logger",
"container_name" : "logger",
...
Delete the test pod and stop the Elasticsearch port forward with Ctrl+C:
kubectl delete pod logger
Step 8 - Exploring logs in Kibana
Kibana is only reachable inside the cluster. Forward its Service on the server:
kubectl -n logging port-forward service/logging-kb-http 5601
From your workstation, open an SSH tunnel to the server, replacing your_user and your_server_ip:
ssh -N -L 5601:127.0.0.1:5601 your_user@your_server_ip
Open https://localhost:5601, accept the self-signed certificate warning and log in as elastic with the password stored in ES_PASSWORD (print it on the server with echo "$ES_PASSWORD").
To see the logs:
- Open the main menu and go to Discover.
- Click Create data view, set Index pattern to
k8s-*and Timestamp field to@timestamp, then save it. - Filter with KQL, for example
kubernetes.namespace_name : "default"ormessage : "error".
Add columns such as kubernetes.pod_name and message from the field list to get a readable log view.
Troubleshooting
Elasticsearch pod is Pending. No node has enough free memory or the PersistentVolumeClaim cannot bind. Run kubectl -n logging describe pod logging-es-default-0 and check the events at the bottom.
Elasticsearch restarts with max virtual memory areas vm.max_map_count [65530] is too low. Step 1 was not applied on the node running the pod. Set the sysctl there.
Fluentd logs show certificate verify failed. The host in the ConfigMap must match a name in the certificate. Use logging-es-http.logging.svc, not an IP address.
No k8s-* index appears. Check that files exist under /var/log/containers on the node and that Fluentd is not logging pattern not matched errors. If your runtime is Docker Engine with the json-file driver instead of containerd, replace the cri parser with @type json.
Index health is yellow. The index has replicas that cannot be allocated on a single node. Apply the settings request from Step 6 to set number_of_replicas to 0.
Conclusion
You deployed Elasticsearch and Kibana with the ECK operator, shipped every container's logs with a Fluentd DaemonSet, verified them end to end and limited retention to seven days. For production, raise count to three Elasticsearch nodes (and set replicas back to 1), give Fluentd a dedicated write-only user, and consider adding the fluent-plugin-concat filter to join multi-line stack traces into single events.
