© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2022
M. ZadkaDevOps in Pythonhttps://doi.org/10.1007/978-1-4842-7996-0_14

14. Kubernetes

Moshe Zadka1  
(1)
Belmont, CA, USA
 

Kubernetes is a popular container orchestration framework. It runs containers on Linux machines (virtual or physical) based on various parameters.

You can run Kubernetes (often shortened to K8s) as an open source project. Many cloud providers offer a service of managed Kubernetes, where they run Kubernetes and manage its low-level reliability.

There are two main ways that Python interacts with Kubernetes. First, Kubernetes runs Python applications. There are some important things to consider when packaging Python applications to run with Kubernetes. Second, Python can automate Kubernetes through its API.

Both are covered here.

14.1 Pods

One of the main concepts in Kubernetes is the pod. A pod is a group of containers that share a common network namespace but not a common file system. They are always run together.

Many pods (and in some Kubernetes installations, all pods) have only one container. If there is more than one container, there usually be the main container and other supporting containers, sometimes called sidecars. A sidecar container can do anything from terminating SSL traffic to exporting metrics and many things in between.

14.1.1 Liveness and Readiness

When configuring a pod, there are two important notions.
  • Readiness

  • Liveness

A liveness check determines whether the container needs to be restarted. If a liveness check fails, Kubernetes restarts the pod.

A Kubernetes service selects pods with criteria. Only pods where the readiness check succeeds are included in the service.

A service is often used by different methods of load-balancing traffic to decide where to send the traffic. For a service used in this way, a failed readiness check means no traffic is sent to the relevant pod. Ideally, for web-based applications, a dedicated endpoint or several endpoints is configured to determine health and readiness.

While it is possible to configure liveness checks to wait for an initial boot-up time, this is not the best way to do so.

If an application has a slow start-up time, the best way to handle it is to start responding to liveness early, as the application does any pre-computing or initial configuration, and configure a readiness check to indicate the initial configuration is done, and the application is ready to receive traffic.

Slow start-up is sometimes caused by needing some initial data. This data might come from a slow data service or might need slow pre-processing before being in the proper shape to respond to queries. In the following example, for simplicity, we avoid a back-end service altogether and focus on an application that requires a heavy computation before it starts.

In this toy example, a web service returns many copies of the same line: the fortieth number of the Fibonacci sequence, divided by 1000. To get a proper slow start-up, the Fibonacci sequence is implemented using the naive, slow algorithm.
def fibonacci(n):
    if n < 2:
       return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

There are faster ways to compute Fibonacci numbers. This function is useful as a stand-in for a slow computation that is harder to optimize.

To have the application be faster, there is a cache of the relevant Fibonacci numbers.
fibonacci_results = {}
If the application starts with the following, it takes quite a while to start up.
fibonacci_results[40]  =  fibonacci(40)

On a test machine, this took 10 to 20 seconds.

When configuring a liveness check, it is useful if it has a short timeout. After the application is started, something has gone seriously wrong if the liveness check takes more than a second.

However, configuring a liveness check with a 1-second timeout results in a pod that goes into a Kubernetes crash loop. This is a case where a better start-up, combined with separate readiness and liveness checks, can be useful.

The application in this example uses the klein web framework, which uses Twisted as its underlying networking framework.

The initialization logic can be deferred to a thread, and the result stored in the cache when it is available.
import functools, operator
from twisted.internet.threads import deferToThread
set_40 = functools.partial(operator.setitem, fibonacci_results, 40)
d = deferToThread(fibonacci, 40)
d.addCallback(set_40)
The code for the core business logic can be written using the cached value.
from klein import route
@route("/")
def fibonacci_40(request):
    stuff = fibonacci_results[40] // 1000
    return "a line " * stuff
from klein import run
run("localhost", 8080)

Notice that this code fails if the cache is not initialized yet. Because of this, the pod should be configured with a readiness check.

Note that while / could be configured as the readiness endpoint since it fails if the result is missing, this is suboptimal. If it does not fail, it does some extra computation: creating a big string and sending it to the client.

In a more realistic example, this could be even worse. For example, this might call back to a back-end service or have some undesirable side effect.

Configuring a dedicated readiness check is better.
@route("/ready")
def ready(request):
    if 40 in fibonacci_results:
        return "Yes"
    raise ValueError("still initializing")

In this case, failure is accomplished by raising an exception. In a more sophisticated readiness code, it is sometimes worthwhile to send a specific HTTP error code and give more details. This can help in troubleshooting.

Finally, since neither of these endpoints would serve as a useful liveness check, the application needs a dedicated endpoint. Again, in general, it is useful to give applications dedicated health checks.

Those can do more than just serve as a way to make sure that the application’s web framework is functional. They can also check configuration and connectivity, for example.
@route("/health")
def healthy(request):
    return "Yes"
A Kubernetes pod definition for this application might have the following lines in it.
apiVersion: v1
kind: Pod
# ...
spec:
    containers:
    - name: fibonacci
    # ...
     livenessProbe:
        httpGet:
            path: /health
            port: 8080
        periodSeconds: 3
    readinessProbe:
        httpGet:
            path: /ready
            port: 8080
        periodSeconds: 3

This pattern of starting up immediately but having a readiness check that stops incoming traffic is powerful. In this case, we configured a pod directly. In more realistic cases, this configuration would be part of a deployment.

Having a fast start-up and careful readiness makes blue/green deployment patterns easier to accomplish. It allows configuring which colors are active based on how many nodes are ready. Unlike initialDelaySeconds configured in Kubernetes, optimizations to application start-up time are immediately reflected in better use of computing resources.

Even better, degradations in application start-up time cause degradation in the use of computing resources, not a catastrophic failure. While the degradation should be fixed, allowing the deployment to go forward based on human decisions, not Kubernetes configuration, is an operational advantage.

14.1.2 Configuration

When writing applications that are designed to run on Kubernetes, it is important to consider where their configuration comes from. In this context, configuration refers to things that need to be different between environments (development, staging, production, etc.). Any configuration that does not change between the environments can be added to the container images.

Environment Variables

Environment variables are often a recommended mechanism to pass in configuration parameters. For example, this is considered the correct way in The Twelve-Factor App methodology.

Environment variables serve as a simple namespace that is accessible from anywhere in the application code and contains arbitrary data. Setting environment variables via Kubernetes is done by adding an env stanza to the container spec.
env:
- name: GEM_LEVEL
  value:   "diamond"
To access the environment variable, the application code uses the os.environ dict-like object.
gem_level  =  os.environ["GEM_LEVEL"]

This can be done anywhere in the application code. For the most testable code, this is best done at as high a level as possible. In web applications, this can be done where a WSGI application is constructed.

The following example uses the Pyramid web framework. With Pyramid, the code for a web application that uses the GEM_LEVEL environment variable might look as follows.
from pyramid import config as configlib, response
def gem_level(request):
    level = request.registry.settings["gem_level"]
    return response.Response(f"Level: {level}")
def make_app(environ):
    settings = dict(gem_level=os.environ["GEM_LEVEL"])
    with configlib.Configurator(settings=settings) as config:
        config.add_route('gem_level', '/')
        config.add_view(gem_level, route_name='gem_level')
    app = config.make_wsgi_app()
    return app

Note that so far, none of this code touches the os.environ dict-like object itself. It defined a make_app() function, which accepts a dict-like object as a parameter.

This makes the code easier to test without trying to patch environment variables. The top-level code creating the WSGI app would look as follows.
import os
application  =  make_app(os.environ)

This Python code is in a file gem_level.py.

The application object is a WSGI-compatible application. When running locally, it can be run using
(gem-testing)  $  pip  install  gunicorn  pyramid
(gem-testing) $ GEM_LEVEL=2 gunicorn gem_level

When running code locally, create and activate a virtual environment before running pip install.

Since gunicorn uses the application variable in a module by default, this code runs correctly and use the GEM_LEVEL environment variable.

The relevant Dockerfile might end with a line like the following.
ENTRYPOINT ["gunicorn", "gem_level"]
For example, for a proof-of-concept, it is possible to write a short Dockerfile.
FROM python
RUN pip install gunicorn pyramid
COPY gem_level.py /
ENTRYPOINT ["gunicorn", "gem_level"]

Note that this is not a Dockerfile suited for production use; for that, refer to Chapter 12.

Configuration Files

Kubernetes pods, and therefore, deployments, support configmap. While configmap settings can set environment variables or command-line parameters, there are different ways to set them.

One thing that configmap solves uniquely is being able to set, through the Kubernetes configuration, data that appears as a file inside a container in a pod.

This is especially useful when porting an existing Python program that already uses a configuration file to set parameters to Kubernetes. For example, gunicorn can be used to read a Paste-compatible configuration file to run a WSGI application.

For example, a Paste-based version of the Pyramid application would look as follows.
def make_app_ini(global_config, **settings):
    with configlib.Configurator(settings=settings) as config:
        config.add_route('gem_level', '/')
        config.add_view(gem_level, route_name='gem_level')
        app = config.make_wsgi_app()
return app

When using this, the line application = make_app(os.environ) should be removed from gem_level.py.

The following is a relevant configuration for the diamond level.
[app:main]
use = call:gem_level:make_app_ini
[DEFAULT]
gem_level = diamond
In this case, a pod configuration might be set up as follows.
apiVersion: v1
kind: ConfigMap
metadata:
   name: gem-level-config-diamond
data:
     paste.ini: |
         [app:main]
         use = call:gem_level:make_app_ini
         [DEFAULT]
         gem_level = diamond
--
apiVersion: v1
kind: Pod
metadata:
   name: configmap-demo-pod
spec:
   containers:
       - name: gemlevel
         image: ...
         command: ["gunicorn", "--paste", "/etc/gemlevel/paste.ini"]
         volumeMounts:
             - name: config
               mountPath: "/etc/gemlevel"
               readOnly: true
   volumes:
      - name: config
        configMap:
        name: gem-level-config-diamond
        items:
          - key: "paste.ini"
            path: "paste.ini"
Especially when many parameters come from the paste.ini, this can be a quicker path to moving the application to Kubernetes than moving each one to an environment variable. Note that in this case, part of the paste.ini was not configured as defined. The first two lines would be the same in every environment.
[app:main]
use = call:gem_level_ini:make_app

This is a common issue in moving applications when configuration files include a mix of application configuration and environment configuration.

There are ways to work around this, such as by merging configuration files. The best solution depends on the local situation and can vary.

Secrets

Secrets are similar to configmaps. The main difference is that if Kubernetes is set up properly, secrets are stored encrypted in the API server.

Note that this is not true by default! Setting up secret encryption in Kubernetes is a complicated topic. It often depends on the capabilities in the environment that Kubernetes is running on, whether a cloud provider or on-premises.

However, assuming secret encryption has been properly set up, this is the right place to store database passwords, API tokens, and the like.

For example, if a pod needs access to the code for a planetary defense system or the code on someone’s luggage, the following configuration might work.
apiVersion: v1
kind: Secret
metadata:
   name: luggage
type: Opaque
data:
    code: MTIzNDU=
The code, in this case, is base64 encoded 12345. You can retrieve the secret from Kubernetes using
$ kubectl get secret luggage -o=jsonpath='{.data.code}'| base64 decode

In a production-grade Kubernetes deployment, secrets are protected by at-rest encryption and access controls. This command can be used when debugging local or testing Kubernetes clusters.

Kubernetes secrets can be set as files or environment variables. In general, it is best to pass them into containers as files.

Environment variables have many paths to leak accidentally or intentionally. For example, many error-reporting systems capture all environment variables to help debug.

Mounting a secret as a file is similar to mounting a configuration map.
spec:
   containers:
   - name: acontainer
     # ...
     env:
     - name: SECRET_CODE
       value: /etc/secrets/code
     volumeMounts:
     - name: secrets
       mountPath: "/etc/secrets"
       readOnly: true
volumes:
   - name: secrets
     secret:
     secretName: luggage
     items:
     - key: code
       path: code
This is a typical configuration. The application is still configured mainly via environment variables. However, the environment variable itself is not sensitive; it is just the literal value /etc/secrets/code. Inside the application, code might look like the following.
with open(os.environ["SECRET_CODE"]) as fpin:
    code = fpin.read()

The secret itself is only stored in the file system and the application’s memory, never in an environment variable.

14.1.3 Python Sidecars

Until now, the pods in the examples were single-container pods. One of the main benefits of pods is that they can include more than one container.

All containers in a pod share a network namespace but not the filesystem. In practice, containers in a pod can be built using different stacks and different container images. They can communicate using 127.0.0.1 as an endpoint.

Containers designed to go as the other container in the pod, next to the main one, are called sidecars. It is important to note that from the perspective of Kubernetes, nothing makes a container. When managing pods, though, it is usually the case that one container does the real work while the rest are in support positions.

As a contrived example, imagine an application with a JSON diagnostic endpoint, say on /status. The HTTP status code is always 200. One of the fields in the JSON result is "database-connected": BOOLEAN, where the BOOLEAN is a JSON boolean: either true or false.

The application should not be considered ready if the database is not connected. An httpGet probe is not enough. It always returns successfully.

In a pod, all containers must be ready for the pod to receive connection. A sidecar container’s readiness check can grab the main containers’ status and check the database connection.
def readiness(request):
    result = httpx.get("http://127.0.0.1:8080").json()
    if not result["database-connected"]:
        raise ValueError("database not connected", result)
    return Response("connected")
with configlib.Configurator() as config:
    config.add_route('readiness', '/ready')
    config.add_view(readiness, route_name='readiness')
    application = config.make_wsgi_app()

This is a better alternative to using exec probes, which rely on running commands inside a container. Sidecars can be used for more than readiness checks: exporting metrics, proxying, scheduled cleanup, and more are just a small sample of what can be done with sidecars.

Since sidecar container images are often built ad hoc for existing images, Python is a useful language for them even if the main container is not written in Python.

14.2 REST API

Kubernetes has an OpenAPI-based RESTful API. Because the API is described using OpenAPI, clients can be automatically generated for many languages.

This has its downsides. The documentation for the Python Kubernetes client can be impenetrable at times. It is not well organized.

It is, however, explorable using a Python prompt. Describing the entire API is beyond the length of a single chapter, but it is useful to understand a few basic concepts.

The Python Kubernetes API client is installable using pip install kubernetes. In this package, two important modules are often imported.

The config module allows reading a kubectl-compatible configuration and connecting to the same endpoint. Since most Kubernetes management tools give such a configuration, this is useful.

The API documentation usually suggests loading a global configuration. In general, such implicit globals make code harder to understand, test, and debug.

A better approach is to use the kubernetes.client.new_client_from_config() function. Given no argument, this function reads the kubectl configuration.

If the code runs in a container run by Kubernetes, Kubernetes can have the container access a service account. If this is done, new_client_from_config() connects to Kubernetes as the relevant account. This allows managing Kubernetes automation and its permission directly from Kubernetes.
from kubernetes import config as k8config
client = k8config.new_client_from_config()

The kubernetes subpackages have generic names. It is often a good idea to use import as to make it easier to read the code that uses them.

The client returned by the function is of little use by itself. Instead, specific API areas in Kubernetes correspond to specific classes in kubernetes.client.

For example, the core v1 API is accessed through kubernetes.client.CoreV1Api. Explicitly passing the client to the code allows easier testing and centralized management of client configuration.
from kubernetes import client as k8client, config as k8config
client = k8config.new_client_from_config()
core = k8client.CoreV1Api(client)
This is the API that manages the core objects in Kubernetes: pods, namespaces, and similar low-level objects. For example, using this API, it is possible to get a list of all pods.
from kubernetes import client as k8client, config as k8config
client = k8config.new_client_from_config()
core = k8client.CoreV1Api(client)
res = core.list_pod_for_all_namespaces()
for pod in res.items:
    for container in pod.spec.containers:
    print(container.image)

This prints all container images for containers currently running in the Kubernetes installation.

14.3 Operators

Kubernetes operators are a powerful tool for customizing Kubernetes. There is only one twist: operators are not a Kubernetes concept. Instead, operators are a pattern. The pattern consists of several parts.
  • One or more inputs

  • One or more outputs

The operator then runs a reconcilation loop.
  • Retrieve inputs

  • Retrieve outputs

  • Calculate correct inputs for the outputs

  • Check the difference between the previous two steps

  • Fix the differences

Fixing the differences can be done in one of two ways.
  • Deleting outputs that should not exist and creating output that should

  • Modifying incorrect output

Some things in Kubernetes cannot be modified, and only the first option is valid. Writing operators in this way tends to be easier, so this is usually the first version of an operator.

14.3.1 Permissions

Kubernetes operators need permissions to read the inputs and read/write the outputs. Since operators are regular Kubernetes API clients, they get permissions the same way all other clients get permissions: Resource-Based Access Control (RBAC).

The full details of Kubernetes RBAC are beyond the current scope. One important thing to note is that a common pattern is that operators are deployed as a Kubernetes deployment. Usually, this means the operator uses a deployment running the operators’ code in pods.

These pods can be assigned a Kubernetes service role with the appropriate permissions. In this case, the API client has the right access, as long as it uses the automatically mounted API configuration to access Kubernetes.

14.3.2 Custom Types

To avoid having the inputs having any other effect, many operators also define a custom resource type. Kubernetes allows adding a custom type. The operator can do this, but more commonly, it is done as part of whatever process sets up the operator.

Output types may or may not be custom types; for example, if the output type is a Deployment or a ReplicaSet, it is a standard type. However, for higher-level operators, the outputs might be inputs to a lower-level operator, so a custom type.

Definining a Kubernetes custom resource type is done by creating an object of kind CustomResourceDefinition.

A useful example for the rest of the explanation is a Character type for characters from The Wizard of Oz. Since the story has multiple remakes, let’s assign each character to a universe. Dorothy from universe 5 can only interact with characters in universe 5.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
   name: characters.wizardofoz.example.org
spec:
   group: wizardofoz.example.org
   versions:
     - name: v1
       served: true
       storage: true
       schema:
         openAPIV3Schema:
           type: object
           properties:
             spec:
               type: object
               properties:
                 name:
                   type: string
                 universe:
                   type: integer
   scope: Namespaced
   names:
      plural: characters
      singular: character
      kind: Character

The most important part of the definition is the spec. In this case, it notes that the object has two properties: name, a string, and universe, an integer.

A Character is also created using kubectl and a YAML file.
apiVersion: "wizardofoz.example.org/v1"
kind: Character
metadata:
   name: a-dorothy
spec:
   name: Dorothy
   universe: 42

Since this is a relatively simple object, the definition is pretty short. In this case, the Character object represents Dorothy from universe 42.

Similarly, the Quest type is defined using CustomResourceDefinition.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
   name: quests.wizardofoz.example.org
spec:
   group: wizardofoz.example.org
   versions:
     - name: v1
       served: true
       storage: true
       schema:
         openAPIV3Schema:
           type: object
           properties:
             spec:
               type: object
               properties:
                 goal:
                   type: string
                 universe:
                   type: integer
   scope: Namespaced
   names:
     plural: quests
     singular: quest
      kind: Quest

14.3.3 Retrieval

An operatorfirst has to get all the input objects. This is one advantage of having a custom input kind: all objects of that kind are relevant.

For non-custom types, the Kubernetes Python classes have autogenerated classes. However, for custom ones, it does not. One alternative is to regenerate the client. This is complicated and error-prone.

A quicker, though messier, an alternative is to write ad hoc classes.

Kubernetes objects have metadata. The metadata structure is consistent, so it makes sense to wrap it up in its own class.
import attr
from typing import AbstractSet, Tuple
@attr.frozen
class Metadata:
    uid: str = attr.ib(eq=False)
    name: str = attr.ib(eq=False)
    namespace: str
    labels: AbstractSet[Tuple[str, str]]
    @classmethod
    def from_api(cls, result):
        return cls(
           uid=result["metadata"]["uid"],
           name=result["metadata"]["name"],
           namespace=result["metadata"]["namespace"],
           labels=frozenset(result["metadata"].get("labels", {}).items()),
        )

This representation of the metadata, with frozenset for labels and an attr.frozen decorator makes Metadata immutable.

Immutable objects are hashable by value and can be added to sets and used as dictionary keys. Although the operator does not require it, this is a powerful invariant in more complicated operators.

The metadata is important to the operator: the reconciliation loop needs to match the right input to the right output. It is also the most complicated part because, in this case, the character and quest objects only have two attributes each.
@attr.define
class Character:
    crd_plural = "characters"
    metadata: Metadata = attr.ib(repr=False)
    name: str
    universe: int
    @classmethod
    def from_api(cls, result):
        return cls(
            metadata=Metadata.from_api(result),
            **result["spec"],
        )
@attr.define
class Quest:
    crd_plural = "quests"
    metadata: Metadata = attr.ib(repr=False)
    goal: str
    universe: int
    @classmethod
    def from_api(cls, result):
        return cls(
           metadata=Metadata.from_api(result),
           **result["spec"],
        )
Putting the logical parsing code and interpreting the raw dictionary inside the object’s named constructor makes the retrieval code concise and generic.
from kubernetes import client as k8client
def get_custom(client, klass):
    custom = k8client.CustomObjectsApi(client)
    results = custom.list_cluster_custom_object(
        group="wizardofoz.example.org",
        version="v1",
        plural=klass.crd_plural,
    )
    for result in results["items"]:
        yield klass.from_api(result)

14.3.4 Goal State

After retrieving the input objects, the reconciliation loop needs to calculate the goal state: what is the right state for the output objects given the input objects.

This is the business logic of the operator. The parts until now were the plumbing: representing and getting the data from Kubernetes.

The code here is based on the book The Wizard of Oz.
  • Dorothy wants to go home.

  • The Scarecrow wants a brain.

  • The Tin Man wants a heart.

  • The Cowardly Lion wants courage.

Other characters in this journey-style story are encountered, but none have a quest. When the operator encounters a different character, such as Glinda, it does not create a quest for it.
import uuid
def quests_from_characters(characters):
    name_to_goal = dict(
        Dorothy="Going home",
        Scarecrow="Brain",
        Tinman="Heart",
        Lion="Courage",
    )
    for character in characters:
        name = character.name
        try:
              goal = name_to_goal[name]
        except KeyError:
            continue
        quest = Quest(
           Metadata(
               uid="",
               name=str(uuid.uuid4()),
               labels=frozenset([("character-name", character.metadata.name)]),
               namespace=character.metadata.namespace,
           ),
           goal=goal,
           universe=character.universe,
        )
        yield quest

Note that the name is automatically generated as a UUID. This is useful since it guarantees there are no collisions with little effort. Many Kubernetes loops, including built-in loops like Deployment and ReplicaSet, include a UUID-like name.

14.3.5 Comparison

This representation of the goal state as a generator of quests is not the most convenient. The easiest way to compare the existing quests to the goal quests is to match them to the original character.
def by_character(quests):
    ret_value = {
        dict(quest.metadata.labels).get("character-name"):  quest
        for quest in quests
    }
    if None in ret_value:
        del ret_value[None]
    return ret_value

A quest not marked with a character-name label is assumed to have been manually added. The operator leaves such quests alone. In a more realistic example, one of the labels added would have been to note the quest as created by the operator, and those not created by the operator would have been filtered out.

After conveniently representing the data, the operator compares the goal state with the existing state.
import enum
@enum.unique
class Action(enum.Enum):
    delete = enum.auto()
    create = enum.auto()
def compare(*, existing, goal):
    for character, quest in goal.items():
        try:
            current_quest = existing[character]
        except KeyError:
            pass
        else:
            if current_quest == quest:
                continue
            yield Action.delete, current_quest
        yield Action.create, quest
    for character, current_quest in existing.items():
        if character in goal:
            continue
        yield Action.delete, current_quest

The comparison generates an Action.delete result for any quest that exists and should not and an Action.create result for any quest that does exist and should. Some operators instead choose to disable invalid goals and delete them later as part of a garbage collection process. This can be useful for troubleshooting operators.

14.3.6 Reconciliation

To reconcile the desired state with the existing state, the actions need to be performed by the Kubernetes API client. There are two options: using the CustomObjectsApi or using DynamicClient.

The DynamicClient requires a more upfront setup, though it leads to simpler code for creating objects. Since this is a simple operator, the overhead of an upfront setup is more than its worth.

The reconciliation code in this operator uses CustomObjectsApi to delete and create objects.
def perform_actions(actions):
    custom = k8client.CustomObjectsApi(client)
    for kind, details in actions:
        if kind == Action.delete:
            custom.delete_namespaced_custom_object(
                "wizardofoz.example.org",
                "v1",
                "default",
                "quests",
                details.metadata.name,
            )
        elif kind == Action.create:
            body = dict(
                apiVersion="wizardofoz.example.org/v1",
                kind="Quest",
                metadata=dict(
                    name=details.metadata.name,
                    labels=dict(details.metadata.labels)
                ),
                spec=dict(goal=details.goal, universe=details.universe),
            )
            custom.create_namespaced_custom_object(
                "wizardofoz.example.org",
                "v1",
                "default",
                "quests",
                body
            )

Note that with the CustomObjectsApi the creation logic has to duplicate some parameters between the body and the call to create_namespaced_custom_object().

14.3.7 Combining the Pieces

Now that all the pieces are in place, the reconcile() function takes a preconfigured client objects and puts those pieces together to get the data and generate the reconciliation API calls.
def reconcile(client):
    goal_quests = by_character(
        quests_from_characters(get_custom(client, Character))
    )
    existing_quests = by_character(get_custom(client, Quest))
    actions = compare(existing=existing_quests, goal=goal_quests)
     perform_actions(actions)
Finally, running the code depends on how the operator is configured. In the following example, the assumption is that the default configuration is enough. If this is not the case, it might make sense to pass configuration more explicitly.
from kubernetes import config as k8config
import time
client = k8config.new_client_from_config()
while True:
    reconcile(client)
    time.sleep(30)

It is possible to avoid polls by using the watch() API, which makes the code slightly more complicated. In practice, polling loops have little enough overhead. They are easier to write and troubleshoot, so it is usually a good idea to avoid the watch() API until it has proven necessary.

14.4 Summary

Kubernetes is a popular choice for container orchestration. In practice, it ends up running many Python-based containerized applications. Understanding the capabilities it offers allows building containers that take advantage of those capabilities.

Automating Kubernetes is possible through its API. This API allows retrieving and manipulating data. This can be used in CI/CD systems to trigger deployment and troubleshoot scripts to quickly find issues.

One kind of automation is called the operator pattern, which is a reconciliation loop creating, deleting, or modifying Kubernetes API objects based on different objects. In a more general sense, either the operator’s inputs or outputs can be external. For example, an operator can be triggered by the existence of a row in a database or might update values in a web service.

Python is well-suited to writing ad hoc, local operators. Those can harmonize the way the Kubernetes cluster is set up, avoiding the need to synchronize tooling and configuration in a separate place from the cluster, such as a chart or template repository.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.142.98.186