Helm is a great tool for defining and deploying Kubernetes-native application packages. If we deploy to multiple environments, we typically have to write multiple values.yaml
files containing duplicate values. Keeping our values files DRY is possible, however, either natively via multiple value files options or, using YQ.
The WET way: write multiple environment-specific configuration files
If we don’t care about code duplication and go the WET (write everything twice) way, we may just pass environment-specific values files to Helm
. Let’s for example consider that one of our Helm application packages is scaled differently on DEV and PROD. We could, obviously, just maintain a values.dev.yaml
and a values.prod.yaml
. Both would, however, only differ in the scaling configuration and therefore contain duplicate code.
Keeping our environment-specific configuration DRY: default values and environment-specific overrides
Applying the DRY (don’t repeat yourself) principle would, also fairly obviously, imply that we only store the overrides in our environment-specific configuration files values.dev.yml
respectively values.prod.yaml
and use default values otherwise. Helm supports this out of the box by allowing multiple values files parameters. E.g.
helm install mychart . -f values.yaml -f values.dev.yaml
The values of both files are merged from right to left, with the right file’s properties taking precedence.
If we need to do some initial processing of our values file first we may also want to merge our values files explicitely using a YAML tool.
YQ, for instance, has a multiply-merge operator *
that, if we use it on objects and arrays, will perform a merge operation. Even more interestingly, if we combine the multiply-merge operator with the load
operator, we can merge two YAML files as follows.
yq '. *= load("values.dev.yaml")' values.yaml
The above command, for example, performs a right-to-left merge of values.dev.yaml
into values.yaml
.
Note that deep merging is enabled by default for objects. If our configuration contains arrays, we have to explicitly ask YQ to do deep merging using the
d
flag.
An example for initial processing could be variable substitution. If we collect some configuration values from the environment, we may want to merge our values files with YQ, then substitute environment variables with envsubst
and finally pipe the result to helm
.
Example
Helm provides chart bootstrapping with helm create
. So let’s create a simple chart like this:
helm create mychart && cd mychart
$ Creating chart
As we can see, a values.yaml
is provided and some resources like a Kubernetes deployment and ingress are defined in the templates directory.
.
├── Chart.yaml
├── charts
├── templates
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── service.yaml
│ ├── serviceaccount.yaml
│ └── tests
│ └── test-connection.yaml
└── values.yaml
3 directories, 10 files
Using helm install
on this chart will create the following manifests (as we can see with the --dry-run
flag).
helm install --dry-run mychart . -f values.yaml
$ NAME: mychart
LAST DEPLOYED: Sat Jun 18 13:50:19 2022
NAMESPACE: default
STATUS: pending-install
REVISION: 1
HOOKS:
---
# Source: mychart/templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "mychart-test-connection"
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['mychart:80']
restartPolicy: Never
MANIFEST:
---
# Source: mychart/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
---
# Source: mychart/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
---
# Source: mychart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
template:
metadata:
labels:
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
spec:
serviceAccountName: mychart
securityContext:
{}containers:
- name: mychart
securityContext:
{}image: "nginx:1.16.0"
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{}
NOTES:
1. Get the application URL by running these commands:
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=mychart,app.kubernetes.io/instance=mychart" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace default $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace default port-forward $POD_NAME 8080:$CONTAINER_PORT
Note that the resources block of the Deployment
resource is empty. Now, let’s add a values.dev.yaml
with the following content
# values.dev.yaml
resources:
requests:
cpu: ${CPU_REQUESTS_DEV}
memory: ${MEM_REQUESTS_DEV}
Merging this into values.yaml
with YQ as shown above will produce the following YAML file.
yq '. *= load("values.dev.yaml")' values.yaml
$ # Default values for mychart.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: ${CPU_REQUESTS_DEV}
memory: ${MEM_REQUESTS_DEV}
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
Note, that our resources
block now contains the values—i.e. variable substitution tokens—configured in values.dev.yaml
.
If we additionally replace environment variables—after defining them of course—with envsubst
, we end up with the following YAML.
export CPU_REQUESTS_DEV=1 MEM_REQUESTS_DEV=512Mi && yq '. *= load("values.dev.yaml")' values.yaml | envsubst
# Default values for mychart.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 1
memory: 512Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
As we can see, the resources.requests
block now contains the values we defined in the environment variables.
Since helm
conveniently allows us to read values files from standard input, we can simply pipe the output above into our Helm install
command.
export CPU_REQUESTS_DEV=1 MEM_REQUESTS_DEV=512Mi \
$ && yq '. *= load("values.dev.yaml")' values.yaml \
| envsubst \
| helm install mychart . -f - --dry-run
NAME: mychart
LAST DEPLOYED: Mon Jun 20 13:06:36 2022
NAMESPACE: default
STATUS: pending-install
REVISION: 1
HOOKS:
---
# Source: mychart/templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "mychart-test-connection"
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['mychart:80']
restartPolicy: Never
MANIFEST:
---
# Source: mychart/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
---
# Source: mychart/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
---
# Source: mychart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
template:
metadata:
labels:
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: mychart
spec:
serviceAccountName: mychart
securityContext:
{}containers:
- name: mychart
securityContext:
{}image: "nginx:1.16.0"
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 1
memory: 512Mi
NOTES:
1. Get the application URL by running these commands:
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=mychart,app.kubernetes.io/instance=mychart" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace default $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace default port-forward $POD_NAME 8080:$CONTAINER_PORT