Amazon Web Services ブログ

AWS アカウントの運営上のベストプラクティスの監査を自動化する方法

マイクロサービスアーキテクチャでは、他の組織が運営上のベストプラクティスに従っていることを確認するために、セントラルオペレーショナルエクセレンスチームを必要とする場合が多くあります。

例えば、Amazon S3 バケット内のオブジェクトのライフサイクルポリシー、バージョニング、およびアクセスポリシーが適切に設定されたかどうかを知りたい場合があります。適切な構成においては、所望の保存および削除ポリシーを確実に得ることができ、Amazon S3 オブジェクトの偶発的な共有を回避します。

これと同様に、チームがテーブル内で Amazon DynamoDB auto scaling を有効にしているかについて知りたい場合もあります。これにより、トラフィックの増加をシームレスに処理できるようにスループット容量 (読み出しと書き込みの容量単位) が増加し、ワークロードが減少するとスループット能力が低下します。この scaling は、適切な量のプロビジョニングした容量分を支払うことを意味します。最後に、効率的かつ自動化された応答のために、Amazon CloudWatch アラームを DynamoDB テーブル (または他の AWS リソース) への設定完了を確認することをお勧めします。

AWS は Amazon CloudWatch 、AWS CloudTrailAWS Config 、および AWS Trusted Advisor などのサービスを提供し、運営監査を可能にします。このブログ記事では、さまざまな AWS services により提供される AWS Lambda と API を使用して、運営上のベストプラクティスの監査を自動化する方法について説明します。

ソリューションの概要

この記事では、 Lambda 関数の AWS Identity and Access Management (IAM) ロールを作成し、DynamoDB API を使用して、異なるルールの DynamoDB テーブルとインデックスを確認します。また、設定がこれらのルールのいずれかに違反していないかどうかを知らせるための自動通知を設定します。このソリューションを拡張してルールを追加したり、また運営上のベストプラクティスのために AWS services を監査するソリューションを変更することが可能です。この記事のコードには、下記の操作が示されています。

  • DynamoDB テーブルの現在の AWS アカウント制限を確認する。
  • 総プロビジョニングしたスループット – テーブルとグローバルセカンダリインデックス (GSI) を計算し、AWS アカウント制限の x % よりも大きい場合は警告する。
  • 各テーブルのプロビジョニングしたスループットを計算し、それがアカウントテーブルの最大制限の x % を超えている場合は警告する。
  • 各 GSI のプロビジョニングしたスループットを確認し、テーブルのスループットよりも x % 少ない場合は警告する。
  • CloudWatch よって記録された以下の DynamoDB メトリックの CloudWatch アラーム、つまり ConsumedReadCapacityUnitsConsumedWriteCapacityUnitsReadThrottleEventsWriteThrottleEvents 、および ThrottledRequests に対して CloudWatchアラームを構成したかどうかを確認する。
  • 警告の数を合算し、必要に応じてそれらの警告をカスタムメトリックとして CloudWatch に書き込みする。

Lambda 関数の IAM ロールを作成する

開始するには、IAM ロールを作成し、既存または新しいカスタムポリシーに添付して、下記のポリシーに示すように dynamodb および cloudwatch の権限を付与します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "dynamodb:DescribeLimits",
                "dynamodb:ListTables",
                "dynamodb:DescribeTable",
                "cloudwatch:putMetricData",
                "cloudwatch:DescribeAlarmHistory",
                "cloudwatch:DescribeAlarms",
                "cloudwatch:DescribeAlarmsForMetric",
   "logs:*"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ]
}

Lambda 関数の作成と設定

次に、Lambda 関数を作成して設定します。

  1. Lambda コンソールで、[ 関数の作成 ] を選択します。
  2. [ 関数の作成 ] ステップで、[Author from scratch] を選択します。
  3. [Basic information] で次の情報を追加します。
    • Name : AWSAccountAudit
    • Runtime : Python 3.6
    • Role : 既存のロールを選択
    • 既存のロール : 前述のセクションで作成した IAM ロールを選択
  4. [関数の作成] を選択します。
  5. [Function code] で、次の操作を行います。
    • Code entry typeRuntimeHandler をデフォルト値のままとします。
    • テキストボックスの既存のコードを前のコードブロックに置き換えます。
      import boto3
      import json
      import logging
      from datetime import datetime
      
      def lambda_handler(event, context):
      
          settings = Settings()
      
          #Set region
          region = 'us-east-1'
          if settings.region:
              region = settings.region
      
          #Init Clients
          ddbClient = boto3.client('dynamodb', region_name=ddbRegion)
          cloudWatchClient = boto3.client('cloudwatch', region_name=region)
      
          #New account object
          account = Account()
      
          #Get account limits
          account_limits(dynamoDBClient, account)
      
          #Get details for all tables in account
          account_tables(dynamoDBClient, cloudWatchClient, account, settings)
      
          #Analyse account object, print report and generate number of warnings
          warnings = generateReport(account, settings)
      
          #Add warnings as custom cloud watch metric if enabled in settings
          if(settings.addCustomMetric == 1):
              addWarningAsCustomCloudWatchMetric(cloudWatchClient, settings, warnings)
      
          #Print warning count
          message = str(warnings) + ' warning(s) found.View Log output below or go to CloudWatch log group to see detailed report.'
      
          return message
      
      ##############################################
      #Add warning count as custom cloud watch metric
      def addWarningAsCustomCloudWatchMetric(cloudWatchClient, settings, warnings):
          #Add custom metric to CloudWatch if enabled in settings
          cloudWatchClient.put_metric_data(
          Namespace=settings.customMetricNamespace,
          MetricData=[
              {
                  'MetricName': settings.customMetricName,
                  'Value': warnings,
                  },
                  ]
              )
      
      ##############################################
      # Analyse account and generate report
      def generateReport(account, settings):
          warnings = 0
      
          print('DynamoDB analysis report...')
          #Print Limits and Provisioned Capacity
          print('- TableMaxReadCapacityUnits=%s,\t TableMaxWriteCapacityUnits=%s' % (account.tableRcuLimit , account.tableRcuLimit))
          print('- AccountMaxReadCapacityUnits=%s,\t AccountMaxWriteCapacityUnits=%s' % (account.accountRcuLimit, account.accountWcuLimit))
          print('- ProvisionedRcu=%s,\t ProvisionedWcu=%s' % (account.provisionedRcu, account.provisionedWcu))
      
          #Warn on RCU account limit alert
          if(account.provisionedRcu >= (settings.accountLimitAlertLevel/100.0)*account.accountRcuLimit):
              print('\tRCU Limit Alert! Current provisioned RCU (%s) is %s %% of current account limit of %s.You have set warning threshold to %s%%.' % \
              (account.provisionedRcu, (100.0*account.provisionedRcu/account.accountRcuLimit), account.accountRcuLimit, settings.accountLimitAlertLevel))
              warnings += 1
      
          #Warn on WCU account limit alert
          if(account.provisionedWcu >= (settings.accountLimitAlertLevel/100.0)*account.accountWcuLimit):
              print('\tWCU Limit Alert! Current provisioned WCU (%s) is %s %% of current account limit of %s.You have set warning threshold to %s%%.' % \
              (account.provisionedWcu, (100.0*account.provisionedWcu/account.accountWcuLimit), account.accountWcuLimit, settings.accountLimitAlertLevel))
              warnings += 1
      
          for table in account.tables:
              print('- Table=%s, Rcu=%s, Wcu=%s' % (table.name, table.rcu, table.wcu))
      
              #Table RCU Warnings
              if(table.totalRcu >= (settings.accountLimitAlertLevel/100.0)*account.tableRcuLimit):
                  print('\tRCU Limit Alert! Current provisioned RCU (%s) of table is %s %% of current table-max account limit of %s.You set warning threshold to %s%%.' % \
                  (table.totalRcu, (100.0*table.totalRcu/account.tableRcuLimit), account.tableRcuLimit, settings.accountLimitAlertLevel))
                  warnings += 1
      
              #Table WCU Warnings
              if(table.totalWcu >= (settings.accountLimitAlertLevel/100.0)*account.tableWcuLimit):
                  print('\tWCU Limit Alert! Current provisioned WCU (%s) of table is %s %% of current table-max account limit of %s.You set warning threshold to %s%%.' % \
                  (table.totalWcu, (100.0*table.totalWcu/account.tableWcuLimit), account.tableWcuLimit, settings.accountLimitAlertLevel))
                  warnings += 1
      
              #GSI Warnings
              for gsi in table.gsi:
                  if(gsi.gsiWarning == 1):
                      print('\tGSI Warning! GSI %s has Wcu=%s while table has Wcu=%s.If there is not enough Wcu provisioned for GSI it can throttle table.' % \
                      (gsi.name, gsi.wcu, table.wcu))
                      warnings += 1
      
              #CloudWatch Alarm Warnings
              for cwm in table.cloudWatchMetrics:
                  if(len(cwm.cloudWatchAlarms) == 0):
                      print('\tCloudWatch Warning! %s has no CloudWatch Alarms configured.' % cwm.name)
                      warnings += 1
                  else:
                      for cwa in cwm.cloudWatchAlarms:
                          if(cwa.hasActionsEnabled == 0):
                              print('\tCloudWatch Warning! alarm %s for metric %s has no action configured.' % (cwa.name, cwm.name))
                              warnings += 1
      
          return warnings
      
      ##############################################
      #Get limits
      def account_limits(client, account):
          response = client.describe_limits()
          account.tableRcuLimit = response.get('TableMaxReadCapacityUnits')
          account.tableWcuLimit  = response.get('TableMaxWriteCapacityUnits')
          account.accountRcuLimit = response.get('AccountMaxReadCapacityUnits')
          account.accountWcuLimit = response.get('AccountMaxWriteCapacityUnits')
      
      ##############################################
      #Get tables
      def account_tables(client, cloudWatchClient, account, settings):
          response = client.list_tables()
          tables = response.get('TableNames')
      
          for table in tables:
              odt = DynamoDBTable()
              analyse_table(client, cloudWatchClient, table, odt, settings)
      
              account.provisionedRcu += odt.totalRcu
              account.provisionedWcu += odt.totalWcu
      
              account.tables.append(odt)
      
      ##############################################
      #Analyse tables and nested objects of each table
      def analyse_table(client, cloudWatchClient, tableName, table, settings):
          response = client.describe_table(TableName=tableName)
          json.dumps(response, default=date_handler)
          table.name = tableName
          table.totalRcu = table.rcu = response['Table']['ProvisionedThroughput']['ReadCapacityUnits']
          table.totalWcu = table.wcu = response['Table']['ProvisionedThroughput']['WriteCapacityUnits']
          if('GlobalSecondaryIndexes' in response['Table']):
              gsis = response['Table']['GlobalSecondaryIndexes']
              for gsi in gsis:
                  ogsi = DynamoDBGsi()
                  ogsi.name = gsi['IndexName']
                  ogsi.rcu = gsi['ProvisionedThroughput']['ReadCapacityUnits']
                  ogsi.wcu = gsi['ProvisionedThroughput']['WriteCapacityUnits']
                  table.totalRcu += ogsi.rcu
                  table.totalWcu += ogsi.wcu
                  if(ogsi.wcu != table.wcu and ogsi.wcu < ((settings.gsiThroughputAlertLevel/100.0)*table.wcu)):
                      ogsi.gsiWarning = 1
                      ogsi.gsiWarningDiff = table.wcu - ogsi.wcu
                      table.gsiWarning = 1
                  table.gsi.append(ogsi)
      
          checkCloudWatchAlarmsForTable(table, cloudWatchClient)
      
      ##############################################
      #Check CloudWatchAlarms for Table
      def checkCloudWatchAlarmsForTable(table, cloudWatchClient):
          checkCloudWatchAlarmForMetric(cloudWatchClient, table, 'ConsumedReadCapacityUnits')
          checkCloudWatchAlarmForMetric(cloudWatchClient, table, 'ConsumedWriteCapacityUnits')
          checkCloudWatchAlarmForMetric(cloudWatchClient, table, 'ReadThrottleEvents')
          checkCloudWatchAlarmForMetric(cloudWatchClient, table, 'WriteThrottleEvents')
          checkCloudWatchAlarmForMetric(cloudWatchClient, table, 'ThrottledRequests')
      
      #Check CloudWatchAlarms for each metric
      def checkCloudWatchAlarmForMetric(client, table, metricName):
          response = client.describe_alarms_for_metric(
          Namespace='AWS/DynamoDB',
          MetricName = metricName,
          Dimensions=[
              {
                  'Name': 'tablename',
                  'Value': table.name
              },
              ]
          )
      
          cloudWatchMetric = CloudWatchMetric()
          cloudWatchMetric.name = metricName
      
          metricAlarm = response.get('MetricAlarms')
          metricAlarmCount = len(metricAlarm)
      
          #Check if each alarm is properly configured
          if(metricAlarmCount > 0) :
              for m in range(0, metricAlarmCount) :
                  cwAlarm = CloudWatchAlarm()
                  cwAlarm.name = response.get('MetricAlarms')[m].get('AlarmName')
                  if(response.get('MetricAlarms')[m].get('ActionsEnabled') == True):
                      cwAlarm.hasActionsEnabled = 1
                  cloudWatchMetric.cloudWatchAlarms.append(cwAlarm);
      
          table.cloudWatchMetrics.append(cloudWatchMetric)
      
      ##############################################
      def date_handler(obj):
          if hasattr(obj, 'isoformat'):
              return obj.isoformat()
          else:
              raise TypeError
      
      ############ Class definitions #################
      
      class Account:
          tableRcuLimit = 0
          tableWcuLimit = 0
          accountRcuLimit = 0
          accountWcuLimit = 0
          provisionedRcu = 0
          provisionedWcu = 0
      
          def __init__(self):
              self.tables = []
      
      class DynamoDBTable:
          name = ''
          rcu = 0
          wcu = 0
          totalRcu = 0
          totalWcu = 0
          gsiWarning = 0
      
          def __init__(self):
              self.gsi = []
              self.cloudWatchMetrics = []
      
      class CloudWatchMetric:
          name = ''
      
          def __init__(self):
              self.cloudWatchAlarms = []
      
      class CloudWatchAlarm:
          name = ''
          hasActionsEnabled = 0
      
      class DynamoDBGsi:
          name = ''
          rcu = 0
          wcu = 0
          gsiWarning = 0
          gsiWarningDiff = 0
      
      class Settings:
          #AWS region
          region = 'us-west-2'
          #Generate warning if provisioned throughput is more than x% of account limit
          accountLimitAlertLevel = 50
          #Generate warning GSI has throughput less than x% of the table's throughput
          gsiThroughputAlertLevel = 50
          #Add warnings to CloudWatch as custom metric
          addCustomMetric = 0
          customMetricNamespace = 'kashif'
          customMetricName = 'DynamoDBAuditWarnings'
  6. [Basic settings] で、次の項目を更新する。
    • [Memory (MB) ] : デフォルト設定のままにする (128)
    • [Timeout] : 5 分
  7. [Save] 選択して変更を保存する。
  8. [Configuration] および [Add triggers] で、使用可能なオプションのリストから [CloudWatch Events] を選択する。
  9. [Rule][Create a new rule] を選択し、次の情報を入力する。
    • [Rule name] : AWS-DynamoDB-Daily-Audit
    • [Rule description] : 運営上のベストプラクティスのための DynamoDB テーブルの日常監査
    • [Rule type] : スケジュール表示
    • [Schedule expression] :レート (1 日)。AWS アカウントの希望の監査頻度に応じて、異なる頻度を選択できます。
  10. [Add] を選択して、 Lambda 関数のトリガを追加する。
  11. [Test] を選択し、[Configure test events][Create new test event] および [Hello World Event Template] のままとする。イベント名である [AWSAccountAuditTest] を入力し、[Create] 作成を選択する。
  12. [Test] を選択して Lambda 関数を実行する。

[Execution result] で、このスクリプトの結果である警告の数が表示されます。[Log output] で出力内容の詳細を表示できます。大量の警告がある場合、すべての警告が [Log output] に表示されないことがあります。この機能の完全な出力結果を得るには、[Click here] のリンクを選択し、[Log output] の下にある CloudWatch ロググループを表示します。Lambda 関数のコードには、警告を生成する AWS リージョンや閾値など、さまざまなパラメータを指定できます。次のコードブロックは、これらのパラメータを提供する方法を示しています。

#AWS Region
region = 'us-west-2'

#Generate warning if provisioned throughput is more than x% of account limit
accountLimitAlertLevel = 50
#Generate warning that GSI has throughput less than x% of the table's throughput
gsiThroughputAlertLevel = 50
#Add warnings to CloudWatch as custom metric
addCustomMetric = 0
customMetricNamespace = 'kashii'
customMetricName = 'DynamoDBAuditWarnings'

まとめ

DynamoDB テーブルの監査を自動化することにより、運営上のベストプラクティスに従っていることを確認したり、自動化されたモニタリングと通知を実行することができます。

また、いつでもルールを追加することが可能です。例えば、各テーブルからいくつかのアイテムをランダムに読み込んで、テーブル内の各アイテムの大きさを理解することができます。非常に大きなアイテムが格納されている場合、 Amazon S3 を使用して大きなオブジェクトを格納することを監査では推奨できます。この監査では、DynamoDB を使用して主要なメタデータを格納し、Amazon S3 のオブジェクトへのリンクも推奨する場合があります。他の AWS services 用の API を使用して、この記事に示す Lambda 関数を拡張し、AWS services の運営上のベストプラクティスに従っていることを確認すること可能です。


著者について

Josh Kahn 氏は、Amazon Web Services のソリューションアーキテクトです。同氏は AWS の顧客と協力し技術指導や設計アドバイスを提供しています。また、同氏の専門知識は、アプリケーションアーキテクチャー、サーバーレス、コンテナ、NoSQL 、機械学習にまで多岐にわたります。