After a break we are back with another post about Kubernetes. This time we will focus on how to pass configuration to application running in K8s cluster. But first, to have some playground, we are going to build docker image with test application and deploy it to K8s cluster.
Image repository
To make images available to all cluster's nodes we create private docker repository running on master node:
docker run -d -p 5000:5000 --restart=always --name registry registry:2
On each of worker nodes we need to update /etc/docker/daemon.json
with new repository definition:
"insecure-registries" : [ "k8admin:5000" ]
and then restart docker.
Docker image
We will use simple node.js
script acting as cloud service. Let's create base.Dockerfile
:
FROM node:15-alpine
WORKDIR /app
RUN npm install --production
RUN npm install express
RUN apk --no-cache add curl
This defines our base image: node.js
with express
framework on top of Alpine Linux
, with extra curl
for connectivity diagnostics. To build the image we execute command:
docker build --build-arg https_proxy=http://proxy.yoursite.local:8080 -f base.Dockerfile -t k8admin:5000/blog-base .
This runs image build and tags it with private repository address. That way the image will go to our repo instead of central Docker registry:
docker push k8admin:5000/blog-base
Let's define simple application image on top of base image:
FROM k8admin:5000/blog-base:latest
ARG SRCDIR=.
COPY $SRCDIR/index.js .
and the execute commands:
docker build --build-arg https_proxy=http://proxy.yoursite.local:8080 --build-arg SRCDIR=$1 -t k8admin:5000/blog-app.
docker push k8admin:5000/blog-app
blog-app
image gets index.js
from given directory. Simplest version of index.js
is to listen on port 3000 and send process environment in return to http GET request:
const express = require('express')
const os = require('os')
function printObject(o) {
let out = '';
for (let p in o) {
out += p + ': ' + o[p] + '\n';
}
return out;
}
const app = express()
app.get('/', (req, res) => {
let r = 'Environment of ' + os.hostname + ':\n';
r += printObject(process.env)
res.send(r)
})
const port = process.env.BLOG_APP_SVC_SERVICE_PORT
app.listen(port, () => console.log(`listening on port ${port}`))
Deployment
Next - to make our app to the Kubernetes - we define deployment (dep.yml
):
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: blog-app
name: blog-depl
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
name: blog-app
template:
metadata:
labels:
name: blog-app
spec:
containers:
- name: blog-app
image: k8admin:5000/blog-app:latest
command: ["node", "index.js"]
ports:
- containerPort: 3000
env:
- name: SOME_ENV
value: some-value
- name: OTHER_ENV
value: OTHER-value
---
apiVersion: v1
kind: Service
metadata:
name: blog-app-svc
namespace: blog
spec:
type: NodePort
selector:
name: blog-app
ports:
- port: 3000
targetPort: 3000
nodePort: 30001
Some explanation for definition above:
kind: Deployment
- the kind of object to be defined
metadata.name
- the name of deployment
metadata.namespace
- the namespace where deployment and all of its subobjects are to be placed. To create blog
namespace execute kubectl create namespace blog
before yor create deployment. Using namespaces makes live easier. In case of this blog I remove blog
namespace to delete all its objects before moving to next scenario.
replicas
- sets the number of our app instances
labels
and selectors
are mechanisms to search objects in the cluster
containers
- definition of application containers
name
- distinguishing name of container
image
- tag of the image
command
- command to run on the container in array manner
ports.containerPort
- port number to be exposed from the container; in our case it is the same port that was set in index.js
.
env
- some environment variables to be passed to the container
---
- object separator in multiobject yaml definition file
kind: Service
- definition of service object; Service is K8s mechanism to enable communication to application
type: NodePort
- the kind of service, that exposes static port on each cluster's node
port
- internal service port
targetPort
- the port of the container; usually the port
and targetPort
have the same value
nodePort
- the port to be exposed on cluster node
Now we are ready deploy the app:
$ kubectl apply -f dep.yml
deployment.apps/blog-depl created
service/blog-app-svc created
Success! To 'taste' it let's invoke our app. First we need to determine to which cluster node it has been deployed (remember, we've chosen to run only 1 copy of the app):
$ kubectl get pods -n blog -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
blog-depl-6bd7865df7-t5pmh 1/1 Running 0 9m56s 10.40.0.2 k8work1 <none> <none>
The app is running on k8work1 node. Let's send it http GET
request:
$ curl -X GET k8work1:30001
Environment of blog-depl-6bd7865df7-t5pmh:
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME: blog-depl-6bd7865df7-t5pmh
SOME_ENV: some-value
OTHER_ENV: OTHER-value
KUBERNETES_PORT_443_TCP_ADDR: 10.96.0.1
BLOG_APP_SVC_SERVICE_HOST: 10.106.234.15
KUBERNETES_SERVICE_HOST: 10.96.0.1
KUBERNETES_SERVICE_PORT_HTTPS: 443
KUBERNETES_PORT: tcp://10.96.0.1:443
BLOG_APP_SVC_PORT_3000_TCP_PORT: 3000
KUBERNETES_PORT_443_TCP: tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP_PROTO: tcp
BLOG_APP_SVC_PORT_3000_TCP_PROTO: tcp
BLOG_APP_SVC_PORT_3000_TCP_ADDR: 10.106.234.15
BLOG_APP_SVC_SERVICE_PORT: 3000
BLOG_APP_SVC_PORT: tcp://10.106.234.15:3000
BLOG_APP_SVC_PORT_3000_TCP: tcp://10.106.234.15:3000
KUBERNETES_SERVICE_PORT: 443
KUBERNETES_PORT_443_TCP_PORT: 443
NODE_VERSION: 15.4.0
YARN_VERSION: 1.22.5
HOME: /root
In return - as expected - we have got environment of the container. It is worth notice that - besides of env we have defined in deployment (SOME_ENV
, OTHER_ENV
) - there are several variables injected by Kubernetes. An application can use them for its own configuration. Because we defined the same port value for the service and the container, we can use BLOG_APP_SVC_SERVICE_PORT
value in index.js
. So instead of
const port = 3000
we can set
const port = process.env.BLOG_APP_SVC_SERVICE_PORT
and this way move port number from application code to K8s service definition.
To make calling cloud app easier we assembly the command:
curl -X GET "`kubectl get pods -n blog --selector=name=blog-app --field-selector status.phase=Running --template '{{range .items}}{{ if not .metadata.deletionTimestamp }}{{.spec.nodeName}}{{end}}{{end}}'`:30001"
As you can see it filters the name of pod running in blog
namespace and uses it to build curl
parameter (thanks to https://github.com/kubernetes/kubectl/issues/450#issuecomment-706677565). Let's make it bash
function:
cg () {
curl -X GET "`kubectl get pods -n blog --selector=name=blog-app --field-selector status.phase=Running --template '{{range .items}}{{ if not .metadata.deletionTimestamp }}{{.spec.nodeName}}{{end}}{{end}}'`:30001"/"$1";
}
Now we can call our cloud app by simple cg
command.
ConfigMaps
Now that we have our test cloud running (and verified passing environment variables) we can move to ConfigMaps. ConfigMaps are common, native way to pass non sensitive information to container.
To cleanup test environment delete and recreate blog
namespace.
From text file
Let's have a text file, just like this file.cfg
:
This is theoretical configuration file
being injected to container.
To convert it to ConfigMap we use command:
$ kubectl create cm -n blog file-cm --from-file=./file.cfg
configmap/file-cm created
Parameter --from-file
indicates that all file content is to be copied to ConfigMap we named file-cm
.
and created ConfigMaps looks like:
$ kubectl get cm -n blog file-cm -o yaml
apiVersion: v1
data:
file.cfg: |
This is theoretical configuration file
being injected to container.
kind: ConfigMap
metadata:
creationTimestamp: "2021-01-20T13:42:02Z"
managedFields:
- apiVersion: v1
fieldsType: FieldsV1
fieldsV1:
f:data:
.: {}
f:file.cfg: {}
manager: kubectl
operation: Update
time: "2021-01-20T13:42:02Z"
name: file-cm
namespace: blog
resourceVersion: "43679504"
selfLink: /api/v1/namespaces/blog/configmaps/file-cm
uid: 0aea8662-b765-4113-ba69-be821f9f83f7
Next prepare deployment to use ConfigMap:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: blog-app
name: blog-depl
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
name: blog-app
template:
metadata:
labels:
name: blog-app
spec:
containers:
- name: blog-app
image: k8admin:5000/blog-app:latest
command: ["node", "index.js"]
ports:
- containerPort: 3000
volumeMounts:
- name: blog-vol
mountPath: /etc/blog.cfg
volumes:
- name: blog-vol
configMap:
name: file-cm
---
apiVersion: v1
kind: Service
metadata:
name: blog-app-svc
namespace: blog
spec:
type: NodePort
selector:
name: blog-app
ports:
- port: 3000
targetPort: 3000
nodePort: 30001
What changed:
- new element
volumes
consists info of resources for the deployment
configMap.name
- indicates the resource is ConfigMap with given name
volumeMounts
describes how resources are to be mapped to blog-app
container
name
indicates volume
- the source of data
mountPath
tells kubernetes where to mount the resource
We want our app to read ConfigMap and see what will be read, so we need to modify its source (index.js
):
const os = require('os')
const express = require('express')
const fs = require('fs')
const app = express()
app.get('/', (req, res) => {
let r = 'Config map content from ' + os.hostname + ':\n'
r += fs.readFileSync('/etc/blog.cfg/file.cfg', 'utf8')
res.send(r)
})
const port = process.env.BLOG_APP_SVC_SERVICE_PORT
app.listen(port, () => console.log(`listening on port ${port}`))
Now we rebuild the app, deploy new version and execute
cg
to see how it works:
$ cg
Config map content from blog-depl-659bfdd9c5-2pnbv:
This is theoretical configuration file
being injected to container.
Works fine. But what's all this for? Coudn't we just copy config to the image and forget about ConfigMaps? Sure we could, but the whole thing is that with ConfigMap we can update data without need to rebuild the image or even to restart the pod. After update of ConfigMap definition its corresponding container value is updated automatically. Let's update the ConfigMap:
$ echo "And now it's updated." >> file.cfg
$ kubectl create cm -n blog file-cm --from-file=./file.cfg --dry-run=client -o yaml | kubectl apply -f -
The latter command thanks to --dry-run=client
parameter generates yaml with updated config map definition and then applies it. This way existing ConfigMap is actually updated.
After some time (needed to update cached values) query the app:
$ cg
Config map content from blog-depl-659bfdd9c5-2pnbv:
This is theoretical configuration file
being injected to container.
And now it's updated.
The app sees updated ConfigMap without need to be restarted.
From key-value
Let some.env
have simple env like key-value content:
blog_env=some_value
blog_env2=another_value
We can convert it into ConfigMap with command:
$ kubectl create cm -n blog env-cm --from-env-file=./some.env -o yaml
apiVersion: v1
data:
blog_env: some_value
blog_env2: another_value
kind: ConfigMap
metadata:
creationTimestamp: "2021-01-27T12:31:15Z"
managedFields:
- apiVersion: v1
fieldsType: FieldsV1
fieldsV1:
f:data:
.: {}
f:blog_env: {}
f:blog_env2: {}
manager: kubectl
operation: Update
time: "2021-01-27T12:31:15Z"
name: env-cm
namespace: blog
resourceVersion: "45121359"
selfLink: /api/v1/namespaces/blog/configmaps/env-cm
uid: 3dbae9de-787d-4f5d-b564-8207840957c9
This type of ConfigMap can be mapped to container's environment with definition:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: blog-app
name: blog-depl
spec:
replicas: 1
selector:
matchLabels:
name: blog-app
template:
metadata:
labels:
name: blog-app
spec:
containers:
- name: blog-app
image: k8admin:5000/blog-app:latest
command: ["node", "index.js"]
ports:
- containerPort: 3000
env:
- name: blog_env
valueFrom:
configMapKeyRef:
name: env-cm
key: blog_env
- name: changed_env
valueFrom:
configMapKeyRef:
name: env-cm
key: blog_env2
Section env
defines environment variable blog_env
with value from ConfigMap env-cm
key blog-env
and variable changed_env
analogically.
To see if it works we switch back to returning environment version of index.js
, rebuild the app, deploy new version and execute cg
:
$ cg | grep env
blog_env: some_value
changed_env: another_value
There is some weakness of this type of ConfigMap: corresponding container's values are not updated with map change automatically. To see modified ConfigMap value deployment has to be restarted:
$ kubectl rollout restart deployment -n blog blog-depl
Secrets
When it comes to sensitive data such as password ConfigMaps may not be secure enough.
Instead of them K8s offers Secrets. Just like ConfigMaps Secrets can be created from file or from literal. We have not tried creating ConfigMap from literal, so let's do this with Secrets:
$ kubectl create secret generic lit-sec --from-literal=pass=pass-value -o yaml
apiVersion: v1
data:
pass: cGFzcy12YWx1ZQ==
kind: Secret
metadata:
creationTimestamp: "2021-01-27T15:11:01Z"
managedFields:
- apiVersion: v1
fieldsType: FieldsV1
fieldsV1:
f:data:
.: {}
f:pass: {}
f:type: {}
manager: kubectl
operation: Update
time: "2021-01-27T15:11:01Z"
name: lit-sec
namespace: blog
resourceVersion: "45144690"
selfLink: /api/v1/namespaces/blog/secrets/lit-sec
uid: fd4c7427-2e47-43c6-8bf6-4d12366bd10f
type: Opaque
As you can see the value of created secret is not explicit but base64-encoded - so still not secure.
To access secret from container we need to mount it:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: blog-app
name: blog-depl
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
name: blog-app
template:
metadata:
labels:
name: blog-app
spec:
containers:
- name: blog-app
image: k8admin:5000/blog-app:latest
command: ["node", "index.js"]
ports:
- containerPort: 3000
volumeMounts:
- name: sec-vol
mountPath: /etc/secrets
volumes:
- name: sec-vol
secret:
secretName: lit-sec
The value of the secret is going to be available in /etc/secrets/pass
, so we need to modify get
method in our index.js
:
app.get('/', (req, res) => {
let r = 'Secret value from ' + os.hostname + ':\n'
r += fs.readFileSync('/etc/secrets/pass', 'utf8')
res.send(r)
})
rebuild, restart and try with cg
:
$ cg
Secret value from blog-depl-84d59fd8b8-5sr7r:
pass-value
As the secret is mounted, its value is propagated to container automatically:
$ kubectl create secret generic lit-sec -n blog --dry-run=client \
> --from-literal=pass=new-pass-value -o yaml | kubectl apply -f -
secret/lit-sec configured
After short time:
$ cg
Secret value from blog-depl-84d59fd8b8-5sr7r:
new-pass-value
Limiting access to secret
One way to protected sensitive data is to limit access to secret. It can be done by setting access mode
for certain item and configuring securityContext for the container. First let's define secret with two keys:
kubectl create secret generic -n blog lit-sec --from-literal=pass=pass-value --from-literal=user=user-value
Next set up deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: blog-app
name: blog-depl
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
name: blog-app
template:
metadata:
labels:
name: blog-app
spec:
containers:
- name: blog-app
image: k8admin:5000/blog-app:latest
command: ["node", "index.js"]
ports:
- containerPort: 3000
volumeMounts:
- name: sec-vol
mountPath: /etc/secrets
securityContext:
runAsUser: 1000
allowPrivilegeEscalation: false
volumes:
- name: sec-vol
secret:
secretName: lit-sec
items:
- key: pass
mode: 0400
path: pass
- key: user
mode: 0444
path: user
Finally modify index.js
so we could get user
and pass
separately:
app.get('/user', (req, res) => {
var r = 'User value from ' + os.hostname + ':\n'
r += fs.readFileSync('/etc/secrets/user', 'utf8')
res.send(r)
})
app.get('/pass', (req, res) => {
var r = 'User value from ' + os.hostname + ':\n'
r += fs.readFileSync('/etc/secrets/pass', 'utf8')
res.send(r)
})
When we issue cg user
we get user-value
, but when we want to get cg pass
an error EACCES: permission denied, open '/etc/secrets/pass'
occurs. This is because we defined mode: 0400
so only root
can read /etc/secrets/pass
and set containers' user id to 1000
indicating it should be run as regular user.
Disadvantages of Secrets
Despite its name Secrets are not secured by default. They are held non-ecrypted in Kubernetes etcd
(which is storage mechanism for K8s cluster), so anyone who has access to etcd
can know the Secrets. Fortunately encryption of Secrets at rest can be enabled using --encryption-provider-config
of kube-apiserver
and Key Management Service (KMS).
Still, anyone who can create any pod that uses a secret can know secret value. Disturbing, isn't it? This and other disadvantages of Secrets are described in theirs documentation.
HashiCorp Vault
One of solutions for safe storage and management of sensitive data is HashiCorp Vault. Vault can be setup standalone or can be deployed to K8s cluster. In this article we will use standalone Vault and access it from K8s container using REST API.
To install Vault on Centos we simply follow the documentation.
Then we start Vault Server in development configuration by issuing:
$ vault server -dev -dev-root-token-id root -dev-listen-address '10.92.29.12:8200'
Parameters:
- 'dev' - tells Vault to run in development mode - it is unsealed and uses volatile memory storage. Normally Vault Server starts in sealed state, where it knows its storage but does not know how to decrypt it. To unseal the Vault one must know master key.
- 'dev-root-token-id' - sets the token which allows to authenticate to Vault to value
root
. Otherwise Vault would generate random root token that we would have to remember.
- 'dev-listen-address' - tells Vault to use certain interface and address. By default Vault in dev mode runs on '127.0.0.1' and may not be accessible from outside of the the host.
Export VAULT_ADDR='http://10.92.29.12:8200'
and VAULT_TOKEN=root
to configure your terminal session.
Let's add some secrets to our brand new Vault Server. First enable secret engine:
$ vault secrets enable -path=blog -version=2 kv
This turns on secrets key-value engine version 2 (with history) on blog
path. Then add some secret:
$ vault kv put blog/entry some=thing
Key Value
--- -----
created_time 2021-01-28T15:44:10.978011886Z
deletion_time n/a
destroyed false
version 3
Secret revealed its secret: it's third time I've set its value 😉
Now we can retrieve secret from Vault using both vault
command:
$ vault kv get blog/entry
====== Metadata ======
Key Value
--- -----
created_time 2021-01-28T15:44:10.978011886Z
deletion_time n/a
destroyed false
version 3
==== Data ====
Key Value
--- -----
some thing
and REST API:
$ curl -s -X GET --header "X-Vault-Token: $VAULT_TOKEN" $VAULT_ADDR/v1/blog/data/entry | jq -r '.data.data.some'
thing
Accessing Vault from K8s cluster
To make our Vault available from cluster we define service without a pod selector.
apiVersion: v1
kind: Service
metadata:
namespace: blog
name: external-vault
labels:
name: blog-app
spec:
ports:
- protocol: TCP
port: 80
To define how external-vault
maps to network address we add an Endpoint:
apiVersion: v1
kind: Endpoints
metadata:
name: external-vault
namespace: blog
labels:
name: blog-app
subsets:
- addresses:
- ip: 10.92.29.12
ports:
- port: 8200
These two combined tell kubernetes that service external-vault
port 80
is to be redirected to ip 10.92.29.12
port 8200
.
Now let's assume that for some abstract reason we would like to forbid reading our secret
more than once. Vault offers Policies to limit access to its object (file ro.hcl
):
path "blog/*" {
capabilities = ["read","list"]
}
We write policy to Vault with command:
$ vault policy write blog-ro ro.hcl
Success! Uploaded policy: blog-ro
and we create access token:
$ vault token create -use-limit 1 -policy blog-ro
Key Value
--- -----
token s.sa1RaGpfFPeCTYEkc5q6YsOY
token_accessor sxGyEDegi1DGNUzmp9Ev0jZT
token_duration 768h
token_renewable true
token_policies ["blog-ro" "default"]
identity_policies []
policies ["blog-ro" "default"]
Token defined this way allows only to read our secret and only to do it once.
Let's try it with our app. New deployment (service blog-app-svc
remains unchanged):
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: blog-app
name: blog-depl
namespace: blog
spec:
replicas: 1
selector:
matchLabels:
name: blog-app
template:
metadata:
labels:
name: blog-app
spec:
containers:
- name: blog-app
image: k8admin:5000/blog-app:latest
command: ["node", "index.js"]
ports:
- containerPort: 3000
env:
- name: VAULT_PATH
value: "/v1/blog/data/entry"
- name: VAULT_TOKEN
value: "s.sa1RaGpfFPeCTYEkc5q6YsOY"
passes to container blog-app
path to secret and one-time token to authorize the request. I know passing token via environment is not the best way, but this is just for example.
index.js
has to be modified to access secret via external service:
const express = require('express')
const os = require('os')
function printObject(o) {
let out = '';
for (let p in o) {
out += p + ': ' + o[p] + '\n';
}
return out;
}
const http = require('http')
const options = {
hostname: 'external-vault',
path: process.env.VAULT_PATH,
headers: {
'X-Vault-Token': process.env.VAULT_TOKEN
}
}
function requestCall() {
return new Promise((resolve, reject) => {
http.get(options, (response) => {
let chunks = [];
response.on('data', (fragments) => {
chunks.push(fragments);
});
response.on('end', () => {
let body = Buffer.concat(chunks);
resolve(body.toString());
});
response.on('error', (error) => {
reject(error);
});
});
});
}
async function exGet(req, res) {
let r = 'Vault secret from ' + os.hostname + ':\n';
prom = requestCall();
try {
aw = await prom;
let j = JSON.parse(aw);
r += printObject(j.data.data);
res.send(r);
}
catch(e){
res.send(e);
}
}
const app = express();
app.get('/', exGet);
const port = process.env.BLOG_APP_SVC_SERVICE_PORT
app.listen(port, () => console.log(`listening on port ${port}`))
By the way, code above became more complicated because javascript is designed as asynchronous and we want to get response synchronously.
Again build the app, deploy it and execute
cg
:
Vault secret from blog-depl-dfc994588-r8c4v:
some: thing
We've got out secret. But when we try to get it once more:
$ cg
{}
empty object is returned. The token was one-time only.
There is so much more you can achive with Vault and Kubernetes, starting with deploying in Vault to cluster, through injecting secrets into pods using sidecar, to advanced scenarios like those presented here.
That's it for now. This article has already gone too long 😉