Configure and Deploy Your Service with Helm

Helm[60] is the package manager for Kubernetes that enables you to distribute and install services in Kubernetes. Helm packages are called charts. A chart defines all resources needed to run a service in a Kubernetes cluster—for example, its deployments, services, persistent volume claims, and so on. Charts on Kubernetes are like Debian packages on Debian or Homebrew formulas on macOS. As a service developer, you’ll want to build and share a Helm chart for your service to make it easier for people to run your service. (And if you’re dogfooding your own service, you’ll get the same benefit.)

A release is a instance of running a chart. Each time you install a chart into Kubernetes, Helm creates a release. In the Debian package and Homebrew formula examples, releases are like processes.

And finally, repositories are where you share charts to and install charts from; they’re like Debian sources and Homebrew taps.

To install Helm, run this command:

 $ curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 ​\
  | bash

Before we write our own Helm chart, let’s take Helm for a spin and install an existing chart. Bitnami[61] maintains a repository of charts for popular applications. Let’s add a Bitnami repository and install the Nginx chart, which is a web and proxy server:

 $ helm repo add bitnami https://charts.bitnami.com/bitnami
 $ helm install my-nginx bitnami/nginx

We can see the releases by running $ helm list:

 $ helm list
 NAME NAMESPACE REVISION UPDATED STATUS...
 my-nginx default 1 2020... deployed...

Let’s request Nginx to confirm that it’s really running:

 $ POD_NAME=$(kubectl get pod \
  --selector=app.kubernetes.io/name=nginx \
  --template '{{index .items 0 "metadata" "name" }}')
 $ SERVICE_IP=$(kubectl get svc \
  --namespace default my-nginx --template "{{ .spec.clusterIP }}")
 $ kubectl exec $POD_NAME curl $SERVICE_IP
  % Total % Received % Xferd Average Speed Time Time Time Current
  Dload Upload Total Spent Left Speed
 100 612 100 612 0 0 597k 0 --:--:-- --:--:-- --:--:-- 597k
 <!DOCTYPE html>
 <html>
 <head>
 <title>Welcome to nginx!</title>
 <style>
  body {
  width: 35em;
  margin: 0 auto;
  font-family: Tahoma, Verdana, Arial, sans-serif;
  }
 </style>
 </head>
 <body>
 <h1>Welcome to nginx!</h1>
 <p>If you see this page, the nginx web server is successfully installed and
 working. Further configuration is required.</p>
 
 <p>For online documentation and support please refer to
 <a href="http://nginx.org/">nginx.org</a>.<br/>
 Commercial support is available at
 <a href="http://nginx.com/">nginx.com</a>.</p>
 
 <p><em>Thank you for using nginx.</em></p>
 </body>
 </html>

We could use the same technique for deploying Nginx in a production environment, aside from setting some configuration parameters to fit our use case. Helm made it easy to install and configure an Nginx cluster, and we can manage other services the same way.

Uninstall the Nginx release by running the following:

 $ helm uninstall my-nginx
 release "my-nginx" uninstalled

Now, let’s build our own chart.

Build Your Own Helm Chart

In this section, we’ll build a Helm chart for our service and use it to install a cluster in our Kind cluster.

Create your Helm chart by running these commands:

 $ mkdir deploy && cd deploy
 $ helm create proglog

Helm created a new chart in a new proglog directory that’s bootstrapped with an example that shows you what a Helm chart looks like—to write your own or to tweak for your own services. The proglog directory contains these directories and files:

 .
 └── proglog
  ├── charts
  ├── Chart.yaml
  ├── templates
  │   ├── deployment.yaml
  │   ├── _helpers.tpl
  │   ├── ingress.yaml
  │   ├── NOTES.txt
  │   ├── serviceaccount.yaml
  │   ├── service.yaml
  │   └── tests
  │   └── test-connection.yaml
  └── values.yaml
 
 4 directories, 9 files

The Chart.yaml file describes your chart. You can access the data in this file in your templates. The charts directory may contain subcharts, though I’ve never needed subcharts.

The values.yaml contains your chart’s default values. Users can override these values when they install or upgrade your chart (for example, the port your service listens on, your service’s resource requirements, log level, and so on).

The templates directory contains template files that you render with your values to generate valid Kubernetes manifest files. Kubernetes applies the rendered manifest files to install the resources needed for your service. You write your Helm templates using the Go template language.

You can render the templates locally without applying the resources in your Kubernetes cluster by running $ helm template. This is useful when you’re developing your templates or if you want to apply your changes in a two-step plan-then-apply process because you can see the rendered resources that Kubernetes will apply.

To check out the resources Helm would create with the example chart, run this command:

 $ helm template proglog

You’ll see the following:

 ---
 # Source: proglog/templates/serviceaccount.yaml
 apiVersion: v1
 kind: ServiceAccount
 metadata:
  name: RELEASE-NAME-proglog
  labels:
 
  helm.sh/chart: proglog-0.1.0
  app.kubernetes.io/name: proglog
  app.kubernetes.io/instance: RELEASE-NAME
  app.kubernetes.io/version: "1.16.0"
  app.kubernetes.io/managed-by: Helm
 ---
 # Source: proglog/templates/service.yaml
 rest

We don’t need the example templates, so remove them by running this command:

 $ rm proglog/templates/**/*.yaml proglog/templates/NOTES.txt

Generally, Helm charts include a template file for each resource type. Our service will require two resource types: a StatefulSet and a Service, so we’ll have a statefulset.yaml file and a service.yaml file. Let’s begin with the StatefulSet.

StatefulSets in Kubernetes

You use StatefulSets to manage stateful applications in Kubernetes, like our service that persists a log. You need a StatefulSet for any service that requires one or more of the following:

And by “stable,” I mean persisted across scheduling changes like restarts and scaling.

If your service isn’t stateful and doesn’t require these features, then you should use a Deployment instead of a StatefulSet. One example is an API service that persists to a relational database, like Postgres. You’d run the API service with a Deployment because it’s stateless, and you’d run Postgres with a StatefulSet.

Create a deploy/proglog/templates/statefulset.yaml file with this code:

DeployLocally/deploy/proglog/templates/statefulset.yaml
 apiVersion: ​apps/v1
 kind: ​StatefulSet
 metadata:
  name: {{ include "proglog.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels: {{ include "proglog.labels" . | nindent 4 }}
 spec:
  selector:
  matchLabels: {{ include "proglog.selectorLabels" . | nindent 6 }}
  serviceName: {{ include "proglog.fullname" . }}
  replicas: {{ .Values.replicas }}
  template:
  metadata:
  name: {{ include "proglog.fullname" . }}
  labels: {{ include "proglog.labels" . | nindent 8 }}
  spec:
 # initContainers...
 # containers...
  volumeClaimTemplates:
  - metadata:
  name: ​datadir
  spec:
  accessModes: [ ​"​​ReadWriteOnce"​ ]
  resources:
  requests:
  storage: {{ .Values.storage }}

I have omitted the spec’s initContainers and containers fields to make the snippet smaller (we will fill those in next). The only thing of note here is that our StatefulSet has a datadir PersistentVolumeClaim—the claim requests storage for our cluster. Based on our configuration, Kubernetes could fulfill the claim with a local disk, a disk provided by your cloud platform, and so on. Kubernetes takes care of obtaining and binding the storage to your containers.

Now, replace initContainers... in the previous snippet with this code:

DeployLocally/deploy/proglog/templates/statefulset.yaml
 initContainers:
 - name: {{ include "proglog.fullname" . }}​-config-init
  image: ​busybox
  imagePullPolicy: ​IfNotPresent
  command:
  - ​/bin/sh
  - ​-c
  - |-
  ID=$(echo $HOSTNAME | rev | cut -d- -f1 | rev)
  cat > /var/run/proglog/config.yaml <<EOD
  data-dir: /var/run/proglog/data
  rpc-port: {{.Values.rpcPort}}
  # Make sure the following three key-values are on one line each in
  # your code. I split them across multiple lines to fit them in
  # for the book.
  bind-addr: \
  "$HOSTNAME.proglog.{{.Release.Namespace}}.\svc.cluster.local:\
  {{.Values.serfPort}}"
  bootstrap: $([ $ID = 0 ] && echo true || echo false)
  $([ $ID != 0 ] && echo 'start-join-addrs: \
  "proglog-0.proglog.{{.Release.Namespace}}.svc.cluster.local:\
  {{.Values.serfPort}}"')
  EOD
  volumeMounts:
  - name: ​datadir
  mountPath: ​/var/run/proglog

Init containers run to completion before the StatefulSet’s app containers listed in the containers field. Our config init container sets up our service’s configuration file. We configure the first server to bootstrap the Raft cluster. And we configure the subsequent servers to join the cluster. We mount the datadir volume into the container so we can write to the same configuration file our app container will read from later.

Replace containers... in the previous snippet with this:

DeployLocally/deploy/proglog/templates/statefulset.yaml
 containers:
 - name: {{ include "proglog.fullname" . }}
  image: ​"​​{{​ ​.Values.image.repository​ ​}}:{{​ ​.Values.image.tag​ ​}}"
  ports:
  - containerPort: {{ .Values.rpcPort }}
  name: ​rpc
  - containerPort: {{ .Values.serfPort }}
  name: ​serf
  args:
  - ​--config-file=/var/run/proglog/config.yaml
 # probes...
  volumeMounts:
  - name: ​datadir
  mountPath: ​/var/run/proglog

These containers define our StatefulSet’s app containers; we need one for our service. We mount the volume to the container for reading the configuration file and persisting the log. We use a flag to tell our service where to find its configuration file.

Container Probes and gRPC Health Check

Kubernetes uses probes to know whether it needs to act on a container to improve your service’s reliability. With a service, usually the probe requests a health check endpoint that responds with the health of the service.

There are three types of probes:

These probes should help improve your service’s reliability, but they can cause incidents if they’re not carefully implemented (like the example of the liveness probe that restarts the container before it’s finished initializing). The systems dedicated to improving the reliability of the service can cause more incidents than the service by itself.

You have three ways of running probes:

The first two are lightweight because they don’t require any extra binaries in your image. However, a command can be more precise and necessary if you use your own protocol.

gRPC services conventionally use a grpc_health_probe command that expects your server to satisfy the gRPC health checking protocol.[62] Our server needs to export a service defined as:

 syntax = ​"proto3"​;
 
 package​ ​grpc​.health.v1;
 
 message​ HealthCheckRequest {
 string​ ​service​ = 1;
 }
 
 message​ HealthCheckResponse {
 enum​ ServingStatus {
  UNKNOWN = 0;
  SERVING = 1;
  NOT_SERVING = 2;
  }
  ServingStatus status = 1;
 }
 
 service​ Health {
 rpc​ Check(HealthCheckRequest) ​returns​ (HealthCheckResponse);
 
 rpc​ Watch(HealthCheckRequest) ​returns​ (stream HealthCheckResponse);
 }

Let’s update our server to export the health check service.

Open internal/server/server.go and add the highlighted imports:

DeployLocally/internal/server/server.go
 import​ (
 "context"
 "time"
 
  api ​"github.com/travisjeffery/proglog/api/v1"
 
  grpc_middleware ​"github.com/grpc-ecosystem/go-grpc-middleware"
  grpc_auth ​"github.com/grpc-ecosystem/go-grpc-middleware/auth"
  grpc_zap ​"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap"
  grpc_ctxtags ​"github.com/grpc-ecosystem/go-grpc-middleware/tags"
 
 "go.opencensus.io/plugin/ocgrpc"
 "go.opencensus.io/stats/view"
 "go.opencensus.io/trace"
 "go.uber.org/zap"
 "go.uber.org/zap/zapcore"
 
 "google.golang.org/grpc"
 "google.golang.org/grpc/codes"
 "google.golang.org/grpc/credentials"
 "google.golang.org/grpc/peer"
 "google.golang.org/grpc/status"
 
»"google.golang.org/grpc/health"
» healthpb ​"google.golang.org/grpc/health/grpc_health_v1"
 )

Then, update the NewGRPCServer function to include the highlighted lines in this snippet:

DeployLocally/internal/server/server.go
 func​ NewGRPCServer(config *Config, grpcOpts ...grpc.ServerOption) (
  *grpc.Server,
 error​,
 ) {
  logger := zap.L().Named(​"server"​)
  zapOpts := []grpc_zap.Option{
  grpc_zap.WithDurationField(
 func​(duration time.Duration) zapcore.Field {
 return​ zap.Int64(
 "grpc.time_ns"​,
  duration.Nanoseconds(),
  )
  },
  ),
  }
 
  trace.ApplyConfig(trace.Config{
  DefaultSampler: trace.AlwaysSample(),
  })
  err := view.Register(ocgrpc.DefaultServerViews...)
 if​ err != nil {
 return​ nil, err
  }
 
  grpcOpts = append(grpcOpts,
  grpc.StreamInterceptor(
  grpc_middleware.ChainStreamServer(
  grpc_ctxtags.StreamServerInterceptor(),
  grpc_zap.StreamServerInterceptor(
  logger, zapOpts...,
  ),
  grpc_auth.StreamServerInterceptor(
  authenticate,
  ),
  )), grpc.UnaryInterceptor(
  grpc_middleware.ChainUnaryServer(
  grpc_ctxtags.UnaryServerInterceptor(),
  grpc_zap.UnaryServerInterceptor(
  logger, zapOpts...,
  ),
  grpc_auth.UnaryServerInterceptor(
  authenticate,
  ),
  )),
  grpc.StatsHandler(&ocgrpc.ServerHandler{}),
  )
  gsrv := grpc.NewServer(grpcOpts...)
 
» hsrv := health.NewServer()
» hsrv.SetServingStatus(​""​, healthpb.HealthCheckResponse_SERVING)
» healthpb.RegisterHealthServer(gsrv, hsrv)
 
  srv, err := newgrpcServer(config)
 if​ err != nil {
 return​ nil, err
  }
  api.RegisterLogServer(gsrv, srv)
 return​ gsrv, nil
 }

These lines create a service that supports the health check protocol. We set its serving status as serving so that the probe knows the service is alive and ready to accept connections. Then we register the service with our server so that gRPC can call this service’s endpoints.

Replace probes... in deploy/proglog/templates/statefulset.yaml with this snippet to tell Kubernetes how to probe our service:

DeployLocally/deploy/proglog/templates/statefulset.yaml
 readinessProbe:
  exec:
  command: [​"​​/bin/grpc_health_probe"​, ​"​​-addr=:{{​ ​.Values.rpcPort​ ​}}"​]
  initialDelaySeconds: ​10
 livenessProbe:
  exec:
  command: [​"​​/bin/grpc_health_probe"​, ​"​​-addr=:{{​ ​.Values.rpcPort​ ​}}"​]
  initialDelaySeconds: ​10

Then add these highlighted lines to your Dockerfile to install the grpc_health_probe executable in your image:

DeployLocally/Dockerfile
 FROM​​ golang:1.14-alpine AS build
 WORKDIR​​ /go/src/proglog
 COPY​​ . .
 RUN ​CGO_ENABLED=0 go build -o /go/bin/proglog ./cmd/proglog
»RUN ​GRPC_HEALTH_PROBE_VERSION=v0.3.2 && ​\
» wget -qO/go/bin/grpc_health_probe ​\
» https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/​\
»${​GRPC_HEALTH_PROBE_VERSION​}​/grpc_health_probe-linux-amd64 && ​\
» chmod +x /go/bin/grpc_health_probe
 
 FROM​​ scratch
 COPY​​ --from=build /go/bin/proglog /bin/proglog
»COPY​​ --from=build /go/bin/grpc_health_probe /bin/grpc_health_probe
 ENTRYPOINT​​ ["/bin/proglog"]

The last resource we need to define in our Helm chart is the Service.

Kubernetes Services

A Service in Kubernetes exposes an application as a network service. You define a Service with policies that specify what Pods the Service applies to and how to access the Pods.

Four types of services specify how the Service exposes the Pods:

I don’t recommend using NodePort services (aside from the ones LoadBalancer services create for you). You have to know your nodes’ IPs to use the services, you must secure all your Nodes, and you have to deal with port conflicts. Instead, I recommend using a LoadBalancer or a ClusterIP service if you’re able to run a Pod that can access your internal network.

Create a deploy/proglog/templates/service.yaml for your service template with the following code:

DeployLocally/deploy/proglog/templates/service.yaml
 apiVersion: ​v1
 kind: ​Service
 metadata:
  name: {{ include "proglog.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels: {{ include "proglog.labels" . | nindent 4 }}
 spec:
  clusterIP: ​None
  publishNotReadyAddresses: true
  ports:
  - name: ​rpc
  port: {{ .Values.rpcPort }}
  targetPort: {{ .Values.rpcPort }}
  - name: ​serf-tcp
  protocol: ​"​​TCP"
  port: {{ .Values.serfPort }}
  targetPort: {{ .Values.serfPort }}
  - name: ​serf-udp
  protocol: ​"​​UDP"
  port: {{ .Values.serfPort }}
  targetPort: {{ .Values.serfPort }}
  selector: {{ include "proglog.selectorLabels" . | nindent 4 }}

This snippet defines our “headless” Service. A headless Service doesn’t load balance to a single IP. You use a headless Service when your distributed service has its own means for service discovery. By defining selectors on our Service, Kubernetes’ endpoint controller changes the DNS configuration to return records that point to the Pods backing the Service. So, each pod will get its own DNS record similar to proglog-{{id}}.proglog.{{namespace}}.svc.cluster.local, and the servers will use these records to discover each other.