AWS のタグを jq, JMESPath, Jsonnet でいい感じに取り扱う話

estie SRE の sugitak です。今日はね、 JSON を扱っていこうと思います。

何の記事?

手元で bashzsh を使っていると、環境変数ってこういう形ですよね。ペアで扱います。

Environment=production
Product=estie
...
DD_ENV=production

ところが AWS はこういうふうに保持しています。これは人間の目に優しくないので、できれば手元と同じく Key=Value っぽい形で取り扱えたら嬉しいな。そのように思っているわけです。

[
  {
    "Key": "Environment",
    "Value": "production"
  },
  {
    "Key": "Product",
    "Value": "estie"
  },
  ...
  {
    "Key": "DD_ENV",
    "Value": "production"
  }
]

これを jq などのツールを使って変換し、人間と AWS とのインターフェイスをいい感じにしていこう、というのが今回の記事です。

jq

jq はもう説明不要ですね。 CLI で JSON を扱う処理系の決定版です。

https://jqlang.github.io/jq/

最初の例では、その jq を使用して、 EC2 インスタンス一覧からタグだけを取り出してみましょう。

EC2 タグのデータ構造

EC2 タグは、 aws ec2 describe-instances の情報から取得できます。このとき API から返却される値から tag を抽出すると、以下のような場所にあることがわかります:

{
    "Reservations": [
        "Instances": [
            {
                ...
                "Tags": [
                    {
                        "Key": "Name",
                        "Value": "Tokyo"
                    }
                ],
                ...
            },
            {
                ...
                "Tags": [
                    {
                        "Key": "Name",
                        "Value": "Osaka"
                    },
                    {
                        "Key": "DD_ENV",
                        "Value": "production"
                    },
                    ...
                ],
                ...
            }
        ]
    ]
}

ちょっとわかりにくいので jq / JMESPath のパス表現を使うと、

.Reservations[].Instances[].Tags    // jq の場合
Reservations[].Instances[].Tags     // JMESPath の場合

はい。わかりやすくなりました。

jq でタグ一覧を取り出してみる

先ほどのパス表現を使って取り出してみると、こうなります:

$ aws ec2 describe-instances --output json | jq '.Reservations[].Instances[].Tags'
[
  {
    "Key": "Name",
    "Value": "Tokyo"
  }
]
[
  {
    "Key": "Name",
    "Value": "Osaka"
  },
  {
    "Key": DD_ENV",
    "Value": "production"
  },
  ...
]
...

はい。取り出せましたね。

取り出すだけでなく、もう少し実用的なことをしてみましょう。任意のタグ、たとえば Name に相当するタグの Value を抽出するにはどうすればいいでしょうか?

EC2 のインスタンス名一覧を取得する

EC2 のインスタンス名、実体は Name タグです。インスタンス名一覧を表示しようと思ったら、 .Key == "Name" となる要素だけに絞り込んだうえで、その .Value を表示させれば OK です。要素の絞り込みは select を使えばできます。

$ aws ec2 describe-instances --output json | jq -r '.Reservations[].Instances[] | (.Tags[]? | select(.Key == "Name")).Value'
Tokyo
Osaka
...

Reservations[].Instances[] の中から Tags を持っているものを取り出し、そのうち .Key == "Name" となるものを絞り込んだうえでその Valueを並べれば名前の一覧のできあがり、という内容。 Straightforward ですね。

インスタンスIDとのペアを出力したければ、さらに少し組み替えてこんな感じ。

$ aws ec2 describe-instances --output json | jq '.Reservations[].Instances[] | [ .InstanceId, (.Tags[]? | select(.Key == "Name")).Value ]'
[
  "i-0xxxxxxxxxxxxxxxx",
  "Tokyo"
]
[
  "i-0yyyyyyyyyyyyyyyyy",
  "Osaka"
]
...

やりましたね!

本題: Key:Value 形式に組み替える

さて、いよいよ本題です。タグをシンプルな辞書形式へと組み替えていきましょう。

おおむねこんな構造をしているものを:

"Tags": [
    {
        "Key": "Name",
        "Value": "Tokyo"
    }
],
"Tags": [
    {
        "Key": "Name",
        "Value": "Osaka"
    },
    {
        "Key": "DD_ENV",
        "Value": "production"
    },
],

こういうふうにしたい、ということです。

[
  {
    "Name": "Tokyo"
  },
  {
    "Name": "Osaka",
    "DD_ENV": "production",
  },
  ...
]

はい。こんな感じ↓でできます:

$ aws ec2 describe-instances --output json | jq '.Reservations[].Instances[] | [(.Tags[]? | {key:.Key, value:.Value})] | from_entries'
{
  "Name": "Tokyo"
}
{
  "Name": "Osaka",
  "DD_ENV": "production"
}
...

やったね!

解説

jq には、そのものずばり from_entries という関数があります。この関数に [{key: xxx, value: yyy}] という Array を渡すと、 {xxx: yyy} という Object として返してくれます。

ここで、小さな問題がひとつあります。 AWS から返ってくる Object は Key Value と大文字になっているのです。 from_entries が受け付けるのは key value 、小文字の場合だけなので、このままでは from_entries に渡したところでうまく変換できません。そこで、{key:.Key, value:.Value} という呼び出しをし、 from_entries に渡せる形へと変換しているのですね。

はい!これで目的達成です!

さらに便利に使う

先ほどはタグ一覧を見ましたが、それだけだとどのインスタンスかわかりにくいです。インスタンスIDとIPアドレスも加えてみましょう。

$ aws ec2 describe-instances --output json | jq '.Reservations[].Instances[] | {"InstanceId": .InstanceId, "IPAddr": .PrivateIpAddress, "Tags": ([(.Tags[]? | {key:.Key, value:.Value})] | from_entries)}'
{
  "InstanceId": "i-0xxxxxxxxxxxxxxxx",
  "IPAddr": "10.0.0.51",
  "Tags": {
    "Name": "Tokyo"
  }
}
{
  "InstanceId": "i-0yyyyyyyyyyyyyyyy",
  "IPAddr": "10.0.0.17",
  "Tags": {
    "Name": "Osaka",
    "DD_ENV": "production",
  }
}
...

最高!取り扱いやすくなりました。

JMESPath

ここまで jq での取り扱いを説明しました。 aws コマンドに組み込まれた JSON 変換ツールである JMESPath ではどうなるでしょうか?

JMESPath も jq と同じく JSON 処理系です。 jq と比べると若干柔軟性が低い面がある一方で、 aws CLI に組み込まれているという利点がなかなか大きく、クエリの仕方を覚えると aws コマンドをより便利に使えるようになるため、知っておく価値のある言語となっています。

https://jmespath.org/

なお JMESPath は CLI でスタンドアロンで利用することもできます。そのときのコマンドは jp です。

https://github.com/jmespath/jp

JMESPath で取り出してみる

先ほどの jq の例と同じように、 JMESPath を用いてタグ一覧を取得してみます。

$ aws ec2 describe-instances --output json --query 'Reservations[].Instances[].Tags'
[
    [
        {
            "Key": "Name",
            "Value": "Tokyo"
        }
    ],
    [
        {
            "Key": "Name",
            "Value": "Osaka"
        },
        {
            "Key": DD_ENV",
            "Value": "production"
        }
        ...
    ]
    ...
]

jq でも同じことはできていましたが、 JMESPath の場合はパイプが不要で aws コマンドだけで完結しているところがポイントです。

Name タグで絞り込む

Reservations[].Instances[].Tags まで追いかけたあと、その projection 結果を ?Key=='Name' で絞り込み、その結果の Key, Value ペアを配列として作れば、以下のような結果が得られます。

$ aws ec2 describe-instances --output json --query "Reservations[].Instances[].Tags[?Key=='Name'][Key,Value][]"
[
    [
        "Name",
        "Tokyo"
    ],
    [
        "Name",
        "Osaka"
    ],
    ...
]

Name タグだけの配列を作る

絞り込みまでは同様に実施して、最後に Value だけを表示すれば、 Name タグの一覧が得られます。

$ aws ec2 describe-instances --output json --query "Reservations[].Instances[].Tags[?Key=='Name'].Value[]"
[
    "Tokyo",
    "Osaka",
    ...
]

自分が JMESPath でできたのはこのあたりまででした。 JMESPath には jq の from_entries 相当のビルトイン関数がないようなので、そうなると jq ほど自在に JSON を組み替えることは難しそうです。

とはいえやはり JMESPath は aws CLI に組み込まれている利点が圧倒的に強く、ちょっとしたことなら JMESPath でササっと絞り込みでき非常に便利です。 aws コマンドさえあれば動くということで、社内用マニュアルとかシェルスクリプトとかに気軽に組み込めますからね。

なお、上では --output json で出力していますが、これを --output text 形式で出力するようにすると、 Name タグの内容が水平タブ 0x09 区切りで出力されるようになるので、 CLI 的にとっても便利です。シェル芸たのしい!たのしい! 🤤

$ aws ec2 describe-instances --output text --query "Reservations[].Instances[].Tags[?Key=='Name'].Value[]"
Tokyo   Osaka   ...

Jsonnet

話変わって Jsonnet です。 Jsonnet は、 JSON を生成することを主な目的とした言語です。

https://jsonnet.org/

estie ではデプロイに ecspresso を使用しています。 ecspresso では ECS の Task Definition の JSON ファイルをそのまま設定ファイルとして使用するのですが、実はこれを Jsonnet として扱うこともできます。 Jsonnet は他ファイルからの読み込みができるので、環境変数をはじめとした共通の設定を一括管理できるようになります。 Jsonnet はとても便利なんですね。

https://github.com/kayac/ecspresso

さて、そんな Task Definition の環境変数ですが、普通にやれば {Key: "Key", Value: "Value"} と冗長な書き方をすることになります。できるなら Key: Value 形式でもって短く書きたいですよね。今回 Jsonnet の例として、そのような Task Definition の書き方を紹介します。

まずは、元となる Task Definition の書き方から見てみましょう。

変更前

普通に書くと、下のようになります。環境変数を毎回4行ずつ書くので縦長になりコードの見通しが悪く、「何を設定したか」がひとめでわかりません。

{
    environment: [
        {
            name: "Environment",
            value: "production"
        },
        {
            name: "DD_ENV",
            value: "production"
        },
        {
            name: "NO_COLOR",
            value: "true"
        },
        ...
    ],
    image: image,
    ...
}

変更後

環境変数を envs という辞書として外出しすると、これをうまいところ並べられるようになります。

local envs = {
    Environment: production,
    DD_ENV: production,
    NO_COLOR: true,
    ...
}

{
    environment: std.map(function(k){
        name: k,
        value: envs[k]
    }, std.objectFields(envs)),
    image: image,
}

Jsonnet には jq のように from_entries 相当の関数がありません。なので、多少無理やりですが、変数の Key 一覧を std.objectFields 関数で呼び出したのちその Key を再度同じ変数に Key として突っ込んで Value を得る、ということをすれば、なんとか解決できます。

このくらいの行数だと「変更後」の方が無駄に凝っていてわかりにくい感はありますが、環境変数が15個を超えてくると「ひとめで見える」利点が大きくなってきます。どのみち下の environment 以下は普段は見る必要すらなくて、環境変数の追加削除時は local envs だけ気にしていればいいですからね。この記法を使う利点は少なからずあるかと思います。

おわりに

estie では JSON や Jsonnet に限らず、 YAML, HCL, CUE, Pkl など設定言語に対して熱意ある仲間を募集しています。一緒に産業の真価をさらに拓いていきましょう!

hrmos.co

© 2019- estie, inc.