О дивный мир, Infrastructure as a Code! С каждым днём появляется всё больше инструментов для работы с гетерогенными инфраструктурами любой сложности. На каждом уровне абстракции — свои утилиты и комбайны, которые если и не решают всех задач, то активно к этому стремятся. Примеров много. Самый популярный — Terraform. Чуть менее востребованы не такие функциональные Pulumi и Crossplane.

Все давно привыкли, что управлять контейнерами и кластерами Kubernetes помогает целый набор решений совершенно разного калибра и функциональности: Rancher, Deckhouse, OpenShift, Kubespray, Helm, werf, K9s, Komodor.

Привет! На связи Василий Мармер, DevOps-тимлид компании «Флант». Сегодня мы поговорим об Atlas — еще одной утилите, которая делает работу DevOps-инженера более комфортной. Atlas увидел мир в ноябре 2021 года, а сейчас у него более 3,3 тысячи звёзд на GitHub. Язык программирования — Go.

Введение

Рассматриваемая утилита служит для управления схемами миграций баз данных и позволяет задействовать современные паттерны DevOps. Разработчики Atlas воспринимают свой инструмент как универсальный способ визуализировать, планировать и выстраивать миграции схем баз данных в соответствии с CI.

Atlas и его оператор справляются не только с обычными MySQL и PostgreSQL, но и с MariaDB, TiDB и CockroachDB, неплохо поддерживает SQLite, что может стать подспорьем для разработчиков, которые предпочитают эту простую и легковесную базу данных. К сожалению, в рамках одной статьи протестировать их все невозможно.

Из важных особенностей Atlas стоит отметить, в частности, возможность настраивать политику сравнения схем: можно выбрать skip destructive (чтобы обезопасить всю базу целиком или конкретные таблицы) или concurrent_index (чтобы удалять или создавать индексы конкурентно, без блокирования таблиц, — это опция, кстати, по умолчанию выключена). Удобно и логично настраиваются типы колонок в HCL.

В Kubernetes можно использовать Atlas не только как оператор, но и в качестве init-контейнера конкретной базы данных. Ещё можно использовать Argo CD. Этот вариант выглядит наиболее интересным из всех, однако требует отдельной и довольно объемной статьи. Можно, конечно, ограничиться рекомендуемыми на официальном сайте инструментами миграции и импорта. Кроме того, есть неплохо описанная интеграция с Terraform — эта статья может стать неплохим подспорьем для тех, кто с помощью Terraform «крутит базы в облаках».

Одним словом, вариантов использования Atlas очень много: на хостах — как инструмент или контейнер, в Kubernetes — от sidecar-, init-контейнера, управления схемами баз данных (независимо от количества кластеров, в том числе созданных с помощью Custom Resources) до использования подхода GitOps с помощью Argo CD и подобных инструментов.

В любом случае теперь DDL гораздо проще сделать частью детально описанных инфраструктур.

Подготовка к экспериментам

Atlas позволяет работать двумя способами:

  • использование декларативного описания схемы базы данных (как в Terraform) — сравниваем текущее состояние базы со схемой, описанной в HCL-, SQL-форматах или используем интеграцию с ORM (в результате получаем diff для дальнейшей работы);

  • версионирование — планируем изменения, проверяем их и, наконец, применяем с помощью Atlas.

Нас, DevOps-инженеров, для воплощения мечты World as a Code интересует прежде всего именно декларативный вариант использования Atlas. Что ж, проведём полевые испытания.

Прежде всего установим на хост сам инструмент:

# curl -sSf https://atlasgo.sh | sh
Install 'atlas-linux-amd64-latest' to '/usr/local/bin/atlas'? [y/N] y
Downloading https://release.ariga.io/atlas/atlas-linux-amd64-latest
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 69.0M  100 69.0M    0     0  51.5M      0  0:00:01  0:00:01 --:--:-- 51.5M
atlas version v0.12.1-972da10-canary
https://github.com/ariga/atlas/releases/latest
Installation successful!

Не забываем про completion:

atlas completion bash > /etc/bash_completion.d/atlas

Для экспериментов нам понадобится плагин Compose для Docker. Убедимся, что он установлен:

# docker compose version
Docker Compose version v2.19.1

Работа с Atlas в Docker Compose

Начнем изучение Atlas с использования Docker Compose. Не будем затрагивать самые основы, такие как установка и принципы работы, — они достаточно хорошо описаны в официальной документации. А более сложные варианты использования утилиты, такие как совместная работа с K8s, обсудим далее.

Первые опыты начнём над MySQL. Для этого поднимем в Docker небольшой пример:

version: '3.8'

services:
  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: groot_password
    ports:
      - "3306:3306"

Создадим для наглядности простую базу данных:

DROP DATABASE IF EXISTS employees…
DROP DATABASE IF EXISTS employees;
CREATE DATABASE IF NOT EXISTS employees;
USE employees;

SELECT 'CREATING DATABASE STRUCTURE' as 'INFO';

DROP TABLE IF EXISTS dept_emp,
                     dept_manager,
                     titles,
                     salaries, 
                     employees, 
                     departments;

/*!50503 set default_storage_engine = InnoDB */;
/*!50503 select CONCAT('storage engine: ', @@default_storage_engine) as INFO */;

CREATE TABLE employees (
    emp_no      INT             NOT NULL,
    birth_date  DATE            NOT NULL,
    first_name  VARCHAR(14)     NOT NULL,
    last_name   VARCHAR(16)     NOT NULL,
    gender      ENUM ('M','F')  NOT NULL,    
    hire_date   DATE            NOT NULL,
    PRIMARY KEY (emp_no)
);

CREATE TABLE departments (
    dept_no     CHAR(4)         NOT NULL,
    dept_name   VARCHAR(40)     NOT NULL,
    PRIMARY KEY (dept_no),
    UNIQUE  KEY (dept_name)
);

CREATE TABLE dept_manager (
   emp_no       INT             NOT NULL,
   dept_no      CHAR(4)         NOT NULL,
   from_date    DATE            NOT NULL,
   to_date      DATE            NOT NULL,
   FOREIGN KEY (emp_no)  REFERENCES employees (emp_no)    ON DELETE CASCADE,
   FOREIGN KEY (dept_no) REFERENCES departments (dept_no) ON DELETE CASCADE,
   PRIMARY KEY (emp_no,dept_no)
); 

CREATE TABLE dept_emp (
    emp_no      INT             NOT NULL,
    dept_no     CHAR(4)         NOT NULL,
    from_date   DATE            NOT NULL,
    to_date     DATE            NOT NULL,
    FOREIGN KEY (emp_no)  REFERENCES employees   (emp_no)  ON DELETE CASCADE,
    FOREIGN KEY (dept_no) REFERENCES departments (dept_no) ON DELETE CASCADE,
    PRIMARY KEY (emp_no,dept_no)
);

CREATE TABLE titles (
    emp_no      INT             NOT NULL,
    title       VARCHAR(50)     NOT NULL,
    from_date   DATE            NOT NULL,
    to_date     DATE,
    FOREIGN KEY (emp_no) REFERENCES employees (emp_no) ON DELETE CASCADE,
    PRIMARY KEY (emp_no,title, from_date)
); 

CREATE TABLE salaries (
    emp_no      INT             NOT NULL,
    salary      INT             NOT NULL,
    from_date   DATE            NOT NULL,
    to_date     DATE            NOT NULL,
    FOREIGN KEY (emp_no) REFERENCES employees (emp_no) ON DELETE CASCADE,
    PRIMARY KEY (emp_no, from_date)
);

Зальем эту базу в наш MySQL-контейнер:

# cat db.sql | docker exec -i root-db-1 mysql -pgroot_password
mysql: [Warning] Using a password on the command line interface can be insecure.
INFO
CREATING DATABASE STRUCTURE
INFO
storage engine: InnoDB

Появилось шесть табличек с простой структурой:

# docker exec -it root-db-1 mysql -pgroot_password -e 'use employees; show tables'
mysql: [Warning] Using a password on the command line interface can be insecure.
+---------------------+
| Tables_in_employees |
+---------------------+
| departments         |
| dept_emp            |
| dept_manager        |
| employees           |
| salaries            |
| titles              |
+---------------------+

Теперь можно испытать Atlas в деле:

# atlas schema inspect -u "mysql://root…
# atlas schema inspect -u "mysql://root:groot_password@localhost:3306/employees"
table "departments" {
  schema = schema.employees
  column "dept_no" {
    null = false
    type = char(4)
  }
  column "dept_name" {
    null = false
    type = varchar(40)
  }
  primary_key {
    columns = [column.dept_no]
  }
  index "dept_name" {
    unique  = true
    columns = [column.dept_name]
  }
}
table "dept_emp" {
  schema = schema.employees
  column "emp_no" {
    null = false
    type = int
  }
  column "dept_no" {
    null = false
    type = char(4)
  }
  column "from_date" {
    null = false
    type = date
  }
  column "to_date" {
    null = false
    type = date
  }
  primary_key {
    columns = [column.emp_no, column.dept_no]
  }
  foreign_key "dept_emp_ibfk_1" {
    columns     = [column.emp_no]
    ref_columns = [table.employees.column.emp_no]
    on_update   = NO_ACTION
    on_delete   = CASCADE
  }
  foreign_key "dept_emp_ibfk_2" {
    columns     = [column.dept_no]
    ref_columns = [table.departments.column.dept_no]
    on_update   = NO_ACTION
    on_delete   = CASCADE
  }
  index "dept_no" {
    columns = [column.dept_no]
  }
}
table "dept_manager" {
  schema = schema.employees
  column "emp_no" {
    null = false
    type = int
  }
  column "dept_no" {
    null = false
    type = char(4)
  }
  column "from_date" {
    null = false
    type = date
  }
  column "to_date" {
    null = false
    type = date
  }
  primary_key {
    columns = [column.emp_no, column.dept_no]
  }
  foreign_key "dept_manager_ibfk_1" {
    columns     = [column.emp_no]
    ref_columns = [table.employees.column.emp_no]
    on_update   = NO_ACTION
    on_delete   = CASCADE
  }
  foreign_key "dept_manager_ibfk_2" {
    columns     = [column.dept_no]
    ref_columns = [table.departments.column.dept_no]
    on_update   = NO_ACTION
    on_delete   = CASCADE
  }
  index "dept_no" {
    columns = [column.dept_no]
  }
}
table "employees" {
  schema = schema.employees
  column "emp_no" {
    null = false
    type = int
  }
  column "birth_date" {
    null = false
    type = date
  }
  column "first_name" {
    null = false
    type = varchar(14)
  }
  column "last_name" {
    null = false
    type = varchar(16)
  }
  column "gender" {
    null = false
    type = enum("M","F")
  }
  column "hire_date" {
    null = false
    type = date
  }
  primary_key {
    columns = [column.emp_no]
  }
}
table "salaries" {
  schema = schema.employees
  column "emp_no" {
    null = false
    type = int
  }
  column "salary" {
    null = false
    type = int
  }
  column "from_date" {
    null = false
    type = date
  }
  column "to_date" {
    null = false
    type = date
  }
  primary_key {
    columns = [column.emp_no, column.from_date]
  }
  foreign_key "salaries_ibfk_1" {
    columns     = [column.emp_no]
    ref_columns = [table.employees.column.emp_no]
    on_update   = NO_ACTION
    on_delete   = CASCADE
  }
}
table "titles" {
  schema = schema.employees
  column "emp_no" {
    null = false
    type = int
  }
  column "title" {
    null = false
    type = varchar(50)
  }
  column "from_date" {
    null = false
    type = date
  }
  column "to_date" {
    null = true
    type = date
  }
  primary_key {
    columns = [column.emp_no, column.title, column.from_date]
  }
  foreign_key "titles_ibfk_1" {
    columns     = [column.emp_no]
    ref_columns = [table.employees.column.emp_no]
    on_update   = NO_ACTION
    on_delete   = CASCADE
  }
}
schema "employees" {
  charset = "utf8mb4"
  collate = "utf8mb4_0900_ai_ci"
}

Сделаем несколько изменений. Предположим, что в нашем приложении могут быть достаточно длинными названия отделов, имена, фамилии. Сравним файлы в формате .HCL со схемами — новой и старой.

Важный момент: нам нужен ключ --dev-url, который указывает на соединение с пустой dev-базой, с помощью которой Atlas симулирует, подсчитывает и проигрывает изменения схем. Укажем docker://mysql/8/schema  — и Atlas сам поднимет пустую вре́менную базу в Docker.

Получаем готовый diff:

# atlas schema diff -f file://employees.hcl --to file://employees_v2.hcl --dev-url docker://mysql/8/schema
-- Modify "departments" table
ALTER TABLE `departments` MODIFY COLUMN `dept_name` varchar(200) NOT NULL;
-- Modify "employees" table
ALTER TABLE `employees` MODIFY COLUMN `first_name` varchar(50) NOT NULL, MODIFY COLUMN `last_name` varchar(50) NOT NULL;

Такой же результат будет, если сравнить схему базы данных и новый файл.

Продолжим эксперименты. Добавим ещё одну (уже вторую) пустую базу данных, просто скопировав предыдущий контейнер и изменив порт, который пробрасывается на localhost:

# docker compose up -d
[+] Running 2/2
 ✔ Container root-db2-1  Started                                                                                                                                                                           1.1s
 ✔ Container root-db-1   Running
# cat db.sql | docker exec -i root-db2-1 mysql -pgroot_password
mysql: [Warning] Using a password on the command line interface can be insecure.
INFO
CREATING DATABASE STRUCTURE
INFO
storage engine: InnoDB
# atlas schema diff -f mysql://root:groot_password@localhost:3306/employees --to mysql://root:groot_password@localhost:3307/employees --dev-url docker://mysql/8/schema
Schemas are synced, no changes to be made.

Приведем первую базу данных к состоянию первой миграции с помощью файла схемы:

# atlas schema apply -u mysql://root:groot_password@localhost:3306/employees --to file://employees_v2.hcl --dry-run
-- Planned Changes:
-- Modify "departments" table
ALTER TABLE `employees`.`departments` MODIFY COLUMN `dept_name` varchar(200) NOT NULL;
-- Modify "employees" table
ALTER TABLE `employees`.`employees` MODIFY COLUMN `first_name` varchar(50) NOT NULL, MODIFY COLUMN `last_name` varchar(50) NOT NULL;
# atlas schema apply -u mysql://root:groot_password@localhost:3306/employees --to file://employees_v2.hcl
-- Planned Changes:
-- Modify "departments" table
ALTER TABLE `employees`.`departments` MODIFY COLUMN `dept_name` varchar(200) NOT NULL;
-- Modify "employees" table
ALTER TABLE `employees`.`employees` MODIFY COLUMN `first_name` varchar(50) NOT NULL, MODIFY COLUMN `last_name` varchar(50) NOT NULL;
✔ Apply

Вторую базу данных приведем к состоянию первой напрямую:

# atlas schema apply -u mysql://root:groot_password@localhost:3307/employees --to mysql://root:groot_password@localhost:3306/employees --dry-run
-- Planned Changes:
-- Modify "departments" table
ALTER TABLE `departments` MODIFY COLUMN `dept_name` varchar(200) NOT NULL;
-- Modify "employees" table
ALTER TABLE `employees` MODIFY COLUMN `first_name` varchar(50) NOT NULL, MODIFY COLUMN `last_name` varchar(50) NOT NULL;
# atlas schema apply -u mysql://root:groot_password@localhost:3307/employees --to mysql://root:groot_password@localhost:3306/employees --auto-approve
-- Planned Changes:
-- Modify "departments" table
ALTER TABLE `departments` MODIFY COLUMN `dept_name` varchar(200) NOT NULL;
-- Modify "employees" table
ALTER TABLE `employees` MODIFY COLUMN `first_name` varchar(50) NOT NULL, MODIFY COLUMN `last_name` varchar(50) NOT NULL;

Ключ --auto-approve позволяет выполнять операции без вмешательства пользователя.

Дальше посмотрим, как Atlas работает с PostgreSQL. Для этого понадобится короткий compose:

version: '3.8'

services:
  db:
    image: postgres
    restart: always
    environment:
      POSTGRES_PASSWORD: cheburek15
    ports:
      - "5432:5432"
  db2:
    image: postgres
    restart: always
    environment:
      POSTGRES_PASSWORD: cheburek15
    ports:
      - "5433:5432”

Запустим его:

# docker compose up -d
[+] Running 3/3
 ✔ Network root_default  Created                                                                                                                                                                           0.1s
 ✔ Container root-db2-1  Started                                                                                                                                                                           1.2s
 ✔ Container root-db-1   Started

Теперь приготовим тестовый DDL:

CREATE SCHEMA IF NOT EXISTS cd;

CREATE TABLE cd.facilities (
    facid integer NOT NULL PRIMARY KEY,
    name character varying(100) NOT NULL,
    membercost numeric NOT NULL,
    guestcost numeric NOT NULL,
    initialoutlay numeric NOT NULL,
    monthlymaintenance numeric NOT NULL
);

CREATE TABLE cd.members (
    memid integer NOT NULL PRIMARY KEY,
    surname character varying(200) NOT NULL,
    firstname character varying(200) NOT NULL,
    address character varying(300) NOT NULL,
    zipcode integer NOT NULL,
    telephone character varying(20) NOT NULL,
    recommendedby integer,
    joindate timestamp without time zone NOT NULL
);

Зальём его в первую базу:

# cat psql1.sql | docker exec -i root-db-1 psql postgresql://postgres:cheburek15@root-db-1
CREATE SCHEMA
CREATE TABLE
CREATE TABLE

Подготовим слегка изменённый DDL:

CREATE SCHEMA IF NOT EXISTS cd;

CREATE TABLE IF NOT EXISTS cd.facilities (
    facid integer NOT NULL PRIMARY KEY,
    name character varying(100) NOT NULL,
    membercost numeric NOT NULL,
    guestcost numeric NOT NULL,
    initialoutlay numeric NOT NULL,
    monthlymaintenance numeric NOT NULL
);

CREATE TABLE IF NOT EXISTS cd.members (
    memid integer NOT NULL PRIMARY KEY,
    surname character varying(200) NOT NULL,
    firstname character varying(200) NOT NULL,
    address character varying(300) NOT NULL,
    zipcode integer NOT NULL,
    telephone character varying(20) NOT NULL,
    recommendedby integer,
    joindate timestamp without time zone NOT NULL,
    FOREIGN KEY (recommendedby) REFERENCES cd.members(memid) ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS cd.bookings (
    bookid integer NOT NULL PRIMARY KEY,
    facid integer NOT NULL,
    memid integer NOT NULL,
    starttime timestamp without time zone NOT NULL,
    slots integer NOT NULL,
        FOREIGN KEY (facid) REFERENCES cd.facilities(facid),
        FOREIGN KEY (memid) REFERENCES cd.members(memid)
);

Зальем наш DDL во вторую базу:

# cat psql2.sql | docker exec -i root-db2-1 psql postgresql://postgres:cheburek15@root-db2-1
CREATE SCHEMA
CREATE TABLE
CREATE TABLE
CREATE TABLE

Применим изменения во второй базе данных к первой. Всё происходит так, как и ожидалось:

# atlas schema apply -u postgresql://postgres:cheburek15@localhost:5432/postgres?sslmode=disable --to postgresql://postgres:cheburek15@localhost:5433/postgres?sslmode=disable --dry-run
-- Planned Changes:
-- Modify "members" table
ALTER TABLE "cd"."members" ADD CONSTRAINT "members_recommendedby_fkey" FOREIGN KEY ("recommendedby") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE SET NULL;
-- Create "bookings" table
CREATE TABLE "cd"."bookings" ("bookid" integer NOT NULL, "facid" integer NOT NULL, "memid" integer NOT NULL, "starttime" timestamp NOT NULL, "slots" integer NOT NULL, PRIMARY KEY ("bookid"), CONSTRAINT "bookings_facid_fkey" FOREIGN KEY ("facid") REFERENCES "cd"."facilities" ("facid") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "bookings_memid_fkey" FOREIGN KEY ("memid") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE NO ACTION);

# atlas schema apply -u postgresql://postgres:cheburek15@localhost:5432/postgres?sslmode=disable --to postgresql://postgres:cheburek15@localhost:5433/postgres?sslmode=disable --auto-approve
-- Planned Changes:
-- Modify "members" table
ALTER TABLE "cd"."members" ADD CONSTRAINT "members_recommendedby_fkey" FOREIGN KEY ("recommendedby") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE SET NULL;
-- Create "bookings" table
CREATE TABLE "cd"."bookings" ("bookid" integer NOT NULL, "facid" integer NOT NULL, "memid" integer NOT NULL, "starttime" timestamp NOT NULL, "slots" integer NOT NULL, PRIMARY KEY ("bookid"), CONSTRAINT "bookings_facid_fkey" FOREIGN KEY ("facid") REFERENCES "cd"."facilities" ("facid") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "bookings_memid_fkey" FOREIGN KEY ("memid") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE NO ACTION);

Если подумать — плоховато нам будет без индексов. Для разнообразия накинем их вручную на вторую базу:

CREATE INDEX…
CREATE INDEX "bookings.memid_facid"
  ON cd.bookings
  USING btree
  (memid, facid);

CREATE INDEX "bookings.facid_memid"
  ON cd.bookings
  USING btree
  (facid, memid);

CREATE INDEX "bookings.facid_starttime"
  ON cd.bookings
  USING btree
  (facid, starttime);

CREATE INDEX "bookings.memid_starttime"
  ON cd.bookings
  USING btree
  (memid, starttime);

CREATE INDEX "bookings.starttime"
  ON cd.bookings
  USING btree
  (starttime);

CREATE INDEX "members.joindate"
  ON cd.members
  USING btree
  (joindate);

CREATE INDEX "members.recommendedby"
  ON cd.members
  USING btree
  (recommendedby);

# cat psql3.sql | docker exec -i root-db2-1 psql postgresql://postgres:cheburek15@root-db2-1
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX
CREATE INDEX

Теперь, чтобы накинуть индексы на первую базу, испытаем миграции: сначала создадим с помощью create_tables миграцию с базы данных без индексов:

# atlas migrate diff create_tables --dir "file://migrations" --to "postgresql://postgres:cheburek15@localhost:5432/postgres?sslmode=disable" --dev-url "docker://postgres/15/test"

Посмотрим, что содержит директория:

# ls migrations/
20230712165510_create_tables.sql  atlas.sum
# cat migrations/20230712165510_create_tables.sql
-- Add new schema named "cd"
CREATE SCHEMA "cd";
-- Create "facilities" table
CREATE TABLE "cd"."facilities" ("facid" integer NOT NULL, "name" character varying(100) NOT NULL, "membercost" numeric NOT NULL, "guestcost" numeric NOT NULL, "initialoutlay" numeric NOT NULL, "monthlymaintenance" numeric NOT NULL, PRIMARY KEY ("facid"));
-- Create "members" table
CREATE TABLE "cd"."members" ("memid" integer NOT NULL, "surname" character varying(200) NOT NULL, "firstname" character varying(200) NOT NULL, "address" character varying(300) NOT NULL, "zipcode" integer NOT NULL, "telephone" character varying(20) NOT NULL, "recommendedby" integer NULL, "joindate" timestamp NOT NULL, PRIMARY KEY ("memid"), CONSTRAINT "members_recommendedby_fkey" FOREIGN KEY ("recommendedby") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE SET NULL);
-- Create "bookings" table
CREATE TABLE "cd"."bookings" ("bookid" integer NOT NULL, "facid" integer NOT NULL, "memid" integer NOT NULL, "starttime" timestamp NOT NULL, "slots" integer NOT NULL, PRIMARY KEY ("bookid"), CONSTRAINT "bookings_facid_fkey" FOREIGN KEY ("facid") REFERENCES "cd"."facilities" ("facid") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "bookings_memid_fkey" FOREIGN KEY ("memid") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE NO ACTION);
# cat migrations/atlas.sum
h1:TBIl3WYjzvTYokioT+K3FOOlq9LSAzryhjxw5gAGz6U=
20230712165510_create_tables.sql h1:BWXssdgcYxJcYSNOaK7bYlhgB5LsumtrNXiBfkC3HiU=

Теперь с помощью create_indexes создаем вторую миграцию, уже с индексами:

# atlas migrate diff create_indexes --dir "file://migrations" --to "postgresql://postgres:cheburek15@localhost:5433/postgres?sslmode=disable" --dev-url "docker://postgres/15/test"

Внутри видим следующее:

# cat migrations/20230712165923_create_indexes.sql
-- Create index "bookings.facid_memid" to table: "bookings"
CREATE INDEX "bookings.facid_memid" ON "cd"."bookings" ("facid", "memid");
-- Create index "bookings.facid_starttime" to table: "bookings"
CREATE INDEX "bookings.facid_starttime" ON "cd"."bookings" ("facid", "starttime");
-- Create index "bookings.memid_facid" to table: "bookings"
CREATE INDEX "bookings.memid_facid" ON "cd"."bookings" ("memid", "facid");
-- Create index "bookings.memid_starttime" to table: "bookings"
CREATE INDEX "bookings.memid_starttime" ON "cd"."bookings" ("memid", "starttime");
-- Create index "bookings.starttime" to table: "bookings"
CREATE INDEX "bookings.starttime" ON "cd"."bookings" ("starttime");
-- Create index "members.joindate" to table: "members"
CREATE INDEX "members.joindate" ON "cd"."members" ("joindate");
-- Create index "members.recommendedby" to table: "members"
CREATE INDEX "members.recommendedby" ON "cd"."members" ("recommendedby");

Что ж… Выглядит прекрасно!

А вот и вишенка на торте. Если попросить Atlas проверить последние две миграции из директории migrations на тестовой базе в Docker, то Atlas сообщит: «При создании индексов будут заблокированы таблицы в базе данных». Мы же не готовы с этим согласиться, не так ли?

# atlas migrate lint --dir file://migrations --dev-url "docker://postgres/15/test" --latest 2
20230712165923_create_indexes.sql: concurrent index violations detected:


	L2: Creating index "bookings.facid_memid" non-concurrently causes write locks on the "bookings" table
	L4: Creating index "bookings.facid_starttime" non-concurrently causes write locks on the "bookings" table
	L6: Creating index "bookings.memid_facid" non-concurrently causes write locks on the "bookings" table
	L8: Creating index "bookings.memid_starttime" non-concurrently causes write locks on the "bookings" table
	L10: Creating index "bookings.starttime" non-concurrently causes write locks on the "bookings" table
	L12: Creating index "members.joindate" non-concurrently causes write locks on the "members" table
	L14: Creating index "members.recommendedby" non-concurrently causes write locks on the "members" table

Поправим миграцию руками, добавив волшебное слово CONCURRENTLY:

-- Create index "bookings.facid_memid" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.facid_memid" ON "cd"."bookings" ("facid", "memid");
-- Create index "bookings.facid_starttime" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.facid_starttime" ON "cd"."bookings" ("facid", "starttime");
-- Create index "bookings.memid_facid" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.memid_facid" ON "cd"."bookings" ("memid", "facid");
-- Create index "bookings.memid_starttime" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.memid_starttime" ON "cd"."bookings" ("memid", "starttime");
-- Create index "bookings.starttime" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.starttime" ON "cd"."bookings" ("starttime");
-- Create index "members.joindate" to table: "members"
CREATE INDEX CONCURRENTLY "members.joindate" ON "cd"."members" ("joindate");
-- Create index "members.recommendedby" to table: "members"
CREATE INDEX CONCURRENTLY "members.recommendedby" ON "cd"."members" ("recommendedby");

Запускаем линтер снова:

# atlas migrate lint --dir file://migrations --dev-url "docker://postgres/15/test" --latest 2
atlas.sum: checksum mismatch

Всё как у взрослых. Пытаемся исправить проблему:

# atlas migrate hash
[team-alfa] root@demo-gitlab-runner ~ # atlas migrate lint --dir file://migrations --dev-url "docker://postgres/15/test" --latest 2
20230712165923_create_indexes.sql: concurrent index violations detected:

	L2: Creating index "bookings.facid_memid" non-concurrently causes write locks on the "bookings" table
	L4: Creating index "bookings.facid_starttime" non-concurrently causes write locks on the "bookings" table
	L6: Creating index "bookings.memid_facid" non-concurrently causes write locks on the "bookings" table
	L8: Creating index "bookings.memid_starttime" non-concurrently causes write locks on the "bookings" table
	L10: Creating index "bookings.starttime" non-concurrently causes write locks on the "bookings" table
	L12: Creating index "members.joindate" non-concurrently causes write locks on the "members" table
	L14: Creating index "members.recommendedby" non-concurrently causes write locks on the "members" table

Странно… Линтер игнорирует CONCURRENTLY!

Чтобы разобраться, почему так происходит, мы создали issue. Ответ последовал в тот же день — авторы исправили найденный нами недочет.

Кстати, при создании таких индексов следует помнить, что CONCURRENTLY — это не свойство индекса, а свойство инструкции по созданию индекса. Не стоит забывать и о том, что длинная транзакция, затрагивающая таблицу, и такой индекс для этой же таблицы — это не самые лучшие друзья.

Мы уверены в безупречности наших миграций. Применим их на первой базе, наконец-то указав первую миграцию как базовую (--baseline 20230712165510):

# atlas migrate apply --dir "file://migrations" -u "postgresql://postgres:cheburek15@localhost:5432/postgres?sslmode=disable" --baseline 20230712165510
Migrating to version 20230712165923 from 20230712165510 (1 migrations in total):

  -- migrating version 20230712165923
    -> CREATE INDEX CONCURRENTLY "bookings.facid_memid" ON "cd"."bookings" ("facid", "memid");
    pq: CREATE INDEX CONCURRENTLY cannot run inside a transaction block

  -------------------------
  -- 13.541209ms
  -- 0 migrations ok (1 with errors)
  -- 0 sql statements ok (1 with errors)
Error: sql/migrate: sql/migrate: execute: executing statement "CREATE INDEX CONCURRENTLY \"bookings.facid_memid\" ON \"cd\".\"bookings\" (\"facid\", \"memid\");" from version "20230712165923": pq: CREATE INDEX CONCURRENTLY cannot run inside a transaction block: sql/migrate: execute: write revision: pq: current transaction is aborted, commands ignored until end of transaction block

Возникшая ошибка вполне логична. Исправление напрашивается само:

# atlas migrate apply --dir "file://migrations" -u "postgresql://postgres:cheburek15@localhost:5432/postgres?sslmode=disable" --baseline 20230712165510 --tx-mode none
Migrating to version 20230712165923 from 20230712165510 (1 migrations in total):

  -- migrating version 20230712165923
    -> CREATE INDEX CONCURRENTLY "bookings.facid_memid" ON "cd"."bookings" ("facid", "memid");
    -> CREATE INDEX CONCURRENTLY "bookings.facid_starttime" ON "cd"."bookings" ("facid", "starttime");
    -> CREATE INDEX CONCURRENTLY "bookings.memid_facid" ON "cd"."bookings" ("memid", "facid");
    -> CREATE INDEX CONCURRENTLY "bookings.memid_starttime" ON "cd"."bookings" ("memid", "starttime");
    -> CREATE INDEX CONCURRENTLY "bookings.starttime" ON "cd"."bookings" ("starttime");
    -> CREATE INDEX CONCURRENTLY "members.joindate" ON "cd"."members" ("joindate");
    -> CREATE INDEX CONCURRENTLY "members.recommendedby" ON "cd"."members" ("recommendedby");
  -- ok (140.526595ms)

  -------------------------
  -- 148.935351ms
  -- 1 migrations
  -- 7 sql statements

С этого момента внутри базы существуют схема и табличка atlas_schema_revisions.atlas_schema_revisions, где будут сохраняться миграции:

20230712165923 | create_indexes |    2 |       7 |     7 | 2023-07-12 17:47:38.182729+00 |        3720354 |       |            | WQxGEQSGBr+QCZQB+/cRZ84Ei8Br2iJ9nbwC9/k2oW8= | ["h1:sro8dOuCifHn590KwRbuoLE1w3

8YVON6O2KtEA0pbMQ=", "h1:RlXV4ShxPexTzjxAJZTrOLohhHskcmEHg34yCE1UqAc=", "h1:3yLYUR5xmF7yOWqA01WQrXZDNSHz7iDMa2ln0ZE23EU=", "h1:ZykewJpaiOCFHTEQrJ5mwj1S4uxGjHpTZcb4lG+qTyc=", "h1:TL2rPzD9teKkY4RxGU//7PgInoonG8

1+UrPkB1ykRO0=", "h1:X1salktLooVmN73SG2G8dUjgrbOysmHah5bs3o2eahk=", "h1:+DMH0dQEBDmoGUVXJS0FS9TqXYK6Jd4UbqaMXBd40lQ="] | Atlas CLI v0.12.1-972da10-canary

Более подробно описывать функционал миграций в Atlas не будем. Отметим лишь, что основную часть потребностей разработчика (если вообще не все) инструмент закрывает — вплоть до импорта всех известных форматов утилит для миграции, а также позволяет создавать кастомные форматы.

Свобода никак не ограничивается. Можно использовать Gorm, Beego как провайдеров схем для Atlas. Можно написать свой провайдер с помощью Atlas SDK.

Что ж, настало время перейти на другой уровень.

Работа с Atlas в Kubernetes

Давайте соберём в K8s стенд из нескольких баз данных и накатим на них миграции с помощью Atlas Operator:

$ helm install atlas-operator oci://ghcr.io/ariga/charts/atlas-operator -n atlas-operator --create-namespace
Pulled: ghcr.io/ariga/charts/atlas-operator:0.2.0
Digest: sha256:15c3d10510acf7ba7ba203a3d6c1ecf4a2d54549031eb74aef6452c33488a5e2
NAME: atlas-operator
LAST DEPLOYED: Wed Jul 12 21:46:56 2023
NAMESPACE: atlas-operator
STATUS: deployed
REVISION: 1
TEST SUITE: None
$ kubectl -n atlas-operator get po
NAME                              READY   STATUS    RESTARTS   AGE
atlas-operator-77f748578b-rxhbr   1/1     Running   0          38s

Запустим кучку баз с помощью CloudNativePG и Bitpoke MySQL из конфигурационного файла db.yaml:

apiVersion: v1…
apiVersion: v1
kind: Secret
metadata:
  namespace: databases
  name: shop-secret
type: kubernetes.io/basic-auth
stringData:
  username: shop
  password: cheburek15
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: psql1
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:15
  bootstrap:
    initdb:
      database: shop
      owner: shop
      secret:
        name: shop-secret
  storage:
    size: 1Gi
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: psql2
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:13
  bootstrap:
    initdb:
      database: shop
      owner: shop
      secret:
        name: shop-secret
  storage:
    size: 1Gi
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: psql3
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:11
  bootstrap:
    initdb:
      database: shop
      owner: shop
      secret:
        name: shop-secret
  storage:
    size: 1Gi
---
apiVersion: v1
kind: Secret
metadata:
  name: mysql-secret
type: Opaque
stringData:
  # root password is required to be specified
  ROOT_PASSWORD: groot_password
  ## application credentials that will be created at cluster bootstrap
  DATABASE: employees
  # USER:
  # PASSWORD:
---
apiVersion: mysql.presslabs.org/v1alpha1
kind: MysqlCluster
metadata:
  name: db1
spec:
  replicas: 1
  secretName: mysql-secret
  mysqlVersion: "8.0"
  volumeSpec:
    persistentVolumeClaim:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 1Gi
---
apiVersion: mysql.presslabs.org/v1alpha1
kind: MysqlCluster
metadata:
  name: db2
spec:
  replicas: 1
  secretName: mysql-secret
  mysqlVersion: "5.7"
  volumeSpec:
    persistentVolumeClaim:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 1Gi
---

Создадим ns и применим файл db.yaml:

$ kubectl create ns databases
namespace/databases created
$ kubectl -n databases apply -f db.yaml
secret/shop-secret created
cluster.postgresql.cnpg.io/psql1 created
cluster.postgresql.cnpg.io/psql2 created
cluster.postgresql.cnpg.io/psql3 created
secret/mysql-secret created
mysqlcluster.mysql.presslabs.org/mysql1 created
mysqlcluster.mysql.presslabs.org/mysql2 created

Восемьдесят секунд  — и всё работает!

$ kubectl -n databases get po
NAME          READY   STATUS    RESTARTS   AGE
db1-mysql-0   4/4     Running   0          81s
db2-mysql-0   4/4     Running   0          81s
psql1-1       1/1     Running   0          60s
psql2-1       1/1     Running   0          61s
psql3-1       1/1     Running   0          60s

Приготовим две большие портянки, одна из них — для наших «мусь»:

apiVersion: v1…
---
apiVersion: v1
kind: Secret
metadata:
  name: mysql1-creds
type: Opaque
stringData:
  url: "mysql://root:groot_password@db1-mysql.databases:3306/employees"
---
apiVersion: v1
kind: Secret
metadata:
  name: mysql2-creds
type: Opaque
stringData:
  url: "mysql://root:groot_password@db2-mysql.databases:3306/employees"
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: migrationdir-mysql
data:
  20230717152110_create_tables.sql: |
    -- Create "employees" table
    CREATE TABLE employees (emp_no int NOT NULL, birth_date date NOT NULL, first_name varchar(14) NOT NULL, last_name varchar(16) NOT NULL, gender enum('M','F') NOT NULL, hire_date date NOT NULL, PRIMARY KEY (emp_no)) CHARSET utf8mb4;
    -- Create "departments" table
    CREATE TABLE departments (dept_no char(4) NOT NULL, dept_name varchar(40) NOT NULL, PRIMARY KEY (dept_no), UNIQUE INDEX dept_name (dept_name)) CHARSET utf8mb4;
    -- Create "dept_manager" table

   CREATE TABLE dept_manager (emp_no int NOT NULL, dept_no char(4) NOT NULL, from_date date NOT NULL, to_date date NOT NULL, PRIMARY KEY (emp_no, dept_no), INDEX dept_no (dept_no), CONSTRAINT dept_manager_ibfk_1 FOREIGN KEY (emp_no) REFERENCES employees (emp_no) ON UPDATE NO ACTION ON DELETE CASCADE, CONSTRAINT dept_manager_ibfk_2 FOREIGN KEY (dept_no) REFERENCES departments (dept_no) ON UPDATE NO ACTION ON DELETE CASCADE) CHARSET utf8mb4;
    -- Create "salaries" table
    CREATE TABLE salaries (emp_no int NOT NULL, salary int NOT NULL, from_date date NOT NULL, to_date date NOT NULL, PRIMARY KEY (emp_no, from_date), CONSTRAINT salaries_ibfk_1 FOREIGN KEY (emp_no) REFERENCES employees (emp_no) ON UPDATE NO ACTION ON DELETE CASCADE) CHARSET utf8mb4;
    -- Create "titles" table
    CREATE TABLE titles (emp_no int NOT NULL, title varchar(50) NOT NULL, from_date date NOT NULL, to_date date NULL, PRIMARY KEY (emp_no, title, from_date), CONSTRAINT titles_ibfk_1 FOREIGN KEY (emp_no) REFERENCES employees (emp_no) ON UPDATE NO ACTION ON DELETE CASCADE) CHARSET utf8mb4;
    -- Create "dept_emp" table
    CREATE TABLE dept_emp (emp_no int NOT NULL, dept_no char(4) NOT NULL, from_date date NOT NULL, to_date date NOT NULL, PRIMARY KEY (emp_no, dept_no), INDEX dept_no (dept_no), CONSTRAINT dept_emp_ibfk_1 FOREIGN KEY (emp_no) REFERENCES employees (emp_no) ON UPDATE NO ACTION ON DELETE CASCADE, CONSTRAINT dept_emp_ibfk_2 FOREIGN KEY (dept_no) REFERENCES departments (dept_no) ON UPDATE NO ACTION ON DELETE CASCADE) CHARSET utf8mb4;
  20230717152222_change_lengths.sql: |
    -- Modify "departments" table
    ALTER TABLE departments MODIFY COLUMN dept_name varchar(100) NOT NULL;
    -- Modify "employees" table
    ALTER TABLE employees MODIFY COLUMN first_name varchar(50) NOT NULL, MODIFY COLUMN last_name varchar(50) NOT NULL;
  atlas.sum: |
    h1:FfpuPZJBiN0hYk3K9vIDQfvpbN90jnDVXR53+8YtrBw=
    20230717152110_create_tables.sql h1:CLp9rnUH4jHwWkpAiCScuDtUzYrXbSnJtxI8Mh8ntjQ=
    20230717152222_change_lengths.sql h1:T0Hi658Y7ZsVhaGSD6mhIyFTgURYFpmEf83Yl9Nd4oA=
---
apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasMigration
metadata:
  name: atlasmigration-mysql1
spec:
  urlFrom:
    secretKeyRef:
      key: url
      name: mysql1-creds
  dir:
    configMapRef:
      name: migrationdir-mysql
---
apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasMigration
metadata:
  name: atlasmigration-mysql2
spec:
  urlFrom:
    secretKeyRef:
      key: url
      name: mysql2-creds
  dir:
    configMapRef:
      name: migrationdir-mysql

Внутри у нас есть секреты с URL, которые укажут Atlas’у, куда идти и как (обязательно с указанием namespace, поскольку оператор живёт в другом пространстве имен), общий configmap с нашей схемой и её изменениями в виде миграций, а также custom resource AtlasMigration, который сведёт всё воедино и попросит оператор провести миграции наших баз данных.

Посмотрим, что получится. Применим наш YAML-файл:

$ kubectl -n databases apply -f migrations-mysql.yaml
secret/mysql1-creds created
secret/mysql2-creds created
configmap/migrationdir-mysql created
atlasmigration.db.atlasgo.io/atlasmigration-mysql1 created
atlasmigration.db.atlasgo.io/atlasmigration-mysql2 created

Вывод команды get нельзя назвать очень информативным:

$ kubectl -n databases get atlasmigrations.db.atlasgo.io
NAME                    AGE
atlasmigration-mysql1   2m59s
atlasmigration-mysql2   2m59s

Посмотрим описание базы данных. Хм… Видим, что Reason: Applied, однако для полной уверенности информации всё-таки не хватает:

$ kubectl -n databases describe…
$ kubectl -n databases describe atlasmigrations.db.atlasgo.io atlasmigration-mysql1
Name:         atlasmigration-mysql1
Namespace:    databases
Labels:       <none>
Annotations:  <none>
API Version:  db.atlasgo.io/v1alpha1
Kind:         AtlasMigration
Metadata:
  Creation Timestamp:  2023-07-17T16:01:56Z
  Generation:          1
  Managed Fields:
    API Version:  db.atlasgo.io/v1alpha1
    Fields Type:  FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .:
          f:kubectl.kubernetes.io/last-applied-configuration:
      f:spec:
        .:
        f:dir:
          .:
          f:configMapRef:
        f:urlFrom:
          .:
          f:secretKeyRef:
    Manager:      kubectl-client-side-apply
    Operation:    Update
    Time:         2023-07-17T16:01:56Z
    API Version:  db.atlasgo.io/v1alpha1
    Fields Type:  FieldsV1
    fieldsV1:
      f:status:
        .:
        f:conditions:
        f:lastApplied:
        f:lastAppliedVersion:
        f:observed_hash:
    Manager:         manager
    Operation:       Update
    Subresource:     status
    Time:            2023-07-17T16:15:19Z
  Resource Version:  53849536
  UID:               43b829b4-c36d-4761-adff-aa304457c21a
Spec:
  Dir:
    Config Map Ref:
      Name:  migrationdir-mysql
  URL From:
    Secret Key Ref:
      Key:   url
      Name:  mysql1-creds
Status:
  Conditions:
    Last Transition Time:  2023-07-17T16:15:18Z
    Message:
    Reason:                Applied
    Status:                True
    Type:                  Ready
  Last Applied:            1689610518
  Last Applied Version:    20230717152222
  observed_hash:           c7911afe9a6f0918b5cdebfc41f85baf6d9bc08cd272aed87b25b3541be71a35
Events:                    <none>

Если же заглянуть в базу данных, то все сомнения развеиваются:

$ kubectl -n databases exec -i db1-mysql-0 -cmysql -- mysql -uroot -pgroot_password employees -e'show create table employees;'
mysql: [Warning] Using a password on the command line interface can be insecure.
Table	Create Table
employees	CREATE TABLE `employees` (\n  `emp_no` int NOT NULL,\n  `birth_date` date NOT NULL,\n  `first_name` varchar(50) NOT NULL,\n  `last_name` varchar(50) NOT NULL,\n  `gender` enum('M','F') NOT NULL,\n  `hire_date` date NOT NULL,\n  PRIMARY KEY (`emp_no`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
$ kubectl -n databases exec -i db2-mysql-0 -cmysql -- mysql -uroot -pgroot_password employees -e'show create table employees;'
mysql: [Warning] Using a password on the command line interface can be insecure.
Table	Create Table
employees	CREATE TABLE `employees` (\n  `emp_no` int(11) NOT NULL,\n  `birth_date` date NOT NULL,\n  `first_name` varchar(50) NOT NULL,\n  `last_name` varchar(50) NOT NULL,\n  `gender` enum('M','F') NOT NULL,\n  `hire_date` date NOT NULL,\n  PRIMARY KEY (`emp_no`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

С PostgreSQL аналогично:

apiVersion: v1…
---
apiVersion: v1
kind: Secret
metadata:
  name: psql1-creds
type: Opaque
stringData:
  url: "postgres://shop:cheburek15@psql1-rw:5432/shop?sslmode=disable"
---
apiVersion: v1
kind: Secret
metadata:
  name: psql2-creds
type: Opaque
stringData:
  url: "postgres://shop:cheburek15@psql2-rw:5432/shop?sslmode=disable"
---
apiVersion: v1
kind: Secret
metadata:
  name: psql3-creds
type: Opaque
stringData:
  url: "postgres://shop:cheburek15@psql3-rw:5432/shop?sslmode=disable"
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: migrationdir-psql
data:
  20230712165510_create_tables.sql: |
    -- Add new schema named "cd"
    CREATE SCHEMA "cd";
    -- Create "facilities" table
    CREATE TABLE "cd"."facilities" ("facid" integer NOT NULL, "name" character varying(100) NOT NULL, "membercost" numeric NOT NULL, "guestcost" numeric NOT NULL, "initialoutlay" numeric NOT NULL, "monthlymaintenance" numeric NOT NULL, PRIMARY KEY ("facid"));
    -- Create "members" table
    CREATE TABLE "cd"."members" ("memid" integer NOT NULL, "surname" character varying(200) NOT NULL, "firstname" character varying(200) NOT NULL, "address" character varying(300) NOT NULL, "zipcode" integer NOT NULL, "telephone" character varying(20) NOT NULL, "recommendedby" integer NULL, "joindate" timestamp NOT NULL, PRIMARY KEY ("memid"), CONSTRAINT "members_recommendedby_fkey" FOREIGN KEY ("recommendedby") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE SET NULL);
    -- Create "bookings" table
    CREATE TABLE "cd"."bookings" ("bookid" integer NOT NULL, "facid" integer NOT NULL, "memid" integer NOT NULL, "starttime" timestamp NOT NULL, "slots" integer NOT NULL, PRIMARY KEY ("bookid"), CONSTRAINT "bookings_facid_fkey" FOREIGN KEY ("facid") REFERENCES "cd"."facilities" ("facid") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "bookings_memid_fkey" FOREIGN KEY ("memid") REFERENCES "cd"."members" ("memid") ON UPDATE NO ACTION ON DELETE NO ACTION);
    -- Create "employees" table
  20230712165923_create_indexes.sql: |
    -- Create index "bookings.facid_memid" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.facid_memid" ON "cd"."bookings" ("facid", "memid");
-- Create index "bookings.facid_starttime" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.facid_starttime" ON "cd"."bookings" ("facid", "starttime");
-- Create index "bookings.memid_facid" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.memid_facid" ON "cd"."bookings" ("memid", "facid");
-- Create index "bookings.memid_starttime" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.memid_starttime" ON "cd"."bookings" ("memid", "starttime");
-- Create index "bookings.starttime" to table: "bookings"
CREATE INDEX CONCURRENTLY "bookings.starttime" ON "cd"."bookings" ("starttime");
-- Create index "members.joindate" to table: "members"
CREATE INDEX CONCURRENTLY "members.joindate" ON "cd"."members" ("joindate");
-- Create index "members.recommendedby" to table: "members"
CREATE INDEX CONCURRENTLY "members.recommendedby" ON "cd"."members" ("recommendedby");
 ("recommendedby");
  atlas.sum: |
    h1:xTbw3AxHRH3YSW7dOGteMxvcigM3f/Y7pdkwwScmbGM=
    20230712165510_create_tables.sql h1:BWXssdgcYxJcYSNOaK7bYlhgB5LsumtrNXiBfkC3HiU=
    20230712165923_create_indexes.sql h1:WQxGEQSGBr+QCZQB+/cRZ84Ei8Br2iJ9nbwC9/k2oW8=
---
apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasMigration
metadata:
  name: atlasmigration-psql1
spec:
  urlFrom:
    secretKeyRef:
      key: url
      name: psql1-creds
  dir:
    configMapRef:
      name: "migrationdir-psql"
---
apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasMigration
metadata:
  name: atlasmigration-psql2
spec:
  urlFrom:
    secretKeyRef:
      key: url
      name: psql2-creds
  dir:
    configMapRef:
      name: "migrationdir-psql"
---
apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasMigration
metadata:
  name: atlasmigration-psql3
spec:
  urlFrom:
    secretKeyRef:
      key: url
      name: psql3-creds
  dir:
    configMapRef:
      name: "migrationdir-psql"

На этом пути нас ждал один курьёзный Pull Request — в ссылке на issue tracker всего проекта мы обнаружили опечатку, пришлось поправить и её. Это, конечно же, никак не помешало создать новый issue о нашей неудавшейся миграции, когда свойство CONCURRENTLY приводит к ошибке при создании индекса:

Status:
  Conditions:
    Last Transition Time:  2023-07-17T17:18:37Z
    Message:               sql/migrate: sql/migrate: execute: executing statement "CREATE INDEX CONCURRENTLY \"bookings.facid_memid\" ON \"cd\".\"bookings\" (\"facid\", \"memid\");" from version "20230712165923": pq: CREATE INDEX CONCURRENTLY cannot run inside a transaction block: sql/migrate: execute: write revision: pq: current transaction is aborted, commands ignored until end of transaction block
    Reason:                Reconciling
    Status:                False
    Type:                  Ready
  Last Applied:            0

С помощью разработчиков мы поправили наши миграции до исправного состояния. Из документации было не совсем очевидно, что именно нужно сделать. Но мы разобрались и стало понятнее:

A file directive should be separated by two newlines from the first statement's comment.

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

Заключение

Мы попробовали Atlas в нескольких простых комбинациях в двух контейнеризованных окружениях. Кажется, что результат вполне неплох, а инструмент пригоден для применения во всех контурах. Важно понимать:

  • что именно находится в миграциях DDL;

  • какой результат необходим в состояниях баз данных;

  • каковы ожидания от инструмента миграции и оператора, который им управляет. 

Контейнеризованная инфраструктура, которую используют разные группы разработчиков с несколькими (и даже вполне многочисленными) контурами-окружениями, — очень интересный и живой кейс.

Научившись совместно использовать Atlas, можно с максимальной воспроизводимостью и прозрачностью для всех участников процесса — а, главное, безопасно! — тиражировать изменения не только кода, но и DDL: от локального окружения разработчика до production. Безопасность могут обеспечить как сам инструмент (например, с помощью вычисления контрольных сумм миграций), так и проверка миграций. Нет сомнений, что функционал будет развиваться и дальше.

P. S.

Читайте также в нашем блоге: