AWS CLI で取得した情報をシェルスクリプトで使うときの Tips

はじめに

AWS 上のリソースの操作を自動化したいとき、ちょっとしたことなら AWS CLIシェルスクリプトで書くのが一番お手軽だと思います。ただ、きちんとソースコード管理されるようなスクリプトであれば、例えば EC2 の Instance ID のような自動採番値はスクリプトに仕込みたくないので、list や describe 系のコマンドで情報を取得したうえでゴニョゴニョしたくなります。

これらの参照系コマンドは実にたくさんの情報を返してくるので、欲しい情報だけ抜き出さないといけませんが、そのあたりのやり方を書いておきます。

基本: --query オプション

AWS CLI の出力から抽出・加工する方法として、 --query オプション を使う方法と、JSON 出力して jq で加工する方法をよく見かけますが、jq はデフォルトインストールされていない環境も多いので、個人的には --query を好んで用います。

例えば、 Name タグが hoge-instanceインスタンスIDを取りたい場合を考えます。以下のコマンドを叩くと、インスタンスの細かい情報が取得できます。

$ aws ec2 describe-instances \
    --filter "Name=tag:Name,Values=hoge-instance" --output json
{
    "Reservations": [
        {
            "Groups": [],
            "Instances": [
                {
                    "AmiLaunchIndex": 14,
                    "ImageId": "ami-gej3kjfo9",
                    "InstanceId": "i-0f4f44a816fexample",
                    "InstanceType": "c4.xlarge",

...

}

ここから InstanceId だけ取り出したいので、出力の階層構造を踏まえ、以下のように --query を設定します。

$ aws ec2 describe-instances \
    --filter 'Name=tag:Name,Values=hoge-instance' \
    --query 'Reservations[0].Instances[0].InstanceId' --output text

i-0f4f44a816fexample

--output text としていますが、 json のままだと、出力に引用符が残ります。 --output の設定値を変えても、クエリの抽出結果には影響しません。

シェルスクリプト内では、こうして必要な情報を抽出できるコマンドを書き、 $() で変数に代入して扱う形が多くなると思います。

一度のコマンドで複数の値を取りたい場合

一度のコマンド実行結果から複数の値を取りたい場合は、 set コマンドが便利です。例えば、上記の結果から InstanceIdImageId を取りたいというような場合は以下のように書きます。

set $(aws ec2 describe-instances \
    --filter 'Name=tag:Name,Values=hoge-instance' \
    --query 'Reservations[0].Instances[0].[InstanceId,ImageId]' \
    --output text)

INSTANCE_ID=${1}
IMAGE_ID=${2}

query で [InstanceId, ImageId] のように配列に括るようにして text 形式で出力すると、各値がスペース区切りで出力されます。これを set に与えることで位置パラメータ $1, $2, ... に代入されて、個別に取り出すことができるようになります。

query の JMESPath ?演算子 を組み合わせると、順不同で帰ってくるリストから、特定の条件にマッチするものを複数一度に取得することができます。以下の例は、 ALB に複数設定されたリスナのリストから、http と https の各リスナの ARN を抽出する例です。

set $(aws elbv2 describe-listeners --load-balancer-arn $load_balancer_arn \
      --query '[Listeners[?Port==`80`].ListenerArn,Listeners[?Port==`443`].ListenerArn]' \
      --output text)

local listener_arn_http=${1}
local listener_arn_https=${2}

カンマ区切りリストとして取得する

CloudFormation などは、複数値をとるパラメータの形式として、カンマ区切りリストが使えます。このようなカンマ区切りリストを手軽に作る方法です。

例えば、ある ECS Service のタスクが展開される Subnet のリストを取りたいときは以下のようなコマンドになります。

$ aws ecs describe-services --cluster the-cluster --services the-service \
       --query 'services[0].networkConfiguration.awsvpcConfiguration.subnets' \
       --output text

subnet-xxxxxxxx subnet-yyyyyyyy

これをカンマ区切りリストにするには printf コマンドが使えます。

$ printf '%s,' \
   $(aws ecs describe-services --cluster the-cluster --services the-service \
       --query 'services[0].networkConfiguration.awsvpcConfiguration.subnets' \
       --output text)

subnet-xxxxxxxx,subnet-yyyyyyyy,

ただしこれでは、末尾にもカンマが付加されてしまうため、変数展開を使って末尾のカンマを取り除きます。 スクリプトではこんな感じ。

SUBNET_LIST=$(printf '%s,' \
   $(aws ecs describe-services --cluster the-cluster --services the-service \
       --query 'services[0].networkConfiguration.awsvpcConfiguration.subnets' \
       --output text))

echo ${SUBNET_LIST%,}

# 出力結果
# subnet-xxxxxxxx,subnet-yyyyyyyy

その他細かいネタ

よく必要になる AWSアカウントID を取得するスニペットです。

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

おわりに

だいたいのケースは、ここに書いたように、AWS CLI とシェルの組み込みコマンドで何とかなってしまうので、生の ID をスクリプトに埋め込まないようにしましょう。

( あんまり複雑になるようなら他のプログラミング言語SDKを使ったほうがいいと思いますが )

Mac の iTerm2 上で Home/End キーを使いたい

MacBook に PC 用キーボードをつないで使っていますが、Terminal での行頭/行末移動が不便。なので、Windows 同様、Home/End キーで行頭/行末移動ができるようになりたい。

というのを実現したのでその手順を。

なお、 以下手順は iTerm2 で確認しています。標準のターミナルでは使えないようです。

  1. Home/End キーの Escape Sequence を確認
    iTerm2 上で Ctrl+V の直後に特殊キーを押すと、そのキーのエスケープコードが表示されます。例えば Ctrl+V Home と押すと、^[[HCtrl+V End^[[F が出力されます。

  2. bindkey コマンドでエスケープコードと動作の関連付けを行う
    (zshの場合 )1 で確認した Escape Sequence にカーソル移動の動作を紐つけます。(.zshrc などに追記しておくといいでしょう)

bindkey "^[[H" beginning-of-line
bindkey "^[[F" end-of-line     

※試してませんが、bash なら bind -x でも同様のことができるかも知れません。

以上で、 iTerm2 上で Home/End キーによる行頭/行末移動ができるようになります。

anyenv で tfenv を入れると init でエラーが出るので回避する

数多ある *env 系のツール導入をぐっと簡単にしてくれる anyenv ですが、現行バージョン (1.1.1) では、tfenv を導入すると、シェルログイン時にエラーが出るようになります。

No such command 'init'
Usage: tfenv <command> [<options>]

Commands:
   install       Install a specific version of Terraform
   use           Switch a version to use
   uninstall     Uninstall a specific version of Terraform
   list          List all installed versions
   list-remote   List all installable versions

anyenv はログイン時、導入されている *env すべての init サブコマンドを順に実行するのですが、 tfenv には init サブコマンドがないのでエラーとなるようです。

修正 PR も上がっているようなのでそのうち取り込まれると思いますが、当面の回避方法のメモ。

ここでは、Mac で homebrew を使って anyenv をインストールした前提で書きます。

anyenv のインストール先を確認

% brew ls anyenv
/usr/local/Cellar/anyenv/1.1.1/bin/anyenv
/usr/local/Cellar/anyenv/1.1.1/completions/ (3 files)
/usr/local/Cellar/anyenv/1.1.1/libexec/ (14 files)

で、 libexec 以下の anyenv-init を修正します。

% cp /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init.backup
% vim /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init

修正内容は以下のような感じ。

% diff -up /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init.backup /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init
--- /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init.backup 2020-01-26 22:57:10.000000000 +0900
+++ /usr/local/Cellar/anyenv/1.1.1/libexec/anyenv-init    2020-01-26 22:59:09.000000000 +0900
@@ -140,5 +140,5 @@ for env in $(anyenv-envs); do
     ;;
   esac

-  echo "$(${ENV_ROOT}/bin/${env} init - ${no_rehash_arg}${shell})"
+  [ "${env}" != 'tfenv' ] && echo "$(${ENV_ROOT}/bin/${env} init - ${no_rehash_arg}${shell})"
 done

homebrew を使わず git clone でインストールした場合は、 .anyenv/libexec/anyenv-init を同じように修正すればOKです。

本当はちゃんと 「init のない *env」を判定したいところなんですが、まあ一時的な回避策ということで。

CircleCI で CIRCLE_SHA1 を使う場合の注意

過去に自分がミスった内容と似た事例を他にも見かけたので、今更ながらメモ。

TL; DR

  • CircleCI の ECS/ECR へのデプロイに関するドキュメントで、コンテナイメージのタグに CIRCLE_SHA1 の値が設定されているが、これには要注意
  • CircleCI における CIRCLE_SHA1 の値は、最終コミットを識別するハッシュ値だよ
  • Git上のブランチやタグに依って変わるわけではないので、ブランチ・タグによって異なるものの識別には使えないよ
  • イメージタグのような識別子を作成する場合、それが必要なユニーク性を持っているか確認することが必要だよ

CircleCI で ECR/ECS にデプロイするときのコンテナイメージタグ

CircleCI 公式のドキュメントとして、以下の記事が公開されています。 (2019/12/15現在)

AWS ECR/ECS へのデプロイ - CircleCI

上記記事より引用

version: 2.1
orbs:
  aws-ecr: circleci/aws-ecr@0.0.2
  aws-ecs: circleci/aws-ecs@0.0.3
workflows:
  build-and-deploy:
    jobs:
      - aws-ecr/build_and_push_image:
          account-url: "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com"
          repo: "${AWS_RESOURCE_NAME_PREFIX}"
          region: ${AWS_DEFAULT_REGION}
          tag: "${CIRCLE_SHA1}"
      - ...

ビルドの結果出来上がる個々の コンテナイメージを識別するものとしてタグを設定します(上記例の tag )が、ここでは、タグに設定する値は "${CIRCLE_SHA1}" となっています。

(以前のドキュメントでは、普通に docker コマンドを使用して docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com:${CIRCLE_SHA1} という感じになっていたはず…)

CIRCLE_SHA1 って?

さて、この CIRCLE_SHA1 に設定される値はどのようなものでしょうか。こちらも CircleCI の公式ドキュメントで説明されています。

環境変数の使い方 - CircleCI

CIRCLE_SHA1 String 現在のビルドの最後のコミットに関する SHA1 ハッシュ。

VCS が Git であれば、 Git のコミットハッシュと同一の値になります。CircleCI でビルドを開始する際に Git リポジトリから取得した内容 (バージョン断面) を識別するものと考えていいと思います。

コンテナイメージタグに CIRCLE_SHA1 を設定すると

この CIRCLE_SHA1 をコンテナイメージタグに使うと、どうなるでしょう?

「Git リポジトリの内容が同一ならば、ビルドされるコンテナイメージも同一である」という条件が成り立つのであれば、これで問題ありません。CIRCLE_SHA1リポジトリの内容ごとにユニークであるので、そこと 1:1 に対応付けられるコンテナイメージも CIRCLE_SHA1 で識別することができます。

しかし、上記の条件が必ずしも成り立たない場合、つまりGitリポジトリが同一であっても、できあがるコンテナイメージが異なる場合がある(異なるイメージそれぞれを管理する必要がある)場合は注意が必要です。

例えば、よく見かける方式として、本番環境やステージング環境にリリースを行うために、Git 上の master から特定のブランチにマージし、それをトリガーとして CircleCI で各環境向けのビルドおよびデプロイを行う、という方式を考えます。

コンテナイメージ内に環境の情報を含める場合は、当然、リポジトリの内容が同一であっても、本番環境向けとステージング環境向けでできあがるコンテナイメージに差異が発生します。しかし、 CIRCLE_SHA1 の値は、トリガーとなったブランチに依らず。あくまでも最終コミットのハッシュ値によって決定されるため、それぞれのコンテナイメージに同一のタグが設定されることになります。

Docker および ECR の仕様上、すでに存在するタグと同一のタグを付与して Push した場合、古い方のイメージからはタグが剥がされてしまうため、例えば ステージング環境へのデプロイ後に本番環境へのデプロイを行った場合、ステージング環境の ECS クラスタにも本番環境用のコンテナイメージがデリバリされてしまうことになります。また、並行して複数環境のビルド・デプロイを行って、完了する順番が不定となるような場合、見かけ上ランダムにコンテナイメージが切り替わるため、再現検証の難しい障害となることがあります。

回避方法

この問題をするには、以下のやり方が考えられます

  • ECR リポジトリを環境ごとに用意する
  • CIRCLE_SHA1 以外の、適切な識別文字列を用意する。
  • タグにデプロイ先環境を識別する文字列を付加する

インフラ構成や実装の手間、ユニーク性の要件、費用等に合わせた方法を取るとよいでしょう。

まとめ

CIRCLE_SHA1 (に限りませんが) を何かの識別子として使用する場合、そのユニーク性が識別する対象のユニーク性と合致するかどうか、毎回きちんと検証しましょうというお話でした。

必要最低限のrequirements.txtを作成する (virtualenv版)

2017年に書いた、「必要最低限のrequirements.txtを作成する」という記事に未だに多少のアクセスがありまして、今現在でこれを参考にされてしまうとちょっとアレだなあと思いましたので。

(当時は Docker 憶えたてでイキっていました。反省)

noisyspot.hatenablog.com

クリーンな Python 環境を得るには virtualenv がおすすめ

virtualenv は、システムの Python 環境から、任意のディレクトリ以下に最低限のライブラリや実行ファイルをコピーし、クリーンな仮想 Python 環境を構成できるツールです。

virtualenv.pypa.io

通常は、開発したいプロジェクトやプロダクトごとに仮想環境を構成し、そのプロダクトに必要なライブラリだけ仮想環境にインストールして使用する、という使い方になると思いますが、開発で試行錯誤した結果、開発環境がプロダクションに不要なライブラリで汚染されていて整理したい、なんてこともあると思います。

そんなときは慌てず、別のディレクトリに改めてクリーンな仮想環境を作って、作成したソースコードをコピーした上で、改めて必要最低限のライブラリを pip install してテストし、問題なければ pip freeze してあげれば良いです。

使い方

virtualenv が未インストールであれば、インストールします。 pip install virtualenv でインストールできますが、特定のバージョンを入れたいなどオプションについてはドキュメントを参照

仮想環境を作りたいディレクトリに移動し、環境を作成します。

$ mkdir -p ~/path/to/project
$ cd ~/path/to/project
$ virtualenv .
Using base prefix '/home/user/.pyenv/versions/3.7.5'
New python executable in /home/user/path/to/project/bin/python3.7
Also creating executable in /home/user/path/to/project/bin/python
Installing setuptools, pip, wheel...
done.

すると、このディレクトリの下に bin, include, lib というディレクトリができます。bin 以下にある activate というスクリプトを source すると、仮想環境を使用するモードになります。

$ source bin/activate
(project) $   

プロンプトに (ディレクトリ名) が付加されていれば、そのディレクトリの仮想環境に入っている状態です。

この状態で、必要なライブラリを pip install し、 pip freeze すれば、明示的にインストールしたライブラリと依存ライブラリだけのリストが出力されます。

仮想環境から出たい場合は deactivate コマンドを実行する ( activate 時に alias が設定されている ) か、シェルからログアウトしてください。

細かすぎて伝わらないJava10のvar仕様

いろいろ試したのでメモ。

初期化時に型が決まる

まあ当たり前ですが。

public class Main {
    public static void main(String... args) {
        var hoge = 1;              // hoge は int型になる
        hoge = 2147483648;         // Integer.MAX_VALUE + 1
        System.out.println(hoge);
    }
}
> javac Main.java
Main.java:4: エラー: 整数2147483648が大きすぎます
        hoge = 2147483648; // Integer.MAX_VALUE + 1
               ^
エラー1個

以下のようににすると、iはint型になるので桁溢れして無限ループになります。

for (var i = 0; i < 2147483648; i++) {
....

プリミティブ型も推論してくれる

public class Main {
    public static void main(String... args) {
        var i = 1;
        i.getClass().getName();
    }
}
>javac Main.java
Main.java:4: エラー: intは間接参照できません
        i.getClass().getName();
         ^
エラー1個

余計な Boxing/Unboxing は発生しないようで一安心。

匿名クラスのインスタンスで初期化すると匿名クラス型の変数になる

        var hoge = new Object() {
            int a = 1;
            String s = "Hello!";
            int func(int a, int b) {
                return a + b;
            }
        };

のようにすると、hoge は Object 型ではなくここで定義された匿名クラスの型 (Main$1とか) になります。 従って、この同じメソッドの中であれば、 hoge.ahoge.func(1, 2); のように匿名クラス内のメンバを Interface定義なしで 呼び出すことができます。おもしろいけど使い所あるかな?


以降思いついたら順次追加

無限Streamに終了条件を設定する (Java9版)

noisyspot.hatenablog.com

というのを以前書きましたが、Java9では Stream#takeWhile というメソッドが追加されてこういうケースが簡単に書けるようになりました。

    public static String getTreePathTakeWhile(Node node, String delimiter) {
        List<String> names = Stream.iterate(node, Node::getParent)
                .takeWhile(n -> n != null)
                .map(n -> n.getName())
                .collect(Collectors.toList());
        Collections.reverse(names);

        return delimiter + String.join(delimiter, names);
    }

これでSupplierを使った無限Streamがとても使いやすくなりますね。