Имеется проект, которому во время деплоя надо выполнить 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 не было.
Готово.







