Rodando migrações de banco de dados no Kubernetes
Grande parte dos frameworks web modernos dão suporte a migrações de banco de dados, porém apesar de corriqueira essa tarefa apresenta seus desafios quando vamos automatizar os deploys de nossas aplicações. Nesse tutorial mostro uma forma de fazer isso no Kubernetes e os cuidados que devemos tomar para evitar problemas de concorrência.
Pré-requisitos
Para esse laboratório você precisará de:
Iniciando o Helm no cluster:
helm init --history-max 200
Caso tenha caído de paraquedas aqui e não conheça o Helm, Minikube ou kubectl, confira meus tutoriais anteriores pra maiores informações.
Levantando o banco de dados
Vamos usar o MySQL como banco de dados. Usaremos o Helm para instalá-lo no nosso cluster:
helm install stable/mysql \
--name="dummy-db" \
--set="mysqlUser=dummy_user" \
--set="mysqlPassword=dummy_password" \
--set="mysqlDatabase=dummy_database" \
--set="persistence.size=1Gi"
Pra garantir que o banco de dados está acessível, vamos redirecionar a porta 3306 do serviço criado pelo Helm. Pra isso primeiro precisamos saber como o Helm nomeou o esse serviço:
kubectl get svc
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# dummy-db-mysql ClusterIP 10.110.38.88 <none> 3306/TCP 2m15s
# kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 6m43s
Com o nome do serviço em mãos (dummy-db-mysql
), fazemos o redirecionamento da porta 3306 do serviço para a porta 7000 na nossa máquina:
kubectl port-forward svc/dummy-db-mysql 7000:3306
Daí basta abrir uma conexão com o banco na porta 7000.
mysql \
-u dummy_user \
-pdummy_password \
--port 7000 \
--host 127.0.0.1 \
--execute="show databases"
# +--------------------+
# | Database |
# +--------------------+
# | information_schema |
# | dummy_database |
# +--------------------+
Criando configmap com os dados de acesso ao banco
Para acessar o banco de dados no cluster vamos precisar do usuário, senha, nome do banco, porta e host. Usaremos a senha que já está disponível em um secret criado pelo Helm durante a instalação do MySQL:
kubectl get secrets
# NAME TYPE DATA AGE
# default-token-ndkzk kubernetes.io/service-account-token 3 26m
# dummy-db-mysql Opaque 2 21m
Em relação ao restante dos dados, vamos criar um configmap que será usado mais a frente:
apiVersion: v1
kind: ConfigMap
metadata:
name: database-configmap
data:
db.name: dummy_database
db.user: dummy_user
db.host: dummy-db-mysql
db.port: "3306"
Configmap é um objeto do Kubernetes que usamos pra armazenar dados não sensíveis de forma que possa ser usado em um ou mais pods.
Repare que a porta é uma string. Isso é importante porque ela será passada pra aplicação em uma variável de ambiente, e variáveis de ambiente sempre devem ser uma string. Vamos aplicar essa configuração no Kubernetes:
kubectl apply -f database-configmap.yaml
# configmap/database-configmap created
Consultando os configmaps existentes:
kubectl get configmaps
# NAME DATA AGE
# database-configmap 4 57s
# dummy-db-mysql-test 1 27m
Pra garantir que o conteúdo está correto podemos forçar o output como yaml:
kubectl get configmap database-configmap -o yaml
# apiVersion: v1
# data:
# db.host: dummy-db-mysql
# db.name: dummy_database
# db.port: "3306"
# db.user: dummy_user
# kind: ConfigMap
# metadata:
# annotations:
# kubectl.kubernetes.io/last-applied-configuration: |
# {"apiVersion":"v1","data":{"db.host":"dummy-db-mysql","db.name":"dummy_database","db.port":"3306","db.user":"dummy_user"},"kind":"ConfigMap","metadata":{
# "annotations":{},"name":"database-configmap","namespace":"default"}}
# creationTimestamp: "2019-07-11T00:51:01Z"
# name: database-configmap
# namespace: default
# resourceVersion: "2719"
# selfLink: /api/v1/namespaces/default/configmaps/database-configmap
# uid: f40d15bb-a375-11e9-8c10-080027559648
Diversos dados nesse yaml foram inseridos pelo próprio Kubernetes, e podemos ignorá-los. Como esperado, o campo data
contém exatamente o que definimos na configmap.
Armazenando a SECRET_KEY em um secret
Usaremos uma aplicação Django como exemplo, e toda aplicação Django depende de uma configuração chamada SECRET_KEY. Dado que é uma chave secreta, vamos armazená-la em uma secret:
kubectl create secret \
generic dummy-secret \
--from-literal="secret.key=bd569faf-5ec8-493f-8308-dc3077d6b3f9"
# secret/dummy-secret created
Escolhi o valor da chave acima aleatoriamente.
Levantando a aplicação
Vamos começar levantando a aplicação Django com apenas uma réplica. Mais a frente explico o porquê isso é tão importante.
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app
spec:
replicas: 1
selector:
matchLabels:
app: dummy-app
template:
metadata:
labels:
app: dummy-app
spec:
containers:
- name: dummy
image: ematos/k8s-db-migration:1.0
readinessProbe:
httpGet:
path: /
port: 8000
periodSeconds: 7
initialDelaySeconds: 5
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
ports:
- containerPort: 8000
Aqui já fazemos uso do configmap que criamos anteriormente, além do secret com a senha do banco de dados e o secret com a secret key do Django.
Um ponto importante é a configuração do readiness proble (spec.template.spec.readinessProbe
). Essa configuração informa ao Kubernetes que o pod só pode receber tráfego depois que o endpoint /
(definido em spec.template.spec.readinessProbe.httpGet.path
) responder com status 200. Após o pod estar de pé o Kubernetes espera 5 segundos antes de fazer a primeira checagem para saber se o pod responde positivamente (conforme spec.template.spec.readinessProbe.initialDelaySeconds
). Depois da primeira tentativa, o Kubernetes faz a checagem a cada 7 segundos (de acordo com spec.template.spec.readinessProbe.periodSeconds
)
kubectl apply -f app.yaml
# deployment/dummy-app created
Alguns segundos mais tarde a aplicação deve estar rodando, daí podemos fazer um redirecionamento de portas pra ver se realmente está tudo ok:
kubectl port-forward deployment/dummy-app 8000:8000
Em seguida acessamos http://localhost:8000
no navegador. Se você configurou tudo corretamente, deve ver seguinte resultado:
Agora vamos consultar o banco de dados pra verificar se existe alguma tabela nova:
kubectl port-forward svc/dummy-db-mysql 7000:3306
Depois:
mysql \
-u dummy_user \
-pdummy_password \
--port 7000 \
--host 127.0.0.1 \
dummy_database \
--execute="show tables"
O resultado esperado é que não apareça nenhum output porque não rodamos nenhuma migração ainda.
Rodando migrações automaticamente
Migrações devem rodar antes que a aplicação seja iniciada. No Kubernetes conseguimos fazer isso usando init containers. Essa funcionalidade nos permite rodar qualquer tipo de script antes que a aplicação inicie, e caso o script não finalize com sucesso, o Kubernetes faz novas tentativas até conseguir. Se o script rodar com sucesso, aí sim a aplicação é iniciada, e esse é exatamente o comportamento que precisamos.
Atualizando app.yaml
pra configurar nosso init container:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app
spec:
replicas: 1
selector:
matchLabels:
app: dummy-app
template:
metadata:
labels:
app: dummy-app
spec:
initContainers:
- name: db-migrate
image: ematos/k8s-db-migration:1.0
command: ['python', 'manage.py', 'migrate']
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
containers:
- name: dummy
image: ematos/k8s-db-migration:1.0
readinessProbe:
httpGet:
path: /
port: 8000
periodSeconds: 7
initialDelaySeconds: 5
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
ports:
- containerPort: 8000
A configuração spec.template.spec.initContainers
é bastante semelhante à configuração spec.template.spec.containers
. No comando de inicialização colocamos o comando para rodar a migração no banco de dados. Subindo essa alteração:
kubectl apply -f app.yaml
# deployment.apps/dummy-app configured
Se você for rápido o suficiente pra consultar o status dos pods, deve ver nossa aplicação em estágio de inicialização:
kubectl get pods -l="app=dummy-app"
# NAME READY STATUS RESTARTS AGE
# dummy-app-d6b64d6b9-76c6f 0/1 Init:0/1 0 12s
A coluna READY
muda para 1/1
quando no pod estiver pronto para receber tráfego, ou seja, quando o readiness probe tiver um retorno de sucesso.
Agora vamos listar as tabelas no banco de dados pra garantir que o script de migração realmente rodou (não esqueça de rodar kubectl port-forward svc/dummy-db-mysql 7000:3306
antes):
mysql \
-u dummy_user \
-pdummy_password \
--port 7000 \
--host 127.0.0.1 \
dummy_database \
--execute="show tables"
# +----------------------------+
# | Tables_in_dummy_database |
# +----------------------------+
# | auth_group |
# | auth_group_permissions |
# | auth_permission |
# | auth_user |
# | auth_user_groups |
# | auth_user_user_permissions |
# | django_admin_log |
# | django_content_type |
# | django_migrations |
# | django_session |
# +----------------------------+
Escalando a aplicação
Para escalar a aplicação basta alterar o número de réplicas:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app
spec:
replicas: 4
selector:
matchLabels:
app: dummy-app
template:
metadata:
labels:
app: dummy-app
spec:
initContainers:
- name: db-migrate
image: ematos/k8s-db-migration:1.0
command: ['python', 'manage.py', 'migrate']
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
containers:
- name: dummy
image: ematos/k8s-db-migration:1.0
readinessProbe:
httpGet:
path: /
port: 8000
periodSeconds: 7
initialDelaySeconds: 5
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
ports:
- containerPort: 8000
Aplicando a nova configuração e vendo o status dos pods:
kubectl apply -f app.yaml
# deployment.apps/dummy-app configured
kubectl get pods
# NAME READY STATUS RESTARTS AGE
# dummy-app-5f889b85df-285hv 0/1 Init:0/1 0 2s
# dummy-app-5f889b85df-ktdc8 1/1 Running 0 37s
# dummy-app-5f889b85df-lr4p6 0/1 Init:0/1 0 2s
# dummy-app-5f889b85df-t7rs7 0/1 Init:0/1 0 2s
# dummy-app-5f889b85df-wvh9z 0/1 Init:0/1 0 2s
# dummy-db-mysql-797f8c5474-7bc9h 1/1 Running 0 20m
Agora temos quatro novas réplicas em estágio de inicialização, ou seja, todas estão rodando o script de migração simultaneamente. Como não há migrações pendentes, não existe problema em rodar o script diversas vezes em paralelo, porém esse não é o comportamento desejado quando existir uma nova migração pra rodar. Veremos na próxima seção como evitar isso.
Configurando o Rolling Update
Rolling update é uma configuração que diz como será a substituição de réplicas antigas por réplicas novas quando ocorrer um deploy. No nosso caso queremos configurar da seguinte forma: Somente uma réplica da nova versão será levantada de cada vez (para garantir que não haverá conflito do script de migração do banco), e cada réplica antiga só poderá ser removida quando já existir uma réplica nova para substituí-la:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # somente 1 réplica nova criada por vez
maxUnavailable: 0 # nenhuma réplica antiga será removida até existir uma nova
selector:
matchLabels:
app: dummy-app
template:
metadata:
labels:
app: dummy-app
spec:
initContainers:
- name: db-migrate
image: ematos/k8s-db-migration:1.0
command: ['python', 'manage.py', 'migrate']
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
containers:
- name: dummy
image: ematos/k8s-db-migration:1.0
readinessProbe:
httpGet:
path: /
port: 8000
periodSeconds: 7
initialDelaySeconds: 5
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
ports:
- containerPort: 8000
O Kubernetes usa a configuração do readinessProbe
para saber se já pode considerar a aplicação como saudável e prosseguir para levantar uma nova réplica ou matar uma réplica antiga.
Aplicando as modificações:
kubectl apply -f app.yaml
# deployment.apps/dummy-app configured
kubectl get pods -l="app=dummy-app"
# NAME READY STATUS RESTARTS AGE
# dummy-app-678db74b56-h67wp 1/1 Running 0 2m49s
# dummy-app-678db74b56-lkg9w 1/1 Running 0 3m54s
# dummy-app-678db74b56-qpwzw 1/1 Running 0 2m49s
# dummy-app-678db74b56-trtw8 1/1 Running 0 2m49s
Apesar da configuração ter sido aplicada com sucesso, o Kubernetes entendeu que não havia necessidade de substituir os pods. Para forçar essa substituição vamos adicionar uma label qualquer ao nosso app.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: dummy-app
template:
metadata:
labels:
app: dummy-app
force: deploy # label aleatória criada só pra forçar um deploy
spec:
initContainers:
- name: db-migrate
image: ematos/k8s-db-migration:1.0
command: ['python', 'manage.py', 'migrate']
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
containers:
- name: dummy
image: ematos/k8s-db-migration:1.0
readinessProbe:
httpGet:
path: /
port: 8000
periodSeconds: 7
initialDelaySeconds: 5
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
ports:
- containerPort: 8000
Aplicando a nova configuração:
kubectl apply -f app.yaml
# deployment.apps/dummy-app configured
Analisando os status dos pods podemos ver que somente uma réplica fica de pé por vez:
kubectl get pods -l="app=dummy-app"
# NAME READY STATUS RESTARTS AGE
# dummy-app-678db74b56-lkg9w 1/1 Running 0 13m
# dummy-app-678db74b56-tw5gv 1/1 Terminating 0 3m40s
# dummy-app-678db74b56-xsc6q 1/1 Running 0 3m40s
# dummy-app-678db74b56-zpdcz 1/1 Running 0 3m40s
# dummy-app-6b98c9cb8-48cs4 0/1 Init:0/1 0 1s
# dummy-app-6b98c9cb8-z78bc 1/1 Running 0 12s
No output acima temos quatro réplicas saudáveis (READY 1/1
e STATUS Running
) por conta da configuração de maxUnavailable: 0
, e somente uma réplica sendo levantada por vez por conta da configuração maxSurge: 1
.
Subindo uma nova versão da aplicação
Vamos subir uma nova versão (com migração) mudando a tag da imagem pra 2.0
(não esquecer de mudar em containers
e initContainers
), e também manteremos a quantidade de réplicas em quatro:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dummy-app
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: dummy-app
template:
metadata:
labels:
app: dummy-app
spec:
initContainers:
- name: db-migrate
image: ematos/k8s-db-migration:2.0
command: ['python', 'manage.py', 'migrate']
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
containers:
- name: dummy
image: ematos/k8s-db-migration:2.0
readinessProbe:
httpGet:
path: /
port: 8000
periodSeconds: 7
initialDelaySeconds: 5
env:
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
ports:
- containerPort: 8000
Aplicando essa nova versão do app.yaml:
kubectl apply -f app.yaml
# deployment.apps/dummy-app configured
Aguardando todas as réplicas serem substituídas, vamos ver os logs dos contêineres que tentaram rodar a migração:
kubectl logs -l="app=dummy-app" -c="db-migrate"
# Operations to perform:
# Apply all migrations: admin, auth, contenttypes, core, sessions
# Running migrations:
# No migrations to apply.
# Operations to perform:
# Apply all migrations: admin, auth, contenttypes, core, sessions
# Running migrations:
# No migrations to apply.
# Operations to perform:
# Apply all migrations: admin, auth, contenttypes, core, sessions
# Running migrations:
# No migrations to apply.
# Operations to perform:
# Apply all migrations: admin, auth, contenttypes, core, sessions
# Running migrations:
# Applying core.0001_initial... OK
Vamos que somente um deles rodou a migração (últimas quatro linhas no output acima).
Sobre escalar e rodar migração
Um ponto muito importante é que não devemos escalar e rodar migração ao mesmo tempo, isso porque o Kubernetes ignora a configuração de rolling update quando aumentamos a quantidade de réplicas da aplicação.
Resumindo: Quando for rodar migração, não mude a quantidade de réplicas. Quando for mudar a quantidade de réplicas, não rode migrações.
Rollback
Pela minha experiência felizmente não é muito comum precisarmos fazer rollback de migrações no ambiente de produção, mas é importante saber fazê-lo se necessário.
Para fazer rollback não basta usar uma versão mais antiga da imagem da aplicação. Pelo contrário, precisamos da versão mais recente possível para que tenhamos à disposição todos os passos de como desfazer as migrações.
Apesar de podermos configurar isso no deployment, não seria prático ficar alterando o arquivo que descreve o comportmento da aplicação. Pra resolver esse problema usaremos um job.
No Kubernetes, job é um objeto que executa um script até que este finalize. Diferente do deployment, por exemplo, já é esperado que o job tenha um fim, ou seja, o Kubernetes não tentará executá-lo novamente.
Vamos à configuração do job:
apiVersion: batch/v1
kind: Job
metadata:
name: dummy-db-migration-job
spec:
template:
metadata:
name: dummy-db-migration-job
spec:
restartPolicy: Never
containers:
- name: dummy
image: ematos/k8s-db-migration:latest
command:
- python
- manage.py
- migrate
- $(MIGRATION_APP_LABEL)
- $(MIGRATION_VERSION)
env:
- name: MIGRATION_APP_LABEL
value: core
- name: MIGRATION_VERSION
value: 0001_initial
- name: APP_SECRET_KEY
valueFrom:
secretKeyRef:
name: dummy-secret
key: secret.key
- name: APP_DB_HOST
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.host
- name: APP_DB_USER
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.user
- name: APP_DB_PASSWORD
valueFrom:
secretKeyRef:
name: dummy-db-mysql
key: mysql-password
- name: APP_DB_NAME
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.name
- name: APP_DB_PORT
valueFrom:
configMapKeyRef:
name: database-configmap
key: db.port
Nosso job está configurado pra nunca reiniciar em caso de falha (spec.template.spec.restartPolicy: Never
). Além disso eu gostaria de chamar a atenção pro fato de estarmos usando a imagem com tag latest
(poderia ter sido a mais recente que usamos: 2.0
), isso pra garantir que o código tem todas as versões necessárias das migrações, e por consequência saberá como fazer todos os rollbacks necessários.
Além das variáveis de ambiente que definimos no deployment, usamos mais duas no job. Uma para dizer de qual app do Django queremos fazer rollback, e outra com a versão para a qual queremos fazer rollback. Essas variáveis são interpoladas no comando que o contêiner executa (spec.template.spec.containers[0].command
).
Aplicando esse job:
kubectl apply -f job.yaml
# job.batch/dummy-db-migration-job created
Alguns segundos mais tarde o job já deve ter sido completado:
kubectl get pods,jobs
# NAME READY STATUS RESTARTS AGE
# pod/dummy-app-67659d6d57-bpbrn 1/1 Running 1 23h
# pod/dummy-app-67659d6d57-btxg2 1/1 Running 1 23h
# pod/dummy-app-67659d6d57-gscj2 1/1 Running 1 23h
# pod/dummy-app-67659d6d57-ksszn 1/1 Running 1 23h
# pod/dummy-db-migration-job-lx7ck 0/1 Completed 0 24s
# pod/dummy-db-mysql-df6d64979-qk4km 1/1 Running 1 23h
# NAME COMPLETIONS DURATION AGE
# job.batch/dummy-db-migration-job 1/1 5s 24s
É possível ver os logs gerados pelo job:
kubectl logs dummy-db-migration-job-lx7ck
# Operations to perform:
# Target specific migration: 0001_initial, from core
# Running migrations:
# No migrations to apply.
Repare que o pod criado pelo job não é destruído automaticamente. É preciso removê-lo manualmente:
kubectl delete job dummy-db-migration-job # ou kubectl delete -f job.yaml
Job para todas as migrações?
Se usamos um job para fazer rollback, por que não usá-lo para todas as migrações? Não sou muito fã dessa opção porque é um pouco mais complexo de automatizá-la. Imagine que o deploy da aplicação foi atumatizado numa ferramenta de integração contínua. Se utilizarmos um job para fazer as migrações nós seremos obrigados a monitorá-lo para saber quando terminou e se terminou com sucesso ou falha. Outro ponto ruim é que sempre teremos que “lembrar” de rodar o job antes de levantar a aplicação. Quando usamos init containers, tudo já fica embutido num só lugar.
Conclusão
Nesse tutorial vimos como:
- Configurar containers de inicialização usando
initContainers
. - Executar comandos específicos através do
containers[].command
. - Criar e usar
configmap
. - Usar o
readinessProbe
pra Habilitar tráfego pro contêiner somente quando a aplicação estiver respondendo. - Rodar scripts usando
Job
. - Configurar o ritmo de atualização através do
RollingUpdate
.
A aplicação Django que deu origem às imagens usadas no tutorial e os arquivos de configuração estão disponíveis no meu Github. As imagens usadas aqui estão no meu DockerHub com as seguintes tags: 1.0
, 2.0
, 3.0
e latest
.