Имеется проект, которому во время деплоя надо выполнить SQL-миграции.
Для запуска миграций надо склонировать репозиторий из Github, и затем выполнить собственно миграции, которые в нём хранятся.
Сейчас у нас для этого используются Kubernetes initContainers, причём два – сначала один, с git
, клонирует репозиторий с миграциями в Kubernetes Volume, второй с sql-migrate
– запускает из этого расшаренного волюма миграции.
Тут есть несколько проблем:
- каждый раз, когда запускается новый под – он запускает миграции
- если в Deployment запускается несколько подов – они одновременно запустят миграции
- если миграции займут долгое время – то под не будет отвечать на 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
Собираем, пушим:
[simterm]
$ docker build -t projectname/sql-migrate-git . $ docker push projectname/sql-migrate-git
[/simterm]
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.
Протестируем:
[simterm]
~ # export GIT_AUTHUSER=backend-user ~ # export GIT_AUTHKEY=cdc***0fe ~ # git clone https://$GIT_AUTHUSER:[email protected]/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.
[/simterm]
Окей, работает.
Запуск миграций в 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***[email protected]/projectname/backend-services.git && ls -l backend-services/db/migrations
Тут в restartPolicy
указываем, что если контейнер в поде сфейлит свою задачу – перезапускать его не надо, и в backoffLimit=0
– если под сфейлится, то не пересоздавать его вообще, а просто завершить джобу со статусом Failed.
В git clone
бранч, пользователя и URL будем подставлять из values.yaml
, а токен – из Helm secrets, его чуть позже вынесем в переменные.
Запускаем:
[simterm]
$ kk -n eks-dev-1-appname-api-ns apply -f appname-api-jobs.yaml job.batch/migration-job created
[/simterm]
Логи джобы:
[simterm]
$ 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
[/simterm]
Всё склонировалось.
Статус пода:
[simterm]
$ kk -n eks-dev-1-appname-api-ns get pod NAME READY STATUS RESTARTS AGE migration-job-f72vs 0/1 Completed 0 9s
[/simterm]
И статус джобы:
[simterm]
$ kk -n eks-dev-1-appname-api-ns get job NAME COMPLETIONS DURATION AGE migration-job 1/1 2s 5s
[/simterm]
Можно готовить запуск миграций.
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:[email protected]/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:[email protected]/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" ...
Запускаем:
[simterm]
$ 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
[/simterm]
Проверяем:
[simterm]
$ 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
[/simterm]
Гуд – репозиторий клонируется, файл конфига создаётся.
Запуск миграций
Добавляем вызов самих миграций, пока с -dryrun
, второй командой – проверяем статус:
... args: - git clone --single-branch --branch develop https://backend-user:[email protected]/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 ...
Запускаем, и проверяем логи:
[simterm]
$ 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 | +------------------------------------------+-------------------------------+
[/simterm]
Можно переносить всё в Helm.
Helm template
Что надо сделать в старом чарте?
- вырезать
initContainers
- вырезать старые секреты
- вынести переменные в
values.yaml
- вынести токен и пароль баз данных в
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 удаляются, потом создаются.
Проверяем статус джобы:
[simterm]
$ kk -n eks-stage-1-appname-api-ns get job NAME COMPLETIONS DURATION AGE appname-api-migration-job 1/1 3s 6m21s
[/simterm]
И её логи:
[simterm]
$ 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 | +------------------------------------------+-------------------------------+
[/simterm]
Applied 0 migrations, т.к. изменений в миграциях с момента последнего APPLIED
не было.
Готово.