タケユー・ウェブ日報

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

WSLのバックアップとインポート

まとめ

  • wsl --exportwsl --import
  • インポートした後はデフォルトのユーザーが root になってしまうので、設定してやる必要がある
  • .bat を書けばバックアップをとってからシャットダウン、もできる

forest.watch.impress.co.jp

事の起こり

  • WSL2をゴリゴリ仕事で使っている
  • 何かあったとき戻せるようにしたい
  • ツール類の再インストール作業などなるべくしたくない
  • プロビジョニングツールはツールの更新で動かなくなったりしてつらい
  • スナップショットとりたい

バッチファイルを書いた

wsl-backup.bat

@echo off

echo %date%
echo %time%

set yyyy=%date:~0,4%
set mm=%date:~5,2%
set dd=%date:~8,2%

set time2=%time: =0%

set hh=%time2:~0,2%
set mn=%time2:~3,2%
set ss=%time2:~6,2%

set timestamp=%yyyy%-%mm%-%dd%-%hh%-%mn%-%ss%
wsl --export Ubuntu D:\backups\wsl\Ubuntu_%timestamp%.tar

shutdown.bat

@echo off

call D:\wsl-backup.bat

shutdown /s /t 30 /c "Shut down after 30 seconds."

仕事終わりに shutdown.bat を「管理者で実行」

復元方法

WSL2のセットアップを終えた環境で

wsl --import <NAME> <PATH> <FILE>

wsl --import Ubuntu "C:\wsl\Ubuntu" D:\backups\wsl\Ubuntu_2020-05-30-11-19-12.tar

デフォルトのユーザーを設定

そのままだと root になるので、デフォルトのユーザーを指定します。

C:\Users\yuichi> wsl
Last login: Fri Apr 17 21:35:43 JST 2020 on pts/5
Welcome to Ubuntu 18.04.4 LTS (GNU/Linux 5.4.72-microsoft-standard-WSL2 x86_64)

root@PANDA:~#
Function WSL-SetDefaultUser ($distro, $user) { Get-ItemProperty Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\*\ DistributionName | Where-Object -Property DistributionName -eq $distro | Set-ItemProperty -Name DefaultUid -Value ((wsl -d $distro -u $user -e id -u) | Out-String); };
WSL-SetDefaultUser <DistroName> <UserName>

WSL-SetDefaultUser Ubuntu takeyuweb

github.com

AWS SDK for Ruby で EC2 ImageBuilder のパイプラインを作成する

まとめ

事の起こり

  • EC2 ImageBuilder でゴールデンAMIを作成している
  • EC2 ImageBuilder のコンポーネントを変更する際、それを使うようにパイプラインを更新するには、コンポーネント、レシピ、パイプラインとそれぞれ作り直す必要があり、面倒
  • AWSマネジメントコンソール上からだと設定できないオプション(たとえばイメージ作成に使用するEC2インスタンスのEBSボリュームサイズ)がある
  • CloudFormationでは作成できない

AWS SDKを用いてコード化し、管理可能かつ実行可能にすることで、作業を効率化したくなりました。

サンプルコード

CloudFormation

S3バケットやEC2インスタンスプロファイルなどはCloudFormationで作成していたのでそれを使いました。 スタックのOutputsでRubyスクリプトに渡します。

ポイントはEC2インスタンスに割り当てるロールです。

Mappings:
  SubnetConfig:
    VPC:
      CIDR: "10.0.0.0/16"
    Private:
      CIDR: "10.0.1.0/24"
    Public:
      CIDR: "10.0.2.0/24"
Resources:
  ImageBuilderRole:
    Type: "AWS::IAM::Role"
    DeletionPolicy: Retain
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
        - "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder"
      Policies:
        - PolicyName: allow-put-log
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - "s3:*"
                Resource:
                  - !GetAtt BuildLogBucket.Arn
                  - !Join ["/", [!GetAtt BuildLogBucket.Arn, "*"]]

  EIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  VPC:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsSupport: true
      EnableDnsHostnames: true
      CidrBlock: !FindInMap [SubnetConfig, VPC, CIDR]
  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref "AWS::Region"
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Private", "CIDR"]
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref "AWS::Region"
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public", "CIDR"]
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachement:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt EIP.AllocationId
      SubnetId: !Ref PublicSubnet
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref "PrivateRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      NatGatewayId: !Ref NatGateway
  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref "PublicRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref "InternetGateway"
  PrivateSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet
      RouteTableId: !Ref PrivateRouteTable
  PublicSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable

  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Access to EC2 Instances
      VpcId: !Ref "VPC"

  ImageBuilderInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: "/"
      Roles:
        - !Ref ImageBuilderRole

  BuildLogBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: buildlog
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Status: Enabled
            ExpirationInDays: 30
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7

Outputs:
  PrivateSubnetId:
    Value: !Ref PrivateSubnet
  EC2SecurityGroupId:
    Value: !Ref EC2SecurityGroup
  BuildLogBucket:
    Value: !Ref BuildLogBucket
  ImageBuilderInstanceProfile:
    Value: !Ref ImageBuilderInstanceProfile

Ruby

CloudFormationのスタックから必要な設定値を取得するようにしました。 ここはコマンド引数などで与えてもよいですし、アプリ等に組み込むなら関数にして引数として与えてもよいでしょう。

require "aws-sdk"
require "securerandom"

semantic_version = Time.now.strftime('0.%Y%m%d.%H%M%S')
imagebuilder = Aws::Imagebuilder::Client.new(region: AWS_REGION)

def get_stack_output(name)
  stack_outputs[name]
end

def stack_outputs
  return @stack_outputs if defined?(@stack_outputs)

  cloud_formation = Aws::CloudFormation::Client.new(region: AWS_REGION)
  stack = cloud_formation.describe_stacks(stack_name: STACK_NAME).stacks[0]
  @stack_outputs = stack.outputs.inject({}) { |memo, output| memo.tap { |memo| memo[output.output_key] = output.output_value } }
end

component_build_version_arn = imagebuilder.create_component(
  name: COMPONENT_NAME, # required
  semantic_version: semantic_version, # required
  platform: "Linux", # required, accepts Windows, Linux
  data: COMPONENT_YAML,
  client_token: SecureRandom.uuid, # required
).component_build_version_arn

image_recipe_arn = imagebuilder.create_image_recipe(
  name: RECIPE_NAME,
  semantic_version: semantic_version, # required
  components: [ # required
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/update-linux/1.0.0',
    },
    {
      component_arn: component_build_version_arn,
    },
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/simple-boot-test-linux/1.0.0',
    },
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/reboot-test-linux/1.0.0',
    }
  ],
  parent_image: "arn:aws:imagebuilder:ap-northeast-1:aws:image/amazon-linux-2-x86/x.x.x", # required
  block_device_mappings: [
    {
      device_name: "/dev/xvda",
      ebs: {
        encrypted: false,
        delete_on_termination: true,
        volume_size: 16,
        volume_type: "gp2", # accepts standard, io1, gp2, sc1, st1
      }
    },
  ],
  client_token: SecureRandom.uuid, # required
).image_recipe_arn

infrastructure_configuration_arn = imagebuilder.create_infrastructure_configuration({
  name: INFRASTRUCTURE_CONFIGURATION_NAME, # required
  instance_types: ["m5a.large"],
  instance_profile_name: get_stack_output('ImageBuilderInstanceProfile'), # required
  security_group_ids: [get_stack_output('EC2SecurityGroupId')],
  subnet_id: get_stack_output('PrivateSubnetId'),
  logging: {
    s3_logs: {
      s3_bucket_name: get_stack_output('BuildLogBucket'),
      s3_key_prefix: "path/to/logs/",
    },
  },
  terminate_instance_on_failure: true,
  client_token: SecureRandom.uuid, # required
}).infrastructure_configuration_arn

distribution_configuration_arn = imagebuilder.create_distribution_configuration({
  name: DISTRIBUTION_CONFIGURATION_NAME, # required
  distributions: [ # required
    {
      region: "ap-northeast-1", # required
      ami_distribution_configuration: {
        name: IMAGE_NAME, # e.g. "my-app {{imagebuilder:buildDate}}"
        ami_tags: {
          "TagName" => "TagValue"
        },
      }
    },
    {
      region: "us-west-2", # required
      ami_distribution_configuration: {
        name: IMAGE_NAME,
        ami_tags: {
          "TagName" => "TagValue"
        },
      }
    },
  ],
  client_token: SecureRandom.uuid, # required
}).distribution_configuration_arn


image_pipeline_arn = imagebuilder.list_image_pipelines(
  filters: [
    {
      name: "name",
      values: [IMAGE_PIPELINE_NAME],
    },
  ],
).image_pipeline_list[0]&.arn
if image_pipeline_arn
  imagebuilder.delete_image_pipeline(image_pipeline_arn: image_pipeline_arn)
end
imagebuilder.create_image_pipeline({
  name: IMAGE_PIPELINE_NAME, # required
  image_recipe_arn: image_recipe_arn, # required
  infrastructure_configuration_arn: infrastructure_configuration_arn, # required
  distribution_configuration_arn: distribution_configuration_arn,
  image_tests_configuration: {
    image_tests_enabled: true,
    timeout_minutes: 60,
  },
  schedule: {
    schedule_expression: "cron(0 0 * * tue)",
    pipeline_execution_start_condition: "EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE", # accepts EXPRESSION_MATCH_ONLY, EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE
  },
  status: "DISABLED", # accepts DISABLED, ENABLED
  client_token: SecureRandom.uuid, # required
})

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 に保存されます(デフォルト)