ヘヴィメタル・エンジニアリング

AWS特化型エンジニアのほのぼのヘヴィメタルブログ

ヘヴィメタル・エンジニアリング

クラウド特化型ヘヴィメタルエンジニアのほのぼのブログ

ECS内のインスタンスを自動ドレイニングするシステム構築 ~ ECS を使う上で外せないステータス 、ドレイニング~

はじめに

よくAWSに触れるものです。

AWSにはElastic Container Service(以下ECS)と呼ばれるフルマネージド型のコンテナオーケストレーションサービスがあります。

ECSはEC2インスタンス or Fargate上にコンテナをスケーラブルに配置、管理、監視できるサービスで、比較的扱いやすいサービスです。

そんなECSですがコンテナのライフサイクルやEC2を使う場合は配置、サービス(ECS特有の用語)によるタスクスケーリングなどはそれなりの知識が必要なのも事実です。

今回はそんなコンテナのライフサイクルに関連して、ドレイニングについて解説 & そのドレイニングを用いた安全なインスタンスの終了のさせ方を解説していきたいと思います。

ドレイニングについて

EC2のライフサイクルは複数あります。

こちらの公式ドキュメントに図が載っています。

docs.aws.amazon.com

これ以外にもECS側でのEC2のステータスがあります。それがDraining(ドレイニング)です。

Auto ScalingでScale Inすると、ドレイニング状態のインスタンスは、クラスター内でそのインスタンスが持つタスクを他のキャパシティに余裕があるインスタンス内に移動させます。

キャパシティはCPUとMemoryどちらとも収まる範囲で算出されます。

以下に図を示します。

f:id:xkenshirou:20210108010151p:plain

ステータスがStoppedにならずにタスクが移動するので、コネクションは途絶えずにダウンタイムは生じません。

一方、ドレイニング状態にないままAuto ScalingでScale Inすると、以下の図ではランダムに選ばれたインスタンスがStopped状態になり、内部のタスクは削除されます。

f:id:xkenshirou:20210108010555p:plain

ドレイニング状態とは違い、タスクはそのまま削除されたのでコネクションは消えダウンタイムが生じる可能性があります。

ちなみに、Scale Inの際に削除を希望しないインスタンスを残す場合はAuto Scaliningの設定からインスタンスの削除保護を選択することで可能です。

f:id:xkenshirou:20210108011439p:plain

Auto Scalingの設定画面からインスタンス管理を選択、削除を希望しないインスタンスを指定してスケールイン保護の設定を選択します。

これで指定したインスタンスはScale Inの対象から外れ削除されません。

以下からドレイニングの手段について解説します。

ドレイニング手段

手動

インスタンスを手動でドレイニングする方法はとても簡単です。

ECSコンソールからClusterを選択し、ECSインスタンスを選択してアクションからドレイニングを指定するだけです。

f:id:xkenshirou:20210109080502p:plain

指定のインスタンスがドレイニング状態になったことが確認できたらインスタンスを削除してみましょう。

f:id:xkenshirou:20210125192037p:plain

するとインスタンス内のタスクが他のキャパシティに余裕のあるインスタンスに移ることがわかります。

ただ手動のやり方だと、スケールインする際に毎回手動のオペレーションが増え、よきせぬスケールインには対応できません。

そのため今回は自動でドレイニングを走らせるシステムを構築したのでご紹介します。

自動

自動でドレイニングするシステムを構築します。

今回作成するリソースは以下です。

  • SNS Topic
  • SNS Subscription
  • Lambda Function
  • LambdaExecutionRole
  • Lambda Permission
  • Auto Scaling Lifecycle Hook
  • Auto Scaling Lifecycle HookのIAM

流れとしては、Auto ScalingによってScale In →アクティビティ通知 -> SNS -> Lambda です。

ここでポイントとなるのはLifecycle Hookです。

Life cycle HookはAuto Scalingによってインスタンスが起動、削除される際にその実行を待ってカスタムアクションを追加することができます。

今回でいうと、Scale Inの際にインスタンスが削除されますが、それがHookとなり、指定のパラメータ通りの動きをします。

後ほど詳しく説明します。

以下が実際のテンプレートです。

Parameters:
  EcsClusterName:
    Type: String
    Description:
      Enter the target ECS cluster name.
  EcsInstanceAsg:
    Type: String
    Description:
      Enter the AutoScalingGroup name.

Resources:
  SNSLambdaRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Effect: "Allow"
            Principal:
              Service:
                - "autoscaling.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AutoScalingNotificationAccessRole
      Path: "/"
  LambdaExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      Policies:
        -
          PolicyName: "lambda-inline"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                - autoscaling:CompleteLifecycleAction
                - logs:CreateLogGroup
                - logs:CreateLogStream
                - logs:PutLogEvents
                - ecs:ListContainerInstances
                - ecs:DescribeContainerInstances
                - ecs:UpdateContainerInstancesState
                - sns:Publish
                Resource: "*"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AutoScalingNotificationAccessRole
      Path: "/"
  ASGSNSTopic:
    Type: "AWS::SNS::Topic"
    Properties:
      Subscription:
        -
          Endpoint:
             Fn::GetAtt:
                - "LambdaFunctionForASG"
                - "Arn"
          Protocol: "lambda"
    DependsOn: "LambdaFunctionForASG"
  LambdaFunctionForASG:
    Type: "AWS::Lambda::Function"
    Properties:
      Description: Gracefully drain ECS tasks from EC2 instances before the instances are
                   terminated by autoscaling.
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: python3.6
      MemorySize: 128
      Timeout: 60
      Code:
        ZipFile: !Sub |
            import xxx
            import xxx
            <ここにpythonの処理を書いていきます>
  LambdaInvokePermission:
    Type: "AWS::Lambda::Permission"
    Properties:
       FunctionName: !Ref LambdaFunctionForASG
       Action: lambda:InvokeFunction
       Principal: "sns.amazonaws.com"
       SourceArn: !Ref ASGSNSTopic
  LambdaSubscriptionToSNSTopic:
    Type: AWS::SNS::Subscription
    Properties:
       Endpoint:
          Fn::GetAtt:
             - "LambdaFunctionForASG"
             - "Arn"
       Protocol: 'lambda'
       TopicArn: !Ref ASGSNSTopic
  ASGTerminateHook:
    Type: "AWS::AutoScaling::LifecycleHook"
    Properties:
      AutoScalingGroupName: !Ref EcsInstanceAsg
      DefaultResult: "ABANDON"
      HeartbeatTimeout: "900"
      LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING"
      NotificationTargetARN: !Ref ASGSNSTopic
      RoleARN:
         Fn::GetAtt:
         - "SNSLambdaRole"
         - "Arn"
    DependsOn: "ASGSNSTopic"

上記でlifecycle HookではHeartbeatTimeoutDefaultResultの設定が肝です。

HeartbeatTimeoutはLifecycle Hookが動いてから経過した時間を指し、その後DefaultResultを実行します。

なので上記ではAuto ScalingでScale Inが走り、インスタンス削除処理がHookになり、そのHookが起こってから900秒立ってもLifecycle Hookが完了しないならABANDON、つまりインスタンスは強制的に削除されます。

このHookがないと、例えば、他のインスタンスにタスクが移るときにポート被りやリソース不足で移動できないとずっとインスタンスがドレイニング状態のままインスタンスが残り続けることになります。

なので、一定期間立ったら強制的に削除するようにして、強制終了したことをslackなどに通知するといいでしょう。

次にLambdaにおける処理を見てみましょう。

import json
import time
import boto3
CLUSTER = '${EcsClusterName}'
REGION = '${AWS::Region}'
ECS = boto3.client('ecs', region_name=REGION)
ASG = boto3.client('autoscaling', region_name=REGION)
SNS = boto3.client('sns', region_name=REGION)
def find_ecs_instance_info(instance_id):
    paginator = ECS.get_paginator('list_container_instances')
    for list_resp in paginator.paginate(cluster=CLUSTER):
        arns = list_resp['containerInstanceArns']
        desc_resp = ECS.describe_container_instances(cluster=CLUSTER,containerInstances=arns)
        for container_instance in desc_resp['containerInstances']:
            if container_instance['ec2InstanceId'] != instance_id:
                continue
            print('Found instance: id=%s, arn=%s, status=%s, runningTasksCount=%s' %
                  (instance_id, container_instance['containerInstanceArn'],
                    container_instance['status'], container_instance['runningTasksCount']))
            return (container_instance['containerInstanceArn'],
                    container_instance['status'], container_instance['runningTasksCount'])
    return None, None, 0
def instance_has_running_tasks(instance_id):
    (instance_arn, container_status, running_tasks) = find_ecs_instance_info(instance_id)
    if instance_arn is None:
        print('Could not find instance ID %s. Letting autoscaling kill the instance.' %
              (instance_id))
        return False
    if container_status != 'DRAINING':
        print('Setting container instance %s (%s) to DRAINING' %
              (instance_id, instance_arn))
        ECS.update_container_instances_state(cluster=CLUSTER,
                                              containerInstances=[instance_arn],
                                              status='DRAINING')
    return running_tasks > 0
def lambda_handler(event, context):
    msg = json.loads(event['Records'][0]['Sns']['Message'])
    if 'LifecycleTransition' not in msg.keys() or \
        msg['LifecycleTransition'].find('autoscaling:EC2_INSTANCE_TERMINATING') == -1:
        print('Exiting since the lifecycle transition is not EC2_INSTANCE_TERMINATING.')
        return
    if instance_has_running_tasks(msg['EC2InstanceId']):
        print('Tasks are still running on instance %s; posting msg to SNS topic %s' %
              (msg['EC2InstanceId'], event['Records'][0]['Sns']['TopicArn']))
        time.sleep(5)
        sns_resp = SNS.publish(TopicArn=event['Records'][0]['Sns']['TopicArn'],
                                Message=json.dumps(msg),
                                Subject='Publishing SNS msg to invoke Lambda again.')
        print('Posted msg %s to SNS topic.' % (sns_resp['MessageId']))
    else:
        print('No tasks are running on instance %s; setting lifecycle to complete' %
              (msg['EC2InstanceId']))
        ASG.complete_lifecycle_action(LifecycleHookName=msg['LifecycleHookName'],
                                      AutoScalingGroupName=msg['AutoScalingGroupName'],
                                      LifecycleActionResult='CONTINUE',
                                      InstanceId=msg['EC2InstanceId'])

find_ecs_instance_infoはECS上でのインスタンスの状態を検知してくれます。

ECS上のインスタンスすべての情報を取り、そのjson値からstatusを返します。

instance_has_running_tasksインスタンス内にコンテナが走っている場合はインスタンスのステータスをドレイニング状態にします。

最後にlambda_handlerの中でmainの処理を実行します。

インスタンス内のSNS経由のメッセージで情報を受け取りインスタンス内のコンテナが0になったときに待機状態にあったlifecycle Hookを完了します。

以上のテンプレートをCloudformationで流すことで自動ドレイニングシステムは作成されます。

1つ手作業があり、Scale Inが走った際にSNSを通してLambda実行するためのアクティビティ通知を設定しないといけません。

f:id:xkenshirou:20210110133312p:plain

AWSコンソールでAuto Scaling Groupを選択して、アクティビティ通知の作成を選択、「終了」アクションが起こったときに指定のSNSに通知するように設定します。

以上でシステムの構築は完了です。

実際にScale Inをしてみて、任意のインスタンスがドレイニング状態になり安全にインスタンスが削除されるか確認しましょう。

終わりに

今回はECSのインスタンスのライフサイクルに関することを書きました。

ECSのインスタンスリソース調整やタスク配置は奥が深いので、今度はそちらについても書こうかなと思います。

まだEC2にSSHしてるの?System ManagerのSession Managerを使って楽に安全にアクセスしよう!

はじめに

よくAWSに触れるものです。

皆さんAWSのEC2インスタンスにアクセスするときはどのようにされていますか?

おそらくキーペアを作成して、それをインスタンスに紐付け、その秘密鍵をもとにSSHしていると思います。

ベーシックで簡単なやり方ですが、そもそも秘密鍵を持つとセキュリティ的に良くないですし、アクセス制御をSecurity Groupに入れていたりすると結構面倒だったりします。

そんな時、とても楽な方法がSession Managerです。

今回はそちらを紹介します!

Session Managerとは

Session Managerとは、EC2インスタンスAWSのSystem Managerを経由してアクセスする方法です。

以下にメリットとデメリットをあげます。

・メリット
- ローカルに認証情報をもつ必要がない
- aws cliを1発叩くだけでアクセスできる
- コンソールからもアクセスできる

・デメリット
- なし

といった感じでデメリットは無いのでぜひ使ってみましょう!

デモ

簡単に2つの使い方を見てみましょう。

aws cli

以下のコマンドを叩くだけです。

$ aws ssm start-session --target <INSTANCE_ID>

Starting session with SessionId: xxxxxxxxxxxx
>
>

これだけでアクセスできます。

コンソール

まずSystem Managerの画面を開きます。

f:id:xkenshirou:20210104190859p:plain

次にセッションの開始をクリックして、指定のインスタンスをクリックするだけで... f:id:xkenshirou:20210104191141p:plain

f:id:xkenshirou:20210104191236p:plain

新しいタブが開いてインスタンスにアクセスできました!

次にこのSession Managerを導入方法を説明します。

導入方法

Session Managerを使うには条件が2つあります。

  1. AmazonSSMManagedInstanceCoreと同等のポリシーがEC2インスタンスロールに付与されている。
  2. ssm-agentのバージョンが2.3.68.0 以降であること

です。

今回はAmazon LinuxAmazon Linux 2への導入方法について解説します。

両者のAMIを使ってインスタンスを作成した時、ssm-agentはインストールされているとドキュメントに書いてありますが、実際にはされていません。
(検証した結果、agentはインストール、起動されていませんでした。もしインストールされているディレクトリを知っている方がいらっしゃったら教えていただきたいです。)

ただ比較的新しめのAmazo Linux 2のAMIを使用しているとssm-agentが起動しているので、IAMロールの設定のみでSession Managerが使えます。

もしssm-agentが動いていない場合は以下を参照してインストールしてください。 ssm-agentのstatus確認方法も以下にあります。

docs.aws.amazon.com

Auto Scalingを使っている際はUserDataにインストールコマンドを入れましょう。

UserDataのCloudformationスタックのサンプルです。

LaunchTemplate:
    Type: "AWS::EC2::LaunchTemplate"
    Properties:
      LaunchTemplateData:
        UserData:
          Fn::Base64:
            sudo yum install -y https://s3.ap-northeast-1.amazonaws.com/amazon-ssm-ap-northeast-1/latest/linux_amd64/amazon-ssm-agent.rpm

これで準備は完了です。

使い方

実際に使う際はコマンドでできるのが一番楽でしょう。

まずはローカルにSystem Manager Pluginをインストールします。

インストーラーをインストールします。

$ curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac/sessionmanager-bundle.zip" -o "sessionmanager-bundle.zip"

unzipします。

$ unzip sessionmanager-bundle.zip
$ sudo ./sessionmanager-bundle/install -i /usr/local/sessionmanagerplugin -b /usr/local/bin/session-manager-plugin

環境は整いました。

私は普段、以下の2コマンドを使っています。

インスタンスを探す。

$ aws ec2 describe-instances --query "Reservations[*].Instances[*].[InstanceId,Tags[?Key=='Name'].Value]" --output=yaml
- - - i-0d3xxxxxxxxxxxxxxx
    - - foo
- - - i-042xxxxxxxxxxxxxxx
    - - bar

Session Managerを使ってアクセスする、

$ aws ssm start-session --target <INSTANCE_ID>

Starting session with SessionId: xxxxxxxxxx
>
>

この2コマンドを使うだけで、AWSコンソールにログインをすることなくEC2インスタンスにアクセスできます。

終わりに

デバックなどの際はいちいちAWSコンソールに入ったり、ローカルに秘密鍵を保持したりする必要のないやり方ですが、メリットしか無いので、ぜひ使ってみてください。

AWSのセキュリティにはConfigとGuardDutyが外せない!

はじめに

よくAWSにふれるものです。

 以前、弊社のブログでそちらについて詳しく書きましたが、そちらで書ききれなかったことを加えて改めて執筆します。

 AWSはマネージドサービスも多く比較的容易に多くのシステムを構築することができますが、作成したリソースが多くなったり、開発者が多くなるほど、セキュリティチェックが曖昧になりがちなので、予期せぬセキュリティホールを作らないように運用する必要があります。

 そんな時にはAWS Config(以下Config)とGuardDutyが役に立ちます。

 今回はその特徴を捉え、実践的、応用的な使い方について紹介していきたいと思います。

ConfigとGuardDutyについて

仕様

 両者ともにS3, IAM, EC2を始めとしたリソースに対するセキュリティチェックを行いますが、詳しくは以下のような違いがあります。

Config
- 多くのAWSサービスのセキュリティチェックに対応している
- 事前にチェックする項目を選択できる
- セキュリティチェックをカスタマイズできる
- セキュリティチェックにかかったリソースを自動修復することができる

GuardDuty
- EC2, IAM, S3へのチェック
- CloudTrailログ、VPCフローログ、DNSログからチェック
- セキュリティチェックは事前に用意されているものが自動で働く
- セキュリティチェックのカスタマイズはできない

 基本的に Config = AWSリソースの設定ミス を検知してくれるもの、GuardDuty = 潜在的なセキュリティ脅威(バックドア、ポートスキャン、マルウェア) を検知するものという認識で良いと思います。
 ちなみに一部のセキュリティチェックは両者で被っているものもあるので、除外するといいでしょう。
 両者の詳細な機能に関してはドキュメントに書いてあるので、省きます。

料金について

 詳しくはドキュメントを見ることをおすすめします。

Configの料金

aws.amazon.com

GuardDutyの料金

aws.amazon.com

 ここでは両者の金額を抑えるポイントを書きたいと思います。
   まずConfigに関してですが、主な料金は設定項目(リソースの設定や依存関係に変更のあった際に記録されるもの)、とルール評価です。
 なので無駄に設定項目の範囲を広げないこと、ルールを作りすぎないことがポイントになります。
 私はCloudFormationでConfigを作成しますが、以下のようにAllSupported = falseとしResourceTypeを指定することで、対象のリソースを絞り、設定項目の数を減らすことができます。(対象のリソースを絞らない場合はAllSupported = true)

 ConfigRecorder:
   Type: AWS::Config::ConfigurationRecorder
   DependsOn:
     - ConfigRecorderRole
     - ConfigBucketPolicy
   Properties:
     Name: configuration-recorder
     RecordingGroup:
       AllSupported: false
       IncludeGlobalResourceTypes: false
       ResourceTypes:
         - AWS::EC2::Host
         - AWS::EC2::Instance
         - AWS::EC2::NetworkInterface
         - AWS::EC2::SecurityGroup
         - AWS::S3::Bucket
         - AWS::S3::AccountPublicAccessBlock
         - AWS::RDS::DBInstance
         - AWS::RDS::DBSecurityGroup
         - AWS::RDS::DBSnapshot
         - AWS::RDS::DBCluster
         - AWS::RDS::DBClusterSnapshot
         - AWS::IAM::User
         - AWS::IAM::Group
         - AWS::IAM::Role
         - AWS::IAM::Policy
         - AWS::ACM::Certificate
         - AWS::CloudTrail::Trail
         - AWS::Lambda::Function
         - AWS::Config::ResourceCompliance
     RoleARN: !GetAtt ConfigRecorderRole.Arn

 

 ルールに関しては以下ドキュメントにあるようにかなりの種類があるので、必要なものだけpick upすることで料金を最小限に抑えることができます。

・マネージドルール一覧

docs.aws.amazon.com

 一方、GuardDutyに関してはVPCフローログの有無、CloudTrailのデータ収集範囲が大きく関わります。特に後者に関しては注意が必要で、CloudTrailで証跡を作成した際にデータイベントを有効にしている場合、そのS3バケットへのGetObject, PutObject, DeleteObjectアクションが多い場合、相当な数のイベントが発生します。そのイベント単位でGuardDutyに料金が加算されていくので、導入する際はまずCloudTrailの証跡でデータイベントが有効になっているものがあるかのチェックをしましょう。

Config

 細かい使い方についてはドキュメントに書いてあるので省きます。
 ここでは大きく、実践的、応用的な使い方について書いていこうと思います。

実践

リソース作成

 CloudFormationで作成します。
 必要なリソースは以下です。
AWS::Config::ConfigurationRecorder
AWS::Config::AggregationAuthorization
AWS::Config::DeliveryChannel
AWS::Config::ConfigRule
AWS::S3::Bucket
AWS::S3::BucketPolicy
AWS::IAM::Role

 ここでポイントとなることが1点。後述することに繋がりますが、AWS::Config::AggregationAuthorizationを作成することで親アカウントへConfig結果を共有することができます。そうすることで、親アカウントで子アカウントの結果を包括的に管理、監視することができます。

リソース例は以下(IAM、S3は省きます)

 ConfigRecorder:
   Type: AWS::Config::ConfigurationRecorder
   DependsOn:
     - ConfigRecorderRole
     - ConfigBucketPolicy
   Properties:
     Name: configuration-recorder
     RecordingGroup:
       AllSupported: false
       IncludeGlobalResourceTypes: false
       ResourceTypes:
         - AWS::EC2::Host
         - AWS::EC2::Instance
         - AWS::EC2::NetworkInterface
         - AWS::EC2::SecurityGroup
         - AWS::S3::Bucket
         - AWS::S3::AccountPublicAccessBlock
         - AWS::RDS::DBInstance
         - AWS::RDS::DBSecurityGroup
         - AWS::RDS::DBSnapshot
         - AWS::RDS::DBCluster
         - AWS::RDS::DBClusterSnapshot
         - AWS::IAM::User
         - AWS::IAM::Group
         - AWS::IAM::Role
         - AWS::IAM::Policy
         - AWS::ACM::Certificate
         - AWS::CloudTrail::Trail
         - AWS::Lambda::Function
         - AWS::Config::ResourceCompliance
     RoleARN: !GetAtt ConfigRecorderRole.Arn
 AggregationAuthorization:
   Type: AWS::Config::AggregationAuthorization
   Properties:
     AuthorizedAccountId: !Ref AuthorizedAccountId
     AuthorizedAwsRegion: !Ref AuthorizedAwsRegion
 DeliveryChannel:
   Type: AWS::Config::DeliveryChannel
   DependsOn:
     - ConfigBucketPolicy
   Properties:
     ConfigSnapshotDeliveryProperties:
       DeliveryFrequency: "Six_Hours"
     S3BucketName: !Ref ConfigBucket
 
 RootAccountMFAEnabled:
   Type: AWS::Config::ConfigRule
   DependsOn:
     - ConfigRecorder
   Properties:
     ConfigRuleName: RootAccountMFAEnabled
     Description: Checks whether the root user of your AWS account requires multi-factor authentication for console sign-in.
     Source:
       Owner: AWS
       SourceIdentifier: ROOT_ACCOUNT_MFA_ENABLED
     MaximumExecutionFrequency: TwentyFour_Hours
 IAMRootAccessKeyCheck:
   Type: AWS::Config::ConfigRule
   DependsOn:
     - ConfigRecorder
   Properties:
     ConfigRuleName: IAMRootAccessKeyCheck
     Description: Checks whether the root user access key is available. The rule is compliant if the user access key does not exist.
     Source:
       Owner: AWS
       SourceIdentifier: IAM_ROOT_ACCESS_KEY_CHECK
     MaximumExecutionFrequency: TwentyFour_Hours

ルールは適宜追加しましょう。

aggregator

 個人利用であればAWSアカウントを複数管理することは少ないでしょうが、企業利用だとそうはいきません。その際に有効な手段がaggregatorです。それにより全管理アカウントのConfigルール評価結果を包括的に見ることができます。
 作成はコンソールで行い、親子アカウントの連携は先程のCloudFormationテンプレート作成の際に説明したので省略します。
 実際に優位な点は集約ビューにて全アカウントの結果を包括的に見ることができるだけではありません。高度なクエリ機能を使うとConfig結果に対してSQLを用いることができ、より詳細な結果の検索も可能になります。

 以下は高度なクエリページでSQLを用いた例です。アカウントIDと、評価結果が非準拠(セキュリティに問題がある評価)である条件を指定してリソースが検索できました。
 この機能はAPIとしても提供されているので外部システムに組み込むのも容易です。

f:id:xkenshirou:20201212010953p:plain

応用

カスタムルールの利用

 Configルールにはマネージドルール、カスタムルールの2つがあります。前者はデフォルトでConfigに用意されているルールで、後者はLambdaで自作するルールです。
 カスタムルールを作成することで基本的にはどんなリソースでもチェックすることができます。
 Configはマネージドルールで指定したリソースタイプ (AWS::EC2::Instance、 AWS::S3::Bucketとか) のリソースすべてを評価します。ただ例えばS3BucketPublicReadProhibited (パブリックからの読み取りを有効にしているS3バケットをチェックする) のようなルールだと、サービスによってはそのルールに準拠しないS3バケットを作成、使用する用途もあるため、特定のリソースをルール評価から除外したいというケースは容易に想定できます。
 ただルールでは特定のリソースを除外することはできず、対象のリソースタイプのリソースをすべて評価します。そうなったときには自分でカスタムルールを作成するしかありません。

 このようにマネージドルールだけではできない処理をカスタムルールで作成する方法を書いていこうと思います。

 今回は上記の例をカスタムルールで作成します。
 マネージドルールに機能を付け足すような実装をするため、AWSが提供している以下のようなソースコードを利用します。

・Configマネージドルールのソース

github.com

 今回作成したいルールに対応するマネージドルールはS3BucketPublicReadProhibited なので、同じものを使いたいですがありません。そこまで頻繁にコミットのあるリポジトリではないので、動作が合致するソースが無いということもよくあります。なので、似ているソースを編集して意図したルールを作成しましょう。

 今回はこちらを参考にします。

github.com

 結構なソースのボリュームに見えますが、注目するアクションは2つです。
- evaluate_compliance
- lambda_handler

 上記アクションに追記していきます。

 evaluate_complianceは以下のように修正しました。

def evaluate_compliance(event, configuration_item, valid_rule_parameters):
   client = get_client('s3', event)
   bucketName = json.loads(event['invokingEvent'])['configurationItem']['resourceId']
   evaluations = []
  
   ## Block public access
   try:
       publicAccessBlock = client.get_public_access_block(
           Bucket=bucketName
       )
      
       if (publicAccessBlock['PublicAccessBlockConfiguration']['BlockPublicAcls'] == True) \
       and (publicAccessBlock['PublicAccessBlockConfiguration']['IgnorePublicAcls'] == True) \
       and (publicAccessBlock['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == True) \
       and (publicAccessBlock['PublicAccessBlockConfiguration']['RestrictPublicBuckets'] == True):
           evaluations.append(build_evaluation(bucketName, 'COMPLIANT', event))
           return evaluations
      
   except:
       print('The public access block configuration was not found')
    
   ## Block public access for Acl 
   publicAccessBlockAcl = client.get_bucket_acl(
       Bucket=bucketName
   )
  
   for grant in publicAccessBlockAcl['Grants']:
       if 'URI' in grant['Grantee']:
           if grant['Grantee']['URI'] == 'http://acs.amazonaws.com/groups/global/AllUsers' \
           and grant['Permission'] == 'FULL_CONTROL'\
           or grant['Grantee']['URI'] == 'http://acs.amazonaws.com/groups/global/AllUsers'\
           and grant['Permission'] == 'READ':
               evaluations.append(build_evaluation(bucketName, 'NON_COMPLIANT', event))
               return evaluations
  
   ## Block public access for bucket policy
   try:
       bucketPolicyList = client.get_bucket_policy(
           Bucket=bucketName
       )
       for bucketPolicy in json.loads(bucketPolicyList['Policy'])['Statement']:
           if bucketPolicy['Principal'] == '*':
               if 'Condition' in bucketPolicy:
                   if '0.0.0.0/0' in bucketPolicy['Condition']['IpAddress']['aws:SourceIp']:
                       evaluations.append(build_evaluation(bucketName, 'NON_COMPLIANT', event))
                       return evaluations
               else:
                   evaluations.append(build_evaluation(bucketName, 'NON_COMPLIANT', event))
                   return evaluations
   except:
       print('The bucket policy does not exist')
  
   evaluations.append(build_evaluation(bucketName, 'COMPLIANT', event))
   return evaluations

 こちらはConfigがリソースを評価するアクションですが、S3に対して下記の3点をチェックするような処理を追加しています。

  1. Block Public Access
  2. ACL
  3. Bucket Policy

 1でパブリックアクセスをブロックしていればConfigルール評価では準拠になるようにしています。

   ## Block public access
   try:
       publicAccessBlock = client.get_public_access_block(
           Bucket=bucketName
       )
      
       if (publicAccessBlock['PublicAccessBlockConfiguration']['BlockPublicAcls'] == True) \
       and (publicAccessBlock['PublicAccessBlockConfiguration']['IgnorePublicAcls'] == True) \
       and (publicAccessBlock['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == True) \
       and (publicAccessBlock['PublicAccessBlockConfiguration']['RestrictPublicBuckets'] == True):
           evaluations.append(build_evaluation(bucketName, 'COMPLIANT', event))
           return evaluations
      
   except:
       print('The public access block configuration was not found')

 1で公開になっていたとしても、パブリックアクセスはできないので、2、3のチェックをします。2、3のどちらかで許可しているとパブリックアクセスが可能になります。

 以下が2のACLで許可される場合の例です

f:id:xkenshirou:20201212011117p:plain

 以下が3のバケットポリシーで許可される場合の例です。

f:id:xkenshirou:20201212011144p:plain

 評価処理はマネージドルールのS3BucketPublicReadProhibitedと同じものが再現できました。今回の肝は特定のリソースを除外する処理を追加することなので、そちらをlambda_handlerアクションに追加していきます。

 既存のlambda_handlerの最後に以下のような処理を追加しました。

   ## Exclude AWS resource have an exclusion tag.
   AWS_S3_CLIENT = get_client('s3', event)
   try:
       evaluationResourceTags = AWS_S3_CLIENT.get_bucket_tagging(
           Bucket=evaluation_copy[0]['ComplianceResourceId']
       )
          
       for tag in evaluationResourceTags['TagSet']:
           if tag['Key'] == 'excludeConfigRule' and tag['Value'] == 'S3BucketPublicReadProhibited':
               return evaluations
      
   except:
       print("This bucket has no tags.")
  
   while evaluation_copy:
       AWS_CONFIG_CLIENT.put_evaluations(Evaluations=evaluation_copy[:100], ResultToken=result_token, TestMode=test_mode)
       del evaluation_copy[:100]
 
   # Used solely for RDK test to be able to test Lambda function
   return evaluations

 S3バケットにタグを複数つけることができますが、そのうちexcludeConfigRuleというkeyを持ち、指定している特定のルール名がvalueでついていたら、後半のwhileループ内で行われているConfigに評価結果を送る処理へ通さないようにします。

 大まかには以上の二点を追記してやることで今回の目的のカスタムルールの処理が完成します。あとはこちらの処理を実行するLambdaを作成して、そのLambdaを使うカスタムルールをCloudFormationで作成します。

 ## custom rule
 S3BucketPublicReadProhibited:
   Type: AWS::Config::ConfigRule
   DependsOn:
     - ConfigPermissionToCallLambda
   Properties:
     ConfigRuleName: S3BucketPublicReadProhibited
     Description: Checks that your Amazon S3 buckets do not allow public read access.
       The rule checks the Block Public Access settings, the bucket policy, and the
       bucket access control list (ACL).
     Scope:
       ComplianceResourceTypes:
       - "AWS::S3::Bucket"
     Source:
       Owner: "CUSTOM_LAMBDA"
       SourceDetails:
         - EventSource: "aws.config"
           MessageType: "ScheduledNotification"
           MaximumExecutionFrequency: TwentyFour_Hours
         - EventSource: "aws.config"
           MessageType: "ConfigurationItemChangeNotification"
 ## lambda functionのArn
       SourceIdentifier: !GetAtt ConfigRuleFunctionForS3BucketPublicReadProhibited.Arn

 以上で目的のカスタムルールの完成です。実際にConfig上で評価ができているかの確認をしましょう。

 以下のようなテストバケットを作成しておきます。

f:id:xkenshirou:20201212011233p:plain

 作成したルールで評価すると、作成したバケットには特にタグは付けていないので、評価されています。

f:id:xkenshirou:20201212011252p:plain

 そこで以下のようにバケットにタグを付けてみます。

f:id:xkenshirou:20201212011317p:plain

 一度、Config結果をコンソール上のアクションから結果の削除を選択して削除しましょう。その後アクションの再評価を押すと

f:id:xkenshirou:20201212011340p:plain

 先程評価されていたexclusion-samplae-bucketが消えています。完璧に処理が動いてることが分かりました。

GuardDuty

実行

 続いてGuardDutyについて説明していこうと思います。  GuadDutyの始め方はとても簡単!

f:id:xkenshirou:20201212011913p:plain

f:id:xkenshirou:20201212012029p:plain

 GuadDutyを有効にするをクリックするだけです。

 以下のように、EC2、IAM、S3に対して自動セキュリティチェックが行われ、アラートが出てきます。

f:id:xkenshirou:20201212012547p:plain

 各アラートの解決の仕方は公式のドキュメントがあるので、そちらを参照してください。

・セキュリティ脅威一覧
docs.aws.amazon.com

 GuardDutyも親子アカウントで結果を包括してみることができるので、親アカウントで集計の設定をすることをおすすめします。

f:id:xkenshirou:20201212013301p:plain

監視

 GuardDutyの結果をいちいちコンソールに入って見に行くのは面倒です。なので、こちらにはSNS経由でChatBotを通してslack通知しましょう。

 ChatBotに関しては本ブログでも紹介していますので、こちらを御覧ください。 xkenshirou.hatenablog.com

 以下のように、GuardDutyでセキュリティ脅威が取得されたら検知するCloudwatch Eventを作成しましょう。

Parameters:
  SnsTopicArn:
    Type: String
    Description: arn:aws:sns:ap-northeast-1:xxx:chatbot-topic

Resources:
  GuarddutyEventRule:
    Type: AWS::Events::Rule
    Properties:
      Name: GuarddutyNotificationEvent
      Description: GuarddutyNotificationEvent
      EventPattern:
        source:
          - aws.guardduty
        detail-type:
          - GuardDuty Finding
      State: ENABLED
      Targets:
        - Arn: !Ref SnsTopicArn
          Id: GuarddutynotificationTopic

上記のEventを受け取ってSlack通知するSNSとChatbotを作成します。

Parameters:
  TargetWorkspaceId:
    Type: String
  TargetChannelId:
    Type: String

Resources:
  ChatbotIamRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: chatbot-iam-role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: chatbot.amazonaws.com
            Action: sts:AssumeRole
          - Effect: Allow
            Principal:
              Service: management.chatbot.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: chatbot-iam-policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - cloudwatch:Describe*
                  - cloudwatch:Get*
                  - cloudwatch:List*
                  - sns:Get*
                  - sns:List*
                  - sns:Unsubscribe
                  - sns:Subscribe
                  - logs:Get*
                  - logs:List*
                  - logs:Describe*
                  - logs:TestMetricFilter
                  - logs:FilterLogEvents
                Resource:
                  - "*"

  ChatbotTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: chatbot-topic

  ChatbotSlack:
    Type: AWS::Chatbot::SlackChannelConfiguration
    Properties: 
      ConfigurationName: slack-channel-configuration
      IamRoleArn: !GetAtt ChatbotIamRole.Arn
      LoggingLevel: INFO
      SlackChannelId: !Ref TargetChannelId
      SlackWorkspaceId: !Ref TargetWorkspaceId
      SnsTopicArns: 
        - !Ref ChatbotTopic

これで以下のようにSlackで通知が来るようになりました。

f:id:xkenshirou:20201212014615p:plain

デフォルトでセキュリティリスクの危険度としては以下です。

青 -> 低
橙 -> 中
赤 -> 高

これでセキュリティ監視ができるようになりました。

まとめ

 ConfigもGuardDutyも導入コストが低く、効果が大きいのでおすすめなサービスです。

 セキュリティ管理や監視は範囲が広ければ広いほど人が実行するのが難しく、今回紹介したConfigとGuarddutyを導入することでそういった範囲を自動でカバーできるので、エンジニアの負担も減らしつつセキュリティリスクを少なくすることができるので、とても有益でした。