k8s Operators to power your Starfleet
Make Things That Matter
100% Remote
HQ in Minneapolis
Strategy, Design, Development
also...
Support & Maintenance - Drupal & Wordpress
Flight Deck - k8s powered hosting & local dev
Just another day in Engineering
Not a product...an API...or a library.
A resident, privileged container...
...which watches for CRDs, and performs actions
Model the structure needed for you app
Not what's needed to run it on k8s
A framework to create operators
Open source, sponsored by Red Hat
Homebrew (macOS), Github release
Homebrew (macOS), Github release
Creating a project
operator-sdk init --domain example.com --plugins x,y,z
Not (necessarily) a web domain
Namespace for API definitions (kinds)
Shorter is better
kind.group.domain.tld
domain is your org
group is your app
domain is your org
group is your app
The runtime env for your operator
Go, Helm, Ansible
Most flexible, but most difficult
Best for new, from-scratch operators
Easiest, but least flexible
Add CRD support to existing chart
More flexible than Helm,
less complex than Go
Interpreted, not compiled
Built-in modules for k8s, DBs, Cloud, and more
Interpreted, not compiled
Built-in modules for k8s, DBs, Cloud, and more
Port existing CI scripts to CRDs
Simpler apps which need stateful changes
path/to/my-operator/src
Aids in build automation
path/to/my-operator/src
├── config/
├── Dockerfile
├── Makefile
├── molecule/
├── playbooks/
├── PROJECT
├── requirements.yml
├── roles/
└── watches.yaml
path/to/my-operator/src
├── config/ <-- CRDs, Schema, Samples, etc.
├── Dockerfile
├── Makefile
├── molecule/
├── playbooks/
├── PROJECT
├── requirements.yml
├── roles/
└── watches.yaml
path/to/my-operator/src
├── config/
├── Dockerfile <-- Operator Dockerfile
├── Makefile
├── molecule/
├── playbooks/
├── PROJECT
├── requirements.yml
├── roles/
└── watches.yaml
path/to/my-operator/src
├── config/
├── Dockerfile Ansible files
├── Makefile -------------
├── molecule/ <-- tests
├── playbooks/ <-- source
├── PROJECT
├── requirements.yml <-- dependencies
├── roles/ <-- more source
└── watches.yaml
path/to/my-operator/src
├── config/
├── Dockerfile
├── Makefile
├── molecule/
├── playbooks/
├── PROJECT
├── requirements.yml
├── roles/
└── watches.yaml <-- SDK configuration
Creating an API
operator-sdk create api --group myApp --kind myKind --generate-role
watches.yaml
* --generate-role only
One for each API
config/crd/bases/
Based on OpenAPI v3 schema
"Accept all" schema by default
Based on OpenAPI v3 schema
"Accept all" schema by default
Based on OpenAPI v3 schema
"Accept all" schema by default
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: mysqlclusters.myapp.myorg.tld
spec:
group: myapp.myorg.tld
names:
kind: MySQLCluster
listKind: MySQLClusterList
plural: mysqlclusters
singular: mysqlcluster
scope: Namespaced
versions:
- name: v1
schema:
openAPIV3Schema:
description: MySQLCluster is the Schema for the mysqlclusters API
properties:
apiVersion:
description: 'APIVersion defines the versioned schema of this representation
of an object. Servers should convert recognized schemas to the latest
internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
type: string
kind:
description: 'Kind is a string value representing the REST resource this
object represents. Servers may infer this from the endpoint the client
submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
type: string
metadata:
type: object
spec:
description: Spec defines the desired state of MySQLCluster
type: object
x-kubernetes-preserve-unknown-fields: true
status:
description: Status defines the observed state of MySQLCluster
type: object
x-kubernetes-preserve-unknown-fields: true
type: object
served: true
storage: true
subresources:
status: {}
(Scroll down)
Writing the role
Operator relies on service account, role
path/to/my-operator/src
└── config
└── crd
├── role_binding.yaml
├── role.yaml
└── service_account.yaml
Work with your CRDs,
and common k8s defs...but not configmaps.
Work with your CRDs,
and common k8s defs...but not configmaps.
Work with your CRDs,
and common k8s defs...but not configmaps.
CRD defintion created, deleted, edited
Passed as Ansible vars
{{ ansible_operator_meta.name }}
{{ ansible_operator_meta.namespace }}
Differs in older SDK versions
Contents exported as top-level Ansible vars
CamelCase
→ snake_case
apiVersion: myapp.myorg.tld/v1
kind: MySQLCluster
metadata:
name: mysqlcluster-sample
spec:
mysqlAdminSecret: mysql-admin #
mysqlReadersSecret: mysql-reader # Note the CamelCase
size: "20Gi" #
---
- name: Get the spec variables
debug:
msg: |
{{ ansible_operator_meta.name }}
{{ ansible_operator_meta.namespace }}
{{ mysql_admin_secret }} #
{{ mysql_readers_secret }} # snake_case here
{{ size }} #
k8s
module
Local cluster already authenticated
---
- name: Create mysql writer service
k8s:
definition: |
---
apiVersion: v1
kind: Service
metadata:
name: {{ ansible_operator_meta.name }}
labels:
app.kubernetes.io/managed-by: "my-operator"
app.kubernetes.io/created-by: "my-manager"
spec:
clusterIP: None
ports:
- name: mysql
port: {{ mysql_port | default('3306') }}
protocol: TCP
selector:
app.kubernetes.io/name: "mysql"
mysql-cluster: "{{ ansible_operator_meta.name }}"
namespace: "{{ ansible_operator_meta.namespace }}"
(Scroll down)
Set in Makefile
IMG ?= https://registry.example.com/my-operator:x.y.z
Set in Makefile
IMG ?= https://registry.example.com/my-operator:x.y.z
make docker-build
Builds and tags the image
docker login
make docker-push
make deploy
Applies to default authorized cluster
YAML in config/samples/
Edit, then kubectl apply
Creates new k8s objects
Deployments, StatefulSets, Pods, etc.
Creates new k8s objects
Deployments, StatefulSets, Pods, etc.
Creates new k8s objects
Deployments, StatefulSets, Pods, etc.
Validates current config against operator
Must not have any changed
Validates current config against operator
Must not have any changed
kubectl edit
the CRD
status.ansibleResult
section
apiVersion: flightdeck.t7.io/v1
kind: MySQLCluster
...
status:
conditions:
- ansibleResult:
changed: 0
completion: 2021-07-19T15:57:27.258505
failures: 1
ok: 9
skipped: 6
lastTransitionTime: "2021-07-19T15:57:27Z"
message: unknown playbook failure
reason: Failed
status: "False"
type: Failure
(Scroll down)
kubectl logs -f my-operator-pod -c manager
Useful for debugging
kubectl logs -f my-operator-pod -c manager
Useful for debugging
--------------------------- Ansible Task StdOut -------------------------------
TASK [Work with the writer] ********************************
fatal: [localhost]: FAILED! => {"reason": "Could not find or access '/tmp/ansible-operator/runner/mykind.myapp.tld/v1/MySQLCluster/my-namespace/mysqlcluster-sample/project/writer.yml' on the Ansible Controller."}
-------------------------------------------------------------------------------
(Scroll right)
make undeploy
Mind your finalizers!
Stateful changes
On-cluster applications
External APIs
No k8s defs created, so not needed
k8s
lookup
Local cluster already authenticated
- name: Get CRD
set_fact:
_crd: "{{ lookup('k8s', kind='my-kind', namespace='my-namespace', resource_name=_var_with_name)}}"
(Scroll right)
Inline
Task-level vars
set_fact
task
Returns empty list, does not fail the run!
(_def_lookup | default([])) | length < 1
Causes run to fail
Set message to aid debugging
Causes run to fail
Set message to aid debugging
- name: Fetch the MySQLCluster definition
set_fact:
_cluster_def: "{{ lookup('k8s', kind='MySQLCluster', namespace=_cluster_def_namespace, resource_name=cluster.name) }}"
vars:
_cluster_def_namespace: "{{ cluster.namespace | default(ansible_operator_meta.namespace) }}"
- fail:
msg: "Could not find requested MySQLCluster {{ cluster.name }}"
when:
- "(_cluster_def | default([])) | length < 1"
(Scroll right)
Must be handled by your operator
No auto-cleanup like k8s defs
Run when deleting a k8s def
Specified in watches.yaml
Existing role, playbook with custom vars
New role or playbook
Seperate from creation role
My result in some duplication, but easier!
Init manually, name after creation role
ansible-galaxy init path/to/my-operator/src/roles myRole_finalizer
Add finalizer
key,
point to new role.
---
- version: v1
group: myapp.myorg.tld #
kind: MySQLCluster # From `operator-sdk create api`
role: mysqlcluster #
finalizer:
name: myapp.myorg.tld/cluster-finalizer
role: mysqlcluster_finalizer
---
- version: v1
group: myapp.myorg.tld
kind: MySQLCluster
role: mysqlcluster
finalizer: #
name: myapp.myorg.tld/cluster-finalizer # Created manually
role: mysqlcluster_finalizer #
make undeploy
kills operator first
Delete defs manually, or finalizers: []
Update Dockerfile
Based on RedHat UBI
pip
for Python-only libs
yum
for everything else
Thanks Jeff Geerling!
FROM quay.io/operator-framework/ansible-operator:latest
COPY requirements.yml ${HOME}/requirements.yml
RUN ansible-galaxy collection install \
-r ${HOME}/requirements.yml \
&& chmod -R ug+rwx ${HOME}/.ansible
COPY watches.yaml ${HOME}/watches.yaml
COPY roles/ ${HOME}/roles/
COPY playbooks/ ${HOME}/playbooks/
USER root
ADD https://dev.mysql.com/get/mysql80-community-release-el8-1.noarch.rpm /tmp/mysql80-community-release-el8-1.noarch.rpm
RUN yum install -y /tmp/mysql80-community-release-el8-1.noarch.rpm && \
yum install -y mysql-community-client mysql-community-common mysql-community-libs python38-PyMySQL
USER ansible
 
FROM quay.io/operator-framework/ansible-operator:latest
COPY requirements.yml ${HOME}/requirements.yml #
RUN ansible-galaxy collection install \ #
-r ${HOME}/requirements.yml \ #
&& chmod -R ug+rwx ${HOME}/.ansible #
# Lines from SDK
COPY watches.yaml ${HOME}/watches.yaml #
COPY roles/ ${HOME}/roles/ #
COPY playbooks/ ${HOME}/playbooks/ #
USER root
ADD https://dev.mysql.com/get/mysql80-community-release-el8-1.noarch.rpm /tmp/mysql80-community-release-el8-1.noarch.rpm
RUN yum install -y /tmp/mysql80-community-release-el8-1.noarch.rpm && \
yum install -y mysql-community-client mysql-community-common mysql-community-libs python38-PyMySQL
USER ansible
 
FROM quay.io/operator-framework/ansible-operator:latest
COPY requirements.yml ${HOME}/requirements.yml
RUN ansible-galaxy collection install \
-r ${HOME}/requirements.yml \
&& chmod -R ug+rwx ${HOME}/.ansible
COPY watches.yaml ${HOME}/watches.yaml
COPY roles/ ${HOME}/roles/
COPY playbooks/ ${HOME}/playbooks/
USER root # change to root to run installs
ADD https://dev.mysql.com/get/mysql80-community-release-el8-1.noarch.rpm /tmp/mysql80-community-release-el8-1.noarch.rpm
RUN yum install -y /tmp/mysql80-community-release-el8-1.noarch.rpm && \
yum install -y mysql-community-client mysql-community-common mysql-community-libs python38-PyMySQL
USER ansible
 
FROM quay.io/operator-framework/ansible-operator:latest
COPY requirements.yml ${HOME}/requirements.yml
RUN ansible-galaxy collection install \
-r ${HOME}/requirements.yml \
&& chmod -R ug+rwx ${HOME}/.ansible
COPY watches.yaml ${HOME}/watches.yaml
COPY roles/ ${HOME}/roles/
COPY playbooks/ ${HOME}/playbooks/
USER root
# Download MySQL
ADD https://dev.mysql.com/get/mysql80-community-release-el8-1.noarch.rpm /tmp/mysql80-community-release-el8-1.noarch.rpm
RUN yum install -y /tmp/mysql80-community-release-el8-1.noarch.rpm && \
yum install -y mysql-community-client mysql-community-common mysql-community-libs python38-PyMySQL
USER ansible
 
FROM quay.io/operator-framework/ansible-operator:latest
COPY requirements.yml ${HOME}/requirements.yml
RUN ansible-galaxy collection install \
-r ${HOME}/requirements.yml \
&& chmod -R ug+rwx ${HOME}/.ansible
COPY watches.yaml ${HOME}/watches.yaml
COPY roles/ ${HOME}/roles/
COPY playbooks/ ${HOME}/playbooks/
USER root
ADD https://dev.mysql.com/get/mysql80-community-release-el8-1.noarch.rpm /tmp/mysql80-community-release-el8-1.noarch.rpm
# Install MySQL client and Python libs
RUN yum install -y /tmp/mysql80-community-release-el8-1.noarch.rpm && \
yum install -y mysql-community-client mysql-community-common mysql-community-libs python38-PyMySQL
# Switch back to ansible user.
USER ansible
(Scroll down)
Automating deployment
src/
helps here!
Nothing fancy here
Public base image, no complex build
docker build, tag, and push
Docker Hub automated build
Conventional manifest
Helm chart
Capture kustomize
output
Hack the makefile!
deploy: kustomize
cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
$(KUSTOMIZE) build config/default | kubectl apply -f -
to
deploy: kustomize
cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
$(KUSTOMIZE) build config/default > path/to/output.yml -
Often used to dpeloy operators
Templating, versioning, 3rd party integrations
Use kustomize
output
Break up into files, add helm labels
path/to/my-operator
└── charts
└── my-chart
├── Chart.yaml
├── templates
│ ├── crds.yaml
│ ├── deployment.yaml
│ ├── _helpers.tpl
│ └── rbac.yaml
└── values.yaml
(You'll see why in a sec!)
Need a Helm repository
Hosts chart tarballs by version
Pages hosts the repo
Actions builds the chart
gh-pages
branch
Branch must be empty
Uses stefanprodan/helm-gh-pages
Commit to .github/workflows/release-chart.yml
Sets app, chart version to gh-pages tag
Ignores version in Chart.yaml
name: release-chart
on:
push:
tags: '*'
jobs:
release-chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@master
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
branch: gh-pages
app_version: ${{ env.RELEASE_VERSION }}
chart_version: ${{ env.RELEASE_VERSION }}
(Scroll down.)
Commit to gh-pages
, tag, push
Hub builds image
Action builds Chart
This event!
socketwench.github.io/engineering-i-need-more-power