コンテナイメージのタグをよしなに探すツールを作りました

SRE チームの市川恭佑です。

今回はコンテナに関連するDevOpsツールを作った話です。ecresolve と書いて「イーシーリゾルブ」と読みます*1

github.com

ecresolve は、ざっくり言うとコンテナイメージのタグが上書きされる前提で、複数タグを指定すると一番先頭で見つかったものを出力するツールです。 基本的な使い方としては、たとえば dogcat のタグのみが存在する ECR リポジトリ animal があるとき、以下のコマンドを実行すると標準出力に dog が出力されます*2

ecresolve monkey bird dog human cat --repository-name=animal --format=tag-only
# dog

なお、本記事の大前提として、もちろん本番環境において、特にお客様のデータを取り扱うようなコンポーネントについては、コンテナイメージの「上書き前提のmutableな運用」はおすすめしません。

しかし、検証環境*3にあるジョブやミドルウェアをはじめとして、「最新版を指し示すタグ( latest など)を決め打ちで指定すると面倒ごとが少ない!」という場面もそれなりにあると思います。 こういったコンテナですが、たま〜にイジりたくなること、ありません?そのときに非互換な変更があると、動作確認で厄介なことになります。

ケーススタディー: ジョブの開発

以下では、いったん検証環境に「検証環境を更新するためのジョブ」を動かしているコンテナがあると仮定して話を進めます。

【前提】このジョブは、Amazon ECS 上で日常的に稼働していて、CI(GitHub Actions)からキックされています。 ソースコードは標準的なOLTPアプリケーションと同じリポジトリに置いてあります。

【運用】ジョブの内容変更は頻繁ではありませんが、依存ライブラリのアップデートもあるので、 main ブランチが更新されるたびにコンテナイメージがビルドされます。 そのとき、タグは latest を毎度更新する形で push されます。 ジョブは main 以外のブランチでも実行されますが、ビルドにはそれなりに時間もかかるし、レジストリの容量が爆発するのも嫌なので latest を参照してもらっています。

【拡張性】ビルドのスクリプトや ecspresso の設定ファイル*4では、一応 REVISION という環境変数でタグを上書きできるようにしています。 なお、補足として JOB_REPO_URL は 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/job といった形式で、Amazon ECR リポジトリのURLを指します。

# コンテナのビルド・push(他のフラグは省略)
docker buildx build \
  --tag "${JOB_REPO_URL}:${REVISION:latest}" \
  --push \
  .

以下はタスク定義の一部です。

{
    ..., // 略
    "containerDefinitions": [{
        "name": "job",
        "image": "{{ must_env `JOB_REPO_URL` }}:{{ env `REVISION` `latest` }}",
        ... // 略
    }]
}

変更作業の動作確認がしたいだけなのに・・・

さて、この状況で、ジョブに非互換な変更を加えて動作確認をしたいとき、どうしたら良いでしょうか?

(ジョブの変更作業は feature/enhance-job ブランチで行なっているものとします。)

特に何も手を加えなければ、以下の図のように feature/enhance-job で発火されるジョブも latest を参照します。

パワー系ソリューションとしては、 feature/enhance-job ブランチでコンテナのビルド・push を行なって latest を更新することも考えられますが、もし動作確認やレビューに時間が掛かった場合、他のブランチで実行されるジョブが正常に動作しなくなります。

流石にそれでは困るので、「ビルド・push」および「ジョブ発火」の両方について、スクリプトないしワークフローにパラメータをねじ込んで挙動を変更する方針にしましょう。

ここから先は複数ファイルに跨ってコードを確認する必要がありますが、その結果 「あれ、、、実際に REVISION で上書きしようと思うと、、、可能だけど若干しっくりこないぞ・・・?」 という気持ちになるかもしれません。

より具体的には、feature/enhance-job ブランチで CI の YAML ファイルに REVISION 指定を記述して、マージ前に消す方針を取ればワークアラウンドとしては機能します。 もしくは、より根本的にジョブを発火する根本的なコンポーネントまで手を加えることでマニュアル感を減らすことも可能でしょう。しかし、それがパラメータのバケツリレーを増やして認知負荷に影響する可能性は否めません。 これが冒頭で紹介した厄介な問題です。

嗚呼・・・ただ単に、変更作業の動作確認がしたいだけなのに・・・。

手軽に可変タグを運用する方法

先ほどの話はあくまでケーススタディなので、ご自身のプロジェクトにピッタリと当てはまる事はほとんど無いでしょう。

しかし、一般論として、コンテナイメージの運用は掘り下げるとキリがないのも事実です。 可変性以外に、ロールバックや自動削除の問題もあれば、同じリビジョンのイメージでもパラメータによって挙動が異なることさえあります。 本番環境におけるコンテナイメージ運用の深掘りは信頼性の追求に繋がりますが、検証環境においては必ずしもそのメリットが大きいとは限りません。

むしろ筆者は、検証環境のうち特に恒常的かつ柔軟に使うもの(prd,stg,dev構成であればdev)の運用は、無理に本番環境に寄せるべきでないと考えています。 そのような環境では開発者*5が直感的に運用できることが最優先事項です。 それにも拘らず本番環境に寄せるための無理な共通化を施して、逆に本番環境の運用が複雑になってしまったら本末転倒です。

結論: ecresolve でサクッと探索

ということで、検証環境を想定して、可変タグをgitブランチに対応させて手軽に運用する方法を提案します。 実際にはブランチ以外の管理単位を設定しても良いのですが......そもそもブランチ自体が本質的には可変なリビジョン情報なので、素直に対応させるのも悪くないと思います。

コンセプトとしては以下の図のとおりです。ブランチ名の / はタグ名として使えないので s/\//___/g で加工するものとします。また、先程のケーススタディから main ブランチが更新されるたびにコンテナイメージがビルドされる」という状況は変えないものとします。

とはいえ「RunTask APIを実行時に現在のブランチ名に対応したタグを参照 → CannotPullContainerErrorだったらmainに向ける」スタイルだとエラー時のフィードバックが遅すぎます。 そこで、 ecresolve で事前に Amazon ECR にアクセスして存在するタグを突き止めておきます。

CI に仕込んでおくコードとしては、ざっとこんな調子です。こうすると、ECRリポジトリ job から現在のブランチに対応するタグを探し、存在しなければ main を参照させることができます。

serialized_branch=$(git rev-parse --abbrev-ref HEAD | sed 's/\//___/g')
image_tag=$(ecresolve "$serialized_branch" main \
    --repository-name=job --format=tag-only)
export REVISION=$image_tag

なお、--format フラグのデフォルトは json です。その場合はタグ名ではなくECR APIと互換なJSONが出力されます。 詳しくは README をご参照ください。

おまけ

ecspresso をお使いの場合、v2.5で追加予定の External Plugins を用いることで、ecresolve をタスク定義のテンプレートに組み込むこともできます。

その場合の例として、タスク定義を以下のように記述できますが、

{
    ..., // 略
    "containerDefinitions": [{
        "name": "job",
        "image": "{{ must_env `JOB_REPO_URL` }}:{{ ecresolve-job `$SERIALIZED_BRANCH` `main` }}",
        ... // 略
    }]
}

ecsoresso.yml の記述に関して、少々知識が必要になってくるので、好みの分かれるところかと思います*6

plugins:
  - name: external
    config:
      name: ecresolve-job
      command: ["ecresolve", "--format=tag-only", "--repository-name", "job"]
      parser: string # デフォルト(json)の場合は↑の--formatを削った上で {{ (ecresolve_job `$SERIALIZED_BRANCH` `main`).imageId.imageTag }} でもアクセス可能
      num_args: 2
      timeout: 5

それでは、よいコンテナライフをお過ごしください! 👋

カヤックでは、コンテナと程よく付き合いたいエンジニアも募集しています!

hubspot.kayac.com

*1:ユースケースは Amazon ECR に限られていますが、超絶簡単なソフトウェアなので、他のコンテナレジストリをご利用の方は同じようなものを適宜つくってみても良いかもしれません。

*2:なぜ cat ではないかと言うと、引数に指定した複数のタグのうち「最初に見つかったイメージ」の情報が出力されるからです。

*3:語弊を防ぐため、本記事では「本番以外の環境」のことをひっくるめて"検証環境"と呼ぶことにします。

*4:ecspresso使っていないよ、という方は他のツールにおけるタスク定義の扱いに置き換えて想像してください。

*5:機能開発エンジニアもそうですし、QAエンジニアや企画チームの方々も含まれます

*6:commandをbash -cにするなど、より汎用的なPlugin設計も可能ですが、濫用のリスクと照らし合わせてご検討ください。

【Go】kong で CLI のトップレベルに --version フラグを実装する

お久しぶりです。SRE の市川恭佑です。

今回は Go で CLI ツールを作成する際の小ネタを紹介します。

そもそも CLI パーサの選定

Go でコマンドを解析する手法は多岐に渡ります。そもそも標準 flag パッケージだけで実装することも可能ですし、spf13/cobraurfave/cli をフラグパーサに採用することも多いかと思います。

好きなものを使っていただくのが一番ですが、今回の話題で取り上げる alecthomas/kong は、サブコマンドのサポートのみならず簡単な制約チェックも提供されているのが魅力的です。個人的には、不正なフラグが与えられたときに適切なエラーメッセージを返すことの面倒臭さを考えると、ここら辺もパーサ側にお願いしたいなって気持ちになることが多いです。

ちなみに kong は、alecthomas/kingpin と同じ作者によるツールで、みなさんお馴染みの(?) kayac/ecspressofujiwara/lambroll でも使われています*1

kong による制約チェック

kong の README から Supported tags を覗いてみると、いくつかの制約を指定できることが分かります*2

今回はわかりやすさのため、 required に絞って紹介します。読んで字の如く、特定のフラグが必須であることを示すために使います。

type CLI struct {
    // `required:""` でフラグを必須にできる
    Mandatory string `required:"" short:"m" help:"Mandatory flag."`
    Optional  bool   `short:"o" help:"Optional flag."`

    // `arg:""` の場合はデフォルトで必須
    Arg string `arg:"" help:"An argument."`
}

なお、サブコマンドがあるネスト構造において、存否の制約は親子関係を考慮して適用されます。

早い話、以下のようなユースケースにおいて --bar が必須となるのは、サブコマンド foo を実行したときだけです。 サブコマンド versionでも --bar が要求される、ということはありません。

type CLI struct {
    Foo struct {
        Bar bool `required:"" short:"b" help:"Do foo with bar."`
    } `cmd:"" help:"Do foo."`

    Version struct{} `cmd:"" help:"show version"`
}

困ったポイント

先ほどの例で「そりゃ version コマンドで他のフラグが要求される訳がないだろ」と思った方もいるかもしれません。 その感覚はごもっともなのですが、サブコマンドを持たないシンプルなユースケースの場合、どうなるでしょう?

筆者はここでハマりました。具体的には、以下のように超絶シンプルなコマンド piyo を実装しました。

// cmd/piyo/main.go (一部抜粋)

type CLI struct {
    Foo string `arg:"" help:"Foo string."`
    Bar string `required:"" short:"b" help:"Bar string."`
    // (任意フラグ省略)
    Version bool `short:"v" help:"Show version and exit."`
}

func main() {
    var cli CLI
    kong.Parse(&cli)

    if cli.Version {
        // Version はビルド時に -ldflags で埋め込む
        fmt.Printf("piyo %s\n", Version)
        return
    }

    // ここから通常の処理
}

Foo は arg なので必須です。Bar も required タグを付けています。 つまり、通常は $ piyo xxx --bar fuga といった形で使って欲しいということになります。

また、main 関数からも読み取れるとおり、 $ piyo --version とした場合はバージョン情報を出力して素直に終了することを期待していました。

しかし、実際に $ piyo --version を実行したところ、以下のようなエラーが出力されました。

piyo: error: missing flags: --bar

どういうことかというと、 kong.Parse(&cli) を抜ける前に制約チェックが実行され、 --bar 制約の巻き添えで発生したエラーを基にkongがプログラムを終了 *3 したのです。

ちょっと深掘り: --help という例外

(結論だけ知りたい方は 実装方法のところ までスキップしてください)

この問題に直面したとき、最初に浮かんだ疑問は「でも、なぜ --help は成功するのだろう」というものでした。 実際、 $ piyo --help を実行したところ、以下のようにヘルプが表示され、コマンドは正常終了しました。

$ piyo --help
Usage: piyo --repository-name=STRING <foo> [flags]

Arguments:
  <foo>    Foo string.

Flags:
  -h, --help          Show context-sensitive help.
  -b, --bar=STRING    Bar string.
      // (略)
  -v, --version       Show version and exit.

何故でしょう?

その答えにつながるカギは、「そもそも我々は --help オプションなど定義していない」ということです。

この時点で、 kong.Parse の内部処理で、ビルトインの -h, --help が特別扱いされていて、制約チェックの前に(ヘルプ情報だけ表示して)プログラムを終了させるという振る舞いになっているのではないか」 という仮説を立てました。

実際に kong のソースコードを読んでみると、該当する処理を担っている箇所を見つけました。 helpValue という型に BeforeReset() メソッドが実装されています。

さらに深掘り: Hooks と Bindings

--help の実装に関して、実は README にもサラッと記載があったのですが、 BeforeReset() などの 'Hooks' と呼ばれているメソッドたちは、通常の Go のメソッド呼び出しとは異なる方法で発火されます。

大雑把に解説すると、Hooks 呼び出しは以下の流れで実装されています。

  1. kong.Parse() 等を経由してユーザーが *Kong の Parse() メソッドを実行
  2. 各 Hook の名前に対して *KongapplyHook(ctx, name) メソッドを実行
  3. 2つの内部関数( getMethod() および callMethod() )を用いて当該 Hook を特定・実行

これを踏まえて 第2ステップ および 第3ステップ のコードを眺めていただくと、Bindings という概念が見えてくると思います*4。Bindings は、型と値が対になっている map です*5。 これを用いて Hooks がどのように呼び出されているかを簡単な図にしてみました。(Foo, Bar, Piyoは先ほど例示したコードとは無関係です💦🙏)

補足すると、 *N をレシーバとしている BeforeReset() メソッドが呼び出せないのは、Bindings に含まれない Piyo という型を引数に持っているからです。その他のレシーバに対するメソッドは、いずれも引数が Bindings の部分集合となっているため呼び出せます。なお、引数の順番は関係ないようです。

トップレベル --version フラグの実装

さて、それでは本題の --version フラグを実装していきましょう。

深掘りで紹介したアクロバティックなメタプログラミングを目の前にすると不安の波が押し寄せてくる Gopher の方々も少なくないかと思われますが......安心してください、簡単ですよ。

というのも、kong のソースコードにある util.go という便利そうなファイルで、今回のユースケースに当てはまる VersionFlag という型が用意されており......なんと丁寧に BeforeReset() メソッドまで実装されているのです。

先ほどの piyo コマンドについても、以下のようにすれば --version フラグを実装できます。

type CLI struct {
    Foo string `arg:"" help:"Foo string."`
    Bar string `required:"" short:"b" help:"Bar string."`
    // (任意フラグ省略)

    // 型が bool ではなく kong.VersionFlag になっている!
    Version kong.VersionFlag `short:"v" help:"Show version and exit."`
}

func main() {
    var cli CLI
    kong.Parse(&cli, kong.Vars{"version": fmt.Sprintf("piyo %s", Version)})

    // if cli.Version のような処理は要らない!

    // ここから通常の処理
}

結論だけ知りたい方は、ここまでの内容で十分です。お疲れ様でした。

以下は、深掘りを読んでいただいた方向けの内容です。

kong.VersionFlag の実装および違和感との向き合い方

まだ実装を見ていない方は一度目を通してもらうとして、 VersionFlagBeforeReset() は第二引数に kong.Vars を受け取っていることが分かります。 つまり、これも Bindings に含まれているということです*6

この kong.Vars を介してバージョン情報が出力されていますが、 kong.Vars の基底型は map[string]string で、内部実装では map のキーが "version" とハードコードされています。 それゆえ kong.Parse() のオプション*7として kong.Vars を渡す際に、利用者側もハードコードで "version" というキーを指定する必要があります。

ここで、 main() またはその付近に記述する内容が、依存パッケージの内部実装と密結合になることに違和感を感じる方もいるかもしれません。

その場合は、以下のように VersionFlag を再実装してあげると、 kong.Parse() にオプションを渡す必要もなくなり、少しすっきりした気持ちになるかもしれません。

var Version = "(dev)" // ビルド時に -ldflags で埋め込む

type VersionFlag bool

// BeforeReset は kong における Hook の一つで、制約チェックよりも前に実行されます。
func (v VersionFlag) BeforeReset(app *kong.Kong) error {
    fmt.Fprintf(app.Stdout, "piyo %s\n", Version)
    app.Exit(0)
    return nil
}

なお、 --version 結果の冒頭にコマンド名を出すか否かも好みによる部分だと思いますが、ここの部分を動的に出力したい場合は app.Model.Name を利用することも可能です*8

まとめ

CLI のパースは、コマンド実装者にとってはできるだけ手を抜きたいところですが、利用者の体験に直結する部分であるにも拘らず、自動テストが行き届かない傾向にあるのも事実です。

kong に限らず、ある程度柔軟性の高い CLI パーサを使うのであれば、少しだけ時間をとって内部処理を追ってみることをお勧めします。

CLI パーサへの理解は、日々の業務においては些細な要素に過ぎませんが、緊急でスクリプトを書かないといけない機会においては意外と役立ったりするものです。

カヤックでは、息を吐くようにCLIツールを発明しちゃうエンジニアも募集しています!

hubspot.kayac.com

*1:kong の基本的な使い方については README の Introduction をご参照ください。

*2:xor などについては テスト を読むと理解しやすいと思います。

*3:kong.Parse の代わりに kong.New を使ったりオプションを渡すことで os.Exit を回避する方法はありますが今回の問題の解決には繋がらないので割愛します。

*4:もし reflect のコードを読むのに慣れていない場合は、callFunction内の In や Out といった語彙が、関数の入出力を指していることがヒントになるかもしれません。

*5:厳密には、値というよりも値の getter の方が正確です。コードをご参照ください。

*6:Bindings に kong.Vars を追加している箇所はこちらです。

*7:Uberスタイルで、kong.Vars に Apply メソッドが実装されています。

*8:開発中に go run すると値が "main" になったりしますが、ビルド後の実行であればコマンド名になるようです。