Composition Concepts
Esta é a parte mais importante do Crossplane. O Composition é o coração do Crossplane e o que o torna tão poderoso para abstrair qualquer coisa.
Vamos entender a partir de agora que os managed resources são apenas peças que utilizamos para montar uma solução. Para utilizar essas peças usamos as APIs que são criadas por cada provider. O que fizemos antes foi criar peça por peça, sem um controller para a solução como um todo. Deletar a solução envolveria em deletar cada uma das partes em ordem inversa.
Quando estudamos o Kubernetes aprendemos o que é um pod, depois um replicaset e depois um deployment. Entendemos que um deployment cria replicasets e estes criam os pods. Se fossemos criar um pod o nome do pod é o que definimos. Porém se criarmos um deployment veremos que o pod recebe alguns sufixos ao nome.
# Criando um pod sem controlador
❯ kubectl run myapp --image nginx
pod/myapp created
# Criando um deployment que cria um replicaset, que cria um pod
❯ kubectl create deployment myapp --image nginx
deployment.apps/myapp created
❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
myapp 1/1 Running 0 25s
myapp-5b5df85c44-z2795 1/1 Running 0 12s
❯ kubectl get rs
NAME DESIRED CURRENT READY AGE
myapp-5b5df85c44 1 1 1 12s
Podemos observar que o número 5b5df85c44 foi um número randômico criado pelo deployment para o replicaset e o nome do replicaset inicia com o nome do pod seguido também de um número randômico z2795.
Deletar o deployment envolve em remover o replicaset e por sua vez o pod. O que eu queremos mostrar é que existe um controller para isso e no final das contas composition é o controller para todos os recursos que criamos em sua definição.
- Composition funciona como um controller de alto nível, similar ao Deployment no Kubernetes.
- Hierarquia e nomes gerados automaticamente que os resources manages receberão
- Um único recurso (Composition) pode gerenciar vários recursos subjacentes, facilitando a criação e exclusão de toda a solução de uma vez.
Assim como o Deployment abstrai detalhes de implementação (como a geração de ReplicaSets), o Composition no Crossplane abstrai a complexidade dos recursos infraestrutura. Isso permite que desenvolvedores consumam recursos complexos sem precisar entender todos os detalhes subjacentes.
Compositions no permite definir o que algo é e codificar nossa expertise e expor serviços como abstrações.
Conceitos Inicial da API do Kubernetes
Não vamos entrar a fundo nisso é só um overview rápido.
Ao utilizar um Custom Resource Definition (CRD) no Kubernetes estendendo a API que o kube-api-server consegue interpretar, e essa extensão é definida usando um esquema baseado em OpenAPI (anteriormente conhecido como Swagger).
Quando você cria um CRD, você define a estrutura da sua nova resource (recurso) personalizada usando um esquema que segue as especificações do OpenAPI v3 para descrever:
- Os campos que seu recurso personalizado terá
- Os tipos de dados desses campos
- Validações que devem ser aplicadas
- Descrições para documentação
O Kubernetes utiliza essas definições de esquema OpenAPI para:
- Validar objetos quando são criados ou atualizados
- Gerar documentação para a sua API estendida
- Fornecer informações de tipo para clientes como kubectl
Essa abordagem permite que você estenda o Kubernetes de forma padronizada, aproveitando os mesmos mecanismos que o próprio Kubernetes usa para suas APIs nativas.
Aqui um exemplo de um CRD do kubernetes. Estamos criando uma API e seus parâmetros e criando algo novo.
apiVersion: apiextensions.k8s.io/v1 # Veja que estamos extendendo diretamente do k8s.
kind: CustomResourceDefinition
metadata:
name: webapps.example.com
spec:
group: example.com
names:
kind: WebApp
plural: webapps
singular: webapp
shortNames:
- wa
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- image
- replicas
properties:
image:
type: string
description: "A imagem do contêiner a ser usada para a aplicação web"
replicas:
type: integer
minimum: 1
maximum: 10
description: "Número de réplicas da aplicação web"
port:
type: integer
default: 80
description: "Porta que a aplicação expõe"
env:
type: array
description: "Variáveis de ambiente para o contêiner"
items:
type: object
required:
- name
- value
properties:
name:
type: string
value:
type: string
additionalPrinterColumns:
- name: Replicas
type: integer
description: Número de réplicas
jsonPath: .spec.replicas
- name: Image
type: string
description: Imagem do contêiner
jsonPath: .spec.image
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
Com essa definição acima, poderíamos aplicar o seguinte manifesto.
apiVersion: apps.example.org/v1alpha1
kind: WebApp
metadata:
name: minha-aplicacao
namespace: default
spec:
appName: minha-aplicacao
tier: medium
region: us-east-1
public: true
environment:
DATABASE_URL: ${.db.connection}
LOG_LEVEL: info
DEBUG: "true"
Mas se aplicarmos o manifesto acima o que irá acontecer?
Não acontece automaticamente nada além do armazenamento desse objeto na API do Kubernetes. O simples fato de definir um CRD e criar uma instância dele não resulta automaticamente na criação de pods, deployments ou qualquer outro recurso.
Para que algo aconteça quando você cria esse objeto WebApp, você precisa de um controlador personalizado
que:
- Observe os recursos do tipo WebApp
- Reaja às mudanças desses recursos
- Crie/atualize/exclua os recursos subjacentes (como Deployments, Services, etc.)
Sem um controlador, seu recurso WebApp ficará apenas armazenado no etcd do Kubernetes, mas não terá nenhum efeito prático no cluster. É essencialmente um dado estruturado que não faz nada por si só. O fluxo completo seria:
- Você define o CRD WebApp (estrutura de dados)
- Você implementa e implanta um controlador personalizado para processar objetos WebApp
- Você cria uma instância WebApp chamada "minha-webapp"
- O controlador detecta o novo objeto e cria os recursos subjacentes (Deployment, Service, etc.)
É exatamente assim que operators trabalham no Kubernetes se você está mais familiarizado com esse termo.
O Crossplane possui um CRD chamado CompositeResourceDefinition que atua como um "Custom Resource definition" dentro do ecossistema do Crossplane. Quem irá reagir a este recurso será o Crossplane, então estamos pulando a etapa 2 de implementar um controlador.
CompositeResourceDefinition (XRD)
CompositeResourceDefinition (XRD) do Crossplane é efetivamente uma espécie de Custom Resource Definition (CRD), mas com funcionalidades estendidas e um propósito específico dentro do ecossistema Crossplane.
Crossplane Core que roda no pod do crossplane no namespace crossplane-system é o responsável por gerenciar os CompositeResourceDefinitions (XRDs). Especificamente, o controlador de compositions (Composition Controller) que faz parte do Crossplane Core é o componente que observa, processa e gerencia o ciclo de vida dos recursos compostos definidos através de XRDs. Este controlador é implementado como parte da instalação principal do Crossplane e funciona independentemente dos providers específicos. Ele monitora a API do Kubernetes para eventos relacionados a XRDs, recursos compostos (XRs) e claims (XRCs), e implementa toda a lógica necessária para traduzir esses recursos de alto nível em recursos gerenciados de acordo com as definições de Composition.
Diferente dos recursos específicos de providers (como recursos AWS, GCP, etc.), que são gerenciados pelos controladores de cada provider, os recursos de compositions são sempre gerenciados por este controlador central do Crossplane.
Poderíamos fazer a seguinte analogia:
- Controlador de composistion ≈ Controlador de Deployment
- Providers ≈ Controladores de ReplicaSet
- Recursos gerenciados pelos providers ≈ Pods
Vamos então utilizar o controlador de composition para criar tudo o que precisamos e extender a API ao nosso favor!
Depois que entendemos o Kubernetes, raramente criamos pods sem ser através de deployments. Quando entendermos compositions raramente utilizaremos um managed resource sozinho.
Vamos criar o nosso primeiro XRD.
- O grupo é onde o Kind que vamos criar abaixo estará na API do Kubernetes.
---
apiVersion: apiextensions.crossplane.io/v1 # Extendendo pelo Crossplane agora
kind: CompositeResourceDefinition
metadata:
name: hello.devsecops.puziol.com.br
spec:
group: devsecops.puziol.com.br
names:
kind: Hello
plural: hello
claimNames:
kind: HelloClain
plural: helloclaim
versions:
# Siga as versões como o kubernetes faz, v1, v1beta1, v1alpha1, etc.
# Podemos declarar várias versões para ter retro compatibilidade e served a fará ficar ou não disponível na api para ser referenciado
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema: {}
Podemos ter várias versões, mas somente uma delas pode ser referenceable.
Schema que ainda não foi mostrado é baseado no openapi. O que fizemos até agora foi abrir um espaço na api, mas não tem nada definido. A parte de schemas espera receber alguma coisa na request que será feita, mas por enquanto deixamos em branco.
Para conhecer melhor sobre esse yaml vamos em https://docs.crossplane.io/api.
Vamos aplicar o manifesto acima.
❯ kubectl apply -f compositiondefinition.yaml
compositeresourcedefinition.apiextensions.crossplane.io/hello.devsecops.puziol.com.br created
# Vamos procurar os custom resources que ele gerou.
❯ kubectl api-resources| grep hello
hello devsecops.puziol.com.br/v1alpha1 false Hello
helloclaim devsecops.puziol.com.br/v1alpha1 true HelloClain
# Só para confirmar que isso é de fato um custom resource definition no kubernetes
❯ kubectl get customresourcedefinitions.apiextensions.k8s.io | grep hello
hello.devsecops.puziol.com.br 2025-03-19T11:58:19Z
helloclaim.devsecops.puziol.com.br 2025-03-19T11:58:19Z
De curiosidade se quiser ver isso exposto vamos fazer o seguinte comando
kubectl proxy
Só um spoiler rápido, Hello é um recurso em nível de cluster, e HelloClaim é o mesmo recurso em nível de namespace, mas falaremos disso depois.
A api do kubernetes estará disponível em 127.0.0.1:8001 no seu navegador. O path foi criado...
Fazendo um curl para os os patchs disponíveis..
❯ curl http://127.0.0.1:8001/apis/devsecops.puziol.com.br
{
"kind": "APIGroup",
"apiVersion": "v1",
"name": "devsecops.puziol.com.br",
"versions": [
{
"groupVersion": "devsecops.puziol.com.br/v1alpha1",
"version": "v1alpha1"
}
],
"preferredVersion": {
"groupVersion": "devsecops.puziol.com.br/v1alpha1",
"version": "v1alpha1"
}
}%
# Aqui ja podemos ver os recursos disponíveis.
❯ curl http://127.0.0.1:8001/apis/devsecops.puziol.com.br/v1alpha1
{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "devsecops.puziol.com.br/v1alpha1",
"resources": [
{
"name": "helloclaim",
"singularName": "helloclain",
"namespaced": true, ## EM NÍVEL DE NAMESPACE
"kind": "HelloClain",
"verbs": [
"delete",
"deletecollection",
"get",
"list",
"patch",
"create",
"update",
"watch"
],
"categories": [
"claim"
],
"storageVersionHash": "KaYrjAdZFTk="
},
{
"name": "helloclaim/status",
"singularName": "",
"namespaced": true,
"kind": "HelloClain",
"verbs": [
"get",
"patch",
"update"
]
},
{
"name": "hello",
"singularName": "hello",
"namespaced": false, ## EM NÍVEL DE CLUSTER
"kind": "Hello",
"verbs": [
"delete",
"deletecollection",
"get",
"list",
"patch",
"create",
"update",
"watch"
],
"categories": [
"composite"
],
"storageVersionHash": "kBh7PX4F5h4="
},
{
"name": "hello/status",
"singularName": "",
"namespaced": false,
"kind": "Hello",
"verbs": [
"get",
"patch",
"update"
]
}
]
}%
E conferindo o nosso recurso em sí temos.
❯ kubectl get compositeresourcedefinitions.apiextensions.crossplane.io
NAME ESTABLISHED OFFERED AGE
hello.devsecops.puziol.com.br True True 9m37s
## xrds ou xrd é também pode ser usado.
❯ kubectl get xrds
NAME ESTABLISHED OFFERED AGE
hello.devsecops.puziol.com.br True True 9m39s
A definição desses recursos são fáceis de ver pois são detectaveis e podemos utilizar inclusive nos IDPs e em muitas outras ferramentas.
❯ kubectl explain hello.devsecops.puziol.com.br --recursive
GROUP: devsecops.puziol.com.br
KIND: Hello
VERSION: v1alpha1
DESCRIPTION:
<empty>
FIELDS:
apiVersion <string>
kind <string>
metadata <ObjectMeta>
annotations <map[string]string>
creationTimestamp <string>
deletionGracePeriodSeconds <integer>
deletionTimestamp <string>
finalizers <[]string>
generateName <string>
generation <integer>
labels <map[string]string>
managedFields <[]ManagedFieldsEntry>
apiVersion <string>
fieldsType <string>
fieldsV1 <FieldsV1>
manager <string>
operation <string>
subresource <string>
time <string>
name <string>
namespace <string>
ownerReferences <[]OwnerReference>
apiVersion <string> -required-
blockOwnerDeletion <boolean>
controller <boolean>
kind <string> -required-
name <string> -required-
uid <string> -required-
resourceVersion <string>
selfLink <string>
uid <string>
spec <Object> -required-
claimRef <Object>
apiVersion <string> -required-
kind <string> -required-
name <string> -required-
namespace <string> -required-
compositionRef <Object>
name <string> -required-
compositionRevisionRef <Object>
name <string> -required-
compositionRevisionSelector <Object>
matchLabels <map[string]string> -required-
compositionSelector <Object>
matchLabels <map[string]string> -required-
compositionUpdatePolicy <string>
enum: Automatic, Manual
publishConnectionDetailsTo <Object>
configRef <Object>
name <string>
metadata <Object>
annotations <map[string]string>
labels <map[string]string>
type <string>
name <string> -required-
resourceRefs <[]Object>
apiVersion <string> -required-
kind <string> -required-
name <string>
writeConnectionSecretToRef <Object>
name <string> -required-
namespace <string> -required-
status <Object>
claimConditionTypes <[]string>
conditions <[]Object>
lastTransitionTime <string> -required-
message <string>
reason <string> -required-
status <string> -required-
type <string> -required-
connectionDetails <Object>
lastPublishedTime <string>
Vamos criar um recurso do tipo Hello.
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: Hello
metadata:
name: hello-test
spec: {} # Vamos iniciar vazio
❯ kubectl apply -f hellocomposition.yaml
hello.devsecops.puziol.com.br/hello-test created
❯ k get hello.devsecops.puziol.com.br
NAME SYNCED READY COMPOSITION AGE
hello-test False 6s
Não está sincronizado porque o controller do Crossplane não conseguiu nem começar a trabalhar nele, afinal só fizemos declarações que não informam o Crossplane o que ele deve fazer e isso era o esperado. A informação que falta é qual composition ele deveria disparar, mas ainda não fizemos nenhuma.
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: Hello
metadata:
name: hello-test
spec: {} # Vamos iniciar vazio
Vamos pensar nesse xrd.
---
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: sqls.devsecops.puziol.com.br
spec:
group: devsecops.puziol.com.br
names:
kind: SQL
plural: sqls
claimNames:
kind: SQLClaim
plural: sqlclaims
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema: {}
Se fizessemos se aplicarmos o manifesto abaixo, claro que neste momento não temos ninguem respondendo a isso, mas se tivesse, a composition que estivesse definidida para responder irá iniciar.
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
metadata:
name: database
spec: {}
Abaixo temos o início de uma composition e vamos mostrar como se ela sabe que precisa responder no endpoint correto.
---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgresql
spec:
compositeTypeRef: #ISSO INDICA QUE ESTA COMPOSITION RESPONDERÁ QUANDO.
# ESTA FOR O GROUP
apiVersion: devsecops.puziol.com.br/v1alpha1
# E ESTE FOR O KIND
kind: SQL
resources:
#.... continua...
Porém, poderíamos ter várias compositions respondendo no mesmo group/kind (endpoint). Imagine que uma composition criará o banco de dados na AWS, e a outra no GCP.
Composition para GCP.
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: gcp-postgresql
labels: # colocamos labels para apontar diferencia uma da outra.
provider: gcp
db: postgresql
spec:
compositeTypeRef:
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
resources: # para gcp
#.... continua...
Composition para AWS.
````yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: aws-postgresql
labels: # colocamos labels para apontar diferencia uma da outra.
provider: aws
db: postgresql
spec:
compositeTypeRef:
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
resources: # para aws
#.... continua...
Quando definirmos um manifesto que queremos um banco SQL PostgreSQL no GCP faríamos assim...
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQL
metadata:
name: my-postgressql
spec:
#...
## DAS COMPOSITIONS QUE RESPONDEM VAMOS SELECIONAR A QUE TEM ESSAS DUAS LABELS
compositionSelector:
matchLabels:
provider: gcp
db: postgresql
Então a composition gcp-postgresql que iria iniciar.
Não é possível falar de composition sem falar de XRD, pois uma coisa está ligada na outra e uma coisa chama a outra. Ao fazer uma chamada de API através da aplicação de manifestos estamos fazendo para o XRD e podemos passar muitos parâmetros que podem ser utilizados dentro das compositions.
Observe que eu falei PODEM. Claro que se vamos pedir parâmetros queremos utilizar, mas não precisaríamos se não quisermos.
Vamos explorar um pouco mais o XRD para receber parâmetros e encaminhar parâmetros mais tarde para as compositions. Se vamos criar um banco de dados podemos querer definir a versão, o tamanho, o nome, e outras coisas.
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: sqls.devsecops.puziol.com.br
spec:
group: devsecops.puziol.com.br
names:
kind: SQL
plural: sqls
claimNames:
kind: SQLClaim
plural: sqlclaims
versions:
- name: v1alpha1
served: true
referenceable: true
schema: # Aqui já vamos definir o objeto da request
openAPIV3Schema:
type: object
properties:
spec:
type: object # poderia ser object, array, string, integer, number, boolean
# Acredito que só usar object será a melhor opção em 99% dos casos, para fica mais portável e permitir melhorias
# Todo object tem o properties. Dentro dele podemos definir qualquer coisa.
properties:
id: # Nós queremos ter um ID,
type: string
description: Database ID
parameters:
type: object # Outro objeto dentro deste.
properties:
version:
description: The DB version depends on the DB type and versions available in the selected provider.
type: string
size:
description: "Supported sizes: small, medium, large"
type: string
# Estamos forçando que somente estas são as entradas permitidas
enum:
- small
- medium
- large
default: small
databases:
# Criar servidor de banco de dados não faz sentido se não ter banco de dados dentro, então vamos passar uma lista.
description: The list of databases to create inside the DB server.
type: array
items:
type: string
# Se não quiser, podemos eliminar o schema.
schemas:
## Vamos passar um schema para cada um dos banco
description: Database schema. Atlas operator (https://atlasgo.io/integrations/kubernetes/operator) needs to be installed in the cluster. Leave empty if schema should NOT be applied.
type: array # Um array de objetos!
# Poderíamos usar para inicializar rodar comandos sql na inicialização do database
# - database1
# sql: uma string
# - database2
# sql: uma string
# ...
items:
type: object
properties:
database:
description: The name of the database where to apply the schema.
type: string
sql:
description: The SQL to apply.
type: string
required:
- version
required:
- parameters
Podemos pensar que ficou faltando passar um usuário, mas vamos deixar a composition resolver isso com um usuário default e senha randômica.
Com essa definição acima como sería o objeto que estamso esperando receber?
apiVersion: devsecops.puziol.com.br/v1alpha1
kind: SQLClaim
metadata:
name: teste-db
annotations:
organization: DevSecOps
spec:
id: teste-db
compositionSelector: # Queremos que a composition que responde com essas labels faça isso.
matchLabels:
provider: google
db: postgresql
parameters:
version: "13"
size: small
databases:
- db-01
- db-02
schemas:
- database: db-01
sql: |
create table videos (
id varchar(50) not null,
description text,
primary key (id)
);
create table comments (
id serial,
video_id varchar(50) not null,
description text not null,
primary key (id),
CONSTRAINT fk_videos FOREIGN KEY(video_id) REFERENCES videos(id)
);
Todos esses valores que passamos serão encaminhados à composition correta e podemos trabalhar com eles na montagem de recursos. É a mesma coisa que passar variáveis para um projeto Terraform.
E se não passarmos qual a composition queremos utilizar?
Podemos definir qual a composition será a default colocando esta annotation crossplane.io/is-default-composition: "true"
em uma delas.
Se duas tiverem esta mesma label de default, aquela que for encontrada primeiro, ou seja, a que alfabeticamente vem primeiro, será escolhida. O mesmo vale se nenhuma for definida como default.
Tendo uma composition default, ou somente uma composition que responde ao XRD não precisamos passar o compositionSelector caso queiramos.
Para saber quais as compositions respondem ao XRD é verificar o status do próprio XRD, que pode conter referências às Compositions que o usam.
kubectl describe xrd sqls.devsecops.puziol.com.br