ぱいぱいにっき

Pythonが好きすぎるけれど、今からPerlを好きになりますにっき

大コンテナ時代における.gitを使うワークフローの難点を解決するためにGitHubDDLを作った

こんにちは、この記事はPerl Advent Calendar 2021の4日目の記事です。

3日目は@yoku0825さんのPerlで作られたMySQL用の何かについてでした。日々お世話になっている、pt-query-digestがPerlで作られているのは知っていたのですが、他にもいろいろPerl製ツールがあるんですね。

さて、最近仕事で発生した課題を解決するためにGitHubDDLというCPANモジュールを作ったので紹介させていただきます。

TL;DR

  • コンテナ環境において、プロジェクトの.gitをコンテナイメージに焼いたり、volume mountを行うのはいくつかの面で望ましくない
  • 仕事ではDBスキーママイグレーションに.gitを用いるGitDDLを使用していた
  • 以上のために、ECSでEFSマウントで.gitをマウントして構成が複雑になったり、.gitをイメージに焼いてpullが遅くなるなどしていた
  • それを解決するために、.gitを使わないツールとしてGitHubDDLを作った
    • .gitの代わりにGitHubからファイルを取得する

コンテナ環境に.gitを使うツールを使うのは辛い

Web開発においてはほとんどのケースでgitを使うようになっているのではないのでしょうか。gitはバージョン管理ツールではありますが、その副作用としてファイルの変更ごとに対応したコミットハッシュがつき、それをバージョン番号と見なすことができます。このバージョン番号や、過去のバージョンのファイルを取り出せるというメリットを使って古今東西さまざまなツールやワークフローが作られていると思います。

さて、現在の私の仕事では、全ての本番環境はAmazon ECSを使ったコンテナ環境で動作していて、踏み台サーバ的な存在も廃止してしまいました。では、手動でバッチプログラムを実行したり、DBスキーママイグレーションする作業をする場合には、本番とほぼ同様のコンテナを起動して、そこにECS Execやそれに類する技術でログインし、実行しています。また、シェルへのログインもせずコンテナ起動時にCMDをバッチプログラムに上書きして実行する場合もあります。

つまり、S3やEFSなどで外部から引っ張ってこない限りは、コンテナイメージに焼かれたファイル群のみを使ってバッチを実行するという制限が付いているわけです。ここに.gitを要求するようなワークフローが存在するとコンテナに.gitを焼くなり、EFSマウントを行うしかありません。このケースは非常にめんどくさくなります。

.gitをコンテナイメージに焼く場合の弊害

.gitにはshallow cloneを活用しない限りは、最初のコミットから今に至るまで全てのファイルが記録されています。.gitをファイル変更履歴として活用する場合、shallow cloneで--depth=1を指定すると意味がなくなるため、ある程度のコミットを掘って取得する、あるいは通常のcloneを行うことになります。

前者の場合、depthで指定した深さよりも前の履歴が必要と思われるケースではさらにunshallowをすることになりますが、それが頻繁に想定される場合はshallow clone自体が意味がないので、結局通常のcloneが最適解ということになります。さらに必要になったらunshallowする場合は何らかの形でcloneする場合の鍵をコンテナタスクが取得できなければなりません。必要になったらunshallowは労力が大きそうです。

というわけでリポジトリの全ての履歴を持った.gitを焼くと楽になるのですが、反面コンテナイメージが大きくなります。大きくなった場合、buildやpushに時間がかかります。一番大きな問題はpullに時間がかかることです。コンテナタスクを増やしてスケールアウトをする場合にpullの時間がかかると、タスク数が負荷を受け止め切れる台数に至るまでに時間がかかり、障害を起こすかもしれません。

また、Amazon ECSの場合はEFSを使用する手があります。つまりコンテナイメージの外に.gitを置くボリュームを作成しておき、これを必要に応じてマウントするという手法です。ですが、これは構成が複雑になり、定期的な.gitのメンテナンスが必要です。頻繁にブランチを切り替えたりする環境ではgit gcを定期的にするのを怠って、checkoutが非常に遅くなり問題になったケースがありました。

.gitを使わないAlternative GitDDL => GitHubDDL

私のプロジェクトで使用しているGitDDLは、DBスキーママイグレーションツールで、.gitを使用してDBに適用ずみのDDLを取り出し、ローカルにあるDDLと比較してALTER文などを生成するものです。

スキーママイグレーションは頻繁に行う作業であり、このためにコンテナイメージに.gitを焼いていたのですが、いい加減なんとかしないといけないなというのと、実装アイディアはあったので、今回新しくGitHubDDLを作成しました。GitHubDDLはGitDDLから多くのコードを利用しており、初期化オプション以外のメソッドの互換性を保った実装にしています。

.gitの代わりにGitHubを使う

GitDDLではgitコマンドを使ってDBに適用されているDDLを取り出すと記述しました。具体的にはこのようなコードになっています。

GitDDL/GitDDL.pm at bdf5dae23d685d90136cec606cbe42d5e9c78c95 · typester/GitDDL · GitHub

sub _dump_sql_for_specified_commit {
    my ($self, $commit_hash, $outfile) = @_;

    my ($mode, $type, $blob_hash) = split /\s+/, scalar $self->_git->run(
        'ls-tree', $commit_hash, '--', $self->ddl_file,
    );

    my $sql = $self->_git->run('cat-file', 'blob', $blob_hash);

    open my $fh, '>', $outfile or croak $!;
    print $fh $sql;
    close $fh;
}

この関数の外部から渡されている$commit_hashは変更対象のDBに存在するgit_ddl_version(名前はオプションで変更できる)から取り出したものです。これからgit ls-treeでgitオブジェクトのIDを取得し、git cat-fileで実際に取り出しています。

一方、GitHubDDLではどうやっているかというと、

GitHubDDL/GitHubDDL.pm at 046e10461231e619aae1473523aeedacfc1031f7 · mackee/GitHubDDL · GitHub

sub _dump_sql_for_specified_commit {
    my ($self, $commit_hash, $outfile) = @_;

    open my $fh, '>', $outfile or croak $!;
    if (my $method = $self->dump_sql_specified_commit_method) {
        my $sql = $method->($commit_hash);
        print $fh $sql;
        close $fh;
        return;
    }

    my $url = sprintf "https://raw.githubusercontent.com/%s/%s/%s/%s",
        $self->github_user,
        $self->github_repo,
        $commit_hash,
        $self->ddl_file;

    my $furl = Furl->new;
    my $res = $furl->request(
        method          => "GET",
        url             => $url,
        headers         => [
            Authorization => "token " . $self->github_token,
            Accept        => "application/vnd.github.v3+raw",
        ],
        write_code      => sub {
            my ( $status, $msg, $headers, $buf ) = @_;
            if ($status != 200) {
                die "status is not success when dump sql from GitHub: " . $self->ddl_file . ", status=" . $status;
            }
            print $fh $buf;
        }
    );
    close $fh;
}

このような形で、GitHubへ直接アクセスすることでファイル本体を取得しています。ちなみにprivate repositoryなどでも使えるように、Access tokenをつけています。これは基本的にはいわゆるPersonal Access Tokenを指定しますが、拙作のGitHub::Apps::Authを使うことで、GitHub Appsの認証情報でも同様に使うことができます。

その他おまけ

上記の__dump_sql_for_specified_commitメソッドの動作を外部から書き換えられるように、dump_sql_specified_commit_methodというオプションをつけられます。これはCodeRefを受け取るもので、どういうケースで使うかというと、.gitがあるときはGitDDLと同じ動作、GITHUB_TOKENが与えられたらGitHubDDLの動作という風に切り替えられます。

以下のコードは、これに加えて、DDL_VERSION環境変数にローカルのDDLのコミットハッシュが与えられるつもりだが、ない場合はGitDDLと同じ動作という挙動を実現しています。

my $ddl_version = $ENV{DDL_VERSION};
if (!$ddl_version) {
    $ddl_version = `git log -n 1 --pretty=format:%H -- sql/schema.sql`;
    die 'require $DDL_VRESION or .git on local for migrate' if $?;
    chomp $ddl_version;
}
my $dump_sql_specified_commit_method;
my $github_token = $ENV{GITHUB_TOKEN};
if (!$github_token) {
    $dump_sql_specified_commit_method = sub {
        my $commit = shift;

        my (undef, undef, $blob_hash) = split /\s+/, `git ls-tree $commit -- sql/schema.sql`;

        my $sql = `git cat-file blob $blob_hash`;
        chomp $sql;
        return $sql;
    },
}

my $gd = GitHubDDL->new(
    ...,
    ddl_version => $ddl_version,
    dump_sql_specified_commit_method => $dump_sql_specified_commit_method,
);

このようにすれば、漸進的にGitDDLからの移行ができます。

いかがでしたでしょうか? 明日5日はid:kfly8さんの「Perlのコンテキストクイズにツールで答えてみた」です。お楽しみに!

以下は捕捉情報

DBスキーママイグレーションとは

普段のWeb開発で私はMySQLもしくはMySQLプロトコルをしゃべるDBソフトウェアを使っています。MySQLが属するRDBMSというデータベースの種族は、かっちりと形が決まったデータベーススキーマを定義してRDBMSに適用し、それに沿ったデータを実際に稼働するWebアプリケーションで操作します。

ところで、RDBMSスキーマ、一般的にはDDLと呼ばれる形式で記述されます。例えば新しくテーブルを作るときは、

CREATE TABLE `sample_table` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(191) NOT NULL,
    PRIMATY KEY (`id`)
);

のように記述するわけです。

ただ、私がお仕事で作るWebアプリケーションは、インターネット上で公開された後も、数年間の運用というものが発生し、その間も機能を追加したり修正をしたりなどの作業が発生します。

そういった既存コードをいじる作業もあるのですが、DBスキーマを変更しなければならない場面も発生します。

例えば、上記のsample_tableにカラムdescriptionを足したいとなった場合、これがお手元の開発環境であれば、

DROP TABLE `sample_table`;
CREATE TABLE `sample_table` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(191) NOT NULL,
    `description` TEXT DEFAULT NULL
    PRIMATY KEY (`id`)
);

とすれば良いのですが、これをこのまま実際に稼働している本番環境で適用できることはまずありません。何故なら、運用を始めたWebアプリケーションのDBには消えてはいけないとされるデータが既に入っており、そこにDROP TABLEを打つことはできないからです。

ではどうするか。以下のDDLで既存テーブルにカラムを追加します。

ALTER TABLE `sample_table` ADD COLUMN `description` TEXT DEFAULT NULL;

このような一連の作業をDBのスキーママイグレーションと呼びます。

実際に世間ではどうやっているの

人間がスキーマ変更を伴うデプロイのたびに、手で温かみのあるALTER文を書くのは、できなくはありませんが、めちゃくちゃ大変なことではあります。また、ローカル開発環境構築時などではイチから完全な状態のDDLが欲しいところです。その場合、開発環境向けには完全なDDL、デプロイ用にALTER文を用意することになり、ミスや抜けもれが容易に発生します。もちろんこの完全なDDLとALTER文を逐次適用した状態のDBを比較するテストコードを作ることはできなくはないとは思いますが...。

というわけで私の知るかぎり、この辺りの運用を解決するためのアプローチとして以下の2つがあります。

  • 変更したい部分を記述したDDLもしくはそれに準じるDSLを書く。完全なDDLはそこから生成する
  • 古い(もしくは適用されている)完全なDDLと、変更後の完全なDDL間の差分をプログラムで計算し、ALTER文を生成する

また、後者の差分を出すケースの場合の中にも、比較対象のDDLの取り出し方にいくつかアプローチがあるようです * 適用したDDLをバージョンごとにローカルファイルで保存しておく * メリット: ローカルファイル同士の差分でシンプル * デメリット: DDLの適用ごとに完全なDDLのファイルが増えていく。以前に適用したファイルはどれか何らかの方法で覚えておく必要あり * バージョン管理ツールでDDLが管理されている場合、比較する際にバージョン管理ツールで前回適用したコミットハッシュからDDLを取り出す * メリット: バージョン管理ツールでDDLを含んだコードを管理している場合は合理的 * デメリット: ALTERを生成する場所にバージョン管理ツールのメタデータファイルが必要, 適用済みコミットハッシュを何らかの手段で覚えておく必要あり * 変更対象のDBに接続し、SHOW CREATE TABLEなどのコマンドで適用済みのDDLを取り出す * メリット: 前のDDLのバージョンなどを覚える必要はない。適用したいDDL以外のローカルファイルが必要ない * デメリット: ALTERを生成する場所からDBに接続できる必要がある

それぞれメリットデメリットがあります。

GitDDLのやり方

GitDDLは今まで説明した方法のうち以下を採用します。

  • 2つの完全なDDLから差分を生成する
  • gitから過去に適用したDDLを取り出す
  • 適用したコミットハッシュはgit_ddl_versionという専用のテーブルに記録する