AWS CloudWatch シェルだけでPutMetricDataを実行する

# 目的と背景
AWS CLI便利ですよね。色々なAPIリクエストを生成して投げてくれたり、ステータスが変わるまで待ってくれたり、結果応答を整形してくれたり。

しかしこの記事では**AWS CLIやAWS SDK、その他GitHubにある既存の便利なスクリプトは使いません**。
具体的には**CentOS6に標準でインストールされているもののみでCloudWatchにプロセス監視結果を通知します**。[^1]

なぜそんなことをするかというと、ちょっとした動作検証目的でt2.microのインスタンスを動かしているのですが、ここで動かしているMySQLがよく落ちる。原因は判明したのですが、せっかくだしプロセスの死活監視いれとこう、となったわけです。
だけれどもそのインスタンスはCentOS6。もちろんデフォルトではAWS CLIは入っていません。最小構成で動作させてるので、Pythonだとか余計なものはインストールしたくない。ディスク容量もギリギリだし。

良く言えば少しでもリソースを節約したかった、というところです。

# やってみる
## インスタンスの設定
まずはEC2インスタンスからPutMetricDataができるように権限設定をします。
IAMロールを使います。私はEC2インスタンスからならばなんでもIAMロールの権限に基づいてAWS APIを叩くべきだと思ってます。クレデンシャル情報は管理したくないですし。

ポリシーは以下のようにしました:

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Action": [
                "cloudwatch:PutMetricData"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```
PutMetricDataさえできればいいのです。これをEC2インスタンスに適用するIAMロールにアタッチします。

## プロセス監視と通知を行うスクリプトの作成
メインとなるBashスクリプトです。

```Bash:putprocessalives.sh
#!/bin/bash

curl_opt="-sSL"
CURL="/usr/bin/curl $curl_opt --connect-timeout 3"

count_processes() {
  local proc="$1"
  if [ -z "$proc" ]; then
    echo 0
  else
    ps -ef | grep "$proc" | grep -v grep | wc -l
  fi
}

sha256() {
  local msg="$1"
  echo -n "$1" | /usr/bin/openssl dgst -sha256 | sed -e 's/^.*=[ \t]*//'
}

hmac_sha256() {
  local hexkey="$1"
  local msg="$2"
  echo -n "$msg" | /usr/bin/openssl dgst -sha256 -mac HMAC -macopt hexkey:${hexkey} -hex | sed -e 's/^.*=[ \t]*//'
}

to_hex() {
  local str="$1"
  echo -n "$str" | od -An -tx1 | tr -d '[:blank:]\n'
}

# get instance info.
# instance id
instanceId=`$CURL http://169.254.169.254/latest/meta-data/instance-id`
if [ -z "$instanceId" ]; then
  echo "can't access instance meta-data." 1>&2
  exit 1
fi
# region from AZ
region=$(\
$CURL http://169.254.169.254/latest/meta-data/placement/availability-zone | \
sed -e 's/\(.*\)./\1/'\
)

# iam credentials
iamrole=`$CURL http://169.254.169.254/latest/meta-data/iam/security-credentials`
if [ -z "$iamrole" ]; then
  echo "this instance must be applied an IAM role that can put metric data." 1>&2
  exit 1
fi
accessKeyId=$(\
$CURL http://169.254.169.254/latest/meta-data/iam/security-credentials/${iamrole} | \
grep '^\s*"AccessKeyId" :' | \
sed -e 's/^.*:\s*"\(.*\)"[ \t]*,\{0,1\}/\1/'\
)
secretAccessKey=$(\
$CURL http://169.254.169.254/latest/meta-data/iam/security-credentials/${iamrole} | \
grep '^\s*"SecretAccessKey" :' | \
sed -e 's/^.*:[ \t]*"\(.*\)"[ \t]*,\{0,1\}/\1/'\
)
securityToken=$(\
$CURL http://169.254.169.254/latest/meta-data/iam/security-credentials/${iamrole} | \
grep '^\s*"Token" :' | \
sed -e 's/^.*:[ \t]*"\(.*\)"[ \t]*,\{0,1\}/\1/'\
)

# check alives
metric_mysql=`count_processes "mysqld "`
metric_httpd=`count_processes "httpd$"`

# make request & calc signature
aws_host="monitoring.${region}.amazonaws.com"
uri=/doc/2010-08-01/
amz_alg=AWS4-HMAC-SHA256
date_ymd=`date -u +%Y%m%d`
amz_service="monitoring" # or "cloudwatch" ?
amz_scope=${date_ymd}/${region}/${amz_service}/aws4_request
amz_date=`date -u --iso-8601=seconds | sed -e 's/[-:]//g; s/+[0-9]*/Z/'`
amz_signedHeaders="host"
query="\
Action=PutMetricData\
&MetricData.member.1.Dimensions.member.1.Name=InstanceId\
&MetricData.member.1.Dimensions.member.1.Value=${instanceId}\
&MetricData.member.1.MetricName=NumOfMysqld\
&MetricData.member.1.Unit=Count\
&MetricData.member.1.Value=${metric_mysql}\
&MetricData.member.2.Dimensions.member.1.Name=InstanceId\
&MetricData.member.2.Dimensions.member.1.Value=${instanceId}\
&MetricData.member.2.MetricName=NumOfHttpd\
&MetricData.member.2.Unit=Count\
&MetricData.member.2.Value=${metric_httpd}\
&Namespace=AWS%2FEC2\
&Version=2010-08-01\
&X-Amz-Algorithm=${amz_alg}\
&X-Amz-Credential=`echo -n ${accessKeyId}/${amz_scope} | sed -e 's|/|%2F|g'`\
&X-Amz-Date=${amz_date}\
&X-Amz-Security-Token=`echo -n ${securityToken} | sed -e 's/+/%2B/g; s|/|%2F|g; s/=/%3D/g'`\
&X-Amz-SignedHeaders=`echo -n ${amz_signedHeaders} | sed -e 's/;/%3B/g'`\
"
payload_hash=`sha256 ""`
canonicalRequest=`cat <<EOS
GET
${uri}
${query}
host:${aws_host}

${amz_signedHeaders}
${payload_hash}
EOS`

canonicalRequest_hash=`sha256 "$canonicalRequest"`
string_to_sign=`cat <<EOS
${amz_alg}
${amz_date}
${amz_scope}
${canonicalRequest_hash}
EOS`

kDate=$(hmac_sha256 $(to_hex AWS4${secretAccessKey}) ${date_ymd})
kRegion=`hmac_sha256 ${kDate} ${region}`
kService=`hmac_sha256 ${kRegion} ${amz_service}`
kSigning=`hmac_sha256 ${kService} aws4_request`

signature=`hmac_sha256 ${kSigning} "${string_to_sign}"`

# put metrics
result=`$CURL "https://${aws_host}${uri}?${query}&X-Amz-Signature=${signature}" -w '\n%{http_code}'`
http_status=`echo "$result" | tail -1`
if [ "$http_status" != "200" ]; then
  echo "response code was not 200." 1>&2
  echo "$result" 1>&2
  exit 1
fi

```
コマンドとしてはシェルビルトインと`sed`とか`grep`とか基本的なコマンド、それにAPIリクエストの実行に`curl`、リクエストに付加する署名の作成に`openssl`を使ってます。
上から簡単に説明していきます。
### 関数について
* count_processes()
指定されたプロセスの数を出力する関数です。

* sha256()
指定されたメッセージのSHA256ダイジェストを16進表記で出力します。

* hmac_sha256()
キーとメッセージを受けとり、HMAC-SHA256値を16進表記で出力します。キーは16進表記の文字列です。

* to_hex()
引数の文字列のバイト値を16進表記で出力します。

### 必要なインスタンスの情報の取得
`# get instance info.`からの部分です。
インスタンスメタデータからインスタンスID、リージョン、IAMロールのクレデンシャル情報とセキュリティトークンを取得します。
IAMロールによるアクセスですのでこのようにしていますが、発行済みのアクセスキーIDとアクセスキーも使用できます。またその場合はセキュリティトークンは必要ないと思います。
IAMロールのクレデンシャルは**一時的セキュリティ認証情報**であるため、後述のリクエストのパラメタに`X-Amz-Security-Token`を含めねばならず、その値であるセキュリティトークンを取得しています。

### プロセス監視とAPIリクエスト
`# check alives`, `# make request & calc signature`, `# put metrics`の部分です。
check alivesでは単純にプロセス数をカウントしてるだけです。
put metricsも単純にcurlでGETリクエストをしているだけです。

では`# make request & calc signature`とはなんだ？ということですが、ここが肝です。生AWS APIリクエストを行う場合に一番面倒くさい部分です。普通はAWS CLIやAWS SDKがやってくれる部分ですが、今回それらを一切使わないので自前でやらなければなりません。
詳しくは[こちらの公式ドキュメント](http://docs.aws.amazon.com/ja_jp/general/latest/gr/signing_aws_api_requests.html)を参照してください。この「署名バージョン 4 署名プロセス」に従ってリクエストを構築していきます。

公式ドキュメントを読むと、正規リクエスト？？？リクエストヘッダーの形式なのか？？？とか感じてしまうかもしれませんが、そうではなく単純にリクエストと送信元関連の情報を含む特定の形式の文字列をハッシュするというだけです。

で、その実装例が上記ということになります。正直いって[Pythonの例](http://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html#sig-v4-examples-get-query-string)をシェルでやってみただけです。ですが、注意点とシェルならではの点もあるので解説します。

最初の方では複数回使うような文字列を変数にいれてます。
日付の形式はISO　8601の基本形式と決まっているので整形してます。
接続先ホスト名や変数`uri`に設定されてるパスについては[CloudWatch API リファレンス](http://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/APIReference/Welcome.html)を参照してください。PutMtricDataの形式や、その他必要なパラメタについてもリファレンスを参照して設定しました。

変数`query`に設定されている文字列を説明します。この文字列はリクエストのクエリ文字列としても使いますが、署名する文字列(の元になる文字列）にも含めます。ここがちょっとでも間違っているとAPIからの応答はエラーになってしまうので、正確に指定してください。
必要なパラメタは[PutMetaData](http://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/APIReference/API_PutMetricData.html)固有のものと、[共通パラメタ](http://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/APIReference/CommonParameters.html)です。ここで注意するべきことは**パラメタの順番**です。パラメータ名がASCIIコードの昇順に並ぶようにしないといけません。さもないと、APIサーバ側で検証する内容と異なってしまい、SignatureDoesNotMatchというエラーになるでしょう。
また、URLパラメータですので、URLエンコーディングもしなければなりません。上記の実装では、必要なところだけ`sed`で置換してます。

`payload_hash`は送信するコンテンツ(HTTPリクエストのメッセージボディ)をSHA256でハッシュした値の16進表記文字列です。今回はGETリクエストで、コンテンツはないので""(空文字)をハッシュした値となります。SHA256ハッシュには`openssl dgst`コマンドを使用しています。

そしてさらに署名プロセスに従い、正規リクエスト文字列やそのハッシュを作成し、署名対象となる文字列`string_to_sign`を作成します。

次に署名するキーを作成しなければなりません。それが、kDate=~kSigning=の部分です。この流れは署名プロセスで定められている通りです。`kSigning`が最終的なキーとなります。
ここでHMAC-SHA256でMAC値を生成しないといけないのですが、これにも`openssl dgst`を作成しています。実装時にここでちょっとハッシュに使うキーの指定方法について悩みました。メッセージの方はそのままパイプでopensslコマンドに渡せるのですが、キーのほうは`openssl dgst -hmac <key>`の形式だと`<key>`にはバイナリ値そのままを渡さなければなりません。-binaryオプションをつけてMACを出力し、バイナリ値を変数に格納して次のキーとして使えばいけたのかもしれませんが、どこかに文字列として出力する際に困りそうだったのでどうにかならないかと調べたら、なんとキーを16進文字列形式で指定する方法がありました！
こちらのブログを参考にさせてもらいました:
http://nwsmith.blogspot.jp/2012/07/using-openssl-to-generate-hmac-using.html
署名も16進形式でリクエストに付加するので出力は16進形式で統一するのが楽ですね。

最終的には署名対象メッセージ`string_to_sign`をキー`kSigning`を使ってHMAC-SHA256にかけ、署名を作成します。出来上がった署名は変数`signature`に格納しています。
ここまでで署名プロセスは完了です。

いよいよ最後は署名をクエリパラメタの末尾に`&X-Amz-Signature=${signature}`として付加し、APIにGETリクエストを投げます。リクエストにはcurlを使ってます。
上記スクリプトでは成功した場合は何も出力しません。失敗した場合はAPIからの応答メッセージが表示されます。作成中に発生したエラーは以下:

* InvalidClientTokenId : セキュリティトークンが必要なのに付与しなかったり、間違ってる場合。URLエンコードし忘れて出ました。 
* SignatureDoesNotMatch : 署名が間違っている場合。このリクエストなら正規リクエストや署名対象メッセージはこうなるはずだ！と指摘してくれるのでデバッグに便利でした。

## 定期実行設定
cronで1分間隔で上記スクリプトを叩くようにしてみました。
```
*/1 * * * * /opt/exec/putprocessalives.sh >> /var/log/putprocessalives.log 2>&1
```

## マネジメントコンソールから見てみる
![aws_mc_cw1.png](https://qiita-image-store.s3.amazonaws.com/0/137783/55a9425f-7b75-155b-76d4-b01b98db7d54.png)

通知時に`Namespace=AWS%2FEC2`としているのでAWS/EC2名前空間の下に追加されてます。[^2]
こんな感じで無事プロセス数を監視することができました。

***
[^1]: 構成によってはインストールされないコマンドもありますが、「有名どころ」のコマンドで、というニュアンスで…
[^2]: APIリファレンスには「You cannot specify a namespace that begins with "AWS/".」って書いてあります。CPU使用率とかと同じとこにまとめたかったので知らずに指定し、結果動いてますが本来は別の名前空間にするべきでしょう。