awslim - Goで実装された高速なAWS CLIの代替品を作った

最初に3行でまとめ

  • AWS CLIは便利です。しかし起動が遅いので、Goで実装された高速な(ただし機能は少ない)代替品を作りました。awslim といいます
  • リリースバイナリは無駄に大きいので、必要な機能だけを組み込んだビルドを簡単にできるようにしてあります。ビルドして使うのがお勧めです
  • どうぞご利用下さい

github.com

以下はこれに至るまでの経緯とか、実装や使い方の話とかです。長いです。

作成の経緯

AWSの各種サービスにアクセスするための AWS CLI は、スクリプトコマンドラインから処理を自動化するために大変便利なツールです。AWSでサーバーサイドの開発、運用している人であれば、ほぼ全員がお世話になっているんじゃないかと思います。

しかし、AWS CLI (コマンド名aws) には「起動が重い」という問題があるなとずっと思っていました。具体的には、aws --version コマンドを起動してバージョ��名を表示するだけでも、手元の環境 (例: AMD Ryzen 5 3400G)で750ms程度の時間(CPU時間)が必要です。

$ /usr/bin/time aws --version

aws-cli/2.15.51 Python/3.11.8 Linux/5.15.0-106-generic exe/x86_64.ubuntu.22
0.67user 0.08system 0:00.75elapsed 99%CPU (0avgtext+0avgdata 59848maxresident)k

そして、何をするにもこの起動のオーバーヘッドが乗ってきます。AWS CLI は基本的に AWS のひとつのサービスのひとつの API を呼び出すためのツールなので、shell script などと組み合わせて複数回の API 呼び出しを自動化すると、その回数だけ起動オーバーヘッドに CPU を使います。

手作業でたまに実行するだけならまだしも、CPUが貧弱な環境、たとえば 0.25 vCPU の Fargate であるとか、メモリが少ない Lambda 上での自動化処理に aws コマンドを利用すると、用途によっては実用に耐えません (0.25 vCPUなら起動だけで実時間で3秒必要です)。

このような場合には、自分は Go の AWS SDK を利用して、コードを書くことで解決していました。確かにそれで解決はできるのですが、本当に一回 API を呼び出せば終わるような処理でもいちいち Go のコードを書いてビルドしてバイナリを置いて…というのも面倒です。

AWS CLIをGoで実装してシングルバイナリにしてほしい」

Go と AWS を使ったことがある人であれば、全員が100回ぐらい考えたことがあるんじゃないかと思います。

ということで、ある日思いついて作ってみたらできました。という話を先日あった kamakura.go#6 でLTしてきました。

speakerdeck.com

ポイントとしてはこんな感じです。

  • aws-sdk-go-v2 の全サービスの Client のメソッド (≒ AWSAPI) は、全部同じ形式で呼び出すことができる
  • Go の refelct を使って、Client にあるメソッド一覧を元に、全てのメソッドを呼び出すコードを生成している
  • 全部のサービスを使えるようにすると馬鹿でかいので困る
    • ので、必要なものだけビルドできるようにしたよ

特徴

  • AWS サービスクライアントの任意のメソッド (API) を呼び出します
  • 入力には JSON または Jsonnet を使用します
  • 結果をJSON形式で出力します
  • メソッドの入出力構造体にファイルをバインドできます
  • JMESPath で出力をクエリできます
  • AWS CLI 設定ファイルを使用します(~/.aws/config)

制限事項

  • AWS CLI との互換性は 100% ではありません
  • AWS CLIプラグインはサポートされていません (session-manager-plugin など)

速度比較

高速、の根拠を載せておきます。

sts get-caller-identity を 0.25 vCPU Fargateの(AMD64)で実行して、/usr/bin/time -v で計測した結果です。 (aws-cli/2.15.51 Python/3.11.8, awslim v0.1.0)

command CPU time(user, sys) Elapsed time(s) Max memory(MB) Size(MB)
aws 0.67 + 0.10 = 0.77 3.11 64.2 225
awslim(all) 0.08 + 0.03 = 0.11 0.43 101.5 476
awslim(40) 0.02 + 0.01 = 0.03 0.05 30.2 95
  • awslim(built for all AWS services): 7倍高速
  • awslim(built for 40 AWS services): 25倍以上高速

AWSの全サービス用のコードをビルドした全部いりのリリースバイナリはそこそこ巨大です (約 500MB、AWS CLIはZIP展開後 225MB なので2倍強)。そのために起動も速くはなく、100ms程度のCPUを消費しますし、メモリフットプリントは AWS CLI の1.5倍程度あります。しかしそれでも AWS CLI の7倍は速いです。

上の表で awslim(40) となっているのは、自分が使ったことがある AWS のサービスを適当に40個選んで (当然、メジャーなものが大半です)、そのサービスだけを使えるバイナリをビルドした場合です。これであれば 30ms 程度で実行でき、メモリ消費も少なく、圧倒的に高速です。

CPUリソースが乏しい環境で動かす場合、リリースバイナリを使うのはなく、必要なサービスだけ使えるようにした専用バイナリをビルドすることをお勧めします。後述しますが、ビルドは簡単にできるようになっています。

つかいかた

詳しくは README を参照して下さい。

ここでは、AWS CLI と同じ処理を awslim で書くとどうなるかを並べてみます。

引数が必要ないパターンでは、ほとんど同一です。

$ aws sts get-caller-identity
{
    "UserId": "AIDAJ3OGXXXXXXXXXXXX",
    "Account": "012345678901",
    "Arn": "arn:aws:iam::012345678901:user/fujiwara"
}

$ awslim sts get-caller-identity
{
  "Account": "012345678901",
  "Arn": "arn:aws:iam::012345678901:user/fujiwara",
  "UserId": "AIDAJ3OGXXXXXXXXXXXX",
  "ResultMetadata": {}
}

API に入力が必要な場合、AWS CLI ではコマンドライン引数を(複数)指定しますが、awslim は JSON / Jsonnet を文字列またはファイル名で指定します。手で書く場合は JSON フィールド名のquoteの必要がない Jsonnet を使うほうが書きやすいと思いますし、なんらかの機械的な出力を使うのであれば JSON のほうが生成しやすいでしょう。

$ aws ecs describe-clusters --cluster default

$ awslim ecs describe-clusters '{"Cluster":"default"}' # json
$ awslim ecs describe-clusters '{Cluster:"default"}' # jsonnet

$ aws ecs list-tasks --cluster default --family web
{
    "taskArns": [
        "arn:aws:ecs:ap-northeast-1:012345678901:task/default/f678fe41be334c589513fb0c9490de49"
    ]
}

$ awslim ecs list-tasks '{Cluster:"default",Family:"web"}'  
{
  "NextToken": null,
  "TaskArns": [
      "arn:aws:ecs:ap-northeast-1:012345678901:task/default/f678fe41be334c589513fb0c9490de49"
  ],
  "ResultMetadata": {}
}

ここで注意が必要なのは、入出力のJSONフィールド名の大文字小文字はAWS CLIと互換ではない(場合がある)ことです。awslim は Go SDK の構造体をそのまま単純に JSON に変換しているため、フィールド名の先頭は必ず大文字になります。AWS CLI は、サービスによって異なります。(例: stsでは先頭が大文字、ecsでは小文字など)

また、サービス名はほとんど AWS CLI と同一ですが、一部異なるものがあります。たとえば aws logs に対応するのは awslim cloudwatchlogs です。これは AWS CLI のサブコマンド名と、AWS SDK Go v2 のパッケージ名が異なるものがあるためです。今後、同一視する alias を用意するかも知れません。

入力への値の埋め込み

awslim の入力はひとつの JSON / Jsonnet 文字列(またはファイル)ですが、これはコマンドライン引数を全てのAPIに対する入力に応じて定義する手間を省いているためです。JSONであれば、単純にGo SDK の Input 構造体にUnmarshal するだけで済みます。

とはいえ常に文字列をシェルの変数展開などで組み立てるのは面倒なので、Jsonnet の --ext-str, --ext-code という仕組みで外部からの値を埋め込むことができます。--ext-str, --ext-code は name=value;で連結して複数渡すこともできます。

$ awslim ecs list-tasks '{Cluster: std.extVar("cluster"), MaxResults: std.extVar("max")}' \
    --ext-str cluster=default \
    --ext-code max=10

入力はファイルに書いておいて、ファイル名を指定することもできます。

$ cat input.jsonnet
{
  Cluster: std.extVar("cluster"),
  MaxResults: std.extVar("max"),
}

$ awslim ecs list-tasks input.jsonnet
    --ext-str cluster=default \
    --ext-code max=10

入力の JSON をどう組み立てたらいいか分からない、という場合は入力の代わりに help を指定すると、AWS SDK Go v2 のドキュメントへの URL が表示されます。

$ awslim ecs list-tasks help

See https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ecs#Client.ListTasks

URL を開いて、この場合 ListTasks であれば ListTasksInput のリンクを辿って参照すれば、それが SDK に渡される JSON (に対応する SDK の構造体) です。

--input-stream (-i) / --output-stream (-o)

API の中には、入力にデータをストリームで渡せる / 出力をストリームで受け取れるものがあります。典型的には、S3 へのオブジェクト転送を行うための s3.PutObject / GetObject ですね。awslim ではこのストリームに対して標準入出力とファイルをバインドする機能があります。

$ awslim s3 put-object '{Bucket:"my-bucket", Key:"my.jpg", ContentType:"image/jpeg"}' \
    --input-stream my.jpg

$ awslim s3 get-object '{Bucket:"my-bucket", Key:"my.jpg"}' \
    --output-stream my.jpg

つまりこれで、aws s3 cp 相当のことができます。

現在のところ全ての Input / Output 構造体には、高々ひとつの io.Reader(Input) / io.ReadCloser(Output)しか存在しないため、バインドするフィールド名を特に指定する必要はありません。コード生成時に構造体のフィールドの型を見て自動で判別しています。

--follow-next (-f)

AWS CLI では、API的にページングが必要なものでも自動的に内部で辿って出力してくれたりします (aws s3 ls など)。awslim は SDK の呼び出しを単純に wrap するという実装方針なので、暗黙的に next token を辿ることはしません。

ただし、明示的に {Outputのフィールド名}={Inputのフィールド名} を --follow-next に指定すれば、Outputのフィールドが空でない場合にInputのフィールドに埋めて再度呼び出すようになっています。

例えば s3.ListObjectV2 では次のページがある場合、Output の NextContinuationToken というフィールドに値が入ってきて、Input の ContinuationToken に設定することで次のページを取得できます。この場合、以下のように指定します。

$ awslim s3 list-objects-v2 '{Bucket: "my-bucket"}' \
  --follow-next NextContinuationToken=ContinuationToken

入出力で同じフィールド名を使うAPIの場合は、名前の指定のみでOKです。

$ awslim ecs list-tasks '{Cluster:"default"}' \
  --follow-next NextToken

--raw-output (-r), --query (-q)

AWS CLIaws --output text では出力が JSON ではなくテキスト形式になります。正直、これは構造があるレスポンスの場合あまり役に立たず(個人の感想です)、唯一自分が使う場面が --query と組み合わせて文字列を結果として得る (そしてシェル変数に入れる) 場合です。

ということで、jq -r と同様に「結果が文字列の場合だけ JSON 形式ではなく生のテキストを出力する」--row-output (-r) を用意しました。

要するにこれがやりたいわけです。

$ ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

$ ACCOUNT_ID=$(awslim sts get-caller-identity --query Account -r)

--query は AWS CLI 同様、JMESPath で結果をクエリするやつです。

最適化ビルドのすすめ

速度比較の項で述べたとおり、全部入りのバイナリはサイズが大きく、起動が遅いものです。現時点で380以上ある、AWS SDK に存在する全サービスの、全 API (1万以上) を呼び出すコードを組み込んでいるためです。

あなたが AWS 上で運用しているプロダクトで、使っているサービスはいくつあるでしょうか。たいていは多くて数十、100以上のサービスを使っていることはほぼないと思います。しかも、その全てのサービスに API 呼び出しを行う CLI が必要なことはまずないでしょう。

サーバー上で awslim を実行する場合は、必要な特定のサービスだけ実行できるバイナリをビルドすることを強くお勧めします。方法は README にありますが、コンテナイメージを作る場合にはビルド用イメージを使って、以下のようにマルチステージビルドをするのが便利です。

環境変数 AWSLIM_GEN に、ビルドするサービス名を , 区切りで設定して RUN ./build-in-docker.sh するだけです。簡単ですね。

FROM ghcr.io/fujiwara/awslim:builder AS builder
ENV AWSLIM_GEN=ecs,firehose,s3
ENV GIT_REF=v0.1.0
RUN ./build-in-docker.sh

FROM debian:bookworm-slim
COPY --from=builder /app/awslim /usr/local/bin/awslim

手元で Linux 用バイナリを生成する場合にも、docker が使えます。ビルド用イメージを docker run するとバイナリがコンテナの中に生成されるので、docker cp で取りだしてください。

$ docker run -it -e AWSLIM_GEN=ecs,firehose,s3 ghcr.io/fujiwara/awslim:builder
$ docker cp $(docker ps -lq):/app/awslim .

また、リリースバイナリはビルドした時点の AWS SDK Go v2 に依存した状態ですが、自分でビルドする場合、各サービスのコードはその時点の最新版が使われます。つまり、あるサービスの新機能がリリースされて SDK も更新された場合、その機能をすぐに使うためにはリリースバイナリを待つのではなく、自前ビルドを使うほうが対応が早いことが多いでしょう。

まとめ

  • AWS CLIは便利です。しかし起動が遅いので、Goで実装された高速な(ただし機能は少ない)代替品を作りました。awslim といいます
  • リリースバイナリは無駄に大きいので、必要な機能だけを組み込んだビルドを簡単にできるようにしてあります。ビルドして使うのがお勧めです
  • どうぞご利用下さい