aws-cli のラッパースクリプトを書いて作業を超効率化する

この記事は[Wano Group Advent Calendar 2023](https://qiita.com/advent-calendar/2023/wano-group)の2日目の記事となります。
1日目は、@masafumi330 の　[全エンジニアにおすすめする本「世界一流エンジニアの思考法」](https://qiita.com/masafumi330/items/43f9129a0d3a1112640f) です。そちらもどうぞ！

また、グループ会社のEDOCODE(メインで仕事してます)の[Advent Calendar](https://adventar.org/calendars/8641)もありますので、よろしければそちらもどうぞ。

## この記事に書いていること

以前からAWSは使ってはいるのですが、そんなに突っ込んで使うことも少なく、aws-cliを使う機会もそこまで多くありませんでした。ですが、頻繁に使い出すと AWS の mangement console 触ってると、日が暮れる...とか、ストレスで死にそう...という心の声が聞こえたので、ラッパースクリプトを書いて、作業を効率化しましたよ、という話です。

注意: お使いの環境によって、多少変えないといけないところがあるかと思いますが、そのへんはよしなにしてください。また、フィルターとかもっとうまくかけるんじゃない？という話もあるかと思いますが、コメントで教えてもらえれば幸いです。

## aws-cli のラッパースクリプトを書くメリット

* 新しい人がチームに入ったときに設定等がスムーズに行える
* AWS management console でクリックをたどっていくストレスがなくなる
* 人間が覚えるには難しい内容をラッパースクリプト内に押し込める
* 地味にめんどくさい単純計算をする必要がなくなる
* 人間が覚えるには多いが、9割くらいは毎回同じ引数をラッパースクリプトに押し込める
* 体感10-100倍くらいは効率化できる(かも)

こんな感じで、気分的にも体感でも作業効率がかなり上がること間違いないです。

## 準備: aws-cli のプロファイル名をチーム内で統一する

まず、これをやりましょう。統一することにはメリットしかないので、統一しましょう。統一しない場合、「俺の考えた最高に格好いいプロファイル名を使える」というメリットはあるかもしれませんが、格好いいプロファイル名をチームで共有すれば解決です!

統一するメリットとしては:

- 統一していれば、slackでこのコマンド叩いてと、コピペで渡したものが、そのまま使えます。統一していないと、profileを書き換える必要があります
- 複数のプロファイルを切り替える必要があるときに、ラッパーコマンド内で、デフォルトのプロファイルを決めることができます

「今回のプロジェクトはこの名前で統一する!」と独断と偏見で決めてしまえば良いので、簡単です。

- project-name-dev
- project-name-stg
- project-name-prod

とか、こんな感じで良いと思います。

こんなんでもありですね(いや、ない)。

- project-name-bronze
- project-name-silver
- project-name-gold

## 作ったラッパースクリプト

- aws-sso ... aws sso のラッパー
- aws-logs ... aws logs のラッパー
- aws-pipelines ... aws codepipelines のラッパー
- aws-ssm ... aws ssm のラッパー
- aws-ecs ... aws ecs のラッパー
- aws-ecr ... aws ecr のラッパー

と、まぁ、(だいたい)`-`でつないだだけです。
なお、ほぼすべてのコマンドは`jq`コマンドを内部で使っていますので、事前に[jq](https://jqlang.github.io/jq/)をインストールしておいてください。

## ラッパースクリプトの紹介

というわけで、作ったラッパースクリプトのなかみを紹介していきますが、最初に共通の挙動について説明します。

### ラッパースクリプトの共通する挙動

あまり時間を掛けたくないですし、覚えやすいように、単純な引数にしました(順番は重要)。

```
コマンド [環境指定] サブコマンド [引数...]
```

という感じです。指定されなかったときは、helpを表示するというようにしています。
スクリプトの冒頭は、全部ほぼ一緒で、下記のようになっています。
環境指定がないときは、devが使われるようになっています。

```bash
#!/bin/bash -e

# project-name は適当に変更してください
if [[ "$1" =~ ^-(dev|stg|prod)$ ]]; then
  AWS_PROFILE="project-name-$1";
  shift
fi

# project-name は適当に変更してください
if [ "$AWS_PROFILE" = "" ]; then
  AWS_PROFILE=project-name-dev # 指定がなければdevを使う
fi

# クラスタ名のところは、適当に変更してください
if [[ "$AWS_PROFILE" =~ dev ]]; then
   CLUSTER_NAME=dev-cluster
elif [[ "$AWS_PROFILE" =~ stg ]]; then
   CLUSTER_NAME=stg-cluster
elif [[ "$AWS_PROFILE" =~ prod ]]; then
   CLUSTER_NAME=prod-cluster
fi
```

また、それぞれのコマンド(aws-ssoを除く)には、`list` コマンドがあります。
サブコマンドの引数となるものをリストアップする役割です。listに引数を与えた場合、その引数でgrepした結果を出力するようにしています。

listに引数なしだと、全部出力されて、
```sh
% aws-ecs list 
a-ecs
b-ecs
c-ecs
```
`b` を引数に渡すと、`b`が含まれるものだけ出力されます。
```sh
% aws-ecs list b
b-ecs
```

それでは、各コマンドの詳細について説明します。以下の説明では、上記の共通部分の続きを書いていると思ってください。

### aws-sso

これを用意して置くことのメリットとしては、**「プロジェクトに新しく入る人に aws-cli の設定について、ごちゃごちゃ教える必要がない」** ところです。
アカウントを準備したら、このコマンドから「`aws-sso login` とやれば、cli 使えるようになるので」と言うだけですみます。
残念ながら、人間には長いURLやアカウントIDなどを覚える能力はないので、ラッパースクリプト内に書いてしまうのが良いです。

※ アスタリスクの部分は適当に変えてください

```bash
if [ "$1" = "" ] || [ "$1" != "login" ]; then
  echo "$0 ... login to AWS env or setup AWS profile if first time login"
  echo
  echo "$0 [-dev,-stg,-prod] login... setup profile"
  exit 1
fi

if grep -E "\[profile $AWS_PROFILE\]" ~/.aws/config > /dev/null; then
  echo "Login to $AWS_PROFILE"
  echo
  aws sso login --profile=$AWS_PROFILE
  exit 1;
fi

cat <<EOL >> ~/.aws/config
[profile $AWS_PROFILE]
sso_region = ap-northeast-1
region = ap-northeast-1
output = json
sso_role_name = ********
EOL

if [[ "$AWS_PROFILE" =~ dev ]]; then
  echo 'sso_start_url = https://********.awsapps.com/start#' >> ~/.aws/config
  echo 'sso_account_id = ************' >> ~/.aws/config
elif [[ "$AWS_PROFILE" =~ stg ]]; then
  echo 'sso_start_url = https://********.awsapps.com/start#' >> ~/.aws/config
  echo 'sso_account_id = ************' >> ~/.aws/config
elif [[ "$AWS_PROFILE" =~ prod ]]; then
  echo 'sso_start_url = https://********.awsapps.com/start#' >> ~/.aws/config
  echo 'sso_account_id = ************' >> ~/.aws/config
fi 

echo "Login to $AWS_PROFILE"
aws sso login --profile=$AWS_PROFILE
```

### aws-logs

AWSの management console上の cloudwathで、リアルタイムのログ見るのしんどくないですか？僕はしんどいです。

list でロググループの一覧を取得します。
tail で対象のロググループのログの取得を開始します。引数に数字を渡すと、その数字分の、分数前からのログを取得します。これで、地味に現在日時から9時間と○分前を計算するのに毎回頭のリソースが奪われることがなくなり、より仕事に集中できるようになります。grepで絞ることもできますね。

※ アスタリスクの部分は適当に変えてください

```bash
COMMAND=$1
TARGET=$2;
SINCE=$3;

help() {
  echo
  echo $0 ... aws logs wrapper command;
  echo
  echo "$0 [-dev,-stg,-prod] list [\$GREP_KEYWORD] ... list log groups"
  echo "$0 [-dev,-stg,-prod] tail \$log_group_namepull [\$sicne_time_stamp|\$minutes] ... tail log of \$log_group_name. default \$since_time_stamp is now -9 hours."
  echo
  exit 1;
}

if [ "$COMMAND" != "list" ] && [ "$COMMAND" != "tail" ]; then
  echo "COMMAND is required as 1st arg: list/tail";
  help;
fi

if [ "$COMMAND" = "tail" ]; then
  if [ "$TARGET" = "" ]; then
    echo "tail requires second arg: log group name";
    help;
  fi
fi

if [ "$COMMAND" = "list" ]; then
  CMD="cat"
  if [ "$TARGET" != "" ]; then
    CMD="grep $TARGET"
  fi
  aws logs describe-log-groups --profile $AWS_PROFILE | \
    jq -r '.logGroups[] | .logGroupName' | eval $CMD
elif [ "$COMMAND" = "tail" ]; then
  if [ "$SINCE" != "" ]; then
     if [[ "$SINCE" =~ ^[0-9]+$ ]]; then
       DIFF=$((60 * 9 + $SINCE))
       SINCE=$(date +"%Y-%m-%dT%T" --date "$DIFF minutes ago")
     fi
  else
    SINCE=$(date +"%Y-%m-%dT%T" --date "9 hours ago")
  fi
  echo "tail since: $SINCE (UTC)";
  aws logs tail --follow --profile $AWS_PROFILE --since $SINCE $TARGET
fi
```

### aws-pipelines

コードパイプラインが、今どういう状況なんやろーというのが気になります。まぁ、Slackに通知もしてはいるのですが。`watch aws-pipelines name1 name2 name3` のようにやって、terminalの片隅においておくと、いい感じです。
また、codepipelineを手動で開始したり、止めたりすることもできます。

```bash
COMMAND=$1
TARGET=$2
PIPELINE_ID=$3

help() {
  echo 
  echo "$0 ... aws codepipelines wrapper command;"
  echo
  echo "$0 [-dev,-stg,-prod] list [\$GREP_KEYWORD] ... list pipelines"
  echo "$0 [-dev,-stg,-prod] start \$pipeline_name ... start \$pipeline_name"
  echo "$0 [-dev,-stg,-prod] stop \$pipeline_name \$pipeline_id ... stop \$pipeline_name. \$pipeline_id is shown as a result of start command"
  echo "$0 [-dev,-stg,-prod] status \$pipeline_name [\$pipeline_name ...] ... show status of \$pipeline_name(s)"
  echo
  exit 1;
}

if [ "$COMMAND" != "list" ] && [ "$COMMAND" != "start" ] && [ "$COMMAND" != "status" ] && [ "$COMMAND" != "stop" ] ; then
  echo "COMMAND is required as 1st arg: list/start/stop/status";
  help;
fi

if [ "$COMMAND" = "start" ] || [ "$COMMAND" = "status" ] || [ "$COMMAND" = "stop" ] ; then
  if [ "$TARGET" = "" ]; then
    echo "start requires second arg: pipeline name";
    help;
  fi
  if [ "$COMMAND" = "stop" ] && [ "$PIPELINE_ID" = "" ]; then
    echo "stop requires third arg: pipeline id";
    help;
  fi
fi

if [ "$COMMAND" = "list" ]; then
  CMD="cat"
  if [ "$TARGET" != "" ]; then
    CMD="grep '$TARGET'"
  fi
  aws codepipeline --profile $AWS_PROFILE list-pipelines | jq -r '.pipelines[] | .name' | eval $CMD
elif [ "$COMMAND" = "start" ]; then
  echo "$COMMAND: $TARGET @ $AWS_PROFILE"
  aws codepipeline --profile $AWS_PROFILE start-pipeline-execution --name $TARGET | \
      jq -r '\"pipelineExecutionId: \" + .pipelineExecutionId'
elif [ "$COMMAND" = "stop" ]; then
  echo "$COMMAND: $TARGET @ $AWS_PROFILE"
  aws codepipeline --profile $AWS_PROFILE stop-pipeline-execution \
      --pipeline-name $TARGET --pipeline-execution-id $PIPELINE_ID | jq -r
elif [ "$COMMAND" = "status" ]; then
  shift
  for TARGET in $@; do
    echo "$COMMAND: $TARGET @ $AWS_PROFILE"
    aws codepipeline --profile $AWS_PROFILE  get-pipeline-state --name $TARGET | \
        jq -r '.stageStates[] | [.stageName, .latestExecution.status, .latestExecution.pipelineExecutionId, .inboundExecution.status, .inboundExecution.pipelineExecutionId ] | @tsv'
  done
fi
```

## aws-ecs

コンテナにログインしたいときに使います。

login と言いながら、引数にコマンド渡せるので、exec とかのが良かったかもしれませんが、後付なので、loginのままになってます。exec だと引数ないのも違和感を感じますしね。

```bash
TASK_ARN="";
COMMAND=$1
CONTAINER_NAME=$2
CMD=$3

help() {
  echo 
  echo $0 ... aws ecs wrapper command;
  echo
  echo $0 [-dev,-stg,-prod] list [\$GREP_KEYWORD] ... list containers
  echo $0 [-dev,-stg,-prod] login \$container_name [CMD] ... login to \$container_name. if CMD is given, execute CMD instead of bash
  echo
  echo "-n flag is dry-run. just show aws cli command."
  exit 1;
}

if [ "$COMMAND" != "list" ] && [ "$COMMAND" != "login" ] ; then
  echo "COMMAND is required as 1st arg: list/login";
  help;
fi

if [ "$COMMAND" = "start" ] && [ "$CONTAINER_NAME" = "" ]; then
  echo "CONTAINER_NAME is required as 2st arg of login";
  help;
fi

if [ "$COMMAND" = "login" ]; then
  echo "$COMMAND: $CONTAINER_NAME @ $AWS_PROFILE"
  TASK_ARN=$(aws ecs --profile $AWS_PROFILE list-tasks --cluster $CLUSTER_NAME | \
		 jq -r '.taskArns[]' | xargs -L 100 \
         aws ecs --profile $AWS_PROFILE describe-tasks --cluster $CLUSTER_NAME --tasks | \
		 jq -r '.tasks[].containers[] | select(.lastStatus == "RUNNING") | select(.name == "'$CONTAINER_NAME'") | .taskArn');
  
  if [ "$CMD" = "" ]; then
    CMD='/bin/bash'
  fi

  aws ecs execute-command \
      --cluster $CLUSTER_NAME \
      --task $TASK_ARN \
      --profile $AWS_PROFILE \
      --container $CONTAINER_NAME \
      --interactive \
      --command "$CMD"
elif [ "$COMMAND" = "list" ]; then
  CMD="cat"
  if [ "$CONTAINER_NAME" != "" ]; then
    CMD="grep '$CONTAINER_NAME'"
  fi
  aws ecs --profile $AWS_PROFILE list-tasks --cluster $CLUSTER_NAME | \
      jq -r '.taskArns[]' | xargs -L 100 -r \
      aws ecs --profile $AWS_PROFILE  describe-tasks --cluster $CLUSTER_NAME --tasks  | \
      jq -r '.tasks[].containers[] | select(.lastStatus == \"RUNNING\") |.name' | \
      eval $CMD;
fi
```

## aws-ssm

ssm でログインしたいのですが、残念ながら、instance のIDなどは人は覚えることができませんし、覚える意味もありません。

```bash
TASK_ARN="";
COMMAND=$1
TARGET=$2
CMD=$3

help() {
  echo 
  echo $0 ... aws ssm wrapper command;
  echo
  echo $0 [-dev,-stg,-prod] list [\$GREP_KEYWORD] ... list ssm target
  echo $0 [-dev,-stg,-prod] login \$instanceId ... login to \$instanceId
  echo
  exit 1;
}

if [ "$COMMAND" != "list" ] && [ "$COMMAND" != "login" ] ; then
  echo "COMMAND is required as 1st arg: list/login";
  help;
fi

if [ "$COMMAND" = "login" ] && [ "$TARGET" = "" ]; then
  echo "InstanceId is required as 2nd arg of login";
  help;
fi

if [ "$COMMAND" = "login" ]; then
  echo "$COMMAND: $TARGET @ $AWS_PROFILE"
  
  aws ssm --profile=$AWS_PROFILE start-session --target $TARGET
elif [ "$COMMAND" = "list" ]; then
  CMD="cat"
  if [ "$TARGET" != "" ]; then
    CMD="grep '$TARGET'"
  fi
  aws ec2 --profile=$AWS_PROFILE describe-instances | \
      jq -r '.Reservations[] | .Instances[] | [(.NetworkInterfaces[] | .Groups[0] | .GroupName), .InstanceId] | @tsv' | \
      eval $CMD
fi
```

## aws-ecr

このプロジェクトでは、環境ごとにECRも用意されていたので、URLが異なります。人はECRのURLなんてものは覚えられないので、ラッパースクリプト内に書いておきます。

※ アスタリスクの部分は適当に変えてください

```bash
COMMAND=$1
TARGET=$2;
TAG=$3;

help() {
  echo 
  echo $0 ... aws ecr wrapper command;
  echo
  echo $0 [-dev,-stg,-prod] login... docker login to ecr
  echo $0 [-dev,-stg,-prod] list [\$GREP_KEYWORD] ... list repotisory name and URI
  echo $0 [-dev,-stg,-prod] pull \$repository_name [\$tag_name] ... docker pull latest image of \$repository_name
  echo $0 [-dev,-stg,-prod] tags \$repository_name ... list tags of \$repository_name
  echo
  exit 1;
}

if [ "$COMMAND" != "list" ] && [ "$COMMAND" != "tags" ] && [ "$COMMAND" != "pull" ] && [ "$COMMAND" != "login" ]; then
  echo "COMMAND is required as 1st arg: list/tags/pull/login";
  help;
fi

if [ "$COMMAND" = "pull" ] || [ "$COMMAND" = "tags" ]; then
  if [ "$TARGET" = "" ]; then
    echo "tags/pull requires second arg: repository name";
    help;
  fi
fi

if [ "$COMMAND" = "list" ]; then
  CMD="cat"
  if [ "$TARGET" != "" ]; then
    CMD="grep $TARGET"
  fi
  aws ecr describe-repositories --profile $AWS_PROFILE | jq -r '.repositories[] | [.repositoryName, .repositoryUri] | @tsv' | eval $CMD
elif [ "$COMMAND" = "login" ]; then
  if [[ "$AWS_PROFILE" =~ dev ]]; then
    aws ecr get-login-password --profile $AWS_PROFILE | docker login --username AWS --password-stdin *********.ecr.ap-northeast-1.amazonaws.com
  elif [[ $AWS_PROFILE =~ stg ]]; then
    aws ecr get-login-password --profile $AWS_PROFILE | docker login --username AWS --password-stdin *********.ecr.ap-northeast-1.amazonaws.com
  elif [[ $AWS_PROFILE =~ prod ]]; then
    aws ecr get-login-password --profile $AWS_PROFILE | docker login --username AWS --password-stdin *********.ecr.ap-northeast-1.amazonaws.com
  fi
elif [ "$COMMAND" = "pull" ]; then
  if [ "$TAG" = "" ]; then
    TAG=$(aws ecr list-images --profile $AWS_PROFILE --repository-name $TARGET | \
          jq -r '.[] |sort_by(.imageTag) | last.imageTag')  
  fi
  aws ecr describe-repositories --profile $AWS_PROFILE | \
      jq -r '.repositories[] | select(.repositoryName == \"'$TARGET'\") | .repositoryUri' | \
      xargs -i -r docker pull {}:$TAG
elif [ "$COMMAND" = "tags" ]; then
  aws ecr list-images --profile $AWS_PROFILE --repository-name $TARGET | \
      jq -r '.[] |sort_by(.imageTag) | .[].imageTag'
fi
```
## 実測

さて、実際どのくらい作業効率が良くなるのか、測ってみました。動画でとっても良かったのですが、編集スキルも時間もないので、結果だけですみません。

クラウドウォッチのログを見るのに要する時間です。

### ブラウザ

1. management console を開く(10秒)
1. 検索にcloudwatchを入力してcloudwatchを開く(17秒)
1. ロググループをクリックして開く(9秒)
1. ロググループから目当てのログをインクリメンタル検索して開く(13秒)
1. 最新のログをクリックして開く(3秒)

52秒かかっています。タブ開きすぎてブラウザ遅くなってるかもしれませんけども、いつもそんなもんなんで...。

### aws-cli wrapper

1. aws-logs list (2.5秒)
1. 結果から目当てのものを見つける(0.5秒)
1. aws-logs tail ↑で見つけたもの (1秒)

合計4秒くらいです。

ブラウザと比べると、当然ですが、だいぶ速いですね。

しかもですね。

terminalなら、ctrl+rとか使えますね!

1．`ctrl + r` + tail で検索して実行 (0.5秒)

ブラウザは2度目でもやること変わらないので、(運が良ければ)100倍以上速いですね!(強引)

### 結果

|             | aws-cli wrapper(4秒) | ctrl+r(0.5秒) |
|-------------|---------------------|---------------|
|ブラウザ(52秒) |                13倍|          104倍 |

まぁ、ctrl+rは置いといて...、13倍？思ったより大したことないな？と思いましたかね？
でも、これ、100回繰り返すと、4800秒差が出るので、1.5時間以上の効率化となります。

それに、あなたのストレスもきっと軽減されているはずですよ。

## おしまい

以上、aws-cliのラッパースクリプトの紹介でした。見ての通り、やってることは`aws-cli`と`jq`を使っているだけの超簡単なものです。helpとか、引数のチェックしてる行数のが多いくらいですね。

より改善するなら、list以外で引数を取るサブコマンドの実行時にエラーが出たら、listの結果から引数で絞って、一つしかなかったら、引っかかるもので実行を行うとか、2つ出たら、1 or 2 みたいな感じで、入力したら実行できるようにするとか...。

他にも1プロジェクト特化型じゃなくて、複数プロジェクトで使えるようにして、プロジェクトごとのプロファイルを作れるようにしてやれば、1コマンドできますね。これは、プロジェクト名と環境名をチーム間で統一すれば簡単にできそうです。

改善の余地はまだあるかとは思いますが、こんな簡単なものでも、体感的には10〜100倍、気分的には256倍くらいは効率良くなった気はします。ラッパースクリプトを書くのに1日もかからないので、継続的に関わるプロジェクトならかける価値はあるかと思います。

## 人材募集

現在、Wanoグループでは人材募集をしています。興味のある方は下記を参照してください。
[JOBS | Wano Group](https://group.wano.co.jp/jobs/)