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階に止まります。
5階のボタンを10回押しても、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バケットを作成し、使うことにします。

スクリプトの実行方法

「小規模利用(シングルアカウント)向け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 0

create_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 0

create_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


免責事項

Follow me!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です