Quantcast
Channel: クックパッド開発者ブログ
Viewing all 802 articles
Browse latest View live

クエリログを使ったAurora MySQLの負荷テスト

$
0
0

最近はZX-25Rが気になっている菅原です。4気筒250ccといえば、以前バリオス2に乗っていたんですが、あれもよく回るよいバイクでした。足つきの良さが懐かしいです。

この記事では、クエリログを使ったAurora MySQLの負荷テストの話を書きます。

MySQLの負荷テスト

サービスに使われているデータベースは、Webサーバと比べて自動的なスケールアップ・スケールアウトが簡単ではないためキャパシティプランニングは非常に重要です。サービスへのアクセス増による負荷増大の結果、急激に性能が低下するためなるべく事前にキャパシティを把握しておきたいところです。

クックパッドではサービスのデータベースとして主にAurora MySQLを利用しているのですが、キャパシティを把握するための負荷テストには以前から苦労してきました。

1. シナリオを書くのが大変

サービスで使われているデータベースの負荷テストのシナリオを人間が書こうとすると、あるシナリオでは極端に性能がよくなり、またあるシナリオでは極端に性能が悪くなるということがあり、実際のサービスを模してシナリオを書くのが非常に困難で、意味のある負荷テストをすることが難しいことが多くありました。

2. 大きなサイズのシナリオを実行する負荷テストツールがない

データベースの負荷テストツールは大きく2種類あり、一つはTCP-xのような決められたモデルを実行するツールで、もう一つは任意のシナリオを実行するツールです。TCP-xを実行するツールはクックパッドのサービスの性質とは異なるため、任意のシナリオを実行するツールを用いて負荷テストを行っていたのですが、MySQLで任意のクエリやシナリオを実行するツールはあまり多くなく、私の知る限りは以下の通りでした。

クエリログを使ったMySQLの負荷テスト

今までは上記のツールを使って負荷テストを行っていました(特にJdbcRunnerはとても便利でした!)。

しかし、既存のツールにはいくつか不満がありました。

1. 基本的にシナリオを人間が書く必要がある&大きなシナリオを読み込むのにメモリが必要

mysqlslapを除き任意のシナリオを実行することができるのですが、当然、人間がDSLやJavaScriptでシナリオを書く必要があり、前述の通り、実際のサービスに沿ったシナリオを書くことが困難でした。また、シナリオはメモリに読み込まれるため、大量の異なるデータをテストデータにしようとすると、クライアント側にデータを読み込むための大量のメモリが必要になりました。

2. 結果のデータの解析が難しい

負荷テストの実行結果について、平均応答時間だけではなく、中央値やヒストグラムなどが欲しいのですが、あまり多くの情報は出力されなく、出力されるデータは単純なテキストであったため、データをパースして結果を集計するにもやや手間がかかりました。

Webサーバの場合、アクセスログをテストデータとして負荷テストを行うことが多いと思います。MySQLに関しても同じようなことができないかと以前から考えていたのですが

  1. データが大きく(GB単位)、メモリに乗せるのが難しい
  2. データの羅列をDSLやJavaScriptに変換するのに手間がかかる

という問題がありました。

それらの問題を解決するためqrnという負荷テストツールを新たに作成しました。

qrn

データベース負荷テストツールqrnの大きな特徴は次の通りです。

  1. JSON Linesをテストデータとする
  2. テストデータは逐次ディスクから読み取り、全体をメモリにはロードしない
  3. テスト結果をJSONで細かく出力する

たとえば、以下のデータを

{"query":"select 1"}{"query":"select 2"}{"query":"select 3"}

4並列・5 qps/userで10秒間実行する場合、コマンドと出力結果は次のようになります。

$ qrn -data data.jsonl -dsn root:@/ -nagents4-rate5-time10
00:07 | 4 agents / run 184 queries (20 qps){"DSN": "root:@/",
  "Files": ["data.jsonl"],
  "Started": "2020-05-13T11:18:14.224848+09:00",
  "Finished": "2020-05-13T11:18:24.559912+09:00",
  "Elapsed": 10,
  "Queries": 189,
  "NAgents": 4,
  "Rate": 5,
  "QPS": 18.287694303306097,
  "MaxQPS": 21,
  "MinQPS": 18,
  "MedianQPS": 19,
  "ExpectedQPS": 20,
  "LoopCount": 15894,
  "Response": {"Time": {"Cumulative": "78.389862ms",
      "HMean": "392.47µs",
      "Avg": "414.761µs",
      "P50": "418.565µs",
      "P75": "462.099µs",
      "P95": "532.099µs",
      "P99": "735.68µs",
      "P999": "760.585µs",
      "Long5p": "632.823µs",
      "Short5p": "218.38µs",
      "Max": "760.585µs",
      "Min": "182.384µs",
      "Range": "578.201µs",
      "StdDev": "90.961µs"},
    "Rate": {"Second": 2411.0260584461803},
    "Samples": 189,
    "Count": 189,
    "Histogram": [{"57µs - 115µs": 1},
      {"115µs - 173µs": 1},
      {"173µs - 231µs": 4},
      {"231µs - 289µs": 14},
      {"289µs - 346µs": 12},
      {"346µs - 404µs": 48},
      {"404µs - 462µs": 63},
      {"462µs - 520µs": 34},
      {"520µs - 760µs": 12}]},
  "Token": "a579889e-97f9-4fd1-8b33-93ab2c78e6ad"}

クエリログのテストデータ化

MySQLのクエリログ(general log)をqrnのテストデータとして使用する場合、general logをJSON Linesに変換する必要があります。そのためのツールgenlogも新たに作成しました。

以下のようなgeneral logをJSON Linesに変換できます。

2020-05-27T05:03:27.500301Z   11 Query   SET @@sql_log_bin=off
2020-05-27T05:03:27.543379Z   11 Query  select @@session.tx_read_only
2020-05-27T05:03:27.683485Z   11 Query  COMMIT
$ genlog general.log # or `cat general.log | genlog`{"Time":"2020-05-27T05:03:27.500301Z","Id":"11","Command":"Query","Argument":"SET @@sql_log_bin=off"}{"Time":"2020-05-27T05:03:27.543379Z","Id":"11","Command":"Query","Argument":"select @@session.tx_read_only"}{"Time":"2020-05-27T05:03:27.683485Z","Id":"11","Command":"Query","Argument":"COMMIT"}

また、クエリログからSELECT文のみを抽出したい場合は、jqを使ってフィルタリングします。

$ jq -rc'select(.Command == "Query") | select(.Argument | test("select" ; "i"))' general.log.jsonl > mysql-general-select.jsonl

general logの出力については負荷増大の懸念から、今まで避けているところがあったのですが、実際に出力してみるとそれほどCPU使用率は上がらなかったため、現在では必要であれば出力するようにしています。

ただしAurora MySQLでgeneral logをCloudWatch Logsにエクスポートした場合は、それなりにCPU使用率が上がるため、Aurora MySQLのgeneral logを使用する場合にはエクスポートせずに、DBインスタンスの各ログをダウンロードするようにしました(ダウンロード用のツールも作成しました)。

テスト結果の集計

テスト結果は前述のようにJSONで出力しているのですが、どのクエリがどの程度応答時間を占めているのかを分析したい場合、負荷テストを実施しているテスト用データベースのlong_query_timeを0にして、すべてのクエリのスロークエリログを出力し、それをpt-query-digestで分析するということを行っています。

ウェイトを占めるクエリがわかるため、実際にサービスでスロークエリログが出力する前に重いクエリを改善するというようなことに役立ちます。

まとめ

データベースの負荷テストツールを自作することにより、クエリログをテストデータとした負荷テストが行えるようになり、Webサーバのような感覚で簡単にデータベースの負荷テストを行えるようになりました。これにより、事前のデータベースのキャパシティ把握が容易になったと考えています。

今はまだ手動で負荷テストを実施しているので、今後は負荷テストの自動化を目指したいところです。


負荷試験用 Web コンソールの開発

$
0
0

技術部 Site Reliability (SR) グループの id:itkqです。2020 秋タイトルで一番期待しているのはおちこぼれフルーツタルトです。本エントリでは、Web サービスの負荷試験に対する障壁を下げるために、汎用的な Web コンソール開発に至ったまでの話を書きます。

Web サービスの負荷試験の障壁を下げたい

クックパッドでは、マイクロサービスを支える基盤が成熟しており、新規サービス開発や、サービスリニューアルなどの機能開発の場面では、疎結合な新規のマイクロサービスとして実装されることが多いです。このようなサービスをリリースする際は、予想されるトラフィックに対して、実際にそれを捌ききれるかどうかテストする、いわゆる負荷試験をすることは一般的です。これまで、サービスリリース時に、負荷試験をきちんと行うこともあれば、負荷試験を行わないこともありました。負荷試験が行われない理由は、そのコストの大きさにあると私は考えました。本質的であるテストシナリオの用意だけでなく、負荷試験ツールの選定・負荷試験環境の構築・負荷試験ツールの動作環境の構築などの手間がかかります。

SR グループでは、負荷試験の障壁を下げ、開発チームが気軽に負荷試験を行えるようにすることで、自分たちが開発するサービスのボトルネックの認識やキャパシティプランニングを行えるようにすることを考えました。そのために、SR グループがメンテナンスする負荷試験用プラットフォームの提供を目指しました。

Serverless-artillery を利用したプロトタイピング

このプラットフォームで用いる負荷試験ツールは、サーバーレスであることが理想だと考えていました。負荷試験ツール側のリソースが不足してしまうことはしばしばあり、リソースの不足の心配を減らすためです。また、pay-as-you-go であることは、負荷試験のユースケースにマッチしており、コストを最適化できる見込みがあったからです。サーバーレスで動作する現代的な負荷試験ツールを自前で実装することも考えましたが、同じような思想で作られた Serverless-artilleryを見つけ、検証を行いました。このツールは、NodeJS 製の負荷試験ツール Artilleryと、サーバーレスアプリケーションをデプロイするためのフレームワーク serverless frameworkを組み合わせ、Artillery を AWS Lambda 上で実行するものです。Lambda function の実行時間やリソースの制限に合わせてテストシナリオを分割し、同時並列に Lambda function を実行し、目標負荷を実現する仕組みです。テストシナリオはドキュメントが充実している Artillery の記法 (YAML) で書くことができます。例えば、https://example.com/に対して 10 秒間、1 秒間に 1 仮想ユーザが GET リクエストする設定は以下のように記述します。

config:target:"https://example.com"phases:- duration:10arrivalRate:1scenarios:- flow:- get:url:"/"

Serverless-artillery を使うには、上記の YAML (script.yml) に加えて、次のような内容の serverless.yml を用意します。

service: serverless-artillery-minimum
provider:name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
functions:loadGenerator:handler: handler.handler
    timeout:300

次の操作で、負荷を発生させる Lambda function を含む CloudFormation stack がデプロイされます。

$ npm i -g slsart
(snip)

$ slsart deploy

        Deploying function...

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
........
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service serverless-artillery-minimum.zip file to S3 (17.91 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
...............
Serverless: Stack update finished...
Service Information
service: serverless-artillery-minimum
stage: dev
region: ap-northeast-1
stack: serverless-artillery-minimum-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  loadGenerator: serverless-artillery-minimum-dev-loadGenerator
layers:
  None
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

        Deploy complete.

負荷試験の実行は次のようになります。

$ slsart invoke --path script.yml

        Invoking test Lambda

{
    "timestamp": "2020-10-20T09:53:48.491Z",
    "scenariosCreated": 10,
    "scenariosCompleted": 10,
    "requestsCompleted": 10,
    "latency": {
        "min": 425.7,
        "max": 449.8,
        "median": 440.5,
        "p95": 449.8,
        "p99": 449.8
    },
    "rps": {
        "count": 10,
        "mean": 1.07
    },
    "scenarioDuration": {
        "min": 429.5,
        "max": 571.1,
        "median": 444.2,
        "p95": 571.1,
        "p99": 571.1
    },
    "scenarioCounts": {
        "0": 10
    },
    "errors": {},
    "codes": {
        "200": 10
    },
    "matches": 0,
    "customStats": {},
    "counters": {},
    "scenariosAvoided": 0,
    "phases": [
        {
            "duration": 10,
            "arrivalRate": 1
        }
    ]
}

        Your function invocation has completed.

(snip)

Serverless-artillery もドキュメントが充実しており、動作や設定について丁寧に説明されているため、詳しくは READMEを参照してください 。自分でいくつかテストシナリオを用意して想定通り実行できることを確認したり、長期間の ramp-up テストが期待通り動作することを確認しました。

次に、この Serverless-artillery を利用して、社内の環境で負荷試験をするための最低限の準備をしました。具体的には以下の項目です。

アクセストークンのハンドリング

社内の API サービスは OAuth2 をベースとした自前の認証認可サービスで認証認可を行うことが一般的です。これらの API サービスに対して負荷試験を行う場合、アクセストークンをリクエストヘッダにセットしたり、アクセストークンの有効期限が切れていた場合にリフレッシュする処理が必要になります。Artillery には、リクエストの前後やテストシナリオの前後でフックする仕組みがあり、これを使ってアクセストークンのハンドリングを実装しました。

リクエスト結果のメトリクスを扱うプラグインの改善

Artillery には、Pluginという仕組みがあり、Artillery 内部で扱うイベントに反応する処理を追加することができます。なかでも有用なのは、リクエスト結果をメトリクスとして外部に保存する monitoring plugin です。Serverless-artillery は AWS Lambda を前提としているため、手間が少ない monitoring plugin として artillery-plugin-cloudwatchをまず試しました。しかし他の plugin である artillery-plugin-influxdbなどに比べ欲しい機能が不足していたため、自分でいくつか機能を追加しました。 それについて upstream にコメントを求めましたが、長らく反応がなかったため、現在はフォーク版を使っています。

Jsonnet による設定の抽象化

Serverless-artillery には、先述の通り 2 つの設定ファイルが必要で、1 つは serverless フレームワークの設定である YAML ファイル、もう 1 つは Artillery のテストシナリオである YAML ファイルです。それぞれの YAML ファイルは、対象が異なる負荷試験であっても共通部分が多いため、Jsonnet で抽象化するようにしました。Jsonnet は、社内で設定を記述するのに広く使われているテンプレート言語です。使用例として、ECS へのデプロイを行うための Hako の定義ファイルがあります 。

以下に抽象化のイメージを示します。

recipes/serverless.jsonnet

local serverless = import '../lib/serverless.libsonnet';
local config = serverless.productionConfig('recipes');
std.manifestYamlDoc(config)

lib/serverless.libsonnet

local tags = {
  Project: 'serverlss-artillery',
};

{
  productionConfig(name):: {
    service: std.format('serverless-artillery-%s', name),
    provider: {
      name: 'aws',
      region: 'ap-northeast-1',
      runtime: 'nodejs12.x',
      stage: 'prod',
      role: 'arn:aws:iam::XXXXXXXXXXXX:role/LambdaServerlessArtillery',
      deploymentBucket: {
        name: 'dummy-bucket',
      },
      stackTags: tags,
      logRetentionInDays: 7,
    },
    functions: {
      loadGenerator: {
        handler: 'handler.handler',
        timeout: 300,
      },
    },
  },
}

recipes/script.jsonnet

local script = import '../lib/script.libsonnet';

local config = {
  config: script.productionBase('recipes') {
    phases: [
      {
        duration: 1800,  // 30 min
        arrivalRate: 1,
        rampTo: 500,
      },
    ],
    variables: {
      recipe_id: [
      1, 2, 3, 4, 5,
      ],
    },
  },
  scenarios: [
    {
      flow: [
        {
          get: {
            url: '/v1/recipes/{{ recipe_id }}', // ランダムに variables.recipe_id が選ばれる
            beforeRequest: 'ConfigureAccessToken', // アクセストークンの処理
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

lib/script.libsonnet

{
  productionBase(name): {
    target: 'https://cookpad-dummy-api.com',
    processor: './custom-functions.js', // ConfigureAccessToken が定義されたファイル
    defaults: {
      headers: {
        'user-agent': 'serverless-artillery',
      },
    },
    http: {
      timeout: 10,
    },
    plugins: {
      cloudwatch: self.cloudwatchPlugin(name),
    },
  },
  cloudwatchPlugin(name):: {
    region: 'ap-northeast-1',
    namespace: 'serverless-artillery',
    dimensions: {
      name: name,
      stage: 'prod',
    },
  },
}

レシピサービスリニューアルリリースにおける負荷試験

実は Serverless-artillery を検証していた段階で、レシピサービスのリニューアルリリース前に負荷試験を行いたい、と開発チームから声がかかっており、プロトタイピングしたものを実際に利用することにしました。

レシピサービスは社内で最も歴史のあるサービスで、内部のマイクロサービス化やリファクタリングは進んでいるものの、それ専用の仕組みがあったりと複雑な構成です。レシピサービスについて、専用の負荷試験環境を構築することは非常に難しく、また大きな労力がかかることは予想できたため、細心の注意を払いながら「本番環境」で負荷試験を行いました 1。テストシナリオは基本的に開発チーム側で準備してもらいつつ、レビューは SR グループでも行いました。負荷試験はテストシナリオを微修正しつつ何度か実行し、ミドルウェアのボトルネックなどいくつかの脆弱な箇所が洗い出されました。

今回のリニューアルでは、新たに 2 つのマイクロサービスが BFF の下に追加されました。その 2 つのサービスが関わるエンドポイントをリストアップし、各エンドポイントの予想アクセスパターンとアクセス量を考慮しながら、開発チームを中心にテストシナリオを作ってもらいました。実際に使われたテストシナリオの 1 つは次のようになっていました (URL など一部加工しています)。

local constants = import '../lib/constants.libsonnet';
local script = import '../lib/script.libsonnet';

local name = 'renewal-0221';
local config = {
  config: script.productionBase(name) {
    phases: [
      {
        duration: 3600,  // 1 hour
        arrivalRate: 1,
        rampTo: 655,
        name: 'Warm up the application',
      },
      {
        duration: 900,  // 15 min
        arrivalRate: 655,
        name: 'Sustained max load',
      },
    ],
    payload: [
      {
        path: './data/payload.csv',
        fields: [
          'kiroku_image',
        ],
      },
    ],
    variables: {
      recipe_id: constants.recipe_ids,
      clipped_at: [
        '2020-02-18T12:33:22+09:00',
      ],
      time_zone: [
        'Asia/Tokyo',
      ],
      keyword: constants.keywords,
      order: [
        'date',
      ],
      tsukurepo_count: [
        10,
      ],
    },
  },
  scenarios: [
    {
      name: 'deau/sagasu',
      weight: 200,
      flow: [
        {
          get: {
            url: '/dummy/app_home/deau_contents',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
        {
          post: {
            url: '/dummy/app_home/sagasu_search_result',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              keyword: '{{ keyword }}',
              order: '{{ order }}',
            },
          },
        },
      ],
    },
    {
      name: 'clip',
      weight: 450,
      flow: [
        {
          post: {
            url: '/dummy/clip',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              clipped_at: '{{ clipped_at }}',
              time_zone: '{{ time_zone }}',
            },
          },
        },
        {
          get: {
            url: '/dummy/{{ resourceOwnerId }}/bookmarks?recipe_ids={{ recipe_id }}', // resourceOwnerId は ConfigureAccessToken により挿入される
            beforeRequest: 'ConfigureAccessToken',
            capture: [
              {
                json: '$[0].id',
                as: 'bookmark_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/{{ bookmark_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
    {
      name: 'kiroku',
      weight: 5,
      flow: [
        {
          post: {
            url: '/dummy/kirokus',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              items: [
                {
                  item_type: 'PHOTO',
                  data: '{{ kiroku_image }}',
                },
              ],
            },
            capture: [
              {
                json: '$.id',
                as: 'kiroku_id',
              },
              {
                json: '$.items[0].id',
                as: 'kiroku_item_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/kirokus/{{ kiroku_id }}/items/{{ kiroku_item_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

まずシナリオのフェーズは、最大 RPS を 655 として、1 時間かけて ramp-up した後にそれを 15 分維持するように設定されています。これは、ピークタイムに向けてアクセスが伸びる現実を反映し、増加する負荷をシステムがオートスケールで対処できることを試験するためです。シナリオは機能ごとに 3 つを用意しました。それぞれには weight で重み付けをして、予想アクセスパターンを反映しています。recipe_id などのテストデータは別途事前に準備しておきました。

負荷試験中は、関連するメトリクスのダッシュボードを注視していました。想定外の事態が起き、負荷をすぐに中断することが何度か起きました。しかし、サーキットブレイカーの発動や、デグラデーションの考慮がなされていたことにより、実ユーザに大きな影響を与えることはありませんでした。負荷試験により発見された脆弱な点と、その対応の例を以下に挙げます。

  • サービス X の ECS サービスのスケーリングポリシーの最大タスク数に当たってしまい、リソースが不足しレイテンシが増加した。最大タスク数を引き上げた
  • サービス Y のバックエンド Elasticsearch が CPU リソース不足になりレイテンシが増加した。Elasticsearch の Data ノードのスケールアウト、N+1 クエリの解消、追加でレスポンスのキャッシュを実装が行われた
  • サービス Z のバックエンド MySQL が CPU リソース不足になりレイテンシが増加した。Z 内でのキャッシュの実装の見直しが行われ、さらに MySQL 接続ユーザやコネクション周りの設定不備も見つかった

最初のうちは、開発チームと SR グループで一緒に負荷の見守りを行っていましたが、終盤はほとんど開発チームだけで負荷試験の実行や中断ができるようになっていました。結果的に想定のテストシナリオをすべてクリアし、キャパシティに自信を持って 100% リリースすることができ、キャパシティ起因の問題は発生しませんでした。本番環境での負荷試験は、それ自体が大きなテーマであるため、このエントリではあえて詳細を書いていません。近いうちに別のエントリとして公開したいと考えています。

Web コンソールの開発

レシピサービスのリニューアルリリースにおける負荷試験を通して、Serverless-artillery を利用したプロトタイプが実用に耐えそうなことが分かりました。このプロトタイプでは以下の問題点があることが分かっていました。

  • 負荷試験の操作が CLI で、かつ強い IAM 権限を持つ人しか実行できない
  • 負荷試験が今行われているのか、いないのかがすぐに分からない

これをより利用しやすくするため、Web コンソールを開発しました。普通の Rails アプリケーションとして実装し、F5 と名付けました。コスト最適化のため、データベースは Aurora Serverless (MySQL) を利用しています。例として、10 分間で 50 RPS まで ramp-up し、50 RPS を 3 分間継続するというデバッグ用の負荷試験を実行したときの様子が以下になります。

f:id:itkq:20201021212900p:plain
負荷試験中の F5 のスクリーンショット

f:id:itkq:20201021211348p:plain
itkq/artillery-plugin-cloudwatch により収集したメトリクスの Grafana ダッシュボード

f:id:itkq:20201021211408p:plain
負荷試験対象側の Grafana ダッシュボード

また、開発にあたり工夫した点は次の通りです。

開発チームへの移譲

この取り組みの当初の課題意識を解決するため、開発チームが自分たちで負荷試験を操作できる仕組みを入れることにしました。G Suite の OAuth2 を利用した認証認可を実装し、特定のサービス (エンドポイント) のオーナー権をユーザに与え、オーナーのユーザはそのサービスに対して負荷試験の実行や中止を行えるようにしました。また、すべての操作はログとして残すようにして、後から追跡可能にしました。

テストシナリオを GitHub で管理して同期する

テストシナリオは負荷試験において本質的で、ピアレビューしたい場面があります。標準的なレビューフローをとれるようにするため、テストシナリオは Jsonnet として GitHub で管理し、それを同期するようにしました。このように設定ファイルを GitHub で管理するのは社内でよくある手法のため、受け入れられやすいと考えました。

まとめ

社内での Web サービスの負荷試験について、現状と改善の余地を述べ、Serverless-artillery を使った負荷試験の検証、より利用しやすくするための Web コンソールの開発に至るまでを説明しました。開発した Web コンソールは、実際に数回負荷試験に利用されています。テストシナリオのレビューで SR グループが最初関わることもありますが、その後は開発チームがほとんど自分たちで負荷試験のサイクルを回せているという所感です。これ以外にも、負荷試験に利用できる周辺の仕組み2が整ってきており、負荷試験、さらにはキャパシティプランニングが開発において当たり前となっていくような開発体制になることを目指していきたいです。


  1. 先日のイベントの発表資料も参考になります https://speakerdeck.com/rrreeeyyy/cookpad-tech-kitchen-service-embedded-sres

  2. 例えば Aurora のクローンシステム https://techlife.cookpad.com/entry/2020/08/20/090000

日々の簡単なプロトタイピングに Flutter を活用する

$
0
0

こんにちは、 CTO 室の山田です。 私は新卒入社から現在までずっと Amazon Alexa や LINE Clova などのいわゆるスマートスピーカーやスマートディスプレイ向けのアプリケーション開発に携わっています。

特に Amazon Alexa に関しては、日本だけでなく、スペイン、メキシコ、アメリカ、ブラジルの計 5 カ国にてサービスを展開しています。

現在は上記のプラットフォームへアプリケーションを公開する形でサービスを提供していますが、私たちが掲げている目標は「Voice User Interface の特性を活かし毎日の料理をもっと楽しみにする」ことであり、必ずしも特定のデバイスやプラットフォームに特化をして開発をするわけではありません。

例えば iOS/Android などのモバイルデバイス上での方が今より良いサービスを提供できるかもしれませんし、私たちでハードウェアを開発した方が良いこともあるかもしれません。 今のスマートスピーカーはいわゆるウェイクワードで呼び出してから指示を出しますが、実は料理のシーンではもっと別の良い方法があるかもしれません。

こういった可能性を模索する上で、 Voice User Interface というまだまだ技術的に発展途上な領域に関しては、そもそも実用できるレベルで動くのか。という技術的な検証が非常に重要となります。

しかしながら、さまざまな領域の技術的検証をする場合、当たり前ですがそれぞれについて小さくない学習コストがかかります。

これを理由にその可能性を掘ることは諦めたくないですが、出来る限り検証したい部分以外は最低限のコストで済ませたいです。

Flutter を用いたプロトタイプの開発

このような背景から、 低い学習コストで iOS/Android 純正ライブラリの性能を検証する用途で Flutter を活用することができるのではないかと実際にプロトタイプを作成し検証を行いましたので事例として紹介させていただきます。 今回はキッチンで料理をしている状況で iOS/Android 純正の音声認識ライブラリの認識精度がどの程度の性能かを検証するプロトタイプを作成しました。

まずはこちらをご覧ください。


目を見て話せるレシピアプリのプロトタイプ


目を見て話せるレシピアプリのプロトタイプ 2

人の目線に気付くと話を聞く姿勢になってくれて、手順を読み上げてくれるレシピアプリです。ゲームによくある感じの近づくと注意を向けてくれて会話ができる CPU みたいな感じのをイメージしてみました。

今回のプロトタイプでは、手順の読み上げや、以下の動画にある通り材料の分量の確認と、動画には無いのですが作った料理を写真に撮ってもらう機能を用意してみました。

リモートワークなので自宅での撮影になるのですが、リアルの日常的なプロトタイピングの風景だと思っていただければ幸いです。実際にこの機能を使って料理も作ったのですが、特に大きな問題もなく作り切ることが出来ました。 今回は技術的検証なので、例えば換気扇や水を流している状況下での認識精度も簡単にテストをしてみたのですが、問題なく認識していました。


目を見て話せるレシピアプリのプロトタイプ 3

今回のテストから以下のことがわかりました。

  • 意外とスマートフォンのマイクでもキッチンで十分に使える音声認識精度だった
    • 離れた位置から声で操作することを念頭に置いて開発されたデバイスではないため、あまりマイクの性能には期待をしていなかったが、料理中全体を通しても認識エラーは数えるほどだった。
    • 特に換気扇や水が流れている状態でも認識率に大きい影響がなかったことが印象的だった
    • 今回は認識できる発話のバリエーションが少なかったため、もっとさまざまな発話に対応させた時の認識精度についてもテストをしたい
  • 「目を合わせると話ができる」のコンセプトは微妙だった
    • やはり音声インターフェースの強みは「手」と「目」がいらないことなんだと改めて感じた。いちいちスマートフォンを覗き込みにいかないといけないとなると便利さ半減だなと料理している時に感じた。結局はスマートフォンが置いてある場所に動線が影響を受けるように感じた
    • 意外とスマートフォンが目を認識する精度は高く、逆に高すぎるせいで変に検知されないよう検知しづらい場所に置いて使っていたため、ちょっと不自然な動きをする羽目になった
    • 目を検知しづらい場所に置くのではなく、もう少し別の置き方とか、そもそも目を検知するルールを変更すればもう少し良くなる可能性を感じた。今は目をあわせなくとも両目の存在が検知されたら反応するようにしているが、それだと目が届くところに置きづらいのできちんと目を合わせないと反応しないようにすればもう少し使いやすくなるかもしれないと感じた
    • もしくは目を合わせる。ではなく、 Hand Pose Detection に置き換えても良いかも知れない
    • ウェイクワード無しで目を見ると話しかけられるのはそれなりに簡単だし楽だと感じた一方、ファミリーの環境だと誰に話しかけてるのか明示的でなくなってしまうのでちょっと微妙なのかも知れないなと感じた

これらの機能は、 Flutter のパッケージ越しに SFSpeechRecognizer や AVSpeechUtterance など iOS 純正の Framework のみで実現しています。また、同じパッケージで Android の純正 API のみで実現することも可能です。 さらに、まだ stable channel では提供されていないため今回は取り組みませんでしたが Web app や Linux app の開発もサポートを予定しているようなのでこれらのプラットフォームについてもコストをかけずに検証が可能になるかもしれません。

このように、さまざまなプラットフォームの技術的な検証を小さい学習コストで実現出来ました。

終わりに

今回は、モバイル向け OS 純正の音声認識ライブラリの性能を、出来る限り小さい学習コストで検証するために Flutter を活用しました。

Flutter を使い開発したアプリケーションはキッチンで料理をするのに十分使えるレベルのクオリティであったため、実際にそのアプリケーションを使いキッチンで料理をするプロトタイピングを行いました。

これにより、音声認識ライブラリの認識性能について、私たちが想定する利用シーンに特有の事柄に対しても検証をすることが出来ました。例えば今回は換気扇や水が流れているシーンでも問題なく認識できうることがわかったのは大きな収穫でした。

また、今回コンセプトとして据えた「目を見て話せる」というのはキッチンで料理をするシーンでは微妙で、もう少し洗練させるか、もっと別の良いトリガーを考える必要があると感じました。 これもモバイル向け OS の画像認識ライブラリの性能や仕様によるところがあるため、例えばペーパープロトタイピングなどでは実感するのが難しく、実際に簡単なアプリケーションを作って料理をしたおかげで得られた収穫でした。

このように、技術的な検証を含む日常的なプロトタイピングにおいて、 Flutter を活用することでモバイル向け OS のアプリケーションを簡単に開発し、プロトタイピングすることが出来ました。 今後も、より多くのプラットフォームで動くようになることを期待しつつ、さまざまなプロトタイピングのシーンで活用していこうと思います。

ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築

$
0
0

こんにちは。PlayStation 5が一向に買えない@giginetです。普段はモバイル基盤部というところでiOSの基盤開発をしています。

皆さん、行動していますか?我々は日々Webサービス上で様々な行動をしています。サービス開発において、改善に活かすための効率的な行動ログの収集方法はしばしば課題になります。

今回は、サービス開発者がモバイルアプリ上で簡単にログを定義し、分析を行えるログ基盤を導入した事例について紹介します。

行動ログとは何か

モバイルアプリの行動ログとは、ユーザーのアプリ上の操作や利用状況を取得、集積するためのものです。 例えば、特定の画面を表示したり、特定のボタンをタップしたり、といったユーザー操作を起点として送信されています。

集められたログは、サービス開発のための分析や実態把握に役立てられます。

最近はFirebase Analyticsなど、PaaSの形態で提供されるログ基盤も増えてきました。 一方で、クックパッドのようなサービス規模になると、流量やコスト、ニーズへの適合という面から独自のログ基盤を構築しています。

以下の記事では、クックパッドを支えるログ基盤の概要について説明しています。主にバックエンドの構成などに興味がある方はご覧ください。

一般的な行動ロガーの実装

この記事ではクライアントサイドのログ実装に着目していきましょう。 なお、今回紹介するログ基盤は、どのクライアント実装によっても利用できる仕組みですが、この記事では、Swiftで記述したiOSアプリでの利用を例に取っています。

一般的な行動ロガーの実装として、以下のようなものが思い浮かぶでしょう。

Analytics.logEvent("select_content", parameters:["content_type": "image","content_id": "P12453","items": [["name": "Kittens"]]
])

これはFirebase Analyticsのドキュメントで説明されているロギングの実装例です。

この方式ですと、クライアント側から任意のログを柔軟に送ることができます。しかし、スキーマレスであるこのようなログ実装の規模が大きくなっていったとき、すぐに収集がつかなくなってしまうのは想像に難くないでしょう。

これまでの問題点

クックパッドアプリの規模になると、日々膨大な量のログが送付され、リリースを重ねるごとにログの送信箇所が増えていきます。

また、多くの開発者が関わっているため、実装者のほか、後世でそのログを分析する人やディレクターも細かな仕様を把握する必要があります。実際に起こりうる問題について見て行きましょう。

行動ログへの注釈が難しい

現実のサービス開発における行動ログには様々なコンテキストが含まれています。

例えば、その行動ログがどのタイミングで送信されるのか、どのバージョンから実装されているのか、どのような定義域の値を送るのかなどでしょう。

クックパッドでは、これらのコンテキストに注釈を与えるため、dmemoという社内サービスを用いています。

dmemoを用いると、データウェアハウス(DWH)上の全てのテーブルやカラムにメタ情報を付与することができます。

f:id:gigi-net:20201102143355p:plain
データベースドキュメント管理ツールdmemo

しかし、現実には、柔軟すぎるログ実装には様々な問題があり、その度に各ログの持つコンテキストが膨大な物になっていきます。

バージョンごとの挙動差異の追跡が難しい

モバイルアプリのリリースごとの細かな挙動の変化を追うのが難しい問題もありました。

ログの実装は、バージョン毎に細かな挙動が修正されたり、特定のバージョンにおいては不具合によって期待した値が送られていないという問題も度々起こりえます。

このような場合もdmemoに注釈を付与して管理する必要がありました。

f:id:gigi-net:20201102143437p:plain
dmemoでのバージョン毎の挙動差異の注釈の様子

ログの実装ミスが防げない

冒頭のロガーの例のようなスキーマレスなインターフェイスは、実装時の柔軟性が上がりますが、実装時のミスを防ぐことが難しくなります。

フィールド名のtypoや、必要なペイロードの欠損、型の間違いなどのミスは常に起こりえます。

ログの実装をミスしたリリースが世に出てしまうと何が起きるのでしょうか。そうです、またdmemoへの記述量が増えていきます。

ログイベントの廃止が難しい

ログの廃止の問題もありました。分析の必要性がなくなったり、該当する機能そのものがなくなったりしたケースです。

この場合は、あるバージョンからログが送信されなくなります。同様に、dmemoに全て記述しておく必要があります。

ログの送付し忘れやデグレーションに気付きづらい

逆に、送信しなくてはならないログの送信がされていないケースもあります。

これは、実装時の単なる実装漏れから、リファクタリング時にうっかり処理が消えてしまうことも考えられます。

上記のログの廃止も併せ、それぞれのログが送られているべきなのか、そうではないのか、確認が必要でした。

これらの問題は、全て現実世界における行動ログの持つコンテキストが膨大なことに起因します。このようなドキュメンテーションの多くをdmemoに依存する必要がありました。

ドキュメントからロガーの実装を生成するログ基盤

そこで、今回実現したのが、Markdownで書かれたログ定義ドキュメントからのログ実装の自動生成です。

ログ定義をヒューマンリーダブルなドキュメントとして記述し、そこからクライアント実装のコードを自動生成します。

例を見てみましょう。

1. ログ仕様を決めてドキュメントを記述する

まずログの仕様を決め、そのドキュメントを専用の文法に従ってMarkdownで記述します。

# recipe_search

レシピ検索画面のイベントです

## search

レシピ検索を行った際に送付されます

- keyword: !string 256
    -検索キーワード
- order: SearchOrder
    -検索順
    - latest,popularity

## show_recipe

検索結果画面からレシピ詳細画面に遷移する際に送付されます

- recipe_id: !integer
    -表示したレシピのID

このとき、ログ定義にはドキュメントを追記することもできます。

ログ定義の構成

このMarkdownがどのような構造になっているかを簡単に見て行きましょう。ログ定義はカテゴリ、イベント、カラムの3つの要素で構成されます。

カテゴリ (recipe_search)

複数のイベントの集合です。近しい機能(例えば一つの画面上に実装されているものなど)を同一のカテゴリとして扱います。

大見出しがカテゴリを表現します。カテゴリはいくつかのイベントを持ちます。

イベント (search, show_recipe)

特定の行動に対応する単位です。「検索を行った」(search)や「レシピを表示した」(show_recipe)などの単位が挙げられます。

それぞれの小見出しが、そのカテゴリに属するイベントを表現します。

カラム

各イベントに付与されるペイロードを表します。各カラムは型を持ち、この型がデータベース上の型と一致します。

また、全てのイベントが共通して持つペイロードは別途定義しています。 例えば、送信日時やユーザーID、送信者のOSやアプリケーションバージョンといった項目が挙げられます。

2. クライアント用のログ実装を生成する

次に、トランスパイラを実行します。

$ ./generate-log-classes

これによって、例えばiOSアプリにおいては、以下のようなSwiftのソースコードが生成されます。

ここで生成された RecipeSearchが、アプリケーション上でログイベントを表現する型となります。

例えば、レシピ検索のイベントはRecipeSearch.searchとして利用できます。

/// レシピ検索画面のイベントですpublicenumRecipeSearch:LogCategory {
    publicstaticvarcategoryName:String { "recipe_search" }
    publicvareventName:String {
        switchself {
        case .search:return"search"case .showRecipe:return"show_recipe"
        }
    }
    publicfuncmakePayload() ->[String: Any] {
        switchself {
        caselet .search(keyword, order):return [
                "keyword":keyword.validateLength(within:256).dump(),
                "order":order.dump(),
            ].compactMapValues { $0 }
        caselet .showRecipe(recipeId):return [
                "recipe_id":recipeId.dump(),
            ].compactMapValues { $0 }
        }
    }

    /// レシピ検索を行った際に送付されますcase search(keyword:String, order:SearchOrder)
    /// 検索結果画面からレシピ詳細画面に遷移する際に送付されますcase showRecipe(recipeId:Int64)
}

3. アプリケーション上でログを送信する

最後に生成されたログイベントを用いて、ログを送付します。

ログを送付したいタイミングで以下を呼び出します。

presentRecipeDetailViewController(recipeID:42)
logger.postLog(RecipeSearch.showRecipe(recipeId:42))

このようにアプリケーション開発者は、ログの仕様をMarkdownで記述するだけで、簡単にログの実装を完了できます。

このロガーは、冒頭に紹介したスキーマレスなロガーと違い、各ログイベントが型で保証されています。 これにより、フィールド名の間違い、欠損、型の間違いなどのヒューマンエラーを防いでいます。

ログ基盤の構成

このログ基盤は、以下のような構成で実現されています。

f:id:gigi-net:20201102143935p:plain
ログ基盤の構成

ログ定義

ログ定義はMarkdownで記述され、各アプリケーションのリポジトリ上に置かれています。

ログ定義と実装が紐付くことで、ログ定義の変遷やバージョン毎の挙動の違いも、通常のソースコードと同様にバージョン管理ツールから追跡可能になりました。

Markdownパーサー

ログ定義Markdownをパースし、中間表現に変換します。これを実現するMarkdownコンパイラ daifukuを実装しました。Rubyで記述しています。

daifukuは、Markdownを中間表現(RubyオブジェクトやJSONなど)に変換することのみを担当します。ここで重要なのは、daifukuはコード生成の責務を負わないことです。

コード生成は各プロジェクトがパースされた中間表現を読み取って行います。

これにより、変換先の実装言語によらず、汎用的にこのログ基盤を利用できるようになりました。

クックパッドでは、これと同様のログ生成の仕組みを、iOSアプリのほか、Androidアプリと、Webフロントエンドでも実現しています。 daifukuが生成した中間表現を、それぞれSwift, Kotlin, TypeScriptのコードにトランスパイルすることで実現しています。

コンパイル時にログ定義の簡易的なバリデーションも行っています。定義が命名規則に合致するかチェックしたり、利用できないカラム名を定義していないかなどです。

トランスパイラ(コード生成機)

daifukuが生成した中間表現を、テンプレートエンジンを用いて、各言語の実装にトランスパイルします。 このトランスパイラは、各プロジェクト毎に用意されています。多くはRubyで実装されていますが、任意の言語で実装できます。

ロガー

トランスパイラが生成したログイベントを解釈し、ログ送信ライブラリに渡す層です。

アプリケーション開発者は、ログを送りたい場面でログイベントオブジェクトを作り、ロガーに渡します。

logger.postLog(RecipeSearch.showRecipe(recipeId:42))

ログ送信ライブラリ

最後が、収集されたログをログバックエンドに送信するための層です。

iOSアプリでは、以前からクックパッドで利用されている、ログ収集ライブラリのPureeを用いています。

Pureeはログのバッファリングや永続化を自動的に行い、非同期でログバックエンドへのバッチ送信を行うライブラリです。

PureeはOSSとして公開されており、他社のプロダクトでも多く利用されています。

さらに簡単で安全なログ開発基盤へ

この仕組みを運用することで、冒頭に挙げた問題の多くを解決することができるようになりました。

ここからは、利便性と堅牢性をさらに高める、発展的な機能について説明していきます。

廃止になったログイベントの表現

ログイベントの廃止を、特殊なアノテーションで指定することもできます。

## [obsolete] tap_promotion_banner
    -バナーをタップしたときに送付されます
    -バージョンXX.Xからバナーは表示されなくなりました

ログ定義に[obsolete]指定を追加すると、このログイベントをコード生成から除外することができます。

単純なドキュメントからの削除ではなく、特殊なアノテーションを付加して記述できるようにしているのは、ログ調査の簡略化のための工夫です。 これにより、Markdown上にドキュメントを残しつつ、アプリケーションからは送ることができないログを表現できます。

ログ定義の静的解析による実装し忘れの検知

冒頭で挙げた、ログの送信し忘れ、実装漏れはある程度の静的解析でチェックすることができるようになりました。 前述のobsolete指定がされていないかつ、アプリケーション上のどこからも送信されていないログは、実装忘れである可能性が高くなります。

このチェックをCI上で実行し、利用されていないログイベントが見つかった場合は、実装か廃止を促します。

IDEのドキュメンテーションとの統合

トランスパイル時に、各言語のドキュメンテーションの仕組みに沿ったコメントを生成することで、ドキュメントをIDEに統合することもしています。

Markdownに記述したドキュメントは、コード生成時にログイベントのDocumentation Commentとして出力されます。 これにより、実装時に簡単にログの仕様を把握することができます。

f:id:gigi-net:20201102144026p:plain
Xcode上でログ定義をコード補完している様子

言語機能による型安全なログイベント

コード生成時に出力先の言語の型システムを利用することで、さらに堅牢なロガーの実装を生成できます。

もう一度、レシピ検索の行動ログの例を見てみましょう。

# recipe_search

## search

- keyword: !string 256
    -検索キーワード
- exclude_keyword: !string? 256
    -除外キーワード
- order: SearchOrder
    -検索順
    - recent,popularity

このとき、各カラムの型としてデータベースに格納するプリミティブな型(!から始まります)以外に、特殊な型指定を利用することで、ログイベントが要求するペイロードの型を細かく制御することができます。

いくつか例を見ていきましょう。

オプショナル型

型指定に?を付けることで、仕様上nullが入りうるカラム、そうではないカラムを区別することができます。

exclude_keywordカラムは検索時の除外キーワードです。通常のキーワードが必須であるのに対し、除外キーワードは必須ではありません。

オプショナル型は、上記の例にある exclude_keywordのような、状況により付与しないカラムの表現に役立ちます。

オプショナル型でないカラムは、自動的にnullを非許容とするため、実装時に空の値が送られてしまうことを型システムで防ぐことができます。*1

logger.postLog(RecipeSearch.search(keyword:nil, excludeKeyword:nil)) // keywordにnilは渡すことができない

カスタム型

カスタム型を使って、任意の型をペイロードとして渡すこともできます。代表的な使用例は、特定の定義域しか取らない文字列型の表現です。

orderカラムは検索順を記録する文字列型のカラムです。新着順(recent)か、人気順(popularity)のいずれかの値を取ります。

このカラムを単なる文字列型として扱ってしまうと、アプリケーション実装者のミスにより、定義外の値が混入してしまう可能性があります。

logger.postLog(RecipeSearch.search(order:"oldest")) // このorderは渡せない

そこで、このようなenumを定義し、ログイベントがこの値を要求することで、定義外の値を送ってしまうことを型システムで防いでいます。

publicenumSearchOrder:String, ColumnType {
    case recent
    case popularity
}

この場合は実態はStringのenumなので、ログバックエンドには文字列型として送信されます。

これらの工夫により、アプリケーション実装者が利用するロガーは型が保証されています。これにより、意図しない値が入り込むことを実装レベルで防いでいます。

logger.postLog(RecipeSearch.search(keyword:"いちごどうふ", 
                                                             excludeKeyword:nil, 
                                                             order: .popularity))

まとめ

今回はクックパッドのログ基盤のフロントエンドでの取り組みを中心にお伝えしました。

ログ定義がドキュメントベースで管理されるようになったことで、冒頭で挙げた種々の問題を一度に解決することができました。

このログ基盤の導入以前には、ログの実装コンテキストを追ったり、実装ミスを防ぐのが難しい状況でした。 しかし、導入後は、ドキュメントとログ実装が紐付くことで、開発・分析時共に、ログの設計、実装、利用が全て容易に行えるようになったことがおわかり頂けたと思います。

ログ基盤は、組織によってニーズが異なり、なかなか汎用的な仕組みを作ることが難しい領域だと感じています。 この記事が最高のログ基盤を作るための一助となりましたら幸いです。

クックパッドでは最高の行動ログ基盤を使って開発したいエンジニアを募集しています。

*1:なお、どれだけ気をつけてもDBに到達する頃にはなぜかnullになっているケースを完全に防げないことはあまりにも有名である

nerman: AllenNLP と Optuna で作る固有表現抽出システム

$
0
0

事業開発部の @himktです.好きなニューラルネットは BiLSTM-CRF です. 普段はクックパッドアプリのつくれぽ検索機能の開発チームで自然言語処理をしています.

本稿では,レシピテキストからの料理用語抽出システム nermanについて紹介します. nerman の由来は ner (固有表現抽出 = Named Entity Recognition) + man (する太郎) です. クックパッドに投稿されたレシピから料理に関する用語を自動抽出するシステムであり,AllenNLP と Optuna を組み合わせて作られています. (コードについてすべてを説明するのは難しいため,実際のコードを簡略化している箇所があります)

料理用語の自動抽出

料理レシピには様々な料理用語が出現します. 食材や調理器具はもちろん,調理動作や食材の分量なども料理用語とみなせます. 「切る」という調理動作を考えても,「ざく切りにする」「輪切りにする」「みじん切りにする」など,用途に合わせて色々な切り方が存在します. レシピの中からこのような料理用語を抽出できれば,レシピからの情報抽出や質問応答などのタスクに応用できます.

料理用語の自動抽出には,今回は機械学習を利用します. 自然言語処理のタスクの中に,固有表現抽出というタスクが存在します. 固有表現抽出とは,自然言語の文(新聞記事などの文書が対象となることが多いです)から人名や地名,組織名などの固有表現を抽出するタスクです. このタスクは系列ラベリングと呼ばれる問題に定式化できます. 系列ラベリングを用いた固有表現抽出では,入力文を単語に分割したのち各単語に固有表現タグを付与します. タグが付与された単語列を抽出することで固有表現が得られます.

固有表現抽出の例
一般的な固有表現抽出(上)と料理用語への応用例(下)

今回は人名,地名などの代わりに食材名,調理器具名,調理動作の名前などを固有表現とみなしてモデルを学習します. 詳細な固有表現タグの定義は次の章で説明します.

データセット

機械学習モデルの学習には教師データが必要です. クックパッドでは言語データ作成の専門家の方に協力していただき,アノテーションガイドラインの整備およびコーパスの構築に取り組みました. レシピからの固有表現抽出については京都大学の森研究室でも研究されています(論文はこちら. PDF ファイルが開かれます). この研究で定義されている固有表現タグを参考にしつつ,クックパッドでのユースケースに合わせて次のような固有表現タグを抽出対象として定義しました.

固有表現タグの一覧
固有表現タグの一覧

この定義に基づき,クックパッドに投稿されたレシピの中から 500 品のレシピに対して固有表現を付与しました. データは Cookpad Parsed Corpus と名付けられ,社内の GitHub リポジトリで管理されています. また,機械学習モデルで利用するための前処理(フォーマットの変更など)をしたデータが S3 にアップロードされています.

Cookpad Parsed Corpus に関するアウトプットとして論文化にも取り組んでいます. 執筆した論文は自然言語処理の国際会議である COLING で開催される言語資源に関する研究のワークショップ LAW(Linguistic Annotation Workshop)に採択されました. 🎊

タイトルは以下の通りです.

Cookpad Parsed Corpus: Linguistic Annotations of Japanese Recipes
Jun Harashima and Makoto Hiramatsu

Cookpad Parsed Corpus に収録されているレシピは固有表現の他にも形態素と係り受けの情報が付与されており, 現在大学等の研究機関に所属されている方に利用いただけるように公開の準備を進めています.

準備: AllenNLP を用いた固有表現抽出モデル

nerman ではモデルは AllenNLPを用いて実装しています.

github.com

AllenNLP は Allen Institute for Artificial Intelligence (AllenAI) が開発している自然言語処理フレームワークであり, 最新の機械学習手法に基づく自然言語処理のためのニューラルネットワークを簡単に作成できる便利なライブラリです. AllenNLP は pipでインストールできます.

pip install allennlp

AllenNLP ではモデルの定義や学習の設定を Jsonnet 形式のファイルに記述します. 以下に今回の固有表現抽出モデルの学習で利用する設定ファイル(config.jsonnet)を示します. (モデルは BiLSTM-CRF を採用しています.)

  • config.jsonnet
local dataset_base_url = 's3://xxx/nerman/data';

{
  dataset_reader: {
    type: 'cookpad2020',
    token_indexers: {
      word: {
        type: 'single_id',
      },
    },
    coding_scheme: 'BIOUL',
  },
  train_data_path: dataset_base_url + '/cpc.bio.train',
  validation_data_path: dataset_base_url + '/cpc.bio.dev',
  model: {
    type: 'crf_tagger',
    text_field_embedder: {
      type: 'basic',
      token_embedders: {
        word: {
          type: 'embedding',
          embedding_dim: 32,
        },
      },
    },
    encoder: {
      type: 'lstm',
      input_size: 32,
      hidden_size: 32,
      dropout: 0.5,
      bidirectional: true,
    },
    label_encoding: 'BIOUL',
    calculate_span_f1: true,
    dropout: 0.5,
    initializer: {},
  },
  data_loader: {
    batch_size: 10,
  },
  trainer: {
    num_epochs: 20,
    cuda_device: -1,
    optimizer: {
      type: 'adam',
      lr: 5e-4,
    },
  },
}

モデル,データ,そして学習に関する設定がそれぞれ指定されています. AllenNLP はデータセットのパスとしてローカルのファイルパスだけでなく URL を指定できます. 現状では httphttps,そして s3のスキーマに対応しているようです. (読んだコードはこのあたり) nerman では train_data_pathおよび validation_data_pathに S3 上の加工済み Cookpad Parsed Corpus の学習データ,バリデーションデータの URL を指定しています.

AllenNLP は自然言語処理の有名なタスクのデータセットを読み込むためのコンポーネントを提供してくれます. しかしながら,今回のように自分で構築したデータセットを利用したい場合には自分でデータセットをパースするクラス(データセットリーダー)を作成する必要があります. cookpad2020は Cookpad Parsed Corpus を読み込むためのデータセットリーダーです. データセットリーダーの作成方法については公式チュートリアルで 説明されているので詳しく知りたい方はそちらを参照いただければと思います.

設定ファイルが作成できたら, allennlp train config.jsonnet --serialization-dir resultのようにコマンドを実行することで学習がはじまります. 学習のために必要な情報すべてが設定ファイルにまとまっていて,実験を管理しやすいことが AllenNLP の特徴の1つです. serialization-dirについては後述します.

今回の記事では紹介しませんが, allennlpコマンドには allennlp predictallennlp evaluateなどの非常に便利なサブコマンドが用意されています. 詳しく知りたい方は公式ドキュメントを参照ください.

nerman の全体像

以下に nerman の全体像を示します.

nerman の全体像
nerman の全体像

システムは大きく分けて 3 つのバッチから構成されています.それぞれの役割は以下の通りです.

  • (1) ハイパーパラメータ最適化
  • (2) モデルの学習
  • (3) 実データ(レシピ)からの固有表現抽出(予測)

本稿では,順序を入れ替えて モデルの学習 =>実データでの予測 =>ハイパーパラメータ最適化 の順に解説していきます.

モデルの学習

モデルの学習バッチは以下のようなシェルスクリプトを実行します.

  • train
#!/bin/bash

allennlp train \
  config/ner.jsonnet \--serialization-dir result \--include-package nerman

# モデルとメトリクスのアップロード
aws s3 cp result/model.tar.gz s3://xxx/nerman/model/$TIMESTAMP/model.tar.gz
aws s3 cp result/metrics.json s3://xxx/nerman/model/$TIMESTAMP/metrics.json

準備の章で解説したように, allennlp trainコマンドでモデルを学習します. --serialization-dirで指定しているディレクトリにはモデルのアーカイブ(tar.gz 形式), アーカイブファイルの中にはモデルの重みの他に標準出力・標準エラー出力,そして学習したモデルのメトリクスなどのデータが保存されます.

学習が終わったら, allennlp trainコマンドによって生成されたモデルのアーカイブとメトリクスを S3 にアップロードします. (アーカイブファイルにはモデルの重みなどが保存されており,このファイルがあれば即座にモデルを復元できます.) また,メトリクスファイルも同時にアップロードしておくことで,モデルの性能をトラッキングできます.

S3
S3 の様子.実行日ごとにモデルのアーカイブとメトリクスがアップロードされる.

  • metrics.json

生成されるメトリクスファイル.性能指標だけでなく学習時間や計算時間などもわかります)

{"best_epoch": 19,
  "peak_worker_0_memory_MB": 431.796,
  "training_duration": "0:29:38.785065",
  "training_start_epoch": 0,
  "training_epochs": 19,
  "epoch": 19,
  "training_accuracy": 0.8916963871929718,
  "training_accuracy3": 0.8938523846944327,
  "training_precision-overall": 0.8442808607021518,
  "training_recall-overall": 0.8352005377548734,
  "training_f1-measure-overall": 0.8397161522865011,
  "training_loss": 38.08172739275527,
  "training_reg_loss": 0.0,
  "training_worker_0_memory_MB": 431.796,
  "validation_accuracy": 0.8663015463917526,
  "validation_accuracy3": 0.8688788659793815,
  "validation_precision-overall": 0.8324965769055226,
  "validation_recall-overall": 0.7985989492119089,
  "validation_f1-measure-overall": 0.815195530726207,
  "validation_loss": 49.37634348869324,
  "validation_reg_loss": 0.0,
  "best_validation_accuracy": 0.8663015463917526,
  "best_validation_accuracy3": 0.8688788659793815,
  "best_validation_precision-overall": 0.8324965769055226,
  "best_validation_recall-overall": 0.7985989492119089,
  "best_validation_f1-measure-overall": 0.815195530726207,
  "best_validation_loss": 49.37634348869324,
  "best_validation_reg_loss": 0.0,
  "test_accuracy": 0.875257568552861,
  "test_accuracy3": 0.8789031542241242,
  "test_precision-overall": 0.8318906605922551,
  "test_recall-overall": 0.8214125056230319,
  "test_f1-measure-overall": 0.8266183793571253,
  "test_loss": 48.40180677297164}

モデルの学習は EC2 インスタンス上で実行されます. 今回のケースではデータセットは比較的小さく(全データ = 500 レシピ), BiLSTM-CRF のネットワークもそこまで大きくありません. このため,通常のバッチジョブとほぼ同じ程度の規模のインスタンスでの学習が可能です. 実行環境が GPU や大容量メモリなどのリソースを必要としないため,通常のバッチ開発のフローに乗ることができました. これにより,社内に蓄積されていたバッチ運用の知見を活かしてインフラ環境の整備にかかるコストを抑えつつ学習バッチを構築できています.

また, nerman のバッチはすべてスポットインスタンスを前提として構築されています. スポットインスタンスは通常のインスタンスよりもコストが低く, 代わりに実行中に強制終了する(spot interruption と呼ばれる)可能性があるインスタンスです. モデルの学習は強制終了されてしまってもリトライをかければよく,学習にかかる時間が長すぎなければスポットインスタンスを利用することでコストを抑えられます. (ただし,学習にかかる時間が長ければ長いだけ spot interruption に遭遇する可能性が高くなります. リトライを含めた全体での実行時間が通常のインスタンスでの実行時間と比較して長くなりすぎた場合, かえってコストがかかってしまう可能性があり,注意が必要です.)

実データでの予測

以下のようなシェルスクリプトを実行して予測を実行します.

  • predict
#!/bin/bashexport MODEL_VERSION=${MODEL_VERSION:-2020-07-08}export TIMESTAMP=${TIMESTAMP:-`date '+%Y-%m-%d'`}export FROM_IDX=${FROM_IDX:-10000}export LAST_IDX=${LAST_IDX:-10100}export KUROKO2_PARALLEL_FORK_INDEX=${KUROKO2_PARALLEL_FORK_INDEX:--1}export KUROKO2_PARALLEL_FORK_SIZE=${KUROKO2_PARALLEL_FORK_SIZE:--1}if [$KUROKO2_PARALLEL_FORK_SIZE=-1]||[$KUROKO2_PARALLEL_FORK_INDEX=-1];thenecho$FROM_IDX$LAST_IDX' (without parallel execution)'elseif (($KUROKO2_PARALLEL_FORK_INDEX>=$KUROKO2_PARALLEL_FORK_SIZE));thenecho'$KUROKO2_PARALLEL_FORK_INDEX'=$KUROKO2_PARALLEL_FORK_INDEX'must be smaller than $KUROKO2_PARALLEL_FORK_SIZE'$KUROKO2_PARALLEL_FORK_SIZEexitfi# ==============================================================================# begin: FROM_IDX ~ LAST_IDX のデータを KUROKO2_PARALLEL_FORK_SIZE の値で等分する処理# ==============================================================================NUM_RECORDS=$(($LAST_IDX - $FROM_IDX))echo'NUM_RECORDS = '$NUM_RECORDSif (($NUM_RECORDS % $KUROKO2_PARALLEL_FORK_SIZE!=0));thenecho'$KUROKO2_PARALLEL_FORK_SIZE = '$KUROKO2_PARALLEL_FORK_SIZE'must be multiple of $NUM_RECORDS='$NUM_RECORDSexitfiDIV=$(($NUM_RECORDS / $KUROKO2_PARALLEL_FORK_SIZE))echo'DIV='$DIVif (($DIV<=0));thenecho'Invalid DIV='$DIVexitfiLAST_IDX=$(($FROM_IDX + (($KUROKO2_PARALLEL_FORK_INDEX + 1) * $DIV)))FROM_IDX=$(($FROM_IDX + ($KUROKO2_PARALLEL_FORK_INDEX * $DIV)))echo'$FROM_IDX = '$FROM_IDX' $LAST_IDX = '$LAST_IDX# ============================================================================# end: FROM_IDX ~ LAST_IDX のデータを KUROKO2_PARALLEL_FORK_SIZE の値で等分する処理# ============================================================================fi

allennlp custom-predict \--from-idx$FROM_IDX\--last-idx$LAST_IDX\--include-package nerman \--model-path s3://xxx/nerman/model/$MODEL_VERSION/model.tar.gz

aws s3 cp \--recursive\--exclude"*"\--include"_*.csv"\
    prediction \
    s3://xxx/nerman/output/$TIMESTAMP/prediction/

予測バッチは学習バッチが作成したモデルを読み込み,固有表現が付与されていないレシピを解析します. また,予測バッチは並列実行に対応しています. クックパッドには 340 万品以上のレシピが投稿されており,これらのレシピを一度に解析するのは容易ではありません. このため,レシピを複数のグループに分割し,それぞれを並列に解析しています.

並列処理の様子
並列処理の様子

FROM_RECIPE_IDXLAST_RECIPE_IDXで解析対象とするレシピを指定し, KUROKO2_PARALLEL_FORK_SIZEという環境変数で並列数を設定します. 並列実行されたプロセスには KUROKO2_PARALLEL_FORK_INDEXという変数が渡されるようになっていて,この変数で自身が並列実行されたプロセスのうち何番目かを識別します. プロセスの並列化は社内で利用されているジョブ管理システム kuroko2の並列実行機能 (parallel_fork) を利用して実現しています.

custom-predictコマンドは上で定義した変数を用いて対象となるレシピを分割し, AllenNLP のモデルを用いて固有表現を抽出するコマンドです. AllenNLP では自分でサブコマンドを登録でき,このようにすべての処理を allennlpコマンドから実行できるようになっています. サブコマンドは以下のように Python スクリプト(predict_custom.py)を作成して定義できます. (サブコマンドについての公式ドキュメントはこちら

  • custom_predict.py
import argparse

from allennlp.commands import Subcommand

from nerman.data.dataset_readers import StreamSentenceDatasetReader
from nerman.predictors import KonohaSentenceTaggerPredictor


defcreate_predictor(model_path) -> KonohaSentenceTaggerPredictor:
    archive = load_archive(model_path)
    predictor = KonohaSentenceTaggerPredictor.from_archive(archive)
    dataset_reader = StreamSentenceDatasetReader(predictor._dataset_reader._token_indexers)
    return KonohaSentenceTaggerPredictor(predictor._model, dataset_reader)


def_predict(
  from_idx: int,
  last_idx: int,
  model_path: str,
):

    # predictor の作成
    predictor = create_predictor(model_path)
    ...  # Redshift からデータを取ってきたりモデルに入力したりする処理(今回の記事では解説は割愛します)defpredict(args: argparse.Namespace):
    from_idx = args.from_idx
    last_idx = args.last_idx
    _predict(from_idx, last_idx)


@Subcommand.register("custom-predict")
classCustomPrediction(Subcommand):
    @overridesdefadd_subparser(self, parser: argparse._SubParsersAction) -> argparse.ArgumentParser:
        description = "Script to custom predict."
        subparser = parser.add_parser(self.name, description=description, help="Predict entities.")

        subparser.add_argument("--from-idx", type=int, required=True)
        subparser.add_argument("--last-idx", type=int, required=True)
        subparser.add_argument("--model-path", type=str, required=True)

        subparser.set_defaults(func=predict)  # サブコマンドが呼ばれたときに実際に実行するメソッドを指定するreturn subparser

model_pathという変数にはモデルのアーカイブファイルのパスが指定されています. アーカイブファイルのパスは load_archiveというメソッドに渡されます. load_archiveは AllenNLP が提供しているメソッドであり,これを利用すると保存された学習済みモデルの復元が簡単にできます. また, load_archiveはデータセットのパスと同様 S3 スキーマに対応しているため,学習バッチでアップロード先に指定したパスをそのまま利用できます. (load_archiveの公式ドキュメントはこちら

文字列をモデルに入力するためには AllenNLP の Predictorという機構を利用しています. 公式ドキュメントはこちらです. 系列ラベリングモデルの予測結果を扱う際に便利な SentenceTaggerPredictorクラスを継承し,以下に示す KonohaSentenceTaggerPredictorクラスを定義しています. predictメソッドに解析したい文字列を入力すると,モデルの予測結果を出力してくれます.

from allennlp.common.util import JsonDict
from allennlp.data import Instance
from allennlp.data.dataset_readers.dataset_reader import DatasetReader
from allennlp.models import Model
from allennlp.predictors import SentenceTaggerPredictor
from allennlp.predictors.predictor import Predictor
from konoha.integrations.allennlp import KonohaTokenizer
from overrides import overrides


@Predictor.register("konoha_sentence_tagger")
classKonohaSentenceTaggerPredictor(SentenceTaggerPredictor):
    def__init__(self, model: Model, dataset_reader: DatasetReader) -> None:
        super().__init__(model, dataset_reader)
        self._tokenizer = KonohaTokenizer("mecab")

    defpredict(self, sentence: str) -> JsonDict:
        return self.predict_json({"sentence": sentence})

    @overridesdef_json_to_instance(self, json_dict: JsonDict) -> Instance:
        sentence = json_dict["sentence"]
        tokens = self._tokenizer.tokenize(sentence)
        return self._dataset_reader.text_to_instance(tokens)

nerman では,日本語のレシピデータを扱うために日本語処理ツールの konohaを利用しています. KonohaTokenizerは Konoha が提供している AllenNLP インテグレーション機能です. 日本語文字列を受け取り,分かち書きもしくは形態素解析を実施, AllenNLP のトークン列を出力します. 形態素解析器には MeCab を採用しており,辞書は mecab-ipadic を使用しています.

github.com

次に,作成したモジュールを __init__.pyでインポートします. 今回は nerman/commandsというディレクトリに custom_predict.pyを設置しています. このため, nerman/__init__.pyおよび nerman/commands/__init__.pyをそれぞれ次のように編集します.

  • nerman/__init__.py
import nerman.commands
  • nerman/commands/__init__.py
from nerman.commands import custom_predict

コマンドの定義およびインポートができたら, allennlpコマンドで実際にサブコマンドを認識させるために .allennlp_pluginsというファイルをリポジトリルートに作成します.

  • .allennlp_plugins
nerman

以上の操作でサブコマンドが allennlpコマンドで実行できるようになります. allennlp --helpを実行して作成したコマンドが利用できるようになっているか確認できます.

得られた予測結果は CSV 形式のファイルとして保存され,予測が終了した後に S3 へアップロードされます.

次に, S3 にアップロードした予測結果をデータベースに投入します. データは最終的に Amazon Redshift (以降 Redshift) に配置されますが, Amazon Aurora (以降 Aurora)を経由するアーキテクチャを採用しています. これは Aurora の LOAD DATA FROM S3ステートメントという機能を利用するためです. LOAD DATA FROM S3ステートメントは次のような SQL クエリで利用できます.

  • load.sql
load
    data
from
    S3 's3://xxx/nerman/output/$TIMESTAMP/prediction.csv'intotable recipe_step_named_entities
    fields terminated by','
    lines  terminated by'\n'
    (recipe_text_id, start, last, name, category)
    set created_at = current_timestamp, updated_at = current_timestamp;

このクエリを実行することで, S3 にアップロードした CSV ファイルを直接 Amazon Aurora にインポートできます. LOAD DATA FROM S3については AWS の公式ドキュメントが参考になります. バッチサイズやコミットのタイミングの調整の手間が必要なくなるため,大規模データをデータベースに投入する際に非常に便利です.

Aurora のデータベースに投入した予測結果は pipelined-migratorという社内システムを利用して定期的に Redshift へ取り込まれます. pipelined-migrator を利用することで,管理画面上で数ステップ設定を行うだけで Aurora から Redshift へデータを取り込めます. これにより, S3 からのロードと pipelined-migrator を組み合わせた手間の少ないデータの投入フローが実現できました.

解析結果をスタッフに利用してもらう方法として,データベースを利用せずに予測 API を用意する方法も考えられます. 今回のタスクの目標は「すでに投稿されたレシピからの料理用語の自動抽出」であり,これはバッチ処理であらかじめ計算可能です. このため, API サーバを用意せずにバッチ処理で予測を行う方針を採用しました.

また,エンジニア以外のスタッフにも予測結果を使ってみてもらいたいと考えていました. クックパッドはエンジニア以外のスタッフも SQL を書ける方が多いため, 予測結果をクエリ可能な形でデータベースに保存しておく方針はコストパフォーマンスがよい選択肢でした. 予測結果を利用するクエリ例を以下に示します.

  • list_tools.sql
select
    , s.recipe_id
    , e.name
    , e.category
from
    recipe_step_named_entities as e
    innerjoin recipe_steps as s on e.step_id = s.id
where
    e.category in ('Tg')
    and s.recipe_id = xxxx

このクエリを Redshift 上で実行することで,レシピ中に出現する調理器具のリストを取得できるようになりました.

SQL の実行結果
SQL の実行結果

Optuna を用いたハイパーパラメータの分散最適化

最後にハイパーパラメータの最適化について解説します.

github.com

nerman では Optunaを用いたハイパーパラメータの最適化を実施しています. Optuna は Preferred Networks (PFN) が開発しているハイパーパラメータ最適化のライブラリです. インストールは pip install optunaをターミナルで実行すれば完了します.

Optuna では,各インスタンスから接続可能なバックエンドエンジン(RDB or Redis)を用意し,それをストレージで使用することで, 複数インスタンスを利用した分散環境下でのハイパーパラメータ最適化を実現できます. (ストレージは Optuna が最適化結果を保存するために使用するもので,RDB や Redis などを抽象化したものです) インスタンスをまたいだ分散最適化を実施する場合,ストレージのバックエンドエンジンは MySQL もしくは PostgreSQL が推奨されています (Redis も experimental feature として利用可能になっています). 詳しくは公式ドキュメントをご参照ください. 今回はストレージとして MySQL (Aurora) を採用しています.

Optuna には AllenNLP のためのインテグレーションモジュールが存在します. しかしながら,このインテグレーションモジュールを使うと自身で最適化を実行するための Python スクリプトを記述する必要があります. そこで, AllenNLP とよりスムーズに連携するために allennlp-optunaというツールを開発しました. allennlp-optunaをインストールすると,ユーザは allennlp tuneというコマンドで Optuna を利用したハイパーパラメータ最適化を実行できるようになります. このコマンドは allennlp trainコマンドと互換性が高く, AllenNLP に慣れたユーザはスムーズにハイパーパラメータの最適化を試せます.

github.com

allennlp tuneコマンドを実行するには,まず pip install allennlp-optuna.gitallennlp-optunaをインストールします. 次に, .allennlp_pluginsを以下のように編集します.

  • .allennlp_plugins
allennlp-optuna
nerman

allennlp --helpとコマンドを実行して,以下のように retrainコマンドと tuneコマンドが確認できればインストール成功です.

$ allennlp --help
2020-11-05 01:54:24,567 - INFO - allennlp.common.plugins - Plugin allennlp_optuna available
usage: allennlp [-h][--version]  ...

Run AllenNLP

optional arguments:
  -h, --help     show this help message and exit--version      show program's version number and exitCommands:    best-params  Export best hyperparameters.    evaluate     Evaluate the specified model + dataset.    find-lr      Find a learning rate range.    predict      Use a trained model to make predictions.    print-results                 Print results from allennlp serialization directories to the console.    retrain      Train a model with hyperparameter found by Optuna.    test-install                 Test AllenNLP installation.    train        Train a model.    tune         Optimize hyperparameter of a model.

allennlp-optunaが無事にインストールできました. 次に allennlp-optunaを利用するために必要な準備について解説します.

設定ファイルの修正

はじめに,準備の章で作成した config.jsonnetを以下のように書き換えます.

  • config.jsonnet (allennlp-optuna用)
// ハイパーパラメータを変数化する
local lr = std.parseJson(std.extVar('lr'));
local lstm_hidden_size = std.parseInt(std.extVar('lstm_hidden_size'));
local dropout = std.parseJson(std.extVar('dropout'));
local word_embedding_dim = std.parseInt(std.extVar('word_embedding_dim'));

local cuda_device = -1;

{
  dataset_reader: {
    type: 'cookpad2020',
    token_indexers: {
      word: {
        type: 'single_id',
      },
    },
    coding_scheme: 'BIOUL',
  },
  train_data_path: 'data/cpc.bio.train',
  validation_data_path: 'data/cpc.bio.dev',
  model: {
    type: 'crf_tagger',
    text_field_embedder: {
      type: 'basic',
      token_embedders: {
        word: {
          type: 'embedding',
          embedding_dim: word_embedding_dim,
        },
      },
    },
    encoder: {
      type: 'lstm',
      input_size: word_embedding_dim,
      hidden_size: lstm_hidden_size,
      dropout: dropout,
      bidirectional: true,
    },
    label_encoding: 'BIOUL',
    calculate_span_f1: true,
    dropout: dropout,  // ここで宣言した変数を指定する
    initializer: {},
  },
  data_loader: {
    batch_size: 10,
  },
  trainer: {
    num_epochs: 20,
    cuda_device: cuda_device,
    optimizer: {
      type: 'adam',
      lr: lr,  // ここで宣言した変数を指定する
    },
  },
}

最適化したいハイパーパラメータを local lr = std.parseJson(std.extVar('lr'))のように変数化しています. std.extVarの返り値は文字列です.機械学習モデルのハイパーパラメータは整数や浮動小数であることが多いため,キャストが必要になります. 浮動小数へのキャストは std.parseJsonというメソッドを利用します.整数へのキャストは std.parseIntを利用してください.

探索空間の定義

次に,ハイパーパラメータの探索空間を定義します. allennlp-optunaでは,探索空間は次のような JSON ファイル(hparams.json)で定義します.

  • hparams.json
[{"type": "float",
    "attributes": {"name": "dropout",
      "low": 0.0,
      "high": 0.8}},
  {"type": "int",
    "attributes": {"name": "lstm_hidden_size",
      "low": 32,
      "high": 256},  },
  {"type": "float",
    "attributes": {"name": "lr",
      "low": 5e-3,
      "high": 5e-1,
      "log": true}}
]

今回の例では学習率とドロップアウトの比率が最適化の対象です. それぞれについて,値の上限・下限を設定します. 学習率は対数スケールの分布からサンプリングするため, "log": trueとしていることに注意してください.

最適化バッチは次のようなシェルスクリプトを実行します.

  • optimize
#!/bin/bashexport N_TRIALS=${N_TRIALS:-20}# Optuna の試行回数を制御するexport TIMEOUT=${TIMEOUT:-7200}# # 一定時間が経過したら最適化を終了する(単位は秒): 60*60*2 => 2hexport TIMESTAMP=${TIMESTAMP:-`date '+%Y-%m-%d'`}export OPTUNA_STORAGE=${OPTUNA_STORAGE:-mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST_NAME/$DB_NAME}export OPTUNA_STUDY_NAME=${OPTUNA_STUDY_NAME:-nerman-$TIMESTAMP}# ハイパーパラメータの最適化
allennlp tune \
  config/ner.jsonnet \
  config/hparam.json \--serialization-dir result/hpo \--include-package nerman \--metrics best_validation_f1-measure-overall \--study-name$OPTUNA_STUDY_NAME\--storage$OPTUNA_STORAGE\--direction maximize \--n-trials$N_TRIALS\--skip-if-exists\--timeout$TIMEOUT

このコマンドを複数のインスタンスで実行することで,ハイパーパラメータの分散最適化が実行できます. オプション --skip-if-existsを指定することで,複数のインスタンスの間で最適化の途中経過を共有しています. Optuna は通常実行のたびに新しく実験環境(studyと呼ばれます)を作成し,ハイパーパラメータの探索を行います. このとき,すでにストレージに同名の study が存在する場合はエラーになります. しかし, --skip-if-existsを有効にすると,ストレージに同名の study がある場合は当該の study を読み込み,途中から探索を再開します. この仕組みによって,複数のインスタンスで --skip-if-existsを有効にして探索を開始することでだけで study を共有した最適化が行われます. 上記のスクリプトによって,最適化バッチは与えられた時間(--timeoutで設定されている値 = 2 時間)に最大 20 回探索を実行します.

このように, Optuna のリモートストレージ機能によって,複数のインスタンスで同じコマンドを実行するだけで分散最適化が実現できました! Optuna の分散ハイパーパラメータ最適化の詳しい仕組み,あるいはより高度な使い方については Optuna 開発者の 解説資料が参考になるので, 興味のある方は合わせてご参照ください.

モデルの再学習

最後に,最適化されたハイパーパラメータを用いてモデルを再学習します. 再学習バッチは以下のようなシェルスクリプトで実行します.

  • retrain
#!/bin/bashexport TIMESTAMP=${TIMESTAMP:-`date '+%Y-%m-%d'`}export OPTUNA_STORAGE=${OPTUNA_STORAGE:-mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST_NAME/$DB_NAME}# 最適化されたハイパーパラメータを用いたモデルの再学習
allennlp retrain \
  config/ner.jsonnet \--include-package nerman \--include-package allennlp_models \--serialization-dir result \--study-name$OPTUNA_STUDY_NAME\--storage$OPTUNA_STORAGE# モデルとメトリクスのアップロード
aws s3 cp result/model.tar.gz s3://xxx/nerman/model/$TIMESTAMP/model.tar.gz
aws s3 cp result/metrics.json s3://xxx/nerman/model/$TIMESTAMP/metrics.json

このシェルスクリプトでは allennlp-optunaが提供する retrainコマンドを利用しています. allennlp retrainコマンドはストレージから最適化結果を取得し,得られたハイパーパラメータを AllenNLP に渡してモデルの学習を行ってくれます. tuneコマンド同様, retrainコマンドも trainコマンドとほぼ同じインターフェースを提供していることがわかります.

再学習したモデルのメトリクスを以下に示します.

  • metrics.json
{"best_epoch": 2,
  "peak_worker_0_memory_MB": 475.304,
  "training_duration": "0:45:46.205781",
  "training_start_epoch": 0,
  "training_epochs": 19,
  "epoch": 19,
  "training_accuracy": 0.9903080859981059,
  "training_accuracy3": 0.9904289830542626,
  "training_precision-overall": 0.9844266427651112,
  "training_recall-overall": 0.9843714989917096,
  "training_f1-measure-overall": 0.9843990701061036,
  "training_loss": 3.0297666011196327,
  "training_reg_loss": 0.0,
  "training_worker_0_memory_MB": 475.304,
  "validation_accuracy": 0.9096327319587629,
  "validation_accuracy3": 0.911243556701031,
  "validation_precision-overall": 0.884530630233583,
  "validation_recall-overall": 0.8787215411558669,
  "validation_f1-measure-overall": 0.8816165165824231,
  "validation_loss": 61.33201414346695,
  "validation_reg_loss": 0.0,
  "best_validation_accuracy": 0.9028672680412371,
  "best_validation_accuracy3": 0.9048002577319587,
  "best_validation_precision-overall": 0.8804444444444445,
  "best_validation_recall-overall": 0.867338003502627,
  "best_validation_f1-measure-overall": 0.873842082046708,
  "best_validation_loss": 38.57948366800944,
  "best_validation_reg_loss": 0.0,
  "test_accuracy": 0.8887303851640513,
  "test_accuracy3": 0.8904739261372642,
  "test_precision-overall": 0.8570790531487271,
  "test_recall-overall": 0.8632478632478633,
  "test_f1-measure-overall": 0.8601523980277404,
  "test_loss": 44.22851959539919}

モデルの学習の章で学習されたモデルと比較して,テストデータでの F値(test_f1-measure-overall)が 82.7から 86.0となり, 3.3ポイント性能が向上しました. ハイパーパラメータの探索空間をアバウトに定めて Optuna に最適化をしてもらえば十分な性能を発揮するハイパーパラメータが得られます.便利です.

Optuna はハイパーパラメータを最適化するだけでなく, 最適化途中のメトリクスの推移やハイパーパラメータの重要度などを可視化する機能, 最適化結果を pandas DataFrame で出力する機能をはじめとする強力な実験管理機能を提供しています. より詳しく AllenNLP と Optuna の使い方を学びたい方は AllenNLP の公式ガイドなども合わせて読んでみてください.

まとめ

本稿では AllenNLP と Optuna を用いて構築した固有表現抽出システム nerman について紹介しました. nerman は AllenNLP を用いたモデル学習・実データ適用, Amazon Aurora を活用したデータ投入の手間の削減, および Optuna を活用したスケーラブルなハイパーパラメータ探索を実現しています. AllenNLP と Optuna を用いた機械学習システムの一例として,読んでくださった皆さんの参考になればうれしいです.

クックパッドでは自然言語処理の技術で毎日の料理を楽しくする仲間を募集しています. 実現したい価値のため,データセットの構築から本気で取り組みたいと考えている方にはとても楽しめる環境だと思います. 興味をもってくださった方はぜひご応募ください! クックパッドの {R&D, サービス開発現場} での自然言語処理についてカジュアルに話を聞きたいと思ってくださった方は @himktまでお気軽にご連絡ください.

2020年のクックパッドAndroidアプリのアーキテクチャ事情

$
0
0

こんにちは、モバイル基盤部の加藤です。普段はモバイルアプリの基盤技術の整備や品質管理の業務に携わっています。 今回はクックパッドAndroidアプリ(以後クックパッドアプリ)の2020年時点でのアーキテクチャの紹介をしたいと思います。

アーキテクチャ導入以前のクックパッドアプリ

2017年以前クックパッドアプリにはアーキテクチャと呼べるようなものが存在していませんでした。大まかに API 通信や DB 操作等のデータ取得箇所を分離し、複雑なロジックを持つ場合は Manager, Util 等の強いオブジェクトが生成されていましたが、それ以外は Activity / Fragment に処理を直接記述することがほとんどでした。

そういった状況の中で今後もアプリを継続的に開発可能にすることを目的にアーキテクチャの導入が始まりました。クックパッドアプリでは iOS/Android 両プラットフォームで VIPER アーキテクチャを採用し、現在に至ります。

VIPER アーキテクチャ

クックパッドアプリで VIPER アーキテクチャを選定した理由を説明する前に、簡単に VIPER アーキテクチャを紹介します。

VIPER は View, Interactor, Presenter, Entity, Routing の頭文字を並べたもので、アーキテクチャはこれらの要素と Contract (契約)を元に構成されます。クックパッドアプリでは VIPER の要素を画面(Activity / Fragment)ごとにまとめ、VIPER の1かたまりを シーン(Scnene)と読んでいます。 これらの要素は大まかにそれぞれ以下のような責務を持ちます(フローに合わせて順序を変えています)。

  • View
    • Entityを描画する。実装クラス(Activity / Fragment)は UI を更新し、UI操作をもとに Presenter を呼び出す。
  • Presenter
    • Presentation Logic の起点を示す。実装クラスは Interactor, Routing を呼び出してPresentation Logic を実装する 。
  • Interactor
    • Presentation Logicを実現する為のBusiness Logicを示す。実装クラスは Presenter からリクエストを受け、ビジネスロジックを処理し、結果を Presenter に返す。
  • Routing
    • 発生しうる画面遷移を示す。実装クラスは Presenter からリクエストを受け画面遷移を行う。
    • 一部の記事では Routerとなっていますが、社内では Routing と読んでいます。
  • Entity
    • VIPER シーン中で利用されるデータそのもの。
  • Contract
    • 上記の要素を内容を定義する VIPER の核。

例えばレシピを描画するような画面の場合、以下のように Contract を定義し VIPER を構築します。

interface RecipeContract {
    interface View {
        fun renderRecipe(recipe: Recipe)
    }

    interface Presenter {
        fun onRecipeRequested(recipeId: Long)
        fun onNavigateNextRecipe(recipeId: Long)
    }

    interface Interactor {
        fun fetchRecipe(recipeId: Long): Single<Recipe>
    }

    interface Routing {
        fun navigateNextRecipe(recipeId: Long)
    }

    dataclass Recipe(
        id: Long,
        title: String
    )
}

さらに詳しい内容については https://www.objc.io/issues/13-architecture/viper/等の他記事を参照してください。 クックパッドアプリではこの VIPER を少し拡張して利用しています。具体的に拡張した箇所については後述します。

また先日サマーインターンシップでクックパッドの VIPER を題材にした技術講義を行ったので、より詳しい実装についてはこちらを参照してください。

スライド: https://speakerdeck.com/ksfee684/cookpad-summer-internship-2020-android

リポジトリ: https://github.com/cookpad/cookpad-internship-2020-summer-android

選定理由

クックパッドアプリで VIPER を採用した理由は主に3つありました。

5年先を見据えて選定

Android というプラットフォームは常に進化を続けています。プラットフォームの進化はアーキテクチャにも大きく関わり、新たな要素が使いづらいようなアーキテクチャでは継続的に開発を行うことは難しいです。実際にアーキテクチャ選定時の2017年から今までで Jetpack Compose や Kotlin Coroutines 等、Android アプリ開発において新たな要素が登場しています。こういった新たな要素を吸収することが可能であり長期的に開発を継続することが可能なアーキテクチャ、具体的には 5年を見据えて選定を行いました。

VIPER アーキテクチャが5年耐えると判断した根拠は後述の2つの要素が中心となっています。

Contract による制約

VIPER は上述したように VIPER の各要素の内容を Contractとして定義し、それに基づいて実装します。この Contract による制約は他のアーキテクチャではほとんど見られない要素であり、各要素の責務とその内容を可視化し、非常に見通しのよいコードを実現できます。

またプラットフォームの進化に合わせて VIPER の概念を拡張する場合には、この Contract を拡張すればよく、Contract でいかに定義するかを考えながらチームで議論することで、よりよいアーキテクチャを育てていける非常に拡張性の高いアーキテクチャだと判断しました。

View を中心としたイベントフロー

VIPER は View(UI) のイベントトリガーを中心にフローが構成されています。View を中心にしてフローを構築する場合、ユーザの操作仕様を直接反映するようにコードを実装する必要があります。そこで VIPER アーキテクチャに合わないような実装が必要となった場合、ユーザ体験を損ねる状態になっていると判断ができることを期待し、VIPER を選択しました。

実際にアーキテクチャに適合しないような無理のある実装があった場合には、実装やそもそもの機能仕様に問題が無いかを考えるきっかけとなっており、他のアーキテクチャではなかなか実現できなかったことだと捉えています。

2020年のクックパッドアプリのアーキテクチャ

VIPER アーキテクチャを拡張させながらクックパッドアプリに最適なアーキテクチャを今も模索しています。現在のクックパッドアプリのアーキテクチャは大雑把に以下の図のようになっていますが、その中でもアーキテクチャ導入時点からクックパッドアプリで導入した内容についていくつか紹介します。

f:id:ksfee:20201116204359p:plain

Rx によるデータフロー

VIPER の概要を説明した際に記述したように、Presenter からリクエストをうけた Interactor はデータを Presenter に返す必要があります。クックパッドではこの処理を Rx を利用してフローを構築しており、Interactor からは Observable が返ります。Presenter は受け取った Observable を subscribe し、そこで流れる Entity を View に受け渡し、UI の更新を促します。後述する Interactor から先の Domain レイヤーでも同様に Rx を利用してフローが構築されています。

最近では一部の実装で Kotlin Coroutines も利用されていますが、まだ Rx から乗り換えるという判断までは至っていません。今後 Kotlin Coroutines / Flow 等の Jetpack コンポーネントでのサポートが拡大した際には乗り換えるかもしれません(Presenter, Interactor 間のやり取りは非常に簡素なものが多いため、Coroutines への乗り換えも比較的簡単に行えるようになっています)。

Domain レイヤー

VIPER から下、具体的には Interactor から下のレイヤーについて説明します。

Interactor は Presenter からリクエストを受けた際、必要なデータを集め Presenter に返します。この時必要なデータを API や DB などから取得しますが、クックパッドアプリではここを Domain レイヤーとしてレイヤー構造を築いています。レイヤーは DataSource, DataStore, UseCase の3つからなり、それぞれ以下のように役割を分けています。

  • DataSource
    • API / DB / メモリからデータの操作を行う
    • 例: API の CRUD 操作
  • DataStore
    • 同じデータに対して複数の DataSource を参照する場合、それらの操作を抽象化して操作を行う
    • 例: API とインメモリキャッシュの操作を抽象化
  • UseCase
    • 共通化したいビジネスロジックを Interactor から切り出したもの
    • 例: 複雑な条件のダイアログ表示の判定

Interactor は DataSource, DataStore, UseCase からそれぞれ必要なデータを取得し、ビジネスロジックを構築します。Domain レイヤーとVIPERレイヤーで世界を分断することで、互いに及ぼす影響を最小限に抑えることでできるよう、Domain レイヤーで扱うデータ型と各 VIPER シーンで利用するデータ型(Entity) は異なっており、Interactor で VIPER の Entity への置き換えが行われています。

Paging の追加

2018年に発表された AAC の1つである Paging ライブラリはページング処理を RecyclerView で扱う際に非常に便利です。クックパッドアプリでもこの Paging ライブラリによるページネーションを実装していますが、Paging の DataSource (以降 PagingDataSource)をどのように実装するかが議論になりました。

PagingDataSource から返る PagedList は直接 Adapter に読み込むため、VIPER では PagedList で扱うオブジェクトは Entity である必要があります。通常であればこのような変換は Interactor で行いますが、Interactor は Presenter からのみ呼び出すことにしており PagingDataSource のどこで変換を行うかが問題になりました。

そこで Domain レイヤーから返るオブジェクトの変換と周辺ロジックをまとめて Pagingという新たな VIPER の要素として定義し、ページング処理が必要となる画面はそれほど多くないため必要な画面のみ Paging を別途用意する方針としました。

今後の課題

現在のアーキテクチャは決して完璧なものではなく、開発を続けていくなかでいくつも課題は出てきます。その中でも現在進行系で直面している課題について少し紹介します。

ViewModel の位置付け

現在クックパッドアプリでは AAC の ViewModel は、Activity / Fragment でのみ利用する状態管理オブジェクトとして利用されています。先程紹介した Paging を持つ ViewModel も存在しますが、状態管理オブジェクト以上の責務を持つことはありません。

しかしただの状態管理オブジェクトとしては Android 開発において存在が大きく開発の混乱の元となってしまっており、現在アーキテクチャへ組み込む方法を検討しています。今のところは View を廃止して ViewModel に実装を寄せ、Paging を ViewModel に取り込むという意見が強く、今後議論を重ねてさらにアーキテクチャを拡張する予定です。

ボイラープレートの多さ

Contract を定義する事でコードの見通しが良くなるというメリットはありますが、その一方でVIPER は構成する要素が多く、新たに VIPER シーンを構築するために多くのファイル及び実装が必要となります。

この課題に対して AndroidStudio の LiveTemplate でファイル生成の簡略化を試みましたが、コストがかかるのは実装であり、あまりコストの軽減にはつながらずうまく行きませんでした。これはユニットテストにおいても同様のことがあり、こちらについては自動生成を行うことでコストの軽減につなげる余地がありそうなため今後検討していきたいと考えています。

まとめ

クックパッドでは今回紹介したようなアーキテクチャの改善を開発に関わる全てのメンバーで共有しながら進めています。こういった開発スタイルに興味がある Android エンジニアの方はぜひご連絡ください。

https://info.cookpad.com/careers/

Ruby に Software Transactional Memory (STM) を入れようと思った話

$
0
0

技術部でRubyインタプリタの開発をしている笹田です。コロナの影響で、リモート勤務などに移行し、新しい生活スタイルを満喫されている方々がたくさんいらっしゃるんじゃないかと思います。ただ、私は以前から自主的に自宅勤務することが多かったので、正直生活がぜんぜん変わっていません。

さて、家で私が何をしているかというと、Ruby 3の準備です。その中でも、数年取り組んできた Ruby で並列処理をするための仕組みである Ractor の開発をしています(以前は Guild というコードネームで呼んでいました)。Ractor という名前は、Ruby の Actor みたいな意味で付けました。Erlang とか Elixir で有名な Actor model というアレです。厳密には、Actor model でよく言われる特性をすべて備えているわけではないのですが、並列で動く Ractor を複数作ることで並列計算機上で気楽に並列処理を行うことができます(少なくとも、それができることを目標にしています)。

Ractor は、意図的に Ractor 間でメモリの共有を排除するように設計されています。しかし、どうしても共有したいなぁ、というときのために、Software Transactional Memory (STM) という仕組みを入れようと思いました。STM を使うと、DB のトランザクションのように、何か競合したらなかったことにしてやりなおすメモリを作ることができます。

本稿では、その背景と、実際にどう作ったか、そしてどう試すのか、などについてご紹介します。

Ractor のちょっとしたご紹介

本題に入る前に、本稿を読むために必要になる、Ractor についての基礎知識を少しご紹介します。 しっかりしたリファレンスは ruby/ractor.md at master · ruby/rubyにありますので、よかったら参照してみてください。

Ractor を作って並列計算する

Ractor は、複数作ってそれらが並列に動く、ということで、並列計算機上で並列に動かすことができます。

# Ractor を生成する
r = Ractor.new do
  expr # expr は、他の Ractor とは並列に動くend

r.take #=> expr の実行、つまり Ractor 自体の処理が終了を待ち、#   expr の結果を得る

この例では、Ractor.newで新しい Ractor を作り、そこで exprを実行し、その結果を Ractor#takeで受け取る(r.take)、という意味になります。ここでは1つしか作っていませんが、n 個作れば、n 個の Ractor が並行に処理され(Thread と一緒)、それらがシステムで許された並列度で並列に実行されます(Thread と異なる)。

ちなみに生成時に引数を渡すと、それをブロック引数で受け取ることができます。

r = Ractor.new 10do |n|
  p n #=> 10end
r.take

Ractor 間ではオブジェクトは(あんまり)共有されない

Ractor 上ではたいていの Ruby のプログラムを動かすことができます。つまり、上記 exprに、いろんな Ruby の式が書けます。が、Ractor 間でオブジェクトを共有することは、基本的にはできません。

# s から参照される文字列を、新しく作った Ractor と main Ractor で共有する例# エラーになります

s = "hello"
r = Ractor.new do
  s << "world"end

s << "ko1"
p r.take

この例では、sで参照される "Hello"という文字列を、2つのRactor(起動時からあるmain Ractorと、Ractor.newで作る子Ractorの2つ)で共有してしまう例です。それぞれの Ractor で、String#<<で文字を結合、つまり破壊的操作をしようとしています。一般的には、並列処理において、ロックなどで排他制御をしなければならない場面です。

例えば、スレッドが並列に実行されるようなJRubyなどでは、RactorではなくてThreadでこのようなコードを動かすと、Javaレベルのエラーが起きることがあります(手元で連結を何度も繰り返すようにして試してみたら、java.lang.ArrayIndexOutOfBoundsExceptionが出ました)。余談ですが、MRIは、GIL/GVLによって並列に動くことはなく、String#<<の処理中にスレッドの切り替えが起こらないことを保証しているため、問題なく動かすことができます。が、Ruby レベルの処理ではどこで切り替えがおこるかわからないため、やっぱり排他制御ちゃんと考えないと、となります。

というわけで、もしこのようなコードによって、どんなオブジェクトも複数の Ractor から同時にアクセスできるようになってしまうと、Ractor 間での同期が必須になってしまいます。

Ractorでは、Ractor間で文字列などの共有による、排他制御が必要な状況になるのを防ぐために、いろいろな工夫をしてあります。例えば、ブロックの外側のローカル変数を参照するようなブロックを Ractor.newに渡そうとすると、Ractor.newのタイミングでエラーになります。

in `new': can not isolate a Proc because it accesses outer variables (s). (ArgumentError)

こんな感じでオブジェクトを共有できないので、「ロックをちゃんとしなきゃ」といった、難しいスレッドプログラミングに関する問題から解放されます。やったね。

Ractor 間のコミュニケーション

そうは言っても、何か状態を共有したいことはあります。また、複数の Ractor が協調して動くように作る必要もあるでしょう(何かイベントをまったり、イベントが起こるまで別の Ractor を待たせたり)。そこで、Ractor では、メモリを共有するのではなく、オブジェクトをメッセージとしてコピーして送ったり受け取ったりすることで、データを共有します。

Go で言われているらしい "Do not communicate by sharing memory; instead, share memory by communicating."ということですね。Go と異なるのは、Go はいうてもメモリをいじってコミュニケーションできてしまう(メモリを共有しているので)のですが、Ractor ではコピーしちゃうので、そもそも共有ができません。Go は「気をつけようね」というニュアンスですが、Ractor では「絶対にさせないでござる」という感じです。

Ractor 間のコミュニケーションは Ractor#sendRactor.receiveおよび、Ractor.yieldRactor#takeのペアで行います。

r1 = Ractor.new dowhile msg = Ractor.receive
    Ractor.yield msg
  end:finend

r1.send 1
r1.send 2
r1.send 3
r1.send nil
p r.take #=> 1
p r.take #=> 2
p r.take #=> 3
p r.take #=> :fin

この例では、main Ractor が、作成したRactor r1に対して、1, 2, 3, nil という値を Ractor#sendでメッセージとして送っています。

r1 では、Ractor.receiveで send されたメッセージを受け取って(送られるまで待つ)、それをそのまま Ractor.yieldに渡しています。Ractor.yieldは、他の Ractor がそのオブジェクトを Ractor#takeで持っていくまで待ちます。つまり、1, 2, 3 について、Ractor.yield しているわけです。最後、Ractor.yieldは nilを返すので、while 文が止まり、ブロックは :finを返して Ractor は終了します。

main Ractor では、Ractor#takeによって、Ractor.yieldされた 1, 2, 3 を受け取り、表示します。 また、4 回目の Ractor#takeによって、ブロックの返値 :finを取ります。

というのが、コミュニケーションの方法になります。

さて、メッセージとして渡すオブジェクトは毎回コピーするとご紹介しましたが、いくつかの場合、コピーなしで受け渡されます。コピー無しで受け渡されるオブジェクトのことを「共有可能オブジェクト」と呼びます。

共有可能オブジェクトの定義はこちら:

  • 不変オブジェクトは共有される
    • 不変オブジェクトとは、そのオブジェクトが freezeされており、参照するオブジェクトがすべて共有可能であること
    • 例えば、整数オブジェクトや nil とかは、frozen で参照するオブジェクトは無いので共有可能です
  • クラス・モジュールは共有される
  • その他、特別に共有可能に作られたオブジェクト
    • たとえば、Ractor オブジェクトは共有可能
    • 今回ご紹介する Ractor::TVarも共有可能オブジェクト

共有可能オブジェクトを他の Ractor に送るときは、コピーせずにリファレンスだけ送ります(共有されても、おかしなことは起こらないだろうから。もしくは、共有されても、おかしなことが起こらないように特別に設計されているから)。

それから、渡すときにコピー以外にも move が選べますが、ちょっと長くなってきたのでこの辺で。Ractor に関しては、いろんな話があります。再掲になりますが、詳細は ruby/ractor.md at master · ruby/rubyをご参照ください。

Software Transactional Memory (STM)

Ractor ではメッセージのやりとりで共有できるんですが、やっぱり一部はメモリを直接共有したいこともあるかもしれません(ないかもしれません、ちょっとわからない)。そこで、Software Transactional Memory (STM) という仕組みを入れるのはどうかと考え、実装してみました。最新の Ruby で gem でインストールすれば使えるようになっているので、よかったら試してください。

以降は、その STM の話をご紹介します。

STM が必要な背景

ちょっとしたデータを Ractor 間で共有したい例として、例えば、何かプログラム全体で数を数えたい、ってのがあります。さばいたリクエスト数かもしれません。処理したデータの総サイズを数えたいかもしれません。こういう、Ractor 間でちょっとしたデータを共有する方法が、今はありません。強いて言えば、そのデータを管理する専用のRactorを作ることで行うことができます(専用じゃなくてもいいけど、何か管理するやつ)。

counter = Ractor.new do
  cnt = 0while msg = Ractor.receive
    case msg
    in [:increment, n]
      cnt += n
    in [:decrement, n]
      cnt -= n
    in [:value, receiver]
      receiver.send cnt
    endendend

counter << [:increment, 1] # Ractor#send は Ractor#<< という alias を持っています
counter << [:increment, 2]
counter << [:increment, 3]
counter << [:value, Ractor.current]

p Ractor.receive #=> 6

この例では、カウンターを管理するためだけの Ractor を用意してみました。実際、Actor モデルの言語では、こんな感じで作ることが多いんじゃないかと思います。そして、こういうのを簡単につくるためのライブラリが用意されています。例えば Elixir なんかだと Agent(Agent — Elixir v1.11.2。日本語での詳細は、12月に出版される プログラミングElixir(第2版) | Ohmshaとかがお勧めですよ!)とかですかね。複数の Ractor 間で安全に共有できる、変更可能な状態を作るときは、こんな感じにします。

が、もうちょっと楽に書きたいなぁ、という気分があります。「カウンターごとに Ractor 作るんですか?」って感じです(まだ、Ractor の生成は遅いのです。Thread と同程度に)(べつに、カウンターごとに作らないで、すべてのカウンターを管理する Ractor を作る、みたいな方法でできんこともないです。単純なカウンターの集合だけなら)。

そこで、メモリを共有する仕組みを用意するのはどうでしょうか。cnt = Counter.new(0)としたら、cntは複数の Ractor で共有できる、みたいな感じです。ただ、値の increment でも、ロックが必要です(Thread-safe の説明の例でよくあるアレです)。

じゃあ、ロックしないとアクセスできないようなインターフェースにすると、どうでしょうか。ロックを持たないでアクセスするのを禁止すれば、うっかりロックを忘れてしまうこともなさそうです(エラーになって気づく)。ちゃんとロックをするようにすれば、Ractor 間で排他制御されるので、まずい問題が起こらない気がします。

やってみましょう。

cnt = Counter.new(0)

r = Ractor.new cnt do
  cnt.lock{
    cnt.value += 1
  }
end

cnt.lock{ cnt.value += 2 }

r.take
p cnt.lock{ cnt.value } #=> 3

良さそうです!

さて、ここでカウンタを 2 個にしてみましょう。そして、2つのカウンタは同時に動く必要があるとしましょう。そうですね、c2 = c1 * 2 となるような関係になるという特殊なカウンタです。ロックをうまく使えば大丈夫ですかね?

c1 = Counter.new(0)
c2 = Counter.new(0)

r1 = Ractor.new do
  c2.lock do
    c1.lock do
      c1.value += 2
      c2.value = c1.value * 2endendend

c1.lock do
  c2.lock do
    c1.value += 1
    c2.value = c1.value * 2endend#...?

こんな感じでしょうか。

実は、このプログラムはデッドロックしてしまいます。というのも、main Ractor は c1 -> c2 の順でロックをしていきます。r1 は、c2 -> c1 の順です。このとき、運悪く次のような順にロックしていくと、デッドロックしてしまいます。

  • main: c1.lock
  • r2: c2.lock
  • main: c2.lock ->できないので待つ
  • r2: c1.lock ->できないので待つ

こうならないためには、ロックの順番を、複数の Ractor でそろえる(c1->c2とか)必要があります。

とか考えていくと、ロックのアプローチはいまいちです。うっかり順番間違えるとか、普通にありそうじゃないですか。

STM のよさ

そこで使えそうなのが STM です。DB なんかで Transaction の話はよくご存じの方は多いと思いますが、これをメモリに適用したのが STM で、2010 年くらいに言語処理系界隈で研究が盛んでした。でも、今ではあんまり聞かないですねえ。言語についている STM としては、Clojure とか Haskell (Concurrent Haskell) が有名だと思います。Erlang/Elixir における mnesia も STM と Wikipedia には書いてありました、があれは DB だよなぁ。

STM は、DB のトランザクション(楽観的ロック)と同じように、とりあえずなんか読み書きして、あとで、「あ、別の Ractor とアクセスが被った!」となったらロールバックしてしまいます。簡単ですね。

ロック(悲観的ロック)と何が違うかというと、さっきの順序の問題が現れないんですよね。そもそも「ここからトランザクションです」のように指定するので、ロックの順序がない。この性質を、composable であるといいます。複数の排他制御が必要とする操作を、まとめても問題ないという良い性質です。

STM のデメリットは、操作が衝突してロールバックが多発するとむっちゃ遅くなっちゃうんですよね。この辺はフロー制御をなんとかする、みたいな研究がいろいろあります。たとえば、衝突しまくってそうなら、実行可能なスレッド(今回は Ractor)を絞っちゃうとか。

まぁあと、楽観ロックなので、みんなが read しかしないような場合は、どの処理も並列に実行可能なので速そうです。それから、進行性保証的な話もあったりして、いろいろメリットがあります。

どんな STM を作るのか

STM にもいろいろな流派があります。

  • そもそも、Software じゃない Hardware でやる HTM って分野があります。CPU がサポートしたりしています。が、あんまり最近聞かないですねえ。
  • メモリ操作を全部 transaction の対象にしてしまうという STM があります。C++ とかで多いですね。X10 という昔ちょっとかかわってた言語では、言語組み込みにこういう STM がありました。
  • 特定のメモリを transaction 対象にするという STM があります。特定のメモリしか扱わないので、それ以外のメモリはロールバックしてももとに戻りません。
  • 操作の衝突の定義もいろいろあります。

Ruby の場合は、全部ロールバックできないので(作るのスゴイ大変)、一部のメモリだけを対象にする、というようにします。具体的には、Ractor::TVar.newTVarは Transactional Variable の略)が保持する値のみ、transaction で何か問題があったらロールバックします。そして、Transaction の範囲は Ractor.atomicallyに渡すブロック中ということにします。

というインターフェースが、実は Class: Concurrent::TVar — Concurrent Rubyにあったんですよね。Concurrent Ruby は、Thread を対象にしています。このインターフェース踏襲し、Ractor でも使えるようにしたのが Ractor::TVarです。

先ほどのカウンターの例だと、こんな感じで書けるようにするといいでしょう。

c1 = Ractor::TVar.new(0)
c2 = Ractor::TVar.new(0)

r1 = Ractor.new c1, c2 do |c1, c2|
  # 外側のローカル変数は見えないから引数で渡すRactor.atomically do
    c1.value += 2
    c2.value = c1.value * 2endendRactor.atomically do
  c1.value += 1
  c2.value = c1.value * 2end

main Ractor と子 Ractor で、変更が競合してしまった場合は、どちらかのブロックが再実行されます。先に紹介した通り、ロールバックされるのは Ractor::TVar#valueの値だけなので、例えばインスタンス変数への代入などは残ってしまいます。IO 処理なんかも取り返しがつきません。そのため、Ractor.atomicallyに渡すブロックは、できるだけシンプルにする必要があります。

Ractor.atomicallyは自由にネストすることができます。この性質が、composable である、という話です(ロックですと、ロックの順番に気を付けないといけませんでした)。

TVar は共有可能オブジェクトなので、他の Ractor に渡すことができます。TVar に設定できる値は、他の Ractor から見えることになるので、共有可能オブジェクトに制限されます。たとえば、mutable な文字列などは渡せません。

トランザクションは、次のようなプロセスで管理されます。

  • (1) トランザクションの生成・開始
  • (2) TVar の読み書き
  • (3) トランザクションのコミット

このとき、(2) および (3) のタイミングで競合を検知し、必要ならロールバックを行って (1) に戻ります。

  • (a) (2) において、read した値がすでに他の Ractor に書き換えられていた→ロールバック
  • (b) (3) において、read した値が、すでに他の Ractor で書き換えられていた
  • (c) (3) において、write しようと思ったら、すでに他の Ractor に書き換えられていた

(c) は直観的だと思いますが(git で push しようとしたら、先に他の人が変更していて書き換えられなかった、みたいな話です)、(a), (b) はちょっと意外ではないでしょうか。つまり、書き換えの行わないトランザクションでも、ロールバックは発生し得る、という話です。

この読み込みだけでロールバックしてしまう、という挙動は、2つ以上の値を読み込むときに重要になります。tv1.value, tv2.value 2値を取り出すとき、tv1を読み込んだ後で、他の Ractor が tv2を書き込み、それを main Ractor で読み込んだ時、tv1tv2が一貫性を持たない状態である可能性が出てきます。そのため、(b), (c) のタイミングで、適切な tv1, tv2を読み込めているかチェックする、という話になります。まだちょっとわかりづらいですね。

例えば tv1に配列のインデックス、tv2に配列が格納されているとき、tv1のインデックスを読み込んだ後、なんやかんやがあって他の Ractor で tv2の配列が切り詰められたとします。このとき、すでに読み込んだインデックスは tv2の配列の長さを超えているかもしれません。問題です。

これはつまり、tv1tv2の一貫性が取れていない、という状況です。TVar では、このようなことが起こらないように、上記 (a)~(c) をトランザクションのロールバックタイミングとしています。

さて、1つの値だけを読みだすとき、Ractor.atomicallyが必要かどうかは議論が必要なところです(例えば、p Ractor.atomically{ c1.value }と書かなければならないのか、p c1.valueと書くだけでよいのか)。というのも、この処理は複数読み込みもせず、write もないので、一貫性制御が要らないような気がするからです。実際、Clojure の STM や、Concurrent-ruby の TVar は、トランザクション内でなくても値を読みだすことだけはできるようになっています。

我々は、このときも Ractor.atomicallyを必須としました。というのも、c1.value + c2.valueのように、2つ以上の値を読み込むために、うっかり Ractor.atomicallyを書き忘れそうな気がしたからです。

あと、カウンタとして使おうとすると、increment 処理をよくやると思うので、Ractor.atomically{ tv.value += 1 }のショートカットである Ractor::TVar#increment(n=1)を用意しています。

STM の限界

composable に記述できる STM ですが、たとえば同一トランザクション内で処理しなければならないのに、複数のトランザクションに分けてしまう、という問題はいかんともしがたいです(意図的かもしれないのでエラーにできません)。

c1 = Ractor::TVar.new(0)
c2 = Ractor::TVar.new(0)

r1 = Ractor.new c1, c2 do |c1, c2|
  Ractor.atomically do
    c1.value += 2end# 本当はここで transaction を切ってはいけない!!Ractor.atomically do
    c2.value = c1.value * 2endend

c1 を変更後、c2 を変更する前に他の Ractor が c1, c2 を観測すると、c2 = c1 * 2 という関係が崩れている(一貫性がない)瞬間を目撃できるのです。

ちなみに、何かのカウンタなら多少の誤差は許されることもあるかもしれませんが、例えば STM でよく例に出てくる銀行口座の残高の移動というタスクにおいては大問題になってしまうかもしれません。例えば、A さんから B さんに n 円送金するとき、A さんから残高を減らして、B さんに残高を追加する、という処理になります。このとき、Aさんから残高を減らしたタイミングで他の Ractor から A, B 各氏の口座が観測され、世界から n 円消える、という瞬間を目撃していまいます。それはまずい、あってはならないことです。

(STM 自体はこのように、口座残高のような、同時に複数のデータをいっきに変える(一貫性のない状態を、ほかから見えないようにする)ときに使うことが多いと思います)

さて、この例では恣意的で、こんなミスは起こさないような気がするのですが、例えば、

defadd_2 tv
  Ractor.atomically{ tv.value += 2 }
enddefset_twice tv1, tv2
  Ractor.atomically{ tv2.value = tv1.value * 2}
end

のように定義していれば、add_2(c1); set_twice(c1, c2)のように記述してしまう可能性は十分あります。

どーにかなんないか考えてみたのですが、トランザクションでの read/write のログを取れるようにしておいて、問題が発覚したら、そのログを見つめるなり自動解析ツールなりを作って、トランザクションが分かれていないかチェックする、みたいなことくらいしかできないかなぁ、と思っています。良いアイディアをご存じでしたら教えてください。

そういえば、TVarという名前は concurrent-ruby からとりましたが、T って色々ありますよね。なので、TxVar とかもう少し冗長でもいいかなぁ、などという気分があります。どうしよっかな。

STM の実装

あんまり中身の話をしてもしょうがないような気がしますが、こんなアルゴリズムで実現しています。

  • (1) トランザクション開始時に、現在の時刻 T を取得する
  • (2) TVar の読み込み時 / 書き込み
    • 書き込み時には、TVar には書かず、Ractor local なトランザクションログに書き込む
    • 読み込み時には、トランザクションログにその TVar の書き込み履歴があれば、Ractor local なその最新の値を返し、なければ読み込む。このとき、TVar に記録された最終書き込み時間と、開始時に記録した T を比較し、T が古ければればロールバック。新しければ読み込み完了だが、ついでにトランザクションログに載せておく(次の read 時は、TVar を読む必要がなくなる)
  • (3) コミット
    • コミット時、トランザクションログに記録された TVar たちについて、最終書き込み時間が T より新しくないことを確認
    • 時刻を1進める。この時の時刻を T'とする。
    • 書き込みが必要な TVar には、変更を反映。このとき、その TVar の最終書き込み時間が T'となる。

あんまり難しそうじゃないんですが、なんかこれで動くみたいです。論文的には、TL2 という方式(をちょっと弄っている)なんだそうです。

ちなみに STM を作った本当の経緯。以前から STM が欲しいと思っていました。そこで、9月にとった夏休みに STM の実現方法についてのアイディアが思いついたので、実装したら動いたヤッター、俺スゴイ、となったのです。で、調べてみたら、すでに誰かが提案していて、しかも自分が考慮していなかった箇所とかあったり、名前までついていたという。新しいことを考えるのは大変ですね(いや、別に今回は新しいことは目指してはいなかったんですが)。

ロールバックは、(2) の時は単純に例外を投げるようにしています。(3) のときは、コミットする関数で成功失敗を返し、失敗していたら最初からやりなおす、という実装にしています。

なお、Ractor と言ってますが、Thread 間でも同じように TVar が使えます。なので、Ractor ごとにトランザクションログをもつのではなく、Thread ごとに持つようにしています。この辺で Thread::TVar にするのか Ractor::TVar にするのか悩んだんですが、結局 Ractor::TVar がいいかなぁ、と思い至りました。

Ruby 3.0 における STM

この提案を、Ruby 3.0 の機能として提案してみたのですが、良さがわからん、ということで reject されました(Feature #17261: Software transactional memory (STM) for Threads and Ractors - Ruby master - Ruby Issue Tracking System)。残念。まぁ、確かに Actor っぽい仕組みでメモリを分けたのに、また別の仕組みを入れるのか、という気はしないでもないです。

ただ、実際これないとプログラム書きづらいと思うんだよなー。どうかなー。

ということで、gem を用意しました。

ractor-tvar に Ractor::TVarだけ入っていて、ractor gem は、ractor-tvar への依存があるため、gem install ractorすれば入ります。ractor gem のほうは、今は空ですが、Ractor に関するいろいろなユーティリティーを入れられるようにしようと思っています。

require 'ractor/tvar'で使えるようになります。なお、当然ですが、開発中の Ruby 3.0 上でしか動かせません(そもそも拡張ライブラリがビルドできません)。もう入っていますか?

最初は本体組み込みを前提に STM を実装していたのですが、gem に切り出すために変更が入り、性能が若干落ちています。また、コンテンションマネージメントをまじめにやっていないため、ロールバックが多発するようなシチュエーション(つまり、ある TVar への書き込みが激しいとき)では性能が凄く下がります。逐次実行時より下がります。

性能評価はまじめにやる時間がないのでスキップしますが、いくらかの評価が先のチケット(Feature #17261: Software transactional memory (STM) for Threads and Ractors - Ruby master - Ruby Issue Tracking System)にありますのでご参照ください。

おわりに

本稿では、Ruby に STM を入れたいと思った話と、それからその仕様と実装を軽くご紹介しました。Ruby 3.0 には入らないのですが、gem で使えるので、お試しいただけると良いかもしれません。

STM については、いろいろ偉そうに書きましたが、だいたいこの書籍の受け売りです: Amazon | Transactional Memory, 2nd Edition (Synthesis Lectures on Computer Architecture) | Harris, Tim, Larus, James, Rajwar, Ravi, Hill, Mark | Network Administration

また、Ruby と STM について、Context on STM in Rubyという記事が出ています。

Ruby に STM 入ると、あまり注目されない STM もまた盛り上がる気がします。性能チューニングや記事中に書いたデバッグ支援など、いろいろやることがあるので、興味ある言語処理系の研究者の方とか、共同研究とかどうでしょうか。ちゃんとやれば、学生さんの卒論修論くらいにはなるんじゃないかと思います。

さて、Ruby 3.0 は、そんなわけで Ractor も入るし他にもいろいろ入るし、夢いっぱい楽しさイッパイのリリースです。多分。そんなすてきな Ruby 3.0 をいち早くご紹介するイベントを開催するので、年の瀬ですが、もしよかったらご参加ください。

Ruby 3.0 release event - connpass

では、12月の Ruby 3.0 リリースをお楽しみに!

人気順検索のSolrはスケールのためにディスクを捨てた

$
0
0

技術部クックパッドサービス基盤グループの id:koba789です。
昨年まではデータ基盤グループというところで 最新のログもすぐクエリできる速くて容量無限の最強ログ基盤を作ったりしていました。
今年はちょっとチームを移動しまして、検索システムをいじっていました。今回はそのお話です。
なお、クックパッドには様々な検索システムがありますが、この記事では説明を簡単にするためにレシピの検索のみに焦点をあてています。

クックパッドの検索システムにあった課題

クックパッドにはレシピを検索できる機能があります。
プレミアム会員限定の人気順検索もこの機能の一部です。
しかし、この重要な機能を支える検索システムにはいくつもの課題がありました。

Solr が古すぎる

クックパッドでは、レシピ検索を含む多くの検索機能にSolrを用いています。
今年の始めに私がこの課題に取り組み始めた時点では、その Solr のバージョンは4.9でした。これは5年以上前にリリースされたバージョンです。
古いミドルウェアをあまりに長い間維持し続けてしまうと、OS や運用のためのツールといった周辺のソフトウェアのアップデートも困難になります。
OS だけ一気に最新のバージョンにアップデートしようとしても、互換性の問題で古いミドルウェアは動かなかったりするのです。
こうなると、もはやインクリメンタルなアップデートは非現実的で、古すぎること自体がアップデートをより難しくしているためデッドロックのような状況です。

現代的なプラクティスが実践されていない

上記のような状況から想像に難くないですが、インフラのアーキテクチャも大変古めかしいものでした。
クックパッドのインフラは AWS 上に構築されており、ほとんどのワークロードは ECS と Hako を用いてコンテナ化されています。
またその多くは運用にかかる金銭的コストを最適化するため、負荷に応じてオートスケールするようになっています。
しかしこの検索システムはそうではありませんでした。
Solr は専用の EC2 インスタンスにインストールされており、そのインスタンスはピークでもオフピークでも同じ数だけ動き続けていました。
また、この EC2 インスタンスをインスタンスの退役と入れ替えなどの理由で新規にプロビジョニングする場合は人による作業が必要でした。
その作業はスクリプトでほとんど自動化されてこそいるものの、このスクリプトも例に漏れず非常に古くなっており、保守性を悪化させていました。

これらの課題がありながらも、5年という長い期間ずっと変わらずにいました。
運用上は意外にも安定してしまっていたことと、サービスのコア機能に関わるため迂闊には手を出せなくなっていたことが理由です。 まさに触らぬ神に祟りなしといったところです。

ほぼすべてのワークロードがコンテナ化されたクックパッドにおいて、最後まで残り続け強烈な "レガシー"となっていたのがこの検索システムでした。

検索システムを見つめ直す

前述のような理由から、インクリメンタルなアップデートは諦め、ゼロからアーキテクチャを考え直すことにしました。

システムの作り直しは失敗しやすく困難なので、しばしば悪い方針だと言われます。
もし作り直しを成功させたいならば、新しいシステムで得られるものばかりに目を向けて既存のシステムの観察を怠るようなことがあってはいけません。
必要な要素を見落とせば不十分なシステムができあがって失敗するでしょう。 不要な要素を見極められなければ余計な工数がかかったり実現不可能になったりして失敗するでしょう。
私は既存のシステムを観察し、要件や事実を整理するところから始めました。

まず説明のために、既存のシステムの概略を紹介しましょう。 既存のシステムでは Solr の検索性能をスケールアウトさせるために、1台のプライマリからたくさんの(固定台数の)レプリカに向かってレプリケーションをしていました。 (実際の構成はもう少し複雑ですがここでは本質的ではないため単純化しています)

1台のプライマリからたくさんのレプリカに向かってレプリケーションをしている
既存システムの構成

そしてこのレプリケーションは自動ではなく、日に1度のバッチジョブで明示的にトリガーして実行していました。
そのジョブの内容はおよそ以下のとおりです。

  1. たくさんあるレプリカの中から1台選ぶ
  2. そのレプリカをロードバランサから外す(リクエストが来ないようにする)
  3. レプリケーションを実行する
  4. キャッシュを暖めるため "暖機運転"をする
  5. ロードバランサに戻す
  6. 以下、すべてのレプリカに対して繰り返し

わざわざレプリケーションを1台ずつ実行したり、レプリケーション中はロードバランサから外しておいたり、ロードバランサに戻す前に暖機運転をしたりするのは検索クエリへのレスポンスタイムを遅くしないためです。

さて、観察中に上記以外にも様々なことを発見しましたが、最終的に重要だった点は以下のとおりです。

  • インデックスは日に1度しか更新されない
  • レプリケーション後にキャッシュを暖めるため "暖気運転"をしている
  • Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
  • セグメントファイル(インデックスデータの実体。Solr が内部で使っている全文検索ライブラリである Lucene 用語)のサイズは 6GB 程度である

新しい検索システム

大前提として、新しい検索システムでは最新版の Solr を使うことにしました。
バージョンは4.9→8.6という大ジャンプでしたが、ほぼ schema.xml の書き換えのみで動かすことができました。
丁寧な移植作業の結果、バージョンアップ前後での検索結果の差は非常に少なくできましたが、それでも完全に一致させることはできなかったため、検索結果の品質について責任を持っているチームにお願いしてバージョンアップ後の検索結果の検証をしてもらいました。

また、新しい検索システムではすべてのワークロードをなんとかしてコンテナ化できないかと考えました。
もしコンテナ化できれば、社内に蓄積されたコンテナ運用の豊富なツールやノウハウの恩恵を受けることができ、圧倒的に運用が楽になるからです。

一般に、コンテナの利点を最大限に活かすためにはコンテナはステートレスであるべきとされます。 しかしながら、データベースや検索エンジンのようなミドルウェアは原理的にファイルシステムの永続化が必要でありステートフルです。
いきなり要件が矛盾しているようにも見えますが、一般論ではなく実際の要件に着目するのが大切です。
ここで最も重要なのはクックパッドのレシピ検索のインデックスは日に1度しか更新されないという点です。
ステートの更新頻度が十分に低いのであれば、ステートの変化の度にコンテナを使い捨てることでステートレスとすることができます。
以上のようなアイデアにより、新しい検索システムではセグメントファイルをファイルシステムではなく S3 に永続化することにしました。
また、セグメントファイルを S3 に置いたことでプライマリとレプリカ間のレプリケーションが不要になり、更新系と参照系を完全に分離することができました。

それでは、更新系と参照系について、動作を追って解説します。
まずは更新系の動作を見てみましょう。 なお、図中の s3ar は独自開発した S3 アップローダー・ダウンローダーです。詳細については後述します。

f:id:koba789:20201124151408p:plain
新しい更新系の動作
更新系のコンテナでは、まずインデックスの元になるデータを S3 から Solr に流し込み、すべてのデータを流し込み終えたら Solr を停止します。
この時点でローカルのファイルシステムにはセグメントファイルができあがっていますが、コンテナのファイルシステムは永続化されないため、このままコンテナを停止するとせっかくのセグメントファイルは失われてしまいます。
そのため、コンテナを停止する前にできあがったセグメントファイルを S3 にアップロードします。
アップロードが完了したらコンテナは停止し、破棄します。
この更新系のコンテナは日次のインデックス更新のバッチジョブの一部として起動されます。処理が完了するとコンテナは破棄されるため、処理中以外は計算リソースを消費しません。

続いて参照系の動作を見てみましょう。

f:id:koba789:20201124151412p:plain
参照系の動作
参照系のコンテナは起動するたびに最新のセグメントファイルを S3 からダウンロードします。
セグメントファイルのダウンロードが完了したら単純に Solr を起動するだけです。
参照系のコンテナはインデックスの更新の度に、つまり日に1度すべて作り直され、置き換えられます。
以上の説明から明らかなように、起動プロセスが完全に単純化されたため、スケールアウトのための手作業はもはや不要です。 これは参照系のオートスケールが可能になったことを意味します。

セグメントファイルのダウンロード高速化

ところでこの設計にはひとつ懸念点があります。 それはセグメントファイルのダウンロードに時間がかかると、当然ながら参照系コンテナの起動にも時間がかかるということです。
オートスケールを実践するにはスケールアウトに対する即応性が大切です。 さもなくば増加する負荷に処理能力が足らなくなり、サービスの提供は停止するでしょう。

新しい検索システムではこの問題を解決するためにいくつかの工夫をしています。

セグメントファイルを tmpfs に書く

ダウンロードしたセグメントファイルは tmpfs に書いています。 これはブロックストレージへの書き込みがダウンロードのボトルネックになるためです。
tmpfs を利用するとなると気がかりなのがメモリ使用量です。 ここで既存の検索システムの観察で得た情報の一部を思い出しましょう。

  • レプリケーション後にキャッシュを暖めるため "暖気運転"をしている
  • Solr がインストールされている EC2 インスタンスのタイプは c4.2xlarge である
  • セグメントファイルのサイズは 6GB 程度である

c4.2xlarge のメモリは 15GiB ですから、6GB のセグメントファイルのサイズや暖機運転をあわせて考えると、既存の検索システムはセグメントファイルのデータがすべてメモリ上のページキャッシュに乗り切っていることを期待していたことがわかります。 新しい検索システムでも同等以上の性能を達成したいので、セグメントファイルはすべてメモリに乗るようにすべきでしょう。

するとブロックストレージは完全に無駄であることに気が付きます。 結局ページキャッシュに乗せなければならないならはじめから tmpfs に書くべきです。

専用の高速な S3 アップローダー・ダウンローダー使う

S3 からのダウンロードにおいて、1接続あたりの速度はそこまで速くありません。 そのため、いかにしてダウンロードを並列にするかが高速化の鍵になります。

セグメントファイルは実際には複数のファイルで構成されているため、それぞれのファイルを S3 の1つのオブジェクトとして保存することでダウンロードの並列度を高められそうに思えます。
しかし、これはうまくいきません。なぜなら、それぞれのファイルサイズが大きく偏っており、並列化の効果を十分に発揮できないためです。

そこで、S3 の Multipart upload を利用し、1つのオブジェクトを複数のパートに分割してアップロードし、その分割されたパートごとにダウンロードします。 これは Performance Guidelines for Amazon S3でも紹介されており、AWS SDK for Java の TransferManagerや AWS CLI の s3 cp コマンドでも利用されているテクニックです。 これにより、ファイルサイズの偏りに関わらず、効果的にダウンロードを並列化できます。

また、高速なファイル書き込みではメモリコピーのコストが無視できなくなります。 通常のファイル書き込みで用いる write(2)システムコールはユーザーランドのバッファからカーネルランドのバッファへのコピーを伴います。 ブロックデバイスへの書き込みであればブロックデバイスは十分に遅いため無視できるコストですが、tmpfs のような高速なファイルシステムを使っている場合はそうではありません。
そもそも、せっかく tmpfs を使っていてファイルの実体がメモリ上にあるのですから、そのページに直接書き込みたいと思うのが自然でしょう。
実は、その願いは tmpfs 上のファイルを mmap(2)すると叶えることができます。 ファイルに対する mmap は page cache をユーザーランドに見せる操作であり、tmpfs はファイルの実体が page cache に存在するファイルシステムであるので、mmap すると tmpfs のファイルの実体がそのままユーザーランドから見えるようになるという理屈です。
(スワップを考慮していない雑な説明です)

これらのテクニックをすべて実装したのが独自開発した s3arという S3 アップローダー・ダウンローダーです。 これは Rust で書かれており、マルチコアを完全に使い切って超高速なダウンロードができます。 この s3ar を利用すると約 6GB のセグメントファイルのダウンロードは10秒程度で完了します。 これはスケールアウトの即応性として十分な速度です。

まとめ

5年間も変化を寄せ付けず、強烈なレガシーとなっていたクックパッドの検索システムは、丁寧な観察に基づく大胆な設計とそれを実現する確かな実装によって近代化されました。

この記事では新しい検索システムの開発について紹介しましたが、既存の検索システムから新しいシステムへの切り替えについては触れていません。 次の記事では、この変化の大きな切り替えをいかにして安全に成し遂げたかについて、同僚の id:riseshiaが紹介します。


検索インフラを安全に切り替えた話

$
0
0

こんにちはこんにちは。技術部クックパッドサービス基盤グループの id:riseshiaです。

本記事では直前の記事で提案された新しい検索システム(以下、 solr-hako と呼びます)を利用し、レシピサービスの検索インフラの切り替えた話をします。 solr-hako の設計を直接参照する内容はありませんが、それを前提においた移行作業ですのでそちらの記事を先に読むことをおすすめします。

インフラ構成の変化

まずインフラ構成の変化ををみておきましょう。

f:id:riseshia:20201124172550p:plain
検索インフラ(変更前)

今まではこのようなインフラ構成でした。特徴としては、 search-cache というキャッシュサーバ(Varnish)が手前にあることくらいでしょうか。今回、 solr-hako を利用することで以下のような感じになりました。

f:id:riseshia:20201124172618p:plain
検索インフラ(変更後)

しれっとキャッシュレイヤーである Varnish がなくなったことがわかります。これに関しては後ほど述べます。 では、このインフラの切り替えのためにどういう作業をしてきたのかを話していきたいと思います。

取り組んだ施策

新しい検索システムにマイグレーションするために次のような施策を行いました。

  • solr-hako という新しい仕組みに沿った実装
  • Solr バージョンアップによる影響調査 & 対策
  • 負荷実験
  • 試験運転
  • 切り替え

solr-hako の設計に関しては同僚の id:koba789が書いた記事で紹介しているのでそちらを読んでください。実装に関してはひたすら必要な設定を書いたり、権限を付与したり、 kuroko2にジョブ定義を作成したりのようなものがいっぱいなので割愛し、本記事では残りの施策に関して説明します。

Solr バージョンアップによる影響調査 & 対策

あらゆるフレームワークに対して、バージョンアップの正攻法はまずリリースノートおよびチェンジログを調べるところから始まるのかなと思います。

ですが今回の場合だと、 Solr 4.9 から Solr 8.6 までの変更履歴を全て調べることになるので流石に無理です。 ではどうやって変更を調べるのか。今回は実クエリを利用することにしました。 実際のクエリを数日分抽出し、 Solr 4.9 と Solr 8.6 に投げてそのレスポンスの差分を取りました。そこで見つかった差分から関連の変更を逆引きし、必要な対策をするという流れです。

影響が大きかったのは以下の2つです。

前者は一応クエリの書き方を変えるだけでよかったのですが、後者は検索順番への影響がありました。 流れが大きく変わってしまうので詳細は省きますが、検索サービスの責任を持つサービスチームにお願いし、いくつかの指標を選定した上で検索の品質評価を行いました。結論としては品質に悪影響してない、むしろ少し改善した可能性がありそう?ということになったのでこのまま切り替え作業をやっていくことになりました。 これに関しては機会があれば、別の記事を通して紹介できるかなと思います。

性能実験によるパラメータ・チューニング

影響範囲が分かったし、検索順の変更に対する影響調査を依頼したところで、次は性能実験です。 Solr のバージョンが大幅に変わるので、 Solr の設定及びリソース要求を見直す必要があります。ぱっと思い浮かぶだけでも各種 Solr のキャッシュ設定、スキーマの設定、JVM の設定、 solr-hako で動かすわけですから、ECS のタスクのリソース割り当てなどもあります。 それにオートスケーリングも設定していくので負荷状況による影響なども把握したい。

これらの設定値をいい感じの組み合わせにして一つ一つ試しながら良さそうな組み合わせを探す必要があります。とはいえ、丁寧に新しい組み合わせを試すたび設定を更新して Docker image を作って〜というのは疲れるし、やってられません。 そこで Solr の Config APIに目をつけました。 Config API を利用すると Solr の設定を REST-like API で取得したり、更新したりすることができます。これを利用して実験をあるほど自動化できるツールを作ることにしました。

solr-hako-load-tester

設計目標としては次のようなことを上げました。

  • 多様な Solr のパラメータを試せること
  • 手軽に設定を変更できること
  • 試したい設定をキューに詰め込んで順番に実行できるようにすること

一方でこのツールは今後 solr のバージョンアップの時くらいにしか使われないと予想されるので、実装コストに見合わない機能は目標外にしました。

  • Solr の外のパラメーター(e.g. ECS タスクごとの CPU や JVM の設定)は考慮しない
    • これらは比較的変更が少ない値であり、かつこれらの設定を動的に変更可能な設計にするのは利点に比べて実装コストが高い
  • 積極的なメトリクスの収集
    • 負荷をかけるツール(k6)、 Prometheus、 CloudWatch があれば詳細なデータが取れるのでツールではデータ収集を頑張らなくても問題ない
  • 負荷テストの対象の状態管理
    • 費用はやや高くなるが実験の間は負荷テスト対象(ECS Service)を常駐にすることでエンドポイントを毎回用意する手間を減らせるし、メトリックス収集も楽になる

結果、どうなったかというと、S3 に設定セットをキューイングし、 kuroko2の定期実行でそれを消費しながら実験を行う仕組みが誕生しました。

f:id:riseshia:20201124172648p:plain
load-test の動作の流れ

この図からありそうな質問点をあげていくとこんな感じかなと思います。

  • 負荷をかけるときに使ったクエリはピークタイムのログをサンプリングしたものです
  • 実験ツールとして k6 を選択したのはクエリログをリプレイするという観点で非常にシンプルで扱いやすかったからです
  • 結果はどうみるかというと kuroko2 のジョブ実行ログ、 Prometheus、 CloudWatch の監視結果から確認できるのでそちらを参考にしました。
  • 直前の実験の影響を受けないように実験の直前には ECS タスクの入れ替えをしています

実験結果

適切そうな設定を決めることができました。 そしてどういう設定にしても Solr 4.9 より速い、現状より少ないリソースでも十分なスループットを提供できることが判明したので、急遽このタイミングで Varnish のキャッシュレイヤーを捨てるという意思決定が行われました。

試験運転

使えそうな設定が出来上がったところで実ワークロードでも問題なく動作するかを確認するために試験運転をすることにしました。 試験運転をする方法はいくつかあるかなと思うのですが、今回は Traffic shadowing という手法を選びました。これはリクエストをコピーし、テスト目的でサービスインしてない別の upstream に送る手法です。 もう一つの候補として、一部のユーザに対してロールアウトしてみるという選択肢もありましたが、検索サービスである voyager は検索をリクエストしているユーザが誰なのかわからないので、その情報を何らかの方式で渡すなり、新しい API をはやすなり、新しい Solr 用の voyager を用意するかなりいくつのサービスを跨る対応が必要でコストが増えるし、予想される設計もあまりうれしくないものでした。

一方 Traffic shadowing の場合、2つの利点があります。

  • ユーザに影響を出さない
    • 検索品質の検証は別途やっている、今回の実験の目的は実ワークロードでも安定して動くのかを確認するためで、必ずしもユーザに出す必要はないし、むしろ出さなくていいならそっちがいい
  • すべてのリクエストを流せるので実ワークロードを完全再現できる

ということで Traffic shadowing を試してみることにしました。

Envoy と request_mirror_policy

クックパッドは Service mesh を導入しており、その Data plane として Envoy を採用しています。そして Envoy は Traffic shadowing に利用可能な request_mirror_policyという設定を提供しています。 これはあるクラスタへのリクエストをコピーして別のクラスタに送る機能で、コピーして送ったリクエストのレスポンスはそのまま捨てられます(メトリクスは残ります)。設定を少しいじるだけなので 実際使ってみた感じだと以下のような感じでした。

  • envoy による CPU 負荷はやや増えるが、そこまで目立つ変化ではない
  • Host ヘッダーに -shadowという suffix をくっつけてくるので、 Host ヘッダーを利用する処理が必要な場合、注意が必要

それ以外の気になる点はなく、快適に利用できました。

実験結果 & 対策

予想外のスパイクによるキャパシティー不足

実環境だと利用サービス側のキャッシュパージやバッチ実行などによりリクエストのスパイクが発生するわけですが、これが予想以上に影響が大きく、観測した範囲だと直前の rps より最大2倍くらいに跳ねたり、レイテンシーが不安定になる現象が観測されました。対策としてはオートスケーリングの閾値をやや下げ、もう少し余裕をもたせることでレイテンシーを安定させることができました。

Cold start 問題

Solr は起動してからキャッシュが温まるまではリクエスト処理速度が遅く、いわゆる暖気が必要ということが知られているのですが、性能実験の時はあまり意味のある遅延がみられませんでした。 solr-hako は tmpfs を利用し、メモリーにインデックスを乗せることを推奨しており、実設計でもそれに従ってインデックスストレージとして tmpfs を採用していました。ですのでこれが tmpfs の力なのか?!と思っていたのですが、そんなことはなく実ワークロードだと普通に Cold start 問題が目立つようになってました。後になって思うと、性能実験にはピークタイムのクエリをサンプリングしていたのでクエリのパターンが偏っていたのかもしれません。 対策として2つ考えられて投入前にクエリをいくつか投げるとこで暖気を行う方法と ELB の Slow start modeを利用する方法がありました。 前者だと送られてくるクエリの傾向に合わせて暖気用クエリをメンテするコストがあるので、徐々にリクエストを流すことでレイテンシーの劣化を抑えることができる ELB の Slow start mode を有効にすることにしました。

切り替え

Traffic shadowing を一定期間運用したことにより、これは大丈夫だなという確信を得られたので実際に切り替えることにしました。ロールアウト作業は検索結果の順序の変化と、利用サービス側のキャッシュ事情が混ざるとややこしいことになりかねないので徐々に切り替えるのではなく一気に展開することにしました。試験運用で時間帯による要求キャパシティーも完全に把握できていたので特に懸念点もなく無事切り替えることができました。

と思っていたら、その数日後に移行漏れのバッチが発覚したのはまた別の話です 😇 移行後もしばらくの間、運用上の理由でロールバックする可能性を考慮し古い Solr サーバを残しておきインデックスも更新していたのがここで役に立ちました。備えあれば憂いなし。

結果

コスト節約

f:id:riseshia:20201124172749p:plain
コスト変化

実運用が始まったのが 9月中旬、古いインフラのリソースを片付けたのが10月初です。こちらは Cost Explorer からみた検索インフラのコストの推移ですが、新しいインフラコストが 1/3 くらいになったことがわかります。これは2つの理由があります。 今までの仕組みではそもそも運用しやすい形に実現するのが難しいのでオートスケーリングが有効ではなかったのが一つで、もう一つは Solr のバージョンアップによりそもそも Solr の性能が向上したことがあります。ピークタイムでも以前に比べて半分以下のリソースで運用できているんですね、これが。すごい。

レイテンシーの改善

f:id:riseshia:20201124172807p:plain
レイテンシーの変化

レイテンシーも相当改善されました。これも2つの理由があります。 わかりやすい理由は Solr の性能向上ですね。図の2つ目の崖がそれです。途中でも触れていましたが、 Varnish を介さなくなったのにも関わらず早くなったのが印象的です。 左側の崖は差分検証中に見つけた激重クエリが無駄な subquery を発行していることに気づいてそれを解消した結果発生したものです。

その他

その他の細かい改善点というと以下のようなものがありました。

  • Solr 起因の staging 環境でのエラーがほぼなくなった
    • staging 環境で利用してた Solr は本番用に比べると大変小さく、リクエスト数が急増すると悲鳴を上げがちだったのですが、今回の Solr の性能向上により安定するようになりました
  • 今まではインデックスの更新直後の負荷を恐れてピークタイムでのインデックス更新を避けていたが、そうする必要がなくなりました
  • 普通の ECS サービスになったことによりスキーマの更新と運用がしやすくなりました

まとめ

今回の検索インフラ改善作業の流れ、その時利用した技術などに関する説明は以上になります。このような面白いお仕事がいっぱいあるので興味があるエンジニアの方はぜひご連絡ください! https://info.cookpad.com/careers/jobs/

ドキュメントを書くときの「メンタルモデルの原則」

$
0
0

こんにちは。クリエイション開発部の丸山@h13i32maruです。

みなさんドキュメント書いてますか?私はドキュメントを書くのは結構好きです。最近もプライベートで開発しているJasperというGitHub用Issueリーダーのユーザ向けドキュメント(マニュアル)を書きました。でも良いドキュメントを書くのって難しいですよね。

そこで、本記事では「ツールやライブラリなどを対象にしたユーザ向けドキュメント」を書くときに私が考える原則を紹介します。ちなみに私はテクニカルライティングの専門家ではなく、普通のソフトウェアエンジニアです。そのあたりはいい感じに汲み取っていただけると🙏

🕵️メンタルモデルの原則

良いドキュメントとはどのようなものなのでしょうか?私は「そのツールやライブラリに対して読者がメンタルモデルを構築できる」のが良いドキュメントだと考えています。これを「メンタルモデルの原則」と呼びます(私が勝手に呼んでいるだけなので、ググっても出てこないかも)。

💡メンタルモデルを構築すると推測可能になる

メンタルモデルとは「これをするにはこうする。こうしたらこうなる。」というように動作や結果をイメージできるような心のありようです。あるツールについてメンタルモデルを構築できると、ドキュメントを読むこと自体が楽になります。また、いずれはドキュメントをほとんど読まなくてもツールを使いこなせるようになります。

例えば使い慣れたプログラミング言語であればドキュメントを読まなくても「こういう場合はこう書けそう」とか「多分このへんのドキュメントみたら使い方書いてありそう」となると思います。これはそのプログラミング言語に対してメンタルモデルを構築できているからです。

ようするにメンタルモデルを構築するとはその対象について色々なことを推測できるようになるということです。なのでメンタルモデルを構築できるドキュメントは有益なドキュメントだと考えています。

ではどのようにすれば「メンタルモデルを構築できるドキュメント」を書けるのでしょうか?それには以下のことを意識してドキュメントを書くことが重要だと考えています。

  1. 読者の現在のメンタルモデルや目的
  2. 演繹的・帰納的な説明

📝メンタルモデルの有無や目的に応じてドキュメントをかき分ける

ドキュメントを読む人は様々です。そのツールを使い始めようとしている人、特定の使い方を探している人、100%使いこなしたいと思っている人、などなど。そういった読者に合わせたドキュメントを書くことが重要です。このときに読者はどれくらいメンタルモデルを構築済みかも重要になってきます。

例として「初めて使う人向け」「特定の使い方を探している人向け」「もっと使いこなしたい人向け」のドキュメントについて説明していきます。

① 初めて使う人向け
初めてツールを使う人の場合メンタルモデルは全く構築されておらず、目的はとりあえずさくっと使ってみるというのがほとんどだと思います。そういう読者のためにセットアップ方法とすごく基本的な使い方のドキュメントとして「クイックスタート」や「チュートリアル」を用意するのがよいでしょう。

このとき注意すべきことはメンタルモデルが全く構築されていないので、多少冗長でも丁寧な説明をすることです。そうしないと思わぬところでハマってしまうことがあるでしょう。ただし、ツールの外側のことについてはメンタルモデルが構築されていると思うのである程度省略してもよいと思います。例えばJasperであればGitHubやMacアプリのインストール方法などはサラッと書くにとどめました。

② 特定の使い方を探している人向け
特定の使い方を探している人はある程度メンタルモデルが構築されています。なので「こういうことをするには、多分こういう機能がありそう」と思いながらドキュメントを読み始めます。よって「こういうことをするには」に相当するような「よくあるユースケース」や「サンプル集」を用意するのが良いでしょう。

③ もっと使いこなしたい人向け
こういう人はメンタルモデルがバッチリ構築済みなので、網羅的なドキュメントを用意するのが良いでしょう。いわゆる「リファレンス」や「辞書」みたいなものです。一つ気をつけるべきアンチパターンは「APIリファレンスしか用意されていないライブラリのドキュメント」みたいなものです。これは「もっと使いこなしたい人向け」以外の読者のメンタルモデルや目的を完全に無視していると思います。(それで問題ない場合ももちろんありますが)

ちなみにJasperでは以下のようにドキュメントの入り口を分けることによって、異なる読者を適切なドキュメントに誘導しています。

f:id:h13i32maru:20201126174616p:plain

🙆演繹的・帰納的な説明により推測を可能にする

人間がものを推測するときは大きく分けて演繹的な方法と帰納的な方法があります。

演繹的というのは「プログラミング言語には四則演算がサポートされている。TypeScriptはプログラミング言語である。よって、TypeScriptでは四則演算が使える」のような論理的な因果関係で推測することです。一方で帰納的というのは「TypeScriptは加算・減算・乗算が使える。なので除算も可能なはずであり、四則演算を使えるだろう」のようにいくつかの具体例をもとに推測することです。

よって演繹的・帰納的な方法で説明されたドキュメントを読むことで、そのツールの使い方や目の前のドキュメントには書かれていないことなどを推測できるようになっていきます(=メンタルモデルを構築する)。

例えばJasperでは「JasperはGitHubの検索クエリと完全互換があります」という説明をいれています。これによって読者は「ということはGitHub検索で使っていたあれもつかえるのでは?」という推測を可能にします。ほかにも「リポジトリ指定するにはこう、ユーザ指定するにはこう、ラベル指定するにはこう」という説明をしています。これによって「チーム指定する方法もあるのでは?」という推測を可能にします。

演繹的・帰納的という言葉を使いましたが、ようは「前提としていることを説明」「繰り返しやパターンで説明」を意識してドキュメントを書くとよいという話でした。


というわけでドキュメントを書く上での「メンタルモデルの原則」について紹介でした。良いドキュメントは著者も読者もwin-winなので、いいもの書いていきましょう。

レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話

$
0
0

技術部の外村(@hokaccha)です。今回はクックパッドのウェブサイトのフロントエンドを Next.js などを使って作り直している話を書きます。

この記事で紹介する新システムは、スマートフォン向けのレシピページで確認することができます。もし興味があるかたはレシピページをスマートフォンのユーザーエージェントで開いて DevTools などで確認してみてください。 Next.js と GraphQL で動いているのがわかると思います。

f:id:hokaccha:20201130205054p:plain

ご存じの方も多いかもしれませんが、クックパッドのウェブサイトはモノリシックな Rails で作られていて、10年以上 Rails で開発を続けてきました。10 年以上同じシステムで開発を重ねれば当然レガシーな部分が大量に生まれてきますが、特にフロントエンドはその影響が顕著でした。

どこから使われているかわからない CSS が大量にある、JS のコードは昔ながらの CoffeeScript*1と jQuery で書かれており、JS のライブラリの管理も vendor/assetsにファイルを入れてリポジトリにコミットするという運用。app/assets以下のファイルやディレクトリ名に規則や規約もなく、どこにファイルを置いていいかすらよくわからない、という状態です。

こういった状態なので当然開発効率は悪くなりますし、開発のモチベーションも低くなります。その結果ユーザーに価値を届けるまでのスピードが遅くなってしまうのが最大の問題です。この問題を根本的に解決するため、今回 Next.js でフロントエンドを作り直すという決断をしました。

一度にすべての画面を置き換えるのは無理*2なのでまずは最も活発に開発されておりユーザーからの利用も多いところから始めることにして、スマートフォン版のレシピページをターゲットに決め、今年の2月ぐらいから開発をはじめて先月リリースすることができました。比較的うまくいっているので今後も適用する画面を広げていく予定です。

以降では今回のシステムの技術要素やパフォーマンスへの影響について説明します。

技術要素

今回のシステムにおける重要な技術要素は以下です。

  • TypeScript(言語)
  • Next.js(フロントエンドフレームワーク)
  • GraphQL(API)

技術選択する上でまず最初に考えたのは、TypeScript を中心に据えることです。型チェックによる整合性の検査、補完やリファクタリングを中心としたエディタの支援など、TypeScript を導入することによる生産性の向上は非常に大きいものがあります。

次にフロントエンドの描画ライブラリはシェアの大きさやTypeScript との相性、使いやすさなどを考慮したうえで React を採用することにし、サイトの性質上 Server Side Rendering(以下 SSR)は必要になると思っていたので、React.js で SSR がいい感じに動くフレームワークということで Next.js を採用しました。Next.js は技術選定をした少し前に TypeScript 対応を強化したり、動的なルーティングをサポートしたりと、いい感じのアップデートがあったのも決め手の一つでした。

次に API です。クックパッドにはモバイルアプリなどで使われている、社内では Pantry と呼ばれている REST API のシステムがあります。最初は Pantry を直接 Next.js から利用する方向で考えていましたが、認証の問題や、リクエスト・レスポンスへの TypeScript の型付けの問題、将来的に Pantry 以外のサービスにもリクエストが必要になる可能性を考えると、BFF レイヤーに GraphQL を導入するのがよさそうという結論になり、GraphQL を導入することにしました。

この目論見はうまくいき、BFF のレイヤーとして GraphQL がいい感じに動いています。リクエスト・レスポンスの型定義には graphql-codegenを利用することで GraphQL のスキーマから TypeScript の型定義ファイルを自動生成しています 。また、GraphQL のサーバー実装も TypeScript (素朴な express-graphqlを使った実装)を採用し、単一の言語でフロントエンドと API を開発できるので言語のコンテキストスイッチが少なくなるようにしています*3

現状の GraphQL サーバーの仕事のほとんどは Pantry へのリクエストですが、Pantry へのリクエストのところを少し工夫して楽することに成功したので紹介します。

Pantry は Garageというフレームワークを用いて実装されている API サーバーです。Garage には fields というクエリストリングで取得するリソースのフィールドを絞り込めるという機能があります。

/v1/recipes/:id?fields=id,title,user[id,name]

このような感じです。カンマ区切りで取得したいフィールドを指定し、user[id,name]のようにネストしたリソースのフィールドも絞り込めます。これは GraphQL のクエリに非常によく似ています。GraphQL で表現すると次のようになるでしょう。

query {
  recipe(id: $id) {
    id
    title
    user {
      id
      name
    }
  }
}

Garage の fields は Facebook の Graph APIを参考にされて作られており、Graph API が GraphQL の原型であるという経緯により、このような類似したインターフェースになっているようです。今回のシステムではこの性質を利用し、GraphQL のクエリを Garage の fields に自動で変換し、Pantry へリクエストするという機能を実装しました。これはうまく動いており、 GraphQL のサーバー実装が大幅に簡略化されました。

システム構成

今回はすべての画面をリプレイスするわけではなく、一部の画面だけ新システムに向けるので、その制御を前段のリバースプロキシ(Nginx)で振り分けています。

f:id:hokaccha:20201130093752p:plain

/recipe/:idのスマートフォンからのリクエストを Next.js へ、/graphqlを GraphQL のサーバーへルーティングし、残りはこれまで通りの Rails のアプリケーションへルーティングします。Next.js のサーバーでは SSR をおこない、HTML を作ってユーザーに返します。GraphQL へのリクエストについては、Next.js が SSR 時に GraphQL の API を呼び出す場合と、クライアントがブラウザから直接 GraphQL の API を呼び出す場合があります。

SSR の是非については色々と議論があるでしょうが、パフォーマンス(特に LCP(Largest Contentful Paint))の最適化、OGP 対応などを考慮して SSR を採用しています。

社内では Node.js でサーバーを運用した知見がほとんどなかったので、性能や運用の面で不安がありましたが、社内には ECS によるコンテナのデプロイ基盤が整っており、Docker で動きさえすればマルチプロセス化などは考えずに 1vCPU でタスクを横に並べるだけでいいので思っていたよりも楽に運用が可能でした。性能面でも、Next.js の SSR サーバーは 200rps 強を 1vCPU のタスク 7 つ前後で捌けているのでまずまずといったところです*4

パフォーマンス

パフォーマンスの変化についても少し触れておきます。フロントエンドのパフォーマンス計測には Calibreというサービスを利用しており、以下が Calibre での before/after です。

before

f:id:hokaccha:20201127165227p:plain

after

f:id:hokaccha:20201127165251p:plain

これを見てもらうとわかるように、システムを刷新したことで大幅にパフォーマンスが向上しました。特に First Contentful Paint (以下 FCP) が圧倒的に速くなっているのがわかると思います。なお、これは低速回線(上記の数値は 3G 回線相当で計測)で特に顕著で、LTE 相当だともう少し差は小さくなります。

f:id:hokaccha:20201201101339p:plain
きれいな崖ができた様子

元の実装の FCP が遅かったのはシステムをリニューアルする前からわかっていた問題点のひとつで、巨大な CSS や defer できない JS が head で読まれていて、クリティカルレンダリングパスの最適化ができていないのが原因でした。なんとかしようにもどこで読まれているかわからない CSS が大量にあって消すのが難しい、haml(Rails の View)に埋めこまれた JS が head で読まれる JS に依存していて defer できない、などの理由で FCP の最適化が難しい状態でした。

ですので Rails が遅い、Next.js だと速い、というフレームワークの差ではありません。Rails でもスクラッチで書き直してチューニングすれば同程度のパフォーマンスはでます。ただ、Next.js はそういったパフォーマンス最適化をある程度自動でやってくる点においては非常に楽でした。また、Next.js に組み込まれた Web Vitals の計測機能を使って Web Vitals の数値を記録するようにしたのでこれを使って今後も改善を進めていく予定です。特に LCP、TTI(Time To Interactive) あたりはもう少しどうにかしたいですね。

まとめ

Next.js や GraphQL を使ってウェブサイトのフロントエンドのシステムを刷新している話を書きました。今後も適用範囲を広げていき、開発の生産性をあげることでユーザーに届ける価値を最大化していきます。また、今回書いた以外にも、認証やロギング、エラートラッキング、CSS in JS、描画のパフォーマンス最適化、A/B テストなど色々と面白い知見が溜まっているのでまた別の機会に共有したいと思います。

最後に、クックパッドではモダンなフロントエンドの基盤を作っていく仕事や、この基盤を使ってサービス開発する仕事が大量にあります(切実)。もし少しでも興味があればお気軽にお問い合わせください!

*1:CoffeeScript は今回の話とは別のプロジェクトでなくすことに成功しましたがこの話はまた別の機会に。

*2:調べたら現時点で1600以上のルーティングがありました

*3:Pantry を始めとするバックエンドの API サーバーは Ruby なのでそこを変更する必要があれば当然コンテキストスイッチは発生しますが。

*4:GraphQL のサーバーは別。

AWS の This is My Architecture(動画)でS3を中心としたセキュリティログ基盤の紹介をしました

$
0
0

技術部セキュリティグループの水谷です。

先日、Twitterに投稿もしたのですが、AWSのThis is My Architectureという事例紹介のシリーズでクックパッドが取り組んでいるセキュリティログ管理基盤の紹介ビデオが公開されました。この記事ではビデオの内容の補足、そして撮影の様子などを紹介したいと思います。

This is My Architecture とは?

AWSにおいて事例紹介となるようなアーキテクチャを構築・運用しているユーザが、自分たちのアーキテクチャを5分ほどのビデオで紹介するシリーズです。様々な分野で活躍しているユーザとAWSのアーキテクトの方と一緒にアーキテクチャを紹介する、という形式になっています。

aws.amazon.com

今回クックパッドでもオファーをいただき、社内でいくつかあった事例の中でセキュリティログ管理の話が紹介としては良さそうということで、私がAWSの桐山さんと共にビデオ撮影させていただきました。

動画の撮影

ここからは動画撮影に関するよもやま話をお伝えしようと思います。

撮影まで

この This is My Architecture の動画は毎年年末にラスベガスで開催されている1AWSのイベント re:Inventの会場で撮影されています2。そのため、この動画も撮影したのは実は去年のre:Invent(2019年12月)でした。

しかし、諸般の事情により私が現地に行って撮影する、というのが正式に決まったのが11月5日で、そこから超特急で渡航手配3や発表準備に取り掛かりました。10月にはre:Inventの参加するとは露ほども考えていなかったので、なんとか間に合ってよかったです。

原稿の準備

撮影準備で大変だったのはまず原稿の準備です。撮影をするうえで原稿を事前に用意しないといけないというルールがあったわけではないのですが、今回は英語で話すということと発表時間の制約が厳しい(原則5分以内)ことから、発表内容は全て原稿を用意しました。

このブログの後半に補足として書いていますが、今回のアーキテクチャとして伝えたいことはいろいろありました。なので、最初はもりもりの原稿になってしまい、かなり早口で説明しても2〜3分ほどオーバーしていた記憶があります。

そのため、このアーキテクチャの本質として伝えたいことに要点をしぼり、何度も推敲を重ねて実際に撮影に使用したバージョンまでブラッシュアップしました。特に私自身は英語が全く得意ではないので、わかりやすく丁寧に発音するためにはゆっくり喋れるような余裕が必要でした。そのため、さらに尺に余裕が出せるように原稿を短くしていきました。

発表練習

原稿の準備と並行して、発表練習も11月中にかなり時間をとって取り組みました。

f:id:mztnex:20201201143307p:plain

写真はAWSオフィスにて、実際の撮影時にある黒板をホワイトボードで想定しつつ、説明の手順を確認したり、発表原稿の読みあわせをしたときのものです。先に述べたように、私は英語がだいぶ苦手な部類だったため、流暢に話せるようにということも含めて練習していました。

発音に関しては(出来はさておき)かなり事前に確認しました。やはりどうしても日本人発音になってしまいがちなので、何度かネイティブスピーカーの人に発表を聞いてもらい、聞き取りづらいところなどをチェックして発音を修正する、というのを繰り返しました。また、自分が発音しづらかったりネイティブスピーカーの人に聞き取りづらいような単語は避け、別の単語に置き換えるような修正もしました。特に数字については結果を示すために非常に重要な要素にも関わらず、なかなか正確に聞き取ってもらうのが難しかったので、ボードに直接書く&特にはっきり話す、という作戦でいきました。

また、原稿準備のところでも触れましたが、今回は時間の制約が厳しかったことなどから発表原稿は一言一句丸暗記しました。英語でも日本語でも完全に丸暗記して発表するというのは実は初めての体験だったため、11月は暇さえあれば原稿をブツブツつぶやいていたなと記憶しています。

撮影本番

12月5日の朝(現地時間)から撮影でした。この前日は時差ボケ4と緊張で見事に一睡もできなかったのですが、意外と元気に撮影できました。

f:id:mztnex:20201201143414j:plain

撮影はre:Invent会場の一部が撮影専用のスタジオとなっており、そこで撮影してもらいました。This is My Architectureのページを見ていただくと分かる通り、かなりの数の事例紹介があるため、各チームが入れ替わり立ち替わりで撮影していました。

f:id:mztnex:20201201143434j:plain

ボードを用意してくれるスタッフの方にアーキテクチャ図の説明をしたりしつつ、その場でブラックボードに書き込みをしてもらうなど準備をして、撮影に突入しました。

f:id:mztnex:20201201143458j:plain

かなり入念に準備したかいあって、撮影自体は非常にスムーズに終わりました。一応、スタッフの方からは一発OKをもらったのですが、ちょっと言いよどんでしまったところもあったので英語版をもう一度だけ撮影し直して完了できました。

さらに「時間余ったからせっかくなんで日本語版も撮ろうか?」みたいな話になり急遽日本語版も撮影することになりました。過去のThis is My Architectureをざっと見た限り二ヶ国語で別々に撮影されたものはほぼ無さそうで、おそらくかなりレアケースだったようです。おかげで日本語版も撮影させてもらったのですが、それまでずっと英語で話していた内容で全く日本語版を想定していなかったので、撮影中は必死に英語から日本語に翻訳して喋っていました。

完成動画

ということで完成した動画がこちらとなります。動画の編集や公開のタイミングなどの問題で、最終的に公開されたのが少し遅くなってしまいましたが、無事公開していただきました。

英語版

www.youtube.com

日本語版

www.youtube.com

(動画の補足)S3を中心としたセキュリティログ管理基盤

今回撮影した動画は5分以内に収める&使えるアーキテクチャ図も限られていたため、詳しい説明を大幅に削ってエッセンスだけを話させてもらいました。(原稿の推敲にはだいぶ苦労しました)なのでこのブログで少し内容を補足させてもらえればと思います。ちなみに、この話はかなり前から取り組んでおり何度か講演やブログでも紹介させて頂いたものなので、それらと重複する部分がかなり含まれる点はご容赦ください。

https://techlife.cookpad.com/entry/2018/05/31/080000

過去の記事でも紹介したとおり、クックパッドではセキュリティ監視に使うログを全てS3に保存してから利用する、というアーキテクチャを採用しています。

従来、セキュリティ関連のログ管理ではSIEM(Security Information & Event Manager)などのログ管理ソリューションが用いられて来ました。細部は製品やサービスによって違うものの、大まかな発想としてはログを直接取り込んでアラートを検出するフローとログを保管するフローを別々に扱うものが多かったと思います。このアーキテクチャの利点は、取り込んだログをなるべく早くアラート検知に利用することで発報までの遅延が短くなることです。しかし一方で以下の2つの課題を抱えています。

  1. 障害対応時の対応負荷:センサーからのログ送信経路やアラート検知・ログ保管側のシステムに障害があった場合には対応・復旧作業が必要になります。再度センサーなどからの取り込みをしないとログが欠損してしまいますが、ログの取り込み元の数が多くなるほど対応の工数が大きくなります。また、各センサーで大きくバッファを持たないとログが消失する可能性もあります
  2. ログスキーマ管理の難しさ:利用するログの種類が多くなるとログのフォーマット、スキーマ管理も大きな課題になってきます。アラート検知やログ保管をする際にはログのフォーマットやスキーマを解釈して処理をする必要がありますが、これを事前に設定しておかないと取り込みに失敗します。失敗後のリトライで再度ログを送信する必要がでてくると、これもトラブル対応と同様にログの欠損や消失を防ぐための負荷が大きくなってしまいます。

この問題を緩和するために、クックパッドのセキュリティログ管理基盤ではアラート検知やログ検索のフローを実行する前に「S3にログを保存する」というレイヤーを挟んでいます。S3はバケット上にオブジェクトを作成された際、即座にそのイベント情報をSNS (Simple Notification Service) などへ通知する機能があります。これを使ってS3へのログデータ到達とほぼ同時にアラート検知のためのプロセスを発火させたり、検索システムへログデータを転送させることができます。検索システムへのログデータ転送は、先日AWSのブログで紹介されていたAWS サービスのログの可視化やセキュリティ分析を実現する SIEM on Amazon Elasticsearch Serviceでも同様のアーキテクチャが採用されています。

f:id:mztnex:20201201144027p:plain

このアーキテクチャの利点

先の述べた課題である 障害対応時の対応負荷および ログスキーマ管理の難しさが軽減されるのが、まず1つ目の利点です。利点として一見地味なのですが、継続して運用をすることを考えると、これらの負担軽減は大きな意味を持ちます。

S3は高い可用性を持っており、多くの場合自分でストレージを運用するのに比べてトラブルが少なくてすみます。そのためセンサーからS3へ「とりあえず」ログを投げ込んでおくことで欠損のリスクを大幅に小さくすることができます。また、S3に保存された後の処理が失敗しても、再度S3のオブジェクト生成イベントを流すことで容易に後続の処理を再開することができます(ただしこれは後続の処理が冪等になるよう設計されている必要はあります)。

ログスキーマ管理の観点では「センサーがログを送ってきたタイミングでスキーマが完璧に定義できている必要がない」というのが大きな利点になります。センサーのログを受け取った時点でパースするようなシステムの場合、送信の前に完璧にパースできるようスキーマの把握をしてパーサーを用意しておく必要があります。もしパースに失敗した場合はセンサー側から再送が必要になってします。しかしS3に保存する際にはスキーマは全く関係なく、もしパースに失敗してもS3から処理を再開できます。到着するまでスキーマがはっきりしないようなログでも、S3のオブジェクト生成イベントを一時的にどこかへ退避させることで、到着してからパーサーの準備をするということも可能です。

他にも、安価にログを保存できるS3でログ保管ができる、という利点があります。SIEMなどログ保管・検索を同じアーキテクチャに持つものだと、ログを保持するための料金が比較的高くなってしまいます。保管と検索を同時にしているため、保持しているログなら容易に検索できるメリットがある反面、ストレージ本体やストレージを動かすためのインスタンスないし筐体のコストが高価になりがちという問題があります。S3はデータの保存料金が非常に安価に抑えられておりコストメリットが大きいだけでなく、トータルの保存容量の制限もないためにストレージの残り容量を夜な夜な気にする必要もありません。ライフサイクルマネジメント機能を使って自動的に長期的に保存するのにも向いています。

このアーキテクチャの欠点

従来のアーキテクチャと比べたときに、S3を中心としたアーキテクチャの欠点として挙げられるのは、センサーからアラート検知やログ検索のシステムへログが到達するまでの遅延が発生することです。これはS3がオブジェクトストレージであるため、ログをなるべく細切れにせずにある程度の数を1つのオブジェクトにまとめたほうがリクエスト数が減ってコストメリットが出るためです。また、あまりにリクエスト数が多いとAPIの呼び出し制限にひっかかる恐れがあります(参考)。こうしたことから、センサー側で少しログをためた後にS3へアップロードするため、ログを貯める時間がそのまま遅延になります。この遅延がどのくらいになるかはログの流量やセンサーの能力や性質などに依存するため一概には言えませんが、筆者の経験則からするとおおよそ1〜2分、最大でも5分程度になるように各種設定するのが良いと思われます。

ここで問題になるのが、1〜2分ほどの遅延がセキュリティログの利用にどのくらいの影響を及ぼすのか? という点です。例えばManaged SOCのようなビジネスをしていたり専属の24/365体制なSOCを持つような組織の場合は、アラートが上がった場合に即応する体制が整っており、1〜2分の遅延を削ることに意味があるかもしれません。しかしそうでない場合は必ずしもアラート検出から1〜2分で対応できるとは限らず、遅延が致命的になるとは想像しにくいです。また、Managed SOCでもアラート発生から対応するまでのSLA(Service Level Agreement)が設定されている場合がありますが、最短でも15分程度です。その中の1〜2分であれば、多くの組織の場合許容できるのではないかと考えられます。クックパッド内でもこのような基準をもとに考え、多少の遅延は許容できると判断しました。

分析パートについて

この動画を撮影したのが実に1年前なので、分析パート(ボードのANALYSISの部分)について少し補足です。

アラートの検知については引き続きLambdaを使い、ログの中からアラートとして扱うべき事象の抽出をしています。アラート発報後の対応のフェイズについては サーバーレスで作るセキュリティアラート自動対応フレームワークで紹介しております。

また、ログ検索については動画中でgraylogを使っていると話していますが、その後Athenaをベースとしたセキュリティログ向け全文検索システムminervaに移行しており、graylogは退役しています。こちらについてはAmazon Athena を使ったセキュリティログ検索基盤の構築でも紹介しておりますので、よろしければあわせて御覧ください。

まとめ

最初、この撮影の話を聞いたときは「まあ5分喋るくらい大したことないでしょ」とか軽く考えていたのですが、正直な感想として想像の100倍くらい大変でした。しかし、撮影準備の過程で自分の作ったアーキテクチャについていろいろな人と議論できたり、その本質について考えさせられるなど良い経験もさせてもらいました。なにより、動画という良い形で成果を残せたことは、とても良かったと思います。この機会を提供してくれた&協力してくれたAWSの方々、そして桐山さんに改めて御礼を申し上げたいです。

少し話が変わりますが、クックパッドのセキュリティエンジニアはこのような自分たちが必要とする仕組みを自分たちで考え、組み立て、現場に活かしていくというのが役割の一つになっています。先日、CODE BLUE 2020でもこのような話をさせていただいたのですが、情報セキュリティの課題を知識や経験だけでなくエンジニアリングで解決していく、というのはとても刺激的で、私自身はとても楽しんで仕事をしています。しかし現状、一緒にチャレンジしてくれるメンバーが足りていないこともありセキュリティエンジニアのポジションは引き続き募集しています。もし興味のある方がいらっしゃいましたら、まずはzoomなどでカジュアル面談して実際どうよ?という話もできるかと思いますので、ぜひお気軽にお声がけください!


  1. (既に開催されていますが)今年のre:inventはバーチャルで11/30から3週間続くそうです https://reinvent.awsevents.com/

  2. なぜわざわざre:Inventの開催期間中なのかというと、re:Inventなら世界各地から関係者が集まるから一気に撮影できてよい、ということらしいです

  3. 直前に参加しようとした場合、最大の問題はホテルなのですが、これはre:Inventに参加する同僚の部屋が奇跡的に1人分余っている状態で、そこに転がり込ませてもらいました

  4. re:Inventの会期は12/2からで他の同僚はさらに前に現地入りしていたのですが、自分は12/1に引っ越しがあって、現地着が12/3 夜という突貫出張でした。おかげで当日までに時差ボケ治らず。

クックパッドマートのプロダクト開発チームに On-Call を導入した話

$
0
0

クックパッドマートでサーバーサイドエンジニアを担当している奥薗 基 ( @mokuzon ) です。

クックパッドマートのプロダクト開発チーム*1では半年ほど前からサービスの運用・障害・割り込み対応の当番として on-call を導入しています。直訳すると呼べばすぐ来る、待機しているという意味で、業務時間外も含めシステムを安定して運用するために待機するエンジニア、またはその制度そのものを指しています。SRE チーム*2には on-call は一般的ですし、プロダクト開発チームでも問題があれば直ぐに駆けつけることは一般的です。しかし、プロダクト開発チームで on-call を運用している事例は意外と世に出ていないので、モチベーションと運用方法、効果についてご紹介したいと思います。

クックパッドマートとは?

このブログでクックパッドマートに関するエントリーは久しぶりなので、改めてサービスの紹介をします。既にご存知の方はこのセクションは読み飛ばしても差し支えありません。

クックパッドマートは現在クックパッドが力を入れている新規事業の一つで、生鮮を中心とした EC プラットフォームです。

https://cookpad-mart.com/

以下がサービスの主な登場人物とその関係図です。

クックパッドマートの主な登場人物とその関係図

ユーザーが商品を購入し、販売者が商品を出荷し、ドライバーが各所に設置されたマートステーションに商品を届けます。 この流通を支えるために

  • ユーザー向け
    • EC モバイルアプリケーション
  • 販売者向け
    • 商品管理・出荷管理用 web アプリケーション
  • ドライバー向け
    • 作業用モバイルアプリケーション
    • 管理者向け web アプリケーション
  • マートステーション
    • 冷蔵庫そのもの
    • 遠隔監視・操作用のネットワークとアプリケーション

などを提供しています。

サービスローンチから2年経ち、最近では大手コンビニでマートステーション設置が始まるなど、鋭意拡大中のサービスです。 人員も急増しており、事業部の規模も社内最大になりつつあります。

なぜ On-Call を導入したか

クックパッドマートが on-call を導入することにした大きな理由は

  • 遅延が許されない流通に関わる処理が深夜に集中しているが、それが失敗することが多かった
  • 平日昼間の割り込みタスクが多い

の2つです。

深夜に起きる、遅延が許されない処理の失敗

これを説明するためにはクックパッドマートの特性について話しておく必要があります。

クックパッドマートの特性

1週間ごとに見直されるルーティング

理想はすべての販売者の商品をすべてのマートステーションで受け取り可能にすることですが、事業的・物理的制約でこれはまだ実現できていません。

そのため今は1週間ごとに販売者( 正確には販売者の共同出荷先 ) とマートステーションの組み合わせ、そしてそれを回るドライバーのルーティングを組み直しています。

購入可能かどうかが在庫だけではなく配送計画にも依存していること、そしてこの配送計画のデータ構造そのものが言葉にする以上に複雑です。

この複雑さにアプリケーションも人も対応しきれず、以前は入稿ミスやバグによるデータ不整合が多発していました。

山場が深夜から始まる

クックパッドマートの山場は販売者の商品の出荷から始まります。

市場や農家など、販売者の方々の朝はとても早いです。 早い販売者では深夜 02:00 から出荷が始まり、遅くても朝 10:00 までにほとんどの販売者が出荷を完了しています。 この出荷に合わせてシステムでは深夜から早朝にかけてユーザーの注文を締め切り集計し、出荷方法の指示をまとめて販売者に送信するという処理が動いています。

また、朝 08:00 にはドライバーの集荷・配送手順をまとめたデータ群が作成されます。

実際のドライバーへの指示
実際のドライバーへの指示

On-Call 導入へ

データ不整合があると販売者への出荷指示やドライバーへの集荷・配送指示の作成が止まり、比喩ではなく流通が止まります。流通はかなりギリギリのタイムラインで組まれているため、一刻も早く修正する必要があります。

これらを支える処理は販売者やドライバーが活動する前に処理が済んでいる必要があります。しかしまだアプリケーションが成熟していなかった頃は、この重要な処理を行うバッチが深夜に高頻度で落ち、夜な夜な対応に追われるということが続きました。

深夜に需要バッチが落ち悲鳴を上げるエンジニア達深夜に需要バッチが落ち悲鳴を上げるエンジニア達
深夜に需要バッチが落ち悲鳴を上げるエンジニア達

しかも原因が複雑さにあるために対応可能なエンジニアも少なく、特定メンバーの深夜労働時間が突出して伸びたり、毎晩深夜 02:00 のバッチが通るまでは心配で寝られない、という非常に不健全な状態に陥っていました。 そのため各日ごとに張り込むメンバーを決めて、それ以外のメンバーを解放することが急務でした。 この張り込みを当番化することが on-call 導入のきっかけになりました。

なお、現在は

  • 事前チェックスクリプトが整備され、深夜になる前に問題の修正ができるようになった
  • 万が一の場合も電話で通知されるようになった

ため、深夜のトラブルはほぼなくなり張り込みは完全になくなっています。

割り込み対応

もちろん理想は割り込みが起きないよう根本的な改善をしていくことですが、限られたリソースで新規開発を進めている以上、必ず割り込みは発生します。 そして悲しい現実として、割り込み対応をし続けると本来の業務へ使える時間は減っていき、人事評価に繋がる成果からは遠のきやすくなります。これでは負担が集中しているメンバーのメンタルは濁ります。

そこで、まずは最低限 on-call によりローテーションを決めて特定メンバーに割り込み対応が偏らないようにしています。 また、on-call の時とそうでない時で割り込み対応の有無を明確に分けることはエンジニアが集中しやすい環境としても重要です。

以下の3つがクックパッドマートの割り込み対応の大半を占めています。

データ修正

新規サービスに携わったことがある方々は分かると思いますが、サービス初期は素早くユーザーに価値を提供し事業を成立させることが最優先であるため、どうしても管理画面をはじめ運営用ツールの開発は後回しになりがちです。 しかし事業の規模や複雑さが増すにつれてデータ修正の件数は増えてきます。

すると何が起こるかと言うと、データ修正の依頼がエンジニアに殺到します。 管理画面や機能がない以上、非エンジニアはエンジニアにデータを直接書き換えてもらうかデータ更新のワンタイムスクリプトを書いてもらうしかないからです。

そしてこの割り込みは、関わるメンバーの多さや親切さなど理由はいくつかありますが、一部のメンバーに集中する傾向があります。今も個人への依頼を都度 on-call に誘導する努力を続けています。

非エンジニアからの質問を調査

非エンジニアの同僚から仕様を質問されたり、問題が起きた時に原因調査を依頼されたりすることはよくあります。 もちろんすぐに答えられたり責任範囲が明確だったりするものはそのメンバーが対応すればよいでしょう。しかし、エンジニアも自分が実装した内容を全て覚えてはいませんし、そもそも実装したエンジニアが今も在籍しているとは限りません。その場合結局エンジニアも都度コードを読んだり背景をたどったりして調べる必要があるのです。

特にユーザーからの問い合わせをまとめて受けている CS*3からの質問が圧倒的に多いです。 またクックパッドマートのようなプラットフォーム型サービスでは、提供者側 ( クックパッドマートでは販売者 ) に対しても CS が存在します。こちらは売り上げに直結するため温度感が総じて高く、優先度最高の割り込みになりがちです。

実は on-call 導入前はまだユーザー向けの CS 専任メンバーがおらず、大きな負担にはなっていませんでした。しかし、経験上専任メンバーが生まれたら爆発的に増えることはわかっていたのであらかじめ負担が分散される on-call の仕組みに乗せてしまいました。実際に予想通りになった上、ユーザー数の増加にあわせて CS 案件も増えているので、先手を打ててよかったと思っています。

アクセススパイクへの事前準備

クックパッドマートは EC サービスなので、販促イベント等の

  • いい商品が出た
  • 期間限定品が出た
  • 安く買える商品が出た

という状況ではアクセスが如実に跳ねます。具体的には販促のプッシュ通知やタイムセールが該当します。特にタイムセール開始時のアクセス数は平時の15倍以上になります。このようなアクセススパイクは平時のインフラ構成では捌ききれないので、適切に対策をしておく必要があります。

タイムセール時の rps
実際のタイムセール時の rps、 18:30 過ぎに跳ねている

クックパッドマートのシステムのインフラは殆ど AWS*4を利用して構成されています。さらに、プロダクト開発チームが自走出来るように SRE チームが権限の譲渡や基盤の整備などいわゆるセルフサービス化を進めているため、プロダクト開発チームでも容易にアプリケーションやデータベースのスケールアウトが出来る環境になっています。

販促イベントは不定期かつ内容も多彩なので、こうしたイベントがスケジュールされた際に過去の事例等からキャパシティプランニングをしてインフラの対策をし、該当時間に監視を行うのも on-call の重要な役目となっています。

このアクセススパイクとの奮闘はまた別の機会に詳細にご紹介できればと思います。

On-Call の運用方法

クックパッドマートでは2020年12月時点で 18 名のエンジニアで Primary と Secondary を12時間ないし24時間間隔でローテーションしています。 基本的には Primary が優先的に対応し、あふれた分を Secondary が拾うスタイルです。 現状では障害や割り込みといったタスクの種類と役割の紐付けは行っていません。

運用にあたっては以下のツールを使っています。

エンジニア依頼・質問専用の Slack チャンネル

弊社は全社的に Slack*5を使っており、クックパッドマートも大量のチャンネルを使っています。その中で、エンジニア依頼・質問専用のチャンネルを作りました。

これにより

  • 各チャンネルにいる身近なエンジニアに負担が集中することを防げる
  • 各チャンネルに散らばっていて全体像が見えない割り込み対応の規模・状況が俯瞰できる
  • 記録が一箇所に蓄積される
  • 新メンバーがコミュニケーションする場所を迷わない

などのメリットがあります。

GitHub Issue

よくフロー型とストック型のツールを適切に使い分けましょうと言いますが、上記の Slack 専用チャンネルでは正に情報をストックしづらく参照しにくいという課題があったため、GitHub*6の issue 上にて記録を残すことを意識したやり取りにシフトしつつあります。

こうすることで、初めて経験する問題に直面した際も、過去の同様の事例を元に対処しやすくなりました。

なお、この issue は一つ専用リポジトリを作って、issue open / close のみ先述の Slack チャンネルに通知を流すようにしています。

これにより通知で流れが阻害されることなく、各案件のアサイン割り振りや状況確認等を適切に Slack で行えるようになっています。

現在は以下の template を設けています。

## 概要

## 関係する URL

## 解決したい期限(あれば)

<!---
どなたかをこの issue にアサインして、誰がこの issue に取り組んでいるのか分かりやすい状態にしてください。

* エンジニア on-call で今 Primary の人をまずはアサインしてください。
    * その後、より適切な方がいればエンジニアの方で適宜アサインを変えていきます。
* よく分からなければ何もせずそのままにしておいてください。
    * 気づいたエンジニアの方、アサインの設定をお願いします。
    * もしちょっと待っても誰もコメントしていないようであれば #kaimono-dev-inquiry でエンジニアにご連絡ください。
-->

PagerDuty

当初は2ヶ月ほど Google スプレッドシート*7でローテーション管理をしていました。しかしこの運用は管理者とon-call 対象者に少なくない負担を強いていました。 さらに、通知などツールによる支援を受けたい需要も大きかったため、部署横断の SRE チームで既に利用実績のある PagerDuty*8を採用しました。

  • 夜間に重要な処理が多く走るため、電話をはじめ多くの通知方法を備えている点
  • 業務委託の方にも on-call をご対応いただく際に、業務時間外に割り振られないよう柔軟にローテーションのルールを決められる点
  • 各自のスケジュールを iCal 形式で出力できる点

が特に助かっています。

On-Call を導入して

これまで述べた役割を上記の方法で機械的に分担するようにした結果、

  • on-call 時以外の平和な夜
    • とはいえ今は on-call でもほとんど平和
  • on-call 時以外の割り込みが激減し、生産性が向上
  • on-call は予めスケジューリングされているため、それを見越した見積もりが出来る
  • 割り込み対応の偏りが軽減し、メンバーのメンタルに良い影響
  • 特定メンバーに集中していたアプリケーションの知識やノウハウの拡散

など、サービスの課題から組織の課題まで幅広く健全な状態に近づいたと考えています。

反面、参画して間もないメンバーにも在籍が長いメンバーと同様の負担を強いており、on-call に精神的負担を感じてしまう課題も出始めています。 この問題には、初期は経験豊富なメンバーとペアを組んだり、可能な限りドキュメント整備したりすることで改善を図っています。また、on-call のオンボーディングに関して SRE 本*9で非常に実践的な内容が紹介されており、SRE でなくともそのエッセンスは十分に活かせると考えています。

今後も、様々な問題を仕組みで解決するエンジニアらしさを武器にサービスも組織も更に成長させてゆきたいと思います。 そんなクックパッドマートは絶賛エンジニア大募集中なので、興味のある方はぜひ以下のリンクから詳細な採用情報をご覧ください。

https://cookpad-mart-careers.studio.site/

最後までお読みいただきありがとうございました。

*1:プロダクトの開発を行うチーム。イノベーションを生み出すことに責任があり、その速度も非常に重要視されている。チームには様々な職種のメンバーがいるが、本記事ではその中のエンジニアを指してこの用語を使っている。

*2:サイト・リライアビリティ・エンジニアリング ( Site Reliability Engineering ) を行うエンジニアチーム。Google が提唱していて、プロダクトの安定性に責任を持ち、そのためにソフトウェア・エンジニアリングのエッセンスを用いている。

*3:Customer Support、いわゆるお客様窓口。

*4:https://aws.amazon.com/

*5:https://slack.com/

*6:https://github.com/

*7:https://www.google.com/sheets/about/

*8:https://pagerduty.com/

*9:Betsy Beyer, Chris Jones, Jennifer Petoff, Niall Richard Murphy 編、澤田 武男、関根 達夫、細川 一茂、矢吹 大輔 監訳、Sky株式会社 玉川 竜司 訳『SRE サイトリライアビリティエンジニアリング―Googleの信頼性を支えるエンジニアリングチーム』 O'Reilly Japan, Inc. ( 2017 )

大規模プロジェクトにおけるモバイル基盤の取り組み

$
0
0

こんにちは。モバイル基盤部のこやまカニ大好き(id:nein37)です。

モバイル基盤部では普段CI環境の改善やアプリのビルド速度改善といったモバイルアプリを開発しやすくする様々な取り組みを行っていますが、大規模なサービス開発をサポートするため、直接プロジェクトに参加する場合もあります。

クックパッドAndroidアプリでは10月に大規模なリニューアルを行いました。 モバイル基盤部でも数カ月間このリニューアル作業に関わったので、今回は大規模プロジェクトにおけるモバイル基盤部の役割について書いてみることにします。

リニューアル前リニューアル後
f:id:nein37:20201203195817p:plainf:id:nein37:20201203195915p:plain

リニューアルプロジェクトの概要

3月に書かれたテストケース作成を仕様詳細化の手段とする実験という記事でも少し触れられていますが、クックパッドiOSアプリは半年ほど前に先行して同様の大規模リニューアルを行っていました。

今回のAndroidアプリのリニューアルプロジェクトは先行するiOSアプリの機能や画面構成を元にAndroidで違和感のないように再設計し、6人のAndroidアプリエンジニアを3ヶ月程度投入してプラットフォーム間の機能を揃えるというクックパッドアプリとしてはかなり大規模なプロジェクトでした。

このプロジェクトの実施は実際に機能開発を行う数ヶ月前から告知されていたので、モバイル基盤部ではプロジェクトに先行して準備期間を設定し、アプリ全体の開発効率を引き上げるための取り組みを行いました。 この記事では主にこの準備期間にモバイル基盤が行った作業について説明していきます。

なお、このリニューアルに際してアーキテクチャは大きく変更していないので、記事中に登場するVIPERアーキテクチャ関連の用語に関しては2020年のクックパッドAndroidアプリのアーキテクチャ事情を参照していただくとわかりやすいと思います。

やったこと

まず最初に、大規模リニューアルプロジェクトの実施に先駆けて、事前にやっておいたほうが良いことをissueで議論しました。

f:id:nein37:20201203195951p:plain

以下に出てくる内容もほとんどはこの issue で議論されてタスクとして設定されたものです。 実際に準備期間で行わなかったことでも今後の改善内容として意識することができたので、特に大きなプロジェクトがない場合でも定期的にこういったissueを立てて議論すると良いかもしれません。

minSdkVersion 23

2月に開催された Cookpad.apk #4で3月から minSdkVersion 23 にしますという話をしていたのですが、その後の情勢の変化により一時的に全ユーザーに人気順検索を開放することになったため、この施策で支援できるユーザーを減らしてしまう minSdkVersion の繰り上げは延期されていました。 人気順検索開放施策の終了後もしばらく minSdkVersion 21 だったのですが、今回のリニューアルプロジェクト準備施策の一環として再検討を行い、6月には minSdkVersion 23 にすることができました。

クックパッドにおけるminSdkVersion 23 にすることの利点は主に以下になります。

  • Drawable への tint 挙動を揃えることができる
    • Android では Drawable リソースをメモリに展開して使い回すようになっていますが、5.x系のOSでは tint 適用後のリソースを再利用してしまうため、 本来は tint を適用したくない箇所でも tint が適用され見た目がおかしくなる場合があります。
    • この挙動はDrawableのドキュメントNote:として書いているだけだったので当初は原因がわからず調査が大変でした。
  • android:foregroundによる ViewGroupへのタッチフィードバック実装
    • API21, 22 では FrameLayout以外の ViewGroupforegroundが正しく反映されないため、foregroundを利用してタッチフィードバック(ripple)を実装するとうまく反映されません。
    • stackoverflowの類似投稿
    • material-components のリポジトリにもForegroundLinearLayoutが存在しているので、他プロジェクトでも不便そうだなと思っています。

マルチモジュール関連

Cookpad.apk #1去年のブログ記事でもクックパッドアプリのマルチモジュール化についてお話していますが、現在でも多くの画面実装は :legacyモジュールという巨大なモジュールに残っている状態でした。 :legacyモジュールがあるとついつい :legacyに依存したモジュールを作成してしまうのですが、これだといつまでも :legacyモジュールを無くせないので、準備期間の間に :legacyに依存しない VIPER シーンモジュール、 :featureモジュールを作れるように整備しました。

簡略化していますが、だいたい以下のようなモジュール依存関係になっています。

f:id:nein37:20201203200009p:plain

赤枠の app と書かれた部分がクックパッドアプリのアプリケーションモジュール、青枠の feature と書かれた部分がVIPERシーンで構成された :featureモジュール、そして 緑色の library と書かれた部分がVIPERよりも低レイヤーの :libraryモジュールです。 :featureモジュールは画面機能ごとに完全に独立していますが、 :libraryモジュールは共通の画面実装機能を定義する:library:ui、画面遷移処理を定義する :library:navigation、認証・通信機能を実装する :library:networkなど役割に応じて分割され、必要に応じて :library同士でも依存関係を持っています。

モジュール階層の整理

モジュールの依存整理と直接関係のない変更ですが、 Android Studio 3.6 (当時はまだbeta)から /library/networkのような階層化されたモジュールを正しく Project ウィンドウで扱えるようになったため、モジュールの配置を種類に応じて階層化しました。

f:id:nein37:20201203200027p:plain

Android Studio(Android Gradle Plugin) 更新は基本的に安定版が出るたびに随時行っていますが、 beta を先行して利用したい場合などは以下のように突然 Slack で方針を決める場合もあります。

f:id:nein37:20201203201228p:plain

feature モジュールで必要な機能の移動

:legacyモジュールには CookpadMainActivityと呼ばれる2000行程度の ActivityCookpadMainActivityが管理する ActionBar、サイドメニュー実装なども含まれています。 これらの機能を :featureモジュールから :legacyに依存させずに呼び出すため、 :library:uiモジュールに必要な実装を切り出しました。 その他の細かい Util 系クラスも役割に応じて :library:infra:library:navigationといったモジュールに移動させています。

分離が必要な処理は :featureモジュールを実装してみるまでわからない場合も多いので、事前準備した部分だけでなくあとから必要になって :legacyから分離した機能もかなりあります。 今後も必要に応じて素早く :legacyからの機能分離ができるようにコード理解に努めていきたいと思います。

モジュール間の画面遷移設計

クックパッドアプリでは、 ボトムタブごとにFragmentの遷移履歴を残すために Primary navigation fragment) という仕組みを利用しています。 Primary navigation fragment には長い間公式の詳しいドキュメントがなかったのですが、最近のFragmentドキュメント刷新によってわかりやすくなりました。 (Primary navigation fragment については長くなるので省略します。Navigation コンポーネントの NavHostFragmentと同じようなことを自前でやっていると思ってください)

クックパッドアプリ内の画面遷移ではこの primary navigation fragment が管理している FragmentManagerを利用して主に Fragmentによる画面遷移を行っています。 ここで問題になってくるのが遷移先 Fragmentインスタンスの生成方法です。 基本的に :featureモジュール同士は画面遷移がある場合でもお互いに依存を持つことが出来ません。もし画面遷移が必要な場合にモジュール間の依存で解決しようとした場合、互いの画面を行き来するような :featureモジュールが循環参照になってしまいます。 :featureモジュール間で画面遷移を行うためには遷移先の画面が実装されたモジュールに依存しないようにしつつ、遷移先画面のインスタンスを生成しなくてはいけません。

この問題を解決するため、クックパッドアプリでは低レイヤーの :library:navigationモジュールに配置した AppFragmentFactoryという interface にほぼすべての Fragmentの生成メソッドを定義して抽象化しています。 AppFragmentFactoryの実装はすべての :featureモジュールへの参照を持つアプリケーションモジュールで行っており、各画面が扱う画面遷移用のパラメータに関しては :library:navigationモジュール内に専用の data class を持つようにしています。

また、今回のリニューアルから結果を返す Activityへの画面遷移については ActivityResultContractを利用するように変更しました。 これまでは Activityの処理結果が必要な場合も AppActivityIntentFactoryという interface から Intentを返していたため startActivity()で呼び出すべきか startActivityForResult()で呼び出すべきかわかりませんでしたが、この変更によって結果を返す Activityへの画面遷移は AppActivityResultContractFactoryに分離することができ、画面遷移実装の難易度を少し下げられました。

画面遷移に関しては将来的には公式実装である Navigation コンポーネントに置き換えていくことになると思いますが、クックパッドアプリでは :library:navigaionモジュールの存在によって将来的に別の仕組みにも移行しやすく無理のない実装になっていると思います。

デモアプリモジュールの実装

:legacyに依存しない :featureモジュールを作成できるようになったことで、特定の :featureモジュールのみに依存するアプリモジュール、デモアプリモジュールも作成できるようになりました。 :legacyに依存していてもデモアプリモジュールを作ることはできるのですが、 :legacyへの依存が入るとビルド速度がどうしても遅くなってしまうため、これまではデモアプリモジュールをあまり検討していませんでした。

デモアプリの仕組みはiOS アプリで先行してSandboxアプリとして実装されているものとほぼ同じです。 Androidプロジェクトでは demo という名前のモジュールで作られていることが多いので、クックパッドのAndroidアプリでも :demo:○○_demoというモジュールで作成しています。 大体以下のような構造になっています。

f:id:nein37:20201203200124p:plain

デモアプリモジュールは demo:app_baseへの依存を持ち、このモジュール内で :library:navigation:library:network系モジュールで定義された interface の空実装(stub と呼んでいます)を定義しています。 各デモアプリモジュールは必要に応じて stub を継承し、自分が参照する :featureモジュールへの依存や特定の DataSource が返す結果など必要な処理だけを上書きしています。 デモアプリモジュールではこの仕組によってユーザー状態やネットワークレスポンスをモックすることで様々な表示テストや挙動確認を行うことができる他、巨大な legacy モジュールにも依存していないため、ビルド時間も非常に高速です。 手元の環境で同一差分を :featureモジュールに与えてビルドしてみた所、通常のクックパッドアプリでのビルドは54秒かかるのに対しデモアプリのビルドは16秒でした。

実際に今回のプロジェクトでもつくれぽ送信画面改修時に demo:tsukurepo_demoモジュールでビルドされたアプリが非常に活躍しました。 demo:tsukurepo_demoは画像選択を行う Activityへの遷移処理をモックして固定の画像を返す機能をもっているため、画像の複数枚選択時の挙動を簡単に試すことができます。 以下のアニメーションがデモアプリで画像選択機能をモックして固定の画像を返すようにしているときの動作です。

デモアプリと直接関係のない変更でもデモアプリモジュールが依存している interfaceを編集するたびに stub の修正が必要になってしまうという欠点はありますが、デモアプリがうまく利用できる場面では開発効率が非常に良くなるため今後もデモアプリの運用を改善していく予定です。

スタイル再定義

これまでクックパッドアプリでは2016年頃に定義したスタイルやThemeを少しずつメンテナンスしながら使っていました。 2016年から現在までデザインの大きな変更がなかったため、アプリ全体の Theme/Style も当時のまま AppCompat をベースにしたものを利用していましたが、今回のリニューアルにより Material Components を利用したほうが効率的に実装できる箇所が増えたたため、 Theme.MaterialComponents.*ベースで Theme/Style を再定義することにしました。

ボタン定義

クックパッドアプリでは ButtonTextViewの左端にアイコンを置くデザインをよく使っています。 Android のボタンには上下左右にアイコンを表示するための android:drawableStart属性があり、これまではクックパッドアプリでもこの属性を利用してアイコンを表示していました。 android:drawableStartを利用した場合、以下のようにボタンの左端にアイコンが表示されます。

f:id:nein37:20201203200137p:plain

これまでは上記のデザインで問題なかったのですが、新しいデザインではこのアイコンを文字に揃えて中央寄せにしたいという要望がありました。

f:id:nein37:20201203200154p:plain

これを解決するため、 Material Components の部品である MaterialButtonを利用することにしました。 この部品は先述の android:drawableStartとは別に app:icon属性を持っており、これによってより細かいアイコン描画の制御を行うことが出来ます。 同時に app:iconSizeによる表示サイズの制御や app:iconTintによる表示色の変更もできるようになり、より柔軟な表示ができるようになりました。

MaterialButtonはアイコン表示の他にも app:cornerRadiusapp:strokeWidthといったこれまで背景画像や Shape を利用して描画していた角丸・枠線を描画する属性も備えており、より再利用性しやすい Style を定義することが可能になりました。

実装時に遭遇した問題として、当時の MaterialButton実装にバグが有り、android:background に drawable リソースを指定すると正しく反映されないという問題がありました。 これは簡単に回避する方法がなかったので背景色を android:backgroundTint + color state リソースにして解決しました。 角丸や枠線をすべて属性だけで解決できる MaterialButtonでは drawable リソースを android:backgroundに指定するケースはほとんどないので、結果的に背景リソースがシンプルになってよかったと思います。

他にもToggleButtonMaterialButtonと Style を共通化できなくなるなどの問題もありましたが、 ToggleButton自体の利用箇所が少なかったため、専用の Style を定義しなおして再実装できました。

上記のような問題がありつつも無事 MaterialButtonへの乗り換えができたので、 Hyperion のデバッグメニューからアクセス可能なボタンStyleのプレビュー画面を作成しました。こういった画面を作っておくとレイアウトXMLを実装サンプルとしても使えるので便利です。

f:id:nein37:20201203200107p:plain:w320

MaterialTheme の導入

MaterialButtonを利用することにしました」とさらっと書きましたが、MaterialButtonはアプリの Theme が Theme.MaterialComponents.*を継承している場合しかうまく動作しません。 そのため、アプリの Theme にも手を入れる必要があります。この作業は本当に大変でした。

クックパッドアプリはこれまで Theme.MaterialComponents.Lightを継承していましたが、基本的なボタンなどの Style などは整備されており、その中で StateListDrawableによる背景色切り替えをタッチフィードバックとして利用していました。 長い間、 colorPrimaryすら定義されない状態のまま長年運用してきていたのです。

しかし、 Theme.MaterialComponents.*ベースのアプリではそういうわけにはいきません。 colorPrimary未指定でも色々な箇所にリップルエフェクトがかかり、謎の紫色の tint が適用されます。デフォルトカラーなのかなんなのかわかりませんが、クックパッドアプリが部分的に紫色になってしまうのです。 これを直すために theme の color*系属性を指定し、いろいろな View のデフォルト style を整備し、実装のよくないレイアウトファイルを直しました。 おそらくすべて直せたと思っていますが、もしクックパッドアプリに変な紫のボタンやタッチフィードバックを見かけたら、それは僕の実装漏れです。こっそり教えて下さい。

幸いなことに Material Components の各属性の定義ドキュメントは本当にしっかりしているので、慣れると短期間で色々な箇所を実装できるようになりました。 後述する MaterialCardViewなど非常に素晴らしいView実装もあるため、これまでの AppCompat ベースの実装よりも実装効率が良いと思います。 ボタン Style の整備も含めて Material Components の完全導入には2週間以上掛かっていますが、これはやっておいて良かった変更でした。

もしまだ AppCompat ベースの theme を利用しているプロジェクトがあれば Material Components への切り替えをおすすめします。

MaterialCardView

Material Components を導入し、 Theme.MaterialComponents.*に切り替えたおかげで MaterialCardViewが利用できるようになりました。 このViewは本当に便利で、これまで複雑なViewを組んだり shape drawable + clipToOutline を用意して実現していたことを View 階層ひとつで解決してくれます。

  • 角丸がつけられる
    • これは普通の CardView でも実現できました
    • 内部のViewを自動的に切り取ってくれるので Glide での角丸処理などが不要で便利になりました
  • 枠線がつけられる
    • 角丸+枠線がこれひとつで出来ます。便利
  • ドキュメントから属性が探しやすい
    • Material Components の部品はすべてそうですが、実装例と属性が詳しく書いてあるので非常に実装しやすいです
    • 標準View の属性も Android Developers を見れば書いてありますが、あまりわかりやすくなかったのでこれは嬉しい変更です

たいていのレイアウトは MaterialCardView + ConstraintLayout で組めるので本当に便利になりました。

ShapeableImageView

ShapeableImageViewも Material Components を導入したおかげで使えるようになったView要素です。 Shape による画像の切り抜きや枠線をつけることができる ImageViewで、これまで Glide でやっていた処理をレイアウト側の定義だけで行えるようになりました。

画面実装ドキュメント整備

Material Components の導入による画面実装の変化やリニューアル実施前の相談によって決まった画面実装方針についてドキュメントをまとめました。 今回のリニューアルプロジェクトではAndroidアプリをこれまで開発していなかったメンバーも開発に参加することになったため、初学者にもわかりやすい内容と公式へのリンクをまとめました。 この内容については吉田さんが後日techlifeに記事を書いてくれる予定なので、主な内容だけ列挙しておきます。

  • ViewBindingの利用
    • 時期的にまだ Kotlin View Binding のサポート終了は告知されていませんでしたが、対象レイアウトファイルの取り違えが起きやすい等の問題があったため ViewBinding の利用を推奨していました
    • クックパッドアプリでは主に学習コストの問題から DataBinding はほとんど利用していません
  • Material Components の推奨
    • MaterialButtonShapeableImageViewの利用方法について書いています
  • ConstraintLayoutの使い方
    • よく使う機能や注意点についてまとめています
  • シンボルフォントの利用方法
    • クックパッドアプリでは一部のアイコン表示のためにカスタムフォント(ttf)を利用しています。
    • これを利用するための CookpadSymbolSpanという MetricAffectingSpanとそれを参照する style を用意しているため、その利用方法について書いています。
  • SampleData の利用方法

余談ですが、View実装ドキュメントをリポジトリに入れるPRのレビューにはクックパッドアプリだけでなくクックパッドマートアプリcookpadLive アプリの開発者もレビューに参加してくれていて、非常に良い雰囲気のPRでした。

統一ログ基盤の準備

@giginetさんがドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築という記事で iOSアプリのログ基盤について説明してくれていますが、 リニューアルプロジェクトの実施にあたりAndroidアプリでも同様のログ基盤を整備しました。

これにより、Android アプリでも iOS と同じ定義でログを実装できるようになったため、ログの実装や確認作業がかなり楽になりました。

ふりかえり

ここまでの施策を振り返ると目的別に振り返ると大体以下のような作業を行っていました。 画面構成が大きく変わるため、特にView実装の省力化にフォーカスしていることがわかります。

  • ビルド速度改善
    • feature モジュール依存整理
      • 画面遷移遷移再設計
    • デモアプリモジュール導入
  • 画面実装の省力化
    • minSdkVersion 23
    • Material Components 導入
      • Theme/Style 整備
      • 高効率な実装が可能なViewの導入
    • ドキュメント整備
  • 統一ログ基盤の実装

やってよかった施策

デモアプリモジュール

クックパッドアプリ全体の依存関係で見るとデモアプリモジュールというよりも:legacyモジュールと :featureモジュールの分離が達成できたというのが大きな成果でした。 個人的にはデモアプリモジュールは副産物としてしか見ていなかったのですが、実際にうまく活用できるケースでは実装時間や確認の手間を圧倒的に削減できたので、マルチモジュールプロジェクトでは取り組む価値はあると思います。

画面実装ドキュメント

画面実装は人によって実装方針がバラバラになりがちなので、記法方針をまとめたドキュメントがあることは実装・レビューの両方で時間の短縮に繋がり非常に良かったと思います。 リニューアルプロジェクトに向けて整備したドキュメントでしたが、今でもドキュメントを見て複雑な部分はまだ改善の余地があるということなので、今後の改善ツールとしても使っていける良い仕組みでした。

もうちょっと工夫できたなと思う施策

デモアプリモジュール

デモアプリはツールとしては非常に強力なのですが、クックパッドアプリではうまく動作させるための大量のモック実装(stub)が必要になってしまいます。 ほとんどの stub は :demo:app_baseに作成済みとはいえ、新規 :featureモジュールとセットで demo モジュールを作る作業はかなり大変なので省力化していく必要があると感じています。

リソースの命名規則

画面実装ドキュメントに書いておけば良かった項目の一つがリソースの命名規則です。 モジュール間でリソース名の重複が置きた場合、最後に解決されたモジュールのリソースで同名リソースがすべて上書きされてしまうため、 recipe_background.xmlのようなありがちな命名をしてしまうと意図せず他の画面のデザインを壊してしまう可能性があります。

クックパッドアプリではVIPERシーンという画面ごとの区切りがあるため、これを prefix として必ず入れるルールにすべきでした。 マルチモジュール構成のプロジェクトではありがちな事故なので、みなさんも気をつけてください。

おわりに

今回は大規模リニューアルプロジェクトを控えた状態で主に画面実装の効率を改善するための取り組みについて紹介しました。 リニューアルプロジェクトの作業も面白いですがこういう効率化のための裏方の作業もまた違った面白さがあるので、大きな改修を控えている場合は検討してみるのも良いと思います。

モバイル基盤部では他のエンジニアの開発効率を引き上げられるような取り組みについて常に考えています。こういった開発スタイルに興味がある Android エンジニアの方はぜひご連絡ください。

https://info.cookpad.com/careers/

Ruby 3 の静的解析ツール TypeProf の使い方

$
0
0

こんにちは、フルタイムRubyコミッタとして働いてる遠藤(@mametter)です。昨日、Ruby 3.0.0-preview2がリリースされました!

このリリースには、遠藤が開発している Ruby の静的型解析ツール TypeProf が初めて同梱されています。これの使い方をかんたんにご紹介したいと思います。

デモ

TypeProf は、型注釈のない Ruby コードを無理やり型解析するツールです。とりあえずデモ。

# user.rbclassUserdefinitialize(name:, age:)
    @name = name
    @age = age
  endattr_reader:name, :ageendUser.new(name: "John", age: 20)

typeprof コマンドは、Ruby 2.7 で gem install typeprofでインストールできます *1。TypeProf にこの Ruby コードを与え、typeprof user.rb -o user.rbsと実行してください。次のような内容の user.rbs が生成されているはずです。

# ClassesclassUserattr_readername: Stringattr_readerage: Integerdef initialize: (name: String, age: Integer) -> Integerend

TypeProf は、与えられた Ruby コードの型情報を推定して出力します。user.rbs は、Ruby ではなく、RBS という Ruby 3 標準の型情報記述の言語で書かれています。雰囲気でなんとなく読めるかと思いますが、たとえば def initialize: (name: String, age: Integer) -> Integernameというキーワード引数が Stringインスタンスを受け取り、ageというキーワード引数が Integerインスタンスを受け取る、ということを表現しています。返り値は Integerですが、initializeの返り値はあまり意味がないですね。

TypeProf の特徴は、メソッド呼び出しの情報をフル活用するところです。これは従来のふつうの型解析と本質的に異なるところです。これにより、def initialize(name:, age:) ... endという型注釈が一切ない定義に対しても、User.new(name: "John", age: 20)という呼び出しで渡される型を見てそれっぽい型情報を推定します。

もうひとつデモ

TypeProf は、Ruby コードだけでなく RBS も合わせて解析できます。デモ。

# test.rbdefhello_message(user)
  "The name is " + user.name
enddeftype_error_demo(user)
  "The age is " + user.age
end

user = User.new(name: "John", age: 20)

hello_message(user)
type_error_demo(user)
# user.rbsclassUserdef initialize: (name: String, age: Integer) -> void

  attr_readername: Stringattr_readerage: Integerend

user.rbs は、前の例の出力を少しだけ手修正したものです。これと test.rb をあわせて解析します。 typeprof -v test.rb user.rbsと実行してください。

# Errors
test.rb:7: [error] failed to resolve overload: String#+# ClassesclassObjectprivatedef hello_message: (User) -> Stringdef type_error_demo: (User) -> untyped
end

コマンドライン引数の -vはエラーの可能性を表示させるオプションです。 このため、今度は # Errorsという出力があります。 test.rb の 7 行目を見てみると、"The age is " + user.ageという計算をしていますが、これは StringIntegerを結合しようとしています。 TypeProf はこれをバグとして警告しています。

class Objectから endは、先程と同様に test.rb の型情報を推定したものです。 なお、7 行目に型エラーの可能性があって型の追跡ができなくなったため、type_error_demoメソッドの返り値は untypedとなってます。

Ruby TypeProf Playground

TypeProf は Ruby TypeProf Playgroundでブラウザ上で試せます *2

左上が Ruby コード、左下が RBS(書かなくても良い)で、Analyze ボタンを押すと右側に解析結果が表示されます。 Ruby コードをいじって解析することもできるので、期待に反する挙動を見つけたら Report bug ボタンでぜひ報告してください *3

TypeProf の課題と現状

TypeProf は「型注釈を書かない選択肢を Ruby に残す」ということを至上命題とした極端な設計になっているので、様々な問題点もあります。

  • とにかく解析が遅い *4
  • まともな解析のためにはテストコードが必要 *5
  • 現時点では解析精度が低く、誤検知や見逃しがとても多い
  • 手本になる前例がなく、TypeProf 自体の設計に試行錯誤が必要

そのため Ruby 3.0 の TypeProf では、型注釈のない Ruby コードに対して RBS スタブを生成する、という機能にフォーカスして設計・実装を進めました *6。 RBS スタブ生成機能として経験を積みながら、高速化や解析精度向上を進め、将来的にはかんたんな型検査器として使えるものにできたらいいなと思っています。

まとめ

Ruby 3 に同梱される型解析ツール TypeProf をご紹介しました。かんたんな使い方にフォーカスして書いたので、もう少し詳しいことはドキュメントや過去の発表をご参照ください。

github.com

rubykaigi.org

TypeProf の現状の完成度としては、Ruby パッケージに含まれるすべての .rb ファイルで解析が通る *7ことを確認できた程度です。出力の精度評価や速度向上はまだまだこれから頑張っていきます。ぜひ遊んでみて、気づいたことがあったらバグ報告でも感想でもいただけると泣いて喜びます。

RBS と TypeProf の関係は? Steep や Sorbet というのも聞いたが?

Ruby 3 の静的解析は固有名詞が多くてややこしいので、関係を別記事にまとめました。

techlife.cookpad.com

*1:ruby 3.0.0-preview2 なら gem install なしで typeprof コマンドが利用可能です。

*2:社内で「こういうのを作りたい」と語ったら、id:koba789さんが 1 時間で作ってくれました。

*3:とても適当に運用しているので、サーバが落ちたらごめんなさい。

*4:ふつうの型解析は基本的にメソッド単位で解析を行いますが、TypeProf は呼び出し元をたどる必要があるため、大変になりがちです。なお、RBS 言語を部分的に手書きすればするほど(理論上は)早くなります。

*5:解析のカバレッジを上げるため、スタブ実行というヒューリスティクスを実装しています。これは、どこからも呼ばれなかったメソッドに untyped な引数を与えて無理やり呼び出すものです。これにより、テストがなくても一通りの解析は行えるようになっています。

*6:なお、rbs コマンドにも Ruby コードから RBS スタブを生成する機能がありますが、こちらは基本的にすべての引数を untyped として出力するものです。やることが単純な分、速くて安定しているというメリットもあります。

*7:TypeProf が理不尽に例外終了しないことを確かめた程度ですが、やんちゃなコードだらけの test/ruby や ruby/spec はそれだけでも地獄の苦しみでした。


Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート

$
0
0

こんにちは、フルタイムRubyコミッタとして働いてる遠藤(@mametter)です。

Ruby 3 は「静的型解析」を備えることが目標の 1 つになっています。遠藤が開発してる TypeProf は Ruby 3 の静的型解析エコシステムの中の 1 ツールです。しかし Ruby 3 の静的解析というと、RBS、TypeProf、Steep、Sorbet などいろいろなツール名が出てきてよくわからない、という声を何回か聞いたので、かんたんにまとめておきます。

3 行まとめ

  • RBS:Ruby の型情報を扱う言語。Ruby 3 にバンドルされる。
  • TypeProf:型注釈のない Ruby コードを型解析するツール。Ruby 3 にバンドルされる。
  • Steep/Sorbet:Ruby で静的型付けのプログラミングができるツール。

詳しくはそれぞれ以下で解説します。

RBS とは

RBS は、Ruby 3 の型を扱うための基盤です。おおよそ、次の 4 種類のものからなります。

  • RBS 言語:Ruby プログラムの型情報を記述するための記法(拡張子 .rbs)
  • Ruby 組み込みライブラリの型情報:Ruby の組み込みクラス(ArrayStringなど)の型情報を書いた.rbsファイル群
  • RBS ライブラリ:.rbs ファイルのパースや解析などをするライブラリ
  • rbs コマンド:.rbs ファイルを扱うための便利コマンド

いろいろありますが、Ruby プログラマが直接意識するのは 1 つめの RBS 言語だけだと思います *1。よって、単に RBS と言ったら「RBS 言語」、または「RBS 言語で書かれたソースコード」を指すと考えるのがよいと思います。

RBS 言語の例を示しておきます(core/string.rbsよりものすごく抜粋)。Ruby っぽいですが、Ruby ではない別の言語になっています。

classStringdef empty?: () -> bool
end

この記述は、Ruby の組み込みクラスである Stringクラスの型情報(RBS言語で書かれている)の抜粋で、empty?という無引数のメソッドを持っていて、boolを返すということを表しています。 RBS ライブラリを使うと、こういう .rbs ファイルをパースして抽象構文木を得ることができます。 rbs コマンドは、.rbs ファイルを読んでメソッドを検索するなどができます。

RBS はそれ単体で何かをするものではなく *2、Ruby 3 の型情報を扱うツールが共通で使いたくなるものを集めた gem になっています。この gem は Ruby 3 に同梱されます。しかし基本的には型解析ツール向けの gem であり、普通の Ruby プログラマは RBS 言語を読み書きすることはあっても、RBS gem を直接使うことはあまりないと思います。

TypeProf とは

TypeProf は、型注釈のない Ruby コードを無理やり解析する静的型解析器です。Ruby 3 にバンドルされます。TypeProf は RBS 基盤を活用して作られています。

TypeProf の特徴はなんといっても、「型注釈を書かなくてもなんとなく型解析っぽいことができる」という性質に極振りして設計されているところです。 キーポイントは、メソッド呼び出しの情報を活用して解析するところです。 これにより、たとえばdef hello(user) ... endという型注釈が一切ないメソッドに対しても、hello(User.new)という呼び出しがあれば「メソッドhelloUserインスタンスを引数に取る」ということを推論します。

また、一部のクラスに RBS 言語で型情報を書いて TypeProf に与えることもできます。TypeProf はユーザが明示した型情報を無条件に信用するので、解析精度や解析速度が向上します。

TypeProf について詳しくは別の記事で解説しています。

techlife.cookpad.com

Steep/Sorbet とは

Steep は、Ruby の静的型検査器です。RBS を使って、伝統的な漸進的型付けによる型検査を行うことができます。 単に型エラーを検出できるだけではなく、LSP を実装しているので、エディタ上での型エラー表示、補完、ドキュメント表示なども実装されています。 現状で RBS を使って便利さを実感できるのは、Steep だけです。 このへんがわかりやすい記事にリンクしておきます。

qiita.com

Sorbet は、また別の Ruby の静的型検査器です。 こちらは RBS ではなく RBI という独自形式の型注釈を使います(RBS から RBI への変換器も開発されています)。 ものすごくざっくり言ってしまうと、できることは Steep とおおよそ同じです。 とはいえ、Stripe や Shopify という大企業ですでに数年ほど経験を積んでいるので、完成度はとても高いです。 解析器はC++で書かれていて、解析速度をものすごく重視しています。

Steep も Sorbet も Ruby 3 にバンドルされる予定はありません。 Ruby の設計者である matz が、「型注釈を書くことを Ruby本体として推進しない」と判断した結果です *3。この判断と相性の良い TypeProf は将来の期待とともに Ruby 3 にバンドルされますが、型注釈を書くことをいとわない人は Steep や Sorbet を使うとよいと思います。

なお、Steep 自体はバンドルこそされませんが、RBS はもともと Steep の型注釈言語でした。 Ruby 組み込みライブラリや gem の型情報を各種ツール間で共通化したかったので、RBS という形で共通基盤として切り離され、Ruby本体に同梱されることになりました。

再度まとめ

  • RBS: Ruby 3 の型情報を扱う言語を始めとする基盤。Ruby 3 にバンドルされる。
  • TypeProf: 型注釈のない Ruby コードを型解析するツール。Ruby 3 にバンドルされる。現状の主機能は Ruby コードからの RBS スタブ生成。
  • Steep/Sorbet: Ruby の静的型検査器。型注釈を書く必要はあるが、Ruby で静的型の便利なプログラミング体験ができる。IDE での補完やドキュメント表示も。

*1:厳密に言うと、rbs コマンドは触ることもあるかも。

*2:厳密に言うと、単体でも動的型検査機能が使えます。

*3:これについてはいろいろな意見があると思いますが、個人的にはそういう言語も面白いと思っていて、そのために苦しみながら TypeProf を開発しています。

基本の Android View 実装ドキュメントの紹介

$
0
0

モバイル基盤部の吉田です。 先日 Android アプリのリニューアル時に社内向けに用意した画面実装ドキュメントの内容を補足を交えてご紹介します。

用意した経緯

Cookpad の Android アプリの現在のコードベースは 2014 年に初回リリースされました。しかし当時の実装が 2020 年でもベストプラクティスであることは稀です。 Android 開発は日進月歩で様変わりしています。様々な時代のコードが入り交じるレポジトリで大規模なリファクタリングと新たなメンバーによる開発が始まるということで、新規実装の指針となる View 周りの実装ドキュメントの必要性を感じたので用意しました。

今回のドキュメントが View にフォーカスした理由は、全体設計に関しては既に VIPER の詳細なドキュメントが用意されていましたので、残りは View 周りの具体的な実装方針があればチームで大きなブレがない開発が出来ると考えたためです。

View のドキュメント以外にも、実装に必要な情報や slack 上の議論で決まった事項はdocs以下に明文化する文化があり GitHub Pages でいつでも読める状態を整えています。

View への参照方法

新しいコードでは ViewBinding を採用することにしました。 2020 年の夏の段階で私達のレポジトリでは DataBinding と ViewBinding と synthetics(KotlinAndroidExtension) の3つのツールが View への参照に使われていました。 昔から利用してきた DataBinding は 多機能なため他2つのツールが導入されても完全に置き換える意思決定が難しかったのですが、VIPER アーキテクチャの導入によって View に求められる役割が明確になったことで ViewBinding に統一することが出来ました。
また私達の意思決定とは無関係ですが、先日 synthetics は正式に非推奨なツールになったので ViewBinding への乗り換えが推奨されています。

Migrate from Kotlin synthetics to Jetpack view binding

レイアウトファイルの命名規則

レイアウト XML のファイル名は{component_type}_{screen_name}.xmlという命名規則としました。例えば RecipeActivity の場合、レイアウトファイル activity_recipe.xmlとなります。

コンポーネント 命名規則
Activity activity_xxx
Fragment fragment_xxx
CustomView view_xxx
ItemView item_view_xxx

ID の命名規則

実装からアクセスしたいビューオブジェクトには ID 属性で名前を付ける必要があります。この際ビューオブジェクトに割り振る ID 属性は camelCaseで命名することにしました。 ViewBinding から View にアクセスする際は自動で View の ID が camelCase に変換する仕様があるため、XML 側でも camelCase で記述することで対象アイテムを見つけやすくしています。

<TextViewandroid:id="@+id/recipeName" />

ConstraintLayout の活用

ConstraintLayout は以前から導入していましたが、利用箇所が限定的で十分に活用できていなかったので、新規 View を作成する際は ConstraintLayout で View の配置の指定するように定めました。 ConstraintLayout も非常に多機能ですべての機能は紹介しきれないですが、基本的となる考え方と私達が頻繁に利用する便利な機能を紹介します。

MATCH_CONSTRAINT について

ConstraintLayout は width や height に 0dp を指定してレイアウトすることがあります。これはMATCH_CONSTRAINTという状態で制約に従って最大の大きさにレイアウトすることを示しています。 意外に知られていませんが、ConstraintLayout で MATCH_PARENTを利用するのは非推奨であり下記で紹介する便利な制約のいくつかが正しく動作しない可能性があるので初めて使う際は覚えておきましょう。

Important: MATCH_PARENT is not recommended for widgets contained in a ConstraintLayout. Similar behavior can be defined by using MATCH_CONSTRAINT with the corresponding left/right or top/bottom constraints being set to "parent".

また maxWidthminWidthの代わりにlayout_constraintWidth_maxlayout_constraintWidth_minを利用する必要があるのもハマリポイントの一つです。

ConstraintLayout: Widgets dimension constraints(developer.android.com)

基本的な制約

ConstraintLayout が View の位置を決定するための制約方法は様々ですが、他の View との相対的な位置関係を使った制約を覚えると大体のレイアウトを組むことが出来ます。 相対的な位置関係を決める対象には id が振られている他の View と自分の親 View(parent)が指定可能です。制約は矛盾しない限りいくつでも追加できるので、例えば下記の例では2つ制約を組み合わせると水平方向の中央寄せを表現しています。

<RecipeViewandroid:id="@+id/recipeView"android:layout_width="300dp"android:layout_height="wrap_content"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"        />
属性 説明
layout_constraintTop_toTopOf 自分の上辺を指定した View の上辺の位置に合わせる
layout_constraintTop_toBottomOf 自分の上辺を指定した View の下辺の位置に合わせる
layout_constraintBottom_toTopOf 自分の下辺を指定した View の上辺の位置に合わせる
layout_constraintBottom_toBottomOf 自分の下辺を指定した View の下辺の位置に合わせる
layout_constraintStart_toEndOf 自分の左辺を指定した View の右辺の位置に合わせる
layout_constraintStart_toStartOf 自分の左辺を指定した View の左辺の位置に合わせる
layout_constraintEnd_toStartOf 自分の右辺を指定した View の左辺の位置に合わせる
layout_constraintEnd_toEndOf 自分の右辺を指定した View の右辺の位置に合わせる

下記の例では 「buttonB の左端が buttonA の右端になる」制約をつけることでボタン A,B が横並びに表示されています。(RTL 環境では左右が入れ替わります)

<Button android:id="@+id/buttonA" ... /><Button android:id="@+id/buttonB" ...app:layout_constraintStart_toEndOf="@+id/buttonA" />

f:id:kazy1991:20201207140915p:plain
ConstraintLayout (developer.android.com)

覚えておくと便利な機能

縦横比の指定

ConstraintLayout 以下の View では layout_constraintDimensionRatioが利用可能で View の縦横比を自由に制御できます。例えば"1:1"と指定すれば正方形の View を組むことが出来ます。 蛇足ですが意外にも正方形の View を組むのは大変で、昔はXxxSquareViewのようなカスタムクラスを用意する必要がありました。

<Button android:layout_width="wrap_content"android:layout_height="0dp"app:layout_constraintDimensionRatio="1:1" />

もう少し発展的な利用方法を紹介すると、constraintDimensionRatioは縦横どちらを基準に比率を決めるか指定することが出来ます。h,1:1とすると高さを基準にして 1:1、w,1:1とすると横幅を基準に 1:1 の大きさにレイアウトします。 また、縦横どちらもMATCH_CONSTRAINTの場合にconstraintDimensionRatioを利用すると条件を満たす最も大きなレイアウト方法で描画されるため、明示的に基準となる向きを指定するのがおすすめです。

<Button android:layout_width="0dp"android:layout_height="0dp"app:layout_constraintDimensionRatio="H,16:9"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintTop_toTopOf="parent"/>

バイアスの指定

基本的な制約の説明の際に中央寄せの例を出しました。これは ConstraintLayout 下では通常均等に制約の影響を受ける仕様を生かして簡単に中央寄せも表現できています。 位置を中央から調整したいケースも対応が簡単でconstraint(Horizontal|Vertical)_biasというプロパティが用意されているので、"0"を指定すると左の側の空間がなくなり左寄りにレイアウトされ、"1"を指定すると右寄りのレイアウトが可能です。

<!--- 左右の余白を3:7に調整したい場合 --><RecipeViewandroid:id="@+id/recipeView"android:layout_width="300dp"android:layout_height="wrap_content"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintHorizontal_bias="0.3"        />

文字列のベースラインを揃える制約

基本的な制約では Top や Bottom を利用した位置調整を紹介しましたが、文字列の高さ(ベースライン)を基準に制約をつけることも可能で、layout_constraintBaseline_toBaselineOfというプロパティが用意されています。

View のグループ化

Layerは要素のグループ化を行う疑似要素です。これまでは XML のネストによって View のグルーピングを表現していましたが、それらの代わりとして Layer を使う事ができます。 Layer は View を継承しているのでタップイベントなどのコールバックを受け取ることが出来ます。View なので background の指定も可能なのですが、私達が開発で利用していた2.0.0-beta6の時点では表示領域がおかしくなるケースがあったため、背景の指定は避けるようにしています。

同じような機能を持つものに Groupというものがあります。Group は View オブジェクトの Visibility をまとめて制御するための仕組みです。 ConstraintLayout の 1.1 から使える仕組みのため古い記事では Group を利用しているものが見つかるかもしれないですが、使い分ける必要はなくグループ化には Layer の利用を推奨しています。

<androidx.constraintLayout.helper.widget.Layerandroid:id="@+id/recipe_layer"android:layout_width="0dp"android:layout_height="0dp"android:background="?android:attr/selectableItemBackgroundBorderless"app:constraint_referenced_ids="recipe_count_label,recipe_count"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent"        /><TextViewandroid:id="@+id/recipe_count_label"android:layout_width="0dp"android:layout_height="wrap_content"android:text="@string/my_kitchen_label_recipe"        /><TextViewandroid:id="@+id/recipe_count"android:layout_width="0dp"android:layout_height="wrap_content"android:text="@string/no_count"        />

その他の便利な機能について

ここまで紹介した機能だけで ConstraintLayout を活用して基本的なレイアウトは組めるはずです。少し慣れてきたら BarrierGuidelineFlowを使ってみたり、 ChainStyleの違いなどの理解を深めてより複雑なレイアウトに挑戦してみたり、崩れにくいレイアウトの組み方について考えてみると良いでしょう。

Material Components (for Android)

もしあなたのチームが Material Components (for Android) を導入していなかったら真っ先に導入することをおすすめします。 MaterialComponent は Theme を利用することで Button などの View コンポーネントを置き換えすることも出来ます。Theme の置き換えが簡単にいかない場合もフルパスを指定することで部分的に MaterialComponent の View を利用することが出来ます。

Material Components の Theme の導入に関しては 大規模プロジェクトにおけるモバイル基盤の取り組みで詳しく書かれているのであわせて御覧ください。この記事では実装時に特に重宝した ShapeableImageView と MaterialCardView について紹介します。

ShapeableImageView

角丸や円形のユーザーアイコンを表示する際は画像読み込みライブラリ側で調整していましたが、ShapeableImageView を使うと XML 上でデザインを表現できます。下記の例は自分で Overlay を定義して円形の画像を表示するコードですが、MaterialComponent が提供している Shape が多数あるのでShape Themingを参照して下さい。

<!-- styles.xml --><style name="ShapeAppearance.Circle"parent=""><item name="cornerFamily">rounded</item><item name="cornerSize">50%</item></style>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".MainActivity"><com.google.android.material.imageview.ShapeableImageViewandroid:id="@+id/image_view"android:layout_width="wrap_content"android:layout_height="wrap_content"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent"app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"app:srcCompat="@tools:sample/avatars" /></androidx.constraintlayout.widget.ConstraintLayout>

f:id:kazy1991:20201210074324p:plain
黒背景は実際には描画されません

MaterialCardView

MaterialCardView はこれまで面倒だった内部要素を含めた角丸化したデザインが用意に組めるようになります。また strokeColorstrokeWidthを利用することで外枠を表現することも出来ます。不要であればtransparentを指定して隠すことも可能です。

<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="wrap_content"android:layout_height="wrap_content"app:cardCornerRadius="@dimen/image_rounded_corner"app:strokeColor="@android:color/transparent"app:strokeWidth="0dp">
....
</com.google.android.material.card.MaterialCardView>

社内アイコンフォントの利用廃止

クックパッドには社内 FontAwsome のような便利なアイコンセットがあり Web、モバイルアプリなどの様々なプラットフォームで利用されています。 以前はフォントファイルでの配布だったのですが、先日に複合的な理由でフォント形式でのアイコンセットの運用が終わり、代わりに SVG が提供される事になったため Android では VectorDrawable でサポートすることにしました。

アイコンフォントを TextView で表示していた頃と比べて VectorDrawable に移行したことでいくつか改善した事柄あります。 これまで Drawable しか利用できない箇所(OptionMenu のアイコンなど)はアイコンを画像に書き出して対応していましたがこのような対応が不要になりました。 デザイナーとエンジニアが協力して画面を組み立てる状況において、画面のどこでアイコンセットが使えて、どこが画像切り出しが必要か考えなくてよいのは小さい改善ですが開発効率に繋がります。

また Material Components が提供する app:iconは非常に便利なので XML で Drawable して参照できる VectorDrawable は非常に快適です。その他にはこれまで一部端末でアイコンフォントを利用すると正しく表示されないケースが報告されていたのですがそのようなケースに無くなると考えています。

SVG から VectorDrawable に変換する手法は公式には Android Studio の Vector Asset Studio という GUI ツールしかありませんが、AOSP(Android オープンソース プロジェクト)のレポジトリをチェックアウトすることで、vd-toolという CLI ツールが利用可能です。 クックパッドではvd-toolを使って生成した VectorDrawable を AAR のライブラリ形式にパッケージして社内 Maven レポジトリから入手可能にしています。 vd-toolの詳細については過去に個人ブログにまとめたのでそちらをご確認ください。

RecyclerView

クックパッドのレシピサービスのアプリでは EpoxyGroupieを使用せずに直接 RecyclerView を利用しています。RecyclerView の実装に関しては模索している部分が多いですが、既に慣習になっている部分のみ明文化しました。 上述の通りクックパッドでは VIPER アーキテクチャに沿って実装しているのですが、View から Presenter を呼ぶ際のコールバック扱いと ConcatAdapter を利用して積極的に Adapter を分解する実装手法を推奨しています。

コールバックの扱い

RecyclerView 内で発生したタップイベントを Presenter まで伝えるための callback は RecyclerView.Adapter を継承したクラスの先頭にエイリアスを利用して定義します。

//ReycerView.Adaptertypealias RecipePageRequest = () ->Unitclass RecipeListAdapter(
    privateval recipePageRequest: RecipePageRequest
) : RecyclerView.Adapter<RecipeListViewHolder>() {
    overridefun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeListViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding = ItemViewRecipeListBinding.inflate(layoutInflater, parent, false)
        return RecipeListViewHolder(
            binding = binding,
            recipePageRequest = recipePageRequest
        )
  }
}

//Fragmentclass RecipeListFragment : CookpadBaseFragment(), RecipeListContract.View {
    overridefun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        recipeListAdapter = RecipeListAdapter(
          recipePageRequest = presenter::onRecipePageRequested
        )
    }
}

ConcatAdapter

これまでヘッダーやフッターを持つ RecyclerView は非常に実装が厄介でしたが、ConcatAdapterの登場によって直列に複数の Adapter を繋ぐことが可能になリました。Adapter を ViewType 毎に分割すると単一の Adapter と比べて、引数がシンプルになり ViewType による内部実装の分岐処理が必要がなくなります。

val concatAdapter = ConcatAdapter(headerAdapter, pagedListAdapter, footerAdapter)
binding.recyclerView.adapter = concatAdapter

その他の取り組みについて

ドキュメントの整備の他の取り組みとして、 PullRequest の CI で「未使用リソースのチェック」と 「ktlint によるフォーマットの確認」を自動化させています。

おわり

2020 年に Android 開発 で View について知っておきたいことはある程度網羅出来たと思います。何かしら参考になっていたら幸いです。 もしかすると 2021 年末には Jetpack Compose が デファクトスタンダードになりこの記事は無意味な情報になるかもしれません。そのような未来も非常に楽しみですね。

補足すると ConstraintLayout は Jetpack でも利用出来ますし MaterialComponent の Jetpack Compose サポートのニュースも最近あったので知識が全て無駄になることはありません。 今後もAndroidの開発環境は少しずつ良くなっていくと思うので、また社内ドキュメントを大きく見直す機会がありましたらまた記事にして紹介したいと思います。

キッチンでの微細な重量変化を捉えるには?

$
0
0

こんにちは.研究開発部の鈴本 (@_meltingrabbit) です.

クックパッドの研究開発部では,ユーザーの課題を解決する手段をスマホの中からスマホの外(実世界)に拡張しようとチャレンジしています. 特に,料理を「作る」時の課題を解決するため,様々なデバイスを開発し,調理に関する知識と組み合わせることで,新たな調理支援の方策を切り開こうとしています.

その中の一例として,キッチンのワークトップやコンロでの微細な重量変化が取得できるデバイスをフルスクラッチで構築しました. 本稿ではその取り組みについてご紹介します.

調理中/調理後に知りたい情報?

調理において,重要な情報とは何でしょうか?

  • 分量(重量,体積)
  • 火加減(熱量,温度)
  • 加熱時間(時間)
  • 味付けの濃さ(塩分などの濃度,調味料などの重さ)
  • 焼き色(色,温度)

... などなど,様々なことに目を配りながら,日々調理していると思います.

今回は重量に着目してみます. 調理中の重量変化が取得できると,どういった調理支援が考えられるでしょうか?

いくつか考えてみます.

まな板上にあるカット中の肉や野菜の重量がわかれば,何人前の料理を作っているかがわかります. それによって,その分量に合わせて微修正されたレシピを提示する,といったサポートが考えられます.

また,鍋やフライパンなどに入れた調味料の分量が自動で計量されていたら,今回の味付けが記録できるかもしれません. 食べた後に,「今回はちょっと味が濃かったなぁ」など感じたときに,その時の調理を振り返られるかもしれません.

さらに,例えば唐揚げは,揚げているときに鶏肉の重量が徐々に減少していくことが知られています. この重量変化をつぶさに捉えることができれば,「今がベストな揚げ終わりのタイミングだ!」などと教えてあげることができるかもしれません.

以上のようなモチベーションから,キッチンワークトップの重量を高精度に取得してみよう,と思い立ちました.

調理支援のために必要な重量センサとは

調理支援のための重量センサについて考えます.

上で記したようなことを実現しようとするのであれば,少なくとも小さじ1杯,つまり5 g程度の重量測定精度が不可欠です. そして,ワークトップにのる可能性がある物(コンロや鍋や食材,加えて人間の押し込み荷重など)を考えていくと,最大荷重は最低でも50 kg程度が求められます.

とすると,,,最大荷重は50 kgと仮定して,ダイナミックレンジが1/10,000!?の重量センサを欲している,というわけです. そんな精度でかつキッチンに設置できるものなど,そう簡単には手に入りません.

残された道は,フルスクラッチでの自作. 自作するために,重量計測に対する要求仕様を以下のように策定しました.

  • 測定精度は5 g以下で,最小測定分解能はその1/10以上
  • ダイナミックレンジは可能な限り広く.最大許容荷重は50 kgを超えることを目標
  • サンプリング周波数は高ければ高いほどよい.1,000 Hz程度は超えたい
  • 精度要求が厳しければ,リニアリティとオフセットドラフト,温度特性は妥協する.一方でヒステリシス特性やリピータビリティは重視する

また,既存のシステムキッチンを改造して重量センサを埋め込むのはハードルが高いため,すでにあるキッチンワークトップの上に設置し,その上で調理することにしました. そうすると,次のような構造的な要求が発生します.

  • 既存のキッチンワークトップに設置し,その上で調理するため,可能な限り背の低い形状にする
  • IHコンロとまな板とその他をその上に置くことを想定するので,天板の大きさは1,000 x 600 mm程度にする

得られたデータはリアルタイムに解析し,調理支援という形で調理する人にフィードバックできる必要があります. 高精度・高頻度に計測されたデータを後から抽出でき,解析できる,といったシステムでは,想定している調理支援には使えません. そこで,さらに次のようなシステム要求が追加されました.

  • 取得データはリアルタイムで社内システムで利用できる(例えばAWS IoT Coreに送信できる,など)

これらの要求を満たす重量センサをフルスクラッチしていきます.

実装

最初に実装結果を記します.

下図のようなものが完成しました.

先述したとおり,システムキッチン自体を改造するのは難しいため,既存のキッチンのワークトップに乗せて使用します. IHコンロ,まな板,はかりが乗っているアルミ天板の上の重量が高精度かつ高頻度に測定できます.

f:id:meltingrabbit:20201209030436j:plain
開発した重量センサ

達成されたスペックは,次のとおりです.

  • 測定精度は,設計値は4 gで,実際に使ってみると2 g程度はありそう
  • 許容最大荷重の実効値が54 kgで,絶対最大定格の実効値が84 kg
  • 最高計測周波数が100 kHz(実質的に使えるのはせいぜい数kHz程度か?)

構造の概要は下図に示すとおりで,上から,超低頭ネジ,アルミ天板,カーボンロッド,アルミ治具,荷重センサ(ロードセル),ネジ,アルミ治具,ネジ,アルミフレーム,です. 天板の四隅を荷重センサで支持,計量する構造となっています. (したがって,今後のソフトウェアの改良で重量分布も取得可能になる予定)

f:id:meltingrabbit:20201209030534p:plain
構造図

この重量センサを開発するのには,いくつかのハードルがありました.

まずは,高精度な荷重センサの選定です.いろいろと検討した結果,工場などで用いられる産業用のロードセルを用いていますが,もともとのセンサの想定使用方法通りには使われていません.

また,ダイナミックレンジを大きくするためには,天板をできるだけ軽く作る必要がありました. なぜなら,ロードセルの許容最大荷重が例えば60 kgだとしても,天板の重さが20 kgもあると,実質的に計量可能な最大荷重は40 kgになってしまうからです. 一方で,軽く作ることに集中し,天板の剛性が十分でないと,荷重によって天板がたわみ,余計な力がロードセルに加わってしまい,正確な重量計測ができなくなります.

このサイズですと,板厚3 mm程度のアルミ板ですらおよそ5 kgの重量にもなりますが,両端単純支持では中央に20 kg程度の負荷をかけただけで50 mm程度はたわんでしまうのです. 軽い金属のアルミですら,こんなもんです. この軽量と高剛性という相反する要求を満たすために,最適な天板の厚みを計算しました. そして,アルミ板のみでは剛性が確保できないので,カーボンロッドで補強するなどしています.

カーボンロッドとアルミ天板との接着には,アクリル樹脂系の2液式接着剤を使いましたが,これも大変でした. 2液式接着剤とは,接着前に2種類の溶剤を自分で混ぜて,それが固化する前に接着面に塗りつけ接着するというものです. 今回用いた接着剤は,混合から固化までの時間が90秒だった(カーボンとアルミが接着可能で入手性の良いものがこれしかなかった)ので,小さなパーツの接着ならまだしも,1 m弱の大きなロッドに対して,

  • そこそこ大量の溶剤を均一に混ぜ合わせる
  • それをカーボンロッドとアルミ板に均一に塗る(均一でないとそこで応力集中してしまう)
  • 位置を合わせて仮固定する

を90秒以内に終わらせるというタイムアタックをしなくてはいけないのがきつかったです.

さらには,ロードセルの計測値をリアルタイムに収集し,処理するのためのソフトウェアも重要です.

今回用いたロードセルは,工場などに組み込むことを前提として,RS232Cで計測信号を出力するインターフェイスはメーカーから提供を受けることが可能でした. しかし,RS232Cなので高頻度計測にはボーレートの問題もあるし,4つのセルからの信号を時刻同期しつつ高速に収集するミドルウェアを書くのはめんどくさいなぁ,と思ってしまいました.

最終的には,ロードセルの実体はひずみゲージのブリッジ回路なので,それを駆動して出力をAD変換できるロガーを別途購入し,そこからリアルタイムにデータを取得し,適切な信号処理を施し, 社内の調理支援システムへリアルタイムへデータを送信する,というソフトウェアを自作しました.

いざ,計測

要求仕様の出どころであった小さじ1杯を計測してみました.ワークトップ上に小皿を置き,そこに小さじ1杯を投入していったときの計測結果が下の2図です.

信号処理のフィルタのチューニングを変えていて,1枚目は高頻度な成分を抽出できるような設定になっており, 2枚目はかなり強めのLPFを挟むことで質量の増分を分解しやすい設定になっています.

そのため,1枚目では,水を小皿に垂らしたときの振動がよく計測されている一方で, 2枚目ではおよそ1,5,15秒のところでおおよそ大さじ1杯の水が投入されたということが明瞭に分かる結果となっています.

同じ操作を異なる信号処理で観察すると,違った特徴が見えるのは面白いですね.

次の図は,唐揚げを揚げたときの結果です. 3つの唐揚げを鍋に投入し,それをおよそ2分半揚げ,ひとつずつ取り出した履歴です.

0〜0.5分では,ひとつひとつの唐揚げの重さが分解できています. また,0.4〜2.7分頃では,揚げている最中に唐揚げ内部の水分が蒸発して軽くなっている様が観察できます.

まとめと今後

このように,これからの研究開発のための1センシングデバイスとしての重量センサが,無事に目指していたスペックを満たして実装することができました. 実は,クックパッドではソフトウェア開発のみならず,こういったハードウェアの開発も行っているのです. 今後はこれらを使い,調理中の様々なイベントを収録し,そして社内の様々な知見や技術(ハードもソフトも)を総動員して,「毎日の料理が楽しくなる」ような調理支援の開発を目指していきます!

分析用ログデータに対する品質保証としての異常検知

$
0
0

クックパッドでデータにまつわるあれやこれやをずっとやってる佐藤です。分析・調査に仮説検証にデータパイプラインにと色々やってました。ちなみに先日はCyberpunk2077休暇をとるなどという呑気なことをしていたら、この記事でやりたかったことがほぼできそうなサービスがAWSから発表されて頭を抱えながら書いています。

そのログはどこまで信頼できるのか

クックパッドではサービス改善のためにWebサイトやアプリからログを収集して開発を行っています。これらのログは集計された後、ダッシュボードの形で可視化されてサービス開発者たちの意思決定を支えています。
クックパッドのログ基盤はログ送信側(クライアントサイド)もログ格納側(DWHサイド)も十分に整っており、いつでも必要であれば簡単にログを送信・集計するだけの仕組みができあがっています。

f:id:ragi256:20201216120246p:plain
アプリログにおける大雑把なログ収集の図
(注:例として上図を載せましたが当記事の内容はアプリに限りません)

しかし一方で、送り続けているログの管理・保守にはここ数年課題を感じています。例えば、iOSアプリのダッシュボードを見ていて、去年の6月に突然トップページのDAUだけが激増していることに気付いたとします。しかし、この原因を見付けることは非常に難しいのです。
クックパッドではWebもアプリも多くの人々が開発者として関わっています。このため、誰かがいつかどこかに加えた変化によってアプリのログへ気づかないうちに影響を及ぼしていたということが起こりえます。自分たちの担当する領域で普段は見ない数値を確認してみたら、実は半年前に大きく動いていた。だが特に何かをした記憶がない。こういったケースではどのように原因を特定すればよいでしょうか? KPIに直結しない数値・特定の条件に限定して算出した数値・実数ではなく比率に変換した数値などで、後になってから気づくことが多くありました。

より快適なサービス開発を行うためには、安心してサービスに関わる数値を確認できる状態でなければなりません。そのためにはこういったログに関する課題は解決する必要があります。そこでまず、ログの品質を保証するためにどんなことができるか考えた結果、ログデータの異常検知に取り組むこととなりました。

どうやって開発をすすめるのか

今回、異常検知をやるにあたっていくつか当初から決めていたことがありました。

  1. 作り込みすぎない、とりあえず使える状態を目指す
  2. 全体をパーツとして作ってできる限り交換可能にする
  3. 異常検知そのものだけではなく全体フローの最適化を重視する

これらは異常検知という仕組みが、あくまでもログの品質維持の取り組みのひとつに過ぎないことが理由となっています。もし試してみて全然だめそうだったり、より有望そうな他の手段が思いつけばいつでもピボットしたいと考えていました。
一方で既存の研究分野で培われた時系列モデルやアルゴリズムは、いつかどこかで試してみるタイミングがやってくるとも考えていました。そうなった時、いつでも任意のポイントに対する差し替えが可能となるよう、機能分割のタイミングを逃さないよう開発を進めることとしました。
こういった事情があり、最初から「scikit-leanで回帰モデルを試す」「Prophetを利用する」といった手法ありきの取り組みや「異常を検知したらそれで終わり」といった姿勢を取らないように注意していました。全体的な検知フローを重視していかにしてログの品質保証に繋がるかを考えての方針です。

この方針のもと、異常検知の仕組みは次の3つのステップの順で開発を行っています。

Step.1 MVPを作って自分で試す

まず本当に異常検知すると嬉しいのかどうかを半信半疑になって確認する必要があります。ログの異常検知をすると決まった時点で、DWHに蓄積された各種ログの集計内容を監視して上振れ・下振れなどの変化を監視することは決定していました。ただし、この時点では変化点検知(change point detect)か外れ値検知(outlier detect)かはまだ決まっていません。

最も手軽に異常検知をしようと思った時、DWHからデータを引っ張ってきて、既存の異常検知ライブラリを使って判定するのは時間がかかりすぎるように感じました。
そこでまずMVPとして、SQLのみで異常検知することにしました。最も基本的な時系列モデルはちょっとしたSQLで書くことができるため、ここをベースラインとしてまず仕組み全体を作り上げてしまうことを考えます。
ベースラインとして採用したのは過去n日間の平均・標準偏差を利用した予測です。

f:id:ragi256:20201216120545p:plain
仮に過去平均7日間、σ係数を3と置いたときの図

これは集計済みテーブルさえ用意されていればwindow関数で手軽に書くことができます。もし予測範囲に収まらなかった場合、(少々行儀が悪いですが)ゼロ割を使って無理やりSQLをエラーにします。

select
    data_date
    , uu
    -- uu range: μ ± 3 * σ
    , casewhen uu between week_avg - 3 * week_stdev and week_avg + 3 * week_stdev
      then1-- passelse uu/0-- assert(ゼロ割)end alert
  from (
      select
        data_date -- 対象テーブルにある日付カラム
        , uu      -- 異常検知を行いたい対象となる数値のカラム(ここでは仮にuu)-- ↓平均と標準偏差の計算に当日は含まないため微妙にずれる
        , avg(uu) over (partition by uu orderby data_date rowsbetween8 preceding and1 preceding) asavg
        , stddev_pop(uu) over (partition by uu orderby data_date rowsbetween8 preceding and1 preceding) asstddevfrom
        $alert_target_table -- 異常検知をしかける対象のテーブルwhere
        data_date >= current_date - interval '8 days'
  )
  where
    data_date = current_date

このSQLをバッチジョブとして毎朝実行させ1、ジョブがゼロ割エラーでコケたらSlackに通知を流すようにしました。

f:id:ragi256:20201216121116p:plain
バッチがコケるととりあえずこのエラー通知がSlackに流れる

MVPだけあって当初は大量に誤報が鳴り、ほぼ毎回アラートがあがるのでこのままでは使い物にならないことがわかりました。ですが、このときアラートの精度に関しては一切考えず一旦ワークフローを固めることを考えました。仮にこの誤報が減り、今鳴っているアラートが正しい異常検知の結果であったとした場合、自分は次に何をしたくなるだろうかと考えます。
実際、自分がアラートを見た時には「これは誤報か?確報か?」と毎回調べていましたので次に何をするかは「アラートが何故なったのかを調べる」ということがわかっていました。異常検知アラートの作成者以外も「なぜ異常検知のアラートが鳴ったのか?」を容易に知ることができる状態にしておく必要があります。そこで異常検知している様子がわかりやすくなるように下記のようなグラフを作成し、自動更新がされるように準備しました。

f:id:ragi256:20201216121245p:plain
異常検知の様子をわかりやすく可視化するためのグラフ、オレンジと青が上限・下限で緑がn日間平均

同時にこのグラフを作ったことで何故こんなにも誤報が大量発生したのかも発覚しました。過去に収集していたが今はもう使わなくなったログ・送信条件が厳しく流量の少ないログなどが多く含まれていたため、異常検知に用いるには欠損点が多く不安定な時系列データとなっていたためでした。そこで品質を保証する意義のあるログは「多くのユーザーに」「ある一定期間は使われている」ログと見なして流量と取得日数をフィルターすることにしました。
Step1の始めに「外れ値検知か変化点検値か決めていない」と書きましたが、この時点で外れ値検知ではなく変化点検知を行うことに決定しました。この異常検知システムによって検知したいのは後から対処しようのないサービスの瞬間的な異常ではなく、ログに関する実装の修正を必要とするような開発時点でのエラー・修正ミス・抜け漏れなどを捉えたかったためです。

\ グラフ
f:id:ragi256:20201216121500p:plain
f:id:ragi256:20201216121519p:plain

こういった工夫により、平均と標準偏差という最も素朴な基準でも誤報を減らすことができるようになりました。ハイパーパラメータはモデルではなくモデルに投入するデータのほうにもあったようです。ここでフィルターに用いる各種パラメータをSQLから分離させて対象とするテーブルごとに変更できるようにしておきます。

Step.2 他の人にも使ってもらえるように触りやすい仕組みを整える

Step1の状態では異常検知の仕組みを作った自分しか扱い方がわからず、アラートがきても何をどうしたら良いかわからない状態でした。この仕組みをサービスの開発にも活かすためには、多くの人に使ってもらえるようアラートが鳴ったらどうするかわかりやすいインターフェイスにする必要があります。
そこで、より異常検知した状況をつかみやすく、その後のリアクションをとりやすくするために通知内容を改善することにしました。

通知内容を改善するにあたって、これまでのただエラーを流していただけの状態を改修する必要がありました。そこでまず、バッチジョブの中身を修正し2つの処理に分割することにします。この分割で片方の処理の持つ責務を「異常を検知すること」、もう片方の処理の持つ責務を「検知した内容をどうにかして伝えること」にわけます。こうすることで仮にSlack以外のツールに通知を流す場合や、通知先はSlackのまま異常検知方法を切り替えるといった作業をしやすくなります。
そして、Slackへの通知を行う処理としてSlack WorkflowのWebhookを利用することにしました。このSlack WorkflowはSlack上でステップやタスクを実施してもらうことで定形的なプロセスを自動処理しやすくする仕組みです。また、外部アプリやサービスとの連携も豊富なため、Workflow内のステップで起こしたアクションを外部に渡すことができます。 通常のWebhookでは単純に情報をSlackへ流すだけとなってしまい、検知に対するアクションをとってもらいにくいと考えてWorkflowを採用しました。

f:id:ragi256:20201216121713p:plain
Workflow builderで作成、フォームを2つ加えて後述のGoogleSpreadsheetと連携させている

上図のようにフローを組むことでSlackへ情報を流すとともにリアクションをとってもらえるようになります。今回このWorkflowで設定した異常検知に対するリアクションとは「対応の方針を決める」「なぜその方針に決定したのか理由を書く」の2つです。異常検知が正しく働いたとして、それでも何も対応する必要のないケースもあります。なので「このアラートは無視する、古いバージョンからのログなので放って置いても困らないため」「このアラートはきちんと調査をする、重要指標が減少していてもしも本当に落ち込んでいたら緊急事態のため」と書き込んでもらうことにしました。

f:id:ragi256:20201216121946p:plain
アラートが鳴ったときに流れるメッセージ
f:id:ragi256:20201216122007p:plain
上記メッセージのボタンに反応した後に続くメッセージ

この通知内容によって開発者にログの異常に関するリアクションをとってもらって、「このログ異常は何故起きたのだろう?」「このアラートは無視していいものだろうか」と考えてもらおうというのが狙いです。
このSlack Workflowへの通知切り替えを再度自分でも使ってみて、アラート量的にも対応負荷的にも問題なさそうと感じたあたりでStep3に移りました。

Step.3 他の人にも使ってもらう

いよいよ自分だけではなく誰か別の人にも使ってもらう段階です。手始めに社内用ブログに上記取り組みを投稿して軽く共有し、クックパッドアプリのiOSやAndroidエンジニアが集まるチャンネルで使ってもらうこととしました。
その週にはアラートが鳴り、何度かアラートの対応をしてもらうことができました。しかしすべてがスムーズに進んだというわけもなく、いくつかの改善点がSlackでの会話から浮かび上がりました。

f:id:ragi256:20201216122135p:plain
早速フィードバックがもらえている様子

Slack Workflowという多くの人の目に見える形でアラートをセットしたことで、このようなやりとりをSlack上でこなすことができるようになりました。
また、リアクションをしてもらった結果は自動でGoogleSpreadsheetに溜め込まれていきます。こちらのシートに溜まった知見を元に今後のアラートの改善にもつなげていこうと考えています。  

f:id:ragi256:20201216122221p:plain
2つのフォームから書き込まれた内容が貯まるシート

これから

冒頭にも書きましたとおり今年の re:inventでAWSからAmazon Lookout for Metricsが発表されました。こちらはまだプレビュー版ですが、今回作った異常検知フローをそのまま置き換えることができるかもしれません。幸いにして今回のフローはアルゴリズムやチューニングに注力することなく、最小の労力をもって「ログの品質を保つためにはどんな仕組みが必要となるか?」の模索した解決案の一つに留まっていました。このため最終的な唯一の課題解決手法ではなく、むしろ課題を理解するためのプロトタイプに近く、実際に運用してみることで品質維持のために求められる多くの要素を知ることができました。

  1. 古いバージョンのログをどうするか
  2. 流量の多いログと少ないログの両方同時に監視すると発生する変化量の差をどうするか
  3. 既存の時系列解析や異常検知の研究手法で使われているアルゴリズムやモデルをいつ・どうやって・どう判断しながら組み込んでいくか
  4. そもそも「異常検知」では応急措置的な対応しかできないが、品質維持のために根本的対策や事前防止策をとることはできないか
  5. (他多数)

これらの要素を元に今回作成したシステムとAmazon Lookout for Metricsを比較することでより良い解決策と改善フローを実行できると考えています。

ログの異常というのは本来は起きてほしくない状況ではあります。知らず知らずの内にそのログ異常が起こっていて後から困るという自体を防ぐために、変化点検知作業を自動化する仕組みを整えることができました。まだまだ実用上では粗い点もありますが、漸進的開発をしやすい開発方針をとってきたのでこれからも徐々に改善していくことで「クックパッドではこうやってログの品質を保証しています」と言い切れるデータ基盤を目指していきます。


  1. SQLとバッチジョブの実行に関しては弊社OSSのKuroko2bricolageを利用しています。

モバイルアプリの開発上の違和感・痛みに向き合い、少しずつでも前進するための取り組み

$
0
0

こんにちは、モバイル基盤部の茂呂(@slightair)です。 いやー12月になって寒くなってきましたね。

この記事では最近部で始めた「アーキテクチャ課題共有会」という取り組みについて紹介したいと思います。

開発中に感じる痛み

いきなりですが、モバイルアプリの開発中に痛みを感じたり違和感を持ったことはありませんか? 痛みというと大げさかもしれませんが、例えば以下のような設計・実装上のつらみ、悩みのタネたちのことを指します。

  • 同じような記述を毎回書く必要がある
  • 採用している設計パターンにあてはめようとすると、実装しづらい場合がある
  • 必要となる場面が多い割にできないことがある
  • 適切に扱うのが難しく、使い方を間違えやすいものがある
  • 複雑で理解するのが難しい、手を入れられない

特に複数人で開発しているアプリプロジェクトだと、自分でそういう痛みを感じることもあれば、他の人からそういう声が聞こえてくるという経験があるんじゃないかと思います。

僕たちが開発しているクックパッドアプリはサービスがリリースされてからずっと開発を続けているので、その間に開発規模や環境が大きく変わっています。 例えば、以下のものが挙げられるでしょう。

  • 開発人数・規模の増加
  • 人の入れ替わり
  • サービスのリニューアル
  • アーキテクチャの変更
  • モジュール分割などによるアプリ構造の変化

また、社内だけでなく、OSの更新、プラットフォームの進化、開発ツールの進化など、外部の要因もありますね。

そのような環境の変化があるたびに、それに合わせたやり方や仕組みの見直しを行うわけですが、導入した方針や仕組みがいつも最適な選択であるとはかぎりません。 最初は良さそうに思えたものであっても、使っているうちに欠点に気がついたり、状況が変わって適さなくなったものが出てきます。そうしたものが違和感や痛みとして表面化してきます。

痛みに気づいたら整理して解消すればよい

開発環境には変化が起こり続けているので、このような痛みや違和感が出てくるのは当然のことです。気づいたら課題を整理して解消していけばよいでしょう。

このような痛みに気づいて解消していくためには、どんな些細なことでもよいので、なにか思うところがあったら声を上げやすい雰囲気づくりが大切だと思います。幸いにもクックパッドのアプリ開発の現場ではそのような空気ができているように感じています。

Slack など普段会話をしているところで「こういうところつらくない?」というコメントが流れると「それな」「わかる」「オアーッ!」というようなリアクション*1も一緒に集まってきます。 丁寧な人であれば具体的に困っている設計上の課題や仕組みについて issue に書いてくれる場合もあります。

f:id:Slightair:20201215142521p:plain
Slack での会話

気づいて声を上げるところまではよいのだが…

痛みに気づいた人の声をきっかけに議論がはじまり、具体的な解決案がすぐにまとまればよいですが、必ずしもそのようにスムーズに話が進むわけではありません。 ほとんどの場合、大筋の合意を取って修正の方針を決めるまでが難しく、時間がかかります。

そのため、新しい課題が出てきても普段の仕事をたくさん抱えている中では、致命的なものでない限りなかなか向き合えないことがあります。 今までのやり方でも進められる場合は、違和感を持ちながらもそのまま進めることができてしまうからです。 すると、良くない部分に気づいているのにその問題が埋もれていってしまい、改善が進まないという状況になってしまいます。

最近でいうと、モジュール分割*2に関連する実装上の悩みや、アーキテクチャ*3についての課題が多く出ていました。仕組みの導入や整理した後に、それを実際に使ってサービス開発を進めることではじめて見えてくる問題点はたくさんあります。

部内の乗り越えたい課題

僕の所属するモバイル基盤部は、クックパッドでのモバイルアプリの開発環境を整えるという責務を担う部です。 サービス開発が円滑に進むようにクックパッドアプリの中核部分をメンテナンスしたり、設計パターン、実装方針の決定を主導するということが業務に含まれます。

部には、古くからクックパッドアプリの開発に関わっていて現状の実装方針の経緯に詳しい人もいればそうでない人もいます。 また、画面構築やモバイルアプリの設計パターンの知識、それらに対する興味・関心、得意不得意も人それぞれです。

そのため、改善の業務を進める上で部にもいくつか乗り越えたい課題がありました。

  • 現状のアーキテクチャや実装がどういう状態になっているのか、全員が認識している状態になる
  • 実際に基盤のツールやアーキテクチャを利用した際のサービス開発時の問題点を把握し、理解できるようになる
  • 現状認識の統一を図り、改善の方向性の意思統一ができるようになる

これらの課題は、アサインされたモバイル基盤部の業務を単純に遂行するだけでは落としやすいものばかりです。 浮かび上がってくる課題の理解を深め、真摯に解決に取り組み改善を進めていけるようなフローが必要だと考え始めました。

課題をきちんと拾って向き合う時間を作ろう

せっかく上がってきた課題や意見が Slack コメントや issue のままそこで止まらないように、定期的にきちんと課題に向き合う時間を作って運用してみることにしました。 前述の通り、アプリのアーキテクチャに関する話題が多かったので「アーキテクチャ課題共有会」という名前で始めています。

とりあえず以下のようなルールで始めてみました。

  • アプリのリポジトリとは別に、設計上の課題のみが集まるリポジトリを作り管理する
  • 毎週30分、ひとつ課題をとりあげて背景の理解とその解決方法について議論する
  • 参加者持ち回りで、課題の整理と可能であればその解決案を考えて事前にまとめてきてもらい、課題共有会の最初に説明してもらう
  • 議論中別の話題に発展したら、次回以降につづけるようにする
  • 対応方針が決まったら、やるだけの状態になるのでアサインする

このやり方で 2,3ヶ月運用してみて、うまく回っているところと改善できそうなところの両方がありますが、すでにいくつかの課題の解決に役立てる事ができています。

実際に上がってきた課題の例と課題共有会での進行

この期間で上がった課題の例をひとつ挙げると、「ユーザ情報を表現するオブジェクトの取得方法とその扱いがイマイチ」というものがありました。 この課題をテーマに議論することになりました。

アプリの機能や画面表示において、ログインしているかどうか、有料会員かどうかなどのステータスに依存するものはとても多いです。 このようなユーザ情報を取得したいケース、その使い方を整理して、イマイチな部分を改善しようということになりました。

事前準備

その回の担当者は事前に課題を以下のように整理してきました。

  • ユーザ情報の変化に追従する必要のある画面とそうでない画面があり、ほとんど後者である
  • 現状の仕組みでは全ての場面でユーザ情報の変化を意識しないといけない作りであり、複雑になってしまっている
  • モジュール分割の初期の仕組みに乗っているが、今は別の適切な実装手段があるので、新しい方法に乗り換えるべき

このような、現状とその問題点、それに対する解決案が参加者に共有された状態で議論を行います。 もしキャッチアップの段階で疑問点があれば、議論に入る前に解消します。

議論

課題の整理が済んだら提案された解決方法を評価したり、具体的な実装方法について話し合います。 議論中に別の課題が浮かんできたら元の議論が発散しないように注意し、次以降の課題共有会で話せるように内容を残しておきます。

この回では、ユーザ情報の変化を意識せずに各画面を実装するための仕組みの導入と、新しい実装手段への置き換えを行うことに決まりました。

方針の決定と導入

議論で修正方針が固まったら、どのような話が行われ、どのような結論に至ったかをまとめ、決定事項として残すようにします。 その決定事項をもとに、具体的な作業に落とした issue をアプリのリポジトリに作成するようにします。 あとは通常の修正と同じようにPRを作成し、レビューのステップを踏んで導入完了となります。

f:id:Slightair:20201215142621p:plain
課題共有会後の記録の例

課題共有会の良いところ、これから改善できそうなところ

アーキテクチャ課題共有会を始めたことで良かった点と今後の改善点を紹介します。

定期的に課題に向き合い、前に進めるようになった

まず、定期的に時間を取って課題に向き合うことにより、確実に前に進むことができるようになりました。 また、議題に上がった内容やそれに対して決めた「現時点の方針」をきちんと整理して残せるようになりました。

前述したように、開発環境や状況に応じて最適な戦略や方針は常に変わり続けるので、現時点で何を考えどういう方針を取ったのか記録として残るのが大切なところです。

毎週課題共有会を開いているので、方針が決まってあとはやるだけという状態のものがいくつも生み出せていますが、今度は決まったものの実装待ちが溜まりがちという問題も起きています。 前には進んでいるので今は一旦よしとしています。

参加者の負担を減らしつつ、個人の関心を高められる

課題共有会のはじめのキャッチアップをすばやく行うために、事前準備を行うようにしていました。 この準備はある程度手間のかかるものですが、持ち回りで準備をすることで、負担をうまく分散することができています。 負担の分散だけでなく、各個人で課題の深い部分に気づいたり意識をするようになって、議論を重ねるたびに話が進めやすくなる利点もありました。 また、長年の開発によって積もってきた実装方法や課題、歴史的経緯についても理解を深める機会にもなっています。

議論しているとあっという間に時間が過ぎてしまうので、なるべく小さい課題に分割してから議題として選ぶようにしていますが、それぞれの根となる課題は繋がっていることも多いのです。

アプリの設計方針、戦略についての意識合わせができる

当初の狙い通り、目線や今後の戦略についての意識合わせにも役立ちそうでした。 また、アプリ開発の知識量やアプリの経験値の埋め合わせにも効いています。

僕たちは自分たちのアプリの設計の議論からはじめてしまいましたが、もしモバイルアプリの設計パターンの知識が少なかったりメンバー間で大きく差がありそうな状況であれば、一般的な設計パターンの学習からはじめるのがよさそうにも思いました。 僕たちもこの取り組みを続けるうちにあまり良い答えが見つからなくなってきたら、途中で学習に切り替えようと思っています。

部の課題に関係したこともあり、基本的に今はモバイル基盤部のメンバーのみで課題共有会をやっていますが、取り上げる課題によってゲストを呼び、詳しい状況を聞いたり意見をもらったりしています。 アプリで採用する方針の決定にサービス開発者の現場の視点も入れた方が、より実際の利用に合った方針の選択が期待できるので、他の部署からの参加も呼びかけていきたいと思っています。

より形式的な課題管理の方法を試したい

課題共有会の取り組みはお試しだったため、ゆるーい運用ではじめてみましたが、段々と形になり始めているのでもう少し議題の選び方や粒度をしっかりしていけるとより有益な会にできそうと感じています。 情報の整理方法についても、何を書いて残すべきかはっきりするようなテンプレートを整備するなど、もっと形式的にやっても良さそうに感じています。 Issueのラベルで状態を示すようにしたり、いろいろな工夫をちょうど試しているところです。

まとめ

最近モバイル基盤部で始めた、アーキテクチャや開発環境に対する課題を共有し議論して前に進める取り組みについて紹介しました。 こうして書いてみると「課題を整理して時間を取って議論して記録を残そうね」という当たり前の部分が多く、チームによっては自然に行っていたプロセスかもしれませんが、やり始めてみたらしっくりはまってきたプロセスだったため、紹介しました。

このように様々な形でモバイルアプリ開発の環境を改善していきたいモバイルアプリエンジニアの方、またはこのような開発環境でクックパッドのサービス開発にじっくり取り組んでみたいモバイルアプリエンジニアの方がいましたら、ぜひご連絡ください。

*1:カスタム絵文字を作っておくと便利です

*2:〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来 - https://techconf.cookpad.com/2019/kohki_miki.html

*3:2020年のクックパッドAndroidアプリのアーキテクチャ事情 - https://techlife.cookpad.com/entry/2020/11/17/110000

Viewing all 802 articles
Browse latest View live