Timee Product Team Blog

タイミー開発者ブログ

PlaywrightとMSWを組み合わせる

この記事はTimee Product Advent Calendar 2024 シリーズ2の21日目の記事です。

はじめに

タイミーでフロントエンドエンジニアをしている大川です。
PlaywrightでのUI自動テストを運用してきた中で学んだ、PlaywrightとMock Service Worker(以降、MSW)を組み合わせたAPIのモック方法を紹介します。

Playwrightのテスト中にモックレスポンスを定義する

バックエンドと連携しているシステム全体をテストすることで正常に動作しているか確認していくことは大切ですが、普段のUI開発やUIで利用しているライブラリアップデート後の動作確認時などはAPIリクエストをモックしてPlaywrightのテストを実行するのも有効な場合があると思います。

APIリクエストをモックするためのライブラリにMSWを利用している場合は、開発中に定義したモックハンドラーを再利用してテスト実装を効率化できます。

PlaywrightとMSWを組み合わせる場合には、テストシナリオ内でレスポンスを定義するための工夫が必要です。以下にいくつかの方法をまとめました。

テスト対象のUIでMSWが動作している場合
テスト対象のUIがバックエンドと結合している場合

自前のフィクスチャを定義する

playwright-mswでは、最後に追加されたハンドラーが最初に実行されるよう、すべてのハンドラー定義順序をリバースしている箇所があります。
参考: ハンドラーをリバースしているコード

MSWにも実行順序のルールがあり、それを考慮してハンドラーを用意していると意図しないレスポンスになる場合がありました。
参考: Request handler | Execution order

playwright-mswを利用せず、MSWのハンドラーをベースにモック定義するためにgetRespose 関数が利用できます。
参考: getResponse

以下のコードは getResponse を利用してAPIリクエストをモックしているフィクスチャと、フィクスチャの利用例としてのテストになります。

(フィクスチャの実装例)

import { test as base, expect } from '@playwright/test';
import { http, HttpResponse, RequestHandler, getResponse } from 'msw';

const apiEndpoint = process.env.API_ENDPOINT;

// NOTE: MSWのモックハンドラー定義の例(実際は別ファイルで管理されている想定)
const initialHandlers = [
  http.get(`${apiEndpoint}/user/:userId`, () => {
    return HttpResponse.json({ name: 'John' });
  }),
];

// NOTE: テストコード中でもモック定義するためのビルダーを定義
class MockBuilder {
  private handlers: RequestHandler[] = initialHandlers;
  constructor(...overrides: RequestHandler[]) {
    this.handlers = [...overrides, ...initialHandlers];
  }

  use(...additionalHandlers: RequestHandler[]) {
    this.handlers = [...additionalHandlers, ...this.handlers];
  }

  reset() {
    this.handlers = initialHandlers;
  }

  getHandlers() {
    return this.handlers;
  }
}

const test = base.extend<{ mockBuilder: MockBuilder }>({
  mockBuilder: [
    async ({ page }, use) => {
      const builder = new MockBuilder();
      await page.route(`${apiEndpoint}/**`, async (route) => {
        const actualRequest = route.request();

        // NOTE: MSWとPlaywrightでRequest型が違うので再定義している
        const request = new Request(actualRequest.url(), {
          method: actualRequest.method(),
          headers: actualRequest.headers(),
          body: actualRequest.postData(),
        });

        const response = await getResponse(builder.getHandlers(), request);
        if (!response) {
          await route.fulfill({
            status: 404,
          });
          return;
        }

        const json = await response.json();
        await route.fulfill({ json, status: response.status });
      });

      await use(builder);
      // NOTE: テストコード中に上書きしたハンドラーをリセットする
      builder.reset();
    },
    // NOTE: auto: trueでmockBuilderをテストコード中に呼び出さない場合もセットアップしておく
    { auto: true },
  ]
});

export { expect, test };

(テストの実装例)

test('ユーザーIDを編集できる', async ({ page, mockBuilder }) => {
  // NOTE: 定義済みのユーザー情報取得に加えてユーザーID編集APIをモックする
  mockBuilder.use(
    http.patch(`${apiEndpoint}/user/:userId`, () => {
      return HttpResponse.json({ result: 'OK' });
    }),
  );

  // NOTE: テスト開始
  const newUserId = 'new_user_id';
  await expect(page).toHaveURL('/user/123/edit');
  const input = page.getByRole('textbox', { name: 'ユーザーID' });
  await input.fill(newUserId);
  const button = page.getByRole('button', { name: '保存' });
  await button.click();
  await expect(page).toHaveURL('/user/123');
  const userId = page.getByText(newUserId);
  await expect(userId).toBeVisible();
});

さいごに

今回はPlaywrightとMSWを組み合わせてAPIレスポンスをモックする方法を紹介しました。
PlaywrightもMSWもAPIが充実しており、いろんなテストシナリオに対応できて便利です。

弊社ではPlaywrightでのテスト以外にも静的解析、コンポーネントやフックの単体テスト、ビジュアルリグレッションテストなどを取り入れています。
自動テストを整備することで機能開発とその動作確認、ライブラリアップデートなどの運用をバランスよく実施していける仕組みをつくっていき、お客さまによりよいUI・UXをより早く提供していくことにつなげていければと考えています。

MySQL実行計画によるパフォーマンスチューニングの実践

こんにちは。エンジニアリング本部 プラットフォームエンジニアリングチームの徳富です。 この記事は、 Timee Product Advent Calendar 2024 の 20 日目として、EXPLAINを使用した実行計画の見方についてご紹介します。

背景

タイミーでは、会社の成長に伴い、パフォーマンスチューニングが喫緊に求められています。このような課題に対処するため、クエリのパフォーマンスチューニングにはEXPLAINを使用した実行計画の確認が非常に重要です。しかし、実行計画の解釈には社内でばらつきがありました。この問題を解消するために、実行計画の見方を社内でまとめ、共有することにしました。ただし、この情報を社内だけに留めておくのはもったいないと考え、テックブログを通じて広く公開することに決めました。

実行計画の基本的な見方

MySQLのEXPLAIN文は、SQLクエリがどのように実行されるかの詳細を提供します。実行計画には、以下のような重要な情報が含まれています:

項目 説明
id SELECT識別子で、クエリが複数の部分から構成されている場合(例えば、サブクエリやUNIONが使われている場合)に重要です。同一のidを持つ行は同じSELECT文に属しています。
select_type クエリのタイプを示します。例えば、SIMPLE(単一のSELECT)、SUBQUERY(サブクエリ内のSELECT)、UNION(UNIONの一部)などがあります。
table クエリが参照するテーブル名。複数のテーブルが結合されるクエリでは、どのテーブルがどの順番で処理されるかを示します。
partitions クエリが参照するパーティション。指定されたパーティションを明確にすることで、クエリの実行効率が向上します。
type データへのアクセス方法のタイプ。ALL(フルスキャン)、index(インデックススキャン)、range(範囲スキャン)などがあります。
possible_keys このクエリで使用可能なインデックスのリスト。適切なインデックスが選択されるかどうかの手がかりになります。
key 実際に使用されるインデックス。このインデックスがクエリのパフォーマンスに大きく影響します。
key_len 使用されるキーの長さをバイト数で示します。キーの長さは、インデックスを使用する効率に影響します。
ref インデックスに使用される列や定数。外部キーの結合や、定数との比較で使用されます。
rows 読み込まれる行数の推定値。クエリのコスト評価やパフォーマンスチューニングの際に重要です。
filtered フィルタ条件によって行がどれだけ絞り込まれるかのパーセンテージ。100%に近いほど、フィルタ条件によるデータの絞り込みが効果的です。
Extra クエリの実行に関する追加情報。例えば「Using index」はインデックスのみでデータが解決されていることを、また「Using temporary」は一時テーブルを使用していることを示します。

これらの情報を基に、実行計画を読み解き、クエリのパフォーマンスを最適化する方法を理解します。

各項目の重要な部分の説明(type, filtered, Extra)

type

typeはクエリがどのように実行されるかを示すもので、パフォーマンスの観点から重要です。以下は、最も効率的な順に並べたtypeの種類とその説明です。

項目 内容
const 単一の比較によるレコードの検索で、結果が1行だけに限定される場合に使用されます。通常、主キーやユニークキーの等価比較で見られ、非常に高速です。
eq_ref 主キーまたはユニークキーに基づくジョインで一つのレコードだけを指し示す場合に使用されます。各ジョイン段階で1行のみが処理されるため、効率的です。
ref インデックスを使用して複数の行がマッチする可能性がある検索です。非ユニークインデックスが使われることが多く、キーに基づく絞り込みが行われますが、constやeq_refほどには効率的ではありません。
range インデックスを利用した範囲検索です。特定の範囲内の値を持つ行を効率的に検索しますが、スキャンする範囲によっては処理が重くなる可能性があります。
index インデックス全体をスキャンしますが、テーブル自体は読み込まれません。これは特定のケースでは効率的ですが、全エントリの検査が必要な場合はコストが高くなります。
ALL すべての行をスキャンする必要があるテーブルスキャンです。インデックスがない場合や適切なインデックスが利用されない場合に使用され、パフォーマンスが最も悪いタイプです。避けるべきです。

filtered

filteredの値が100に近いほど、行をフェッチした後に絞り込んだ量が少なかったことを意味します。一方で、この値が 100 ではない場合、インデックスが適切に設定されていない可能性があり、不要な行が多くフェッチされている可能性があります。この場合、クエリやインデックス設計の見直しが必要です。

補足情報:

  • インデックスが存在しないカラムの場合、MySQLのオプティマイザは統計情報を持たないため、filteredの値は以下のように固定されることが一般的です:
    • 等価検索(= value)の場合、filtered10%と固定されます。
      • もしカラムがenum型の場合、filteredの値は取りうる値の逆数(1/enumの値の総数)に基づいて計算され、それを100倍してパーセンテージで表示します。
    • より大きい(> value)の場合、filtered33.33%と固定されます。
    • 範囲検索(BETWEEN start AND end)の場合、filtered11.11%と固定されます。

これらの固定値は、統計情報がない場合のオプティマイザの仮定に基づいています。したがって、適切なインデックス設計を行い、オプティマイザが正確な統計情報を基に、インデックススキャンなど適切なクエリの実行方法を選択できるようにすることが、パフォーマンスの改善につながります。

Extra

  • 説明: クエリの実行に関する追加情報を提供します。ここに表示される内容は、パフォーマンスのボトルネックを特定するのに役立つことがあります。
  • パフォーマンスが良い順に並べた内容(若干条件により前後する):
    • Using index: インデックスだけを使用してデータを取得し、テーブルへのアクセスを避けます。(カバリングインデックス)これはクエリが効率的であることを示す良い兆候です。
    • Using index condition: インデックスコンディションプッシュダウン(ICP)が使用されていることを示します。これはインデックス内でWHERE条件の一部を評価することにより、不要な行の読み込みを減少させ、クエリの全体的な実行時間を短縮します。
    • Using where: データを取得した後に追加の絞り込みを行っている状態を示します。この状況は、インデックスがWHERE条件をすべてカバーしていない場合や、適切なインデックス自体が存在しないに発生します。
    • Using filesort: MySQLが結果をソートするために一時ファイルを使用します。これはクエリのパフォーマンスに影響を及ぼす可能性がありますが、正しい結果を得るために必要な場合があります。
    • Using temporary: クエリ処理のために一時テーブルが使用されます。これはGROUP BYやORDER BYの処理で見られ、大量のデータを扱う際にパフォーマンスに影響を与える可能性があります。
    • Full scan on NULL key: ジョインやサブクエリでNULL値を持つキーをフルスキャンしています。このプロセスは非常にコストが高く、パフォーマンスに大きく影響を及ぼします。

チューニングの具体的な例

具体的なSQLのチューニング方法をご紹介します。使用する環境はこちらのGitHubリポジトリで公開されています:

https://github.com/hirosi1900day/tech-blog-for-mysql

レベル1: whereとgroup byを使ったクエリのチューニング

次のSQLクエリは、特定の日付における商品名ごとの総数量を求めるものです。

SELECT product_name, SUM(quantity) as total_quantity
FROM orders
WHERE order_date = '2021-01-02'
GROUP BY product_name

まず、このクエリの実行計画を確認してみましょう。

EXPLAIN SELECT product_name, SUM(quantity) as total_quantity
FROM orders
WHERE order_date = '2021-01-02'
GROUP BY product_name;

実行計画の結果は以下の通りです:

+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+------------------------------+
| id | select_type | table  | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                        |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+------------------------------+
|  1 | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL |   12 |    10.00 | Using where; Using temporary |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+------------------------------+

実行計画からわかるように、ALL(全スキャン)が使用されており、インデックスが利用されていないことが明らかです。これはデータベースがテーブルの全行をスキャンしているため、データ量が多くなるとパフォーマンスが大きく低下する可能性があります。さらに、Extra列にUsing where; Using temporaryと表示されています。これは、WHERE句での絞り込み後に一時テーブルを使用してGROUP BY処理が行われていることを示しており、効率的ではありません。

チューニングの実施

このクエリのパフォーマンスを向上させるためには、order_dateproduct_nameに複合インデックスを作成することが効果的です。複合インデックスでは、インデックスの列の順序が重要です。このケースでは、WHERE句でorder_dateを使用してデータを絞り込んでから、GROUP BYproduct_nameを使うため、order_dateを第一引数に設定するのが適切です。

インデックスを追加します:

CREATE INDEX idx_date_product ON orders(order_date, product_name);

インデックスを追加した後の実行計画は以下のように改善されるはずです:

EXPLAIN SELECT product_name, SUM(quantity) as total_quantity FROM orders WHERE order_date = '2021-01-02' GROUP BY product_name;

結果:

+----+-------------+--------+------------+------+------------------+------------------+---------+-------+------+----------+-------+
| id | select_type | table  | partitions | type | possible_keys    | key              | key_len | ref   | rows | filtered | Extra |
+----+-------------+--------+------------+------+------------------+------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | orders | NULL       | ref  | idx_date_product | idx_date_product | 6       | const |    2 |   100.00 | NULL  |
+----+-------------+--------+------------+------+------------------+------------------+---------+-------+------+----------+-------+

この改善により、typerefに変わり、クエリはインデックスを使用してより効率的に実行されます。また、Extra情報もクリアされ、一時テーブルを使用することなく処理が行われていることが確認できます。

インデックスの順序の影響

SQLクエリのパフォーマンスを向上させるためには、インデックスの構成とその順序が重要な役割を果たします。ここでは、product_name を第一引数とし、order_date を第二引数とする複合インデックスの影響を考察します。

CREATE INDEX idx_product_date ON orders(product_name, order_date);

この複合インデックスを用いたクエリの実行計画を見てみましょう:

EXPLAIN SELECT product_name, SUM(quantity) as total_quantity
FROM orders
WHERE order_date = '2021-01-02'
GROUP BY product_name;

実行計画は以下のようになります:

+----+-------------+--------+------------+-------+------------------+------------------+---------+------+------+----------+-------------+
| id | select_type | table  | partitions | type  | possible_keys    | key              | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------+------------+-------+------------------+------------------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | orders | NULL       | index | idx_product_date | idx_product_date | 1028    | NULL |    1 |   100.00 | Using where |
+----+-------------+--------+------------+-------+------------------+------------------+---------+------+------+----------+-------------+

ここで、type indexとなっているためidx_product_date インデックスがフルスキャンされ、Extra 列に Using where が表示されている点に注目します。これは、order_date での絞り込みがインデックスを最適に活用していないことを示しています。また、Using temporary が表示されないことから、GROUP BY product_name 処理で複合インデックスが効果的に機能し、一時テーブルが不要になっていることがわかります。このことから、複合インデックスの第一引数の product_name だけが利用され、第二引数以降の order_date は単独でインデックスとしての機能を果たしていません。

さらに、同じ条件で product_name のみにインデックスを設定した場合の実行計画を見てみましょう:

CREATE INDEX idx_product_name ON orders(product_name);

EXPLAIN SELECT product_name, SUM(quantity) as total_quantity
FROM orders
WHERE order_date = '2021-01-02'
GROUP BY product_name;

実行計画は以下の通りです:

+----+-------------+--------+------------+-------+------------------+------------------+---------+------+------+----------+-------------+
| id | select_type | table  | partitions | type  | possible_keys    | key              | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------+------------+-------+------------------+------------------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | orders | NULL       | index | idx_product_name | idx_product_name | 1022    | NULL |    1 |   100.00 | Using where |
+----+-------------+--------+------------+-------+------------------+------------------+---------+------+------+----------+-------------+

この結果から、product_name のみのインデックスでも同じ実行計画になることがわかります。

つまり、複合インデックスを使用している場合でも、第一引数(product_name)が単独で使用されることがある点に注意が必要です。

レベル2: GROUP BYとHAVING、ORDER BY を使ったクエリ

次のSQLクエリは、商品がどれだけ頻繁に複数購入されるかを調べるためのものです。具体的には、商品名ごとに注文総数と平均購入数量を集計します。

SELECT
    product_name,
    COUNT(*) AS total_orders,
    AVG(quantity) AS average_quantity
FROM
    orders
GROUP BY
    product_name
HAVING
    AVG(quantity) > 1
ORDER BY
    average_quantity DESC;

このクエリの実行計画をEXPLAINを使って確認すると、以下のような結果が得られました。

+----+-------------+--------+------------+------+------------------+------+---------+------+------+----------+---------------------------------+
| id | select_type | table  | partitions | type | possible_keys    | key  | key_len | ref  | rows | filtered | Extra                           |
+----+-------------+--------+------------+------+------------------+------+---------+------+------+----------+---------------------------------+
|  1 | SIMPLE      | orders | NULL       | ALL  | idx_date_product | NULL | NULL    | NULL |    1 |   100.00 | Using temporary; Using filesort |
+----+-------------+--------+------------+------+------------------+------+---------+------+------+----------+---------------------------------+

全スキャンが行われており、インデックスが利用されていません。Using temporary; Using filesortは、GROUP BYORDER BY によって一時テーブルが使用され、結果がファイルソートされていることを示しています。これによりデータが多い場合、クエリのパフォーマンスが低下する可能性があります。

チューニングの実施

このクエリはproduct_nameでグルーピングした後に quantity のカラムを使ってHAVINGORDER BYを行うため、product_namequantity に複合インデックスを追加することでデータそのものにアクセスすることなく、インデックスだけでデータの取得が行えそうです(カバリングインデックス)。これにより、データベースが効率的にデータにアクセスできるようになります。

インデックスを追加するSQLは次の通りです:

CREATE INDEX idx_product_name_quantity ON orders(product_name, quantity);

インデックス追加後、実行計画を再び確認すると、次のように改善されるはずです。

+----+-------------+--------+------------+-------+--------------------------------------------+---------------------------+---------+------+------+----------+----------------------------------------------+
| id | select_type | table  | partitions | type  | possible_keys                              | key                       | key_len | ref  | rows | filtered | Extra                                        |
+----+-------------+--------+------------+-------+--------------------------------------------+---------------------------+---------+------+------+----------+----------------------------------------------+
|  1 | SIMPLE      | orders | NULL       | index | idx_date_product,idx_product_name_quantity | idx_product_name_quantity | 1027    | NULL |    1 |   100.00 | Using index; Using temporary; Using filesort |
+----+-------------+--------+------------+-------+--------------------------------------------+---------------------------+---------+------+------+----------+----------------------------------------------+

クエリの最適化は行ったものの、Using temporary; Using filesortがExtra情報から消えない理由は、GROUP BYによるグループ化とORDER BYによるソート処理が原因です。カバリングインデックスが追加されたことによりUsing indexが表示され、インデックスから直接必要なデータを取得しているため、全行スキャンのコストは削減されましたが、集計とソートには依然として追加のリソースが必要です。

SQLクエリでは、GROUP BYでグルーピングした結果をORDER BYでソートする際には、MySQLが内部的にテンポラリーテーブルを作成し、そのテーブルにデータを格納後、ソート処理を行います。このプロセスはメモリやディスクスペースを消費するため、扱うデータ量が増えるとパフォーマンスの低下が発生します。

そのため、データベースでの処理負荷を軽減するために、クエリをさらに絞り込むか、あるいはアプリケーションレベルでデータを取得後に集計やソートを行うアプローチが考慮されるべきです。(その場合アプリケーションサーバーに負荷が発生することになるのでバランスが重要)

レベル3: Joinとサブクエリを使ったクエリ

次のSQLクエリは、各ユーザーが注文した商品の中で、そのユーザーが注文した商品の平均数量を超える数量を持つ商品名を取得します。

SELECT
    users.username,
    orders.product_name,
    orders.quantity
FROM
    users
JOIN
    orders ON users.id = orders.user_id
WHERE
    orders.quantity > (
        SELECT
            AVG(orders.quantity)
        FROM
            orders
        WHERE
            orders.user_id = users.id
    );

このクエリをEXPLAINで実行計画を確認した結果、以下のような内容が得られました。

+----+--------------------+--------+------------+--------+---------------+---------+---------+-----------------------+------+----------+-------------+
| id | select_type        | table  | partitions | type   | possible_keys | key     | key_len | ref                   | rows | filtered | Extra       |
+----+--------------------+--------+------------+--------+---------------+---------+---------+-----------------------+------+----------+-------------+
|  1 | PRIMARY            | orders | NULL       | ALL    | user_id       | NULL    | NULL    | NULL                  |    1 |   100.00 | NULL        |
|  1 | PRIMARY            | users  | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | testdb.orders.user_id |    1 |   100.00 | Using where |
|  2 | DEPENDENT SUBQUERY | orders | NULL       | ref    | user_id       | user_id | 4       | testdb.users.id       |    1 |   100.00 | NULL        |
+----+--------------------+--------+------------+--------+---------------+---------+---------+-----------------------+------+----------+-------------+

ordersテーブルに対してフルスキャンが発生していますが、これはデータ量が少ない場合にインデックスを利用するよりも効率的だと判断されているケースです。特に注目すべきはDEPENDENT SUBQUERYというselect_typeで、これは外部クエリの結果に基づいて行ごとにサブクエリを繰り返し実行することを意味します。

例えば、100人のユーザーがそれぞれ5件の注文を持つ場合、サブクエリは500回実行されるため、効率が低下します。この問題を改善するためには、共通テーブル式(CTE)を使用し、ユーザーごとの平均数量を事前に計算してからメインクエリで利用する方法が有効です。

以下は、CTEを使用したクエリ例です。

WITH UserAverages AS (
    SELECT
        user_id,
        AVG(quantity) AS avg_quantity
    FROM
        orders
    GROUP BY
        user_id
)

SELECT
    u.username,
    o.product_name,
    o.quantity
FROM
    users u
JOIN
    orders o ON u.id = o.user_id
JOIN
    UserAverages ua ON u.id = ua.user_id
WHERE
    o.quantity > ua.avg_quantity;

このクエリの実行計画は以下のようになります。

+----+-------------+------------+------------+--------+---------------+-------------+---------+-----------------------+------+----------+--------------------------+
| id | select_type | table      | partitions | type   | possible_keys | key         | key_len | ref                   | rows | filtered | Extra                    |
+----+-------------+------------+------------+--------+---------------+-------------+---------+-----------------------+------+----------+--------------------------+
|  1 | PRIMARY     | orders     | NULL       | ALL    | user_id       | NULL        | NULL    | NULL                  |    1 |   100.00 | NULL                     |
|  1 | PRIMARY     | <derived2> | NULL       | ref    | <auto_key0>   | <auto_key0> | 4       | testdb.orders.user_id |    2 |    50.00 | Using where; Using index |
|  1 | PRIMARY     | users      | NULL       | eq_ref | PRIMARY       | PRIMARY     | 4       | testdb.orders.user_id |    1 |   100.00 | NULL                     |
|  2 | DERIVED     | orders     | NULL       | index  | user_id       | user_id     | 4       | NULL                  |    1 |   100.00 | NULL                     |
+----+-------------+------------+------------+--------+---------------+-------------+---------+-----------------------+------+----------+--------------------------+

CTEを使用することで、サブクエリの繰り返し実行を防ぎ、一度の計算で済ませるようになりました。ただし、CTEは派生テーブルとして扱われ、実行計画ではDERIVEDと表示されます。これにより、一部のデータがテンポラリーテーブルとして実体化されるため、必ずしも常にサブクエリより高速になるわけではないので注意してください。

DEPENDENT SUBQUERYが実行計画に現れた場合には、データ量が増えるとパフォーマンスが大幅に劣化する可能性があるため、注意が必要です。このような場合には、クエリの設計を見直すことを検討してください。

まとめ

MySQLの実行計画を利用することで、クエリのパフォーマンスを把握し、最適化の方向性を明確にできます。今回は、具体的な例を通じて、インデックスを追加する方法とクエリを変更することでの改善方法を紹介しました。しかし、クエリのチューニングは状況に応じて最適な手法が異なるため、これらの方法以外にも様々なアプローチが考えられます。これからも実際の問題に遭遇した際の解決策をブログで積極的に共有していくことで、より多くの技術者がデータベースのパフォーマンス問題に対処できるよう支援していきたいと思います。

参考

この記事の内容に関連するさらなる情報は、以下の書籍で詳しく学べます。

  • 書籍名: MySQL運用・管理[実践]入門 〜安全かつ高速にデータを扱う内部構造・動作原理を学ぶ
  • 出版社: 技術評論社
  • 出版日: 2024年5月22日

この書籍は、MySQLの内部構造や動作原理について深く掘り下げており、実際の運用や管理におけるパフォーマンスチューニングの実践的なアプローチが学べます。

Slack の Huddles を使ったプラクティスとその背後にある考え

はい、亀井です。 yykamei という名前でインターネット上では活動しています。所属はタイミーです。

今回は Timee Advent Calendar 2024 の 19 日目の記事として、Slack の Huddles を使ったプラクティスとその背後にある考え、というタイトルで、筆を取らせていただきました。

仕事におけるコミュニケーションツールはいろいろありますが、その中でも Slack を使っている組織は多いのかなと思います。その Slack の機能の一つとして Huddles が 2021 年にリリースされました。当時はコロナ禍ということもあり、リモートワークが浸透してきた中でどのようにしてオンラインでのコミュニケーションを充実させるか?をまだまだ模索している中だったと思います。そんな中、 Discord のようにクイックに会話を開始できて、かつ、他のメンバーもリアルタイムで会話に参加できる機能として Huddles がリリースされ、当時の私は少し驚きました。

これまでも Zoom だったり、 Google Meet だったりでビデオチャットは可能でしたし、それこそ Slack Apps などを使えば、簡単にそれらのビデオチャットを呼び出してインスタントに会話が可能でした。しかし、 Slack の Huddles はそれが普段使っているチャットツールに組み込まれていると言うところに大きなアドバンテージがあると思います。そして、誰がどこのチャンネルで会話しているのか?が見える、というのも魅力的なポイントかと思います。

組織のカルチャーにもよりますが、 Slack を使っていてよく Huddles を利用している環境だと、自分が join しているチャンネルのいずれかで Huddles による会話が複数並行して行われることになります。実際のオフィスにおいても会議室は複数あって同じ時間帯に複数の会議室でミーティングが行われることは普通のことですが、それが可視化され、それぞれのミーティングに誰がいるのか?を知ることができるのは意外とメリットが大きいと感じます。どういうメリットかというと「賑やかでいいなー」というメリットです。はい、実利的なメリットというよりも感情面でのメリットです。 Slack を起動すると一目で賑やかさが可視化されるわけですよね。実際に賑やかなのかどうかはわかりませんが、人と人とが会話しているのだからおそらく賑やかなはずです。

Slack Huddles を使ったプラクティスは世の中にすでにたくさんあると思いますが、ここでは私が考えるもの、またなぜそれらを使うのか?を列挙してみようと思います。

Huddles を開いて作業する

Communication は大事ですよね。その Communication を extreme にやるためには会話をする機会が必要です。では会話をする機会を増やすにはどうすればよいのか?という問いに対する答えがこのプラクティスです。リモートワークが主流ではなくメンバーの多くがオフィスで働いている場合は「ちょっといいですか?」という会話ができました。とはいえ「ちょっといいですか?」という声がけ自体、ハードルが高いと感じる場合もありますよね。私はありました。そして、リモートワークが中心の働き方になるとそのハードルの高さ自体感じることなく会話する機会は失われました。特にコロナ禍でこの問題が顕著になりましたね。この問題に対して、多くの組織があの手この手でバーチャルオフィスのツールを導入したり「雑談をする」という時間をカレンダー上でセットしたりして工夫をしてきました。そうした工夫のうちの一つが Huddles を開いて作業する、です。

誰かが Huddles を開いて何かをしていると「なんだなんだ?」という感じで興味を持ってくれた人が Huddles に参加してくれます。そうなったら雑談をしてもよいですし、軽く挨拶をかわして作業を続けてもいいでしょう。

このプラクティスは、チームが継続的に会話をすることに慣れていない、あるいは、会話をすることに対して価値を感じていない場合に有効です。 Communication が大事、という価値観をチームに押し付けてもよいのですが、そうするとチームからは信頼されなくなるでしょう。 Communication も大事ですがチームからの信頼も大事です。もしかしたら、誰もその Huddles に来ないこともあるかもしれません。それでもいいのです。ただそこにいるという状態をまず作り出すこと、それ自体に価値があります。そして、何かのきっかけでチームの誰かが話しかけてくれるかもしれません。そうしたときにその会話を楽しみましょう。そのうち「ここに来れば仕事上の相談ができるかもしれない」という雰囲気を作り出せるかもしれません。そこまでいけばチームが Communication の価値に気づいている可能性があります。

注意点としては、このプラクティスを行うと、ずっと Huddles にいる、という状態になってしまいがちということです。 Communication は大事ですが、人間性も大事です。休みましょう。8時間ずっとオンラインというのはどう考えても非人間的です。無理のない範囲で実施するようにしましょう。

また、もう一つ注意点があります。 Huddles から抜けることを促す、あるいはいつでも Huddles から抜けることを奨励しましょう。というのも、一度会話に入ったら途中で抜けることに躊躇する人もいるからです。そうなると、「抜けたいのに抜けられない」というふうになってしまってせっかくの Huddles の場がその人にとっては好ましくないものに変わる可能性があります。 Huddles というのは出たり入ったりが自由なものなのだ、という雰囲気を普段から醸成しておくとよいでしょう。

他のチームの Huddles に飛び込んでみる

他のチームが複数人で楽しそうに会話しているのをみると思わず飛び込んでみたくなりますよね。飛び込みましょう。わざわざ会議用のビデオチャットではなくあえて Slack で Huddles をしているのですから、これは誘われていると言っても過言ではありません。

なぜこれをするのでしょうか?自分のチーム外のことは普段の業務に関係することは少ないでしょう。その時間を使うぐらいなら普段の業務に時間を割り当てて締め切りが迫っている自分の作業に集中したくなります。もちろん、自分のタスクが逼迫しているなら無理をしてはいけません。ただ、もし自分の作業に余裕があるのであれば、他のチームの働き方を覗いてみると意外と発見があるものです。私が最近発見したことは次のようなものです。

  • あるチームのリファインメントのやり方が勉強になった
  • あるチームのタスクの切り方が勉強になった
  • 「モブプロをやる」と言っても「モブプロ」の定義はチームによって異なるようだ
  • スプリントレビューに対する態度が勉強になった
  • あるチームが適用したコードが自分のチームに開発に影響することがわかった

同じ組織とはいえ隣のチームは仕事の仕方が異なります。もしかしたら文化も異なるかもしれません。同じ言葉なのに異なる意味で会話していることもあります。そうした「異なるもの」に触れることは新たな洞察を得ることにもつながり、学びになります。自分のチームをもっと効果的にアウトカムを生み出すチームにするにあたって「これなら自分のチームに取り入れるとうまくいくのではないか?」という発見があるかもしれません。もちろん、こうした学びは書籍やインターネット上の情報を活用しても得られなくはないでしょう。ただ、そうした学びの実践を目の当たりにできる機会は少ないです。 Huddles でのリアルな会話を目撃することは、そうした体系立った知識がどのように活用されているかを発見する場として活用できるのではないかと思うのです。

加えて、他のチームの Huddles に入ることで単純にいろいろな人と知り合いになれます。やはり知り合いが社内に多いと困った時に頼りやすいですよね。実際、最近 SQL のパフォーマンスについて相談させてもらって非常に助かりました。「ちょっと話したことがあるから頼ったら助けてくれるかもしれない」というぐらいには簡単に知り合いになれるのがこのプラクティスの魅力です。

とはいえ、私も嫌われたくはないので空気を読んで Huddles に入るかどうかは見ます。たとえば、 Huddles を始める前後のやりとりで何かしら深刻そうな会話がテキストで行われていたり、なんとなく 1on1 のような空気感の会話なのであれば入らないようにしています。この読みが当たっているかどうかはわかりません。もしかしたらあまり深く考えずに飛び込んでもよいのかもしれませんが、そのあたりはもともとあった人間関係も影響すると思います。

Huddles にトピックを設定する

Slack Huddles でトピックをセットできるのはご存じでしょうか?このトピックをセットすると Huddles に入っている人からも入っていない人からもどのような会話が行われているのか?がわかって便利です。トピックをセットする目的は、雑談ではなく「今これについて話している」というのを宣言し、 Huddles に入っていないがそのトピックに対して興味がある人を誘い込むことにあります。このプラクティスは、特定の誰かをメンションしたいわけではないが特定のトピックについて話したい、そして、そのトピックに関心のある人を呼びたいときに有効です。

プロジェクトやタスクなどすでに実施することが決まっている業務の場合、関係者は決まっているでしょう。しかし、次に何をするか?という探索などでは、柔軟な考えを取り入れたいですし様々な人の意見を聞きたいでしょう。多種多様な職種の人たちを集めてざっくばらんに話すことができればもしかしたら新しいアイディアが生まれるかもしれません。そうしたときにどうやって人を集めるか?というのは意外と難しいです。そこで Huddles のトピックを広告のように掲げることで関心のある人を呼び込むのです。

これは、「Huddles を開いて作業する」というプラクティスと組み合わせて使うこともできるでしょう。たとえば、パフォーマンスチューニングに取り組んでいるとします。その取り組み内容である「パフォーマンスチューニング」をトピックにセットするとどうでしょうか?チューニングが好きそうな人たちを呼べそうな気がしませんか?

このように Huddles のトピックは人を呼び込むことを目的としています。呼び込んだ後に途中から雑談に変わったらトピックは変更すればよいでしょう。

最後に

ここまで Slack Huddles のプラクティスをみてきました。どれも基本的なものですが、それを使う背景を考えてみると案外面白いですね。実はこれまでほとんど何も考えずにこうしたプラクティスを行ってきたのですが、こうしてアドベントカレンダーを書く機会をいただいたことでそのプラクティスの背景が言語化されました。この場を借りてアドベントカレンダーの機会をいただいたタイミーのプロダクト組織に感謝いたします。

packwerk チェッカーとちゃんと向き合う

こちらは Timee Product Advent Calendar2024 の18日目の記事です。前日は @ryopeko による「RubyWorld Conference 2024に参加してきた」でした。

こんにちは。タイミーでバックエンドのテックリードをしている @euglena1215 です。
タイミーではモノリスな Ruby on Rails アプリケーションに一定の規律を設けるために Packwerk を導入しています。

A Packwerk Retrospective であったように、Packwerk はあくまでツールであり鋭いナイフです。ツールは使い手が意図を持って扱わないとそれに振り回されて怪我をしてしまいます。
この記事では、それぞれのチェッカーがどんな目的を達成するために使えるものなのかを自分なりに整理してまとめてみます。

 

Packwerk 自体はあくまで依存グラフを作成するだけであり、どんな検査を行うかは決定しません。どんな検査を行うかを決定するのはパッケージごとのチェッカーの設定です。この記事を通して「Packwerk での議論が行われる際にはチェッカーの設定もセットで行われるようになるといいな」という微かな期待もあります。

前提

タイミーでは packwerk, packwerk-extensions で提供されているチェッカーのうち、Dependency Checker と Privacy Checker を利用しています。packwerk-extensions には他に Visibility Checker, Folder-Visibility Checker, Layer Checker が存在しますが、今回は省略させてください。

Dependency Checker

Dependency Checkerの概要図

パッケージ間の依存関係を管理・検査するためのチェッカーです。これが唯一 Packwerk 本体が提供しているチェッカーになります。

機能の詳細は https://github.com/Shopify/packwerk/blob/main/USAGE.md#types-of-boundary-checks をご覧ください。

主な使い道

CI 上で実行するテストの枝刈りを行いテストの実行時間を削減する

Dependency Checker はパッケージ間の依存関係を管理するため、全てのパッケージの依存関係を厳密に管理すれば、ある変更に対して影響を受けた可能性のあるパッケージを特定できます。影響範囲外のパッケージはテストの実行を省略可能なのでテストの実行時間を削減することが可能です。

これは Shopify のブログにもモチベーションの1つとして記述されています。

Instead of running the test suite on the whole application, we can run it on the smaller subset of components affected by a change, making the test suite faster and more stable.

ref. https://shopify.engineering/shopify-monolith

この用途で使うためには、全てのパッケージの enforce_dependencies を基本的には strict に設定する必要があります。

# packs/foo/package.yml
enforce_dependencies: strict
dependencies:
    - bar # bar パッケージで変更があれば foo パッケージにも影響を与える
    - baz # baz パッケージで変更があれば foo パッケージにも影響を与える

しかし、この使い道で Dependency Checker を利用する際にはいくつかの注意点が存在します。

パッケージの循環依存をなくす必要がある

循環依存があると、全てのパッケージが影響を受ける可能性ありとしてマークされます。
例えば、A → B → C → D → A という依存があると、どのパッケージを変更しても全てのパッケージのテストを実行する必要があるため実行すべきテストを削減することができません。

循環依存があると全てのパッケージに影響が波及する図

そのため、どうやって循環依存を減らすかが重要です。

Shopify では、依存の方向を変え循環依存を減らすために ActiveSupport::Notifications の pub/sub のような機構を導入したと2020年の記事には記述されていました。

Inversion of control means to invert a dependency in such a way that control flow and source code dependency are opposed. This can be done for example through a publish/subscribe mechanism like ActiveSupport::Notifications.

ref. https://shopify.engineering/shopify-monolith

が、2024年の記事では「結果としてコードが理解しにくくなったこともしばしばあった」と記述されていました。

We relied heavily on inversion of control, for example, to extract package references out of base layer code. These changes introduced indirection that, while resolving the violations, often made code harder to understand.

ref. https://shopify.engineering/a-packwerk-retrospective

ActiveSupport::Notifications をアプリケーションロジックで多用している Rails アプリケーションに出会ったことがないのでどのような書き味・読み味になるのかは私には分かりません。
しかし、同じように依存方向を反転させる ActiveRecord の Callback は馴染み深いのではないでしょうか。ActiveRecord の Callback は適切に使えば便利ですが、使いすぎると苦しめられることは Rails エンジニアのみなさんはよくご存知だと思います。同様のことが起きていないといいのですが果たして…。

Sorbet の利用が実質的には必須

テストの実行を省略するためには厳密な依存チェックが行えている必要があります。feature branch では CI が通っていてマージをしたのに、実は失敗するテストケースが存在していたなんて考えなくもないですよね?

Packwerk はパッケージ間のクラス・定数の呼び出しを検出するツールです。そのため、引数として他パッケージで管理しているクラスのインスタンスが渡された場合、コード上にはクラスが登場しないので検知することができません。

# packs/foo/app/models/foo.rb
class Foo
    # 引数 bar は bar パッケージで管理している Bar クラスのインスタンス
    def foo(bar)
        # bar.bar_method を呼びだすことで bar パッケージへの依存が発生しているが
        # packwerk は `Bar` が登場しない限り依存を検知できない!
        bar.bar_method
    end
end

Sorbet のような引数のクラスを評価するような実装があることで初めて検知することができます。

# packs/foo/app/models/foo.rb
class Foo
    # sorbet ではランタイムで `Bar` を評価するので packwerk が依存を検知できる!
    sig { params(bar: Bar).void }
    def foo(bar)
        bar.bar_method
    end
end

これは Shopify の Rails アプリケーションでの Sorbet による型アノテーションのカバレッジが十分に高いことが前提になっているからだと思われます。

こちらに関しては実質的に Sorbet に依存せずとも厳密なチェックができるようにカスタマイズするための機能提案が行われていますが、1年以上前から動きがありません。

github.com

Shopify からすると今のままで困ってないわけですし、新たな機能を追加することで保守が大変になるので困るのは理解できます。fork して自分で保守を行う覚悟がないのであれば、この用途で使うためには Sorbet の利用が実質的に必須である状態は避けられなさそうです。

基盤パッケージが他パッケージに依存していないことを維持したい

全てのパッケージに関してパッケージ間の依存管理するのは諦めて、最低限基盤パッケージのような他パッケージに依存しないパッケージのみ検査を行うという使い道もあると思います。

この使い道で Dependency Checker を利用するなら以下のような設定になります。

# packs/no_dependency_package/package.yml
# 他パッケージに依存しない基盤的なパッケージ
enforce_dependencies: true | strict
dependencies: []

# packs/normal/package.yml
# 他パッケージに依存して動作する一般的なパッケージ
enforce_dependencies: false
dependencies: []

タイミーでは、 ApplicationRecordApplicationController など Rails が提供している基盤クラスを rails_shims パッケージとしてまとめています。このパッケージが具体の機能に紐づく module を include しているのは好ましくありません。

この使い方をすることで、明らかに依存してはいけない依存を検知することができます。

どの使い道が良いと考えているのか

完全に個人的な意見にはなりますが、Shopify の状況を見るに依存グラフを作成することによるテストの実行時間の削減は現実的ではないと考えています。

そのため、基盤パッケージの意図しない他パッケージへの依存の検出など本当にパッケージ間の依存を管理したい一部のパッケージのみ Dependency Checker を有効にし、一般的なその他パッケージは無効にしておくのが良いのではないかと考えています。

Privacy Checker

Privacy Checker の概要図

packwerk の文脈での private なクラス・定数への参照を管理・検査するためのチェッカーです。元々 packwerk 本体が提供するチェッカーでしたが、packwerk-extensions に切り出されました。
機能の詳細は https://github.com/rubyatscale/packwerk-extensions?tab=readme-ov-file#privacy-checker をご覧ください。

主な使い道

パッケージの利用方法を絞りたいとき

パッケージで扱っているロジックが複雑で「ここを変更した際は一緒にここも変更しないと不整合が発生する」といった暗黙的な依存が発生しているときは、安全な利用方法を Public API として提供しておくのが安心です。

この使い道で Privacy Checker を利用するなら以下のような設定になります。

# Public API を提供して利用方法を絞りたいパッケージ
enforce_privacy: true | strict

# パッケージ外からどのクラスを参照しても構わないパッケージ
enforce_privacy: false

一方、packwerk の開発元である Shopify では Privacy Checker をうまく活用できなかったために packwerk 本体からは削除され、packwerk-extensions に切り出されたのも事実です。何が起きていたのかを分かる範囲でまとめておきたいと思います。

Under Deconstruction: The State of Shopify’s Monolith では「Privacy Checker を守ることだけを目的としたほとんど意味を持たない Public ラッパークラスが量産されていたことに気付いた」との記述がありました。

When we ignored the dependency graph, in large parts of the codebase the public interface turned out to just be an added layer of indirection in the existing control flows. This made it harder to refactor these control flows because it added additional pieces that needed to be changed. It also didn’t make it a lot easier to reason about parts of the system in isolation.

ref. https://shopify.engineering/shopify-monolith

また、Packwerk から Privacy Checker を削除し、packwerk-extensions に切り出すことが決まった GitHub discussion での議論では「Privacy Check に違反したコードはただ public ディレクトリに移動されるだけ、もしくは package_todo.yml に記録されて忘れ去られるだけ。これらを対処するためにより悪いコードが生み出される」との記述がありました。ここでの「悪いコード」とは上記のほとんど意味を持たない Public ラッパークラスだと予想しています。

So far at Shopify, we didn't see much value in the privacy checks. Most of our packages have this option disabled and when we do have them enabled, people mostly fix this kind of violations mostly by moving files around, without improving the APIs, or, even worse, people just record the deprecations and forget about them.
Those ways to solve this violation are actually creating worse code, instead of improving it. The public folders are full of different concepts mixed together, and people are getting annoyed with "Packwerk failing on me with something I can't fix" and just avoiding the tool all together.

ref. https://github.com/Shopify/packwerk/discussions/219

このような状況になった実際の原因は Shopify に直接聞かないと分かりませんが、自分なりに予想するに Public API を定義することに価値を感じていない開発者に対しても Public API の提供および運用を強制させてしまったことにあるのではないかと考えています。

投資対効果に対して効果を感じられていない場合は、投資を最小化させるのが最も効果的な行動です。

どの使い道が良いと考えているか

やはり他パッケージからの利用方法を絞りたいときに活用するのが良いと考えています。しかし、Shopify の事例を踏まえるとまずは Public API を提供することに価値があると感じられるパッケージでのみ有効にするのが良いのではないでしょうか。

新規で作ったパッケージに対して Privacy Checker を有効にし、Public API の定義を頑張ってみるのも良いと思います。

最初は全てを無効にした状態から始めてもいいじゃないか、気楽に行こう

個人的には最初は全てを無効にした状態で導入を行い、機能・ドメインごとでディレクトリが分かれているだけで他は特に何もしない Rails アプリケーションとして運用してみるのも悪くないと思います。
社内でこのような運用をしているパッケージが存在しますが、「触るファイルがまとまっているのでコードリーディングがしやすくなった」とのポジティブな声もありました。一方「普通の Rails のディレクトリ構造と違うのでギョッとする」という声もあります。

ただし、チェッカーを有効にする際は意図を持って有効にしましょう。繰り返しますが、Packerk はあくまでツールであり鋭いナイフです。ツールは利用者が意図を持って扱わないとツールに振り回されて怪我をしてしまいます。
全てを有効にした状態から始めると大量の package_todo.yml の荒波に飲み込まれてしまい、気付くと package_todo.yml を空っぽにすることが目的になることがあります。何度でも言いますが Packwerk はあくまでツールです。自分たちの開発スタイルを Packwerk に合わせるのではなく、Packwerk の設定を自分たちの開発スタイルに合わせましょう。

 

明日は yykamei の「Slack の Huddles を使ったプラクティスとその背後にある考え」です。お楽しみに!

モノリスの運用課題を解決するためにコードオーナーをSentryとDatadogに送る

モノリス特有の運用課題

こんにちは。バックエンドエンジニアの須貝です。

タイミーのバックエンドAPIはモノリスなRuby on Railsアプリケーションです。2024年12月現在、このリポジトリ上で10程度のチームが開発しています。

モノリスは利点も多いのですが、チームが増加するにつれて運用面でモノリス特有の難しさを感じることも増えてきました。例えば、SentryやDatadogで何かエラーや問題を検知しても「これはどこのチームの持ち物なのか」という責任があいまいになってしまい改善がなかなか進まない、基盤的なチームがエラーのトリアージをするにしても調査の負担が大きい、といった課題がありました。

SentryとDatadogにコードオーナーを送る

「まずどこのチームの持ち物なのかわかりやすくしよう」ということで、SentryとDatadogにコードオーナー(リポジトリ内の特定のファイルやディレクトリに責任を持つチーム)の情報を送信するようにしました。すでにバックエンドのリポジトリではGitHubのCODEOWNERSを導入していたのでこちらを利用しました。

なお、CODEOWNERSファイルのパースと、ファイル・クラスなどからコードオーナーを取得する処理ではCodeOwnershipというgemを活用しています。 github.com

やることはシンプルでSentryとDatadogに情報を送信する際に、CodeOwnership gemを使ってコードオーナー名を取得し、tagのような形で付与してあげるだけです。以下、それぞれの実装例を紹介します。

Sentry編

まず、Sentryにコードオーナーを送信するところから見ていきましょう。

CodeOwnershipはbacktraceを渡してコードオーナーを返すこともできるので、Sentryにコードオーナー情報を送信する実装も簡潔です。以下の実装例のようにsentry-ruby gemの設定に数行追加するだけで済みます。

Sentry.init do |config|
  # ...諸々省略
  config.before_send = ->(event, hint) do
    if hint[:exception]
      # コードオーナー名を取得
      code_owner = CodeOwnership.for_backtrace(hint[:exception].backtrace)&.name || 'unknown'
      # code_ownerというtagでSentryに情報を送信する
      event.tags[:code_owner] = code_owner
    end
  end
end

CodeOwnership.for_backtraceは渡したbacktraceを先頭から見ていき、コードオーナーが見つかった場合はそのコードオーナーを返す便利なメソッドです。

これでSentry側にtagとしてコードオーナー情報を送信するようになったので、Sentryの管理画面上でも確認できるようになっています。下記の画像のようにIssueの画面のTagsにコードオーナー名(この例ではWorking Relationsというチーム)が表示されています。

Sentryの画面で見た様子

さらに便利にするためにSentryから飛んでくるSlack通知でもコードオーナー情報を見られるようにしました。SentryのAlertルールの編集で「Set conditions」の「THEN」の「and show tags」の箇所にcode_owner(上記のコード例で設定したtag名)を追加するだけです。

SentryのAlertの編集画面

これでSlack通知で下記のようにコードオーナー名(この例ではIronBankというチーム)が見られるようになりました。

SentryからのSlack通知

Datadog編

続いてDatadogのAPMでもコードオーナー情報を見られるようにしていきます。Controller(APIリクエスト)とSidekiqのjob(非同期処理)の2パターンに対応します。

まずControllerです。下記のようなModuleを書いてControllerでincludeします。

module DatadogTaggable
  extend ActiveSupport::Concern

  included do
    before_action :set_datadog_tags
  end

  private

  def set_datadog_tags
    span = Datadog::Tracing.active_span
    return unless span

    code_owner = CodeOwnership.for_class(self.class)&.name || 'unknown'
    span.set_tag('code_owner', code_owner)
  rescue StandardError => e
    Rails.logger.warn("Failed to set code_owner tag to span: #{e.message} (#{e.class})")
  end
end

すると下記のようにDatadogのAPMでコードオーナー名(この例ではWork Wellというチーム)が確認できるようになります。

Datadog APMのTrace

次はSidekiqです。下記のようなMiddlewareを書いて上記のModule同様にDatadogのspanにtagをセットしてあげましょう。

class SidekiqDatadogMiddleware
  include Sidekiq::ServerMiddleware # loggerを使用するため

  def call(worker, _job, _queue)
    span = Datadog::Tracing.active_span
    if span
      code_owner = CodeOwnership.for_class(worker.class)&.name || 'unknown'
      span.set_tag('code_owner', code_owner)
    end
  rescue StandardError => e
    logger.warn("Failed to set code_owner tag to span: #{e.message} (#{e.class})")
  ensure
    yield
  end
end

あとはSidekiqの設定で上記のMiddlewareを追加してあげれば完了です。

Sidekiq.configure_server do |config|
  # ...略
  config.server_middleware do |chain|
    chain.add SidekiqDatadogMiddleware
  end
# ...略
end

おわりに

実際に上記の仕組みを導入してみて、自分がSentryのエラー通知をよく見ているというのもあり、ぱっと見で担当チームがわかるのはだいぶ助かるなあと日々感じています。主要なエンドポイントやJobは自分の中に脳内マッピングができている(気がする)ものの、さすがに限界を感じ始めていたのでこれ無しではもう生きていけません。後はSentryは最終的にはSlackでメンションまでできるようにしたいのですけど、あまり賢い方法が思いつかずに困っているので良い案のある方は教えてください。

また、上記では紹介しきれなかったのですが、ログにもコードオーナー名を付与しているのでDatadog Logsでもコードオーナーを見られるようにしています。コードオーナーを条件に入れてアラートを設定したり、ダッシュボードを作ることも可能になっているので、チーム単位でのオブザーバビリティの向上にも寄与するのではないかと考えています。

最後に自分が所属しているRailway(Rails WayではなくRailway)というチームではこんな感じでバックエンドアプリケーションの横断的な課題を解いたり、他のバックエンドエンジニアの開発生産性を上げたりするような活動を行っています。なんと、いま絶賛一緒に働いてくれる方を探しているので、カジュアル面談でお話しましょう。

product-recruit.timee.co.jp

英語YATTEIKI

Timee Advent Calendar 2024 13日目の記事です。

こんにちは!バックエンド・エンジニアの松岡です。

僕は2024年3月にタイミーに入社して、オフショア開発チームでブリッジ・エンジニアをしています。

チームメンバーの多くはオフショア先のベトナムの方々で、僕はそんなみんなと一緒にわいわい開発しています。

そんなチームではコミュニケーションの多くが英語で行われていますが、僕の英語力は、、、、

ということで今回は、英語学習について僕の取り組みを紹介します!

目次

過去の英語力

まず以前の僕の英語力は中学生レベルでした。

どのように中学生レベルかというと、英語を学習したのが中学時代だけだったということです。最後の学習機会はきっと高校受験だったと思います。

社会人になってから何度か英語を使う機会がありましたが、今のように学習することはありませんでした。きっと当時の自分には英語学習は優先度の低いことだったのでしょう。

学習をはじめるきっかけ

僕はソフトウェア・エンジニアを20年以上やっています。

その過程でそこそこに技術力を成長させてきましたが、だんだんと自分に伸びしろを感じなくなっていました。

そこで、自分の成長に伸びしろがありそうなものを見渡したときに気になったのが英語でした。

そして次のような意欲や好奇心をもったことがはじまりでした。

  • 伸びしろがあるとはいえ、若くない自分がいまから成長できるのか!?!?やってみるしかない!
  • 英語を覚えたら僕の人生の可能性はどれくらい広がるだろうか?気になる!やるしかない!

一方で、それまで趣味としてやってきた技術の勉強をいったんやめる決断もしました。両立できるほど時間がないと感じたからで、それくらい真剣に取り組むことにしました。

目指すゴール

目指すゴールはCEFRのB2で、そのためにやることは600時間の学習です。

CEFRとは

CEFRとはCommon European Framework of Reference for Languageの略で、外国語の習熟度を測る国際基準です。セファールと呼ぶようです。

ja.wikipedia.org

A1からC2まで等級が6個あり、僕が目指すB2はその真ん中あたりです。

出典:「各資格・検定試験とCEFRとの対照表」(文部科学省)

B2を選ぶ理由

B2を選んだ理由には エンジニア組織の英語化変革 EX という書籍の内容を参考にしています。

この書籍には開発チームのすべてのコミュニケーションの英語化はB2が目安であると書かれており、自分が目指すところはそこかなと思ったのでした。

ゴールまでの学習量

次の解説によると、英語初心者がB2を目指す場合は600時間の学習時間が必要なようです。

prontest.co.jp

600時間は1日に1時間の学習で1.7年、1日に1時間半の学習で1.1年の長さです。

僕には適度かなという印象です!

学習方法

いろいろな方法をためしていますが、僕がお世話になった教材を3個紹介します!

Speak

www.speak.com

一番はじめに取り組んだのがSpeakというサービスでした。2023年4月 (約1年半前) にサブスクリプション契約していて、現在も続いています。

僕は勉強方法に無知だったんですが、その当時話題になっていたことが選んだきっかけでした。

diamond.jp

学習内容はAIと行う英会話レッスンです。会話の相手がAIなのでとても気軽にできることがよいところです。

英語コーチ-イングリッシュおさる

www.youtube.com

中学生のときは小笠原先生から英語を学びましたが、この度はイングリッシュおさる先生から英語を学びました。

下記の動画は先生の5時間の大作です。これを見て基礎を学びことによりその後の学習がより効果的になった気がします。

www.youtube.com

NHKゴガク

いまもっとも学習している教材はNHKゴガクです。次の2個の番組を聴いています。

学習方法は次のとおりです。

よいところは3点です。

  1. 毎週新しいエピソードが配信されるため、学習する教材探しに困らないこと

    各講座はそれぞれに毎週5個の新しいエピソードが配信されます。僕の場合は2個の番組を聴いているのでエピソードの数は合計10個です。

  2. 1個のエピソードの長さが短いため、続けやすいこと

    1個のエピソードの長さは15分です。

  3. 重要な4個のスキルを学習できること

    上記のとおり、リスニング、スピーキング、リーディング、ライティングの4つを学習できます。

学習を継続する方法

何事も継続することが一番難しいですよね。

僕のおすすめは基本的に毎日やるです。

やる日とやらない日があると、やらない日の楽さを知っているのでやる日が少し辛く、よしやるぞ!と心を整えるまでにも時間がかかります。

一方で毎日にやる(やるしかない!)ということにすると上記は不要になります。

とはいえ、疲れていたり、予定があって時間がないときもあり、そんな日にはお休みします。

現在の英語力

さてさて、こんな僕ですが2024年10月に初TOEIC L&Rを受験してきました。

受験の目的は現在地を知るためです。

結果は590点で、CEFRで表すとA1です。目標は600点以上でしたのでくやしい結果でした泣。

TOEICの受験は今回で終わり、次回からはDuolingo English Testを受ける予定です。

englishtest.duolingo.com

これを選ぶ理由は次のとおりです。

  • はやい
    • インターネットで受験できる。
    • 48時間以内に結果が出る。
  • やすい
    • 試験料がTOEICやTOEFLより安い。
  • うまい
    • 世界中の4500以上の教育機関で認められている。

最後に

現在までの学習時間はおよそ200時間でした。

ゴールまであと400時間、来年末までに達成したい!

YATTEIKI!!!

次回のAdvent Calendarで結果発表できるかもしれません。

ということで来年またお会いしましょう〜!

Vertex AI Pipelinesテンプレートを管理するArtifact Registryの導入

Timee Product Advent Calendar 2024 13日目の記事です。

MLOpsエンジニアとして10月にタイミーにジョインした、ともっぴです。

データエンジニアリング部 データサイエンス(以下DS)グループに所属し、ML基盤の構築・改善に取り組んでいます。

概要

本記事では、Vertex AI Pipelinesを効率的に開発するために行った 「Vertex AI Pipelinesテンプレートを管理するArtifact Registryの導入」 の取り組みを紹介します。

過去、DSグループが取り組んできたVertex AI Pipelinesの開発効率化は、以下の記事を参照ください。

tech.timee.co.jp tech.timee.co.jp

背景と課題

背景

前提としてタイミーのML基盤では、サービスレベルに応じた複数のGoogle Projectが存在し、その中で複数のMLパイプラインが動いています。(参考記事)

Artifact Repositoryを導入する以前は、GCSにMLパイプラインテンプレートを格納していました。

下図は簡易的に表現したその時のアーキテクチャです。

Artifact Registry導入前のアーキテクチャ

課題

この状態での課題は2点ありました。

  1. 純粋な管理の煩雑さ

    複数の環境のGCSに、以下のような構成でMLパイプラインテンプレートが存在していました。

     ./project-internal
     ├── ML_Project_A_Bucket
     │   └── pipeline
     │       ├── v0.1.0
     │       │   └── pipeline-config
     │       │       └── pipeline.json
     │       └── v0.2.0
     │           └── pipeline-config
     │               └── pipeline.json
     └── ML_Project_B_Bucket
         └── pipeline
             ├── v1.0.0
             │   └── pipeline-config
             │       └── pipeline.json
             └── v1.1.0
                 └── pipeline-config
                     └── pipeline.json
    

    こうした状況下で、パイプラインやバージョンが増えていくにつれ、見通しが悪くなっていました。

  2. ライフサイクル管理の煩雑さ

    MLパイプラインテンプレートすべてのファイルを保存し続ける必要はなく、開発用は一定の期間で削除、逆に本番用はバージョン管理して一定の世代分は必ず保持する、という管理が求められます。

    GCSでもオブジェクトライフサイクルが設定できるので、n世代前は削除する、n日前のパイプラインは削除する、等のルールは設定可能です。

    しかし従来の構成では、一つのMLプロジェクトにつき一つのバケットを作成していたので、MLプロジェクトが増えるたびに、ライフサイクル設定が必要になってしまい、手間が増える状況でした。

    GCSに集約するという方法も考えられますが、Artifact Registryの方が各種ポリシーを柔軟に設定できることから、Artifact Registryの採用に至りました。また、Artifact RegistryのKFP対応自体が最近*1 *2のことなので、これまで採用を見送ってきた背景もあります。

ソリューション

改善後のアーキテクチャ

Artifact RegistryにKFP formatのリポジトリを作成し、そのリポジトリに対してCDでMLパイプラインテンプレートをアップロードする構成となりました。

Artifact Registry導入後のアーキテクチャ

その構成にしたことで、前項で書いたような複数プロジェクト・複数のバケットで管理してきたMLパイプラインテンプレートを、一つのリポジトリの中で管理できるようになりました。 (以下のディレクトリ構成のイメージ)

./project-central-repository
└── kfp-repository
    ├── pipeline-1
    │   ├── version1.yaml
    │   └── version2.yaml
    └── pipeline-2
        └── version1.yaml

実装サマリー

具体的にどのような実装を行なったのか、概要を紹介します。

  1. CDスクリプトの変更

    CDで行うパイプラインテンプレートのアップロードは、kfpライブラリのRegistryClientを利用しました。KFP CLIから利用できたら便利なのですが、こちらのissueを読む限り現在サポートはされていないようでした。

    そのため、簡易なPythonスクリプトを用意して、 GitHub Actions上で呼び出すことにしました。

  2. cleanup_policyを設定しパイプラインのライフサイクルを管理

    Artifact Registryではcleanup_policyを設定することで、ライフサイクルを設定できます。Artifact Registryで一元管理することで、統一的なルールでライフサイクルを、各MLパイプラインテンプレートに適用でき、管理の煩雑さが解消できました。

実装途中で詰まったところ

実装をする上で、いくつか直面した問題があったので、その概要と解決方法を紹介します。

1) GitHub Actions上でkfp.RegistryClient.upload_pipelineが通らない

CDで呼び出しているアップロード用のPythonスクリプトですが、ローカルでは正常終了するにもかかわらず、GitHub Actions上では認証エラーになる現象に遭遇しました。

原因を探っていると、LayerXさんの記事に行き当たり、RegistryClientがWorkload Identityに対応していないことが原因と判明しました。

LayerXさんの記事の通り、kfp.registry.ApiAuthを利用することで問題は解決しました。

2) jsonではアップロードできず、yamlが必要になる

改善前の構成では、パイプラインはjsonでGCSに格納していました。

しかし、kfp.RegistryClient.upload_pipeline(…)を利用すると、jsonではinvalid extension type pipeline.json というエラーでBad Requestとなりました。

ドキュメントを確認すると、Artifact Registry REST APIを利用する場合、yamlである必要があると記載がありました。

KFP SDK v2ではyamlフォーマットが推奨されており、かつKFP SDK v1のサポートは2024/12/20に切れるとされているため、徐々にyamlに移行していくこととなりそうです。

3) Cloud Composer(Airflow)のRunPipelineJobOperatorからArtifact Registryのパイプラインを利用する際にタグ指定だと権限エラーとなる

最初、以下のようなコードで動作すると思っていたところ、権限エラーとなりました。

from airflow.decorators import dag
from airflow.providers.google.cloud.operators.vertex_ai.pipeline_job import RunPipelineJobOperator

@dag(...)
def main():
    TEMPLATE_REPOSITORY_URL="https://asia-northeast1-kfp.pkg.dev/project-central-repository/pipelines/ml-project/v1.0.0"
    
    vertexai_pipeline = RunPipelineJobOperator(
        task_id=task_id,
        project_id=gcp_project_id,
        region=gcp_location,
        display_name=display_name,
        template_path=TEMPLATE_REPOSITORY_URL,  # KFP Repository上のテンプレートのURL
        service_account=pipeline_service_account
    )

発生したエラーは以下です。

Service account XXX does not have permission to get ArtifactRegistry tag projects/project-central-repository/locations/asia-northeast1/repositories/pipelines/packages/ml-project/tags/v1.0.0 in region asia-northeast1.

タグを取得する権限がないと言われています。

ここで、Artifact Registryにおけるタグとバージョンの違いについて確認します。

タグとは、MLパイプラインテンプレートをアップロードする際に、任意で付与できるものです。一方バージョンは、アップロード時に自動で付与される、sha256: で始まるハッシュ値です。

MLテンプレートのタグとバージョン

関係するサービスアカウントと、Artifact RegistryへのREAD権限は以下の通りです。

サービスアカウント Artifact RegistryへのREAD権限
Cloud Composer用のサービスアカウント あり
MLパイプライン用のサービスアカウント (pipeline_service_account) なし

一時的なエラー解消方法は、2通りあります。

  1. タグではなくバージョンハッシュを指定する
  2. MLパイプライン用のサービスアカウントにArtifact RegistryへのREAD権限を付与する。

しかし、それぞれデメリットがあり、

1.の方法では、MLパイプライン更新のたびにバージョンハッシュを指定し直す運用となり、意図せぬバージョン指定のミスや、手間がかかることがデメリットです。

2.の方法でも、既存のMLパイプライン用サービスアカウントのIAMを変更し、新規のパイプラインを作る際も権限を毎度付与する手間が発生します。タイミーのML基盤では、 MLパイプラインごとに柔軟に権限付与するために、MLパイプラインごとにサービスアカウントを作成する運用になっているため、その工数は無視できないものになります。

Airflowのソースコードを確認したところ、RunPipelineJobOperatorの内部では、PipelineJob初期化時にArtifact Registryからyamlを取得し、Vertex AI Pipelinesのジョブを作成するリクエストを送っている流れになっていることがわかりました。

上述のエラーは、ジョブを作成するリクエストをまさに送っている部分で発生しており、この部分でMLパイプライン用のサービスアカウントから共有リポジトリへのアクセスが発生していると判断できました。

原因を踏まえると、RunPipelineJobOperatorにバージョン付きのMLパイプラインテンプレートを渡せるようにする方法が良いと考えました。

タグからバージョンを取得するには、kfp.RegistryClient.get_tag(…)を利用すれば良いので、最終的に以下のような実装となりました。

from airflow.decorators import dag
from airflow.providers.google.cloud.operators.vertex_ai.pipeline_job import RunPipelineJobOperator

@dag(...)
def main():
    TEMPLATE_REPOSITORY_URL="https://asia-northeast1-kfp.pkg.dev/project-central-repository/pipelines/ml-project/v1.0.0"
    
    # kfp.RegistryClient.get_tag(...)をもとに、タグ付きのURLからバージョン付きのURLを取得するメソッド
    template_url_with_version = resoleve_template_version(TEMPLATE_REPOSITORY_URL)
    
    vertexai_pipeline = RunPipelineJobOperator(
        task_id=task_id,
        project_id=gcp_project_id,
        region=gcp_location,
        display_name=display_name,
        template_path=template_url_with_version,  # KFP Repository上のテンプレートのURL w/ version
        service_account=pipeline_service_account
    )

まとめ

取り組みの内容自体はちょっとしたものですが、ML基盤の拡張性や利便性を高められるものかと思っています。一方、当初想定したよりも特に権限周りで詰まりどころが多く、この記事の内容が誰かの参考になれば嬉しいです。

入社2ヶ月ということもあり最初は入社エントリでも書こうと思っていたのですが、せっかくなら直近取り組んできたことを書きたいと思い、本記事の執筆に至りました。

最後に、入社エントリっぽいことを簡単に書いておきたいと思います。

タイミーではデータを活用した施策が活発に行われています。 MLOpsエンジニアとして、より広く、安全で使い勝手の良いML基盤にするため、日々ワクワクしながら取り組んでいます。

We’re Hiring!

タイミーのデータエンジニアリング部・データアナリティクス部では、ともに働くメンバーを募集しています!!

現在募集中のポジションはこちらです!

「話を聞きたい」と思われた方は、是非一度カジュアル面談でお話ししましょう!


*1:Vertex AI PipelinesテンプレートがArtifact Registryに対応したのは、2022年12月です。 Release Note: https://cloud.google.com/vertex-ai/docs/release-notes#December_05_2022

*2:kfp.RegistryClientのリリースはKFP SDK 2.0.0からで、2023年6月です。Release Note: https://github.com/kubeflow/pipelines/blob/master/sdk/RELEASE.md#200