Simple Tensorflow Serving Operator
Kubernetes is a powerful and highly extensible system for managing containerized workloads and services. It has two main components, the Master Components and the Node Componenets, and extensions.
The Master Components:
- Kube-apiserver, the API server is the front end for the Kubernetes control plane.
- etcd, is a highly-available key value store used as Kubernetes’ backing store for all cluster data.
- kube-scheduler, is the component of the master that watches newly created pods that have no node assigned, and selects a node for them to run on.
- kube-control-manager, is single binary which holds all the built in controlers like Node Controller, Replication Controller, Endpoints Controller, Service Account & Token Controllers.
The Node Components:
- kubelet, an agent that runs on each node in the cluster, which makes sure that containers are running in a pod based on the Specs.
- kube-proxy, is a network proxy that runs on each node, which maintains network rules.
- Container Runtime, is the software that is responsible for running containers, like docker.
CRDs, Controllers and Operators
With Kubernetes, it is relatively easy to manage and scale web apps, mobile backends, and API services right out of the box, because these applications are generally stateless, so the basic Kubernetes APIs, like Deployments, can scale and recover from failures without additional knowledge.
In a Core Os Blog Post from 2016 the Operator Concept was introduced. An Operator is an application-specific controller that extends the Kubernetes API to create, configure, and manage instances of complex stateful applications on behalf of a Kubernetes user. It builds upon the basic Kubernetes resource and controller concepts but includes domain or application-specific knowledge to automate common tasks. Operators are clients of the Kubernetes API that act as controllers for a Custom Resource.
But first let’s look at what Controllers does. Based on Kubernetes Glossary reference, controllers are control loops that watch the state of your cluster, then make or request changes where needed. Each controller tries to move the current cluster state closer to the desired state. So basically, a controller tracks at least one Kubernetes resource type. These objects have a spec field that represents the desired state. The controller for that resource are responsible for making the current state come closer to that desired state.
The Custom Resource is an endpoint in the Kubernetes API that stores a collection of API objects of a certain kind. For example, the built-in pods resource contains a collection of Pod objects. A custom resource is an extension of the Kubernetes API that is not necessarily available in a default Kubernetes installation. Many core Kubernetes functions are now built using custom resources, making Kubernetes more modular.
A Custom Resource Definition (CRD) file defines your own object kinds and lets the API Server handle the entire lifecycle. Deploying a CRD into the cluster causes the Kubernetes API server to begin serving the specified custom resource. When you create a new custom resource definition (CRD), the Kubernetes API Server reacts by creating a new RESTful resource path, that can be accessed by an entire cluster or a single project (namespace).
- most of the above definitions are from https://kubernetes.io/docs/concepts/
Extending Kubernetes
Now that we covred the basic building blocks for extending Kubernetes, there are a number of options to put this in practice. Several tools were built to facilitate the creation of custom controllers.
client-go, is the offical API client library, providing access to Kubernetes restful API interface served by the Kubernetes API server. The library contains several important packages and utilities which can be used for accessing the API resources or facilitate a custom controller.
Sample Controller, uses the client-go library directly and the code-generator to generate a typed client, informers, listers, and deep-copy functions. Whenever the API types change in your custom controller—for example, adding a new field in the custom resource — you have to use the update-codegen.sh script to regenerate the aforementioned source files. Check out this Link if you are interested to find how it works under the hood.
Kubebulder owned and maintained by the Kubernetes Special Interest Group (SIG) API Machinery, is a tool and set of libraries enabling you to build operators in an easy and efficient manner. This is what I’m going to use to build the Simple Tensorflow Serving Operator.
Operator Framework is an open source project that provides developer and runtime Kubernetes tools, enabling you to accelerate the development of an Operator. It is somehow similar with Kubebuilder, but if you are interested what provide one vs the other, here you can find some hints. The Operator Framework includes:
- Operator SDK which enables developers to build Operators based on their expertise without requiring knowledge of Kubernetes API complexities.
- Operator Lifecycle Management: Oversees installation, updates, and management of the lifecycle of all of the Operators (and their associated services) running across a Kubernetes cluster.
- Operator Metering (joining in the coming months): Enables usage reporting for Operators that provide specialized services.
Kubebuilder
Building Kubernetes tools and APIs involves making a lot of decisions and writing a lot of boilerplate. Kubebuilder attempts to facilitate the following developer workflow for building APIs
- Create a new project directory
- Create one or more resource APIs as CRDs and then add fields to the resources
- Implement reconcile loops in controllers and watch additional resources
- Test by running against a cluster (self-installs CRDs and starts controllers automatically)
- Update bootstrapped integration tests to test new fields and business logic
- Build and publish a container from the provided Dockerfile
Install Kubebuilder:
A simple Tensorflow Serving Operator
what is Tensorflow Serving ? Based on the official definition, is a flexible, high-performance serving system for machine learning models, designed for production environments. It deals with the inference aspect of machine learning, taking models after training and managing their lifetimes.
Before diving into the nitty-gritty of building the operator, let’s talk a little bit of what I am trying to achieve. My Operator role would be to deploy and serve a Tensorflow model over Kubernetes and automate a number of steps like: creating the deployment and the service yaml, pull the model from block storage and serve at scale.
You can find a number of articles on the internet on how to run Tensorflow Serving within a docker container or even how to deploy a serving cluster with Kubernetes. That’s cool, but the expectation is that you are going to manage all the steps mentioned above. If you are a machine learning expert, working with Tensorflow, you would like to have these steps automated for you so you can focus on doing experiments and to place your models into production as fast as possible.
There are a number of tools that facilitate these things, maybe most known and used framework over Kubernetes is Kubeflow, which I would highly recommend to everyone who is searching for a production ready Machine Learning workflow on Kubernetes.
My intention is not to compete with these tools, as this is just a toy project but to showcase how to automate some of these steps using a custom made Kubernetes Operator.
Scaffolding Out the Project
In this first step, we are doing any codding, but you’ll see that at the end of the step we’ll have a functional Operator with the custom CRDs installed. This is the value proposition that Kubebuilder offers to us.
To initialize a new project, run following command:
It generates a Kubebuilder project template with a Makefile, a Dockerfile a basic manager and some default yaml files.
Now we can create an API, but before that it is important to get a bit of understanding of the components of the API. We will define groups, versions and kind.
An API Group in Kubernetes is simply a collection of related functionality. Each group has one or more versions, which allow us to change how an API works over time. Each API group-version contains one or more API types, called Kinds. A resource is simply a use of a Kind in the API. Often, there’s a one-to-one mapping between Kinds and resources. For instance, the pods resource corresponds to the Pod Kind.
Resources and Kinds are always part of an API group and a version, collectively referred to as GroupVersionResource (GVR) or GroupVersionKind(GVK).
On completion of this command, Kubebuilder has scaffolded the operator,generating a bunch of files, from the custom controller to a sample CRD. It creates an api/v1alpha1 directory, corresponding to servapi.dev-state.com/v1alpha1 group-version. has also added a file tfserv_types.go which contain the define kind Tfserv.
The directory structure should look something similar to this:
Most of the business logic will go within these files api/v1alpha1/tfserv_types.go and controllers/tfserv_controller.go. The rest of files help us to install the CRDs and build and run the controller in the Kubernetes cluster.
We want to see what it offers out-of-the-box , so we are not doing yet any changes, we use the defaults. Install the CRDS into the cluster:
Run your controller locally (this will run in the foreground, so switch to a new terminal if you want to leave it running):
In a new terminal session create the sample custom resource like this:
If you are looking to the output of the session where controller is running you should see something similar with this:
This tells us that the overall setup was successful so we can start to implement the business logic.
Designing the API
In terms of business logic there are two parts to implement in the operator. We have to modify the the Spec and Status of the defined Kind. Kubernetes functions by reconciling desired state Spec with actual cluster state and then recording what it observed in Status.
Tfserv is our root type, and describes the Tfserv kind. Like all Kubernetes objects, it contains TypeMeta (which describes API version and Kind), and also contains ObjectMeta, which holds things like name, namespace,and labels.
+kubebuilder:object:root comment is called a marker, tells the object generator that this type represents a Kind, so it can generate an implementation of the runtime.Object interface for us, which is the standard interface that all types representing Kinds must implement.
A Kubernetes object in Go is a data structure that can return and set the GroupVersionKind and make a deep copy, which is a clone of the data structure,so it does not share any memory with the original object.
Now we get into the specific of our implementation. What is required for user to provide in order to create the Tensorflow Serving Infrastructure?
Tensorflow Serving support GRPC APIS but also RESTful APIs, so you can specify the port number you would like to listen on.
- GrpcPort – GRPC port number
- RestPort – REST port number
- Replicas – number of Tensorflow serving instances
- ConfigMap – it holds the configuration of the Tensorflow serving service
- ConfigFileName – is the name of the config file
- ConfigFileLocation – is the path to config file
- SecretFileName – is the name of the secret file, which is required if the model is hosted on Google Storage or AWS S3
- SecretFileLocation – is the path to the Secret file
The status is what is recorded by the Controller in the reconciling process.
- Active – record a list of pointers to currently running jobs.
In the end, we add the Go types to the API group. This allows us to add the types in this API group to any Scheme.
Scheme defines methods for serializing and deserializing API objects, a type registry for converting group, version, and kind information to and from Go schemas, and mappings between Go schemas of different versions. The main feature of a scheme is the mapping of Golang types to possible GVKs.
Implementing the Controller
Controllers are the core component of Kubernetes, their role is to ensure that the actual state of the cluster matches the desired state. Reconciler implements a Kubernetes API for a specific Resource by Creating, Updating or Deleting Kubernetes objects, or by making changes to systems external to the cluster (e.g. cloudproviders, github, etc). Reconcile implementations compare the state specified in an object by a user against the actual cluster state, and then perform operations to make the actual cluster state reflect the state specified by the user. Another role of the Reconcile function is to update the Status part of the custom resource.
There is a Reconcilier struct, that define the dependencies of the reconcile method.
It is instantiated in the main package, but I’ll get there soon. What it important to know is that client.Client contains functionality for interacting with Kubernetes API servers and it knows how to perform CRUD operations on Kubernetes objects, like Get, Create, Delete, List, Update which are used heavily within reconciler method. A logger and the Scheme should include the schemes of all objects the controller will work with.
In the reconcile method, we’ll fetch the Tfserv custom resource by calling Get method, which takes in a ctx, the Namespace and the object requested. If the resource is not present, ignore it, if there is an error while retrieving the resource then requeue it.
As the tensorflow serving requires a model-config-file to be defined. We are injecting configuration data into Pods using ConfiMaps. The data stored in a ConfigMap object can be referenced in a volume of type configMap and then consumed by containerized applications running in a Pod.
As this is critical for the application we can’t move forward untill this condition is satisfied and we are able to find the ConfigMap.
We need a deployment and a service infront of the cluster to serve the tensorflow serving containers. We mandate the controller to create the resources and to compare those with the ones that are already running in the cluster.
The desired deployment is created in the memmory. Then, it tries to find the deployment in the Kubernetes cluster, by namespace and name. If it could not be found then it creates one, else it compare the Specs of the found deployment with the desired deployment. If there are not the same then it updates the found deployment. If errors occurs while creating or updating the deployment,it is requed to try again later.
The function to create the deployment, it looks like below. It’s worthwhile to notice that the user defined Specs are used all over the place and two volume are mounted. One is for Secret key as the model is hosted in the cloud and in order to access cloud resources a service account key is required. The othe one is the ConfigMap volume type, which is used to access the configuration file from the ConfigMap.
SetControllerReference sets owner as a Controller OwnerReference on owned. This is used for garbage collection of the owned object and for
reconciling the owner object on changes to owned (with a Watch + EnqueueRequestForOwner).
Lastly we need a Service, which is an abstraction that defines a logical set of Pods and a policy by which to access them. The set of Pods targeted by a Service is usually determined by a selector. The same mechanism, as we saw before for deployment applys here as well.
And the create service function:
Putting all together in the main package
In order to allow our reconciler to quickly look up Deployments and Services by their owner, we’ll need an index. We declare an index key that we can later use with the client as a pseudo-field name, and then describe how to extract the indexed value from the objects. This inform the manager that this controller owns some resources, so that it will automatically call Reconcile on the underlying resources changes.
In the main.go file, we have to ensure that we reference and add all the resources schemes.
Then we use ctrl.NewManager to create new manager, which is used for creating Controllers. SetupWithManager registers this reconciler with the controller manager and starts watching Tfserv, Deployment and Service resources.
The manager is started with mgr.Start command.
Running the Operator
You need a service account key to access the model. You can find some details here, on what is a Service Account and how to create keys to autheticate applications.
Install rbac and CRDs:
Verify if the CRDs were installed:
Create ConfigMap where we define the configuration of the tensorflow service.
Now apply the configuration of the resource for the Tfserv CRD.
There should be no pods either Services available, as the controller is not running yet.
Run the controller locally:
Verify again the pods and services and if everithing worked fine you should see something similar with this:
Verify if the service suceffuly identified the Endpoints:
Check out the deployment:
The pod logs show to us that the tensorflow serving is running and serving the defined model and version and also it is listening for GRPC and REST connections.
Check if the model is reachable and it’s status. Retrieve first the minikube IP and the NodePort.
Runnig the example, which is comming with Tensorflow Serving, we are getting Prediction class: 286, which coresponds to a Cat. So the model is serving requests over http and grpc.
Conclusion
You can find the complete code on Github. I may come back to it and complete the TODOs, maybe extend further on. But the scope is not to create a production ready Operator, it is just for me to get used with the Kubebuilder workflow. I like that the tool generate most of the stuff which allows you to focus more on the business model.