LoginSignup
3
0

More than 3 years have passed since last update.

downloadCompleteLogFileをbashで実装する

Last updated at Posted at 2019-10-11

EC2インスタンス上のOSからRDSのログをダウンロードするには、awsのcliaws 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ロールにアタッチされているポリシーは以下の通り。

ec2_rds_ctl_policy
{
    "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ヘッダとして渡してやればよい。また、のちにAccessKeyIdSecretAccessKeyも使うので、これらも取得する必要がある。perlawkあたりで正規表現を使えば簡単に取り出せる。その他、以降で使用する定数も含めると以下のようなコードになる。

# ========== 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での実装例を見ればどうやればよいかがわかる。大雑把にいって以下のようになる。

  1. リクエスト情報をまとめる
  2. リクエスト情報と時刻、固定文字列から署名対象データを生成する
  3. 署名対象データに署名する
  4. リクエストを発行する

以降ではそれぞれについて、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_ENDPOINTx-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)で固定文字列(それぞれAWS4aws4_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}"

Accepttext/plainを指定したので、標準出力をリダイレクトするなりすれば、そのままログファイルとして出力できる(指定しなくてもデフォルトはtext/plainで返ってきていたが明示しておいて損はないだろう)。

参照資料

付録

引数でIAMロール名、RDSインスタンスID、ログファイルパターンおよび出力先を指定し、マッチするログを全て出力する例。IAM認証情報がいつ期限切れになるかわからないので、ダウンロードするたびに動的に取得するよう実装している。というよりも、上記コードを関数化して繰り返し呼出しするようにしただけなので、あまりきれいにまとまっていない、、、

downloadCompleteLogFile.sh
#!/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


3
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0