Criando Kubernetes Operators com o operator-sdk
Se você desenvolve APIs ou microsserviços, especialmente em ambientes de médio a grande porte, provavelmente você está usando Kubernetes.
Kubernetes é um projeto criado pelo Google em meados de 2015 e que rapidamente se tornou o padrão para gerenciar a execução de containers. Você pode hospedar e gerenciar ele em suas máquinas ou usar alguma solução gerenciada por algum dos grandes players de cloud como AWS, Google e DigitalOcean. Se você quiser se aprofundar mais sobre Kubernetes, ou k8s para deixar mais curto, eu recomendo o livro e o curso do grande Lucas Santos.
Neste post eu quero falar sobre outra funcionalidade importante que é a possibilidade de estendê-lo para criar novas capacidades. Vamos começar pelos conceitos importantes para o entendimento deste artigo.
Resources e Controllers
Um dos conceitos mais básicos é que o k8s gerencia recursos. Segundo a documentação oficial,
Um recurso é um endpoint na API do Kubernetes que armazena uma coleção de objetos de API de um determinado tipo; por exemplo, o recurso built-in pods contém uma coleção de objetos Pod.
Ele faz a gestão destes recursos usando outro conceito importante, os controllers. Quando usamos algum recurso do k8s precisamos definir, em um arquivo yaml qual é o estado desejado por nós. Por exemplo:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 2 # diz ao deployment para executar 2 pods que correspondam ao modelo
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
As informações dentro da chave spec correspondem ao estado desejado do recurso.
O que o k8s faz é garantir que o estado atual do objeto contido no cluster seja igual ao estado desejado que foi declarado, neste caso: dois containers do Nginx, versão 1.14.2 sendo executados na porta 80. Ele faz isso usando o que é chamado de control loop:
Ele verifica se o estado atual do recurso difere do estado desejado, e caso positivo executa a função Reconcile do controller vinculado ao objeto. Desta forma, podemos definir um controller assim:
Um controller rastreia pelo menos um tipo de recurso do Kubernetes. Esses objetos têm um campo spec que representa o estado desejado. O(s) controlador(es) desse recurso são responsáveis por fazer com que o estado atual se aproxime daquele estado desejado.
O k8s possui uma série de recursos embutidos como Pod, Deployment, Service e controllers que rastreiam o ciclo de vida de cada um deles. Mas além deles podemos criar nossos próprios recursos, através dos Custom Resource Definitions (CRD). A junção de um CRD e um controller é o que chamamos de um operator, e é o que vamos explorar neste texto.
operator-sdk
Para ilustrar o que podemos fazer com um operator vou criar uma prova de conceito usando o operator-sdk. Segundo o site oficial:
O Operator SDK facilita a criação de aplicativos nativos do Kubernetes, um processo que pode exigir conhecimento operacional profundo e específico do aplicativo. Este projeto é um componente do Operator Framework, um kit de ferramentas de código aberto para gerenciar aplicativos nativos do Kubernetes, chamados Operadores, de forma eficaz, automatizada e escalável.
É possível criarmos um operator usando Go, Ansible ou Helm. Neste artigo vou usar Go.
O primeiro passo é instalar o CLI do sdk na máquina. Eu usei o brew mas na documentação é possível ver as outras opções.
brew install operator-sdk
O próximo passo é usar o CLI para gerar o esqueleto do projeto, usando os comandos:
operator-sdk init --domain minetto.dev --repo github.com/eminetto/k8s-operator-talk
operator-sdk create api --version v1alpha1 --kind Application --resource --controller
O primeiro comando inicializa o projeto indicando o domínio, informação que é usada pelo k8s para identificar o recurso, e o nome do repositório, que é usado para o nome do pacote do Go. O segundo comando indica a criação de um novo recurso chamado Application, na versão alpha1 e que vamos precisar também do esqueleto de um controller.
Antes de entrarmos no código, é importante entendermos o objetivo da prova de conceito. Na sua forma nativa, colocar uma aplicação em execução no k8s exige que a pessoa desenvolvedora entenda uma série de conceitos como Deployment, Pod, Service, etc. O meu objetivo aqui é reduzir esta carga cognitiva para apenas dois conceitos: um namespace, onde a aplicação vai residir dentro do cluster, e um Application, que vai definir o estado desejado de uma aplicação. Por exemplo, o time precisa criar apenas o seguinte yaml:
apiVersion: v1
kind: Namespace
metadata:
name: application-sample
---
apiVersion: minetto.dev/v1alpha1
kind: Application
metadata:
name: application-sample
namespace: application-sample
spec:
image: nginx:latest
replicas: 2
port: 80
Aplicá-lo ao cluster usando o comando:
kubectl apply -f application.yaml
E o restante é criado pelo nosso controller.
O primeiro passo é configurar nosso recurso para que ele possua os campos relacionados a spec
. Para isso é preciso alterar o aquivo api/v1alpha/application_types.go e adicionar os campos na struct:
type ApplicationSpec struct {
Image string `json:"image,omitempty"`
Replicas int32 `json:"replicas,omitempty"`
Port int32 `json:"port,omitempty"`
}
Mais tarde vamos usar esta informação para gerar os arquivos necessários para a instalação do CRD no nosso cluster. E também vamos usar esta struct para criarmos os recursos necessários.
O próximo passo é criarmos a lógica do nosso controller. O operator-sdk criou o arquivo controllers/application_controller.go e a assinatura da função Reconcile. É esta função que é chamada pelo control loop cada vez que o k8s detecta alguma diferença entre o estado atual do objeto e o estado desejado. O vínculo entre o recurso Application e o nosso controller está definido no arquivo main.go que foi gerado pelo sdk, e não precisamos nos preocupar com ele agora. Essa é uma das vantagens do operator-sdk pois nos permite manter o foco na lógica do controller e abstrai todos os detalhes massantes necessários para que ele funcione.
O código da função Reconcile, e as auxiliares, encontra-se a seguir. Tentei documentar os trechos mais importantes:
func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
var app minettodevv1alpha1.Application
//recupera os detalhes do objeto sendo gerenciado
if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
l.Error(err, "unable to fetch Application")
return ctrl.Result{}, err
}
/*o finalizer é importante pois indica ao k8s que
precisamos ter controle sobre a exclusão do objeto
pois como nós vamos criar outros recursos é
importante que eles sejam excluídos junto
sem o finalizer não dá tempo para que o
garbage collector do k8s faça a exclusão
e corremos o risco de termos recursos sem utilidade
no cluster*/
if !controllerutil.ContainsFinalizer(&app, finalizer) {
l.Info("Adding Finalizer")
controllerutil.AddFinalizer(&app, finalizer)
return ctrl.Result{}, r.Update(ctx, &app)
}
if !app.DeletionTimestamp.IsZero() {
l.Info("Application is being deleted")
return r.reconcileDelete(ctx, &app)
}
l.Info("Application is being created")
return r.reconcileCreate(ctx, &app)
}
func (r *ApplicationReconciler) reconcileCreate(ctx context.Context, app *minettodevv1alpha1.Application) (ctrl.Result, error) {
l := log.FromContext(ctx)
l.Info("Creating deployment")
err := r.createOrUpdateDeployment(ctx, app)
if err != nil {
return ctrl.Result{}, err
}
l.Info("Creating service")
err = r.createService(ctx, app)
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *ApplicationReconciler) createOrUpdateDeployment(ctx context.Context, app *minettodevv1alpha1.Application) error {
var depl appsv1.Deployment
deplName := types.NamespacedName{Name: app.ObjectMeta.Name + "-deployment", Namespace: app.ObjectMeta.Name}
if err := r.Get(ctx, deplName, &depl); err != nil {
if !apierrors.IsNotFound(err) {
return fmt.Errorf("unable to fetch Deployment: %v", err)
}
/*se não existe um Deployment vamos criá-lo
Um trecho importante na definição é o OwnerReferences
pois ele indica ao k8s que este recurso está sendo criado
por um Application.
É assim que o k8s sabe que ao removermos um Application
ele deve remover também todos os recursos que ele criou
Outro ponto importante é que aqui usamos os dados
da nossa Application para criar o Deployment,
como a informação da imagem, porta e replicas
*/
if apierrors.IsNotFound(err) {
depl = appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: app.ObjectMeta.Name + "-deployment",
Namespace: app.ObjectMeta.Name,
Labels: map[string]string{"label": app.ObjectMeta.Name, "app": app.ObjectMeta.Name},
Annotations: map[string]string{"imageregistry": "https://hub.docker.com/"},
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: app.APIVersion,
Kind: app.Kind,
Name: app.Name,
UID: app.UID,
},
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &app.Spec.Replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"label": app.ObjectMeta.Name},
},
Template: v1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"label": app.ObjectMeta.Name, "app": app.ObjectMeta.Name},
},
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: app.ObjectMeta.Name + "-container",
Image: app.Spec.Image,
Ports: []v1.ContainerPort{
{
ContainerPort: app.Spec.Port,
},
},
},
},
},
},
},
}
err = r.Create(ctx, &depl)
if err != nil {
return fmt.Errorf("unable to create Deployment: %v", err)
}
return nil
}
}
/*o controller precisa também gerenciar a alteração
pois se o dev alterar alguma informação de uma
Application já existente isso deve ter impacto nos
demais recursos*/
depl.Spec.Replicas = &app.Spec.Replicas
depl.Spec.Template.Spec.Containers[0].Image = app.Spec.Image
depl.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort = app.Spec.Port
err := r.Update(ctx, &depl)
if err != nil {
return fmt.Errorf("unable to update Deployment: %v", err)
}
return nil
}
func (r *ApplicationReconciler) createService(ctx context.Context, app *minettodevv1alpha1.Application) error {
srv := v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: app.ObjectMeta.Name + "-service",
Namespace: app.ObjectMeta.Name,
Labels: map[string]string{"app": app.ObjectMeta.Name},
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: app.APIVersion,
Kind: app.Kind,
Name: app.Name,
UID: app.UID,
},
},
},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeNodePort,
ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,
Selector: map[string]string{"app": app.ObjectMeta.Name},
Ports: []v1.ServicePort{
{
Name: "http",
Port: app.Spec.Port,
Protocol: v1.ProtocolTCP,
TargetPort: intstr.FromInt(int(app.Spec.Port)),
},
},
},
Status: v1.ServiceStatus{},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, &srv, func() error {
return nil
})
if err != nil {
return fmt.Errorf("unable to create Service: %v", err)
}
return nil
}
func (r *ApplicationReconciler) reconcileDelete(ctx context.Context, app *minettodevv1alpha1.Application) (ctrl.Result, error) {
l := log.FromContext(ctx)
l.Info("removing application")
controllerutil.RemoveFinalizer(app, finalizer)
err := r.Update(ctx, app)
if err != nil {
return ctrl.Result{}, fmt.Errorf("Error removing finalizer %v", err)
}
return ctrl.Result{}, nil
}
Para fazermos o deploy do nosso recurso customizado e do seu controller o SDK fornece comandos no seu makefile:
make manifests
make docker-build docker-push IMG=registry.hub.docker.com/eminetto/k8s-operator-talk:latest
make deploy IMG=registry.hub.docker.com/eminetto/k8s-operator-talk:latest
O primeiro comando gera todos os arquivos necessários para a criação do CRD. O segundo faz a geração de um container docker e o push para o repositório indicado. E o último comando faz a instalação do container gerado no cluster. Dica: é possível automatizar a geração e instalação do controller em seu ambiente de desenvolvimento usando o Tilt. No repositório onde estão estes códigos existe um Tiltfile que faz todo este trabalho. E para conhecer mais sobre o Tilt confira o post que fiz sobre a ferramenta.
Agora basta aplicar ao cluster o yaml com a definição do Application e o controller vai gerar o Deployment e o Service necessários para que a aplicação esteja em execução. Podemos conferir que os recursos foram criados com os comandos a seguir.
kubectl -n application-sample get applications
NAME AGE
application-sample 18s
kubectl -n application-sample get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
application-sample-deployment 2/2 2 2 41s
kubectl -n application-sample get pods
NAME READY STATUS RESTARTS AGE
application-sample-deployment-65b96554f8-8vv64 1/1 Running 0 56s
application-sample-deployment-65b96554f8-v54gp 1/1 Running 0 56s
kubectl -n application-sample get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
application-sample-service NodePort 10.43.63.164 <none> 80:32591/TCP 66s
Este post acabou ficando bem extenso, então tem outros assuntos que vou deixar para um próximo texto, como a parte de testes. Mas espero que eu tenha conseguido despertar o interesse neste assunto. É algo que eu estou bem empolgado e acredito que tem um potencial incrível para ajudar na criação de automações que facilitam muito a vida dos times de desenvolvimento e operações.