Kubernetes: запуск SQL-миграций — Kubernetes Job и Helm hook

Автор: | 10/26/2020
 

Имеется проект, которому во время деплоя надо выполнить SQL-миграции.

Для запуска миграций надо склонировать репозиторий из Github, и затем выполнить собственно миграции, которые в нём хранятся.

Сейчас у нас для этого используются Kubernetes initContainers, причём два — сначала один, с git, клонирует репозиторий с миграциями в Kubernetes Volume, второй с sql-migrate — запускает из этого расшаренного волюма миграции.

Тут есть несколько проблем:

  1. каждый раз, когда запускается новый под — он запускает миграции
  2. если в Deployment запускается несколько подов — они одновременно запустят миграции
  3. если миграции займут долгое время — то под не будет отвечать на readiness проверку, и Kubernetes может его убить, не дав завершить миграции

Что бы избежать этих проблем — переделаем наш сетап, и используем Kubernetes Job что бы запускать только один под с миграциями, и Helm Hooks, что бы запускать эту Job перед непосредственно деплоем новых подов.

Подготовка

Docker image

Создадим свой Docker образ с блекджеком и git, используя https://github.com/rubenv/sql-migrate.

Пишем Dockerfile:

FROM golang:alpine AS builder
RUN apk add --no-cache git gcc g++
RUN go get -v github.com/rubenv/sql-migrate/sql-migrate
RUN mv /go/bin/sql-migrate /bin/sql-migrate

Собираем, пушим:

docker build -t projectname/sql-migrate-git .
docker push projectname/sql-migrate-git

Git authentification

Второй вопрос — аутентификация.

Сейчас Git-контейнер выполняет аутентификацию через приватный RSA-ключ, который получается из Kubernetes Secrets, заносится в переменную, из которой внутри контейнера bash-скриптом /opt/git/git.sh создаётся файл ключа /root/.ssh/id_rsa, который потом используется для аутентификации в Github.

Полностью initContainers в Deployment сейчас выглядят так:

...
      initContainers:
      - name: git-clone
        image: projectname/git-cloner
        env:
        - name: SSH_PRIVATE_KEY
          valueFrom:
            secretKeyRef:
              name: git-ssh-key
              key: id_rsa
        - name: REPOSITORY_URL
          value: {{ .Values.git.repo }}
        - name: GIT_BRANCH
          value: {{ .Values.git.branch }}
        command: ['sh', '-c', '/opt/git/git.sh']
        volumeMounts:
          - name: git-volume
            mountPath: "/git"
            readOnly: false
      - name: init-migration
        image: fufuhu/sql-migrate:latest
        command: ['sh', '-c', 'while [ ! -d /git/db/migrations ]; do sleep 2; done && sleep 2; /bin/sql-migrate up -config=/config/config.yaml -env=main']
        volumeMounts:
          - name: migration-config
            mountPath: "/config/"
            readOnly: true
          - name: git-volume
            mountPath: "/git"
            readOnly: false
...

Лишние объекты, лишние телодвижения.

Вместо этого — используем логин и Github токен, которые будем передавать в контейнер в виде переменных, а клонировать репозитоий будем по HTTPS.

Протестируем:

~ # export GIT_AUTHUSER=backend-user
~ # export GIT_AUTHKEY=cdc***0fe
~ # git clone https://$GIT_AUTHUSER:$GIT_AUTHKEY@github.com/projectname-dev/backend-services.git
Cloning into 'backend-services'...
...
Receiving objects: 100% (5115/5115), 846.55 KiB | 1.30 MiB/s, done.
Resolving deltas: 100% (2826/2826), done.

Окей, работает.

Запуск миграций в Kubernetes

Теперь можно создать манифест templates/appname-api-migrations.yaml, который собственно будет описывать Kubernetes Job, которая будет запускаться Helm hook-ом.

Kubernetes Job

git clone

Сначала протестируем саму джобу — пишем без переменных Helm, переменные окружения для пода указываем plaintext, и запускаем только git clone:

apiVersion: batch/v1
kind: Job
metadata:
  name: "migration-job"
  labels:
  annotations:
spec: 
  backoffLimit: 0
  template:
    metadata:
      name: "migration-job-pod"
    spec:
      restartPolicy: Never
      containers:
      - name: db-migrations
        image: projectname/sql-migrate-git:latest
        command: ["/bin/sh", "-c"]
        args:
          - git clone --single-branch --branch develop https://backend-user:cdc***0fe@github.com/projectname/backend-services.git &&
            ls -l backend-services/db/migrations

Тут в restartPolicy указываем, что если контейнер в поде сфейлит свою задачу — перезапускать его не надо, и в backoffLimit=0 — если под сфейлится, то не пересоздавать его вообще, а просто завершить джобу со статусом Failed.

В git clone бранч, пользователя и URL будем подставлять из values.yaml, а токен — из Helm secrets, его чуть позже вынесем в переменные.

Запускаем:

kk -n eks-dev-1-appname-api-ns apply -f appname-api-jobs.yaml
job.batch/migration-job created

Логи джобы:

kk -n eks-dev-1-appname-api-ns logs job/migration-job
Cloning into 'backend-services'...
total 20
-rw-r--r--    1 root     root           538 Oct 24 12:20 BS_1_init_schema.up.sql
-rw-r--r--    1 root     root           180 Oct 24 12:20 BS_2_add_brand_field.up.sql
-rw-r--r--    1 root     root           225 Oct 24 12:20 BS_3_alter_table.up.sql
-rw-r--r--    1 root     root           194 Oct 24 12:20 BS_4_add_created_at_field.sql
-rw-r--r--    1 root     root           272 Oct 24 12:20 BS_5_alter_table_nourishment_diet.up.sql

Всё склонировалось.

Статус пода:

kk -n eks-dev-1-appname-api-ns get pod
NAME                  READY   STATUS      RESTARTS   AGE
migration-job-f72vs   0/1     Completed   0          9s

И статус джобы:

kk -n eks-dev-1-appname-api-ns get job
NAME            COMPLETIONS   DURATION   AGE
migration-job   1/1           2s         5s

Можно готовить запуск миграций.

Secret

Для запуска миграций нам нужен файл конфига, который создадим из Kubernetes ConfigMap, но в файле конфига указывать пароль к базам данных не хочется.

sql-migrate позволяет передавать данные через переменные, см. https://github.com/rubenv/sql-migrate#as-a-standalone-tool

Создадим переменную $DB_PASSWORD, в которую будем подставлять пароль из Kubernetes Secrets, а в Helm, когда дойдём до него, значение для него будем его создавать из Helm secrets.

Заодно в Secrets вынесем переменную $GIT_TOKEN, которую будем использовать в вызове git clone.

Пока всё в тот же файл templates/appname-api-migrations.yaml добавляем Secret:

...
---
apiVersion: v1
kind: Secret
metadata:
  name: backend-db-password
type: Opaque
stringData:
  db_password: password
  git_token: cdc***0fe

В spec.containers.env джобы добавляем переменные, и обновляем вызов git clone на использование $GIT_TOKEN:

...
      containers:
      - name: db-migrations
        image: projectname/sql-migrate-git:latest
        command: ["/bin/sh", "-c"]
        args:
          - git clone --single-branch --branch develop https://backend-user:$GIT_TOKEN@github.com/projectnamev/backend-services.git &&
            ls -l backend-services/db/migrations;
            cat /config/config.yaml
        env:
        - name: GIT_TOKEN
          valueFrom:
            secretKeyRef:
              name: backend-db-password
              key: git_token
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: backend-db-password
              key: db_password
...

ConfigMap

Создаём ConfigMap, который будет создавать файл /config/config.yaml для sql-migrate, в котором будет использоваться переменная окружения $DB_PASSWORD:

...
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: migration-config
data:
  config.yaml: |
    main:
      dialect: mysql
      datasource: backend-user:${DB_PASSWORD}@tcp(stage.backend-db3-master.example.com:3306)/dbname?parseTime=true
      dir: backend-services/db/migrations
      table: backend_services_migrations

В template подов джобы добавляем volumes, а в spec.containers через volumeMounts монтируем volume с ConfigMap как файл /config/config.yaml.

Полностью джоба теперь выглядит так:

apiVersion: batch/v1
kind: Job
metadata:
  name: "migration-job"
  labels:
  annotations:
spec: 
  backoffLimit: 0
  template: 
    metadata:
      name: "migration-job-pod"
    spec:
      restartPolicy: Never
      containers:
      - name: db-migrations
        image: projectname/sql-migrate-git:latest
        command: ["/bin/sh", "-c"]
        args:
          - git clone --single-branch --branch develop https://backend-user:$GIT_TOKEN@github.com/projectname/backend-services.git &&
            ls -l backend-services/db/migrations;
            cat /config/config.yaml
        env:
        - name: GIT_TOKEN
          valueFrom:
            secretKeyRef:
              name: backend-db-password
              key: git_token
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: backend-db-password
              key: db_password
        volumeMounts:
        - name: migration-config
          mountPath: "/config/config.yaml"
          subPath: "config.yaml"
          readOnly: true
      volumes:
        - name: migration-config
          configMap: 
            name: migration-config
            items:
            - key: "config.yaml"
              path: "config.yaml"
...

Запускаем:

kk -n eks-dev-1-appname-api-ns apply -f appname-api-jobs.yaml
job.batch/migration-job created
secret/backend-db-password created
configmap/migration-config created

Проверяем:

kk -n eks-dev-1-appname-api-ns logs job/migration-job
Cloning into 'backend-services'...
total 20
-rw-r--r--    1 root     root           538 Oct 24 13:41 BS_1_init_schema.up.sql
-rw-r--r--    1 root     root           180 Oct 24 13:41 BS_2_add_brand_field.up.sql
-rw-r--r--    1 root     root           225 Oct 24 13:41 BS_3_alter_table.up.sql
-rw-r--r--    1 root     root           194 Oct 24 13:41 BS_4_add_created_at_field.sql
-rw-r--r--    1 root     root           272 Oct 24 13:41 BS_5_alter_table_nourishment_diet.up.sql
main:
dialect: mysql
datasource: backend-user:${DB_PASSWORD}@tcp(stage.backend-db3-master.example.com:3306)/dbname?parseTime=true
dir: backend-services/db/migrations
table: backend_services_migrations

Гуд — репозиторий клонируется, файл конфига создаётся.

Запуск миграций

Добавляем вызов самих миграций, пока с -dryrun, второй командой — проверяем статус:

...
        args:
          - git clone --single-branch --branch develop https://backend-user:$GIT_TOKEN@github.com/projectname/backend-services.git &&
            ls -l backend-services/db/migrations &&
            cat /config/config.yaml &&
            /bin/sql-migrate up -config=/config/config.yaml -env=main -dryrun &&
            /bin/sql-migrate status -config=/config/config.yaml -env=main
...

Запускаем, и проверяем логи:

kk -n eks-dev-1-appname-test-migrations-ns logs job/migration-job
Cloning into 'backend-services'...
total 20
-rw-r--r--    1 root     root           538 Oct 24 14:02 BS_1_init_schema.up.sql
-rw-r--r--    1 root     root           180 Oct 24 14:02 BS_2_add_brand_field.up.sql
-rw-r--r--    1 root     root           225 Oct 24 14:02 BS_3_alter_table.up.sql
-rw-r--r--    1 root     root           194 Oct 24 14:02 BS_4_add_created_at_field.sql
-rw-r--r--    1 root     root           272 Oct 24 14:02 BS_5_alter_table_nourishment_diet.up.sql
main:
dialect: mysql
datasource: backnd-user:${DB_PASSWORD}@tcp(stage.backend-db3-master.example.com:3306)/dbname?parseTime=true
dir: backend-services/db/migrations
table: backend_services_migrations
+------------------------------------------+-------------------------------+
|                MIGRATION                 |            APPLIED            |
+------------------------------------------+-------------------------------+
| BS_1_init_schema.up.sql                  | 2020-05-07 12:21:25 +0000 UTC |
| BS_2_add_brand_field.up.sql              | 2020-05-12 14:31:17 +0000 UTC |
| BS_3_alter_table.up.sql                  | 2020-05-13 06:17:25 +0000 UTC |
| BS_4_add_created_at_field.sql            | 2020-07-21 09:55:49 +0000 UTC |
| BS_5_alter_table_nourishment_diet.up.sql | 2020-07-21 09:55:49 +0000 UTC |
+------------------------------------------+-------------------------------+

Можно переносить всё в Helm.

Helm template

Что надо сделать в старом чарте?

  1. вырезать initContainers
  2. вырезать старые секреты
  3. вынести переменные в values.yaml
  4. вынести токен и пароль баз данных в secrets.yaml

Ну и самое главное — добавляем аннотации, которые должны будут запустить миграции.

Добавляем аннотации в Job:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Chart.Name }}-migration-job
  labels:
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": before-hook-creation
spec: 
  backoffLimit: 0
...

Тут:

  • "helm.sh/hook": pre-install,pre-upgrade: запускаем перед helm install или upgrade (в нашем Jenkins деплоится через helm secrets upgrade --install)
  • "helm.sh/hook-weight": "-1": очередность создания ресурсов хука, тут для создания Job нам нужны ConfigMap и Secret, поэтому им зададим вес меньше, и они будут созданы перед самой Job
  • "helm.sh/hook-delete-policy": дефолтное значение before-hook-creation (см. документацию), его и поставим на время проверок, потом можно задать hook-succeeded (но тогда не будет логов джобы)

Добавляем annotations в ConfigMap и Secrets, hook-weight ставим меньше, чем у Job.

ConfigMap теперь полностью:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: migration-config
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": before-hook-creation
data:         
  config.yaml: |
    main:
      dialect: {{ .Values.backendConfig.db.driver }}
      datasource: {{ .Values.backendConfig.db.user }}:${DB_PASSWORD}@tcp({{ .Values.backendConfig.db.host }}:{{ .Values.backendConfig.db.port }})/{{ .Values.backendConfig.db.database }}?parseTime=true
      dir: backend-services/db/migrations
      table: {{ .Values.backendConfig.db.migrationsTable }}

Secret:

---     
apiVersion: v1
kind: Secret
metadata:
  name: {{ .Chart.Name }}-migration-secrets
  annotations: 
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-10"
    "helm.sh/hook-delete-policy": before-hook-creation
type: Opaque
stringData:
  backend-db-password: {{ .Values.backendConfig.db.password }}
  git_token: {{ .Values.git.token }}

Целиком Job теперь выглядит так:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Chart.Name }}-migration-job
  labels:
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 0
  template:
    metadata:
      name: {{ .Chart.Name }}-migration-job-pod
    spec:
      restartPolicy: Never
      containers:
      - name: {{ .Chart.Name }}-db-migrations
        image: projectname/sql-migrate-git:latest
        command: ["/bin/sh", "-c"]
        args: 
          - git clone --single-branch --branch {{ .Values.git.branch }} https://{{ .Values.git.user }}:$GIT_TOKEN@{{ .Values.git.repo }} &&
            ls -l backend-services/db/migrations &&
            cat /config/config.yaml &&
            /bin/sql-migrate up -config=/config/config.yaml -env=main || exit 1;
            /bin/sql-migrate status -config=/config/config.yaml -env=main
        env:
        - name: GIT_TOKEN
          valueFrom:
            secretKeyRef:
              name: {{ .Chart.Name }}-migration-secrets
              key: git_token
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ .Chart.Name }}-migration-secrets
              key: backend-db-password
        volumeMounts:
        - name: migration-config
          mountPath: "/config/config.yaml"
          subPath: "config.yaml"
          readOnly: true
      volumes:
        - name: migration-config
          configMap: 
            name: migration-config
            items:
            - key: "config.yaml"
              path: "config.yaml"

Тут ещё добавлена exit 1 к /bin/sql-migrate up, что бы Job отваливался с ошибкой в случае ошибки миграции, и не триггерил за собой деплой.

Запускаем:

В HOOKS видим, что первым создаётся Secret, т.к. hook-weight": "-10", потом ConfigMap, и третим — Job.

И выполнение деплоймента:

Сначала ресурсы Secret, ConfigMap и Job удаляются, потом создаются.

Проверяем статус джобы:

kk -n eks-stage-1-appname-api-ns get job
NAME                         COMPLETIONS   DURATION   AGE
appname-api-migration-job   1/1           3s         6m21s

И её логи:

kk -n eks-stage-1-appname-api-ns logs job/appname-api-migration-job
Cloning into 'backend-services'...
total 20
-rw-r--r--    1 root     root           538 Oct 26 11:32 BS_1_init_schema.up.sql
-rw-r--r--    1 root     root           180 Oct 26 11:32 BS_2_add_brand_field.up.sql
-rw-r--r--    1 root     root           225 Oct 26 11:32 BS_3_alter_table.up.sql
-rw-r--r--    1 root     root           194 Oct 26 11:32 BS_4_add_created_at_field.sql
-rw-r--r--    1 root     root           272 Oct 26 11:32 BS_5_alter_table_nourishment_diet.up.sql
main:
dialect: mysql
datasource: backend-user:${DB_PASSWORD}@tcp(stage.backend-db3-master.example.com:3306)/dbname?parseTime=true
dir: backend-services/db/migrations
table: backend_services_migrations
Applied 0 migrations
+------------------------------------------+-------------------------------+
|                MIGRATION                 |            APPLIED            |
+------------------------------------------+-------------------------------+
| BS_1_init_schema.up.sql                  | 2020-05-07 12:21:25 +0000 UTC |
| BS_2_add_brand_field.up.sql              | 2020-05-12 14:31:17 +0000 UTC |
| BS_3_alter_table.up.sql                  | 2020-05-13 06:17:25 +0000 UTC |
| BS_4_add_created_at_field.sql            | 2020-07-21 09:55:49 +0000 UTC |
| BS_5_alter_table_nourishment_diet.up.sql | 2020-07-21 09:55:49 +0000 UTC |
+------------------------------------------+-------------------------------+

Applied 0 migrations, т.к. изменений в миграциях с момента последнего APPLIED не было.

Готово.