この記事はWano Group Advent Calendar 2023の2日目の記事となります。
1日目は、@masafumi330 の 全エンジニアにおすすめする本「世界一流エンジニアの思考法」 です。そちらもどうぞ!
また、グループ会社のEDOCODE(メインで仕事してます)のAdvent Calendarもありますので、よろしければそちらもどうぞ。
この記事に書いていること
以前から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をインストールしておいてください。
ラッパースクリプトの紹介
というわけで、作ったラッパースクリプトのなかみを紹介していきますが、最初に共通の挙動について説明します。
ラッパースクリプトの共通する挙動
あまり時間を掛けたくないですし、覚えやすいように、単純な引数にしました(順番は重要)。
コマンド [環境指定] サブコマンド [引数...]
という感じです。指定されなかったときは、helpを表示するというようにしています。
スクリプトの冒頭は、全部ほぼ一緒で、下記のようになっています。
環境指定がないときは、devが使われるようになっています。
#!/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に引数なしだと、全部出力されて、
% aws-ecs list
a-ecs
b-ecs
c-ecs
b
を引数に渡すと、b
が含まれるものだけ出力されます。
% aws-ecs list b
b-ecs
それでは、各コマンドの詳細について説明します。以下の説明では、上記の共通部分の続きを書いていると思ってください。
aws-sso
これを用意して置くことのメリットとしては、「プロジェクトに新しく入る人に aws-cli の設定について、ごちゃごちゃ教える必要がない」 ところです。
アカウントを準備したら、このコマンドから「aws-sso login
とやれば、cli 使えるようになるので」と言うだけですみます。
残念ながら、人間には長いURLやアカウントIDなどを覚える能力はないので、ラッパースクリプト内に書いてしまうのが良いです。
※ アスタリスクの部分は適当に変えてください
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で絞ることもできますね。
※ アスタリスクの部分は適当に変えてください
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を手動で開始したり、止めたりすることもできます。
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 だと引数ないのも違和感を感じますしね。
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などは人は覚えることができませんし、覚える意味もありません。
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なんてものは覚えられないので、ラッパースクリプト内に書いておきます。
※ アスタリスクの部分は適当に変えてください
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
実測
さて、実際どのくらい作業効率が良くなるのか、測ってみました。動画でとっても良かったのですが、編集スキルも時間もないので、結果だけですみません。
クラウドウォッチのログを見るのに要する時間です。
ブラウザ
- management console を開く(10秒)
- 検索にcloudwatchを入力してcloudwatchを開く(17秒)
- ロググループをクリックして開く(9秒)
- ロググループから目当てのログをインクリメンタル検索して開く(13秒)
- 最新のログをクリックして開く(3秒)
52秒かかっています。タブ開きすぎてブラウザ遅くなってるかもしれませんけども、いつもそんなもんなんで...。
aws-cli wrapper
- aws-logs list (2.5秒)
- 結果から目当てのものを見つける(0.5秒)
- 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