goでmigrationをsingle binaryで提供する

goといえば、シングルバイナリだと思うんですが Databaseのマイグレーションはどのように適用していますでしょうか?

今回はgoでマイグレーションをシングルバイナリで適用する方法を考えてみたのでここに書いておきます。

はじめに

僕はJavaのWebフレームワークであるdropwizardが好きです。 このフレームワークは、アプリケーションの起動コマンドに db migrateみたいなサブコマンドが実装することができて 割とこの思想は好きでした。

アプリケーションと対応するマイグレーションがあるが マイグレーションは別で適用することができる、みたいな柔軟なところが素敵だなぁと思っていました。

モチベーション

モチベーションとしては、端的にいうとアプリケーションとマイグレーションはセットになって管理されて欲しいからです。 Dockerイメージならその要求はすぐ満たせるでしょう しかし、マイグレーションを適用する方法がdocker imageに入って専用のCLIツールで叩く、とかだとどうでしょうか。 想像してみると辛くないですか?

シングルバイナリでマイグレーションファイルもまとめてバイナリに入れておいて アプリケーションの実行ファイルからマイグレーション出来るとどうでしょうか? これならどこでもすぐ動かせるようになりますね。

docker imageがないと起動できない、とかもありません! 素晴らしいですね。 依存するミドルウェアさえあれば、対応するバイナリを落としてきて 実行するだけです!

じゃあどうやるか

サーバを起動する実行ファイルのサブコマンドとして migrateみたいなのを提供して マイグレーションファイルは静的ファイルをバイナリに含めるツールを使ってバイナリに含めてやれば良いです。

goの文脈では、CLIツール用のライブラリはいくつもあるのでサブコマンドを実装するのは簡単です。 また、静的ファイルをバイナリに含めるのもツールがいくつもあるので簡単です。 あとは組み合わせるだけです。

何を使うか

今回は以下のツールを使います。

今回実装する方針の内容は、rubenv/sql-migrateを使っても可能ですし 静的ファイルをバイナリに組み込む方法として、gobuffalo/packrを使っても良いです。 他にも組み合わせは色々あるとは思います。

ちなみに、rakyll/statikはfs.Readdir(0)の挙動が修正された0.17が最近リリースされたようなので 今回のマイグレーションファイルをシングルバイナリに埋め込んで golang-migrate/migrateの実装が可能になりました。 修正は結構前にされてたんですがリリースされてなくてモヤモヤした日々を過ごしていました。

ちなみに、リリースされているのを見つけたツイートがこちらです。

以前試した時は組み合わせが悪くて動かなかったんですが 動くようになって嬉しいですね。

どうやってやるか

こんな感じで出来ます。

import (
    "log"

    _ "statik-migrate/statik"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    "github.com/golang-migrate/migrate/v4/source/httpfs"
    _ "github.com/lib/pq"
    "github.com/rakyll/statik/fs"
)

//go:generate statik -src=./migrations
func migration() error {
    f, err := fs.New()
    if err != nil {
        return err
    }
    hfs, err := httpfs.New(f, "/")
    if err != nil {
        return err
    }
    m, err := migrate.NewWithSourceInstance("httpfs", hfs, "postgresql://postgres:postgres@localhost:15432/test?sslmode=disable")
    if err != nil {
        return err
    }
    defer m.Close()
    return m.Up()
}

本番用途で使うなら色んなことを考える必要はある気はしますが ベースはこのような形でマイグレーションを実行することができるはずです。

ちなみに本番用途で使うならどんな対応が必要になるか少し考えてみます。

ざっくりこんなところでしょうか。

まとめ

今回はgolang-migrate/migrateとrakyll/statikで マイグレーションをシングルバイナリで提供してみる方法を書きました。 いくつか課題はありつつも、アプリケーションとマイグレーションが固まって提供されることで アプリケーションとマイグレーションの整合性をうまく担保できるのではと思っています。

また、最近思っていることとして アプリケーションの外にマイグレーションを置いておいて マイグレーションだけ先に適用する、みたいな手法も考えた方が良いのかなと思っていたりもします。

これは、アプリケーションのリファクタリングとデータベースのリファクタリングはライフサイクルが違うので 例えば、先にカラムを追加しておいて、新しいアプリケーションがリリースされるまでトリガーを入れておいてデータベースのリファクタリングをする、みたいなことを考えたときに アプリケーションとセットでマイグレーションを提供している場合に、トリガーは手でメンテナンスすることになります。 もしくは、トリガーだけ先にリリースしておいて後で消す、みたいな対応を考えた場合 その間新しい機能の実装はどうするのでしょうか? git flowみたいなので考えると、hotfixで先にトリガーをリリースしておいて 新しい機能はdevelopブランチで実装していく、みたいなのはできる気はしますが 少し混乱しそうだなぁとか思いました。

追記: データーベースのマイグレーションで思っていること

最近、マイグレーションもgitops出来たらいいなぁみたいな気持ちがあリます。 今回の記事の調査で、golang-migrateを調べたところ、golang-migrateはmigrationのソースとしてgithubリポジトリを読むことができます。 これを使えば、DBのある環境にagentもしくはジョブでmigrationをgitopsできるのかなぁと思っています。

ただ、マイグレーションをgitopsする場合、複数の環境にmigrationを当てる、みたいな関係上 バージョン管理とかをうまくしないといけません。 これはマイグレーションのバージョンと適用するバージョンを別で管理しないといけなさそう。

なんか難しそうな話になってきたので寝る

CIでAmazon ECRにDockerイメージをpushするときのプラクティス

ECRにpushするとき、皆さんはどうやってECRの認証をしていますか? 大体の人が、aws cliをインストールして認証しているのではないかと思っています。

今回はAmazon ECRへPushする仕事があったので、一から見直してみた結果をここにまとめておきます。 いくつかのパターンを例に挙げながら、最終的に良さそうだな、と思った内容をここに書いておきます。

僕としては、「aws cli v2」でECRに認証するのが楽かな、という結論になりました。

前提

筆者はCircleCIを主に使っているので、CircleCIベースで話を進めます。 また、アプリケーションの開発時、Amazon ECRは認証が必要です。(認証なしでpush出来るように出来るかは筆者は分かりませんが、使うこともないでしょう。) 開発用の言語はpython以外の前提で書いていきます。 この辺りを踏まえた上で続きをお読みください。

今回の開発用の言語ではgoを想定した上で書いていきます。 また、特に言及がない限り、開発用の言語をビルドするためのイメージを使うことにします。 加えて、aws cliと表記した場合はaws cliのv1を指します。

はじめに

ECRに楽にログインする場合、aws cliを使うのがメジャーな方法だと思います。 しかし、aws cli v1ではpythonのランタイムが必須のため、aws cliのインストールのために 開発用の言語に加えてaws cliを入れたCI用のDockerイメージを作るか aws cliを使わず、他の手段で認証する必要がありました。

今回の記事ではそれらを踏まえた上で、ECRにDockerイメージをpushする方法を この記事で書いていきます。

今回この記事で書くのは以下の方法です。

  1. aws cliをインストールして認証を行う
  2. CircleCI orbを使ってaws cliをインストールして認証を行う
  3. persist_to_workspace/attach_workspaceを使って別のジョブでpushする
  4. 任意の言語で認証を行う
  5. amazon-ecr-credential-helperを使って認証を行う
  6. aws cli v2をインストールして認証を行う

pipからaws cliをインストールして認証を行う

これは単純です。しかし、aws cliをインストールするためだけに時間がかかります。 ちなみにCircleCIの公式のイメージのように、pythonの入ってないイメージの場合はpythonのインストールから必要になります。

  • メリット
    • 単純
  • デメリット
    • インストール時間がそれなりにかかる

CircleCI Orbを使ってaws cliをインストールする

CircleCI Orbを使ってaws cliをインストールすることで 公式がメンテナンスしてくれるという安心感があります。 しかし、awcliのインストールスクリプトpythonなので、やっぱりpythonのランタイムは必要です。

  • メリット
    • 公式がメンテナンスしてくれる安心感が得られる
  • デメリット

persist_to_workspace/attach_workspaceを使って別のジョブでpushする

CircleCIにはworkflowで生成された成果物を別のジョブに引きわたす機能があります。 それが、persist_to_workspace/attach_workspaceです。 この方法は、persist_to_workspace/attach_workspaceを使って 開発用の言語のジョブとは別のジョブでアプリケーションのDockerイメージを作成します。 そのため、開発用の言語のDockerイメージで生成した成果物を awscliを入れたイメージでのビルドに引き渡すことで awscliのインストールをすることがありません。 しかし、そもそもawscliの入ったdocker imageを公式が提供していません。。。

  • メリット
    • awscliのインストールがなくなる
  • デメリット
    • そもそもawscliの入ったdocker imageを公式が提供していないので自前で用意する必要がある
      • 野良のイメージを使ってもいいんですがライフサイクルのよく分からないイメージを使うのは怖い
    • ビルド間での成果物の引き渡しをするのでビルド自体の起動時間(docker imageのpullとコンテナが立ち上がるまでの時間)が増える

任意の言語で認証を行う

これは、前職でやっていた手法なのですが、gradleなどでビルドツールからECRとの認証を行ってpushします。 awscli周りは簡単になるのですが、ビルド周りの複雑さは避けられないでしょう。

  • メリット
    • awscliのインストールがなくなる
  • デメリット
    • ビルド周りの複雑さが少し増える
    • ジョブのコンテナの起動時間が増える

amazon-ecr-credential-helperを使って認証を行う

以下のようなjson~/.docker/config.json に配置しておきます。 そうすると、dockerは docker-credential-ecr-login というPATHに入っている実行ファイルを使って認証を行ってくれます。 ちなみに以下の方法の場合、全てのdockerの通信をecrの通信として扱います。ECR以外のリポジトリにもpushする場合は注意が必要です。 dockerの1.13以降ではリポジトリのURLで認証を分けられます。詳しくはDockerのドキュメントをみてください。

{
    "credsStore": "ecr-login"
} 

しかし、この方法もやはりデメリットがあり、amazon-ecr-credential-helperはディストリビューションからのインストール以外は 自分でビルドする必要があります。 ちなみに、goで開発されているため、ビルドにはgoのランタイムが必要です。

installする方法は https://github.com/awslabs/amazon-ecr-credential-helper をみてください。

aws cli v2をインストールして認証を行う

aws cli v2の場合、pythomのランタイム入りのzipが配布されています。 そのため、その他のツールのインストールが必要ありません。これは便利です。

aws cli v2のインストールは、以下のようなスクリプトを使ってインストールすることになるでしょう。

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

中のファイル構成に依存しても良いなら、キャッシュもうまく効かせられます。 ちなみに現状、上記のような形でunzipした場合は ./aws/dist/aws に実行バイナリが入っています。

  • メリット
    • 公式がランタイムも含めて配布してくれているので簡単にインストールできる
    • 中のファイルの構成に依存しても良いなら、キャッシュしやすい
  • デメリット
    • URLにバージョンが含まれていないので、動かないzipが配信された時にCIが壊れる

まとめ

今回、docker imageをECRにpushする要件があったので、調べていたところ aws cliをインストールをする必要があるなぁと思っていたんですが、それをTwitterで呟いていたところ、aws cli v2がリリースされていたのを思い出して リリース方法を確認したところ、ランタイムもまとめて配布されていることを確認しました。

aws cli v2を使ってビルドする前に、credential helperでPushも出来たのですが goのランタイムで自前ビルドをしてしまっていたので これもaws cli v2に移行しました。

Amazon ECRにpushするなら、aws cli v2で認証するので良いんじゃないでしょうか。

追記

書くの忘れてたんですが aws cli v2を使う場合、ECRへのログイン方法が変わっていることに注意してください。

# aws cli v1の場合
eval "$(aws ecr get-login --no-include-email)"

# aws cli v2の場合
aws ecr get-login-password | docker login --username AWS --password-stdin $REPO_URL

emc v0.0.2をリリースしました

以下で書いたJVMのメモリ設定をアプリケーションのjarファイルから自動で計算してくれるツールです。 progret.hatenadiary.com

v0.0.2をリリースしました。 リリースされた機能は以下の一つです。

  • [experimental] ラムダ式をクラスファイル数にカウントする

ラムダ式をクラスファイル数にカウントする理由としては ラムダ式を実行した場合、クラスの定義がランタイムに行われるからです。

この機能を実装するために、クラスファイルのパーサーをforkして 必要な機能に対応したのが以下のリポジトリにあるモジュールです。

github.com

インストール方法

# MacOS 
curl -L https://github.com/wreulicke/emc/releases/download/v0.0.2/emc_0.0.2_darwin_amd64 -o /usr/local/bin/emc

# Linux
curl -L https://github.com/wreulicke/emc/releases/download/v0.0.2/emc_0.0.2_linux_amd64 -o /usr/local/bin/emc

# Windows
curl -L https://github.com/wreulicke/emc/releases/download/v0.0.2/emc_0.0.2_windows_amd64.exe -o <path-directory>/emc.exe

ラムダ式で定義されるクラスをクラスファイルから検出する方法

ラムダ式で定義されるクラスの数は クラスファイルにはBootstrapMethodsというAttributeがあり その中に、java/lang/invoke/LambdaMetafactory.metaFactoryというメソッドへの参照が書かれている数で検出可能です。 これ自体はそんな難しい事はありません。 大変だったのは、クラスファイルのパーサーを拡張する事でした。

それが次のDoubleとFloatの定数に関する話です。

DoubleとFloatの定数の話

少しコンスタントプールの仕様を読んでいて面白い内容があったのでここに書いておきます。 DoubleとFloatの定数がある場合、クラスファイルの構造上、どうなるかご存知でしょうか?

コンスタントプールとしてはDoubleとFloatは値としては、1枠で表現されます。 しかし、仕様上、なぜか次の枠を飛ばす事になっています。 emcはこれが原因でクラスファイルのパースに失敗するようになっていました。

クラスファイルパーサで対応した部分は以下の部分です。 go-java-class-parser/constant_pool.go at 4a96d1c7ef56592775fe2c8e0ee376a74c042ed4 · wreulicke/go-java-class-parser · GitHub

ちなみに JVM Specificationにもちゃんとこれは書かれていて 以下のように書かれています。

全ての8バイトの定数は2つのエントリを使います。LongとDoubleの定数のエントリがnにある場合、次に使えるのは n + 2のエントリです。 n + 1は有効だが使用不可だと見なされます。 振り返ってみると8バイトの定数が2つのコンスタントプールのエントリを取るのは、悪い選択でした。 と書かれています。

All 8-byte constants take up two entries in the constant_pool table of the class file. If a CONSTANT_Long_info or CONSTANT_Double_info structure is the entry at index n in the constant_pool table, then the next usable entry in the table is located at index n+2. The constant_pool index n+1 must be valid but is considered unusable.

In retrospect, making 8-byte constants take two constant pool entries was a poor choice.

面白いですね。

まとめ

ラムダ式の数もクラスファイル数としてカウントする実験的機能を追加したemc v0.0.2をリリースしました。

その際、クラスファイルをパースする必要があったので、クラスファイルパーサをforkして拡張しましたが DoubleとLongの定数の仕様には驚かされました。 JVM Specificationは読んでみると面白いですね。

終わり。