ConfigMaps
, Secrets
and InitContainers
January 26th, 2019
New Year (kind of), new job, and another chance to say that I’m going to be serious about writing longer form pieces of content for my site. The new job part is probably the biggest motivation, as I’m going to be working extensively with scalable cloud infrastructure built out on top of k8s. This which serves as a great opportunity to write about what I’m learning along the way. Fun!
This post is going to detail a problem I had to work around this week related to configuration changes and version control.
Specifically, the problem I was faced with was in a project that had a directory structure like the following:
repo/
config-file1.ini
config-file2.ini
deployment.yaml
The config-file*.ini
files looked like the following:
...
[database]
password = changeme
host = localhost
...
The deployment.yaml
file represents a typical k8s
Deployment
.
This case, the end result is simply printing out the contents of the
configuration files, to prove that we can see everything that we need to run
our app (i.e. the passwords):
apiVersion: apps/v1
kind: Deployment
...
spec:
...
template:
spec:
containers:
- name: container-1
image: busybox
command: ['sh', '-c', 'cat /etc/config/*; sleep 100']
volumeMounts:
- name: secrets
mountPath: /etc/config
readOnly: true
volumes:
- name: secrets
secret:
secretName: passwords
items:
- key: config-file-1
path: config1.ini
- key: config-file-2
path: config2.ini
The config files contained secret information that couldn’t be checked in
(obviously), but it still had to be accessible to the k8s cluster. A
Secret
was used
to store the contents of the .ini
files in their entirety. Herein lies the
problem!
One of the benefits (in theory) of having configuration checked into version control, and having a solid CI pipeline is that when someone changes a configuration file, a pipeline is triggered to build and redeploy everything. In our case, this is done with Helm and Jenkins.
Unfortunately, the Secret
had been created manually,
using something like the following:
apiVersion: v1
kind: Secret
metadata:
name: my-secret
stringData:
config-file-1: |
...
[database]
password = real-password-1
host = localhost
...
config-file-2: |
...
[database]
password = real-password-2
host = localhost
...
type: Opaque
The password had been put into place for the purposes of creating the Secret
initially, and this had been stored in the cluster. The file had then been
deleted.
The problem arose when trying to alter some other configuration in the .ini
files (for example, changing the hostname for the database), and assuming that
the CI pipeline would push these changes out into the cluster. Given that the
configuration was being mapped into the containers from the static Secret
,
getting the configuration change reflected would mean updating the Secret
manually, creating a new yaml file, like before and applying the change to
the cluster. Not ideal.
Here’s a little solution I came up with instead, that at its core, relies on a
ConfigMap
for the configuration template, a Secret
for the passwords, and
an InitContainer
to take the template and the passwords and populate a file
that could be used by the main container. Easy! … That said, there were some
gotchas along the way though that I want to point out too.
Secret
for the passwordsThe only “secret” information that is contained in the .ini
files is the
password, so it makes more sense to make a Secret
that contains just the
password.
Here’s the config:
apiVersion: v1
kind: Secret
metadata:
name: passwords
stringData:
password1: real-password-1
password2: real-password-2
type: Opaque
These were then created in the cluster, and the files subsequently deleted, like before.
Note that this definitely isn’t best practice in terms of security. There are safer and more reliable ways of creating secrets that don’t rely on generating files locally and storing them on disk temporarily. That’s outside the scope of this post though.
ConfigMaps
for configuration templatesWith the passwords now in their own dedicated Secret
, we can move the
configuration files out of the existing Secret
and into a
ConfigMap
,
which ended up looking something like this:
apiVersion: v1
kind: ConfigMap
metadata:
name: templates
data:
config-1: |
[database]
password = PASSWORD
host = localhost
config-2: |
[database]
password = PASSWORD
host = localhost
Note that in this example, we end up no longer requiring the .ini
files,
opting to have this configuration moved directly into the ConfigMap
. In our
setup we’re using Helm, which allows us to use their templating
language to inline the contents of
files directly into the yaml, so we can still have the .ini
files. I’ll leave
that for another post.
Volumes
The eventual goal is to be able to write the passwords from the Secret
into
the templates contained in the ConfigMap
, which are mounted into the main
container.
Here’s a first pass at the deploment.yaml
for this:
apiVersion: apps/v1
kind: Deployment
...
spec:
...
template:
spec:
containers:
- name: container-1
image: busybox
command: ['sh', '-c', 'cat /etc/config/*; sleep 100']
volumeMounts:
- name: templates
mountPath: /etc/templates
readOnly: true
- name: secrets
mountPath: /etc/secrets
readOnly: true
volumes:
- name: templates
configMap:
name: templates
items:
- key: config-1
path: config1.ini
- key: config-2
path: config2.ini
- name: secrets
secret:
secretName: passwords
Even though we have the templates and passwords mounted into the container,
we’re not actually doing anything with them here. We still need to combine
them. This is where the InitContainers
are useful.
InitContainers
InitContainers
allow you to start one or more containers before the main containers start that
can do some kind of setup. This might be setting an environment variable, or
blocking on some condition before allowing the main containers to launch. In
our use-case, we’re going to use them to put the password into the templates.
Here’s what my second pass looked like:
apiVersion: apps/v1
kind: Deployment
...
spec:
...
template:
spec:
- name: init-password-1
image: busybox
command: ['sh', '-c', 'sed -i "s/PASSWORD/$(cat /etc/secrets/password-1)/" /etc/config/config1.ini']
volumeMounts:
- name: templates
mountPath: /etc/config
readOnly: false
- name: secrets
mountPath: /etc/secrets
readOnly: true
- name: init-password-2
image: busybox
command: ['sh', '-c', 'sed -i "s/PASSWORD/$(cat /etc/secrets/password-2)/" /etc/config/config2.ini']
volumeMounts:
- name: templates
mountPath: /etc/templates
readOnly: false
- name: secrets
mountPath: /etc/secrets
readOnly: true
containers:
- name: container-1
image: busybox
command: ['sh', '-c', 'cat /etc/config/*; sleep 100']
volumeMounts:
- name: templates
mountPath: /etc/templates
readOnly: true
volumes:
- name: templates
configMap:
name: templates
items:
- key: config-1
path: config1.ini
- key: config-2
path: config2.ini
- name: secrets
secret:
secretName: passwords
We’ve used two InitContainers
that mount in the Secret
as read only into
/etc/secrets
, and the ConfigMap
as read-write into /etc/config/
and then
inline the password. The same ConfigMap
is then mounted into the main
container at /etc/config
, and the container reads the contents of the updated
config files. Great!
Unfortunately, there’s a subtle problem in that the ConfigMap
that is mounted
into the InitContainers
isn’t actually read-write, even though we’ve asked
for it to be. This makes sense, given that it would be weird for the container
to make changes to the contents of the underlying ConfigMap
. Are those
changes reflected in the map that’s stored in the cluster?, etc., etc. The same
read only constraints apply to Secrets
.
There’s a nice explanation for it here.
emptyDir
volumesWith the ConfigMap
and Secret
being read-only mounts, we need a way to
generate the configuration and persist that somewhere temporarily and make that
accessible to the main container. We can use an
emptyDir
volume for that!
Here’s what the final configuration looked like:
apiVersion: apps/v1
kind: Deployment
...
spec:
...
template:
spec:
initContainers:
- name: init-password-1
image: busybox
command: ['sh', '-c', 'sed "s/PASSWORD/$(cat /etc/secrets/password-1)/" /etc/templates/config1.ini.tmpl > /etc/config/config1.ini']
volumeMounts:
- name: templates
mountPath: /etc/templates
readOnly: true
- name: secrets
mountPath: /etc/secrets
readOnly: true
- name: configs
mountPath: /etc/config
readOnly: false
- name: init-password-2
image: busybox
command: ['sh', '-c', 'sed "s/PASSWORD/$(cat /etc/secrets/password-2)/" /etc/templates/config2.ini.tmpl > /etc/config/config2.ini']
volumeMounts:
- name: templates
mountPath: /etc/templates
readOnly: true
- name: secrets
mountPath: /etc/secrets
readOnly: true
- name: configs
mountPath: /etc/config
readOnly: false
containers:
- name: container-1
image: busybox
command: ['sh', '-c', 'cat /etc/config/*; sleep 100']
volumeMounts:
- name: configs
mountPath: /etc/config
readOnly: true
volumes:
- name: templates
configMap:
name: templates
items:
- key: config-1
path: config1.ini.tmpl
- key: config-2
path: config2.ini.tmpl
- name: secrets
secret:
secretName: passwords
- name: configs
emptyDir: {}
We’re now mounting the emptyDir
volume into each of the InitContainers
as
read-write and inlining the passwords and into templates and persisting that
into the emptyDir
, which is then made accessible to the main container. The
container only has read-only access to the final configuration file, so there’s
no chance it can try and alter the contents once they are written.
Here’s the final output, that proves we wired it all up correctly:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
deployment-6d8db67956-4vnzr 1/1 Running 1 1m
$ kubectl logs deployment-6d8db67956-4vnzr
[database]
password = real-password-1
host = localhost
[database]
password = real-password-2
host = localhost
And done!
I mentioned as an aside above that it’s usually not the best idea to persist
passwords or key material to disk, and that’s what we’re doing here. That said,
if you’re using an emptyDir
, we can tell it to use an in-memory tmpfs as the
storage medium, which is much safer. To do that, we alter the volume definition
in the Deployment
as follows:
volumes:
...
- name: configs
emptyDir:
medium: Memory
And prove to ourselves that it worked:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
deployment-5-6f7c6c7787-95vtr 1/1 Running 1 1m
$ kubectl exec -it deployment-5-6f7c6c7787-95vtr -- /bin/sh -c "df -h | grep '/etc/config'"
tmpfs 1.8G 8.0K 1.8G 0% /etc/config
So that’s how we can use ConfigMaps
, Secrets
and InitContainers
to enable
us to combine secret information such as passwords with static configuration
that is checked into version control and ensuring that everything is updated
when it changes, rather than manually updating Secret
s.