IaC(Infra as Code) on AWSで環境構築するための準備(その1)

この記事では、IaC(Infra as Code)を用いた環境構築を本格的に始める準備として、コードを格納するS3バケットを用意します。
何となくS3バケットを作成しても、動作はしますが、ここでは、ちゃんとAWSのベストプラクティスを踏まえたS3バケットをAWS CLIを使ったスクリプトで自動作成します。
なお、この記事に関する免責事項は、末尾に掲載されているリンク先に記載されていますので、ご確認ください。
IaCとは
今の時代、環境構築を、GUIから手作業で行う行為は、様々な課題や不都合があり、推奨されません。
手動で環境構築することはデメリットが多く、その裏返しがIaCで環境構築することのメリットです。
| 手動で環境構築するデメリット | IaCで環境構築するメリット |
| オペミスの可能性がある | コード化によりヒューマンエラーを削減し、毎回同じ正確な設定が適用できる |
| 構築作業に時間がかかる | コードによる自動構築なので、時間を短縮できる |
| 同じ環境をもう一つ作ろうとしても再現性が低い | 同じコードを使えば、同じ環境を自動で再現できる |
| 環境を変更した際に、何を、いつ、なぜ変更したのか、記録し、管理する仕組みとプロセスが必要 | Gitでコード管理することで、環境変更の対象箇所や、更新理由、反映タイミングが分かりやすく管理できる |
小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやることで、スクリプトとAWS CLIを組合せて、AWSアカウントの初期設定をしていました。
これも、環境設定をコード化し、自動化するIaCの一種です。
ただし、環境構築/設定を、自動化してコード管理すれば、なんでもIaCかと言うと、IaCには守るべき重要な原則があります。それは、「冪等性(べきとうせい)を担保すること」と、「宣言的であること」です。
冪等性
「冪等性(べきとうせい)」とは、「何度、同じ操作をしても、同じ結果になる」ことです。
| 日常の例 | 説明 | |
| 冪等性 | エレベータの目的階数ボタンを押す |
エレベータで、5階に行きたい時に、5階のボタンを押すと、エレベータは5階に止まります。 |
| 非冪等性 | テレビの音量ボタンを押す | テレビの音量が10の時に、「ボリュームを上げる」ボタンを押すと、音量は11になります。 「ボリュームを上げる」ボタンを10回押すと、音量は20になります。 押した回数だけ、音量が上がり、結果が変わります。 |
この冪等性を担保することは、IaCを実行するタイミングにおける環境の構成や設定が、どのような状態であれ、IaCを実行した結果として、同じ状態に揃うことを保証するものです。そのため、環境変更時には、IaCにより自動的に差分が適用されるわけです。
さて、冪等性を担保するように、スクリプトを作成するのは、結構面倒です。(小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやることのスクリプトは、冪等性を担保するよう作成していますが、そのために、コードの量が増えています)
そこで、冪等性を担保してIaCを実行できるように作られた、プロビジョニングツールを使った方が、リーズナブルです。
宣言的
「宣言的」とは、「前状態とは関係なく、反映した結果、こうなる」を示すことです。前状態は気にせず、最後は「C」になる、と言います。
宣言的の逆は「手続的」です。これは、「前状態Aに対して、状態をBにする」と言うプロセスです。
簡単な例としては、宣言的は前状態を気にしないので「上書き」、手続的は「変更」のイメージです。
もう少しちゃんとした例として、データベーステーブルの定義変更を挙げます。

宣言的なIaCは、「テーブルXは、カラムA、B、Cを持つ」と宣言し、前状態が何であっても(テーブルXが無くても、あるいは、テーブルXにカラムDがあっても)、最後に「カラムA、B、Cを持つテーブルX」を作り出します。
一方、手続的なスクリプトは、「カラムAを持つテーブルX」が存在することを前提として、「テーブルXにカラムBを追加する」スクリプトを実行し、更に、「テーブルXにカラムCを追加する」スクリプトを実行する、というプロセスを経て、最後に「カラムA、B、Cを持つテーブルX」が作り出されます。
宣言的なIaCは、コードにテーブルXの最終的な定義が記載されているので、コードを見れば、それが反映された後の状態が一目瞭然です。
しかし、手続的なスクリプトは、反映する全てのスクリプトと、最初の前提状態を重ね合わせて読み解かないと、反映後の状態が分かりません。
スクリプトであっても、宣言的に作成することは可能ですが、かなり意識しないと、ついつい手続的になりがちなので、これも結構面倒です。
そこで、もともとコードが宣言的であることを前提としたプロビジョニングツールを使った方が、分かりやすいですし、間違えがありません。
プロビジョニングツール
AWSにおける環境構築でのプロビジョニングツールは、TerraformとCloudFormationが代表的です。
Terraformは、AWS以外の、Google CloudやAzureにも対応しており、比較的柔軟性が高く、広く普及しています。一方、CloudFormationは、AWSネイティブなサービスで、他のAWSサービスとの親和性が高く、使い始めるまでの準備のハードルが低いです。
何を使うかは好みもありますが、このブログでは、以下のように使い分けることにします。
| レイヤ | IaCの構成 | 説明 | |
| レイヤ0 | アカウントブートストラップ | スクリプト+AWS CLI | プロビジョニングツールを使うか、何を使うかには関係なく、AWSアカウントを作ったら必須でやるべきこと |
| レイヤ1 | アカウントコントロール |
スクリプト+AWS CLI |
CloudFormationでIaCによる環境構築を行うためのリソース |
|
CloudFormation |
TerraformでIaCによる環境構築を行うための前提やリソース群 | ||
| レイヤ2以降 | ワークロード/アプリ環境 | Terraform | アプリ実行環境や、レイヤ1以外の運用環境/開発環境などの設定やリソース群 |
レイヤ0と1は、手作業でやってもいいんじゃない?と言う気持ちになるかもしれませんが、「IaCとは」に記載した手作業のデメリットとIaCのメリットに立ち返ってください。
レイヤ1構築(その1)
目的
この記事と、「IaC(Infra as Code) on AWSで環境構築するための準備(その2)」で、レイヤ1を構築していきます。
この記事では、CloudFormationを実行するために、IaCのコードを格納するS3バケットを用意します。
実は、このS3バケットは、なくてもCloudFormationを実行することは可能なのですが、S3バケットが無かった場合で、一定サイズ以上のテンプレートを使用する場合などに、一時利用の S3 バケットが自動的に作成されます。このバケットはデフォルト設定で作成されるため、ガバナンス上の懸念が生じます。
- IaCとして管理していないS3バケットが存在することになり、統制が効かなくなる
- AWSのベストプラクティスに準拠していない設定のS3バケットが作成され、将来、第三者のセキュリティ診断などを受けた際に、脆弱性ありと判断される(例:パブリックアクセスブロックが無効、バージョニングなし、HTTPS必須でない など)
そこで、最低限、以下の設定を施したS3バケットを作成し、使うことにします。
- IaCコード格納用S3バケットのアクセスログ取得を有効化し、小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやることで作成したCloudTrailに、このアクセスログも連携する(連携されたアクセスログは、証跡ログ保管用S3バケットに保管される)
- バージョニング設定を有効化する
- Public Access Blockを有効化する
- S3サーバーサイド暗号化を有効化する
- HTTPS以外のアクセスを拒否する
スクリプトの実行方法
「小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやること」の「スクリプトの実行方法」を見てください。
リソースファイル格納用S3バケット作成
スクリプト準備
create_resource_s3.shの---- Define ----のブロックに、S3バケット設定が定義されていますので、適宜書き換えてください。
| # | 設定項目 | 設定できる値 | 説明 |
| 1 |
REGION
|
${REGION}
|
S3バケットを作成するリージョン(REGIONは、awsenv.shで定義されています) |
| 2 |
rBucket
|
${RESOURCE_S3_KEY}$(altAwsIdStr 12) |
S3バケット名(RESOURCE_S3_KEYは、awsenv.shで定義されています)
|
設定
更新したスクリプトを、CloudShellに、アップロードしてください。
CloudShellで、スクリプトを実行してください。
bash ./create_resource_s3.sh設定内容を表示するので、確認の上、yを入力してください。

CloudTrailにリソースファイル格納用S3バケットのデータイベント取得設定を追加
スクリプト準備
小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやることで、作成したcreate_cloudtrail.shに、リソースファイル格納用S3バケットのデータイベントを取得取得する設定の宣言を追加してやります。
create_cloudtrail.shのハイライトされている行が追加行です。
設定
スクリプトを、CloudShellに、アップロードしてください。
CloudShellで、スクリプトを実行してください。
bash ./create_cloudtrail.sh設定内容を表示するので、確認の上、yを入力してください。

CloudWatch Queryの追加
スクリプト準備
小規模利用(シングルアカウント)向けAWSアカウントを作ったら必須でやることで、作成したcreate_loginsight_query.shに、リソースファイル格納用S3バケットのデータイベントをCloudWatchロググループから検索するクエリ設定の宣言を追加してやります。
create_cloudtrail.shのハイライトされている行が追加行です。
設定
スクリプトを、CloudShellに、アップロードしてください。
CloudShellで、スクリプトを実行してください。
bash ./create_loginsight_query.sh設定内容を表示するので、確認の上、yを入力してください。

スクリプト
create_resource_s3.sh
コードを見る(クリック)
#!/bin/bash
# define
mydir=$(dirname $0)
source "${mydir}"/common.sh
source "${mydir}"/awsenv.sh
grptag="resource"
# ---- Define ----
rBucket="${RESOURCE_S3_KEY}$(altAwsIdStr 12)" # リソースファイル格納用S3バケット
info "== Confirm =="
info "アカウント : ${ACCOUNT_ID}"
info "リージョン : ${REGION}"
info "リソースファイル格納用S3バケット : ${rBucket}"
while true
do
input "S3バケットの作成を続行しますか? (y/n): "
read input
if [ "${input}" == "y" ]; then
break
elif [ "${input}" == "n" ]; then
warn "Process cancelled."
exit 0
fi
done
# ---- Create S3 Bucket ----
# Create bucket
if aws s3api head-bucket --region "${REGION}" --bucket "${rBucket}" 2>/dev/null; then
info "S3 bucket exists: ${rBucket}"
else
info "Creating S3 bucket: ${rBucket}"
# リージョンが us-east-1 以外なら LocationConstraint 必須
if [ "${REGION}" == "us-east-1" ]; then
exec aws s3api create-bucket --region "${REGION}" --bucket "${rBucket}"
else
exec aws s3api create-bucket --region "${REGION}" --bucket "${rBucket}" \
--create-bucket-configuration LocationConstraint="${REGION}"
fi
if [ $? -ne 0 ]; then
abort "Process aborted."
exit 1
fi
fi
# Bucket policy setting for Resource Bucket
cat <<EOF > "${TEMPFILE}"
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyNonSSLRequests",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::${rBucket}",
"arn:aws:s3:::${rBucket}/*"
],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}
EOF
exec aws s3api put-bucket-policy --bucket "${rBucket}" --policy file://"${TEMPFILE}"
if [ $? -ne 0 ]; then
abort "Process aborted."
exit 1
fi
# Set Versining
exec aws s3api put-bucket-versioning --region "${REGION}" --bucket "${rBucket}" \
--versioning-configuration Status=Enabled
if [ $? -ne 0 ]; then
abort "Process aborted."
exit 1
fi
# Public access block
exec aws s3api put-public-access-block \
--region "${REGION}" \
--bucket "${rBucket}" \
--public-access-block-configuration \
'{"BlockPublicAcls":true,"IgnorePublicAcls":true,"BlockPublicPolicy":true,"RestrictPublicBuckets":true}'
if [ $? -ne 0 ]; then
abort "Process aborted."
exit 1
fi
# Serverside Encryption
exec aws s3api put-bucket-encryption \
--region "${REGION}" \
--bucket "${rBucket}"\
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
if [ $? -ne 0 ]; then
abort "Process aborted."
exit 1
fi
# --Tagging--
exec aws s3api put-bucket-tagging --region "${REGION}" --bucket "${rBucket}" \
--tagging "TagSet=[{Key=environment,Value=${ENVTAG}},{Key=group,Value=${grptag}}]"
rm -f "${TEMPFILE}*"
info "Process succeeded."
exit 0create_cloudtrail.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
source "${mydir}"/awsenv.sh
grptag="trail"
# ---- Define ----
trailName="Orgless-Management-Events" # CloudTrail名
s3Bucket="${TRAIL_S3_KEY}$(altAwsIdStr 12)" # S3バケット名
trailLogGroupARN="arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:${TRAIL_LOG_GROUP}" # LogGroup名
tagEnv="Key=environment,Value=${ENVTAG}"
tagGroup="Key=group,Value=${grptag}"
rBucket="${RESOURCE_S3_KEY}$(altAwsIdStr 12)" # (S3アクセスログ取得)リソースファイル格納用S3バケット
rBucketARN="arn:aws:s3:::${rBucket}"
eventType="WriteOnly" # (S3アクセスログ取得)取得するイベントタイプ(All/ReadOnly/WriteOnly)
info "== Confirm =="
info "アカウント : ${ACCOUNT_ID}"
info "CloudTrail証跡名 : ${trailName}"
info "連携先S3バケット : ${s3Bucket}"
info " 格納先フォルダ : ${TRIL_S3_PREFIX}"
info "連携先CloudWatchロググループ : ${TRAIL_LOG_GROUP}"
info "アクセスログ取得S3バケット : ${rBucket}"
info " イベントタイプ : ${eventType}"
while true
do
input "CloudTrailの作成を続行しますか? (y/n): "
read input
if [ "${input}" == "y" ]; then
break
elif [ "${input}" == "n" ]; then
warn "Process cancelled."
exit 0
fi
done
# ---- CloudTrail (multi-region) ----
if aws cloudtrail get-trail --name "${trailName}" >& /dev/null; then
info "Trail exists: ${trailName}"
else
info "Creating trail: ${trailName}"
ctArgs=(
--region "${REGION}"
--name "${trailName}"
--s3-bucket-name "${s3Bucket}"
--is-multi-region-trail
--include-global-service-events
--enable-log-file-validation
--cloud-watch-logs-log-group-arn "${trailLogGroupARN}:*"
--cloud-watch-logs-role-arn "arn:aws:iam::${ACCOUNT_ID}:role/${TRAIL_CW_ROLE}"
--s3-key-prefix "${TRIL_S3_PREFIX}"
)
exec aws cloudtrail create-trail "${ctArgs[@]}"
fi
# (S3アクセスログ取得) Add S3 Data Event
aws cloudtrail put-event-selectors \
--trail-name "${trailName}" \
--event-selectors "[
{
\"ReadWriteType\": \"${eventType}\",
\"IncludeManagementEvents\": true,
\"DataResources\": [
{
\"Type\": \"AWS::S3::Object\",
\"Values\": [\"${rBucketARN}/\"]
}
]
}
]"
exec aws cloudtrail start-logging --region "${REGION}" --name "${trailName}"
## --Tagging--
trailARN="arn:aws:cloudtrail:${REGION}:${ACCOUNT_ID}:trail/${trailName}"
exec aws cloudtrail add-tags --region "${REGION}" --resource-id "${trailARN}" --tags-list "${tagEnv}" "${tagGroup}"
# --- Check ---
iErr=0
aws cloudtrail get-trail --region "${REGION}" --name "${trailName}" >& /dev/null
if [ $? -ne 0 ]; then
abort "CloudTrail ${trailName} is missing."
((iErr++))
fi
if [ ${iErr} -gt 0 ]; then
abort "Process aborted."
fi
info "Process succeeded."
exit 0create_loginsight_query.sh
コードを見る(クリック)
#!/bin/bash
mydir=$(dirname $0)
source "${mydir}"/common.sh
source "${mydir}"/awsenv.sh
grptag="trail"
# ---- Define ----
queryDir="alarm/" # クエリをまとめるディレクトリ
queryRootName="${queryDir}QUERY-RootLoginSuccess" # rootユーザログイン確認用クエリ
queryApiRegionName="${queryDir}QUERY-APICallOutsideAllowedRegions" # 未使用リージョンアクセス確認用クエリ
allowedRegionsCSV="${REGION},${OTHER_REGION}"
queryS3AccDir="s3Access/" # (S3アクセスログ取得)S3アクセスログクエリディレクトリ
queryResourceS3AccessName="${queryS3AccDir}QUERY-${RESOURCE_S3_KEY}AccessLog" # (S3アクセスログ取得)リソースファイル格納用S3バケットアクセスログクエリ
info "== Confirm =="
info "CloudWatchロググループ名 : ${TRAIL_LOG_GROUP}"
info "rootユーザログイン確認用クエリ : ${queryRootName}"
info "未使用リージョンアクセス確認用クエリ : ${queryApiRegionName}"
info " 通常使用するリージョン : ${allowedRegionsCSV}"
info "リソースファイル格納用S3アクセスログ確認用クエリ : ${queryResourceS3AccessName}"
while true
do
input "CloudWatchアラームの設定を続行しますか? (y/n): "
read input
if [ "${input}" == "y" ]; then
break
elif [ "${input}" == "n" ]; then
warn "Process cancelled."
exit 0
fi
done
# ---- Investigation Query for alarm ----
# 1) Root login success
queryString="$(cat <<'QL'
fields @timestamp, awsRegion, eventSource, eventName,
userIdentity.type as userType,
userIdentity.arn as userArn,
sourceIPAddress, userAgent, eventID
| filter eventName = "ConsoleLogin"
| filter userIdentity.type = "Root"
| filter responseElements.ConsoleLogin = "Success"
| sort @timestamp desc
| limit 200
QL
)"
queryExists="$(aws logs describe-query-definitions \
--region "${REGION}" \
--query-definition-name-prefix "${queryRootName}" \
--query 'queryDefinitions[?name==`'"${queryRootName}"'`].queryDefinitionId' \
--output text 2>/dev/null || true)"
if [[ -n "${queryExists:-}" && "${queryExists}" != "None" ]]; then
info "Updating existing query definition: ${queryRootName} ($queryExists)"
exec aws logs put-query-definition \
--region "${REGION}" \
--query-definition-id "${queryExists}" \
--name "${queryRootName}" \
--query-string "${queryString}" \
--log-group-names "${TRAIL_LOG_GROUP}"
else
info "Creating new query definition: ${queryRootName}"
exec aws logs put-query-definition \
--region "${REGION}" \
--name "${queryRootName}" \
--query-string "${queryString}" \
--log-group-names "${TRAIL_LOG_GROUP}"
fi
# 2) API calls in NOT-allowed regions (exclude global services)
allowedRegionsJSON=$(printf '"%s"' "${allowedRegionsCSV//,/\",\"}")
queryString="$(cat <<EOF
fields @timestamp, awsRegion, eventSource, eventName,
userIdentity.type as userType,
userIdentity.arn as userArn,
sourceIPAddress, userAgent, eventID
| filter ispresent(awsRegion)
| filter eventCategory = "Management"
| filter awsRegion not in [${allowedRegionsJSON}]
| filter eventSource not in [
"iam.amazonaws.com","cloudfront.amazonaws.com","route53.amazonaws.com",
"globalaccelerator.amazonaws.com","waf.amazonaws.com","wafv2.amazonaws.com",
"support.amazonaws.com","health.amazonaws.com",
"signin.amazonaws.com","sts.amazonaws.com","sso.amazonaws.com","sso-oidc.amazonaws.com",
"ce.amazonaws.com"
]
| filter userIdentity.invokedBy not in ["resource-explorer-2.amazonaws.com"]
| filter readOnly = false
| sort @timestamp desc
| limit 200
EOF
)"
queryExists="$(aws logs describe-query-definitions \
--region "${REGION}" \
--query-definition-name-prefix "${queryApiRegionName}" \
--query 'queryDefinitions[?name==`'"${queryApiRegionName}"'`].queryDefinitionId' \
--output text 2>/dev/null || true)"
if [[ -n "${queryExists:-}" && "${queryExists}" != "None" ]]; then
info "Updating existing query definition: ${queryApiRegionName} ($queryExists)"
exec aws logs put-query-definition \
--region "${REGION}" \
--query-definition-id "${queryExists}" \
--name "${queryApiRegionName}" \
--query-string "${queryString}" \
--log-group-names "${TRAIL_LOG_GROUP}"
else
info "Creating new query definition: ${queryApiRegionName}"
exec aws logs put-query-definition \
--region "${REGION}" \
--name "${queryApiRegionName}" \
--query-string "${queryString}" \
--log-group-names "${TRAIL_LOG_GROUP}"
fi
# ---- Investigation Query for S3 Accesslog ----
# 1) (S3アクセスログ取得)Resource S3 Bucket AccessLog
queryString="$(cat <<EOF
fields @timestamp, eventName, userIdentity.arn, requestParameters.bucketName, requestParameters.key
| filter eventSource = "s3.amazonaws.com"
| filter eventCategory = "Data"
| filter requestParameters.bucketName = "${RESOURCE_S3_KEY}$(altAwsIdStr 12)"
| sort @timestamp desc
EOF
)"
queryExists="$(aws logs describe-query-definitions \
--region "${REGION}" \
--query-definition-name-prefix "${queryResourceS3AccessName}" \
--query 'queryDefinitions[?name==`'"${queryResourceS3AccessName}"'`].queryDefinitionId' \
--output text 2>/dev/null || true)"
if [[ -n "${queryExists:-}" && "${queryExists}" != "None" ]]; then
info "Updating existing query definition: ${queryResourceS3AccessName} ($queryExists)"
exec aws logs put-query-definition \
--region "${REGION}" \
--query-definition-id "${queryExists}" \
--name "${queryResourceS3AccessName}" \
--query-string "${queryString}" \
--log-group-names "${TRAIL_LOG_GROUP}"
else
info "Creating new query definition: ${queryResourceS3AccessName}"
exec aws logs put-query-definition \
--region "${REGION}" \
--name "${queryResourceS3AccessName}" \
--query-string "${queryString}" \
--log-group-names "${TRAIL_LOG_GROUP}"
fi
# --- Check ---
iErr=0
queryExists=$(aws logs describe-query-definitions --region "${REGION}" --query-definition-name-prefix "${queryRootName}" --query 'queryDefinitions[].name' | jq -r '.[]')
if [ -z "${queryExists}" ];then
abort "Metrics alarm ${queryRootName} is missing."
((iErr++))
fi
queryExists=$(aws logs describe-query-definitions --region "${REGION}" --query-definition-name-prefix "${queryApiRegionName}" --query 'queryDefinitions[].name' | jq -r '.[]')
if [ -z "${queryExists}" ];then
abort "Metrics alarm ${queryApiRegionName} is missing."
((iErr++))
fi
queryExists=$(aws logs describe-query-definitions --region "${REGION}" --query-definition-name-prefix "${queryResourceS3AccessName}" --query 'queryDefinitions[].name' | jq -r '.[]')
if [ -z "${queryExists}" ];then
abort "Metrics alarm ${queryResourceS3AccessName} is missing."
((iErr++))
fi
if [ ${iErr} -gt 0 ]; then
abort "Process aborted."
fi
info "Process succeeded."
exit 0
