タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

ApplicationLoadBalancer で Basic認証

まとめ

  • リスナールールで Authorization ヘッダーをチェックする
  • Lambda で WWW-Authenticate: Basic を返す
  • アプリケーションの実装なしに簡易認証できて便利

事の起こり

  • ALB をフロントに置いたWebサイト
  • 全体に認証をかけたい
  • サーバー側コード等の変更は加えない

というわけで、ALB側の工夫でBasic認証をかけることにしました。

もう少し本格的な認証がほしい場合は Cognito User Pool などと組み合わせたOIDC認証機能が便利です。 今回は利用側のリテラシ的にも要件的にもBasic認証で必要十分だったため、Basic認証を使う方法を検討しました。

ALB設定

ALBはHTTPヘッダーなどをもとに、ルールベースで柔軟な応答を組み立てることができます。

今回は

  • 認証OKかどうか Authorization ヘッダーで確認し、OKなら通す
  • 認証NGの場合は Lambda ファンクションを使い、WWW-Authenticate: Basic を返す

方法にしました。 固定レスポンスで WWW-Authenticate: Basic を返せれば Lambda は不要だったのですが、現状無理なようなので、このようになりました。

実際の画面

まず、実際の画面を見てください。

f:id:uzuki05:20200406232707p:plain
Basic認証を要求

f:id:uzuki05:20200406232736p:plain
Basic認証NG

f:id:uzuki05:20200406232753p:plain
Basic認証OK

認証OKかどうか Authorization ヘッダーで確認し、OKなら通す

CloudFormationでは Field: http-header で HTTPヘッダーのルールを書けます。 次の例では Parameters で与えた Username Password を連結したものをBase64エンコードしたものと、Authorization HTTP ヘッダーの値が一致するとき、固定レスポンスとして成功を返すものです。 実際の利用ではEC2インスタンスやECSなどへ forward することになると思います。

  LoadBalancerListenerAuthorizedRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
      - Type: fixed-response
        FixedResponseConfig:
          StatusCode: 200
          MessageBody: Authorized
          ContentType: text/plain
      Conditions:
      - Field: http-header
        HttpHeaderConfig:
          HttpHeaderName: Authorization
          Values:
            - Fn::Join:
                - " "
                - - "Basic"
                  - Fn::Base64: !Sub ${Username}:${Password}
      ListenerArn: !Ref LoadBalancerListener
      Priority: 10

認証NGの場合は Lambda ファンクションを使い、WWW-Authenticate: Basic を返す

上記の LoadBalancerListenerAuthorizedRule よりも優先度の低い( Priority の値が大きい)ルールで Lambda ファンクションへ転送します。 今回は簡単のためデフォルトのアクション(いずれのルールにもマッチしなかった場合)を使いました。

この Lambda ファンクションは単に 401WWW-Authenticate: Basic を返すだけです。 ALBから実行できるように、 Principal: elasticloadbalancing.amazonaws.com な権限をつけます。

  LoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn:
            !Ref LoadBalancerTargetGroupUnauthorized
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP

# DefaultActions を使わない場合は例えば次のようなルールを使う
#  LoadBalancerListenerUnauthorizedRule:
#    Type: AWS::ElasticLoadBalancingV2::ListenerRule
#    Properties:
#      Actions:
#      - Type: forward
#        TargetGroupArn:
#          !Ref LoadBalancerTargetGroupUnauthorized
#      ListenerArn: !Ref LoadBalancerListener
#      Priority: 20

LoadBalancerTargetGroupUnauthorized:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      TargetType: lambda
      Targets:
        - Id: !GetAtt LambdaSendUnauthrized.Arn
    DependsOn:
      - LambdaPermissionSendUnauthrized
  LambdaSendUnauthrized:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |+
          const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); }

          exports.handler = async (event, context) => {
            console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event));

            return {
                statusCode: 401,
                statusDescription: '401 Unauthorized',
                body: 'Unauthorized',
                isBase64Encoded: false,
                headers: {
                    'WWW-Authenticate': 'Basic',
                    'Content-Type': 'text/html'
                }
            };
          };
      Handler: index.handler
      Role: !GetAtt LambdaSendUnauthrizedExecutionRole.Arn
      Runtime: "nodejs10.x"
      MemorySize: 128
      Timeout: 15
  LambdaSendUnauthrizedExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  LambdaPermissionSendUnauthrized:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt LambdaSendUnauthrized.Arn
      Principal: elasticloadbalancing.amazonaws.com

CloudFormation テンプレート

Parameters:
  Username:
    Default: ""
    Type: String
  Password:
    Default: ""
    Type: String

Mappings:
  SubnetConfig:
    VPC:
      CidrBlock: 10.0.0.0/16
    Public1:
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: us-east-1a
    Public2:
      CidrBlock: 10.0.1.0/24
      AvailabilityZone:  us-east-1c

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsSupport: true
      EnableDnsHostnames: true
      CidrBlock: !FindInMap [SubnetConfig, VPC, CidrBlock]
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public1", "CidrBlock"]
      AvailabilityZone: !FindInMap ["SubnetConfig", "Public1", "AvailabilityZone"]
  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public2", "CidrBlock"]
      AvailabilityZone: !FindInMap ["SubnetConfig", "Public2", "AvailabilityZone"]
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachement:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: GatewayAttachement
    Properties:
      RouteTableId: !Ref "PublicRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref "InternetGateway"
  PublicSubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable
  PublicSubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  LoadBalancerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Access to the LoadBalancer from Internet
      VpcId: !Ref "VPC"
  LoadBalancerSecurityGroupIngressHTTP:
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties:
      Description: HTTP
      GroupId: !Ref LoadBalancerSecurityGroup
      CidrIp: 0.0.0.0/0
      FromPort: 80
      ToPort: 80
      IpProtocol: tcp

  LoadBalancer:
    Type: 'AWS::ElasticLoadBalancingV2::LoadBalancer'
    Properties:
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      SecurityGroups:
        - !Ref LoadBalancerSecurityGroup

  LoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn:
            !Ref LoadBalancerTargetGroupUnauthorized
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
  LoadBalancerListenerAuthorizedRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
      - Type: fixed-response
        FixedResponseConfig:
          StatusCode: 200
          MessageBody: Authorized
          ContentType: text/plain
      Conditions:
      - Field: http-header
        HttpHeaderConfig:
          HttpHeaderName: Authorization
          Values:
            - Fn::Join:
                - " "
                - - "Basic"
                  - Fn::Base64: !Sub ${Username}:${Password}
      ListenerArn: !Ref LoadBalancerListener
      Priority: 10

  LoadBalancerTargetGroupUnauthorized:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      TargetType: lambda
      Targets:
        - Id: !GetAtt LambdaSendUnauthrized.Arn
    DependsOn:
      - LambdaPermissionSendUnauthrized
  LambdaSendUnauthrized:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |+
          const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); }

          exports.handler = async (event, context) => {
            console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event));

            return {
                statusCode: 401,
                statusDescription: '401 Unauthorized',
                body: 'Unauthorized',
                isBase64Encoded: false,
                headers: {
                    'WWW-Authenticate': 'Basic',
                    'Content-Type': 'text/html'
                }
            };
          };
      Handler: index.handler
      Role: !GetAtt LambdaSendUnauthrizedExecutionRole.Arn
      Runtime: "nodejs10.x"
      MemorySize: 128
      Timeout: 15
  LambdaSendUnauthrizedExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  LambdaPermissionSendUnauthrized:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt LambdaSendUnauthrized.Arn
      Principal: elasticloadbalancing.amazonaws.com

Outputs:
  LoadBalancerDNSName:
    Value: !GetAtt LoadBalancer.DNSName

特定のActionで読み込み専用のレプリカを使う

ActiveRecord::Base.connected_to で包む

class PostsController < ApplicationController
  around_action :set_reading_role, only: %i[index show]

  def index
    @posts = Post.all # :reading
  end

  def show
    @post = Post.find(params[:id])
    @post.touch # => ActiveRecord::ReadOnlyError (Write query attempted while in readonly mode: UPDATE posts SET posts.updated_at = '2020-04-07 00:00:00' WHERE posts.id = 1):
  end

  private

  def set_reading_role
    ActiveRecord::Base.connected_to(role: :reading) do
      yield
    end
  end
end

DSL風に

class PostsController < ApplicationController
  set_default_connect_role :reading, only: %i[index show]

  def index
    @posts = Post.all # :reading
  end

  def show
    @post = Post.find(params[:id])
    @post.touch # => ActiveRecord::ReadOnlyError (Write query attempted while in readonly mode: UPDATE posts SET posts.updated_at = '2020-04-07 00:00:00' WHERE posts.id = 1):
  end
end
class ApplicationController < ActionController::Base
  include DefaultConnectRole
end
module DefaultConnectRole
  extend ActiveSupport::Concern

  class_methods do
    def set_default_connect_role(role, only: nil, except: nil)
      if only.nil? && except.nil?
        around_action wrap_connected_to(role)
      elsif only.present?
        around_action wrap_connected_to(role), only: only
      elsif except.present?
        around_action wrap_connected_to(role), except: except
      else
        raise ArgumentError.new("need any options")
      end
    end

    private

    def wrap_connected_to(role)
      lambda do |controller, block|
        with_connected_to(role, &block)
      end
    end
  end

  private

  def with_connected_to(role, &block)
    ActiveRecord::Base.connected_to(role: role) do
      yield
    end
  end
end

ACMでCAAエラーが発生したとき

まとめ

  • CAAレコードが1つでも指定されている場合、指定にない認証局によるSSLサーバ証明書発行は失敗する
  • CAAレコードに amazon.com が必要

事の起こり

AWS Certificate Manager を使い、クライアント指定のドメインサブドメインAmazon 発行のSSLサーバ証明書を発行しようとしたところ、 CAAエラーで失敗しました。

f:id:uzuki05:20200403113144p:plain
CAAエラー

CAAとは?

jp.globalsign.com

dev.classmethod.jp

CAAレコードを確認

$ dig caa xxxxxxx.jp +noedns
;; QUESTION SECTION:
;xxxxxxx.jp.              IN      CAA

;; ANSWER SECTION:
xxxxxxx.jp.       3497    IN      CAA     0 issue "letsencrypt.org"

ドメインに Let's Encrypt のみ許可するCAAレコードが設定されていました。

Amazonで発行する場合、追加でCAAレコードを設定する必要がありました。

お客様の DNS 設定に CAA レコードが存在する場合、Amazonドメインに証明書を発行する前に、そのレコードが amazon.com、amazontrust.com、awstrust.com、または amazonaws.com のいずれかの CA を指定している必要があります。

aws.amazon.com

Amazonを許可する設定を追加

(オプション) CAA レコードの設定

yyy.xxxxxxx.jp. IN CAA 0 issue "amazon.com"

f:id:uzuki05:20200403114131p:plain
Route53でのCAA設定

CodePipeline のアクションで AWS Lambda を実行する

まとめ

  • CodePipeline のアクションとして AWS Lambda を実行すれば、いろいろなことができる
    • たとえばRailsのdb:migrateをデプロイ時に自動設定したり、ECSスケジュールタスクを登録したり
  • Lambdaのタスクロールを適切に指定すればAWSアクセスキーなしにAWS SDKが使えて便利
  • CDKを使えばLambdaファンクションのソースコードのバージョン管理も捗る

f:id:uzuki05:20200331232500p:plain
CodePipeline実行の様子

事の起こり

弊社受託案件のうちいくつかは Rails + AWS CodePipeline + ECS による継続的デリバリ環境で稼働しています。

AWS CodePipeline は処理内容と、それどういった順番で実行するかを定義することで、柔軟なリリース自動化が可能です。

処理内容はたとえば、

  • Gitからソースコードをチェックアウトする
  • CodeBuildを使ってDockerイメージのビルドをしてECRにpushする
  • ECSのローリングアップデートを行う

などです。

今回このパイプラインのなかで、

  • Rails の db:migrate したい
  • ECS のスケジュールタスクを更新したい

することにしました。

作業メモ

ECSのコンテナを使って db:migrate するには?

ECSのRunTaskを使います。

  1. Railsの動くアプリ用コンテナを用意する
  2. ワンオフ実行用のタスク定義を作る
  3. たとえばアプリサーバー用タスクであればコマンドを bundle exec rails -s するところ、ワンオフ実行用のタスクでは bundle exec rails -v するだけにする、Nginxなどのサイドカーはつけない、など。
  4. ワンオフ実行用のタスク定義を使って、 RunTask実行。
  5. コマンドのオーバーライドを使って実行するコマンドを指定し、 bundle exec rails -v を上書きする。
  6. タスクロールを、実行する操作に必要な権限を持ったIAMロールで上書きする
    • たとえば「ECS のスケジュールタスクを更新」するなら、ECSの設定を変更する権限が必要

CodePipeline にアクションの成功/失敗を伝える必要がある

CodePipelineのアクションとしてLambaファンクションを指定した場合、実行するLambdaファンクションでは、CodePipelineに対してアクションの実行が成功したか?それとも失敗したか?を伝える必要があります。

これは、Lambdaファンクションの入力イベントにCodePipelineのジョブIDを含むので、それを使って CodePipeline の PutJobSuccessResultPutJobFailureResult を実行します。

Lambdaファンクション

関数ハンドラ

たとえば、次のようなコードになります。

クラスタ名やタスク定義名はLambdaファンクションの環境変数で渡しています。CodePipelineのアクションを作成する際に、実行するLambdaファンクションと、そこに渡す環境変数を指定できます。

# functions/migrate_database/handler.rb
require "json"
require "aws-sdk-codepipeline"
require "aws-sdk-ecs"

def handler(event:, context:)
  puts "event: #{event.inspect}"
  puts "context: #{context.inspect}"

  codepipeline = Aws::CodePipeline::Client.new
  ecs = Aws::ECS::Client.new
  task_params = {
    cluster: ENV['CLUSTER_NAME'],
    count: 1,
    task_definition: ENV['TASK_DEFINITION'],
    launch_type: "FARGATE",
    network_configuration: {
      awsvpc_configuration: {
        subnets: [ENV['VPC_SUBNET_ID']],
        security_groups: [ENV['VPC_SG']],
        assign_public_ip: "DISABLED",
      },
    },
    overrides: {
      task_role_arn: ENV['TASK_ROLE_ARN'],
      container_overrides: [
        {
          name: 'rails',
          command: [
            "bundle",
            "exec",
            "rails",
            "db:create",
            "db:migrate",
            "db:version"
          ],
        },
      ]
    }
  }
  puts "task_params: #{task_params.inspect}"
  resp = ecs.run_task(task_params)
  puts "resp: #{resp.inspect}"
  ecs.wait_until(:tasks_running, cluster: ENV['CLUSTER_NAME'], tasks: [resp.tasks[0].task_arn])
  puts "complete"
  codepipeline.put_job_success_result({job_id: event["CodePipeline.job"]['id']})

  { statusCode: 200, body: JSON.dump(resp.tasks[0]) }
rescue => e
  message = "#{e.class.name} (#{e.message})"
  puts message
  codepipeline.put_job_failure_result({
    job_id: event["CodePipeline.job"]['id'],
    failure_details: {
      type: "JobFailed",
      message: message,
    }
  })

  { statusCode: 501, body: JSON.dump({error: message}) }
end
# functions/migrate_database/Gemfile

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'aws-sdk'

タスクロール

上記コードの task_role_arn: ENV['TASK_ROLE_ARN'], で与えているIAMロールで、LambdaファンクションからAWS SDKを通じて実行した ECSタスクに割り当てられるロールです。 Lambdaファンクションの実行ロールとは別です。

タスクロールはもともとのワンオフ実行用タスク定義で指定しているものだが、実行するECSタスクが何らかのAWSリソースに対する操作を行う場合、それに必要な権限を割り当てたIAMロールで上書きします。

ECSタスクに割り当てるロールなので、 ecs-tasks.amazonaws.com

    const deployElasticWheneverTaskRole = new iam.Role(
      this,
      "deployElasticWheneverTaskRole",
      {
        assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),  // ECSタスクで引き受けるので
        managedPolicies: [
          // たとえばECSタスク中で自身のクラスタを含むECSのフルアクセスを与えるならこんな
          iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonECS_FullAccess")
        ]
      }
    );
    deployElasticWheneverTaskRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverTaskRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: [
              "iam:AttachRolePolicy",
              "iam:CreateRole",
              "iam:GetRole",
              "iam:PassRole",
              "events:ListRules"
            ],
            resources: ["*"]
          })
        ]
      })
    );

   // 省略
          lambda: new lambda.Function(this, "deployElasticWheneverFunction", {
          runtime: lambda.Runtime.RUBY_2_5,
          handler: "handler.handler",
          code: new lambda.AssetCode("./functions/deploy_elastic_whenever"),
          role: deployElasticWheneverFunctionRole,
          environment: {
            CLUSTER_NAME: ecsCluster.clusterName,
            TASK_DEFINITION: oneoffTaskDefinition.family,
            TASK_ROLE_ARN: deployElasticWheneverTaskRole.roleArn,  // ここで環境変数に渡している
            CONTAINER_NAME: "app",
            VPC_SUBNET_ID: vpc.privateSubnets[0].subnetId,
            VPC_SG: vpc.vpcDefaultSecurityGroup,
            REGION: cdk.Aws.REGION
          },
          timeout: cdk.Duration.seconds(900)
        })

ファンクションロール

Lambdaファンクションの実行に割り当てられるロールです。 紛らわしいですが、タスクロールは「ファンクションから実行したECSタスク」に割り当てられるロールであり、こちらは「ECSタスクを実行するファンクション」に割り当てるロールです。 今回は、ECSタスクを実行したいので ecs:DescribeTasks ecs:RunTask を許可してやる必要があります。

例えば次のような感じになります。

    const deployElasticWheneverFunctionRole = new iam.Role(
      this,
      "deployElasticWheneverFunctionRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),  // lambdaファンクションで引き受けるので
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute")
        ]
      }
    );
    deployElasticWheneverFunctionRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverFunctionRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["iam:PassRole", "ecs:DescribeTasks", "ecs:RunTask"], // ECSタスクの操作
            resources: ["*"]
          })
        ]
      })
    );

CodePipeline

抜粋します。

    // 入力は別途イメージビルド用のパイプラインでCodeCommitにpushした imagedefinitions.json
    // CodeCommit への push をトリガーにパイプラインを開始
    const deploySourceOutput = new codepipeline.Artifact();
    const deploySourceAction = new codepipeline_actions.CodeCommitSourceAction({
      actionName: "CodeCommit",
      repository: imageDefinitionsRepository,
      output: deploySourceOutput
    });

    // 各ECSサービスを更新するアクションを定義
    // まずアクションを定義して、最後にどんな順序で実行するか?を指定する。
    const deployOneoffAction = new codepipeline_actions.EcsDeployAction({
      actionName: "OneoffECS",
      service: oneoffEcsService,
      imageFile: new codepipeline.ArtifactPath(
        deploySourceOutput,
        "imagedefinitions.oneoff.json"  // ビルド用のパイプラインでCodeCommitにpushしたものを使う
      )
    });
    const deployAppAction = new codepipeline_actions.EcsDeployAction({
      actionName: "AppECS",
      service: service.service,
      imageFile: new codepipeline.ArtifactPath(
        deploySourceOutput,
        "imagedefinitions.app.json"
      ),
      runOrder: 1
    });
    const deployShoryukenAction = new codepipeline_actions.EcsDeployAction({
      actionName: "ShoryukenECS",
      service: shoryukenEcsService,
      imageFile: new codepipeline.ArtifactPath(
        deploySourceOutput,
        "imagedefinitions.shoryuken.json"
      ),
      runOrder: 1
    });

    const migrateDatabaseFunctionRole = new iam.Role(
      this,
      "migrateDatabaseFunctionRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute")
        ]
      }
    );
    migrateDatabaseFunctionRole.attachInlinePolicy(
      new iam.Policy(this, "migrateDatabaseFunctionRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["iam:PassRole", "ecs:DescribeTasks", "ecs:RunTask"],
            resources: ["*"]
          })
        ]
      })
    );
    const migrateAction = new codepipeline_actions.LambdaInvokeAction({
      actionName: "migrateDatabase",
      userParameters: {},
      lambda: new lambda.Function(this, "migrateDatabaseFunction", {
        runtime: lambda.Runtime.RUBY_2_5,
        handler: "handler.handler",
        code: new lambda.AssetCode("./functions/migrate_database"),
        role: migrateDatabaseFunctionRole,
        environment: {
          CLUSTER_NAME: ecsCluster.clusterName,
          TASK_DEFINITION: oneoffTaskDefinition.family,
          VPC_SUBNET_ID: vpc.privateSubnets[0].subnetId,
          VPC_SG: vpc.vpcDefaultSecurityGroup
        },
        timeout: cdk.Duration.seconds(900)
      })
    });

    const deployElasticWheneverFunctionRole = new iam.Role(
      this,
      "deployElasticWheneverFunctionRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute")
        ]
      }
    );
    deployElasticWheneverFunctionRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverFunctionRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["iam:PassRole", "ecs:DescribeTasks", "ecs:RunTask"],
            resources: ["*"]
          })
        ]
      })
    );
    const deployElasticWheneverTaskRole = new iam.Role(
      this,
      "deployElasticWheneverTaskRole",
      {
        assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com")
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonECS_FullAccess")
        ]
      }
    );
    deployElasticWheneverTaskRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverTaskRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: [
              "iam:AttachRolePolicy",
              "iam:CreateRole",
              "iam:GetRole",
              "iam:PassRole",
              "events:ListRules"
            ],
            resources: ["*"]
          })
        ]
      })
    );
    const deployElasticWheneverAction = new codepipeline_actions.LambdaInvokeAction(
      {
        actionName: "deployElasticWhenever",
        userParameters: {},
        lambda: new lambda.Function(this, "deployElasticWheneverFunction", {
          runtime: lambda.Runtime.RUBY_2_5,
          handler: "handler.handler",
          code: new lambda.AssetCode("./functions/deploy_elastic_whenever"),
          role: deployElasticWheneverFunctionRole,
          environment: {
            CLUSTER_NAME: ecsCluster.clusterName,
            TASK_DEFINITION: oneoffTaskDefinition.family,
            TASK_ROLE_ARN: deployElasticWheneverTaskRole.roleArn,
            CONTAINER_NAME: "app",
            VPC_SUBNET_ID: vpc.privateSubnets[0].subnetId,
            VPC_SG: vpc.vpcDefaultSecurityGroup,
            REGION: cdk.Aws.REGION
          },
          timeout: cdk.Duration.seconds(900)
        })
      }
    );

    // 最後にここまでに作ったアクションを組み合わせてパイプラインを構成
    const deployPipeline = new codepipeline.Pipeline(this, "deployPipeline", {
      pipelineName: `DeployPipeline-${railsEnv}`,
      stages: [
        {
          stageName: "Source",
          actions: [deploySourceAction]
        },
        {
          stageName: "BeforeMigration",
          actions: [deployOneoffAction]
        },
        {
          stageName: "Migration",
          actions: [migrateAction]
        },
        {
          stageName: "Deploy",
          actions: [
            deployAppAction,
            deployShoryukenAction,
            deployElasticWheneverAction
          ]
        }
      ]
    });

Windows10標準の機能でVSCode(でなくてもよい)+マイク音声を録画する

まとめ

  • Windows 10 の標準機能 GameBar を使うと、任意のアプリケーションを録画できる
  • マイクからの音声を含めることができる

事の起こり

タケユー・ウェブでは、お客様からの技術的な質問に対してお答えする顧問的な業務もあります。 その中でVSCodeを使った操作の説明をする機会があり、自分で理解しながら観ることができて、後から見返すこともできる動画を作成してお渡ししたいなと思い、VSCodeの画面を録画する方法を調べました。

私は普段Windows 10を利用しているので、Windows 10で、できれば追加のソフトウェアなしに標準機能での方法になります。

VSCodeと書きましたが、Windows上のアプリケーションであれば、なんでもよいはずです。

GameBar を使って任意のアプリケーションを録画する

任意のアプリケーションをアクティブにする

今回はVSCodeの録画をしたいのでVSCodeをアクティブにします。

f:id:uzuki05:20200330105048p:plain

GameBar を起動する

Windowsキー + G で Xbox Game Bar が起動します。

f:id:uzuki05:20200330105311p:plain

VSCode をゲームとして認識させる

このままではゲームではないので録画させてくれないので、XBox Game Bar に VSCode をゲームだと言いくるめます。

[設定] から [これをゲームとして記憶する] にチェックを入れます。

f:id:uzuki05:20200330105804p:plain

録画を開始する

Game Bar にゲームとして認識させると、キャプチャウィジェットの録画ボタンが押せるようになります。

また、そのアプリケーション実行中はいつでも Windowsキー + ALTキー + R で録画を開始/停止できます。

f:id:uzuki05:20200330110025p:plain
Game Bar のキャプチャウィジェット

f:id:uzuki05:20200330110244p:plain
録画中は画面上に録画中を示す表示がでます

f:id:uzuki05:20200330110313p:plain
録画ファイルは C:\Users\Foo\Videos\Captures に保存されます(デフォルト)

EC2 ImageBuilder コンポーネントのデバッグ

まとめ

  • Terminate instance on failurefalse にしてもテスト用のインスタンスは殺される
  • S3へのログ保存は絶対に有効にしろ
  • ステップはなるべく小分けに

事の起こり

  • EC2 ImageBuilder でゴールデンAMIを作っている
  • ある日昨日まで成功してたテストをパスしなくなった
  • エラー内容を見ても failed to run commands: exit status 1 のようなのでよくわからない

f:id:uzuki05:20200329090414p:plain
EC2 ImageBuilder で謎のエラー

デバッグ

AWS Systems Manager オートメーション

EC2 ImageBuilder のタスクは、AWS Systems Manager オートメーションを利用して動いています。 [AWS Systems Manager] => [アクションと変更] => [自動化] から自動化の実行の一覧を確認します。

  • ImageBuilderBuildImageDocument
  • ImageBuilderTestImageDocument

がそれぞれ、ビルド、テストに使ったドキュメントです。

ですが、ここだけ見てもエラーの詳細はさっぱりわからないので後に書く S3 へのログのアップロードを有効化しておく必要があります。

f:id:uzuki05:20200329092454p:plain
エラー画面

Image Pipeline の Troubleshooting settings

Image Pipeline を作るとき、変更するときに指定できるデバッグ用のオプションが用意されています。 デフォルトでは無効になっているので注意しましょう。

Terminate instance on failure

デフォルトで true チェックを外すとビルド失敗時にビルドに使ったEC2インスタンスを起動したままにしておいてくれるので、実際に環境にログインして調査することができます。

ただ、テストフェーズで失敗した場合は機能しないようです。

なお、EC2インスタンスはプライベートサブネットに作成されるため、SSHには踏み台が必要です。

Key pair

EC2インスタンスに設定するSSHのキーペア。

Logs

設定すると、自動化処理中の以下のようなログを指定したS3バケットにアップロードしてくれます。 Image pipelineの実行IAM roleに指定したS3バケットへのPut権限をつける必要があると思います。 S3バケット中のフォルダ名は、AWS Systems Manager オートメーションの自動化の実行IDを元にした名前になるので、まず実行IDを特定して、S3を探すことになります。

実行IDは EC2 Image Builder images の実行の詳細画面 Reason for failure に書いてあります。 または、AWS Systems Manager オートメーションの自動化の一覧から探してもよいでしょう。

f:id:uzuki05:20200329095417p:plain

f:id:uzuki05:20200329093017p:plain

ログフォルダの中身は以下のような内容になっています。

detailedoutput.json

どのステップで失敗したのか?どんなコマンドを実行したのか?がわかります。

{
    "executionId": "02bb195c-7143-11ea-a1f0-0603d891b362",
    "status": "failed",
    "startTime": "2020-03-29T07:25:22+09:00",
    "endTime": "2020-03-29T07:27:32+09:00",
    "failureMessage": "Document TOE_2020-03-28_22-25-21_UTC-0_02bb195c-7143-11ea-a1f0-0603d891b362/0__myapp-install__0.0.7_1.yml failed!",
    "documents": [
        {
            "name": "MyApp Install",
            "filePath": "TOE_2020-03-28_22-25-21_UTC-0_02bb195c-7143-11ea-a1f0-0603d891b362/0__myapp-install__0.0.7_1.yml",
            "status": "failed",
            "description": "This is MyApp Install testing document.",
            "startTime": "2020-03-29T07:25:22+09:00",
            "endTime": "2020-03-29T07:27:32+09:00",
            "failureMessage": "Phase test failed!",
            "phases": [
                {
                    "name": "test",
                    "status": "failed",
                    "startTime": "2020-03-29T07:25:22+09:00",
                    "endTime": "2020-03-29T07:27:32+09:00",
                    "failureMessage": "Step CheckResponse failed!",
                    "steps": [
                        {
                            "name": "HelloWorldStep",
                            "status": "success",
                            "failureMessage": "",
                            "timeoutSeconds": 7200,
                            "onFailure": "Abort",
                            "maxAttempts": 1,
                            "action": "ExecuteBash",
                            "startTime": "2020-03-29T07:25:22+09:00",
                            "endTime": "2020-03-29T07:25:22+09:00",
                            "inputs": "[{\"commands\":[\"echo \\\"Hello World! Test.\\\"\"]}]",
                            "outputs": "[{\"stdout\":\"Hello World! Test.\"}]"
                        }
                        {
                            "name": "CheckResponse",
                            "status": "failed",
                            "failureMessage": "exit status 244",
                            "timeoutSeconds": 7200,
                            "onFailure": "Abort",
                            "maxAttempts": 3,
                            "action": "ExecuteBash",
                            "startTime": "2020-03-29T07:27:26+09:00",
                            "endTime": "2020-03-29T07:27:32+09:00",
                            "inputs": "[{\"commands\":[\"echo \\\"curl -LI  http://localhost/\\\"\\nSTATUS_CODE=$(curl -LI  http://localhost/ -o /dev/null -w '%{http_code}\\\\n' -s)\\nif [[ $STATUS_CODE == \\\"200\\\" ]]; then\\n    echo \\\"MyApp was successfully invoked.\\\"\\nelse\\n    echo \\\"MyApp was not successfully invoked. Failing.\\\"\\n    exit $STATUS_CODE\\nfi\\n\"]}]",
                            "outputs": "null"
                        },
console.log

どのステップで失敗したのか?どんなコマンドを実行したのか?がわかります。

2020-03-29 07:25:22 Info Document TOE_2020-03-28_22-25-21_UTC-0_02bb195c-7143-11ea-a1f0-0603d891b362/0__myapp-install__0.0.7_1.yml
2020-03-29 07:25:22 Info Phase test
2020-03-29 07:25:22 Info Step HelloWorldStep
2020-03-29 07:25:22 Info Command execution completed successfully
2020-03-29 07:25:22 Info Stdout: Hello World! Test.
2020-03-29 07:25:22 Info Stderr: 
2020-03-29 07:25:22 Info ExitCode 0
2020-03-29 07:27:26 Info Step CheckMonitorStatus
2020-03-29 07:27:27 Info Command execution resulted in an error
2020-03-29 07:27:27 Info Stdout: curl -LI  http://localhost/
MyApp was not successfully invoked. Failing.
2020-03-29 07:27:27 Info Stderr: 
2020-03-29 07:27:27 Info ExitCode 244
2020-03-29 07:27:29 Info Command execution resulted in an error
2020-03-29 07:27:29 Info Stdout: curl -LI  http://localhost/
MyApp was not successfully invoked. Failing.
2020-03-29 07:27:29 Info Stderr: 
2020-03-29 07:27:29 Info ExitCode 244
2020-03-29 07:27:31 Info Command execution resulted in an error
2020-03-29 07:27:31 Info Stdout: curl -LI  http://localhost/
MyApp was not successfully invoked. Failing.
2020-03-29 07:27:31 Info Stderr: 
2020-03-29 07:27:31 Info ExitCode 244

なお、今回の原因

ログを確認したところ、テストフェーズでdockerのインストールなどを行ったこともあり、disk full になったのが原因でした。 ImageBuilder で使うEC2インスタンスのストレージサイズを変更したレシピを作成したところ成功しました。

AMI(EBSスナップショット)の使用量と料金を確認する

まとめ

  • ボリュームサイズとスナップショットの使用量は別
  • スナップショットの使用量を知るにはコストエクスプローラーを使う
  • EBS:SnapshotUsage (GB-Month) 使用タイプとコスト配分タグを組み合わせる

事の起こり

現在、EC2 ImageBuilder で作成したGolden AMIを使った Immutable Infrastructure を構成し、運用しています。 この運用だと、アプリケーションコードの更新やセキュリティアップデートなどで頻繁にAMIを作成することになります。

AMIはEBSスナップショットと組なので、AMIをたくさん作るということは、EBSスナップショットをたくさん作るということになります。

Amazon マシンイメージ (AMI)

個々のスナップショットの容量が数GB、それがたくさんともなれば心配になるのは費用です。

f:id:uzuki05:20200327180719p:plain

「EBSスナップショットの課金容量=ボリュームサイズ」ではない

ドキュメントによれば、スナップショットの課金額は、

  • ストレージの実際に使っているブロックを
  • 圧縮して
  • 作成元のEBSスナップショットからの差分

になるようです。

では、実際に請求対象となる「データを Amazon S3 で保存するのに使用された領域の大きさ」はどこで見られるのでしょう?

参考

スナップショットストレージの基盤になるのは、データを Amazon S3 で保存するのに使用された領域の大きさです。Amazon EBS では空のブロックが保存されないため、スナップショットのサイズはボリュームサイズよりもかなり少なくなるはずです。ボリュームの最初のスナップショットについては、データ全体のコピーが Amazon S3 に保存されます。増分スナップショットごとに、Amazon EBS ボリュームの変更部分のみが保存されます。

Amazon EBS の価格

Snapshotに関しては、EBSボリュームの実サイズがそのまま計算されるのではなく、 圧縮してコピーされ、増分については変更部分のみ保存されます。 (費用をできる限り軽減するようにさせて頂いております。) 

AWS Developer Forums: EBSスナップショットについて ...

Amazon EBS-Backed の AMI の場合、AMI をカスタマイズしたり、新しい AMI を作成したりするたびに、変更のみが保存されます。そのため、最初の AMI の後にカスタマイズする後続の AMI のストレージフットプリントははるかに小さくなり、AMI ストレージ料金が少なくなります。

AMI タイプ

コストエクスプローラーを使う

コストエクスプローラー「使用タイプ」で「EBS:SnapshotUsage (GB-Month)」を選ぶと、スナップショットの「Total usage (GB-Month)」と「Total cost ($)」を確認できます。

f:id:uzuki05:20200327182440p:plain

これだけだと、すべてのスナップショットの合計になるので、特定のスナップショットの使用量を知るには、コスト配分タグを使います。

  1. EBSスナップショット(AMIではない)にタグを付ける(例 RailsEnv=production Service=app CommitID=0123456789abcdef など)
  2. 作成したタグを「ユーザー定義のコスト配分タグ」としてアクティブ化
  3. コストエクスプローラーの「タグ」で選択

このようにEBS:SnapshotUsage (GB-Month) 使用タイプコスト配分タグを組み合わせることで、AMI(EBSスナップショット)の使用量と料金を確認することができます。

コスト配分タグ

コスト配分タグは、AWSの各リソースに設定した「タグ」をもとに、タグごとの利用状況・コストを分析できる機能です。

ただし、リソースに設定したタグはそれだけではコスト配分タグとしては利用できず、別途、「ユーザー定義のコスト配分タグのアクティブ化」の操作が必要です。

ユーザー定義のコスト配分タグのアクティブ化

aws.amazon.com