EC2インスタンス上のOSからRDSのログをダウンロードするには、awsのcliのaws rds download-db-log-file-portionが便利かつ簡単であるが、ダウンロードしたログが途切れる現象(仕様?不具合?)が発生する(もともと"portion"という名の通りといえばその通りである)。ログ全体をダウンロードするのに、downloadCompleteLogFile
というAPIが提供されているものの、残念ながらaws rds
には実装されていない。
そこでOSコマンドを駆使してRDSからログを取ってくるシェルスクリプトを作ってみる。
正直なところ、サンプルコードが公開されているpythonを使うほうが楽なのだけれど、現場ではインフラ保守SEはpythonコードのメンテナンスなんてできません、などという状況がままあるので、「普通のシェルスクリプトです」というと通りがよいのだ、、、
前提環境
REST APIのサンプルでは、アクセスキーとシークレットをハードコーディングする例が記載されているが、AWSではEC2インスタンスにAPI実行を許可するポリシーを持ったロールを付与しておくことがベストプラクティスとされている。ここでもその前提とする。
具体的には以下のような構成となる。
- EC2インスタンス(Amazon Linux)
- RDS for PostgreSQL11
- EC2インスタンスにアタッチしたロール名は
testec2
testec2
ロールにアタッチされているポリシーは以下の通り。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"rds:DownloadDBLogFilePortion",
"rds:DescribeDBLogFiles",
"rds:StopDBInstance",
"rds:StartDBInstance"
],
"Resource": "*"
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances",
"rds:DownloadCompleteDBLogFile"
],
"Resource": "*"
}
]
}
今回のシェルスクリプトに必要なのは、rds:DownloadCompleteDBLogFile
のみ。
downloadCompleteDBLogFile
の概要
Amazon RDS データベースログファイルに記載してある通り、以下のようなリクエストをRDSのAPIエンドポイントに投げてやればよい。
GET /v13/downloadCompleteLogFile/DBInstanceIdentifier/LogFileName HTTP/1.1
Content-type: application/json
host: rds.region.amazonaws.com
これだけ見れば、なんだcurl
とかwget
で楽勝と思いきや、実際には認証関連のリクエストヘッダを生成して付与してやる必要がある。公式サイトにも上記の例に続いて、次のようなリクエスト例が記載されていて、見るものを絶望の淵に追いやってくれる。
GET /v13/downloadCompleteLogFile/sample-sql/log/ERROR.6 HTTP/1.1
host: rds.us-west-2.amazonaws.com
X-Amz-Security-Token: AQoDYXdzEIH//////////wEa0AIXLhngC5zp9CyB1R6abwKrXHVR5efnAVN3XvR7IwqKYalFSn6UyJuEFTft9nObglx4QJ+GXV9cpACkETq=
X-Amz-Date: 20140903T233749Z
X-Amz-Algorithm: AWS4-HMAC-SHA256
X-Amz-Credential: AKIADQKE4SARGYLE/20140903/us-west-2/rds/aws4_request
X-Amz-SignedHeaders: host
X-Amz-Content-SHA256: e3b0c44298fc1c229afbf4c8996fb92427ae41e4649b934de495991b7852b855
X-Amz-Expires: 86400
X-Amz-Signature: 353a4f14b3f250142d9afc34f9f9948154d46ce7d4ec091d0cdabbcf8b40c558
というわけで、これらの値は何なのか、そしてどうやって生成するのか、というのが問題の核心ということになる。
また、仕様なのか不具合なのか不明だが、この通りヘッダを設定しても認証情報が見つからないというエラー(403 Forbidden)になる。以下の情報はAuthorization
ヘッダにまとめて渡す必要がある。
X-Amz-Signature
X-Amz-Algorithm
X-Amz-Credential
X-Amz-SignedHeaders
X-Amz-Content-SHA256
各種セキュリティ情報
前述の通り、以下のセキュリティ情報をHTTPリクエストヘッダに付与してやる必要がある。
X-Amz-Security-Token
今回はEC2インスタンスにロールを付与しているので、これはメタデータから取得可能。
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/$iam_role
{
"AccessKeyId" : "XXXXXXXXXXXXXXXXX",
"SecretAccessKey" : "YYYYYYYYYYYYYYYYYYY",
"Token" : "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ",
}
Token
の値をX-Amz-Security-Token
ヘッダとして渡してやればよい。また、のちにAccessKeyId
とSecretAccessKey
も使うので、これらも取得する必要がある。perl
かawk
あたりで正規表現を使えば簡単に取り出せる。その他、以降で使用する定数も含めると以下のようなコードになる。
# ========== Step 0: 定数設定 ==========
LF="__LF__"
## IAMロールからアクセスキー・シークレット・トークンを取得
a=$(curl http://169.254.169.254/latest/meta-data/iam/security-credentials/${iam_role} | grep -e AccessKeyId -e SecretAccessKey -e Token)
access_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$1/')
secret_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$2/')
token=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$3/')
## サービス関連パラメータ設定
ALGORITHM=AWS4-HMAC-SHA256
region=ap-northeast-1
service=rds
host=${service}.${region}.amazonaws.com
endpoint="https://${host}"
request_path=/v13/downloadCompleteLogFile/${RDS_ID}/${logfilename}
amzdate=$(date -u +%Y%m%dT%H%M%SZ)
datestamp=$(date -u +%Y%m%d)
日付・時刻データは2種類、それぞれUTC時間で必要になる。0時(日本時間9時)をまたいだ場合、つまり$amzdate
と$datestamp
とで日付が異なるとどうなるかは不明。心配なら一回で日付取得して文字列を切り出すか、値を比較して異なるなら再生成するよう実装するほうがセーフかもしれない。
Authorization
これがこの記事の核心である。リクエストを認証してもらうのに、署名(HMACのハッシュ値計算)という手続きが必要で、これをリクエストヘッダ(Authorization
)として渡す必要がある。
pythonでの実装例を見ればどうやればよいかがわかる。大雑把にいって以下のようになる。
- リクエスト情報をまとめる
- リクエスト情報と時刻、固定文字列から署名対象データを生成する
- 署名対象データに署名する
- リクエストを発行する
以降ではそれぞれについて、bash
での実装例を記載していく。特に改行文字の扱いには注意が必要で、ダイジェスト値を求める際にecho "hogehoge" | openssl dgst
などとしてしまうと、末尾の改行コードも含めたダイジェスト値となってしまう点に留意する。また、意味のある文字として改行コード(LF)が随所に埋め込まれるので、IFS
環境変数の設定によってはスペース文字に変換されてしまう可能性がある。このため、以下では文字列定数LF=__LF__
を定義して、ダイジェスト値を求める際に文字変換するように実装している。
1. リクエスト情報をまとめる
ドキュメントには「正規リクエスト(canonical request)」と表現されているが、以下の要素を改行文字(LF)で連結した文字列である。
- HTTPリクエストメソッド:今回は
GET
- HTTPリクエストパス:今回は
/v13/downloadCompleteLogFile/${RDS_ID}/${logfilename}
- HTTPリクエストクエリ文字列:今回はNULL
- 署名対象HTTPリクエストヘッダ:今回は
host:$RDS_API_ENDPOINT
とx-amz-date:<リクエスト発行時刻GMT>
。複数ある場合は改行文字(LF)で区切る - 署名対象ヘッダ情報:今回は
host;x-amz-date
。こちらは複数ある場合セミコロン(;)で区切る - POSTデータのペイロードハッシュ:今回はリクエストメソッドが
GET
なのでNULLのSHA-256ハッシュ値
ほぼ固定値なので、bash
で簡単に生成できる。ペイロードは空なので常に固定値になるが、openssl
コマンドで動的に生成すると以下のようになる。
# ========== Step 1: 正規リクエストの生成 ==========
header="host:${host}${LF}x-amz-date:${amzdate}${LF}"
signed_headers="host;x-amz-date"
request_param=""
payload_hash=$(printf "" | openssl dgst -sha256 | awk '{print$2}')
canonical_request="GET${LF}${request_path}${LF}${request_param}${LF}${header}${LF}${signed_headers}${LF}${payload_hash}"
繰返しになるが、この後ダイジェスト値を求めることになるので、たとえばheader
変数の中でhost: ${host}
というように余分な空白を入れたりすると、まったく異なった署名値ができあがり、無事に(?)403 Forbidden
が返されることになる。
2. リクエスト情報と時刻、固定文字列から署名対象データを生成する
署名対象の文字列は以下の要素をこれまた改行文字(LF)で連結して生成する。
- 署名アルゴリズム:
AWS4-HMAC-SHA256
固定。ダイジェストアルゴリズムは変更できそうだがわざわざビット数が低いアルゴリズムを選択するメリットはないだろう - 資格情報の有効範囲(スコープ):
YYYYMMDD/<AWSリージョン名>/<AWSサービス名>/aws4_request
- 正規リクエストのダイジェスト値
実際のコードは以下のようになる。
# ========== Step 2: 署名対象文字列生成 =========
scope="${datestamp}/${region}/${service}/aws4_request"
dgst=$(printf $canonical_request | perl -pe 's/__LF__/\n/g' | openssl dgst -sha256 | awk '{print$2}')
str_2B_signed="${ALGORITHM}${LF}${amzdate}${LF}${scope}${LF}${dgst}"
正規リクエスト文字列($canonical_request}
)のダイジェスト($dgst
)は、改行文字のマーク(${LF}
)をパイプでperl
につないで改行文字(LF)に変換し、あとはopenssl dgst -sha256
に渡して取得できる。
署名対象文字列(str_2B_signed
)はこれらをやはり改行文字(LF)で連結したものになる。
3. 署名対象データに署名する
上記で生成した署名対処文字列に署名する。使用するアルゴリズム(HMAC)は同じなので、コマンドとしてはopenssl dgst
で生成可能だが、そのステップは以下のようになる。
# | 署名対象データ | 鍵データ |
---|---|---|
1 | 日付($datestamp ) |
AWS4${secret_key} |
2 | リージョン名($region ) |
前のステップで生成したダイジェスト値 |
3 | サービス名($service ) |
前のステップで生成したダイジェスト値 |
4 | aws4_request |
前のステップで生成したダイジェスト値 |
5 | 署名対象データ($str_2B_signed ) |
前のステップで生成したダイジェスト値 |
やっていることは同じだがその意味合いは異なる。1~4までで署名に使用する鍵となる値を生成し、そのダイジェスト値を使用して署名対象データのダイジェスト値(HMACハッシュ値)を計算することになる。
# ========== Step3: 署名生成 ==========
## 署名用鍵生成
kDate=$(printf $datestamp | openssl dgst -sha256 -mac HMAC -macopt key:AWS4${secret_key} | awk '{print$2}')
kRegion=$(printf $region | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kDate} | awk '{print$2}')
kService=$(printf $service| openssl dgst -sha256 -mac HMAC -macopt hexkey:${kRegion} | awk '{print$2}')
sigkey=$(printf "aws4_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kService} | awk '{print$2}')
## 署名
signature=$(printf $str_2B_signed | perl -pe 's/__LF__/\n/g' | openssl dgst -mac HMAC -macopt hexkey:${sigkey} | awk '{print$2}')
ところどころに固定文字列が使用されるので若干複雑に感じるものの、いくつかの情報のハッシュチェーンである。
openssl dgst -mac HMAC
では鍵を渡すときのオプション(-macopt
)として文字列(key:<key_str>
)とhex文字列(hexkey:<hex_str>
)が利用可能で、一つ目だけは文字列で鍵を渡すので、前者のオプションを使用している。出力はhexdumpなので、以降ではhexkey
として渡す必要がある。
繰返しになるが、署名を生成するのにハッシュチェーンを使用していて、鍵生成の最初のステップと最後のステップ(#1と#4)で固定文字列(それぞれAWS4
とaws4_request
)を使用している。$secret_key
をそのまま使わずにソルト文字列の役割を持つAWS4
を挿入している点が心憎い。
4. リクエストを発行する
あとはリクエストヘッダを付与して実際にGET
するだけ。curl
の-H
オプションを使用すれば、リクエストヘッダを追加できる。Authorization
ヘッダ値のフォーマットは以下の通り。
<署名アルゴリズム> Credential=<アクセスキー>/<スコープ>, SignedHeaders=<署名対象HTTPリクエストヘッダ>, Signature=<署名値>
アクセスキーはここで使う。
# ========== Step 4: 署名をリクエストに付与してリクエスト発行 ==========
auth_header="${ALGORITHM} Credential=${access_key}/${scope}, SignedHeaders=${signed_headers}, Signature=${signature}"
curl \
-H "host: ${host}" \
-H "Accept: text/plain" \
-H "x-amz-security-token: ${token}" \
-H "x-amz-date: ${amzdate}" \
-H "Authorization: ${auth_header}" \
"${endpoint}${request_path}"
Accept
にtext/plain
を指定したので、標準出力をリダイレクトするなりすれば、そのままログファイルとして出力できる(指定しなくてもデフォルトはtext/plain
で返ってきていたが明示しておいて損はないだろう)。
参照資料
付録
引数でIAMロール名、RDSインスタンスID、ログファイルパターンおよび出力先を指定し、マッチするログを全て出力する例。IAM認証情報がいつ期限切れになるかわからないので、ダウンロードするたびに動的に取得するよう実装している。というよりも、上記コードを関数化して繰り返し呼出しするようにしただけなので、あまりきれいにまとまっていない、、、
# !/bin/bash
# https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_LogAccess.html
# https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-version-4.html
# ========== 初期化 ==========
while [ $# -gt 0 ]; do
case $1 in
"-region")
shift
region=$1
shift;;
"-rds")
shift
rds_id=$1
shift;;
"-iam")
shift
iam_role=$1
shift;;
"-out")
shift
outputfile=$1
shift;;
"-file")
shift
logfilename=$1
shift;;
*)
echo "Usage: $(basename $0) -region AWS_REGION -rds RDS_IDENTIFIER -iam IAM_ROLE_NAME -file FILENAME_PATTERN_TO_BE_DOWNLOADED -out OUTPUT_FILE" >&2
exit 1;;
esac
done
# assume defaults
iam_role=${iam_role:=$(uname -n)}
rds_id=${rds_id:=database-1}
logfilename=${logfilename:=postgresql.log}
outputfile=${outputfile:=/tmp/postgresql.log}
region=${region:=$AWS_DEFAULT_REGION}
region=${region:=ap-northeast-1}
# set constants
service=rds
metadata_url=http://169.254.169.254/latest/meta-data/iam/security-credentials/${iam_role}
LF="__LF__"
ALGORITHM=AWS4-HMAC-SHA256
host=${service}.${region}.amazonaws.com
endpoint="https://${host}"
# ========== 関数 =========
function downloadRDSLog() {
local logfilename=$1
# ========== Step 0: 定数設定 ==========
## IAMロールからアクセスキー・シークレット・トークンを取得
a=$(curl $metadata_url | grep -e AccessKeyId -e SecretAccessKey -e Token)
access_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$1/')
secret_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$2/')
token=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$3/')
if [ -z "$access_key" -o -z "$secret_key" -o -z "$token" ]; then
echo "security credentials not available" >&2
curl $metadata_url >&2
exit 1
fi
## リクエスト関連パラメータ設定
request_path=/v13/downloadCompleteLogFile/${rds_id}/${logfilename}
amzdate=$(date -u +%Y%m%dT%H%M%SZ)
datestamp=$(date -u +%Y%m%d)
# ========== Step 1: 正規リクエストの生成 ==========
header="host:${host}${LF}x-amz-date:${amzdate}${LF}"
signed_headers="host;x-amz-date"
request_param=""
payload_hash=$(printf "" | openssl dgst -sha256 | awk '{print$2}')
canonical_request="GET${LF}${request_path}${LF}${request_param}${LF}${header}${LF}${signed_headers}${LF}${payload_hash}"
# ========== Step 2: 署名対象文字列生成 =========
scope="${datestamp}/${region}/${service}/aws4_request"
dgst=$(printf $canonical_request | perl -pe 's/__LF__/\n/g' | openssl dgst -sha256 | awk '{print$2}')
str_2B_signed="${ALGORITHM}${LF}${amzdate}${LF}${scope}${LF}${dgst}"
# ========== Step3: 署名生成 ==========
## 署名用鍵生成
kDate=$(printf $datestamp | openssl dgst -sha256 -mac HMAC -macopt key:AWS4${secret_key} | awk '{print$2}')
kRegion=$(printf $region | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kDate} | awk '{print$2}')
kService=$(printf $service| openssl dgst -sha256 -mac HMAC -macopt hexkey:${kRegion} | awk '{print$2}')
sigkey=$(printf "aws4_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kService} | awk '{print$2}')
## 署名
signature=$(printf $str_2B_signed | perl -pe 's/__LF__/\n/g' | openssl dgst -mac HMAC -macopt hexkey:${sigkey} | awk '{print$2}')
# ========== Step 4: 署名をリクエストに付与してリクエスト発行 ==========
auth_header="${ALGORITHM} Credential=${access_key}/${scope}, SignedHeaders=${signed_headers}, Signature=${signature}"
curl \
-H "host: ${host}" \
-H "Accept: text/plain" \
-H "x-amz-security-token: ${token}" \
-H "x-amz-date: ${amzdate}" \
-H "Authorization: ${auth_header}" \
"${endpoint}${request_path}"
if [ $? -ne 0 ]; then
echo "download error" >&2
exit 1
fi
}
# ========== 主処理 ==========
## ファイル初期化
> $outputfile
if [ $? -ne 0 ]; then
echo "file access error(${outputfile})" >&2
exit 1
fi
## ログファイルリストを取得
logfiles=$(
aws rds describe-db-log-files --db-instance-identifier $rds_id --filename-contains $logfilename \
--query "DescribeDBLogFiles[*].[LogFileName]" --output text | sort
)
if [ $? -ne 0 ]; then
echo "log list not available" >&2
exit 1
fi
## ログをダウンロード
for logfile in $logfiles; do
echo "downloading $logfile"
downloadRDSLog $logfile >>$outputfile
done