Kubernetes is one of the most popular open-source container orchestration frameworks in use today. It allows you to easily deploy, scale and manage containerised applications. As your applications grow, the number of Kubernetes resources you have to manage increases, and that’s where Helm comes in. Helm is a package manager for Kubernetes, allowing you to define, install and manage complex Kubernetes clusters at scale. However, unless you want to install all of your helm charts individually (and possibly manually), there is a need for an automated, infrastructure-as-code approach. Enter Helmsman.

The Problem

As mentioned above, in a productionised domain, the set of deployed services and their accompanying resources will grow exponentially. Even when using a package manager like Helm, the sheer amount of deployable resources and packages can become hard to manage.

If you have ten Helm charts to deploy, you could be running ten install and/or upgrade commands to reach the desired cluster state for any given environment. Furthermore, if you have multiple environments (dev, test, preprod, prod etc), you then have ten commands per environment to run - you can quickly see how this could become difficult - not to mention inefficient - to manage.

An Introduction to Helmsman

Helmsman is a tool which allows you to define the desired state of your Kubernetes cluster in code, giving you the ability to deploy, upgrade or destroy that state in a single command. Each environment (namespace traditionally in Kubernetes) has its own state file, making managing versions and resources across environments much simpler.

As a result of Helmsman encapsulating the state of your cluster(s) in code, you can easily describe the state of any cluster by looking at the Helmsman desired state file. This makes it easier to manage what’s deployed, where and at which version.

A Helmsman Story

Let’s take an example where we have a service domain which contains four microservices. Each microservice has slightly different resources requirements (CPU/Memory) and two of them are required to integrate with a database. In non-production environments (dev, test) they are not required to be highly-available, whereas in production environments (preprod, prod) they are.

Basic Helm Chart

We’ll create a Helm application chart that can define the Kubernetes resources required for each of our services. Our example service chart will contain some standard Kubernetes resources such as a deployment and network policy.

metadata:
  environment: replace-me

deployment:	
  create: false
  replicas: 1
  name: replace-me
  image: replace me 
  ports: 
    - 8080
  resources:
    requests: 
      memory: "250Mi"
      cpu: "250m"
    limits: 
      memory: "350Mi"
      cpu: "300m"

networkPolicy:
  create: false
  podSelector:
    matchLabels:
      app: replace-me 
  policyTypes:
    - Egress
  egress: {}

The above is heavily simplified from what a real production chart may look like, but the purpose here is just to give an example to work from later.

Above you can see a create: false property on each resource, this is a practice I tend to follow when building Helm library charts, as it gives implementing charts the ability to opt-in to whichever resources they need, and not just get them implemented by default.

Microservice Setup

Each microservice will have it’s own implementation of the base chart shown above. Let’s first use microservice-a as an example, which has no extra resource requirements, and no database connectivity.

Chart.yaml

---
apiVersion: v2
name: service-a
description: Chart for microservice A
version: 0.1.0
dependencies:
  - name: base
    version: 1.0.0
    repository: "@base-repository"

values.yaml

base:
  deployment:	
    create: true
    replicas: 1
    name: service-a
    image: service-a:1.0.0

As you can see above, microservice-a has a very simple implementation of the base chart, mostly using the default values provided.

Now let’s look at microservice-b. This service will have slightly higher resource requirements and will also need egress networking out to a MySQL database (running in a pod in the same namespace).

Chart.yaml

---
apiVersion: v2
name: service-b
description: Chart for microservice B
version: 0.1.0
dependencies:
  - name: base
    version: 1.0.0
    repository: "@base-repository"

values.yaml

base:
  deployment:	
    create: true
    replicas: 1
    name: service-b
    image: service-b:1.0.0
    resources:
      requests: 
        memory: "500Mi"
        cpu: "350m"
      limits: 
        memory: "550Mi"
        cpu: "400m"

  networkPolicy:
    create: true
    podSelector:
      matchLabels:
        app: service-b
    policyTypes:
      - Egress
    egress:
    - to:
      - podSelector:
          matchLabels:
            app: mysql

Helmsman Implementation

Now let’s look at the Helmsman implementation and how it makes dealing with multi-service deployments simpler.

Our very simple Helmsman folder structure will look as follows (showing only service-a and service-b for brevity):

.
├── dev.yaml        
├── test.yaml       
|── preprod.yaml    
|── prod.yaml       
└── values
    ├── service-a
        └── values-dev.yaml       
        └── values-test.yaml       
        └── values-preprod.yaml   
        └── values-prod.yaml       
    ├── service-b
        └── values-dev.yaml        
        └── values-test.yaml       
        └── values-preprod.yaml    
        └── values-prod.yaml       

Let’s look at a desired state file and one of the values files for each service in a bit more detail to show what’s happening.

As mentioned previously, Helmsman provides a way of describing the desired state for your Kubernetes cluster. In the example we’re using, we’ve got two clusters; non-production (containing dev and test namespaces) and production (containing preprod and prod namespaces).

Let’s take a look at the dev.yaml state file;

metadata:
  description: Desired State File for the dev environment

namespaces:
  dev: 

helmRepos:
  stable: http://custom-helm-repo-example.com

apps:
  service-a:
    namespace: dev
    enabled: true
    chart: stable/service
    version: 1.0.0
    valuesFile: values/service-a/values-dev.yaml    
  service-b:
    namespace: dev
    enabled: true
    chart: stable/service
    version: 1.0.0
    valuesFile: values/service-b/values-dev.yaml     

There’s a few bits going on in the above state file definition, so let’s break it down.

The namespaces property allows you to define the namespace(s) you have or want as part of this state definition. If the namespace(s) don’t exist when you run Helmsman, it will create them for you.

namespaces:
  dev:

The helmRepos property allows you to define the Helm repositories where your packaged charts are stored. There are several options for chart repositories, such as; default, private (backed by Google, AWS or basic auth) and local.

helmRepos:
  stable: http://custom-helm-repo-example.com # This doesn't exist, it's just shown for example purposes

The apps block is the most important block within the example state file shown above, it defines all the services you want deploying as part of this state file. Helmsman is very powerful and provides a lot of configuration options for deploying apps and configuring them. In the example above, we’re using a simple app definition for each service.

apps:
  service-a: 
    namespace: dev 
    enabled: true 
    chart: stable/service 
    version: 1.0.0 
    valuesFile: values/service-a/values-dev.yaml

An important property defined above is the valuesFile property, this tells Helmsman where the values file to be installed as part of this release is located within the Helmsman structure.

As displayed previously, our Helmsman file structure contains the following files;

└── values
    ├── service-a
        └── values-dev.yaml        
    ├── service-b
        └── values-dev.yaml        

So when we’re specifying the valuesFile property as values/service-a/values-dev.yaml it’s referring to the following folder

└── values    
  ├── service-a
      └── values-dev.yaml        

Now let’s look at the contents of those files - this is where the modularisation within Helmsman really shines.

Earlier on we stated that Service A doesn’t have any additional requirements beyond the standard chart specification. Whereas Service B had the additional requirements of higher resources and a connection to a MySQL database. With that being said, let’s look at the values-dev.yaml definition for these services

Service A

Service A only needs to specify the environment it sits within and some basic information about the deployment; name, image and container port, everything else is already defined in the base service chart that we’re using (as defined in the Helmsman dev.yaml state file).

metadata:
  environment: dev

deployment:	
    create: true
    name: service-a
    image: service-a:1.0.0
    containerPort: 8080

Service B

Service B on the other hand, needs a bit more configuration to meet requirements.

metadata:
  environment: dev

deployment:	
  create: true
  name: service-b
  image: service-b:1.0.0
  containerPort: 8080
  resources:
    requests: 
      memory: "500Mi"
      cpu: "350m"
    limits: 
      memory: "550Mi"
      cpu: "400m"

networkPolicy:
  create: true
  podSelector:
    matchLabels:
      app: service-b
  policyTypes:
    - Egress
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: mysql

For the Service B values-dev.yaml file we have specified the environment, deployment and networkPolicy configuration values. This has allowed us to override and add to the values that are defined in the base service chart we’re using as part of this deployment.

As our project grows, we can easily add more services to our desired state file(s), making the management of our environments much simpler than if we had to manage all the helm charts individually.

Bringing It All Together

So now we have our example Helmsman project setup, with our desired state file(s) ready to provision services into our cluster. All we need to do now is issue certain Helmsman commands and we’ll have our services running in no time. Ideally, you’d run Helmsman from CI pipelines, but that goes beyond the scope of this post. We’ll now take a look a few of the more widely used commands.

Dry Run

A really useful feature of Helmsman is the ability to use dry-run. This allows you to point Helmsman at one of your desired state files and do a dry-run installation against your cluster. The benefit of this is you get to see the rendered Kubernetes manifests that would be installed, and can easily verify and validate that the manifests to be installed are correct, without them actually being installed.

helmsman -f dev.yml --dry-run

Apply

Next up is the apply command. This applies your desired state file to your kubernetes cluster, installing all the resources via Helm.

helmsman -f dev.yml --apply

Destroy

Another useful command is the destroy command. This tears down your cluster based on the desired state file - this is useful if you want to tear down environments quickly or nightly to save costs.

helmsman -f dev.yml --destroy

Wrapping Up

Although this post has only shown a very simple example project, hopefully you can see how Helmsman is a very useful tool for managing our Kubernetes environments. As service domains grow, so do the amount of resources we need to keep track of and implement to keep everything ticking along. Rather than trying to keep a handle on all of those resources manually, it’s better to leverage specific tooling (like Helmsman) to provide consistency, efficiency and a much better developer experience!

Helmsman is just one approach to managing your kubernetes environments, and is a good entryway to more GitOps style approaches such as FluxCD or ArgoCD (among others).

You can see all the code for an example service scenario like the one described in this post over on my github.

Comments