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:

  1. kubectl
  2. Minikube
  3. Helm
  4. MySQL client (opcional)

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:

  1. Configurar containers de inicialização usando initContainers.
  2. Executar comandos específicos através do containers[].command.
  3. Criar e usar configmap.
  4. Usar o readinessProbe pra Habilitar tráfego pro contêiner somente quando a aplicação estiver respondendo.
  5. Rodar scripts usando Job.
  6. 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.