Observability
Architecting a Scalable Monitoring Stack: Loki, Cortex & PagerDuty
March 2026 • Sreeraj
As microservices architectures expand, traditional monitoring solutions quickly hit vertical scaling limits. Prometheus consumes massive memory for long-term retention, and Elasticsearch requires heavy JVM tuning and costly storage. To solve this, enterprise platforms are migrating to highly targeted, horizontally scalable telemetry stacks.
At DevopsMint, we enforce a strict standard: Grafana Loki for log aggregation, Cortex for horizontally scalable time-series metrics, and PagerDuty for automated incident routing. This tutorial covers the end-to-end architecture and deployment of this stack in Kubernetes.
Phase 1: Why Loki over Elasticsearch?
Elasticsearch indexes the full text of every log line. If you ingest 1TB of logs, you generate a massive inverted index, requiring substantial memory and CPU. Loki takes a different approach inspired by Prometheus: it groups log lines into "streams" and only indexes the labels (metadata like app, namespace, or node), leaving the actual log text unindexed and compressed in object storage (S3/GCS).
Deploying Loki via Helm
We deploy Loki and its agent, Promtail, using the official Helm charts. Promtail runs as a DaemonSet on every node, mounting the container runtime socket to automatically scrape and label stdout/stderr logs.
# Add the Grafana repository
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
# Create a custom values file for Loki (loki-values.yaml)
loki:
auth_enabled: false
commonConfig:
replication_factor: 1
storage:
type: s3
bucketNames:
chunks: my-loki-chunks
ruler: my-loki-ruler
admin: my-loki-admin
s3:
endpoint: s3.us-east-1.amazonaws.com
region: us-east-1
# Install Loki
helm upgrade --install loki grafana/loki-stack \
--namespace observability \
--create-namespace \
-f loki-values.yaml \
--set promtail.enabled=true
Phase 2: Horizontally Scaling Metrics with Cortex
Prometheus is brilliant, but it is fundamentally designed as a single-node architecture. When you exceed millions of active series (tracking CPU, memory, network I/O across thousands of pods), Prometheus crashes due to OOM (Out of Memory) errors. Cortex provides a multi-tenant, horizontally scalable backend for Prometheus data.
Cortex splits the Prometheus workload into microservices: Distributors (handle incoming metrics), Ingesters (batch them in memory), Queriers (execute PromQL), and Compactors (optimize long-term storage in S3).
Configuring Prometheus Remote Write
Instead of Prometheus keeping data forever, you configure it to act as a lightweight "scraper" that immediately forwards data to Cortex using the remote_write API.
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
# Push scraped data to the Cortex Distributor
remote_write:
- url: http://cortex-distributor.observability.svc.cluster.local/api/prom/push
headers:
# Cortex requires a tenant ID for multi-tenancy
X-Scope-OrgID: "production-tenant"
queue_config:
max_shards: 100
capacity: 10000
Phase 3: Incident Management Pipeline
Telemetry data is useless if it doesn't alert an engineer when things break. Cortex integrates with Alertmanager to evaluate rules and fire alerts. We route these to PagerDuty.
Defining the Cortex Alert Rule
We write rules in standard PromQL. This rule detects if any container consumes more than 85% of its requested memory limit for 5 consecutive minutes.
groups:
- name: pod_resource_alerts
rules:
- alert: ContainerMemoryHigh
expr: sum(container_memory_working_set_bytes) by (pod, namespace) / sum(kube_pod_container_resource_limits{resource="memory"}) by (pod, namespace) > 0.85
for: 5m
labels:
severity: critical
team: infrastructure
annotations:
summary: "Pod {{ $labels.pod }} memory usage > 85%"
runbook_url: "https://wiki.devopsmint.com/runbooks/high-memory"
Routing to PagerDuty
In your Alertmanager configuration, bind the critical severity to the PagerDuty receiver using an Integration Key.
global:
resolve_timeout: 5m
route:
group_by: ['alertname', 'namespace']
group_wait: 30s
group_interval: 5m
repeat_interval: 3h
receiver: 'slack-alerts' # Default
routes:
- match:
severity: critical
receiver: 'pagerduty-oncall'
receivers:
- name: 'pagerduty-oncall'
pagerduty_configs:
- service_key: 'YOUR_PAGERDUTY_INTEGRATION_KEY'
severity: '{{ if eq .CommonLabels.severity "critical" }}critical{{ else }}warning{{ end }}'
description: '{{ template "pagerduty.default.description" .}}'
By enforcing this standard stack, you achieve limitless retention via S3, lightning-fast queries, and a reliable escalation path—forming the backbone of true Site Reliability Engineering.
Containers
End-to-End Containerization: Multi-stage Builds & Security Contexts
February 2026 • Sreeraj
Docker containerization is the foundational layer of modern cloud-native engineering. However, there is a massive difference between a Dockerfile that "works locally" and a production-grade Docker image. Large image sizes slow down CI/CD pipelines, increase deployment times, and expand the vulnerability attack surface.
In this tutorial, we will construct a highly optimized, secure, multi-stage Dockerfile for a Node.js API, implement .dockerignore, and orchestrate it with a database using Docker Compose.
Step 1: The .dockerignore File
Before writing the Dockerfile, you must instruct the Docker daemon to ignore specific local files. If you accidentally copy your local node_modules or .git folder into the image, your build will be bloated and corrupted.
# .dockerignore
node_modules
npm-debug.log
.git
.env
dist
coverage
Dockerfile
docker-compose.yml
Step 2: The Multi-Stage Dockerfile
For compiled languages (like Go) or languages that require build steps (like TypeScript), shipping the build tools (compilers, linters) to production is a severe anti-pattern. Multi-stage builds allow you to use a heavy image to compile the code, and a minimal image to run it.
# ==========================================
# STAGE 1: Builder (Heavy Environment)
# ==========================================
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /usr/src/app
# Copy package manifests first. Docker caches layers;
# if package.json hasn't changed, it skips running npm ci.
COPY package*.json ./
# Install ALL dependencies (including devDependencies like TypeScript)
RUN npm ci
# Copy the rest of the application source code
COPY . .
# Run the build process (compiles TypeScript to JavaScript in /dist)
RUN npm run build
# ==========================================
# STAGE 2: Production (Minimal Environment)
# ==========================================
FROM node:20-alpine AS production
WORKDIR /usr/src/app
# Set Node environment to production (disables debug logs, optimizes express)
ENV NODE_ENV=production
# Copy manifests again
COPY package*.json ./
# Install ONLY production dependencies, saving massive space
RUN npm ci --only=production
# Copy ONLY the compiled artifacts from the builder stage
COPY --from=builder /usr/src/app/dist ./dist
# Security: Run as a non-root user. The 'node' user is built into the alpine image.
USER node
# Expose the port the API listens on
EXPOSE 3000
# Define the command to start the application
CMD ["node", "dist/server.js"]
Step 3: Building and Inspecting
Build the image. The -t flag tags the image. Always use semantic versioning instead of relying on the latest tag.
docker build -t api-service:v1.0.0 .
If you run docker images, you will see the api-service is drastically smaller than a non-multi-stage build because the heavy build tools were left behind in the builder stage.
Step 4: Orchestration with Docker Compose
Microservices rarely operate in isolation; our API requires a PostgreSQL database. We use docker-compose.yml to define the networking, volumes, and environment variables needed to spin up the entire stack locally.
# docker-compose.yml
version: '3.8'
services:
api:
image: api-service:v1.0.0
build: .
ports:
- "3000:3000"
environment:
- DB_HOST=postgres-db
- DB_USER=admin
- DB_PASS=supersecret
depends_on:
postgres-db:
condition: service_healthy
networks:
- backend-net
postgres-db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=supersecret
- POSTGRES_DB=appdb
volumes:
# Persist database data to the local host machine
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d appdb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend-net
networks:
backend-net:
driver: bridge
volumes:
pgdata:
Docker DNS Magic: Notice that the API connects to DB_HOST=postgres-db. Because both containers are attached to the backend-net bridge network, Docker automatically resolves the service name to the container's internal IP address.
Start the entire stack in detached mode with docker-compose up -d. This declarative approach mirrors how Kubernetes handles Deployments and Services, making local development a perfect stepping stone to production.
Kubernetes
Mastering Kubernetes StatefulSets: Persistence & Headless Services
January 2026 • Sreeraj
Kubernetes was originally designed for stateless workloads. A Deployment assumes that Pods are cattle—interchangeable, disposable, and identical. But what happens when you need to deploy a clustered database like Cassandra, Zookeeper, or Kafka?
Stateful applications require stable network identity, persistent storage that moves with the Pod, and ordered deployment and scaling. This is exactly what a StatefulSet provides.
1. The Headless Service: Network Identity
When you create a standard Kubernetes Service, it gets a ClusterIP and acts as a load balancer. Stateful workloads don't want a load balancer; a worker node needs to know exactly how to connect to "Kafka Broker 2".
We solve this with a Headless Service (setting clusterIP: None). This tells Kubernetes to skip load balancing and instead create a DNS A-record for every individual Pod in the StatefulSet.
apiVersion: v1
kind: Service
metadata:
name: elasticsearch-cluster
labels:
app: elasticsearch
spec:
# clusterIP: None makes this a Headless Service
clusterIP: None
ports:
- port: 9200
name: http
- port: 9300
name: transport
selector:
app: elasticsearch
2. Designing the StatefulSet Manifest
The core difference in a StatefulSet manifest is the volumeClaimTemplates block. Instead of mapping one Persistent Volume to all pods (which would cause data corruption), the template dynamically creates a unique PersistentVolumeClaim (PVC) for every Pod replica generated.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: es-node
spec:
serviceName: "elasticsearch-cluster"
replicas: 3
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch:8.10.0
env:
- name: discovery.seed_hosts
# Notice how we can explicitly reference the peer pods via DNS
value: "es-node-0.elasticsearch-cluster,es-node-1.elasticsearch-cluster,es-node-2.elasticsearch-cluster"
- name: cluster.initial_master_nodes
value: "es-node-0,es-node-1,es-node-2"
ports:
- containerPort: 9200
name: http
volumeMounts:
- name: es-data
mountPath: /usr/share/elasticsearch/data
# This provisions a unique volume for es-node-0, es-node-1, etc.
volumeClaimTemplates:
- metadata:
name: es-data
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: "fast-ssd"
resources:
requests:
storage: 50Gi
3. Lifecycle and Operations
Apply the manifests: kubectl apply -f headless-svc.yaml -f statefulset.yaml
Ordered Pod Creation
If you watch the deployment (kubectl get pods -w), you will see that Kubernetes brings them up strictly in order. es-node-0 must be Running and Ready before es-node-1 is created. Scaling down happens in reverse order (2, then 1, then 0).
Stable Network Identity
Other pods in the cluster can now reach specific Elasticsearch nodes via predictable DNS names. The format is <pod-name>.<service-name>.<namespace>.svc.cluster.local. For example: es-node-1.elasticsearch-cluster.default.svc.cluster.local.
Storage Stickiness
If node hardware fails and es-node-1 dies, Kubernetes will reschedule a new pod on a different node. It will name it es-node-1, and it will forcefully detach the original 50Gi AWS EBS volume from the dead node and reattach it to the new node, ensuring zero data loss.
Infrastructure
Terraform Remote State Management: S3, DynamoDB & Locking
December 2025 • Sreeraj
Terraform uses a state file (terraform.tfstate) to track the mapping between the configuration you write and the actual resources provisioned in the cloud. Storing this file locally is fine for a solo developer, but in an enterprise team, it is a critical anti-pattern.
Local state files lead to race conditions (two engineers running apply simultaneously overwriting each other) and security breaches (state files contain plain-text secrets like database passwords). The industry standard is an encrypted S3 Backend with DynamoDB state locking.
Step 1: The Bootstrap (Chicken and Egg Problem)
You cannot store your Terraform state in an AWS S3 bucket that hasn't been created yet. To solve this, we write a temporary local module to provision the S3 bucket and DynamoDB table.
Create bootstrap.tf:
provider "aws" {
region = "us-east-1"
}
# 1. Create the S3 Bucket for State Storage
resource "aws_s3_bucket" "terraform_state" {
bucket = "devopsmint-prod-tf-state"
}
# 2. Enable Versioning (Crucial for rollback if state gets corrupted)
resource "aws_s3_bucket_versioning" "enabled" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# 3. Enable Server-Side Encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "default" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# 4. Create DynamoDB Table for State Locking
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-locks"
billing_mode = "PAY_PER_REQUEST" # Serverless billing
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Initialize and apply this locally: terraform init && terraform apply.
Step 2: Migrating to the Remote Backend
Now that the infrastructure exists, we tell Terraform to stop using the local filesystem. Create a backend.tf file in your main infrastructure repository:
terraform {
backend "s3" {
bucket = "devopsmint-prod-tf-state"
# The path where the state file will be saved in the bucket
key = "core/network/terraform.tfstate"
region = "us-east-1"
# DynamoDB table for state locking
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
Step 3: State Migration
Run the initialization command again. Terraform is smart enough to detect that the backend configuration has changed.
terraform init
Terraform will output: "Do you want to copy existing state to the new backend?" Answer yes. The local terraform.tfstate file is now safe to delete (and should be added to .gitignore).
How Locking Works: When CI/CD (or an engineer) runs terraform plan or apply, Terraform writes a LockID item to the DynamoDB table. If a second pipeline triggers simultaneously, Terraform sees the lock in DynamoDB and throws an error (Error acquiring the state lock), preventing the execution and saving your cloud infrastructure from race-condition corruption.
Observability
Grafana Loki LogQL: Extracting Metrics from Unstructured Logs
November 2025 • Sreeraj
Grafana Loki is incredibly cost-effective because it intentionally avoids full-text indexing. However, this means you cannot just type a keyword into a search bar and expect an instant result. You must master its query language, LogQL, to parse and filter data at runtime.
LogQL is deeply inspired by PromQL. A query consists of a Log Stream Selector (to find the files) and a Log Pipeline (to grep, parse, and format the text).
1. Log Stream Selectors and Line Filters
You must always start by narrowing down the data using labels. This ensures Loki only scans a few megabytes of log files instead of terabytes.
# 1. Stream Selector: Find logs for the payment service
{app="payment-service", env="production"}
# 2. Line Filter: Pipe the stream into a text filter
{app="payment-service"} |= "connection refused" != "healthcheck"
The operators are: |= (contains string), != (does not contain), |~ (matches regex), and !~ (does not match regex).
2. Parsers: Unlocking Structured Data
If your application outputs JSON logs (which it should), Loki can parse the JSON on the fly, turning JSON keys into queryable labels without indexing them.
# Assume log: {"level":"error", "status": 500, "user_id": "U-123", "latency_ms": 450}
# Parse the JSON, then filter numerically
{app="payment-service"} | json | status >= 500
If you have unstructured logs (like Nginx), use the pattern parser:
# Parse Nginx combined log format
{app="nginx"} | pattern `<ip> - - [<time>] "<method> <path> <protocol>" <status> <bytes>` | status >= 400
3. Metric Queries: Turning Logs into Dashboards
The true power of LogQL is extracting time-series metrics from raw text. This saves you from having to instrument your application code with Prometheus libraries for basic metrics.
Example A: Calculating Error Rates
To count how many HTTP 500 errors occur per second over the last 5 minutes, wrap the log query in a rate() function:
rate(
{app="payment-service"} | json | status >= 500 [5m]
)
Example B: Percentile Latency (P99)
If your logs output response durations, you can unwrap that value and calculate the 99th percentile latency across the cluster.
# Extract the latency_ms field, unwrap it as a float, and calculate P99
quantile_over_time(
0.99,
{app="payment-service"} | json | unwrap latency_ms [1h]
)
By mastering LogQL, you can build rich Grafana dashboards showing traffic spikes, error rates, and latency distributions completely from stdout logs.
Kubernetes
Kubernetes Networking: Demystifying CNI and Project Calico BGP
September 2025 • Sreeraj
A fresh Kubernetes cluster built with kubeadm is practically useless out of the box because it has no network. Kubernetes dictates the networking rules (e.g., all Pods must be able to communicate with each other without NAT), but it relies entirely on third-party plugins adhering to the Container Network Interface (CNI) to actually route the packets.
While plugins like Flannel use overlay networks (VXLAN) wrapping packet-in-packet, Project Calico can use pure Layer 3 routing via BGP (Border Gateway Protocol). This results in bare-metal network performance for your containers.
Step 1: Installing the Tigera Operator
Calico is managed via an operator. Install the Tigera Operator to manage the lifecycle of the Calico components on the cluster nodes.
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/tigera-operator.yaml
Step 2: Configuring the IP Pool and BGP
Next, we apply the Installation custom resource. The cidr must exactly match the --pod-network-cidr flag you passed to kubeadm init.
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
ipPools:
- blockSize: 26
cidr: 192.168.0.0/16
# Set to 'None' for pure BGP Layer 3 routing (if underlying network allows)
# Or 'VXLANCrossSubnet' for hybrid environments (like AWS/GCP)
encapsulation: VXLANCrossSubnet
natOutgoing: Enabled
nodeSelector: all()
Once applied, the calico-node DaemonSet deploys onto every Kubernetes worker. It wires up the Linux routing tables, programs eBPF/iptables rules, and starts a BIRD daemon to peer with other nodes and advertise the IP routes of the Pods running on it.
Step 3: Zero-Trust Security with NetworkPolicies
By default, Kubernetes clusters are highly insecure—every Pod can talk to every other Pod across namespaces. Calico acts as the enforcement engine for Kubernetes NetworkPolicy resources, blocking traffic at the Linux kernel level.
Best practice dictates implementing a Default Deny policy in every namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
# Empty podSelector selects ALL pods in the 'production' namespace
podSelector: {}
policyTypes:
- Ingress
- Egress
After applying this, communication is severed. You must explicitly create allow-rules. For example, allowing the frontend Pods to communicate with the backend Pods on port 8080:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
Calico translates these YAML manifests into deeply optimized iptables rules (or eBPF maps) ensuring hardware-level packet filtering for microservice security.
Security
HashiCorp Vault: Dynamic AWS Secrets & Kubernetes Integration
August 2025 • Sreeraj
Hardcoding database passwords in Git or injecting static AWS IAM keys into CI/CD pipelines guarantees that you will eventually suffer a data breach. HashiCorp Vault is the industry standard for centralized, identity-based secrets management.
In this tutorial, we move beyond basic key-value storage and configure Vault to dynamically generate self-destructing AWS credentials.
Step 1: Dynamic AWS Secrets Engine
Vault can connect to AWS using a root/admin IAM user, and then generate temporary, short-lived IAM credentials for your applications on demand.
# 1. Enable the AWS secrets engine
vault secrets enable aws
# 2. Provide Vault with AWS Admin credentials
vault write aws/config/root \
access_key=AKIA_YOUR_ADMIN_KEY \
secret_key=YOUR_ADMIN_SECRET \
region=us-east-1
# 3. Create a Vault Role tied to an IAM Policy
# This policy only allows listing S3 buckets.
vault write aws/roles/s3-readonly-app \
credential_type=iam_user \
policy_document='{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["*"]
}
]
}'
When an application needs to talk to AWS, it requests credentials from Vault. Vault calls the AWS API, creates a new IAM user, attaches the policy, and returns the keys. Vault remembers the lease, and when the TTL expires (e.g., 60 minutes), Vault automatically deletes the IAM user in AWS.
# Request credentials
vault read aws/creds/s3-readonly-app
Step 2: Kubernetes Authentication Method
How does a Kubernetes Pod authenticate to Vault to request these secrets? We use the Kubernetes Auth Method. Vault trusts the Kubernetes API server and verifies the JWT token of the Pod's ServiceAccount.
# Enable K8s auth in Vault
vault auth enable kubernetes
# Configure Vault to talk to the K8s API
vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc.cluster.local"
# Map a K8s ServiceAccount to a Vault Policy
vault write auth/kubernetes/role/my-app-role \
bound_service_account_names=my-app-sa \
bound_service_account_namespaces=production \
policies=app-policy \
ttl=24h
Step 3: Secret Injection via Annotations
Using the HashiCorp Vault Agent Injector in Kubernetes, you don't even need to modify your application code. You simply add annotations to your Deployment, and Vault injects the secrets directly into the Pod's filesystem (usually at /vault/secrets/config).
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "my-app-role"
# Request the dynamic AWS creds
vault.hashicorp.com/agent-inject-secret-aws: "aws/creds/s3-readonly-app"
spec:
serviceAccountName: my-app-sa
containers:
- name: app
image: my-app:v1
This architecture represents true zero-trust infrastructure. Your apps have zero static credentials, and every secret is short-lived and auditable.
Orchestration
HashiCorp Nomad: Lightweight Orchestration without the K8s Overhead
July 2025 • Sreeraj
Kubernetes won the orchestration war, but it requires a massive operational toll. Managing etcd, control planes, CNIs, and complex RBAC is overkill for many organizations. HashiCorp Nomad is a brilliantly simple, highly scalable alternative that ships as a single compiled binary.
1. Architecture and Flexibility
Unlike Kubernetes which only runs containers, Nomad is a general-purpose scheduler. Using "Task Drivers," Nomad can schedule Docker containers, raw Java JAR files, isolated QEMU virtual machines, and even raw Bash batch jobs across a fleet of servers.
2. Writing the HCL Job Specification
Nomad uses HashiCorp Configuration Language (HCL). Let's write a spec to deploy an Nginx web server, restrict its CPU/Memory, and configure a rolling update strategy.
# webapp.nomad
job "frontend" {
datacenters = ["dc1", "dc2"]
type = "service"
# Update strategy: Blue/Green-esque rolling update
update {
max_parallel = 1
health_check = "checks"
min_healthy_time = "10s"
healthy_deadline = "3m"
auto_revert = true
}
group "web" {
# Run 3 replicas
count = 3
network {
port "http" {
to = 80
}
}
# Register with Consul for Service Discovery
service {
name = "frontend-web"
port = "http"
check {
type = "http"
path = "/health"
interval = "10s"
timeout = "2s"
}
}
task "nginx" {
driver = "docker"
config {
image = "nginx:1.24-alpine"
ports = ["http"]
}
resources {
cpu = 200 # 200 MHz
memory = 128 # 128 MB
}
}
}
}
3. Deploying the Job
Submit the job to the Nomad control plane:
nomad job run webapp.nomad
Nomad uses a highly optimized bin-packing algorithm to find the exact servers in dc1 and dc2 that have 200MHz of CPU and 128MB of RAM available. It pulls the image and starts the container.
Because Nomad integrates natively with Consul (HashiCorp's Service Mesh), the moment the container passes its health check, it is registered in Consul's DNS registry, allowing other applications (or an HAProxy load balancer) to discover it instantly.
Observability
The ELK Stack: Advanced Logstash Grok Pipelines for Centralized Logging
May 2025 • Sreeraj
While Loki is optimized for Kubernetes labels, the ELK Stack (Elasticsearch, Logstash, Kibana) remains the gold standard when you need deep, full-text search and complex transformation of legacy, unstructured logs (like firewalls, Apache servers, or legacy JVM apps).
The core power of this stack lies in Logstash—an ingestion engine capable of enriching and structuring chaotic data before it reaches Elasticsearch.
1. The Logstash Pipeline Architecture
A Logstash configuration requires three blocks: input (where data comes from), filter (how to parse it), and output (where to send it). We will write a pipeline to parse a custom application log.
The Target Log Line:
2021-11-04 14:32:01 [ERROR] [user_id: 9948] Payment processor timeout after 3000ms
2. Writing the Pipeline (logstash.conf)
input {
# Accept logs from Filebeat agents running on edge nodes
beats {
port => 5044
}
}
filter {
# Only process logs tagged by Filebeat as 'payment_app'
if [fields][app] == "payment_app" {
# 1. The Grok Filter: Regex magic to structure the string
grok {
match => {
"message" => "%{TIMESTAMP_ISO8601:log_timestamp} \[%{LOGLEVEL:severity}\] \[user_id: %{NUMBER:user_id}\] %{GREEDYDATA:log_message}"
}
}
# 2. Data Enrichment & Cleaning
if [user_id] {
# Convert the extracted user_id from string to integer for ES sorting
mutate {
convert => { "user_id" => "integer" }
}
}
# 3. Time Synchronization
# Replace the Logstash ingestion timestamp with the actual time the app wrote the log
date {
match => [ "log_timestamp" , "yyyy-MM-dd HH:mm:ss" ]
target => "@timestamp"
timezone => "UTC"
}
# 4. Clean up the parsed field to save index space
mutate {
remove_field => [ "log_timestamp" ]
}
}
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
# Implement Index Lifecycle Management (ILM) by writing to daily indices
index => "payment-app-%{+YYYY.MM.dd}"
}
}
3. The Elasticsearch Advantage
By parsing this string before ingestion, Elasticsearch indexes severity as a keyword and user_id as an integer. When you open Kibana, you don't have to search for text; you simply build a visual dashboard filtering by severity: ERROR and sorting by user_id.
Furthermore, because the index is named payment-app-2021.11.04, you can configure Elasticsearch ILM policies to automatically move indices older than 7 days to slower, cheaper "Warm" storage tiers, and delete them after 30 days, optimizing your AWS EBS costs.
Linux
Shell Scripting & Linux Mastery: Debugging Systems at the Kernel Level
April 2025 • Sreeraj
No matter how many cloud abstractions you build, DevOps engineering ultimately boils down to a Linux terminal. When a production node enters a degraded state, you don't have time to wait for a GUI dashboard to load—you need answers directly from the kernel.
1. Bash Strict Mode
If you write automation scripts (e.g., CI/CD deployment scripts), failing to handle errors can result in catastrophic partial executions. Always start your Bash scripts with "Strict Mode".
#!/bin/bash
# set -e: Exit immediately if a command returns a non-zero status
# set -u: Exit if an unset variable is referenced
# set -o pipefail: Fail the entire pipeline if any command within it fails
set -euo pipefail
echo "Deploying application..."
# If this download fails, the script STOPS. It will not execute systemctl restart.
curl -sSL "https://my-repo/app.tar.gz" | tar -xz -C /opt/app/
systemctl restart myapp
echo "Deployment successful."
2. Text Processing (grep, awk, sort)
You have a 5GB Nginx access log and need to find the top 5 IP addresses spamming your server with 404 errors. You can do this in seconds without ELK.
# 1. grep: Extract lines containing 404
# 2. awk: Print the 1st column (the IP address in Nginx combined logs)
# 3. sort: Sort alphabetically so identical IPs are adjacent
# 4. uniq -c: Count consecutive identical lines
# 5. sort -nr: Sort numerically in reverse (highest count first)
# 6. head: Show top 5
grep " 404 " /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -nr | head -n 5
3. Process and System Debugging
When the CPU spikes to 100%, top or htop tells you *which* process is offending. But to know *what* that process is doing, use strace.
# Attach to process ID 1234 and trace system calls.
# -c provides a summary of time spent in each system call.
sudo strace -c -p 1234
If a service won't start because a port is bound, find the offending process using ss (Socket Statistics, the modern replacement for netstat) or lsof.
# Show TCP (-t), listening (-l), numeric ports (-n), and the process PID (-p)
sudo ss -tlnp | grep ":8080"
# Alternatively, list open files/sockets
sudo lsof -i :8080
4. Network Debugging
Is the firewall blocking traffic, or is the application just not responding? Use tcpdump to monitor the interface at the packet level.
# Watch incoming TCP traffic on eth0 targeted at port 443, without resolving hostnames (-n)
sudo tcpdump -i eth0 tcp port 443 -n
Mastering these primitive tools ensures you can navigate and repair any Unix-based environment blindfolded.
Observability
Grafana Alertmanager: Routing, Grouping, and Inhibition Rules
February 2025 • Sreeraj
Alert fatigue is the leading cause of SRE burnout. When an entire AWS Availability Zone goes down, your Prometheus instance will detect hundreds of offline microservices. Without Alertmanager, your phone will receive 500 individual PagerDuty SMS messages. Alertmanager intercepts these alerts, groups them logically, and sends a single, actionable notification.
1. The Route Tree & Grouping
The alertmanager.yml configuration routes alerts based on labels. Grouping bundles alerts with the same specified labels into a single notification.
global:
resolve_timeout: 5m
route:
# The default receiver if no specific routes match
receiver: 'slack-general'
# Group alerts together by cluster, namespace, and alertname
group_by: ['cluster', 'namespace', 'alertname']
# How long to wait before sending the FIRST notification for a new group
# This allows Alertmanager to gather related alerts that fire milliseconds apart
group_wait: 30s
# How long to wait before sending an update on an existing group (e.g., new alerts added)
group_interval: 5m
# How long to wait before re-sending a notification if the alert is STILL firing
repeat_interval: 4h
routes:
# Route database alerts to the DBA team Slack
- match:
service: database
receiver: 'slack-db-team'
# Route CRITICAL alerts to PagerDuty
- match:
severity: critical
receiver: 'pagerduty-oncall'
2. Receivers
Receivers define the actual integration endpoints (Slack webhooks, PagerDuty integration keys, VictorOps, email, etc.).
receivers:
- name: 'slack-db-team'
slack_configs:
- api_url: 'https://hooks.slack.com/services/T0000/B000/XXXX'
channel: '#db-alerts'
title: '{{ template "slack.default.title" . }}'
text: '{{ template "slack.default.text" . }}'
- name: 'pagerduty-oncall'
pagerduty_configs:
- service_key: 'YOUR_INTEGRATION_KEY'
3. Inhibition Rules (Muting Cascading Failures)
Inhibition is Alertmanager's most powerful feature. It allows you to mute an alert if a more severe, related alert is already firing.
For example, if the HostDown alert fires for a Kubernetes worker node, Prometheus will naturally also fire HighLatency or ServiceOffline alerts for all the containers that happened to be running on that node. You want to suppress the container alerts because the root cause is the dead host.
inhibit_rules:
# Source match: The alert that triggers the suppression
- source_match:
alertname: 'HostDown'
severity: 'critical'
# Target match: The alerts to suppress
target_match:
severity: 'warning'
# ONLY apply this suppression if the 'instance' label is identical on both alerts
equal: ['instance']
With grouping and inhibition perfectly tuned, you transform your monitoring system from a noisy spam engine into a surgical incident response tool.
Community
The Complete Guide to DevOps & CNCF Communities in Bangalore
January 2025 • Sreeraj
Bengaluru (Bangalore) is undeniably the technology capital of India. The sheer density of global GCCs (Global Capability Centers), startups, and cloud infrastructure companies has birthed one of the most vibrant, grassroots engineering communities in the world. If you are an infrastructure engineer looking to network, learn, or speak, these are the essential communities you must track.
1. Cloud Native Bangalore (Official CNCF Chapter)
This is the premier community for Kubernetes and cloud-native tech in the city. Backed officially by the Linux Foundation/CNCF, this group boasts nearly 4,000 members.
- Focus: Kubernetes, Prometheus, eBPF, Service Meshes (Istio/Linkerd), and GitOps (ArgoCD).
- Format: Large-scale Saturday meetups, typically featuring 3-4 deep technical talks.
- Venues: Frequently hosted at massive corporate auditoriums like Red Hat (EcoSpace), VMware, or Microsoft Reactor.
- Where to find them: They have migrated off Meetup.com and operate primarily through the official
community.cncf.io/bengaluru portal.
2. DevOps + AI Bangalore
A highly specialized, practitioner-focused community navigating the intersection of SRE and Artificial Intelligence. As LLMs reshape how we write code, this group focuses on how AI reshapes operations.
- Focus: Agentic SRE, AIOps, building LLM infrastructure on Kubernetes, and using AI for automated incident runbook execution.
- Format: Panel discussions and hands-on workshops exploring tooling like LangChain integrated with observability data.
3. AWS User Group Bengaluru (AWS UG BLR)
While not strictly "DevOps", the AWS UG is massive and inherently infrastructure-focused. Their events are legendary for their scale (often hundreds of attendees) and deep dives into cloud architecture.
- Focus: Serverless architectures, AWS networking, EKS optimization, and FinOps.
- Events: They host frequent meetups and the annual AWS Community Day, which is practically a massive conference run entirely by volunteers.
How to Maximize Your Meetup Experience
RSVP Early: Bangalore meetups are notorious for hitting maximum venue capacity within hours of an announcement. Join the respective Telegram, Slack, or WhatsApp groups for early links.
Submit CFPs: These communities run entirely on volunteer speakers. If you have solved a difficult problem at work (e.g., migrating a massive database, reducing cloud costs by 40%, or fighting a wild production outage), submit a Call for Paper (CFP). The organizers actively seek out real-world war stories over vendor pitches.