サーバーレスなバックアップシステムを AWS SAM を用いてシュッと構築する

こんにちは。昨晩のお夕飯は鮭のカレー風味ムニエル定食だったインフラ部 SRE グループの @mozamimy です。

今回は、SRE グループでの取り組みのひとつであるマルチクラウドバックアップを題材にして AWS SAM、CodePipeline (CodeBuild および CodeDeploy を含む) を用いたサーバーレスアプリケーションの構築、ビルドおよびデプロイについて書いていきたいと思います。また、1月に Lambda で Golang が利用可能になった こともあり、CodePipeline の進捗を Slack に投稿する Lambda function を Golang で作ってみたので、そちらもあわせて解説したいと思います。

今回取り扱うトピック

  • マルチクラウドバックアップの重要性
  • サーバーレスアプリケーションについて
  • AWS SAM (Serverless Application Model)
  • s3-multicloud-backup: Lambda を中心に実装するイベント駆動の S3 -> GCS へのバックアップシステム
  • codepipeline-notify: Golang で実装された CodePipeline の進捗を Slack に投稿するアプリケーション

マルチクラウドバックアップ #とは

クックパッドでは、オンプレミスから AWS に移行してから長きにわたって AWS を中心に利用しています。その一部として、レシピ画像やつくれぽ画像の配信のための tofu というシステムが稼働しており1、tofu のバックエンドストレージとして Amazon S3 (以降 S3 と略記) を利用しています。S3 バケットに格納された画像データは、それぞれが大切なユーザーの皆様の画像であり、データの可用性が非常に重視されます。心のこもったひとつひとつの画像を大切に守って配信すべきときに漏れなく配信できるようにする、それは我々 SRE の使命です。

クックパッドにとって、ユーザーの皆様からお預かりしたデータはサービスの要です。S3 は非常に高い可用性を持っているため、たとえば S3 の障害などからデータを守るためには十分と言えるかもしれません。しかし、サービスの要であるからには、たとえば AWS アカウントへのアクセスができなくなるような自体にも備える必要があるとチームでは考えています。そこで我々は Google Cloud Storage (以下 GCS と略記) をバックアップ先として採用し、GCS にバックアップデータを預けることにしました。

今回は、AWS の S3 から Google の GCS のように、クラウドサービスをまたいだバックアップのことをマルチクラウドバックアップと呼ぶことにします。そして、それを実現するためのイベント駆動 Lambda function を中心としたサーバーレスアプリケーションを、AWS SAM のもとで構築するためのノウハウを解説していきます。

サーバーレスアプリケーション

サーバーレスアプリケーションとは、サーバのプロビジョニングなしに2利用できるマネージドサービスを駆使して、何らかのサービスを提供するアプリケーションのことを指します。たとえば AWS では以下のようなコンポーネントを利用することができます。

  • Lambda: コンピューティング
  • API Gateway: リバースプロキシ
  • SNS (Simple Notification Service): pub/sub によるメッセージング
  • SQS (Simple Queue Service): キューによるメッセージング
  • S3: オブジェクトストレージ
  • DynamoDB: NoSQL データベース
  • CloudWatch: ロギングおよびモニタリング

これらのコンポーネントは AWS によって維持管理されており、高い可用性とスケーラビリティを持つとされています。たとえば、コンピューティングという意味では Lambda は EC2 に代わるものであり、Lambda を利用することで EC2 のように OS そのものの面倒を見るといった運用作業から解放され、実現したい機能に集中することができます。また、Lambda function が実行されている時間だけ課金するというモデルなので、EC2 のようにインスタンスが起動している限り課金され続けるコンポーネントよりも経済的に運用しやすいというメリットもあります3

ただし、これらの個別のコンポーネントを組み合わせて動作させるという性質上、アプリケーション全体として俯瞰したときにピタゴラ装置になることは避けられない上、デプロイ作業が非常に面倒なものになります。そのため、これらのコンポーネントを定義し、スマートにデプロイできる「何か」が必要になります。そして、その「何か」の一つとして AWS SAM (Serverless Application Model) が公式に提唱されており、実際に利用できる状態になっています。

AWS SAM (Serverless Application Model)

AWS SAM (以降 SAM と略記) とは、ひとことで言うならば「CloudFormation をベースとしたサーバーレスアプリケーションのリソースを作成、管理できる仕組み (モデル)」といったところです。詳しくは以下の公式リポジトリに置かれているドキュメントに目を通してみてください。

awslabs/serverless-application-model: AWS Serverless Application Model (AWS SAM) prescribes rules for expressing Serverless applications on AWS.

SAM テンプレート自体は CloudFormation テンプレートの特殊化に過ぎず、最終的にベーシックな CloudFormation テンプレートに展開されます。つまり、SAM を使ってサーバーレスアプリケーションをデプロイする場合、最終的に CloudFormation stack が生えて、Lambda function を中心とした各リソースが作られることになります。

ちなみに Lambda のデプロイツールの有名所として Apex といったツールも検討しましたが、公式のエコシステムに賭けるほうが将来性があるということや、Apex は Lambda function のデプロイのみに特化しているといった点が気になり、SAM を中心にしていこうという流れになっています。

aws-sam-local でいい感じにサーバーレスアプリケーションを開発する

SAM を利用することのもう一つのメリットとして、ローカルで開発するときにとても便利な aws-sam-local というツールを利用することができる、ということがあげられます。まだベータ版の位置付けとなっていますが、十分使えるという印象です。API Gateway をシミュレーションすることもでき、SAM に乗っかるならば必携のツールといえます。以降の解説でも aws-sam-local を利用するので、チュートリアルとして試してみたい場合は README を読んでインストールしておいてください。

awslabs/aws-sam-local: AWS SAM Local 🐿 is a CLI tool for local development and testing of Serverless applications

2018-02-07 現在、npm を用いたインストールが Recommended となっていますが、個人的には https://github.com/awslabs/aws-sam-local#build-from-source にあるように、go get でインストールすることをおすすめします。まだベータ版ということもあってか、エラーメッセージがやや不親切であったり、不具合と思われる挙動に出くわしたときにちょっと直して動かしてみることが簡単だからです。今回説明するマルチクラウドバックアップシステムを構築する際にも、SAM テンプレートの CodeUri に相対パスが含まれていると期待通りに動作しないという問題を発見し、それを修正するための PR を出しました。

Fix a glitch that a tamplate CodeUri has like ../ relative path by mozamimy · Pull Request #279 · awslabs/aws-sam-local

余談ですが、SAM のリスのキャラクターは SRE グループの中ではとっとこ SAM 太郎と呼ばれてたいへん愛されて(?)おります。New – AWS SAM Local (Beta) – Build and Test Serverless Applications Locally | AWS News Blog のブログ記事でも、

At it's core, SAM is a powerful open source specification built on AWS CloudFormation that makes it easy to keep your serverless infrastructure as code –and they have the cutest mascot.

とあり、公式にも (?) SAM 太郎推しであることがうかがえます。

f:id:mozamimy:20180202132607p:plain

s3-multicloud-backup: Lambda を中心に実装するイベント駆動の S3 -> GCS へのバックアップシステム

アーキテクチャ

以下に、今回構成する s3-multicloud-backup のアーキテクチャを図示します。

ここでは、ソースとなる S3 バケットの名前を source-s3-bucket とし、バックアップ先の GCS バケットの名前を dest-bucket とします。source-s3-bucket にオブジェクトがアップロードされると、以下の順に処理が進み、最終的に dest-bucket にオブジェクトがバックアップされます。

  1. オブジェクトがアップロードされたという通知が source-s3-bucket-notification SNS topic に飛ぶ。
  2. source-s3-bucket-notification SNS topic を subscribe する s3-multicloud-backup function が起動する。
  3. s3-multicloud-backup function は、SNS 経由で届いた通知を開封し、対象のオブジェクトを source-s3-bucket から取り出して dest-bucket にアップロードする。

ただし、s3-multicloud-backup function によるオブジェクトのダウンロードとアップロードは、何らかの理由で失敗することがあり、堅牢なバックアップシステムを構築するためには失敗した事実を適切にハンドリングする必要があります。ここで起こりうる失敗の原因としては、以下の 3 点が上げられます。

  • S3 もしくは GCS に障害が起きている。
  • function にバグにより例外が起こって処理が正常に完了しなかった。
  • Lambda の実行環境でメモリ不足やタイムアウトが起きた。

このうち、上から 2 つについては障害が回復してから手動でリトライしたりバグを修正するなどの対応が必要となりますが、これらは避けることができません。とはいえ S3 や GCS の可用性を考えると滅多に起こることはないでしょう。

ここで特に考慮すべきは 3 つめのパターンで、オブジェクトのファイルサイズと Lambda の実行環境として設定するメモリ量によっては、それなりに頻繁に起こりえます。だからといって大きめのメモリサイズを確保するとコストに直接響くため、なるべく確保するメモリ量は小さくしたいところです。そこで、ここでは DLQ を用いた多段呼び出しというパターンを用いることにします。

Lambda では非同期呼び出しの場合、適当な時間を置いて 2 回まで自動的に再試行され、それでもダメなら DLQ として指定した SNS topic もしくは SQS queue にメッセージが送られます4。この機能を利用し、1 段目の s3-multicloud-backup function の実行が失敗した場合、s3-multicloud-backup-retry SNS topic に通知を送り、2 段目に控える s3-multicloud-backup-retry function を起動するようにします。もちろん、2 段目の function には、1 段目よりも大きめのメモリサイズを確保するように設定します。

このような構成にすることで、設計としてはやや複雑になりますが、より低コストでバックアップシステムを運用することができます。

最終的に s3-multicloud-backup-retry による実行も失敗した場合、最終的に DLQ として指定されている s3-multicloud-backup-dlq SQS queue に情報が格納されます。CloudWatch Alarm を用いてこの SQS queue の NumberOfMessageSent を監視しておき、DLQ にメッセージが届いたら PagerDuty を経由して SRE が状況判断をして対応する、という流れになっています。

ソースコードと SAM テンプレート

アーキテクチャを理解したところで、ここから function のソースコードと SAM テンプレートについて説明します。ソースコードは https://github.com/mozamimy/s3-multicloud-backup に push してあるので、必要に応じてご活用ください。

ディレクトリツリーは以下のようになっています。

.
├── README.md
├── deploy
│   ├── buildspec
│   │   ├── production.yml
│   │   └── staging.yml
│   └── template
│       ├── production.yml
│       └── staging.yml
├── sample-event-dlq.json
├── sample-event-s3-via-sns.json
└── src
    ├── index.js
    └── package.json

ここでは、ビルドやデプロイに関連するファイルを deploy ディレクトリ以下にまとめ、処理本体が記述された index.js およびパッケージ情報である package.json を src ディレクトリ以下にまとめています。また、aws-sam-local を用いて手元で function を動かすために、サンプルのイベントを含んだ 2 つの JSON ファイルが置かれています。

index.js

'use strict';

const fs = require('fs');
const uuid = require('uuid');
const aws = require('aws-sdk');
const kms = new aws.KMS();
const s3 = new aws.S3();

const GCS_BUCKET = process.env.GCS_BUCKET;
const PROJECT_ID = process.env.PROJECT_ID;
const ENCRYPTED_GC_CREDENTIAL = process.env.GC_CREDENTIAL;
const SKIP_KEY_REGEX = process.env.SKIP_KEY_REGEX;
const RETRY_SNS_TOPIC_ARN = process.env.RETRY_SNS_TOPIC_ARN;
const GC_CREDENTIAL_FILE_PATH = '/tmp/gcp_cred.json';
const TMP_FILE_PATH = `/tmp/${uuid.v4()}`;

function replicateObject(s3ObjectKey, s3Bucket) {
  const kmsParamas = {
    CiphertextBlob: new Buffer(ENCRYPTED_GC_CREDENTIAL, 'base64'),
  };

  const s3Params = {
    Bucket: s3Bucket,
    Key: s3ObjectKey,
  };

  return Promise.all([
    kms.decrypt(kmsParamas).promise().then((data) => {
      const decrypted = data.Plaintext.toString('ascii');
      fs.writeFileSync(GC_CREDENTIAL_FILE_PATH, decrypted);
    }),
    s3.getObject(s3Params).promise(),
  ]).then((results) => {
    const s3Object = results[1];
    fs.writeFileSync(TMP_FILE_PATH, s3Object.Body);

    const storage = require('@google-cloud/storage')({
      projectId: PROJECT_ID,
      keyFilename: GC_CREDENTIAL_FILE_PATH,
    });

    const gcsBucket = storage.bucket(GCS_BUCKET);
    const gcsParams = {
      destination: s3ObjectKey,
      validation: 'crc32c',
      resumable: false,
    };

    return gcsBucket.upload(TMP_FILE_PATH, gcsParams);
  });
}

function deleteTempFile() {
  if (fs.existsSync(TMP_FILE_PATH)) {
    fs.unlinkSync(TMP_FILE_PATH);
  }
}

exports.handler = (event, context) => {
  const message = (event.Records[0].Sns.TopicArn == RETRY_SNS_TOPIC_ARN)
    ? JSON.parse(JSON.parse(event.Records[0].Sns.Message).Records[0].Sns.Message)
    : JSON.parse(event.Records[0].Sns.Message);

  const s3Bucket = message.Records[0].s3.bucket.name;
  const s3ObjectKey = message.Records[0].s3.object.key;

  if (!(SKIP_KEY_REGEX == null) && s3ObjectKey.match(SKIP_KEY_REGEX)) {
    const logMessage = `skipped: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
    console.log(logMessage);
    context.succeed(logMessage);
  } else {
    replicateObject(s3ObjectKey, s3Bucket).then((success) => {
      deleteTempFile();
      const logMessage = `replicated: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
      console.log(logMessage);
      context.succeed(logMessage);
    }).catch((err) => {
      deleteTempFile();
      const logMessage = `ERROR!: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
      console.log(logMessage);
      console.log(err);
      context.fail(logMessage);
    });
  }
};

Lambda が呼び出されたとき、exports.handler に格納された関数が実行されます。この関数では、SNS topic から受け取ったイベントを開封し、バックアップ元のバケットとオブジェクトのキーを取り出し、GCS バケットにアップロードします。SKIP_KEY_REGEX 環境変数に設定された正規表現にマッチするキーに対してはバックアップを行いません。また、184 行目で 1 段目の実行なのか 2 段目の実行なのかを判定して、適切にイベントの開封処理を変えています。

replicateObject() がバックアップ処理の本体で、GCS にアクセスするための暗号化されたクレデンシャル (JSON) を環境変数 ENCRYPTED_GC_CREDENTIAL から読み出し、KMS API を呼ぶことで復号します。そして、S3 バケットから対象のオブジェクトをダウンロードし、復号したクレデンシャルを用いて GCS バケットにアップロードします。クレデンシャルの暗号化や、この function につけるべき IAM role については後述します。

package.json

package.json に関しては特筆すべきところはないでしょう。

{
  "name": "s3-multicloud-backup",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/storage": ">=0.52.0",
    "aws-sdk": ">=2.44.0",
    "uuid": ">=3.0.1",
    "fast-crc32c": ">=1.0.4"
  }
}

ちなみに、fast-crc32c を依存に含めることにより、@google-cloud/storage による CRC チェックが高速になるという利点があります。また、後述しますが @google-cloud/storage の依存にネイティブライブラリが含まれるため、Lambda 環境で実行するためには Linux 上で npm install を実行する必要があります。

buildspec

package.json の項でも説明しましたが、依存ライブラリにネイティブバイナリのビルドが必要になるため、ローカル環境で npm install したときに作成される node_modules をそのまま Lambda 環境に置いてもきちんと動作する保証はありません。ここではその問題を解決するために CodeBuild を利用し生成された生成物を CodeDeploy でデプロイする、という構成にし、一連の処理を CodePipeline にまとめることにします。

以下はそのための buildspec の例です。

version: 0.2
phases:
  install:
    commands:
      - 'cd src && npm install'
  build:
    commands:
      - 'aws cloudformation package --template-file ../deploy/template/production.yml --s3-bucket sam-artifact.ap-northeast-1 --output-template-file template.package.yml'
artifacts:
  files:
    - 'src/template.package.yml'

AWS CLI の cloudformation package サブコマンドを使うことで、--template-file に指定したテンプレートを読み込んで src ディレクトリ以下を zip ファイルにまとめ、--s3-bucket に指定した S3 バケットに生成物を自動的にアップロードしてくれます。また、テンプレートに含まれる CodeUri を生成物用の S3 バケットに書き換えたテンプレートを --output-template-file に指定したファイルに出力します。--s3-bucket に指定するバケットは、各自の環境の合わせて変更してください。

SAM テンプレート

以下の YAML ファイルがデプロイの要となる SAM テンプレート です。上述したアーキテクチャ図と見比べて、どこがどの項目にあたるかをチェックするとよいでしょう。SAM の詳細な記法は以下の公式ドキュメントを参照してください。

serverless-application-model/2016-10-31.md at master · awslabs/serverless-application-model

また、SAM のベースは CloudFormation なので、CloudFormation に慣れておくとより理解が深まるでしょう。CloudFormation については以下のドキュメントが参考になります。

What is AWS CloudFormation? - AWS CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'A serverless application that replicate all objects in S3 bucket to Google Cloud Storage.'

Resources:
  # リトライ通知用 topic
  RetrySNSTopic:
    Type: 'AWS::SNS::Topic'
    Properties:
      DisplayName: 's3mc-stg'
      TopicName: 's3-multicloud-backup-retry'
  # 2 段目でも処理が失敗したとき用の queue
  DLQ:
    Type: 'AWS::SQS::Queue'
    Properties:
      QueueName: 's3-multicloud-backup-dlq'
      MessageRetentionPeriod: 1209600 # 14 days

  # 1 段目の function
  S3MulticloudBackupFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: 'index.handler'
      Runtime: 'nodejs6.10'
      CodeUri: '../../src'
      FunctionName: 's3-multicloud-backup'
      # 事前に作成しておいた IAM role をここに指定する
      Role: 'arn:aws:iam::(Account ID):role/LambdaS3MulticloudBackup'
      MemorySize: 256
      Timeout: 30
      Events:
        ObjectUploaded:
          Type: 'SNS'
          Properties:
            # S3 バケットからきた通知を受け渡すための
            # source-s3-bucket-notification topic の ARN を指定する
            Topic: 'arn:aws:sns:ap-northeast-1:(Account ID):source-s3-bucket-notification'
      DeadLetterQueue:
        Type: 'SNS'
        # `!Ref` を利用して論理名 `RetrySNSTopic` から ARN を取り出して設定する
        TargetArn: !Ref RetrySNSTopic
      # Lambda を起動した環境における環境変数を設定できる
      Environment:
        Variables:
          # マッチするキーのオブジェクトはバックアップしない
          SKIP_KEY_REGEX: '^(_sandbox|test)'
          # バックアップ先の GCS バケット名を指定
          # 環境に合わせて書き換える
          GCS_BUCKET: 's3-multicloud-backup'
          # バックアップ先のプロジェクトを指定
          # 環境に合わせて書き換える
          PROJECT_ID: 'foo-project'
          # リトライ用の SNS topic の ARN を指定
          # `!Ref` は環境変数を設定するときにも使える
          RETRY_SNS_TOPIC_ARN: !Ref RetrySNSTopic
          GC_CREDENTIAL: '暗号化したクレデンシャルをここに(後述)'

  # 2 段目の function
  S3MulticloudBackupRetryFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: 'index.handler'
      Runtime: 'nodejs6.10'
      CodeUri: '../../src'
      FunctionName: 's3-multicloud-backup-retry'
      # 事前に作成しておいた IAM role をここに指定する
      Role: 'arn:aws:iam::(Account ID):role/LambdaS3MulticloudBackup'
      # MemorySize および Timeout は 1 段目より大きめにする
      MemorySize: 2048
      Timeout: 90
      Events:
        BackupFailed:
          Type: 'SNS'
          Properties:
            # 1 段目の DLQ に指定したリトライ用 SNS トピックの ARN
            Topic: !Ref RetrySNSTopic
      DeadLetterQueue:
        Type: 'SQS'
        # 2 段目でも失敗した場合は SQS queue に格納するので、`!GetAtt` を用いて論理名から ARN を取り出して設定
        # SNS の場合は `!Ref` で ARN が返るが、SQS の場合は `!GetAtt` であることに注意
        TargetArn: !GetAtt DLQ.Arn
      Environment:
        Variables:
          SKIP_KEY_REGEX: '^(_sandbox|test)'
          GCS_BUCKET: 's3-multicloud-backup'
          PROJECT_ID: 'foo-project'
          RETRY_SNS_TOPIC_ARN: !Ref RetrySNSTopic
          GC_CREDENTIAL: '暗号化したクレデンシャルをここに(後述)'

SAM では、基本的に Resources に各種リソースを記述していくことになります。Resources のキーは各リソースの論理名で、別の場所で !Ref!GetAtt でリソースの ARN や名前を取得することが可能です。

ここでは、リトライを通知する s3-multicloud-backup-retry SNS topic および DLQ である s3-multicloud-backup-dlq SQS queue のみを SAM テンプレートに定義します。Lambda function にアタッチする IAM role や、S3 バケットからの通知を受ける source-s3-bucket-notification SNS topic は SAM テンプレートの中に定義しません。

これは、source-s3-bucket-notification は s3-multicloud-backup 以外の用途でも利用されるかもしれないということと、クックパッドでは IAM を codenize ツールである codenize-tools/miam: Miam is a tool to manage IAM. It defines the state of IAM using DSL, and updates IAM according to DSL. を用いて GitHub flow に則ってレビューできるように中央集権的に管理しているからです。読者の皆さんの環境次第では、これらの設定も SAM テンプレートに含めてしまってもよいでしょう。

RetrySNSTopic および DLQ は、基本的にデフォルトのパラメータを使うようにすれば十分でしょう。ただし、DLQ では MessageRetentionPeriod1209600 に明示的に指定することにより、メッセージが最長の 14 日間キューに保持されるようにします。

S3MulticloudBackupFunction および S3MulticloudBackupRetryFunction は Lambda function で、それぞれ 1 段目、2 段目の Lambda function になります。MemorySizeTimeout の値が違うことに注目してください。設定する値の詳細については、YAML 中のコメントを確認してください。

クレデンシャルを暗号化する

GCS バケットへのアクセスに利用するクレデンシャルをハードコードするわけにはいかないため、何らかの手段を用いて暗号化して環境変数にセットする必要があります。ここでは、AWS のキーマネジメントサービスである KMS (Key Management Service) を利用してクレデンシャルを暗号化および復号することにします。KMS の詳細については以下のドキュメントを参照してください。

AWS Key Management Service Documentation

まず、s3-multicloud-backup 専用の鍵を作成して、エイリアスおよび ARN をメモしておきます。そして、以下のような Ruby スクリプトを使ってクレデンシャル JSON ファイルの内容を暗号化します。暗号化の際には、作成した鍵を使える適当なアクセスキーを AWS_ACCESS_KEY_ID および AWS_SECRET_ACCESS_KEY 環境変数にセットしてください。

https://github.com/mozamimy/toolbox/tree/master/ruby/easykms

#!/usr/bin/env ruby

require 'aws-sdk-kms'

key_id = ARGV[0]
kms_resp = Aws::KMS::Client.new.encrypt({
  key_id: key_id,
  plaintext: STDIN.read,
})

print Base64.strict_encode64(kms_resp.ciphertext_blob)
$ cat credential.json | ruby kms_encoder.rb [鍵の ARN]

以上の作業で暗号化され Base64 エンコードされた出力をテンプレート中の GC_CREDENTIAL に書き込んでください。

Lambda function に付与する role

以下のような権限を持つ role を事前に作り、作った role の ARN を SAM テンプレートの Role に指定するとよいでしょう。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt"
      ],
      "Resource": [
        "暗号化用の KMS 鍵の ARN をここに"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sns:Publish"
      ],
      "Resource": "s3-multicloud-backup-retry SNS topic の ARN をここに"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sqs:SendMessage"
      ],
      "Resource": "s3-multicloud-backup-dlq SQS queue の ARN をここに"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:Get*"
      ],
      "Resource": "バックアップ元の S3 バケットの ARN をここに/*"
    }
  ]
}

Trust relationship には以下のように指定して Lambda function に付与する role であることを示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

aws-sam-local を使って開発する

SAM テンプレートを書いておけば、aws-sam-local を使ってローカルで Lambda function を実行することができます。その際、以下の点に注意してください。

  • 事前に Lambda 環境に近い状態で依存モジュールをビルドする
  • aws-sam-local コマンドを実行する際に、対象の S3 バケットを読み書きするためのクレデンシャルを AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY で与える

依存モジュールをビルドするには、aws-sam-local も内部で利用している lambci/lambda Docker image を利用すると便利です。以下のようなコマンドで npm install しましょう。

$ docker run --rm -v "$PWD/src/":/var/task lambci/lambda:build-nodejs6.10 npm install

すると、Lambda 環境に近い状態でビルドされた node_modules が src/ ディレクトリに作られ、以下のようなコマンドで function を動かすことができます。

$ aws-sam-local local invoke S3MulticloudBackupFunction -e sample-event-s3-via-sns.json --template=deploy/template/staging.yml

このように、手元から動かしてみることも考慮して、テンプレートは staging (もしくは development) 用と production に分けておくとよいでしょう。

buildspec も同様で、開発用の AWS アカウントと本番用の AWS アカウントが分かれている場合など、CodeBuild によるビルドによる生成物をアップロードするための S3 バケットが異なる場合があるので、staging 用と production 用に設定が分かれているほうが何かと便利だと思います。

aws-sam-local の詳しい使い方を知りたい場合は GitHub ページの README を参照するとよいでしょう。

CodePipeline を使ってビルドとデプロイを行う

ここからは、CodePipeline を使って CodeCommit、CodeBuild および CodeDeploy を組み合わせて、SAM テンプレートで定義したアプリケーションのビルドおよびデプロイについて説明します。

それぞれのサービスの詳細については、以下のドキュメントを参照してください。

デプロイできるようになるまでの大まかな流れとしては、以下のようになります。

  • 各種サービスロールを用意する
    • 各サービスで利用する S3 バケットを 3 つ用意する
      • CodePipeline 用
      • CodeBuild 用
      • CloudFormation 用
  • CodeCommit にリポジトリを用意する
  • CodeBuild にプロジェクトを追加する
  • CodePipeline にパイプラインを作成する
    • ここで CodeCommit、CodeBuild プロジェクト、CodeDeploy を組み合わせる

各種サービスロールを用意する

まだ Code シリーズや CloudFormation を利用していない場合サービスロールがない状態なので、事前に作成する必要があります。CodePipeline などを利用する IAM ユーザが持ってない権限であっても、サービスロールに強い権限があると間接的に行使できることになるため、以下の例を参考になるべく権限を絞るほうがよいでしょう。

CodePipeline 用のサービスロール

以下に CodePipeline が利用するためのサービスロール CodePipelineServiceRole の一例を示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateChangeSet",
        "cloudformation:CreateStack",
        "cloudformation:DeleteChangeSet",
        "cloudformation:DeleteStack",
        "cloudformation:DescribeChangeSet",
        "cloudformation:DescribeStacks",
        "cloudformation:ExecuteChangeSet",
        "cloudformation:SetStackPolicy",
        "cloudformation:UpdateStack",
        "cloudformation:ValidateTemplate"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codebuild:BatchGetBuilds",
        "codebuild:StartBuild"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codecommit:CancelUploadArchive",
        "codecommit:GetBranch",
        "codecommit:GetCommit",
        "codecommit:GetUploadArchiveStatus",
        "codecommit:UploadArchive"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codedeploy:CreateDeployment",
        "codedeploy:GetApplicationRevision",
        "codedeploy:GetDeployment",
        "codedeploy:GetDeploymentConfig",
        "codedeploy:RegisterApplicationRevision"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "lambda:InvokeFunction",
        "lambda:ListFunctions"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "opsworks:CreateDeployment",
        "opsworks:DescribeApps",
        "opsworks:DescribeCommands",
        "opsworks:DescribeDeployments",
        "opsworks:DescribeInstances",
        "opsworks:DescribeStacks",
        "opsworks:UpdateApp",
        "opsworks:UpdateStack"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketVersioning",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": [
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]",
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]/*",
      ]
    }
  ]
}
CodeBuild 用のサービスロール

以下に CodeBuild が利用するためのサービスロール CodeBuildServiceRole の一例を示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "codecommit:GitPull"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:GetAuthorizationToken",
        "ecr:GetDownloadUrlForLayer"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:ap-northeast-1:(Account ID):log-group:/aws/codebuild/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": [
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]/*",
        "[ここに CodeBuild が利用するための S3 バケットの ARN を入れる]/*",
        "[ここに CodeBuild によってビルドされた生成物を置くための S3 バケット (CloudFormation 用) の ARN を入れる]/*"
      ]
    }
  ]
}
CloudFormation 用のサービスロール

以下に CloudFormation が利用するためのサービスロール CloudFormationServiceRole の一例を示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "apigateway:*"
      ],
      "Resource": [
        "arn:aws:apigateway:ap-northeast-1::*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateChangeSet"
      ],
      "Resource": [
        "arn:aws:cloudformation:ap-northeast-1:aws:transform/Serverless-2016-10-31"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "events:*"
      ],
      "Resource": [
        "arn:aws:events:ap-northeast-1:(Account ID):rule/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole"
      ],
      "Resource": [
        "*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "lambda:*"
      ],
      "Resource": [
        "arn:aws:lambda:ap-northeast-1:(Account ID):function*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketVersioning",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": [
        "[ここに CodeBuild によってビルドされた生成物を置くための S3 バケット (CloudFormation 用) の ARN を入れる]/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "sns:ListSubscriptionsByTopic",
        "sns:Subscribe",
        "sns:Unsubscribe"
      ],
      "Resource": [
        "arn:aws:sns:ap-northeast-1:(Account ID):*"
      ]
    }
  ]
}

CodeCommit にリポジトリを用意する

CodeBuild では、コードのソースとして S3 バケットや CodeCommit、GitHub などをサポートしており、最近以下のブログポストにあるように、GitHub Enterprise 連携も登場しました。

Announcing AWS CodeBuild Support for GitHub Enterprise as a Source Type and Shallow Cloning | AWS DevOps Blog

ここでは、もっともお手軽な CodeCommit を利用することにし、CodeCommit に s3-multicloud-backup という名前でリポジトリを作成し、master ブランチをビルド・デプロイすることとします。

CodePipeline を構築する

SAM テンプレートで定義されたアプリケーションをデプロイするための最小のパイプラインは、以下のようなものになります。1 段目で CodeCommit からソースを取得し、2 段目で CodeBuild を用いて依存ライブラリのビルドおよび aws cloudformation package を行い、3 段目で stack の change set を作成し、4 段目で stack の change set を適用します。

f:id:mozamimy:20180202132555p:plain

各ステップは以下のように設定します。

1 段目: CodeCommit

f:id:mozamimy:20180202132537p:plain

Source provider に AWS CodeCommit を指定し、Repository name と Branch name をいい感じに埋めてください。

2 段目: CodeBuild

まず、以下のような設定で CodeBuild のプロジェクト s3-multicloud-backup を作成しましょう。Node.js 用の image を利用し、Buildspec name に buildspec ファイルの位置を指定し、Service role に事前に用意しておいた CodeBuild 用のサービスロールを指定すれば OK です。

f:id:mozamimy:20180202132523p:plain

パイプラインには以下のように設定します。

f:id:mozamimy:20180202132532p:plain

3 段目: CodeDeploy と CloudFormation による stack の change set の作成

以下のスクリーンショットのように、Deployment provider に AWS CloudFormation を指定し、Action mode を Create or replace a change set を指定し、Role name には事前に用意しておいた CloudFormation 用のサービスロールを指定します。

f:id:mozamimy:20180202132548p:plain

4 段目: CodeDeploy と CloudFormation による stack の change set の適用

4 段目では、3 段目で作成した実行計画ともいえる change set を実行できるように、以下のスクリーンショットのように設定します。Action mode に Execute a change set を指定するのがミソです。

f:id:mozamimy:20180202132543p:plain

以上で CodePipeline の設定は完了です。Release change ボタンを押せばパイプラインの各ステップが実行され、最終的に SAM テンプレートに定義された各リソースが作成され、アプリケーションが利用可能な状態になるでしょう。

アラートの発行とモニタリング

クックパッドではアラート用の SNS topic が存在し、その topic にアラートの内容を送るとアラート用のメールアドレスにメールが飛び、最終的に PagerDuty で incident が発行されるという仕組みになっています。以下の例では DLQ 用の SQS の NumberOfMessageSent を CloudWatch Alarm で監視し、メッセージが発行されたことを検知するとアラート用の SNS topic に通知が飛ぶようになっています。アラートに関しては読者の皆さんの環境によって違うと思うので、それに合わせて設定してください。

f:id:mozamimy:20180207085432p:plain

CloudWatch では任意のメトリクスを組み合わせてダッシュボードを作ることができるので、以下のようなダッシュボードを作っておくと便利でしょう。

f:id:mozamimy:20180207085424p:plain

たまに数回エラーが発生していますが、ほとんどは Lambda の非同期呼び出しの自動リトライで成功していることがわかります。また、点になっていてやや見づらいですが、02/02 に一度だけ s3-multicloud-backup-retry function が実行されていることがわかります。

codepipeline-notify: Golang で実装された CodePipeline の進捗を Slack に投稿するアプリケーション

ここまで、SAM テンプレートで定義された s3-multicloud-backup というサーバーレスアプリケーションを CodePipeline を用いてデプロイする方法について述べてきました。

ここからは、1月に Lambda で Golang が利用可能になった ということもあり、Golang で実装された Lambda function の一例として、CodePipeline の進捗を Slack に投稿するアプリケーションを SAM テンプレートで定義してデプロイする方法について書いていきます。従来 Lambda で公式にサポートされていた静的型付け言語は Java および C# のみであったことを考えると、手軽に書ける静的型付け言語ということで Golang は選択肢の一つとして有力なものとなるでしょう。

ソースコードと SAM テンプレート

ここからは Golang で実装された function のソースコードと SAM テンプレートについて説明します。ソースコードは https://github.com/mozamimy/codepipeline-notify に push してあるので、必要に応じてご活用ください。

ディレクトリツリーは以下のようになります。

.
├── Makefile
├── README.md
├── deploy
│   ├── buildspec
│   │   ├── production.yml
│   │   └── staging.yml
│   └── template
│       ├── production.yml
│       └── staging.yml
├── handler
│   └── handler.go
├── main.go
└── sample-event.json

main.go

package main

import (
    "github.com/mozamimy/codepipeline-notify/handler"
    "github.com/aws/aws-lambda-go/lambda"
)

func main() {
    lambda.Start(handler.HandleRequest)
}

ハンドラは handler.go に記述することにし、main では lambda.Start() にハンドラを渡して実行を待ち受ける形になります。

handler.go

package handler

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "net/url"
    "os"
)

type CodePipelineEventDetail struct {
    Pipeline    string      `json:"pipeline"`
    State       string      `json:"state"`
    ExecutionID string      `json:"execution-id"`
    Version     json.Number `json:"version"`
}

type CodePipelineEvent struct {
    Detail CodePipelineEventDetail `json:"detail"`
}

type SlackPayload struct {
    Text        string            `json:"text"`
    Username    string            `json:"username"`
    Icon_emoji  string            `json:"icon_emoji"`
    Icon_url    string            `json:"icon_url"`
    Channel     string            `json:"channel"`
    Attachments []SlackAttachment `json:"attachments"`
}

type SlackAttachment struct {
    Color  string       `json:"color"`
    Fields []SlackField `json:"fields"`
}

type SlackField struct {
    Title string `json:"title"`
    Value string `json:"value"`
    Short bool   `json:"short"`
}

func HandleRequest(codePipelineEvent CodePipelineEvent) {
    field := SlackField{
        Value: fmt.Sprintf("The state of pipeline `%s` is changed to `%s` (execution_id: %s)", codePipelineEvent.Detail.Pipeline, codePipelineEvent.Detail.State, codePipelineEvent.Detail.ExecutionID),
        Short: false,
    }
    colorMap := map[string]string{
        "CANCELED":   "warning",
        "FAILED":     "danger",
        "RESUMED":    "warning",
        "STARTED":    "good",
        "SUCCEEDED":  "good",
        "SUPERSEDED": "warning",
    }
    attachment := SlackAttachment{
        Color:  colorMap[codePipelineEvent.Detail.State],
        Fields: []SlackField{field},
    }
    params, _ := json.Marshal(SlackPayload{
        Username:    "CodePipeline",
        Icon_emoji:  os.Getenv("SLACK_EMOJI_ICON"),
        Channel:     os.Getenv("SLACK_CHANNEL"),
        Attachments: []SlackAttachment{attachment},
    })

    resp, _ := http.PostForm(
        os.Getenv("SLACK_WEBHOOK_URL"),
        url.Values{
            "payload": {string(params)},
        },
    )

    body, _ := ioutil.ReadAll(resp.Body)
    defer resp.Body.Close()

    fmt.Printf(string(body))
}

CodePipelineEvent 構造体がミソで、ハンドラとなる関数 HandleRequest() の引数に構造体を与えることで、自動的に JSON をパースして仮引数に渡してくれます。CloudWatch Event から受け取れるサンプルイベント (sample-event.json) を以下に示します。

{
  "version": "0",
  "id": "CWE-event-id",
  "detail-type": "CodePipeline Pipeline Execution State Change",
  "source": "aws.codepipeline",
  "account": "123456789012",
  "time": "2017-04-22T03:31:47Z",
  "region": "us-east-1",
  "resources": [
    "arn:aws:codepipeline:us-east-1:123456789012:pipeline:myPipeline"
  ],
  "detail": {
    "pipeline": "myPipeline",
    "version": 1,
    "state": "CANCELED",
    "execution-id": "01234567-0123-0123-0123-012345678901"
  }
}

この仕組みは便利な半面、JSON と静的型付け言語の相性の悪さが浮き彫りになるところでもあり、デバッグがやや面倒になりがちな部分なので注意してください。ハンドラの仕様については以下のドキュメントや実装そのものが参考になります。ここでは、引数を取る場合でもっともシンプルなパターン func (TIn) で実装しています。

buildspec

buildspec は以下のように書くとよいでしょう。install フェーズでソースコードを GOPATH 以下に配置し、pre_build フェーズで go get し、build フェーズで go build して得たバイナリを zip にまとめ、AWS CLI の cloudformation package サブコマンドを利用してパッケージングします。このステップにより、--template-file に指定したテンプレートを読み込んで src ディレクトリ以下を zip ファイルにまとめ、--s3-bucket に指定した S3 バケットに生成物を自動的にアップロードしてくれます。s3-multicloud-backup と同様、--s3-bucket に指定するバケットは、各自の環境の合わせて変更してください。

version: 0.2
env:
  variables:
    PACKAGE: 'github.com/mozamimy/codepipeline-notify'
phases:
  install:
    commands:
      - 'mkdir -p "/go/src/$(dirname ${PACKAGE})"'
      - 'ln -s "${CODEBUILD_SRC_DIR}" "/go/src/${PACKAGE}"'
      - 'env'
  pre_build:
    commands:
      - 'cd "/go/src/${PACKAGE}"'
      - 'go get ./...'
  build:
    commands:
      - 'go build -o main'
      - 'zip main.zip main'
      - 'aws cloudformation package --template-file deploy/template/production.yml --s3-bucket sam-artifact.ap-northeast-1 --output-template-file template.package.yml'
artifacts:
  files:
    - 'template.package.yml'

SAM テンプレート

以下に SAM テンプレートの例を示します。今回はシンプルに Lambda function が 1 コしかないため、テンプレートの内容もシンプルです。SLACK_ から始まる各環境変数は、お手持ちの環境に合わせて適宜書き換えてください。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'A serverless application to notify whether it succeeded or not.'

Resources:
  CodePipelineNotify:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: '../../main.zip'
      Handler: 'main'
      Runtime: 'go1.x'
      FunctionName: 'codepipeline-notify'
      MemorySize: 128
      Timeout: 8
      Events:
        CodeCommitStateChanged:
          Type: 'CloudWatchEvent'
          Properties:
            Pattern:
              source:
                - 'aws.codepipeline'
              detail-type:
                - 'CodePipeline Pipeline Execution State Change'
      Environment:
        Variables:
          SLACK_WEBHOOK_URL: 'Slack の incoming webhook URL をここに入れる'
          SLACK_CHANNEL: '#serverless'
          SLACK_EMOJI_ICON: ':samtaro1:'

aws-sam-local を用いてローカルで function を動かす

今回は以下のような簡単な Makefile を用意したので、make コマンドを使ってビルドし aws-sam-local コマンドを使ってローカルで function を動かすことができます。s3-multicloud-backup の場合とは違い、Golang はクロスコンパイルが容易なため、手持ちの環境が Linux でない場合も Linux コンテナを使わずに GOOS=linux としてビルドすれば十分でしょう。

main: main.go handler/handler.go
    go build -o main
main.zip: main
    zip main.zip main
clean:
    rm -f main main.zip
$ GOOS=linux make main.zip
$ aws-sam-local local invoke CodePipelineNotify -e sample-event.json --template=deploy/template/production.yml
$ make clean

また、s3-multicloud-backup の場合と同様、CodePipeline を使ってビルドおよびデプロイのための仕組みを構築することができます。

まとめ

今回は、マルチクラウドバックアップを題材にして AWS SAM、CodePipeline を用いたサーバーレスアプリケーションの構築、ビルドおよびデプロイについて、チュートリアル的に解説しました。また、Golang で実装した CodePipeline の進捗を Slack に投稿するためのアプリケーションについても解説しました。

SAM を用いてサーバーレスアプリケーションの構成をテキストファイルに記述することで、いわゆる Infrastructure as Code の恩恵を受けることができ、加えて CodePipeline をはじめとする AWS の開発者ツールを利用することにより、ビルド及びデプロイの自動化が簡単になります。

今回は扱いませんでしたが、SAM と aws-sam-local の組み合わせによって API Gateway を用いた Web アプリケーションの開発やデプロイが容易になるといったメリットもあります。また、今回はテストコードを含めていませんが、buildspec にテストを起動するためのコマンドを含めれば、ビルドのパイプラインにテストを組み込むことも可能です。

他にも、codepipeline-notify の改良として、DynamoDB にパイプライン名と Slack チャンネルの対応を持たせておき、Slack channel の出し分けといったようなこともできるでしょう。また、AWS SDK や AWS CLI を用いて Slack などのチャットボットが CodePipeline を起動するようにすれば、サーバーレスアプリケーションに Chatops を持ち込むこともできます。

本記事がこれから Lambda を本格利用していこうとしている方や、すでに Lambda を利用しているが SAM などの管理手法を導入していない方の助けになれば幸いです。


  1. 料理を楽しくする画像配信システム

  2. 実際には DynamoDB のようにオートスケールを設定できるものの、プロビジョニングの概念が存在するコンポーネントはありますが…

  3. これはいかなる場合も当てはまるというわけではありません。たとえば、普段はほとんど利用されないが、定期的に実行がバーストするようなワークロードだと Lambda 型のほうが有利になります。

  4. Understanding Retry Behavior - AWS Lambda

Ruby 2.5 の改善を自慢したい

技術部でフルタイム Ruby コミッタをしている笹田です。最近ひさびさに Ruby のライブラリに pull request をしました(show valid break point lines #393)。

12/25 のクリスマスに、Ruby 2.5 が無事にリリースされました(Ruby 2.5.0 リリース)。関係各位の努力に感謝します。いろいろなバグ修正、いろいろな新機能、いろいろな性能改善があります(詳細は、上記リリースノート、もしくは Ruby のソースコードにある NEWS ファイルをご参照ください)ので、試して頂けると良いと思います。そういえば、私がクックパッドに入社して初めての Ruby リリースでした。

前回の techlife ブログ( Ruby の NODE を GC から卒業させた )で遠藤さんが

クックパッドからの主な貢献としては、「trace 命令の削除による高速化」「分岐・メソッドカバレッジの測定のサポート」などがあります。

と書いていました。

リリースノートより「trace 命令の削除による高速化」について引用します。

命令列中のすべての trace 命令を削除することで、5~10% の高速化を実現しました。trace 命令は TracePoint をサポートするために挿入されていましたが、ほとんどの場合、TracePoint は有効にされず、これらの命令は無用なオーバヘッドとなっていました。Ruby 2.5 では、trace 命令を用いる代わりに、動的書き換えを利用します。詳細は [Feature #14104] をご覧ください。

それから、同じくリリースノートに、ブロックパラメータに関する性能改善について書いてあります。

ブロックパラメータによるブロック渡し(例:def foo(&b); bar(&b); end)が、”Lazy Proc allocation” というテクニックを用いることで、Ruby 2.4 と比べて約3倍高速化しました。渡されたブロックを、さらに他のメソッドに渡したい場合、ブロックパラメータを利用する必要があります。しかし、ブロックパラメータは Proc オブジェクトの生成が必要であり、ブロック渡しのためにはこれが大きなオーバヘッドとなっていました。”Lazy Proc allocation” はこの問題を解決します。詳細は [Feature #14045] をご覧ください。

これらは私の仕事だったので、紹介文を書かせて頂きました。他と比べて長すぎますね。まぁ、迫力があっていいんじゃないでしょうか。具体的な数字があると、うれしいですしね。

本稿では、これらの機能をもう少し深掘りして、リリースノートやチケットでの議論では出てこない、普段、私がどんなことを考えながら開発しているのかをご紹介できればと思っています。また、これらの目立つ改善以外の、Ruby 2.5 のために私が行ってきた活動についてもご紹介します。

trace 命令の除去と命令の動的書き換え

まずは、リリースノートに書いてある「trace 命令の除去」についての話です。

何を実現したいのか

この新機能については、福岡Ruby会議02でのキーノート「Rubyにおけるトレース機構の刷新」でお話ししました。

www.slideshare.net

というか、このキーノートに間に合わせるために開発予定を調整しました(EDD: Event Driven Development)。

Ruby には TracePoint という機能があります(リファレンス。古くは set_trace_func)。何かあると、例えば行を越えると何かフックを実行する、ということに使います。例えばこんな感じ。

trace = TracePoint.new(:line) do |tp|
  p tp
end

trace.enable do
  x = 1
  y = 2
  z = x + y
end

は、TracePoint#enable のブロック内で TracePoint:line イベントごとに TracePoint#new に渡したブロックを実行します。そのため、出力は次のようなものになります。

#<TracePoint:line@t.rb:6>
#<TracePoint:line@t.rb:7>
#<TracePoint:line@t.rb:8>

この機能を実現するために、VM が実行する命令列(バイトコード)中に、trace 命令というものを、フックを実行する可能性があるところに沢山挿入しています。一番多いのは :line イベント用に、行が変わる度にこの trace 命令が挿入されています。つまり、5 行のメソッドには 5 つの trace 命令が入っています。

TracePoint って知ってますか?」と聞くと、多くの Rubyist は「知らない」と答えると思います。つまり、あんまり使われない機能なのですが、使われないと、trace 命令は単なるオーバヘッドにしかなりません。つまり、多くの場合、この命令は無駄なわけです。この無駄を排除するためのコンパイルオプション(Ruby のコンパイラはいくつかコンパイルオプションを受け取ります)を指定すれば、TracePoint は動かなくなるけどちょっと速くなる、ということができたのですが、そもそもコンパイルオプションが指定できることを知っている人はごく少数ですよね。

なお、Ruby 2.4 以前を利用しなければならず、Ruby プログラムを 1% でも高速化したい、という方は、プログラムの最初に RubyVM::InstructionSequence.compile_option = {trace_instruction: false} と書いておけば、trace 命令を利用しなくなります(が、TracePoint が利用できなくなるため、例えば byebug といったデバッガが利用できなくなります)。

どうやって高速化するのか:trace 命令を排除

そこで、TracePoint のために trace 命令を利用する、ということをやめました。代わりにどうするか。命令列の命令を書き換える、ということにしました。

実際の例を用いて説明します。

x=1
y=2
p x+y+3

このプログラムは、Ruby 2.4 では次のようにコンパイルされていました。

# Ruby 2.4
0000 trace            1     (   2)
0002 putobject        1
0004 setlocal         x, 0
0007 trace            1     (   3)
0009 putobject        2
0011 setlocal         y, 0
0014 trace            1     (   4)
0016 putself          
0017 getlocal         x, 0
0020 getlocal         y, 0
0023 send             :+
0027 putobject        3
0029 send             :+
0033 send             :p
0037 leave 

いくつか trace 命令が入っていることがわかります。これを、Ruby 2.5 では、

# Ruby 2.5
0000 putobject      1       (   2)[Li]
0002 setlocal       x, 0
0005 putobject      2       (   3)[Li]
0007 setlocal       y, 0
0010 putself                (   4)[Li]
0011 getlocal       x, 0
0014 getlocal       y, 0
0017 send           :+
0021 putobject      3
0023 send           :+
0027 send           :p
0031 leave 

このように trace 命令を排除した状態でコンパイルしておきます。trace 命令がないので、なんとなく速そう、という気分が伝わるんじゃないかと思います。伝わるといいな。

さて、TracePoint を利用した時です。有効にした瞬間、Ruby プロセス中に存在するすべての命令列を探し出して、必要な形に変換します。今回の場合、次のように変換されます。

# Ruby 2.5 / Trace on!
0000 trace_putobject 1    (   2)[Li]
0002 setlocal       x, 0
0005 trace_putobject 2    (   3)[Li]
0007 setlocal       y, 0
0010 trace_putself        (   4)[Li]
0011 getlocal       x, 0
0014 getlocal       y, 0
0017 send           :+
0021 putobject      3
0023 send           :+
0027 send           :p
0031 leave 

最初の putobjecttrace_putobject に変わったのが見て取れると思います。普通の putobjectTracePoint について何もしませんが、trace_putobject は、まず TracePoint についての処理を行ってから、従来の putobject の処理を行います。

この手法は、「TracePoint をオンにするタイミングで命令書き換えが起こるので、それが大きなオーバヘッドになる」という問題がありますが、そもそも TracePoint は使われないので、問題ないと判断しました。

検討した点、苦労したところ

この辺から、リリースノートに書いていない話になります。

なぜ今まで trace 命令を使っていたのか?

見ての通り、難しい話はないので、もっと前からやっておけよ、と言われるかもしれませんが、YARV 開発から10年以上たって、やっと入りました。

以前は、命令の書き換えをしないほうが、別言語への変換(例えば、C 言語への変換)がやりやすいかな、と思っていたからなのですが、最近、「結局そういうことやってないしなぁ」と思ったり(すみません)、現在 JIT コンパイラの導入の話が進んでいますが、「書き換えるなら一度 JIT コンパイル済みコードをキャンセルすればいい」という踏ん切りがついたためです。なら、どうせなら派手に書き換えるようにしてしまえ、と思い、このようにしてみました。

書き換えるに当たり、trace_ prefix 命令ではなく、trace 命令を動的に挿入する、という選択肢もありました(これが、最も互換性に優れた方法です)。ただ、命令を増やすと命令アドレスを変更しなければならず、若干面倒です。そのため、各命令の位置は変更しない、という選択をしました(そのため、プロトタイプは一晩で実現できました)。今後、もっとアグレッシブに命令書き換えを行うためには、命令位置変更にも対応しないといけないと思っています。

trace 命令を沢山入れると、TracePoint を有効にしない場合の速度劣化を気にしなければなりませんでしたが、これからはこのオーバヘッドが気にならなくなります。そのため、TracePoint 向けのイベントを追加できると思っています。例えば、定数やインスタンス変数をトレースしたり、メソッドを呼び出す caller 側をフックしたりすることができると思っています。

trace_ prefix 命令をいつ戻すのか

TracePoint を有効にしている間は trace_ prefix 命令が必要です。ですが、無効にしたタイミングで、TracePoint 向けの処理が不要になります。そのため、初期実装では、TracePoint がすべて不要になったタイミングで、同じようにすべての命令列を探し出して元に戻す処理を入れていました。これは、TracePoint をパタパタと on/off 繰り返すようなプログラムはないだろうな、という予測に基づく設計でした。上記福岡Ruby会議02で紹介した時には、このような設計をしていました。off にするときのオーバヘッドを軽減するための工夫も盛り込んでいます。ただ、ある程度のオーバヘッドは、やはり必要になります(具体的には、ヒープ上からすべての命令列を探し出す部分)。

しかし、一部のライブラリ(具体的に問題としてあがってきたのは power-assert)では、TracePoint の on/off が多く起こるため、問題になることがわかりました。そこで、結局一度 trace_ prefix 命令に変更すれば、その後 TracePoint を無効にしても、そのままにしておくようにしました。TracePoint 向けのチェックがついてしまい、trace 命令があったときと同じように、若干遅くなるのですが、TracePoint をちょっとだけ on にしてその後は利用しない、というシチュエーションは、あまりなさそうかな、と思い、最終的に「戻さない」とすることにしました。

非互換の対応

この変更にともない、バックトレースや TracePoint などで得られる行番号がずれる、という問題がありました。多少、変わっても、人間が見る分には問題ないだろう、と思っていたのですが、人間以外が見る、具体的にはデバッガ等で特定の行番号(例えば、end の位置の行番号)に依存した処理があったため、byebug という有名な Ruby 用デバッガで問題が起こる、ということがありました。

問題は修正できたのですが、この問題が発覚した(教えてもらった)のが 12/23 で、リリースの直前でした。久々にリリース直前にたくさんコーディングをして(例年は、リリース直前には怖くてコードをいじれません)、なんとか問題ないところまでもっていくことができました。本件でお世話になった関係各位に感謝いたします。

我々も、もっとちゃんと著名ライブラリはチェックしておかないとな、という反省をするとともに、RC1 リリースなどでちょっと試してもらえないかと読者の皆様にお願いするところです。

Lazy Proc allocation によるブロックパラメータを用いたブロック渡しの高速化

Lazy Proc allocation というテクニックを考えて、ブロックパラメータを用いたブロック渡しを約3倍高速化することができました。

何を実現したいのか

あるメソッドに渡されたブロックを、ほかのメソッドに渡したい、というシチュエーションが時々あると思います。

def block_yield
  yield
end

def block_pass &b
  # do something
  block_yield(&b)
end

block_pass のようなメソッドを書くと思いますが、このときブロックローカル変数 b でブロックを受け取り、その受け取ったブロックを block_yield(&b) のように渡すことで、このような受け渡しを実現することができます(なお、ブロックを渡す他の(素直な)方法はありません)。

とりあえず、これで一件落着なのですが、ブロックローカル変数を使うと、yield するだけに比べて遅くなってしまう、という問題が生じます。というのも、ブロックローカル変数は Proc オブジェクトを受け取るのですが、この Proc オブジェクトの生成が重いためです。なぜ遅いかを大雑把に言うと、関連するローカル変数領域などをメソッドフレームをたどって芋づる的にヒープに確保しなければならないためです(この理由をより深く理解するには、Rubyのしくみ Ruby Under a Microscope などをご参照ください)。

渡されたブロックという情報を他のメソッドに渡すために、ブロックパラメータを経由してしまうため、Proc という、より冗長なデータを受け取ってしまい、遅い、という問題です。これを解決したい。

ポイントは、ブロックの情報だけだったら軽い、というところです。

どうやって高速化をするか:Lazy Proc creation

block_yield(&b) のようにブロックの情報を渡すだけなら、Proc は必要ありません。なので、ブロックローカル変数が block_yield(&b) のように、他のメソッドにブロックを渡すだけであれば、Proc を作らなくてもよいようにすれば速くなりそうです。本当に Proc が必要になるまで Proc オブジェクトの生成を遅延する、だから Lazy Proc creation と名付けています。まぁ、ある意味自明な機能なのですが、それでも名前を付けると、なんかカッコいいですよね。なお、並列分散処理の分野で "Lazy task creation" という技法があります。あまり関係ないですけど、カッコいい手法なので興味があれば調べてみてください。

さて、ここで問題になるのは、「Proc が必要ないのか?」ということを知る必要があることです。

def sample1 &b
  block_yield(&b)
end

このプログラムは、bProc にする必要はありません。ブロックの情報のまま、他のメソッドに渡してやればいいからです。

def sample2 &b
  b
end

このプログラムは、bProc にする必要があります。呼び出し側が返値として Proc オブジェクトを期待する(かもしれない)からです。

def sample3 &b
  foo(b)
end

このプログラムも、bProc にする必要があります。foo を呼んだ先で Proc オブジェクトを期待する(かもしれない)からです。

こう見ると、block_yield(&b) のようにしか使っていなければ、b はブロック情報のままで良さそうです。では、次の例はどうでしょうか。

def sample4 &b
  get_b(binding)
end

一見すると、b は触っていないので、ブロック情報のままで良いような気がします。が、binding オブジェクトを用いると、そのバインディングを生成した箇所のローカル変数にアクセスすることができるので、get_b の定義を、

def get_b bind
  bind.local_variable_get(:b)
end

のようにすると、b の中身をアクセスすることができます。この場合、bsample4 の返値になるため、やはり Proc オブジェクトにしなければなりません。binding が出たら諦める、という方法もあるのですが、binding はメソッドなので、任意の名前にエイリアスをつけることができます。つまり、どんなメソッド呼び出しも、binding になる可能性があるのです。まぁ、ほぼそんなことは無いと思いますが。

どうやら、プログラムの字面を見て、「bProc オブジェクトにする必要は無い」と言い切るのは難しそうです(このようなことを調べることを、コンパイラの用語ではエスケープ解析ということがあります)。

そこで、実行時に bblock_yield(&b) のようなブロック渡し以外のアクセスがあったとき、初めて Proc オブジェクトを生成するようにしました。

この高速化手法自体は長い間検討していたのですが、もう少し一般的なエスケープ解析が必要じゃないかと思って、それは Ruby では難しそうだな、どうしようかな、と考えていて実現できていませんでした。ただ、改めて考えてみると、ブロックパラメータへのアクセスを実行時に監視すればできることに、ふと自転車を乗っているときに気づいたので、実装することができました。

def iter_yield
  yield
end

def iter_pass &b
  iter_yield(&b)
end

def iter_yield_bp &b
  yield
end

def iter_call &b
  b.call
end

N = 10_000_000 # 10M

require 'benchmark'
Benchmark.bmbm(10){|x|
  x.report("yield"){
    N.times{
      iter_yield{}
    }
  }
  x.report("yield_bp"){
    N.times{
      iter_yield_bp{}
    }
  }
  x.report("yield_pass"){
    N.times{
      iter_pass{}
    }
  }
  x.report("send_pass"){
    N.times{
      send(:iter_pass){}
    }
  }
  x.report("call"){
    N.times{
      iter_call{}
    }
  }
}

__END__

ruby 2.5.0dev (2017-10-24 trunk 60392) [x86_64-linux]
                 user     system      total        real
yield        0.634891   0.000000   0.634891 (  0.634518)
yield_bp     2.770929   0.000008   2.770937 (  2.769743)
yield_pass   3.047114   0.000000   3.047114 (  3.046895)
send_pass    3.322597   0.000002   3.322599 (  3.323657)
call         3.144668   0.000000   3.144668 (  3.143812)

modified
                 user     system      total        real
yield        0.582620   0.000000   0.582620 (  0.582526)
yield_bp     0.731068   0.000000   0.731068 (  0.730315)
yield_pass   0.926866   0.000000   0.926866 (  0.926902)
send_pass    1.110110   0.000000   1.110110 (  1.109579)
call         2.891364   0.000000   2.891364 (  2.890716)

ベンチマーク結果を見ると、ブロック渡しをしているケースでは、修正前と後で3倍程度性能向上していることがわかります。

なぜ block.call は速くならないのか?

def block_call &b
  b.call
  # b.call と同じことをやるように見える yield なら速い。
end

このようなプログラムがあったとき、b がブロック情報のままでも yield 相当の処理に変換してしまえば、Proc オブジェクトを生成せずに済みそうな気がします。が、Proc#callyield には違いがあり、単純に yield に変換することはできません。

さて、何が違うかというと、$SAFE の設定、待避を行う、という機能です。yield では $SAFE について特に何もしませんが、Proc#call では、$SAFEProc オブジェクト生成時のものに設定し、呼び出しから戻すときに、呼び出し時の $SAFE に戻します。つまり、Proc 呼び出しの中で $SAFE を変更しても、呼び出しが終われば元通り、ということです。この違いがなければ、単純な yield に変換することは容易なのですが...。

ところで、$SAFE ってそもそもご存じですかね? 知らない方もいらっしゃるかと思いますが、これからも知らないでもあまり困らないのではないでしょうか。外部からの入力を用いて system メソッドなどで外部コマンドを呼ぶ、といった危険な機能を検知するかどうかを決めるための機能ですが、現在ではあまり利用するということは聞きません(危険なことができないようにするには、もっと OS レベルのセキュリティ機能を使うことを検討してください)。

そういうわけで、「あまり使って無さそうだから、$SAFE なくしませんか? 性能向上も阻害するし」、といったことを Ruby 開発者会議という毎月行っている Ruby コミッタの集まりで聞いてみたところ、まつもとゆきひろさんから、「$SAFE をなくすのはどうかと思うが、Proc オブジェクトで $SAFE の復帰・待避はしなくていいよ」という言質を取ったので([Feature #14250])、Ruby 2.6 では b.call のようにブロックパラメータを呼び出す速度が向上するのではないかと思います。だいたい、上記マイクロベンチマークの処理では、callyield と同じくらいの速度になるんじゃないかと思います。実際、Ruby コミッタ(パッチモンスター)の中田さん実験ではそれくらいの速度が出ているようです。

その他の貢献の話

さて、実はここからが本番なのです。が、もう長々と書いてしまったので、短くまとめます。

上記二つの性能向上は、良い数字が出ているので目立つのですが、実はあんまり苦労していません。だいたい数日で実現できてしまっています(その後、安定させるために、もう少し時間がかかっているんですが)。では、それ以外は何をしていたのでしょうか。

クックパッドに入って、Ruby のテスト環境を新たに整備しました([ruby-core:81043] rapid CI service for MRI trunk)。いわゆる普通のテストを定期的に行う CI は rubyci というものがあるのですが、結果が出るまで時間がかかる、通知がないなど不満がありました。そこで、最短で2分で結果が出る環境を整備することにしました。計算機はクラウド環境では無く、実機を利用しています。私が主催した東京Ruby会議11の予備費を用いて購入したマシンと、ある企業様から Ruby Association へ寄贈頂いたマシン、それからクックパッドで確保できたマシンを利用しています。マシン調達・運用にお世話になった/なっている皆様に深く感謝いたします。

テストは、コミットフックを用いるのではなく、とにかく何度も何度もテストを繰り返す、という方針をとっており、時々しか出ないタイミング問題などをあぶり出すことも挑戦することができました(普通は、同じテストを2度以上実行しても、結果は変わらないと思いますが、Ruby のテストですと、そうでもないことがあります)。実際、いくつかの問題を発見しています(多くはテストの不備でした)。また、結果を Slack に流して(普通ですね)、問題のあるコミットがあれば、すぐに気づくことができるようにしました。複数環境で実行しているため、たとえばビルドエラーが起こると Slack に数十の通知が一斉に飛んでくるので、とても焦るので直さないと、という気分になります。

それから、Ruby でコルーチンを実現するための Fiber 周りの整理・改善を行いました(リファレンスマニュアル)。結果は Fiber の切り替えが数% 速くなる、というなんとも地味な結果になりました。詳細は Ruby会議2017 での私の発表をご参照ください。

www.slideshare.net

実は、今年の多くの開発時間が、この改善につぎ込まれています。というのも、Fiber という機能を私が作ったのが約10年前なのですが、その頃テキトーにしていたところを、全部見直して書き直す、という作業になったからです。「実行コンテキストの管理」という、バグも出やすいムズカシイ部分ですし、そもそも覚えていない。そういう点でも気合いと開発時間が必要でした。うまくいかなくて、何度も最初からやり直しました。

この修正は、一義的には Fiber のための改善なのですが、実は狙いは将来導入を検討している新しい並行・並列のための機能を入れるための基盤作りでした。将来のデザインのために、今のうちに改善を行ったということになります。今年この点を頑張ったおかげで、来年は挑戦しやすくなったなという感触を持っています。

また、今年最大の Ruby への貢献といえば、遠藤さんにクックパッドにジョインして頂き、フルタイム Ruby コミッタとして活躍して頂いたことじゃないかと思います。遠藤さんによる目覚ましい成果は、だいたい私の成果と言っても過言ではないかと思います(過言です)。

おわりに

本稿では、Ruby 2.5.0 に導入した性能向上の仕組みについて、詳しくご紹介しました。その際、どのようなことを考え、気をつけながら開発をしているかも書いてみました。ちょっとだけのつもりが、長くなってしまいました。Ruby 2.5.0 と Ruby 開発の魅力を少しでもお伝えできていれば幸いです。来年も Ruby 2.6 そして Ruby 3 の実現のためにがんばります。

もし、Ruby インタプリタの開発が面白そうだな、と思ったら、できる限りサポートしますのでお声かけください。今年8月に好評だったRuby Hack Challenge( Cookpad Ruby Hack Challenge 開催報告 )の2回目である Ruby Hack Challenge #2 は1月末に英語メインで開催予定ですが(申し込み締め切りは過ぎてしまいました)、2月にも日本語メインで開催しようと思いますので、よろしければ参加をご検討ください。また、RHC もくもく会を、だいたい毎月どこかで開催していますので、そちらもチェック頂ければ幸いです。来月は 1/29 (月) に RHCもくもく会#4を開催予定です。

寒いのでお体に気をつけて、良いお年をお迎えください。

Ruby の NODE を GC から卒業させた

こんにちは、技術部のフルタイム Ruby コミッタの遠藤(@mametter)です。メリークリスマス。

本日 Ruby 2.5.0 がリリース予定です。いろいろな改善が含まれています。クックパッドからの主な貢献としては、「trace 命令の削除による高速化」「分岐・メソッドカバレッジの測定のサポート」などがあります。

ユーザから見える改善はいろいろと記事が出てくると思うので、この記事では、「抽象構文木のメモリ管理のリファクタリング」というあまりユーザから見えない改善を紹介してみます。

概要

Ruby のパーサは、NODE という内部的なオブジェクトで構成された抽象構文木を生成します。2.4 までの NODE は GC に管理される普通のオブジェクトでしたが、2.5 からは GC の外で管理するようになりました。これにより、3 つ嬉しいことがあります。

  • 大きなコードのパースが速くなりました。
  • NODE に詳細なコード位置情報(カラム情報)を載せることができました。
  • 将来的に NODE まわりのコードを整理できる準備ができました。

背景

NODE は、Ruby の抽象構文木のノードを表現する要素です。抽象構文木はソースコード文字列をプログラムで扱いやすい木構造として表現したものです。知らない人は拙著『Ruby でつくる Ruby』などを読んでください :-)

2.4 の NODE は GC に管理されるオブジェクトとして実装されていました。Ruby のオブジェクトは GC の制約で 5 ワード長と決まっています。そのうち 2 ワードは NODE の種別や行番号を表現するために予約されていて、自由に使えるのは 3 ワードのみでした。イメージ的には、長さが 3 で固定されている配列みたいなものです。

この方法には 3 つの問題がありました。

  • パース中に無駄な GC が起きる
  • 抽象構文木に詳細なコード位置情報を載せる余地がない
  • 抽象構文木の表現にムリ・ムダがある

パース中に無駄な GC が起きる

大きなコードをパースする際に NODE オブジェクトが大量に生成され、GC が走ってしまいます。しかも生成中の NODE は回収できないので、この GC はほぼ完全に無駄です。この現象は、a = 1 が大量に並ぶコードを実行すると観測できます。

図:eval("a = 1\n" * N) の実行時間(x 軸は行数 N 、y 軸は時間)

a = 1 が N 行並ぶコードの実行時間を、N を変えながら計測したものです。実行時間が非線形に増えていっているのがわかります。これはパース中に起きる GC のせいです。1 回の GC にかかる時間は O(N) 、GC が起きる回数は O(log N) なので、全体で O(N log N) の時間がかかります。

抽象構文木に詳細なコード位置情報を載せる余地がない

NODE には、その NODE に対応するコードの行番号だけが記録されていました。バックトレースの表示や行カバレッジの測定などでは、この情報だけで十分でした。

しかし、2.5 では分岐カバレッジをサポートすることになりました。分岐は同一の行の中で複数個現れることが普通にあるため、分岐カバレッジのレポートで「どの分岐の実行回数であるか」を示すために、カラム番号(行内で左から何番目か)が必要になりました。また、メソッドカバレッジのレポートでも、開始位置(def の位置)だけではなく終了位置(end の位置)もある方が便利でしょう。

しかし、大きさが限られている NODE には、これらの情報を載せるための場所がありませんでした。

抽象構文木の表現にムリ・ムダがある

3 ワード制限のために、Ruby の抽象構文木にはムリ・ムダが生じています。たとえば true を表すノード NODE_TRUE は、子ノードを持たないので、3 ワードがまるまるムダになってます。逆に、obj.attr += val を表すノード NODE_OP_ASGN2 は情報を 4 つ持つ *1 ので、2 つの NODE をカスケードさせて無理やり表現しています。

おまけ:昔は GC 管理する意味があったが、今はもう意味がない

Ruby 1.8 のころは、抽象構文木をトラバースする方式でインタプリタが実装されていました。このような実装では、抽象構文木が不要になるタイミングが自明ではないので、GC 管理に任せたい気持ちも理解できます。

しかし Ruby 1.9 では YARV が導入され、YARV コンパイラが抽象構文木をバイトコードに変換したあとは、抽象構文木はもう使われません。つまり、パースから実行開始までのわずかな期間だけのために、少なくない NODE オブジェクトを作って捨てることになります。世代別 GC が導入されたから実害はあまりないですが、ムダはムダです。

やったこと

NODE を GC 管理されるオブジェクトとしてではなく、ただの malloc されたバッファの中に確保するようにしました。大量に NODE を作っても、malloc バッファが増えるだけで GC のオブジェクトバッファは圧迫されないので、無意味な GC 起動は基本的に起きません。

主に大変だったのは次の 3 つです。

NODE が NODE 以外のオブジェクトを指すケースの検出と対応

NODE の子どもは基本的に NODE ですが、一部の NODE は NODE 以外のオブジェクトを指すことがあります。たとえばリテラルを表す NODE_LIT は、そのリテラルオブジェクト(文字列や配列など)を参照します。 NODE は GC 管理オブジェクトではないので、これらのオブジェクトはマークされません。そのままでは回収されてしまいます。そこで、NODE のバッファの他に、マークが必要なオブジェクトを管理する配列(mark_ary)を用意し、NODE が NODE 以外のオブジェクトを指すタイミングで mark_ary に追加するようにしました。*2

NODE を目的外使用しているコードの削除

NODE は抽象構文木のためのものなのに、「自動的に free される便利なデータ構造」として転用されてしまっていました。NODE_ALLOCA(自動的に free される一時的バッファ)と、NODE_HEREDOC(ヒアドキュメント関係のパーサの状態を管理するための一時的データ構造)で、いずれも抽象構文木の一部にはなりません。これらは imemo と言われる別種の内部オブジェクトに置き換えて対応しました。

Ripper 対応

Ripper は NODE が GC 管理オブジェクトであることを仮定して書かれているので、切り離しが大変でした。実は完全には切り離せておらず、NODE の先頭ワードはオブジェクトと同じ構造でないといけません *3 。これはいずれなんとかしたいと思っています。

結果

この改善により、背景に上げた 3 つの問題が解決しました(または解決のめどが立ちました)。

パース中の無駄な GC がなくなった

大きなコードの eval が線形になりました。

図:eval("a = 1\n" * N) の実行時間(x 軸は行数 N 、y 軸は時間)

グラフ的には圧倒的ですが、正直この改善が現実世界で生きてくることはあんまり期待できないと思っています。クックパッドの全ソースコードのパースで評価すると、2.67 秒が 2.60 秒になった程度でした。まあ、10,000,000 行のコードとか書きませんよね。コードを自動生成しているプロジェクトでは、ひょっとしたら役立つかもしれません。

笹田コメント:「おまけ:昔は GC 管理する意味があったが、今はもう意味がない」にあるように、Ruby 1.9 から NODE を GC 対象にする必要はないことはわかっており、ずっとやりたいと思ってペンディングしていたところ、遠藤さんが入社して一瞬で作ってくれました。ただ、当初は GC 回数が減るので、もっと性能インパクトがあるかと思ったんですが、現実的なコードでは影響がほとんどなく、意外でした。世代別 GC の性能が十分高い、ということだと思います。

NODE に範囲情報をもたせた

NODE が GC 管理から外れて自由に拡張できるようになりました。今までは各 NODE は開始行番号しか持っていませんでしたが、今は次の 4 つの情報を持っています。

  • 開始行番号
  • 開始カラム番号
  • 終端行番号
  • 終端カラム番号

分岐カバレッジ・メソッドカバレッジはこの情報にもとづいて測定結果を出力します。 また、カラム情報は他にも利用価値がありそうです。たとえば、NoMethodError が起きた箇所を行番号だけでなくカラム番号も出すことで、より詳細に位置を特定できるようにできるかも。

抽象構文木の表現のムリ・ムダを省いていける準備ができた

NODE が自由に使える領域は 3 ワードに限らなくなったので、今後はより柔軟に拡張できます。Ruby のソースコードのうち、評価器部分は YARV への置き換えで大きく整理されましたが、パーサ部分は未整理のまま拡張され続けてきていて、現在は Ruby の中でも最もわかりにくいソースコードの 1 つになっています。メンテナンスの観点でも、将来的に型システムを検討する土台としても、わかりやすくてメモリ効率的によいものになるように整理を進めたいと考えています。

まとめ

Ruby 2.5 NODE を GC 管理から外すことで、(1) パース時の無駄な GC を抑えた、(2) NODE の位置情報を詳細化した、(3) 抽象構文木の整理を進める土台を確立した、という改善を行いました。

謝辞:改良の方針や実装について弊社笹田とたくさん相談しました。また、bison を使って NODE の位置情報を実際に実装していくのは @yui-knk さんがやってくれました。ありがとうございます。

*1:レシーバ obj 、読み書きする attr 、演算子 + 、値 val 。

*2:mark_ary は YARV のコンパイラでも使われているテクニックです。

*3:RB_TYPE_P(obj, T_NODE) によって、NODE かそれ以外かを区別できないといけない。