INTRODUCTION
Welcome to your journey into the world of containerization and orchestration. If you have been developing Go applications, you have likely encountered challenges when deploying your software to different environments. Perhaps your application works perfectly on your development machine but fails mysteriously in production. Maybe you have struggled with dependency conflicts, environment inconsistencies, or the complexity of managing multiple services across different servers. Docker and Kubernetes were created to solve exactly these problems, and by the end of this tutorial, you will understand not only how to use these powerful tools but also why they have become essential in modern software development.
This tutorial takes you on a structured journey from the fundamental concepts of containerization through practical implementation with Docker, and finally to orchestrating complex applications with Kubernetes. We will build a real microservice application step by step, explaining every concept thoroughly along the way. You will learn by doing, with each section building upon the previous one.
PART ONE: UNDERSTANDING CONTAINERIZATION AND DOCKER
What Is Containerization and Why Does It Matter
Imagine you have developed a beautiful Go application on your laptop. It works flawlessly. You have carefully configured your development environment with the right version of Go, set up environment variables, installed necessary system libraries, and everything runs smoothly. Now you need to deploy this application to a production server. Suddenly, you encounter problems. The production server has a different operating system version, different system libraries, and perhaps even a different Go version. Your application that worked perfectly moments ago now fails with cryptic errors.
This scenario represents one of the oldest problems in software development: the challenge of environmental consistency. Traditionally, developers would create detailed deployment documentation, listing every dependency, every configuration step, and every system requirement. Operations teams would then manually set up servers following these instructions, often encountering subtle differences that caused failures. This process was time-consuming, error-prone, and difficult to reproduce reliably.
Containerization solves this problem by packaging your application together with everything it needs to run into a single, portable unit called a container. Think of a container as a lightweight, standalone package that includes your application code, the runtime environment, system libraries, and configuration files. When you run a container, it creates an isolated environment that looks and behaves the same way regardless of where it runs, whether on your laptop, a colleague's machine, or a production server in a data center.
The key insight behind containerization is that instead of virtualizing entire machines (which is what traditional virtual machines do), we virtualize just the operating system. This makes containers much lighter and faster than virtual machines. A virtual machine might take minutes to start and consume gigabytes of memory, while a container starts in milliseconds and uses only the memory your application actually needs.
Docker: The Containerization Platform
Docker is the most popular containerization platform, and it has become almost synonymous with containers themselves. Docker provides a complete ecosystem for building, distributing, and running containers. When we talk about Docker, we are actually referring to several components working together.
The Docker Engine is the core component that creates and runs containers. It consists of a server process called the Docker daemon that manages containers, images, networks, and storage volumes. When you issue Docker commands from your terminal, you are communicating with this daemon through the Docker client.
Docker Images are the blueprints for containers. An image is a read-only template that contains everything needed to run an application. You can think of an image as a snapshot of a filesystem with your application and all its dependencies. Images are built in layers, where each layer represents a change or addition to the filesystem. This layered approach is powerful because layers can be shared between images, making storage and distribution more efficient.
Docker Containers are running instances of images. When you start a container from an image, Docker creates a writable layer on top of the read-only image layers. Any changes you make while the container runs are stored in this writable layer. Multiple containers can run from the same image, each with its own writable layer, completely isolated from each other.
The Docker Registry is a storage and distribution system for Docker images. Docker Hub is the default public registry where you can find thousands of pre-built images for common software like databases, web servers, and programming language runtimes. You can also run private registries for your organization's images.
How Docker Works Under the Hood
To truly understand Docker, we need to look at the Linux kernel features it leverages. Docker containers are not magic; they use standard Linux features that have existed for years but were difficult to use directly. Docker makes these features accessible through a simple, consistent interface.
Namespaces provide isolation for containers. Linux namespaces allow Docker to create isolated workspaces called containers. Each container has its own isolated view of system resources. For example, the PID namespace gives each container its own process tree, so processes in one container cannot see or affect processes in another container. The network namespace gives each container its own network stack with its own IP address, routing tables, and network interfaces. The mount namespace provides each container with its own filesystem view. There are also namespaces for users, IPC (inter-process communication), and UTS (hostname and domain name).
Control Groups, or cgroups, limit and account for resource usage. While namespaces provide isolation, cgroups ensure that containers cannot monopolize system resources. With cgroups, you can limit how much CPU, memory, disk I/O, and network bandwidth a container can use. This prevents a single misbehaving container from affecting other containers or the host system.
Union Filesystems allow Docker to build images in layers. When you create a Docker image, each instruction in your Dockerfile creates a new layer. These layers are stacked on top of each other using a union filesystem, which presents them as a single unified filesystem to the container. This layering approach has several benefits. Layers are cached, so if you rebuild an image and only the top layers changed, Docker can reuse the unchanged lower layers. Layers are also shared between images, so if multiple images use the same base layer, it is stored only once on disk.
Why Docker Matters for Microservices
Microservices architecture has become the dominant pattern for building scalable, maintainable applications. Instead of building one large monolithic application, you build many small services that each do one thing well and communicate with each other over the network. Docker is particularly well-suited for microservices for several reasons.
Each microservice can be packaged in its own container with exactly the dependencies it needs. If one service needs Python 2.7 and another needs Python 3.9, that is no problem when each runs in its own container. This dependency isolation was nearly impossible with traditional deployment methods where all services shared the same server.
Containers start quickly, often in under a second. This makes it practical to scale individual services up and down based on demand. If your user authentication service is getting hammered with requests while your reporting service sits idle, you can quickly start more authentication service containers without affecting anything else.
Containers are portable. The same container that runs on your laptop will run identically in your testing environment, staging environment, and production environment. This eliminates the classic "it works on my machine" problem and makes deployments more predictable and reliable.
PART TWO: BUILDING YOUR FIRST DOCKER CONTAINER
Setting Up Your Development Environment
Before we can build containers, you need to install Docker on your development machine. The installation process varies by operating system, but Docker provides excellent installers for Windows, macOS, and Linux. For this tutorial, I will assume you have Docker installed and can run the docker command from your terminal.
To verify your Docker installation, open a terminal and run:
docker version
You should see output showing both the client and server versions. If you see an error about not being able to connect to the Docker daemon, make sure the Docker service is running on your system.
Let us also verify that Docker can run containers by pulling and running a simple test image:
docker run hello-world
This command downloads a tiny test image from Docker Hub and runs it in a container. The container prints a welcome message and exits. This simple test confirms that your Docker installation can pull images from registries and run containers.
Understanding the Dockerfile
A Dockerfile is a text file containing instructions for building a Docker image. Think of it as a recipe that tells Docker how to construct your image layer by layer. Each instruction in a Dockerfile creates a new layer in the image.
Let us start with a simple example. Suppose we want to create a Docker image for a basic Go application. Here is what a minimal Dockerfile might look like:
FROM golang:1.21-alpine
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
Let me explain each instruction in detail because understanding these fundamentals is crucial for everything that follows.
The FROM instruction specifies the base image for your image. Every Docker image is built on top of another image, forming a chain that ultimately goes back to a minimal base image. In this example, we use golang:1.21-alpine as our base image. This is an official image provided by the Go team that includes Go 1.21 installed on Alpine Linux, a minimal Linux distribution designed for containers. Using an official base image saves us from having to install Go ourselves and ensures we start with a well-maintained, secure foundation.
The WORKDIR instruction sets the working directory for subsequent instructions. It is similar to running cd in a shell script. If the directory does not exist, Docker creates it. In this example, we set the working directory to /app. All subsequent COPY, RUN, and CMD instructions will execute in this directory. Using WORKDIR is better than running cd commands because it makes the Dockerfile clearer and ensures the working directory is set correctly even if previous commands fail.
The COPY instruction copies files from your local filesystem into the image. The first argument is the source path on your local machine, and the second is the destination path in the image. In this example, COPY . . copies everything from the current directory on your local machine to the current working directory in the image (which we set to /app). This includes your Go source code, go.mod file, and any other files in your project directory.
The RUN instruction executes a command during the image build process. The command runs in a new layer on top of the current image, and the results are committed to create a new layer. In this example, RUN go build -o myapp compiles our Go application and creates an executable named myapp. This compilation happens during the image build, not when the container runs. This is important because it means the container does not need the Go compiler at runtime, only the compiled binary.
The CMD instruction specifies the default command to run when a container starts from this image. Unlike RUN, which executes during the build, CMD defines what happens when you run the container. In this example, CMD ["./myapp"] tells Docker to execute our compiled binary when the container starts. The square bracket syntax is the exec form, which is preferred because it does not invoke a shell and allows signals to be properly forwarded to your application.
Building a Go Microservice for Docker
Now let us create a real Go microservice that we will containerize. We will build a greeting service that stores personalized greetings and allows clients to retrieve them. This service will expose a simple HTTP API.
First, let us create the Go code for our greeting service. Create a new directory for your project and initialize a Go module:
mkdir greeting-service
cd greeting-service
go mod init github.com/example/greeting-service
Now create a file named main.go with the following content:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sync"
)
// Greeting represents a personalized greeting message
type Greeting struct {
Name string `json:"name"`
Message string `json:"message"`
}
// GreetingStore manages the storage of greetings
type GreetingStore struct {
mu sync.RWMutex
greetings map[string]string
}
// NewGreetingStore creates a new greeting store
func NewGreetingStore() *GreetingStore {
return &GreetingStore{
greetings: make(map[string]string),
}
}
// Set stores a greeting for a given name
func (gs *GreetingStore) Set(name, message string) {
gs.mu.Lock()
defer gs.mu.Unlock()
gs.greetings[name] = message
}
// Get retrieves a greeting for a given name
func (gs *GreetingStore) Get(name string) (string, bool) {
gs.mu.RLock()
defer gs.mu.RUnlock()
message, exists := gs.greetings[name]
return message, exists
}
This code defines our data structures and storage mechanism. The Greeting struct represents a greeting with a name and message. The GreetingStore provides thread-safe storage for greetings using a map protected by a read-write mutex. We use a mutex because our HTTP handlers will run concurrently, and we need to ensure that concurrent access to the map is safe.
Now let us add the HTTP handlers:
// handleSetGreeting handles POST requests to set a greeting
func handleSetGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var greeting Greeting
if err := json.NewDecoder(r.Body).Decode(&greeting); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if greeting.Name == "" || greeting.Message == "" {
http.Error(w, "Name and message are required", http.StatusBadRequest)
return
}
store.Set(greeting.Name, greeting.Message)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(greeting)
}
}
// handleGetGreeting handles GET requests to retrieve a greeting
func handleGetGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Name parameter is required", http.StatusBadRequest)
return
}
message, exists := store.Get(name)
if !exists {
http.Error(w, "Greeting not found", http.StatusNotFound)
return
}
greeting := Greeting{Name: name, Message: message}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(greeting)
}
}
These handlers implement our API endpoints. The handleSetGreeting function creates a handler that accepts POST requests with JSON bodies containing a name and message. It validates the input, stores the greeting, and returns the created greeting as JSON. The handleGetGreeting function creates a handler that accepts GET requests with a name query parameter, retrieves the corresponding greeting, and returns it as JSON.
Finally, let us add the main function to wire everything together:
func main() {
store := NewGreetingStore()
http.HandleFunc("/greeting", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
handleSetGreeting(store)(w, r)
case http.MethodGet:
handleGetGreeting(store)(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting greeting service on port %s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
The main function sets up our HTTP server. We register a handler for the /greeting endpoint that dispatches to either the set or get handler based on the HTTP method. We also register a /health endpoint that simply returns OK, which is useful for health checks in production environments. The server port is configurable through the PORT environment variable, with a default of 8080. This environment variable approach is a best practice for containerized applications because it allows you to configure the service without rebuilding the image.
You can test this service locally before containerizing it:
go run main.go
In another terminal, you can test the API:
curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice","message":"Hello, Alice!"}' http://localhost:8080/greeting
curl "http://localhost:8080/greeting?name=Alice"
Creating an Optimized Dockerfile
Now that we have a working Go service, let us create a Dockerfile to containerize it. We will use a multi-stage build, which is a Docker best practice that results in smaller, more secure images.
Create a file named Dockerfile in your project directory:
# Build stage
FROM golang:1.21-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o greeting-service .
# Runtime stage
FROM alpine:latest
# Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates
# Create non-root user
RUN addgroup -g 1000 appgroup && \
adduser -D -u 1000 -G appgroup appuser
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/greeting-service .
# Change ownership to non-root user
RUN chown -R appuser:appgroup /app
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# Run the application
CMD ["./greeting-service"]
This Dockerfile uses several advanced techniques that are worth understanding in detail.
Multi-stage builds allow us to use one image for building our application and a different, smaller image for running it. The first stage, labeled builder, uses the full golang:1.21-alpine image which includes the Go compiler and build tools. We compile our application in this stage. The second stage uses alpine:latest, a minimal Linux distribution that is only about 5 MB. We copy only the compiled binary from the builder stage, leaving behind all the build tools and source code. This results in a final image that is much smaller and contains fewer potential security vulnerabilities.
The build process is optimized for Docker's layer caching. Notice that we copy go.mod and go.sum before copying the rest of the source code, then run go mod download. This is strategic. Docker caches each layer, and a layer is only rebuilt if it or any layer before it changes. By copying the dependency files separately and downloading dependencies before copying the source code, we ensure that the dependency download layer is only rebuilt when dependencies change, not every time we modify our source code. This can save significant time during development when you are frequently rebuilding images.
We compile with CGO_ENABLED=0 to create a statically linked binary that does not depend on C libraries. This is important because our runtime image (Alpine) might not have the same C libraries as our build image. A statically linked binary is self-contained and will run on any Linux system.
Security is a critical concern in containerized applications. We create a non-root user and run our application as that user. By default, processes in containers run as root, which is a security risk. If an attacker compromises your application, they would have root privileges within the container. By running as a non-root user, we limit the potential damage from a security breach.
The EXPOSE instruction documents that the container listens on port 8080. This is primarily documentation; it does not actually publish the port. You still need to use the -p flag when running the container to publish ports. However, EXPOSE is useful for documentation and is used by some orchestration tools.
The HEALTHCHECK instruction defines a command that Docker will run periodically to check if the container is healthy. In this case, we use wget to make a request to our health endpoint. If the health check fails three times in a row, Docker marks the container as unhealthy. Orchestration systems like Kubernetes can use this information to automatically restart unhealthy containers.
Building and Running Your Docker Image
Now that we have a Dockerfile, let us build an image. From your project directory, run:
docker build -t greeting-service:v1 .
The -t flag tags the image with a name and version. The dot at the end specifies the build context, which is the current directory. Docker will send all files in this directory to the Docker daemon for the build.
You will see Docker execute each instruction in your Dockerfile, creating a new layer for each one. The first build will take some time because Docker needs to download the base images and all dependencies. Subsequent builds will be much faster thanks to layer caching.
Once the build completes, you can see your image in the local image list:
docker images
You should see your greeting-service image along with the base images Docker downloaded.
Now let us run a container from your image:
docker run -d -p 8080:8080 --name greeting-service greeting-service:v1
The -d flag runs the container in detached mode, meaning it runs in the background. The -p flag publishes the container's port 8080 to port 8080 on your host machine. The --name flag gives the container a friendly name instead of a random generated name.
You can check that your container is running:
docker ps
This shows all running containers. You should see your greeting-service container with status "Up".
Test your containerized service:
curl -X POST -H "Content-Type: application/json" -d '{"name":"Bob","message":"Greetings, Bob!"}' http://localhost:8080/greeting
curl "http://localhost:8080/greeting?name=Bob"
Your service is now running in a container, completely isolated from your host system. You can view the container's logs:
docker logs greeting-service
To see the logs in real-time, add the -f flag:
docker logs -f greeting-service
When you are done testing, you can stop and remove the container:
docker stop greeting-service
docker rm greeting-service
Understanding Docker Networking
When you ran your container with -p 8080:8080, Docker created a network mapping between your host and the container. Understanding Docker networking is crucial for building microservices that communicate with each other.
Docker creates several networks by default. You can see them with:
docker network ls
The bridge network is the default network for containers. When you run a container without specifying a network, it connects to the bridge network. Containers on the bridge network can communicate with each other using IP addresses, but not using container names.
For microservices that need to communicate, you should create a custom bridge network. Containers on a custom bridge network can communicate using container names, which Docker automatically resolves to IP addresses. This is much more convenient than managing IP addresses manually.
Let us create a custom network for our microservices:
docker network create greeting-network
Now we can run our greeting service on this network:
docker run -d --network greeting-network --name greeting-service greeting-service:v1
Notice that we did not use the -p flag this time. We do not need to publish the port to the host because other containers on the same network can access it directly using the container name and port.
Building a Client Service
To demonstrate Docker networking, let us build a simple client service that calls our greeting service. Create a new directory for the client:
mkdir greeting-client
cd greeting-client
go mod init github.com/example/greeting-client
Create a main.go file for the client:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
type Greeting struct {
Name string `json:"name"`
Message string `json:"message"`
}
func main() {
serviceURL := os.Getenv("GREETING_SERVICE_URL")
if serviceURL == "" {
serviceURL = "http://localhost:8080"
}
client := &http.Client{
Timeout: 10 * time.Second,
}
// Set a greeting
greeting := Greeting{
Name: "Charlie",
Message: "Welcome, Charlie!",
}
body, err := json.Marshal(greeting)
if err != nil {
log.Fatalf("Failed to marshal greeting: %v", err)
}
resp, err := client.Post(serviceURL+"/greeting", "application/json", bytes.NewBuffer(body))
if err != nil {
log.Fatalf("Failed to set greeting: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
log.Fatalf("Failed to set greeting: %s", string(bodyBytes))
}
fmt.Println("Successfully set greeting for Charlie")
// Get the greeting
resp, err = client.Get(serviceURL + "/greeting?name=Charlie")
if err != nil {
log.Fatalf("Failed to get greeting: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
log.Fatalf("Failed to get greeting: %s", string(bodyBytes))
}
var retrievedGreeting Greeting
if err := json.NewDecoder(resp.Body).Decode(&retrievedGreeting); err != nil {
log.Fatalf("Failed to decode greeting: %v", err)
}
fmt.Printf("Retrieved greeting: %s - %s\n", retrievedGreeting.Name, retrievedGreeting.Message)
}
This client demonstrates how to call our greeting service. It uses an environment variable GREETING_SERVICE_URL to configure the service location, which allows us to easily point it to different environments.
Create a Dockerfile for the client:
FROM golang:1.21-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o greeting-client .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser
WORKDIR /app
COPY --from=builder /build/greeting-client .
RUN chown -R appuser:appgroup /app
USER appuser
CMD ["./greeting-client"]
Build the client image:
docker build -t greeting-client:v1 .
Now run the client on the same network as the service:
docker run --network greeting-network -e GREETING_SERVICE_URL=http://greeting-service:8080 greeting-client:v1
Notice that we set the GREETING_SERVICE_URL environment variable to http://greeting-service:8080. Docker's DNS resolution automatically resolves greeting-service to the IP address of the greeting-service container on the greeting-network. This is the power of custom bridge networks: containers can communicate using friendly names instead of managing IP addresses.
You should see the client successfully set and retrieve a greeting from the service.
Docker Compose for Multi-Container Applications
Managing multiple containers with docker run commands quickly becomes tedious. Docker Compose is a tool for defining and running multi-container applications. With Compose, you use a YAML file to configure your application's services, networks, and volumes, then create and start all the services with a single command.
Create a file named docker-compose.yml in a directory that contains both your greeting-service and greeting-client projects:
version: '3.8'
services:
greeting-service:
build:
context: ./greeting-service
dockerfile: Dockerfile
ports:
- "8080:8080"
networks:
- greeting-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
greeting-client:
build:
context: ./greeting-client
dockerfile: Dockerfile
environment:
- GREETING_SERVICE_URL=http://greeting-service:8080
networks:
- greeting-network
depends_on:
greeting-service:
condition: service_healthy
networks:
greeting-network:
driver: bridge
This Compose file defines our entire application. The services section defines two services: greeting-service and greeting-client. For each service, we specify how to build the image, what ports to expose, what networks to connect to, and what environment variables to set.
The depends_on directive with condition: service_healthy ensures that the greeting-client service does not start until the greeting-service is healthy. This prevents the client from trying to connect before the service is ready.
The networks section defines our custom network. Docker Compose automatically creates this network when you start the application.
To start your application:
docker-compose up
Docker Compose builds the images if they do not exist, creates the network, and starts the containers. You will see the logs from both services interleaved in your terminal. To run in detached mode, add the -d flag.
To stop your application:
docker-compose down
This stops and removes the containers and networks. If you want to rebuild the images, use:
docker-compose up --build
Docker Compose is invaluable during development because it allows you to define your entire application stack in a single file and manage it with simple commands.
PART THREE: ORCHESTRATING CONTAINERS WITH KUBERNETES
What Is Kubernetes and Why Do We Need It
Docker solves the problem of packaging and running applications consistently across different environments. However, in production, you face additional challenges. How do you manage hundreds or thousands of containers across multiple servers? How do you ensure that if a container crashes, it automatically restarts? How do you scale your application up and down based on demand? How do you perform rolling updates without downtime? How do you manage configuration and secrets securely?
Kubernetes, often abbreviated as K8s, is a container orchestration platform that solves these problems. Originally developed by Google based on their internal container orchestration system called Borg, Kubernetes is now an open-source project maintained by the Cloud Native Computing Foundation. It has become the de facto standard for container orchestration.
Kubernetes provides a declarative approach to managing containerized applications. Instead of telling Kubernetes what to do step by step (imperative approach), you describe the desired state of your application, and Kubernetes works continuously to maintain that state. For example, you might declare that you want three instances of your greeting service running at all times. If one instance crashes, Kubernetes automatically starts a new one to maintain the desired state.
Kubernetes Architecture and Core Concepts
Kubernetes has a distributed architecture consisting of a control plane and worker nodes. Understanding this architecture is essential for working effectively with Kubernetes.
The control plane is the brain of the Kubernetes cluster. It makes global decisions about the cluster and detects and responds to cluster events. The control plane consists of several components. The API Server is the front end of the control plane and exposes the Kubernetes API. All interactions with the cluster go through the API server. The etcd is a distributed key-value store that holds all cluster data. It is the source of truth for the cluster state. The Scheduler watches for newly created Pods that have no assigned node and selects a node for them to run on based on resource requirements, hardware constraints, and other factors. The Controller Manager runs controller processes that regulate the state of the cluster, such as ensuring the desired number of replicas are running.
Worker nodes are the machines that run your containerized applications. Each node runs several components. The kubelet is an agent that runs on each node and ensures that containers are running in Pods as specified. The kube-proxy maintains network rules on nodes, allowing network communication to your Pods from inside or outside the cluster. The container runtime is the software responsible for running containers, typically Docker or containerd.
Now let us understand the core Kubernetes objects that you will work with.
A Pod is the smallest deployable unit in Kubernetes. A Pod represents a single instance of a running process in your cluster and can contain one or more containers. Containers in a Pod share the same network namespace, meaning they can communicate with each other using localhost, and they can share storage volumes. In most cases, a Pod contains a single container, but multi-container Pods are useful when you have tightly coupled containers that need to share resources.
A Deployment is a higher-level abstraction that manages Pods. You describe the desired state in a Deployment, such as how many replicas of a Pod should be running, and the Deployment controller changes the actual state to match the desired state. Deployments also manage rolling updates, allowing you to update your application without downtime.
A Service is an abstraction that defines a logical set of Pods and a policy for accessing them. Pods are ephemeral; they can be created and destroyed dynamically. This means their IP addresses change. Services provide a stable endpoint for accessing a set of Pods. When you create a Service, Kubernetes assigns it a stable IP address and DNS name. Requests to the Service are load-balanced across the Pods that match the Service's selector.
A ConfigMap allows you to decouple configuration from your container images, making your applications more portable. Instead of hardcoding configuration values in your application or baking them into your Docker image, you store them in a ConfigMap and inject them into your Pods as environment variables or configuration files.
A Secret is similar to a ConfigMap but is specifically designed for sensitive information like passwords, tokens, and keys. Secrets are stored in base64 encoding and can be encrypted at rest in etcd.
A Namespace provides a way to divide cluster resources between multiple users or teams. Namespaces are useful in large organizations where multiple teams share a cluster. Resources in one namespace are isolated from resources in other namespaces.
Installing Kubernetes for Local Development
For local development and learning, you need a Kubernetes cluster running on your machine. There are several options, but the most popular are Minikube and kind (Kubernetes in Docker).
Minikube creates a single-node Kubernetes cluster in a virtual machine on your local machine. It is easy to install and use, making it ideal for learning and development.
kind runs Kubernetes clusters using Docker containers as nodes. It is faster than Minikube and uses fewer resources because it does not require a virtual machine.
For this tutorial, I will use Minikube, but the Kubernetes concepts and commands are the same regardless of which tool you use.
To install Minikube, follow the instructions for your operating system on the Minikube website. Once installed, start a cluster:
minikube start
This command downloads the necessary images and starts a single-node Kubernetes cluster. It may take a few minutes the first time.
Minikube automatically configures kubectl, the Kubernetes command-line tool, to communicate with your cluster. Verify that kubectl is working:
kubectl version
You should see both the client and server versions.
To see information about your cluster:
kubectl cluster-info
To see the nodes in your cluster:
kubectl get nodes
You should see a single node with status Ready.
Deploying Your First Application to Kubernetes
Let us deploy our greeting service to Kubernetes. We will start with the imperative approach to understand what happens, then move to the declarative approach which is the recommended way to manage Kubernetes resources.
First, we need to make our Docker image available to Kubernetes. When using Minikube, the easiest way is to build the image directly in Minikube's Docker environment:
eval $(minikube docker-env)
cd greeting-service
docker build -t greeting-service:v1 .
The eval $(minikube docker-env) command configures your shell to use Minikube's Docker daemon instead of your local one. Images built in this environment are available to Kubernetes without needing to push them to a registry.
Now let us create a Deployment imperatively:
kubectl create deployment greeting-service --image=greeting-service:v1
This creates a Deployment named greeting-service that manages Pods running the greeting-service:v1 image. Check the Deployment:
kubectl get deployments
You should see your greeting-service Deployment with 1/1 ready replicas.
Check the Pods:
kubectl get pods
You should see a Pod with a name like greeting-service-xxxxxxxxxx-xxxxx with status Running.
To see detailed information about the Pod:
kubectl describe pod <pod-name>
This shows extensive information about the Pod, including events that occurred during its lifecycle.
Now we need to expose the Deployment as a Service so we can access it:
kubectl expose deployment greeting-service --type=NodePort --port=8080
This creates a Service of type NodePort that exposes port 8080. NodePort makes the Service accessible on a static port on each node in the cluster.
Check the Service:
kubectl get services
You should see your greeting-service Service. Note the port mapping, which will show something like 8080:xxxxx/TCP. The second port is the NodePort.
To access the Service, you need the Minikube IP and the NodePort:
minikube service greeting-service --url
This command outputs the URL where you can access your service. You can test it:
curl -X POST -H "Content-Type: application/json" -d '{"name":"Dave","message":"Hello, Dave!"}' <service-url>/greeting
curl "<service-url>/greeting?name=Dave"
Congratulations! You have deployed your first application to Kubernetes.
Declarative Configuration with YAML
While imperative commands are useful for learning and quick tests, the declarative approach using YAML files is the standard way to manage Kubernetes resources in production. YAML files allow you to version control your infrastructure, review changes, and apply them consistently.
Let us delete the resources we created imperatively:
kubectl delete service greeting-service
kubectl delete deployment greeting-service
Now create a file named greeting-service-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: greeting-service
labels:
app: greeting-service
spec:
replicas: 3
selector:
matchLabels:
app: greeting-service
template:
metadata:
labels:
app: greeting-service
spec:
containers:
- name: greeting-service
image: greeting-service:v1
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: PORT
value: "8080"
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
Let me explain this YAML file in detail because understanding these fields is crucial for working with Kubernetes.
The apiVersion field specifies which version of the Kubernetes API you are using. Different resource types use different API versions. Deployments use apps/v1.
The kind field specifies what type of resource this is. In this case, it is a Deployment.
The metadata section contains data that helps identify the resource. The name field is the name of the Deployment. The labels are key-value pairs that you can use to organize and select resources. Labels are fundamental to how Kubernetes works; they are used by selectors to identify which resources to operate on.
The spec section describes the desired state of the Deployment. The replicas field specifies how many Pods we want running. We set it to 3, so Kubernetes will ensure that three Pods are always running. If a Pod crashes, Kubernetes will start a new one to maintain the desired count.
The selector field tells the Deployment which Pods it manages. The matchLabels selector means the Deployment manages Pods with the label app: greeting-service.
The template section describes the Pods that the Deployment creates. It has its own metadata with labels that match the selector. The spec section describes the containers in the Pod.
The containers array lists the containers in the Pod. In our case, we have one container named greeting-service using the greeting-service:v1 image. The imagePullPolicy: Never tells Kubernetes not to try to pull the image from a registry because we built it locally. In production, you would omit this field and push your image to a registry.
The ports section documents which ports the container exposes. The containerPort field specifies that the container listens on port 8080. The name field gives the port a friendly name that can be referenced elsewhere.
The env section defines environment variables for the container. We set the PORT environment variable to 8080.
The resources section specifies resource requirements and limits. The requests section tells Kubernetes how much CPU and memory the container needs. Kubernetes uses this information when scheduling Pods to nodes. The limits section specifies the maximum resources the container can use. If a container tries to use more memory than its limit, it will be killed. If it tries to use more CPU, it will be throttled. Setting appropriate resource requests and limits is important for cluster stability and efficient resource utilization.
The livenessProbe tells Kubernetes how to check if the container is healthy. Kubernetes periodically makes an HTTP GET request to /health on port 8080. If the probe fails, Kubernetes kills the container and starts a new one. The initialDelaySeconds field tells Kubernetes to wait 5 seconds after the container starts before beginning health checks, giving the application time to initialize. The periodSeconds field specifies how often to perform the check.
The readinessProbe is similar to the livenessProbe but serves a different purpose. While the liveness probe determines if a container should be restarted, the readiness probe determines if a container is ready to accept traffic. If the readiness probe fails, Kubernetes removes the Pod from Service endpoints, so it does not receive traffic. This is useful during startup or when the application is temporarily unable to handle requests.
Now create a file named greeting-service-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: greeting-service
labels:
app: greeting-service
spec:
type: ClusterIP
selector:
app: greeting-service
ports:
- port: 8080
targetPort: 8080
protocol: TCP
name: http
This Service definition is simpler. The type: ClusterIP creates a Service that is only accessible within the cluster. This is the default Service type and is appropriate for internal services. The selector specifies that this Service routes traffic to Pods with the label app: greeting-service. The ports section maps port 8080 on the Service to port 8080 on the Pods.
Apply these configurations:
kubectl apply -f greeting-service-deployment.yaml
kubectl apply -f greeting-service-service.yaml
The kubectl apply command creates the resources if they do not exist or updates them if they do. This is the declarative approach: you declare the desired state, and Kubernetes makes it so.
Check that everything is running:
kubectl get deployments
kubectl get pods
kubectl get services
You should see the Deployment with 3/3 ready replicas, three Pods in Running status, and the Service.
Scaling and Updating Applications
One of Kubernetes' most powerful features is how easy it makes scaling and updating applications.
To scale your Deployment, you can edit the YAML file and change replicas: 3 to replicas: 5, then apply it again:
kubectl apply -f greeting-service-deployment.yaml
Kubernetes will create two more Pods to reach the desired count of five. You can also scale imperatively:
kubectl scale deployment greeting-service --replicas=5
Watch the Pods being created:
kubectl get pods -w
The -w flag watches for changes and updates the output in real-time.
To update your application, you would build a new version of your Docker image, tag it with a new version number, and update the Deployment YAML to reference the new image. Kubernetes performs a rolling update, gradually replacing old Pods with new ones. This ensures zero downtime during updates.
Let us simulate an update. First, make a small change to your greeting service code, perhaps changing a log message. Then build a new image:
docker build -t greeting-service:v2 .
Update your Deployment YAML to use greeting-service:v2, then apply it:
kubectl apply -f greeting-service-deployment.yaml
Watch the rollout:
kubectl rollout status deployment/greeting-service
Kubernetes creates new Pods with the v2 image while keeping the old Pods running. Once a new Pod is ready (passes its readiness probe), Kubernetes terminates an old Pod. This process continues until all Pods are running the new version.
If you discover a problem with the new version, you can roll back:
kubectl rollout undo deployment/greeting-service
Kubernetes will roll back to the previous version using the same rolling update process.
ConfigMaps and Secrets
Hardcoding configuration in your application or Docker image makes it difficult to use the same image across different environments. Kubernetes ConfigMaps and Secrets solve this problem by allowing you to inject configuration at runtime.
Let us create a ConfigMap for our greeting service. Create a file named greeting-service-configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: greeting-service-config
data:
PORT: "8080"
LOG_LEVEL: "info"
MAX_GREETINGS: "1000"
This ConfigMap defines three configuration values. Apply it:
kubectl apply -f greeting-service-configmap.yaml
Now update your Deployment to use the ConfigMap. Modify the env section in greeting-service-deployment.yaml:
env:
- name: PORT
valueFrom:
configMapKeyRef:
name: greeting-service-config
key: PORT
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: greeting-service-config
key: LOG_LEVEL
- name: MAX_GREETINGS
valueFrom:
configMapKeyRef:
name: greeting-service-config
key: MAX_GREETINGS
This tells Kubernetes to populate the environment variables from the ConfigMap. Apply the updated Deployment:
kubectl apply -f greeting-service-deployment.yaml
For sensitive data like API keys or passwords, use Secrets instead of ConfigMaps. Create a file named greeting-service-secret.yaml:
apiVersion: v1
kind: Secret
metadata:
name: greeting-service-secret
type: Opaque
data:
api-key: c3VwZXJzZWNyZXRrZXkxMjM=
The data in a Secret must be base64 encoded. You can encode a value using the base64 command:
echo -n "supersecretkey123" | base64
This outputs c3VwZXJzZWNyZXRrZXkxMjM=, which is what we put in the Secret. The -n flag prevents echo from adding a newline character, which would be included in the encoding.
Apply the Secret:
kubectl apply -f greeting-service-secret.yaml
To use the Secret in your Deployment, add it to the env section:
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: greeting-service-secret
key: api-key
Kubernetes will decode the base64 value and inject it as an environment variable. Your application receives the plain text value, not the encoded version.
You can also mount ConfigMaps and Secrets as files in your containers. This is useful when you need to provide configuration files rather than environment variables. To mount a ConfigMap as a volume, add a volumes section to your Pod spec:
spec:
containers:
- name: greeting-service
volumeMounts:
- name: config-volume
mountPath: /etc/config
volumes:
- name: config-volume
configMap:
name: greeting-service-config
This mounts the ConfigMap at /etc/config in the container. Each key in the ConfigMap becomes a file in that directory, with the value as the file contents.
Deploying the Client Service to Kubernetes
Now let us deploy our greeting client to Kubernetes so we have a complete microservice application running in the cluster. First, build the client image in Minikube's Docker environment:
eval $(minikube docker-env)
cd greeting-client
docker build -t greeting-client:v1 .
Create a file named greeting-client-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: greeting-client
labels:
app: greeting-client
spec:
replicas: 1
selector:
matchLabels:
app: greeting-client
template:
metadata:
labels:
app: greeting-client
spec:
containers:
- name: greeting-client
image: greeting-client:v1
imagePullPolicy: Never
env:
- name: GREETING_SERVICE_URL
value: "http://greeting-service:8080"
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
restartPolicy: Always
Notice that we set GREETING_SERVICE_URL to http://greeting-service:8080. Kubernetes provides built-in DNS resolution for Services. Any Pod in the cluster can access the greeting-service Service using the name greeting-service. Kubernetes DNS automatically resolves this to the Service's cluster IP address.
However, our current client application runs once and exits. For a Kubernetes deployment, we want it to run continuously. Let us modify the client to run in a loop. Update the main.go file in the greeting-client directory:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"time"
)
type Greeting struct {
Name string `json:"name"`
Message string `json:"message"`
}
func main() {
serviceURL := os.Getenv("GREETING_SERVICE_URL")
if serviceURL == "" {
serviceURL = "http://localhost:8080"
}
client := &http.Client{
Timeout: 10 * time.Second,
}
names := []string{"Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"}
rand.Seed(time.Now().UnixNano())
log.Printf("Starting greeting client, calling service at %s", serviceURL)
for {
// Select a random name
name := names[rand.Intn(len(names))]
message := fmt.Sprintf("Hello, %s! The time is %s", name, time.Now().Format(time.RFC3339))
// Set a greeting
greeting := Greeting{
Name: name,
Message: message,
}
body, err := json.Marshal(greeting)
if err != nil {
log.Printf("Failed to marshal greeting: %v", err)
time.Sleep(5 * time.Second)
continue
}
resp, err := client.Post(serviceURL+"/greeting", "application/json", bytes.NewBuffer(body))
if err != nil {
log.Printf("Failed to set greeting: %v", err)
time.Sleep(5 * time.Second)
continue
}
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
log.Printf("Failed to set greeting: %s", string(bodyBytes))
resp.Body.Close()
time.Sleep(5 * time.Second)
continue
}
resp.Body.Close()
log.Printf("Successfully set greeting for %s", name)
// Get the greeting
resp, err = client.Get(serviceURL + "/greeting?name=" + name)
if err != nil {
log.Printf("Failed to get greeting: %v", err)
time.Sleep(5 * time.Second)
continue
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
log.Printf("Failed to get greeting: %s", string(bodyBytes))
resp.Body.Close()
time.Sleep(5 * time.Second)
continue
}
var retrievedGreeting Greeting
if err := json.NewDecoder(resp.Body).Decode(&retrievedGreeting); err != nil {
log.Printf("Failed to decode greeting: %v", err)
resp.Body.Close()
time.Sleep(5 * time.Second)
continue
}
resp.Body.Close()
log.Printf("Retrieved greeting: %s - %s", retrievedGreeting.Name, retrievedGreeting.Message)
// Wait before next iteration
time.Sleep(10 * time.Second)
}
}
This modified client runs in an infinite loop, periodically setting and retrieving greetings with random names. This makes it suitable for running as a long-lived service in Kubernetes.
Rebuild the client image:
docker build -t greeting-client:v1 .
Now apply the Deployment:
kubectl apply -f greeting-client-deployment.yaml
Check that the client is running:
kubectl get pods -l app=greeting-client
View the client logs to see it interacting with the greeting service:
kubectl logs -f deployment/greeting-client
You should see log messages showing the client successfully setting and retrieving greetings. This demonstrates that the two services are communicating within the Kubernetes cluster using the Service DNS name.
Understanding Kubernetes Namespaces
Namespaces provide a way to organize and isolate resources within a Kubernetes cluster. By default, resources are created in the default namespace. In production environments, you typically create separate namespaces for different applications, teams, or environments.
To see all namespaces in your cluster:
kubectl get namespaces
You will see several system namespaces like kube-system, kube-public, and kube-node-lease, plus the default namespace where we have been creating our resources.
Let us create a namespace for our greeting application:
kubectl create namespace greeting-app
You can create a namespace declaratively with a YAML file as well. Create greeting-namespace.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: greeting-app
Apply it:
kubectl apply -f greeting-namespace.yaml
To deploy resources to a specific namespace, you can either specify the namespace in the metadata section of your YAML files or use the -n flag with kubectl. Let us update our Deployment files to specify the namespace. Add this to the metadata section of each file:
metadata:
name: greeting-service
namespace: greeting-app
Apply the updated files:
kubectl apply -f greeting-service-deployment.yaml
kubectl apply -f greeting-service-service.yaml
kubectl apply -f greeting-client-deployment.yaml
Now your resources are in the greeting-app namespace. To view them, you must specify the namespace:
kubectl get pods -n greeting-app
kubectl get services -n greeting-app
To avoid typing -n greeting-app with every command, you can set the default namespace for your current context:
kubectl config set-context --current --namespace=greeting-app
Now kubectl commands will default to the greeting-app namespace.
Namespaces also affect DNS resolution. Services in the same namespace can reference each other by name, as we have been doing. To access a Service in a different namespace, you use the fully qualified domain name: service-name.namespace-name.svc.cluster.local. For example, if you had a service in the default namespace that needed to call our greeting service, it would use http://greeting-service.greeting-app.svc.cluster.local:8080.
PART FOUR: HELM - THE KUBERNETES PACKAGE MANAGER
What Is Helm and Why Use It
As your Kubernetes applications grow more complex, managing multiple YAML files becomes challenging. You might have separate files for Deployments, Services, ConfigMaps, Secrets, Ingresses, and more. When you want to deploy the same application to different environments (development, staging, production), you need to maintain separate YAML files for each environment with slightly different configurations. This duplication is error-prone and difficult to maintain.
Helm solves this problem by providing a templating system and package management for Kubernetes applications. With Helm, you define your application as a chart, which is a collection of templates that can be parameterized. You can then install the same chart with different values for different environments.
Helm uses three main concepts. A Chart is a package of pre-configured Kubernetes resources. It contains all the resource definitions necessary to run an application. A Release is an instance of a chart running in a Kubernetes cluster. You can install the same chart multiple times, and each installation creates a new release. Values are the parameters that customize a chart. You provide values when installing a chart to configure it for your specific needs.
Helm version 3, the current version, simplified the architecture by removing the server-side component called Tiller that existed in Helm 2. Helm 3 is purely a client-side tool that communicates directly with the Kubernetes API server.
Installing Helm
Installing Helm is straightforward. Download the latest release from the Helm GitHub repository and add it to your PATH. On most systems, you can use a package manager. For example, on macOS with Homebrew:
brew install helm
On Linux, you can use the installation script:
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Verify the installation:
helm version
You should see the Helm version information.
Creating Your First Helm Chart
Let us create a Helm chart for our greeting application. Helm provides a command to generate a chart skeleton:
helm create greeting-chart
This creates a directory named greeting-chart with the following structure:
greeting-chart/
Chart.yaml
values.yaml
charts/
templates/
deployment.yaml
service.yaml
ingress.yaml
serviceaccount.yaml
hpa.yaml
NOTES.txt
_helpers.tpl
Let me explain each component. The Chart.yaml file contains metadata about the chart, such as its name, version, and description. The values.yaml file contains default values for the chart's parameters. The charts directory can contain dependent charts. The templates directory contains the Kubernetes resource templates. The _helpers.tpl file contains template helpers that can be reused across templates.
Let us examine and modify these files for our greeting application. First, look at Chart.yaml:
apiVersion: v2
name: greeting-chart
description: A Helm chart for the greeting microservice application
type: application
version: 0.1.0
appVersion: "1.0"
The version field is the chart version, which you increment when you make changes to the chart. The appVersion field is the version of the application the chart deploys.
Now let us look at values.yaml. The generated file contains many default values. We will replace it with values specific to our application:
greetingService:
replicaCount: 3
image:
repository: greeting-service
tag: v1
pullPolicy: Never
service:
type: ClusterIP
port: 8080
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
config:
port: "8080"
logLevel: "info"
greetingClient:
replicaCount: 1
image:
repository: greeting-client
tag: v1
pullPolicy: Never
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
This values file defines all the configurable parameters for our application. Notice how we organize the values hierarchically. All greeting service values are under greetingService, and all client values are under greetingClient. This organization makes the values file easier to read and maintain.
Now let us create templates for our resources. Delete the generated template files and create new ones. Create templates/greeting-service-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "greeting-chart.fullname" . }}-service
labels:
{{- include "greeting-chart.labels" . | nindent 4 }}
app.kubernetes.io/component: service
spec:
replicas: {{ .Values.greetingService.replicaCount }}
selector:
matchLabels:
{{- include "greeting-chart.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: service
template:
metadata:
labels:
{{- include "greeting-chart.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: service
spec:
containers:
- name: greeting-service
image: "{{ .Values.greetingService.image.repository }}:{{ .Values.greetingService.image.tag }}"
imagePullPolicy: {{ .Values.greetingService.image.pullPolicy }}
ports:
- containerPort: {{ .Values.greetingService.service.port }}
name: http
env:
- name: PORT
value: {{ .Values.greetingService.config.port | quote }}
- name: LOG_LEVEL
value: {{ .Values.greetingService.config.logLevel | quote }}
resources:
{{- toYaml .Values.greetingService.resources | nindent 12 }}
livenessProbe:
httpGet:
path: /health
port: {{ .Values.greetingService.service.port }}
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: {{ .Values.greetingService.service.port }}
initialDelaySeconds: 5
periodSeconds: 5
This template uses Helm's templating syntax, which is based on Go templates. The double curly braces {{ }} delimit template directives. The .Values object provides access to the values from values.yaml. For example, {{ .Values.greetingService.replicaCount }} is replaced with the value 3 from our values file.
The include function calls named templates defined in _helpers.tpl. These templates generate common labels and names. The nindent function indents the included content by the specified number of spaces, which is important for YAML formatting.
The toYaml function converts a value to YAML format. We use it for the resources section because it is a complex nested structure that would be tedious to template manually.
The quote function wraps a value in quotes, which is necessary for environment variable values to ensure they are treated as strings.
Now create templates/greeting-service-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "greeting-chart.fullname" . }}-service
labels:
{{- include "greeting-chart.labels" . | nindent 4 }}
app.kubernetes.io/component: service
spec:
type: {{ .Values.greetingService.service.type }}
selector:
{{- include "greeting-chart.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: service
ports:
- port: {{ .Values.greetingService.service.port }}
targetPort: {{ .Values.greetingService.service.port }}
protocol: TCP
name: http
Create templates/greeting-client-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "greeting-chart.fullname" . }}-client
labels:
{{- include "greeting-chart.labels" . | nindent 4 }}
app.kubernetes.io/component: client
spec:
replicas: {{ .Values.greetingClient.replicaCount }}
selector:
matchLabels:
{{- include "greeting-chart.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: client
template:
metadata:
labels:
{{- include "greeting-chart.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: client
spec:
containers:
- name: greeting-client
image: "{{ .Values.greetingClient.image.repository }}:{{ .Values.greetingClient.image.tag }}"
imagePullPolicy: {{ .Values.greetingClient.image.pullPolicy }}
env:
- name: GREETING_SERVICE_URL
value: "http://{{ include "greeting-chart.fullname" . }}-service:{{ .Values.greetingService.service.port }}"
resources:
{{- toYaml .Values.greetingClient.resources | nindent 12 }}
Notice how the GREETING_SERVICE_URL is constructed using the template function to generate the service name. This ensures that the client always uses the correct service name, even if the chart is installed with a different release name.
Now update templates/_helpers.tpl to define the helper templates we are using:
{{/*
Expand the name of the chart.
*/}}
{{- define "greeting-chart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "greeting-chart.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "greeting-chart.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "greeting-chart.labels" -}}
helm.sh/chart: {{ include "greeting-chart.chart" . }}
{{ include "greeting-chart.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "greeting-chart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "greeting-chart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
These helper templates generate consistent names and labels across all resources. The greeting-chart.fullname template creates a unique name for each release by combining the release name with the chart name. The greeting-chart.labels template generates a standard set of labels that follow Kubernetes best practices.
Installing and Managing Helm Releases
Before installing the chart, let us verify that the templates render correctly:
helm template greeting-chart ./greeting-chart
This command renders the templates with the default values and outputs the resulting Kubernetes manifests. Review the output to ensure everything looks correct.
To install the chart:
helm install my-greeting ./greeting-chart
This creates a new release named my-greeting. Helm applies all the rendered templates to your Kubernetes cluster.
Check the status of the release:
helm status my-greeting
This shows information about the release, including the resources that were created.
List all releases:
helm list
To see the Kubernetes resources created by the release:
kubectl get all -l app.kubernetes.io/instance=my-greeting
This uses a label selector to show only resources belonging to this release.
To upgrade a release with new values, you can provide a values file:
helm upgrade my-greeting ./greeting-chart --set greetingService.replicaCount=5
The --set flag allows you to override values from the command line. This is useful for quick changes, but for production environments, you should use values files.
Create a file named production-values.yaml:
greetingService:
replicaCount: 5
image:
tag: v2
resources:
requests:
memory: "128Mi"
cpu: "500m"
limits:
memory: "256Mi"
cpu: "1000m"
greetingClient:
replicaCount: 2
Upgrade the release with these values:
helm upgrade my-greeting ./greeting-chart -f production-values.yaml
Helm merges the values from production-values.yaml with the default values from values.yaml, with the production values taking precedence.
To roll back to a previous version:
helm rollback my-greeting
This rolls back to the previous revision. You can also specify a specific revision number:
helm rollback my-greeting 1
To uninstall a release:
helm uninstall my-greeting
This removes all resources created by the release.
Advanced Helm Features
Helm provides several advanced features that make it powerful for managing complex applications.
Conditional templates allow you to include or exclude parts of a template based on values. For example, you might want to create an Ingress resource only if ingress is enabled. Add this to values.yaml:
ingress:
enabled: false
host: greeting.example.com
Create templates/ingress.yaml:
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "greeting-chart.fullname" . }}-ingress
labels:
{{- include "greeting-chart.labels" . | nindent 4 }}
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "greeting-chart.fullname" . }}-service
port:
number: {{ .Values.greetingService.service.port }}
{{- end }}
The {{- if .Values.ingress.enabled }} directive means this entire resource is only created if ingress.enabled is true.
Loops allow you to iterate over lists. For example, if you want to create multiple environment variables from a list:
greetingService:
extraEnv:
- name: FEATURE_FLAG_A
value: "true"
- name: FEATURE_FLAG_B
value: "false"
In your deployment template:
env:
- name: PORT
value: {{ .Values.greetingService.config.port | quote }}
{{- range .Values.greetingService.extraEnv }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
The range directive iterates over the extraEnv list and creates an environment variable for each item.
Chart dependencies allow you to include other charts as dependencies of your chart. For example, if your application needs a Redis database, you can add Redis as a dependency instead of creating Redis resources yourself.
In Chart.yaml:
dependencies:
- name: redis
version: "17.0.0"
repository: "https://charts.bitnami.com/bitnami"
Then run:
helm dependency update ./greeting-chart
This downloads the Redis chart into the charts directory. You can configure the Redis chart through your values.yaml:
redis:
auth:
enabled: false
master:
persistence:
enabled: false
Hooks allow you to run jobs at specific points in the release lifecycle. For example, you might want to run a database migration job before upgrading your application. Create templates/migration-job.yaml:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "greeting-chart.fullname" . }}-migration
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
containers:
- name: migration
image: "{{ .Values.greetingService.image.repository }}:{{ .Values.greetingService.image.tag }}"
command: ["./migrate"]
restartPolicy: Never
The helm.sh/hook annotation specifies when the hook runs. The pre-upgrade hook runs before an upgrade. The hook-weight determines the order when multiple hooks exist. The hook-delete-policy specifies when to delete the hook resource.
PART FIVE: ADVANCED KUBERNETES CONCEPTS
Persistent Storage with Persistent Volumes
So far, our greeting service stores data in memory, which means all data is lost when the Pod restarts. For production applications, you need persistent storage. Kubernetes provides Persistent Volumes and Persistent Volume Claims for this purpose.
A Persistent Volume is a piece of storage in the cluster that has been provisioned by an administrator or dynamically provisioned using Storage Classes. A Persistent Volume Claim is a request for storage by a user. It is similar to how Pods consume node resources; PVCs consume PV resources.
Let us modify our greeting service to use persistent storage. First, we need to change the service to store data in a file instead of in memory. Update the greeting service main.go:
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"sync"
)
type Greeting struct {
Name string `json:"name"`
Message string `json:"message"`
}
type GreetingStore struct {
mu sync.RWMutex
greetings map[string]string
filePath string
}
func NewGreetingStore(filePath string) (*GreetingStore, error) {
store := &GreetingStore{
greetings: make(map[string]string),
filePath: filePath,
}
if err := store.load(); err != nil {
log.Printf("Warning: could not load existing data: %v", err)
}
return store, nil
}
func (gs *GreetingStore) load() error {
data, err := ioutil.ReadFile(gs.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, &gs.greetings)
}
func (gs *GreetingStore) save() error {
data, err := json.Marshal(gs.greetings)
if err != nil {
return err
}
return ioutil.WriteFile(gs.filePath, data, 0644)
}
func (gs *GreetingStore) Set(name, message string) error {
gs.mu.Lock()
defer gs.mu.Unlock()
gs.greetings[name] = message
return gs.save()
}
func (gs *GreetingStore) Get(name string) (string, bool) {
gs.mu.RLock()
defer gs.mu.RUnlock()
message, exists := gs.greetings[name]
return message, exists
}
func handleSetGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var greeting Greeting
if err := json.NewDecoder(r.Body).Decode(&greeting); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if greeting.Name == "" || greeting.Message == "" {
http.Error(w, "Name and message are required", http.StatusBadRequest)
return
}
if err := store.Set(greeting.Name, greeting.Message); err != nil {
log.Printf("Failed to save greeting: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(greeting)
}
}
func handleGetGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Name parameter is required", http.StatusBadRequest)
return
}
message, exists := store.Get(name)
if !exists {
http.Error(w, "Greeting not found", http.StatusNotFound)
return
}
greeting := Greeting{Name: name, Message: message}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(greeting)
}
}
func main() {
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "/data"
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
filePath := dataDir + "/greetings.json"
store, err := NewGreetingStore(filePath)
if err != nil {
log.Fatalf("Failed to create greeting store: %v", err)
}
http.HandleFunc("/greeting", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
handleSetGreeting(store)(w, r)
case http.MethodGet:
handleGetGreeting(store)(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting greeting service on port %s, data stored in %s", port, filePath)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
Now the service persists greetings to a file. Rebuild the image with a new tag:
eval $(minikube docker-env)
cd greeting-service
docker build -t greeting-service:v3 .
Create a Persistent Volume Claim. Create a file named greeting-pvc.yaml:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: greeting-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
The accessModes field specifies how the volume can be mounted. ReadWriteOnce means the volume can be mounted as read-write by a single node. Other modes include ReadOnlyMany (read-only by many nodes) and ReadWriteMany (read-write by many nodes).
Apply the PVC:
kubectl apply -f greeting-pvc.yaml
Kubernetes will automatically provision a Persistent Volume to satisfy this claim using the default Storage Class.
Check the PVC:
kubectl get pvc
You should see the greeting-data PVC with status Bound, meaning a PV has been provisioned for it.
Now update the Deployment to use the PVC. Modify greeting-service-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: greeting-service
labels:
app: greeting-service
spec:
replicas: 1
selector:
matchLabels:
app: greeting-service
template:
metadata:
labels:
app: greeting-service
spec:
containers:
- name: greeting-service
image: greeting-service:v3
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: PORT
value: "8080"
- name: DATA_DIR
value: "/data"
volumeMounts:
- name: data-volume
mountPath: /data
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: data-volume
persistentVolumeClaim:
claimName: greeting-data
Notice that we set replicas to 1. This is important because ReadWriteOnce volumes can only be mounted by one node at a time. If you need multiple replicas with persistent storage, you would need to use a different storage solution that supports ReadWriteMany, or use a database instead of file storage.
Apply the updated Deployment:
kubectl apply -f greeting-service-deployment.yaml
Now test that data persists across Pod restarts. Set a greeting:
curl -X POST -H "Content-Type: application/json" -d '{"name":"Persistent","message":"This should survive restarts"}' $(minikube service greeting-service --url)/greeting
Delete the Pod to force Kubernetes to create a new one:
kubectl delete pod -l app=greeting-service
Wait for the new Pod to start, then retrieve the greeting:
curl "$(minikube service greeting-service --url)/greeting?name=Persistent"
You should get back the greeting you set earlier, proving that the data persisted across the Pod restart.
StatefulSets for Stateful Applications
Deployments are designed for stateless applications where Pods are interchangeable. For stateful applications like databases where each instance needs a stable identity and persistent storage, Kubernetes provides StatefulSets.
StatefulSets provide several guarantees that Deployments do not. Each Pod in a StatefulSet has a stable, unique network identity that persists across rescheduling. Pods are created and deleted in order, from 0 to N-1 when scaling up and from N-1 to 0 when scaling down. Each Pod can have its own Persistent Volume Claim.
Let us convert our greeting service to use a StatefulSet. Create greeting-service-statefulset.yaml:
apiVersion: v1
kind: Service
metadata:
name: greeting-service-headless
labels:
app: greeting-service
spec:
clusterIP: None
selector:
app: greeting-service
ports:
- port: 8080
name: http
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: greeting-service
spec:
serviceName: greeting-service-headless
replicas: 3
selector:
matchLabels:
app: greeting-service
template:
metadata:
labels:
app: greeting-service
spec:
containers:
- name: greeting-service
image: greeting-service:v3
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: PORT
value: "8080"
- name: DATA_DIR
value: "/data"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
A StatefulSet requires a headless Service, which is a Service with clusterIP: None. This Service does not load balance; instead, it creates DNS records for each Pod.
The volumeClaimTemplates section defines a template for creating Persistent Volume Claims. The StatefulSet creates one PVC per Pod using this template.
Apply the StatefulSet:
kubectl apply -f greeting-service-statefulset.yaml
Watch the Pods being created:
kubectl get pods -w -l app=greeting-service
Notice that the Pods are created sequentially: greeting-service-0, then greeting-service-1, then greeting-service-2. Each Pod has a stable name that persists even if the Pod is rescheduled.
Check the PVCs:
kubectl get pvc
You should see three PVCs: data-greeting-service-0, data-greeting-service-1, and data-greeting-service-2. Each Pod has its own persistent storage.
Each Pod in a StatefulSet has a stable DNS name: pod-name.service-name.namespace.svc.cluster.local. You can access a specific Pod:
kubectl run -it --rm debug --image=alpine --restart=Never -- sh
apk add curl
curl http://greeting-service-0.greeting-service-headless:8080/health
Ingress for External Access
So far, we have been using NodePort Services or port forwarding to access our applications. In production, you typically use an Ingress to expose your services externally. An Ingress is a Kubernetes resource that manages external access to services, typically HTTP and HTTPS.
An Ingress controller is required to satisfy an Ingress. The Ingress controller watches for Ingress resources and configures a load balancer or reverse proxy accordingly. There are many Ingress controllers available, including NGINX, Traefik, and HAProxy.
For Minikube, enable the NGINX Ingress controller addon:
minikube addons enable ingress
Wait for the Ingress controller to start:
kubectl get pods -n ingress-nginx
Create an Ingress resource. Create greeting-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: greeting-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: greeting.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: greeting-service
port:
number: 8080
Apply the Ingress:
kubectl apply -f greeting-ingress.yaml
Get the Minikube IP:
minikube ip
Add an entry to your /etc/hosts file mapping greeting.local to the Minikube IP:
echo "$(minikube ip) greeting.local" | sudo tee -a /etc/hosts
Now you can access your service using the hostname:
curl -X POST -H "Content-Type: application/json" -d '{"name":"Ingress","message":"Accessed via Ingress"}' http://greeting.local/greeting
curl "http://greeting.local/greeting?name=Ingress"
The Ingress controller routes requests to greeting.local to your greeting-service Service.
Resource Quotas and Limit Ranges
In a shared Kubernetes cluster, you need to prevent individual users or applications from consuming all cluster resources. Resource Quotas and Limit Ranges provide this control.
A Resource Quota sets aggregate resource limits for a namespace. Create a file named resource-quota.yaml:
apiVersion: v1
kind: ResourceQuota
metadata:
name: greeting-quota
namespace: greeting-app
spec:
hard:
requests.cpu: "2"
requests.memory: 2Gi
limits.cpu: "4"
limits.memory: 4Gi
pods: "10"
This quota limits the greeting-app namespace to a total of 2 CPU cores requested, 2 GiB memory requested, 4 CPU cores limit, 4 GiB memory limit, and 10 Pods.
Apply the quota:
kubectl apply -f resource-quota.yaml
Check the quota:
kubectl describe resourcequota greeting-quota -n greeting-app
You will see the current usage and limits.
A Limit Range sets default resource limits for containers in a namespace. Create limit-range.yaml:
apiVersion: v1
kind: LimitRange
metadata:
name: greeting-limits
namespace: greeting-app
spec:
limits:
- max:
cpu: "1"
memory: 512Mi
min:
cpu: "100m"
memory: 64Mi
default:
cpu: "500m"
memory: 256Mi
defaultRequest:
cpu: "250m"
memory: 128Mi
type: Container
This Limit Range ensures that containers in the greeting-app namespace have resource requests and limits within the specified ranges. If a container does not specify resources, the defaults are applied.
Apply the Limit Range:
kubectl apply -f limit-range.yaml
Network Policies for Security
By default, Pods in a Kubernetes cluster can communicate with any other Pod. Network Policies allow you to control network traffic between Pods, implementing network segmentation for security.
Network Policies require a network plugin that supports them. Minikube's default network plugin does not support Network Policies, but you can use Calico. Start Minikube with Calico:
minikube start --network-plugin=cni --cni=calico
Create a Network Policy that allows only the greeting-client to access the greeting-service. Create greeting-network-policy.yaml:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: greeting-service-policy
namespace: greeting-app
spec:
podSelector:
matchLabels:
app: greeting-service
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: greeting-client
ports:
- protocol: TCP
port: 8080
This policy applies to Pods with the label app: greeting-service. It allows ingress traffic only from Pods with the label app: greeting-client on port 8080.
Apply the policy:
kubectl apply -f greeting-network-policy.yaml
Now only the greeting-client Pods can access the greeting-service. If you try to access the service from a different Pod, the connection will be blocked.
Horizontal Pod Autoscaling
Kubernetes can automatically scale the number of Pods based on CPU utilization or custom metrics. The Horizontal Pod Autoscaler watches metrics and adjusts the number of replicas accordingly.
First, ensure the Metrics Server is installed. For Minikube:
minikube addons enable metrics-server
Create a Horizontal Pod Autoscaler for the greeting service. Create greeting-hpa.yaml:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: greeting-service-hpa
namespace: greeting-app
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: greeting-service
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
This HPA maintains between 2 and 10 replicas of the greeting-service Deployment, targeting 50 percent CPU utilization.
Apply the HPA:
kubectl apply -f greeting-hpa.yaml
Check the HPA status:
kubectl get hpa -n greeting-app
To test the autoscaler, you would need to generate load on the service. You could use a tool like Apache Bench or create a load generator Pod.
Custom Resource Definitions
Kubernetes is extensible through Custom Resource Definitions. CRDs allow you to define your own resource types that extend the Kubernetes API. This is an advanced topic, but understanding it opens up powerful possibilities.
Let us create a simple CRD for a GreetingConfig resource. Create greeting-crd.yaml:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: greetingconfigs.example.com
spec:
group: example.com
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
defaultMessage:
type: string
maxGreetings:
type: integer
scope: Namespaced
names:
plural: greetingconfigs
singular: greetingconfig
kind: GreetingConfig
shortNames:
- gc
Apply the CRD:
kubectl apply -f greeting-crd.yaml
Now you can create instances of this custom resource. Create greeting-config-instance.yaml:
apiVersion: example.com/v1
kind: GreetingConfig
metadata:
name: my-greeting-config
namespace: greeting-app
spec:
defaultMessage: "Welcome!"
maxGreetings: 1000
Apply it:
kubectl apply -f greeting-config-instance.yaml
List your custom resources:
kubectl get greetingconfigs -n greeting-app
To make CRDs useful, you would typically write a custom controller that watches for these resources and takes action based on them. This is how Operators work in Kubernetes.
PART SIX: PRODUCTION-READY RUNNING EXAMPLE
Now let me provide a complete, production-ready implementation of our greeting microservice system. This implementation includes all the best practices we have discussed, with proper error handling, logging, metrics, and configuration management.
Complete Greeting Service Implementation
Create a new directory structure:
greeting-system/
greeting-service/
main.go
Dockerfile
go.mod
go.sum
greeting-client/
main.go
Dockerfile
go.mod
go.sum
k8s/
namespace.yaml
greeting-service-deployment.yaml
greeting-service-service.yaml
greeting-client-deployment.yaml
greeting-pvc.yaml
greeting-configmap.yaml
greeting-ingress.yaml
helm/
greeting-chart/
Chart.yaml
values.yaml
templates/
_helpers.tpl
deployment-service.yaml
deployment-client.yaml
service.yaml
configmap.yaml
pvc.yaml
Here is the complete production-ready greeting service code:
File: greeting-service/main.go
package main
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// Greeting represents a personalized greeting message
type Greeting struct {
Name string `json:"name"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
// GreetingStore manages the storage of greetings with thread-safe operations
type GreetingStore struct {
mu sync.RWMutex
greetings map[string]Greeting
filePath string
maxSize int
}
// NewGreetingStore creates a new greeting store with persistent storage
func NewGreetingStore(filePath string, maxSize int) (*GreetingStore, error) {
store := &GreetingStore{
greetings: make(map[string]Greeting),
filePath: filePath,
maxSize: maxSize,
}
if err := store.load(); err != nil {
log.Printf("Warning: could not load existing data from %s: %v", filePath, err)
} else {
log.Printf("Successfully loaded %d greetings from %s", len(store.greetings), filePath)
}
return store, nil
}
// load reads greetings from the persistent storage file
func (gs *GreetingStore) load() error {
data, err := ioutil.ReadFile(gs.filePath)
if err != nil {
if os.IsNotExist(err) {
log.Printf("No existing data file found at %s, starting fresh", gs.filePath)
return nil
}
return fmt.Errorf("failed to read file: %w", err)
}
if len(data) == 0 {
log.Printf("Data file %s is empty, starting fresh", gs.filePath)
return nil
}
if err := json.Unmarshal(data, &gs.greetings); err != nil {
return fmt.Errorf("failed to unmarshal data: %w", err)
}
return nil
}
// save writes greetings to the persistent storage file
func (gs *GreetingStore) save() error {
data, err := json.Marshal(gs.greetings)
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
if err := ioutil.WriteFile(gs.filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// Set stores a greeting for a given name
func (gs *GreetingStore) Set(name, message string) error {
gs.mu.Lock()
defer gs.mu.Unlock()
if len(gs.greetings) >= gs.maxSize {
return fmt.Errorf("maximum greeting capacity (%d) reached", gs.maxSize)
}
gs.greetings[name] = Greeting{
Name: name,
Message: message,
Timestamp: time.Now(),
}
if err := gs.save(); err != nil {
return fmt.Errorf("failed to persist greeting: %w", err)
}
log.Printf("Stored greeting for %s", name)
return nil
}
// Get retrieves a greeting for a given name
func (gs *GreetingStore) Get(name string) (Greeting, bool) {
gs.mu.RLock()
defer gs.mu.RUnlock()
greeting, exists := gs.greetings[name]
return greeting, exists
}
// GetAll retrieves all greetings
func (gs *GreetingStore) GetAll() []Greeting {
gs.mu.RLock()
defer gs.mu.RUnlock()
greetings := make([]Greeting, 0, len(gs.greetings))
for _, greeting := range gs.greetings {
greetings = append(greetings, greeting)
}
return greetings
}
// Delete removes a greeting for a given name
func (gs *GreetingStore) Delete(name string) error {
gs.mu.Lock()
defer gs.mu.Unlock()
if _, exists := gs.greetings[name]; !exists {
return fmt.Errorf("greeting not found for name: %s", name)
}
delete(gs.greetings, name)
if err := gs.save(); err != nil {
return fmt.Errorf("failed to persist deletion: %w", err)
}
log.Printf("Deleted greeting for %s", name)
return nil
}
// Count returns the number of stored greetings
func (gs *GreetingStore) Count() int {
gs.mu.RLock()
defer gs.mu.RUnlock()
return len(gs.greetings)
}
// handleSetGreeting handles POST requests to set a greeting
func handleSetGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var greeting Greeting
if err := json.NewDecoder(r.Body).Decode(&greeting); err != nil {
log.Printf("Failed to decode request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if greeting.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
if greeting.Message == "" {
http.Error(w, "Message is required", http.StatusBadRequest)
return
}
if err := store.Set(greeting.Name, greeting.Message); err != nil {
log.Printf("Failed to save greeting: %v", err)
if err.Error() == fmt.Sprintf("maximum greeting capacity (%d) reached", store.maxSize) {
http.Error(w, err.Error(), http.StatusInsufficientStorage)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(greeting)
}
}
// handleGetGreeting handles GET requests to retrieve a greeting
func handleGetGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Name parameter is required", http.StatusBadRequest)
return
}
greeting, exists := store.Get(name)
if !exists {
http.Error(w, "Greeting not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(greeting)
}
}
// handleListGreetings handles GET requests to list all greetings
func handleListGreetings(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
greetings := store.GetAll()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(greetings)
}
}
// handleDeleteGreeting handles DELETE requests to remove a greeting
func handleDeleteGreeting(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Name parameter is required", http.StatusBadRequest)
return
}
if err := store.Delete(name); err != nil {
log.Printf("Failed to delete greeting: %v", err)
http.Error(w, "Greeting not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleHealth handles health check requests
func handleHealth(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
count := store.Count()
response := map[string]interface{}{
"status": "healthy",
"greetings_count": count,
"timestamp": time.Now(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
}
// handleReady handles readiness check requests
func handleReady(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
}
}
// handleMetrics provides basic metrics in Prometheus format
func handleMetrics(store *GreetingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
count := store.Count()
metrics := fmt.Sprintf("# HELP greetings_total Total number of stored greetings\n"+
"# TYPE greetings_total gauge\n"+
"greetings_total %d\n", count)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(metrics))
}
}
func main() {
// Configuration from environment variables
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "/data"
}
maxGreetingsStr := os.Getenv("MAX_GREETINGS")
maxGreetings := 10000
if maxGreetingsStr != "" {
fmt.Sscanf(maxGreetingsStr, "%d", &maxGreetings)
}
logLevel := os.Getenv("LOG_LEVEL")
if logLevel == "" {
logLevel = "info"
}
// Create data directory if it doesn't exist
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("Failed to create data directory %s: %v", dataDir, err)
}
// Initialize greeting store
filePath := dataDir + "/greetings.json"
store, err := NewGreetingStore(filePath, maxGreetings)
if err != nil {
log.Fatalf("Failed to create greeting store: %v", err)
}
// Set up HTTP routes
mux := http.NewServeMux()
mux.HandleFunc("/greeting", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
handleSetGreeting(store)(w, r)
case http.MethodGet:
handleGetGreeting(store)(w, r)
case http.MethodDelete:
handleDeleteGreeting(store)(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/greetings", handleListGreetings(store))
mux.HandleFunc("/health", handleHealth(store))
mux.HandleFunc("/ready", handleReady(store))
mux.HandleFunc("/metrics", handleMetrics(store))
// Create HTTP server
server := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in a goroutine
go func() {
log.Printf("Starting greeting service on port %s (log level: %s, max greetings: %d)", port, logLevel, maxGreetings)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Set up graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited gracefully")
}
File: greeting-service/Dockerfile
# Build stage
FROM golang:1.21-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o greeting-service .
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates wget
RUN addgroup -g 1000 appgroup && \
adduser -D -u 1000 -G appgroup appuser
WORKDIR /app
COPY --from=builder /build/greeting-service .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./greeting-service"]
File: greeting-service/go.mod
module github.com/example/greeting-service
go 1.21
File: greeting-client/main.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// Greeting represents a personalized greeting message
type Greeting struct {
Name string `json:"name"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
// Client represents the greeting service client
type Client struct {
baseURL string
httpClient *http.Client
}
// NewClient creates a new greeting service client
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// SetGreeting sets a greeting on the service
func (c *Client) SetGreeting(name, message string) error {
greeting := Greeting{
Name: name,
Message: message,
}
body, err := json.Marshal(greeting)
if err != nil {
return fmt.Errorf("failed to marshal greeting: %w", err)
}
resp, err := c.httpClient.Post(c.baseURL+"/greeting", "application/json", bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// GetGreeting retrieves a greeting from the service
func (c *Client) GetGreeting(name string) (*Greeting, error) {
resp, err := c.httpClient.Get(c.baseURL + "/greeting?name=" + name)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("greeting not found for name: %s", name)
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes))
}
var greeting Greeting
if err := json.NewDecoder(resp.Body).Decode(&greeting); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &greeting, nil
}
// ListGreetings retrieves all greetings from the service
func (c *Client) ListGreetings() ([]Greeting, error) {
resp, err := c.httpClient.Get(c.baseURL + "/greetings")
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes))
}
var greetings []Greeting
if err := json.NewDecoder(resp.Body).Decode(&greetings); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return greetings, nil
}
// DeleteGreeting deletes a greeting from the service
func (c *Client) DeleteGreeting(name string) error {
req, err := http.NewRequest(http.MethodDelete, c.baseURL+"/greeting?name="+name, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
func main() {
serviceURL := os.Getenv("GREETING_SERVICE_URL")
if serviceURL == "" {
serviceURL = "http://localhost:8080"
}
intervalStr := os.Getenv("REQUEST_INTERVAL")
interval := 10 * time.Second
if intervalStr != "" {
if d, err := time.ParseDuration(intervalStr); err == nil {
interval = d
}
}
client := NewClient(serviceURL)
names := []string{"Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Henry", "Iris", "Jack"}
rand.Seed(time.Now().UnixNano())
log.Printf("Starting greeting client, calling service at %s every %v", serviceURL, interval)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
name := names[rand.Intn(len(names))]
message := fmt.Sprintf("Hello, %s! The time is %s", name, time.Now().Format(time.RFC3339))
if err := client.SetGreeting(name, message); err != nil {
log.Printf("ERROR: Failed to set greeting: %v", err)
continue
}
log.Printf("Successfully set greeting for %s", name)
greeting, err := client.GetGreeting(name)
if err != nil {
log.Printf("ERROR: Failed to get greeting: %v", err)
continue
}
log.Printf("Retrieved greeting: %s - %s (stored at %s)", greeting.Name, greeting.Message, greeting.Timestamp.Format(time.RFC3339))
if rand.Float32() < 0.1 {
greetings, err := client.ListGreetings()
if err != nil {
log.Printf("ERROR: Failed to list greetings: %v", err)
} else {
log.Printf("Total greetings in system: %d", len(greetings))
}
}
if rand.Float32() < 0.05 {
deleteTarget := names[rand.Intn(len(names))]
if err := client.DeleteGreeting(deleteTarget); err != nil {
log.Printf("INFO: Could not delete greeting for %s (may not exist): %v", deleteTarget, err)
} else {
log.Printf("Deleted greeting for %s", deleteTarget)
}
}
case <-quit:
log.Println("Shutting down client...")
return
case <-ctx.Done():
return
}
}
}
File: greeting-client/Dockerfile
FROM golang:1.21-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o greeting-client .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
RUN addgroup -g 1000 appgroup && \
adduser -D -u 1000 -G appgroup appuser
WORKDIR /app
COPY --from=builder /build/greeting-client .
RUN chown -R appuser:appgroup /app
USER appuser
CMD ["./greeting-client"]
File: greeting-client/go.mod
module github.com/example/greeting-client
go 1.21
This completes the production-ready running example. The code includes comprehensive error handling, logging, graceful shutdown, health checks, metrics, and all the features necessary for a production deployment. Both services can be built, containerized with Docker, and deployed to Kubernetes using the configurations we discussed throughout this tutorial.