2022.01.13
Streamlining Local Development on Kubernetes
こんにちは、次世代システム研究室のN.M.です。
Introduction
Firstly, you probably shouldn’t be developing your app on Kubernetes locally.
If possible, use a more lightweight solution, such as Telepresence for K8s to develop locally and interact with a remote cluster.
But, sometimes you need to test a Kubernetes configuration, and rather than use a remote Kubernetes provider, it’s faster, cheaper, and safer to do locally.
Here I test a non-trivial Kubernetes configuration locally.
One aspect of the configuration sets up Nginx and a rust Actix server connected using a Unix Domain Socket (UDS). This is usually faster than connecting through a TCP socket.
Another aspect of the configuration shows how to use Persistent Volumes to support a DB running within K8s.
Our K8s cluster should be easy to create and easy to delete when we are finished with it.
Initialization techniques and tools are discussed that make the process easier.
k3d
docker ps
To start a k3d cluster
k3d cluster create multichoice-dev -p "8888:80@loadbalancer" --registry-create k3d-registry
This creates a local single-node cluster called multichoice-dev. It sets up an LB for ingress that is reachable on the host through port 8888.
An image registry called k3d-registry is created. It runs on the host docker and is reachable from the host and from k3d. You can build and push images from the host and pull images from inside k3d when used in deployment manifests.
As described here, in order to have services reachable from the load balancer you need to set up a K8s ingress resource.
An example is given below
# apiVersion: networking.k8s.io/v1beta1 # for k3s < v1.19 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: multichoice annotations: ingress.kubernetes.io/ssl-redirect: "false" nginx.ingress.kubernetes.io/rewrite-target: /$2 spec: rules: - http: paths: - path: /api pathType: Prefix backend: service: name: actix-multichoice-svc port: number: 9999 - path: /db(/|$)(.*) pathType: Prefix backend: service: name: db-admin-svc port: number: 7777
To use HTTP for testing:
ingress.kubernetes.io/ssl-redirect
– disables the default behavior which redirects to the HTTPS when given an HTTP URL.
To map paths to different backend services:
nginx.ingress.kubernetes.io/rewrite-target: /$2
– will use the second regular expression match in a path, when invoking the backend service.
The example above uses the regular expression /db(/|$)(.*)
. When calling http://localhost:8888/db/pgAdmin, the ingress controller would call a backend such as http://db-admin-svc:7777/pgAdmin
.
To delete our k8s cluster and all its Docker containers, run the command below
❯ k3d cluster delete multichoice-dev
Handling Container Images
K8s is, of course, a container orchestration tool. So we are going to be building, tagging, pushing images from our host, and pulling images from our manifests inside k3d.
When we started the cluster we told k3d to set up a container registry, k3d-registry
, for this purpose.
❯ docker ps -f name=k3d-registry CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e5d8fb5dd9af registry:2 "/entrypoint.sh /etc…" 2 hours ago Up 2 hours 0.0.0.0:57505->5000/tcp k3d-registry
k3d-registry
maps a randomly generated host port, 57505
to the default port, 5000
on k3d.
- When accessing the registry from our host we will use
57505
. - When accessing from within K8s we will use
5000
.
To create an image and make it available to K8s we need to perform the following three steps on our host
- Build the image
❯ docker build -t dieselcli -f Dockerfile_diesel .
- Tag the image
❯ docker tag dieselcli localhost:57505/dieselcli
- Push the image
❯ docker push localhost:57505/dieselcli
To refer to this image from a manifest we would use the following inside a yaml resource manifest
image: k3d-registry:5000/dieselcli:latest
Automating container deployment
The 3 steps to create a new container image in the k3d-registry, as well as updating K8s manifests image reference can get tiring quickly. Luckily this can be automated. A tool that is specifically designed for this is Skaffold, developed by Google.
Skaffold uses a configuration file to tell it how to build and deploy images to K8s.
You can generate this file using the init command.
❯ skaffold init -f skaffold.yaml
This command will look for Dockerfiles (and other application builders), image references, and K8s manifest files, Kustomize files, or Helm files. It will then prompt the user to confirm how the images should be built and deployed. After receiving the answers it needs, skaffold
generates the config file.
Skaffold is quite smart about looking at the files in your project and analyzing their contents.
You may also make any necessary manual changes to skaffold.yaml
.
Skaffold discovers and uses the container registry (here k3d-registry
), generates new image tags and uses those tags to deploy the images to K8s.
Skaffold can be run in two modes watch mode or run mode. In watch mode, it watches for any changes to files referenced by your images. In run mode it runs once, builds and deploys, then exits.
- Run skaffold in watch mode
skaffold dev
It is smart enough to know what files are needed by the images. For example, with Docker builds, it looks inside Dockerfiles, to see which files it should watch. Change any of these files and skaffold will rebuild, re-tag, re-push, and re-deploy your images.
Type ctl-C
to quit skaffold in watch mode. Skaffold will clean up by returning the K8s cluster to the state it was in before.
- Run skaffold once to build and deploy images.
skaffold run --tail
The --tail
option outputs the container logs.
Initializing the Application
Sometimes you need to perform application initialization. We will be restarting K8s and redeploying our application multiple times, so it’s best to automate this.
The manifest segment below uses a initContainer
to perform such an initialization step. In this case, it creates a DB schema.
initContainers: - name: init-db image: k3d-registry:5000/dieselcli:latest command: ["diesel", "migration", "run"]
The image, k3d-registry:5000/dieselcli:latest
has already been built, pushed and deployed by skaffold.
Here is the Dockerfile
FROM rustlang/rust:nightly AS diesel_cli WORKDIR /app RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists/* && \ cargo install diesel_cli COPY --chown=1000:3000 migrations migrations USER 1000:3000 ENV DATABASE_URL=postgres://multichoice:postgres@multichoice-db/multix # RUN diesel setup CMD [ "sleep","100000" ]
The image provides a container with the diesel CLI (a CLI for the rust diesel ORM). initContainer
invokes the command diesel migration run
. This creates the application schema as we have defined it in our source directory. The command runs migration files (create table
SQL statements) under the migrations directory.
Now, whenever we restart k3d, k8s will handle creating the DB schema.
Creating the DB and using a Persistent Volume for Data
Creating a DB inside K8s has specific challenges. The data should be persistent across deployments, for this we use a Persistent Volume and Persistent Volume Claim.
To initialize the DB, we use a configMap
to an executable script. This automates DB initialization.
Here is the deployment manifest for my postgres DB
apiVersion: apps/v1 kind: Deployment metadata: name: multichoice-db spec: selector: matchLabels: app: multichoice-db template: metadata: labels: app: multichoice-db spec: volumes: - name: postgres-pv-storage persistentVolumeClaim: claimName: postgres-pvc - name: db-init configMap: name: db-init-conf defaultMode: 0777 items: - key: init-user-db.sh path: init-user-db.sh containers: - name: multichoice-db image: postgres:latest resources: limits: memory: "128Mi" cpu: "500m" ports: - containerPort: 5432 env: - name: POSTGRES_USER value: multichoice - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-password key: password - name: PGDATA value: /var/lib/postgresql/data/pgdata - name: POSTGRES_DB value: multichoice volumeMounts: - mountPath: /var/lib/postgresql/data name: postgres-pv-storage - mountPath: /docker-entrypoint-initdb.d/init-user-db.sh subPath: init-user-db.sh name: db-init
It uses a persistent volume mount for DB data files, postgres-pv-storage
.
It also uses a configMap volume mount for DB initialization, db-init-conf
. This gets mounted to the /docker-entrypoint-initdb.d/init-user-db.sh
path. The postgres image runs any scripts or SQL it finds in /docker-entrypoint-initdb.d/
as part of DB initialization.
Here is the init-user-db.sh
script
#!/bin/bash set -e psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL CREATE USER multix; CREATE DATABASE multix; GRANT ALL PRIVILEGES ON DATABASE multix TO multix; EOSQL
It’s a simple bash shell to create a database user and database.
The key point to note here is that I specify subPath: init-user-db.sh
. Without the subPath, /docker-entrypoint-initdb.d/init-user-db.sh
becomes a symbolic link to a separate file created by K8s. Then, when the postgres container tries to run the link, a permission denied
error occurs. Using subPath
fixes this problem.
Using a Unix Domain Socket for fast connection between Nginx and the App
There are times when you may want Nginx (or another webserver) to be in front of your application. You may need special features of Nginx, for example, if you have an API that is accessed directly from client browsers, you may need to handle CORS requests.
But, if possible you don’t want to have a TCP connection between Nginx and the app, rather you want a faster Unix Domain Socket (UDS) connection. To allow this on K8s, the Nginx and App containers must both be running on the same Pod, so they may share the socket file.
An example manifest configuration is shown below:
apiVersion: apps/v1 kind: Deployment metadata: name: actix-multichoice spec: selector: matchLabels: app: actix-multichoice template: metadata: labels: app: actix-multichoice spec: containers: - name: actix-multichoice image: k3d-registry:5000/actixmultichoice:latest resources: limits: memory: "128Mi" cpu: "500m" volumeMounts: - mountPath: /var/run/actix readOnly: false name: actix-uds - name: nginx image: k3d-registry:5000/nginx resources: requests: cpu: 100m memory: 100Mi limits: cpu: 500m memory: 500Mi ports: - containerPort: 9000 volumeMounts: - mountPath: /etc/nginx readOnly: true name: nginx-conf - mountPath: /var/run/actix readOnly: false name: actix-uds livenessProbe: exec: command: - sh - -c - curl --fail http://localhost:9000/tests initialDelaySeconds: 10 periodSeconds: 3 readinessProbe: exec: command: - sh - -c - curl --fail http://localhost:9000/tests initialDelaySeconds: 6 periodSeconds: 3 initContainers: - name: init-db image: k3d-registry:5000/dieselcli:latest command: ["diesel", "migration", "run"] volumes: - name: nginx-conf configMap: name: actix-nginx-conf items: - key: nginx.conf path: nginx.conf - name: actix-uds emptyDir: {} securityContext: runAsUser: 1000 runAsGroup: 3000 fsGroup: 2000
This defines two containers, nginx
and actix-multichoice
. These two containers talk to each other via a UDS, /var/run/actix/actix.sock
. This file is accessed by a shared emptyDir
volume, actix-uds
.
Using securityContext
ensures Nginx and actix are both run as the same user and group. This ensures, the UDS file /var/run/actix/actix.sock
is readable and writable by both processes.
The nginx
container also uses a configMap to initialize its configuration, nginx.conf
. This is mounted as readOnly
. This is a standard Nginx configuration file.
We’ve already talked about the initContainers
section.
Skaffold output for our App
Here is the output produced by skaffold run --tail
on the deployment described above:
❯ skaffold run --tail Generating tags... - k3d-registry:5000/actixmultichoice -> k3d-registry:5000/actixmultichoice:ae728e8-dirty - k3d-registry:5000/dieselcli -> k3d-registry:5000/dieselcli:ae728e8-dirty - k3d-registry:5000/nginx -> k3d-registry:5000/nginx:ae728e8-dirty Checking cache... - k3d-registry:5000/actixmultichoice: Found Locally - k3d-registry:5000/dieselcli: Found Locally - k3d-registry:5000/nginx: Found Locally Starting test... Tags used in deployment: - k3d-registry:5000/actixmultichoice -> k3d-registry:5000/actixmultichoice:dcb40d96af9503c15bb18328311f7e5bac11a204f02cec95a07fb05e46d81690 - k3d-registry:5000/dieselcli -> k3d-registry:5000/dieselcli:2cf408c54e30f93d88542feb2041e21eea4de5deeee2f8f730585f4c8adc4e6c - k3d-registry:5000/nginx -> k3d-registry:5000/nginx:a82fb99d455cd8fe10ca6ecda5d72f78c081763b7524986fe4ae9a4ce997aa7b Starting deploy... Loading images into k3d cluster nodes... - k3d-registry:5000/actixmultichoice:dcb40d96af9503c15bb18328311f7e5bac11a204f02cec95a07fb05e46d81690 -> Loaded - k3d-registry:5000/dieselcli:2cf408c54e30f93d88542feb2041e21eea4de5deeee2f8f730585f4c8adc4e6c -> Loaded - k3d-registry:5000/nginx:a82fb99d455cd8fe10ca6ecda5d72f78c081763b7524986fe4ae9a4ce997aa7b -> Loaded Images loaded in 2 minutes 53.749 seconds - configmap/actix-nginx-conf-mg4b44m6k5 created - service/actix-multichoice-svc created - deployment.apps/actix-multichoice created Waiting for deployments to stabilize... - deployment/actix-multichoice is ready. Deployments stabilized in 3.316 seconds Press Ctrl+C to exit [nginx] /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration [nginx] /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/ [nginx] /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh [nginx] 10-listen-on-ipv6-by-default.sh: info: /etc/nginx/conf.d/default.conf is not a file or does not exist [nginx] /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh [nginx] /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh [nginx] /docker-entrypoint.sh: Configuration complete; ready for start up [actix-multichoice] [2022-01-11T06:23:29Z INFO actix_server::builder] Starting 1 workers [actix-multichoice] [2022-01-11T06:23:29Z INFO actix_server::builder] Starting "actix-web-service-"/var/run/actix/actix.sock"" service on "/var/run/actix/actix.sock" (pathname) [init-db] Running migration 2021-11-06-082934_add_tests [init-db] Running migration 2021-11-07-070724_add_answers [actix-multichoice] [2022-01-11T06:52:56Z INFO actix_web::middleware::logger] - "GET /api/tests HTTP/1.0" 200 2 "-" "curl/7.64.0" 0.001773 [actix-multichoice] [2022-01-11T06:52:57Z INFO actix_web::middleware::logger] - "GET /api/tests HTTP/1.0" 200 2 "-" "curl/7.64.0" 0.001341 [nginx] 127.0.0.1 - - [11/Jan/2022:06:52:57 +0000] "GET /api/tests HTTP/1.1" 200 2 "-" "curl/7.64.0" [nginx] 127.0.0.1 - - [11/Jan/2022:06:53:06 +0000] "GET /api/tests HTTP/1.1" 200 2 "-" "curl/7.64.0"
- image tags for
actixmultichoice
,dieselcli
,nginx
have been created and loaded into the k3d node nginx
is initializedactix-multichoice
is listening to the/var/run/actix/actix.sock
UDSinit-db
has set up our toy DB schemaactix-multichoice
is processing the readiness and liveness probesnginx
is also processing the readiness and liveness probes- Liveness and readiness probe configuration are provided above. These allow K8s to do zero-downtime deploys. Also, as you can see, they provide quick confirmation that the app is working properly.
Kustomize
Kustomize provides a lot of advantages over vanilla K8s manifests such as configurations easily customizable for your target environments. It can easily do things such as injecting custom namespace per environment.
I generally like to use it because it adds little complexity and allows easy customizations. Currently, I don’t have a production environment, but by using kustomize, with structured configuration directories, I know I can more easily create a production-ready environment.
As already stated, skaffold can understand and use kustomize files, but if I want to deploy containers that do not use my k3d-registry
, I can simply use kustomize directly:
kubectl apply -k deploy/local/db
kubectl also understands kustomize files.
deploy/local/db
is a path to these files:
deploy/local/db ├── db-admin-deployment.yaml ├── db-admin-service.yaml ├── db-deployment.yaml ├── db-service.yaml ├── init-user-db.sh ├── kustomization.yaml ├── local-path-storage.yaml ├── pv.yaml ├── pvc.yaml └── secret.yaml
kustomization.yaml
is the kustomize configuration file. The syntax is powerful but here, it just tells kustomize what manifests to use and creates a configMap for DB initialization:
--- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization configMapGenerator: - name: db-init-conf files: - init-user-db.sh resources: - local-path-storage.yaml - secret.yaml - pv.yaml - pvc.yaml - db-deployment.yaml - db-service.yaml - db-admin-deployment.yaml - db-admin-service.yaml
Kustomize and ConfigMaps
ConfigMaps allow you to define configurations used in K8s. In this post, Nginx is configured using a configMap, and DB initialization also uses a configMap.
Changing configMap contents does not automatically trigger a reload of containers referencing that configMap.
When testing an Nginx configuration, you probably will want to try multiple versions of a config until you get one that works properly. Automatic reloading of containers referencing configMaps is provided by using a Kustomize configMaps generator
Kustomize configMaps generator renames any new configMap
with a unique name and updates any references. So, any containers referencing the configMap will be reloaded automatically by K8s.
Visual Studio Code Kubernetes Tools
Microsoft’s Kubernetes extension provides some time-saving tools when working with K8s.
Install the extension, then view Kubernetes extension commands in the Command Palette: ⇧⌘P Kubernetes:
Here are the 3 commands I use most frequently
- Set namespace to use for subsequent commands
Kubernetes: Use Namespace
- View container logs
Kubernetes: Logs
- Open container terminal
Kubernetes: Terminal
I personally find the Kubernetes extension UI much easier to use than kubectl
.
In addition, Kubernetes Tools provides useful snippets to create much of the skeleton code required for K8s manifest yaml files. For example to easily create a deployment
resource, simply create a new file in vs code, type deployment, and select the Kubernetes Deployment option as prompted. A basic configuration template for a deployment
resource will be created.
Docker Multi-stage builds
Multi-stage builds allow rebuilding only what needs to be rebuilt, thereby making image creation faster.
Docker multi-stage builds allow the separation of the image build into separate stages. Each stage is only rebuilt as needed thereby saving time on the overall build. For example, your app image may be separated into stages to build a base image, download and install library dependencies, and compile source.
If you only change your application source code, you don’t want to download all your dependencies again.
An example multi-stage build Dockerfile is below:
FROM lukemathwalker/cargo-chef:latest-rust-1.56.0 AS chef WORKDIR /app FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder COPY --from=planner /app/recipe.json recipe.json # Build dependencies - this is the caching Docker layer! RUN cargo chef cook --recipe-path recipe.json # Build application COPY . . RUN cargo build # We do not need the Rust toolchain to run the binary! FROM debian:bullseye-slim AS runtime RUN apt-get update RUN apt-get install -y libpq-dev ENV DATABASE_URL=postgres://multichoice:postgres@multichoice-db/multix COPY --chown=1000:3000 --from=builder /app/target/debug/actix-multichoice /usr/local/bin USER 1000:3000 CMD [ "/usr/local/bin/actix-multichoice" ]
There are 3 container images being created here: chef
, builder
, and runtime
.
Each image only gets rebuilt if the files it depends on are changed. Here I am using chef for rust docker builds. Chef is a tool to speed up docker multi-stage builds.
chef
computes the recipe file, builder
caches our dependencies and builds the binary, runtime
is our runtime environment.
Summary
Ideas discussed to streamline developing locally with K8s:
- k3d because it’s lightweight and can easily create and delete clusters
- skaffold to handle the development lifecycle for your containers
- Know your containers, they may provide ways to do initialization, such as the postgres container initialization script used here
- initContainers to do application initialization
- liveness probes and readiness probes to quickly verify your application is functioning correctly
- VS Code Kubernetes extension commands to view logs, run bash on your containers etc
- kustomize configMap generator to automate dependent container reloading
- docker multi-stage builds to speed up build times
次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。インフラ設計、構築経験者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD