Classi開発者ブログ

教育プラットフォーム「Classi」を開発・運営するClassi株式会社の開発者ブログです。

ISUCON12予選 スコア4位相当でしたが失格になりました

TL;DR

こんにちは。Classi開発部のminhquang4334です。 今年は開発支援部のhenchiyb先輩と一緒に 2回目でyasuoチームとして ISUCON12の予選に参加しました (参考: 1回目で参加したブログ)。

最終結果は予選通過スコアを超えて、 4位/700チーム相当でしたが、SecurityGroupの TCP:8080 ポートがオープンされていたため、レギュレーションに引っ掛かって失敗しました。

以下のチームは予選通過スコアを記録していましたが、追試において失格となっています。

  • yasuo

    • 環境チェックにおいて、SecurityGroupの TCP:8080 ポートがオープンされていた

このブログでは積極的に自分の感想やチームがやったことを共有したいと思っています。

全体的な感想

正直、悲しい気持ち半分、嬉しい気持ち半分で戸惑っています。予選の実施前には、ここまでスコアを伸ばして、上位のチームと並べるのが想像できなかったので、競技中にリーダボードでずっと1位や2位などになっているのがとても嬉しかったです。しかし、そこまでやったのに、夢でも考えられないポート公開したミスで、失格となったことはとても悔しい気持ちでした。

失格という結果だけみると、大失敗のようですが、私がISUCONに参加した目的は自己成長で、昨年参加した時の結果である100位から今年の上位のチームになるまで学びになったことは数多くあるため、大成功だと考えています。

体制

今回チームは二人の体制で予選に参加しました。

  • アプリケーション担当: minhquang4334
    • 役割: ファシリテーターとしてアプリケーションとデータベース周りの改善を進める
  • インフラ担当: henchiyb
    • 役割: インフラ設定やチューニングやスクリプト作成など

二人は同じビルに住んでいますが、Meet経由でお互いに画面を共有しながら、作業を行いました。チャットはSlackを使いました。

使用したツール

  • mysqldumpslow
  • alp
  • top
  • pprof
    • プロファイリングを見るため、TCP:8080 ポートを公開したので、失敗になった原因だ
  • VSCode

チームの方針

予選に参加する前に、チーム内のアグリーメントを決めました。

  • 実装言語はGoにする
    • 予選までアプリケーション担当の自分の経験は6ヶ月しかないが、社内のGoベテランから色々教わっていたため
  • 自動のスクリプト使用する
    • Gitにコミットしてからなるべく早くリリースできるようにスクリプトを工夫した
    • モニタリング結果の取得でも一括取得できるためのスクリプトなど
  • 複数選択肢がある場合、ロジック修正が必要ない選択肢を優先する
    • ロジック修正はミスになりやすいし、デバッグ時間もかかる
    • 例として、N+1の問題であれば、N+1を解消するより、キャッシュの方が簡単にできたら、キュッシュする
  • チームなりのベストプラクティス資料を準備して、それ以外に経験がない技術ならやらない
    • たった8時間の競技で新しい技術を試したら、リスクが高いし、失敗したら精神も落ちる
  • モニタリング結果を心から信じて、それに基づいて改善する
    • 推測するな、計測しよう
    • topやalpやslowlogの結果などを斜めてから、改善の優先順位を判断して進める

当日チームのリポジトリと関連な情報

最終構成

  • サーバー 1つ目: Nginx
  • サーバー 2つ目: アプリケーション + SQLite
  • サーバー 3つ目: MySQL

結果

リーダボード

最大スコアは 75209点で、最終スコアは 59911点です。

競技終了時間のリーダボード

スコア変動

15時ごろ予選通過スコアを超えました。

yasuoチームの競技中のスコア変動グラフ

やったこと

10:32 最初のモニタリング結果 (スコア: 3768)

運営チームから準備してくれたCloud FormationをAWSに適用したり、インスタンスにアクセスしたり、gitやalpやslowログを設定したりするのが30分ぐらいかかりました。10:32ごろに初めてモニタリング結果を出力しながら、ベンチマーカーを実行できました。

10:46 Visit HistoryテーブルにIndexを適用する (スコア: 4920)

モニタリング結果をみたら、すぐにMysqlのCPU利用率が高すぎることや遅いクエリがあると見えました。

# top
   PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                                                                  
   4883 mysql     20   0 2569292 528048  34932 S  89.0  13.9   2:27.14 mysqld 

# slowログ

Count: 2106  Time=0.05s (106s)  Lock=0.00s (0s)  Rows=83.3 (175390), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

MysqlのCPU利用率を減らしないと前に進められないため、Indexを追加しました。以下のIndexを追加した結果、1千点ぐらいのスコア伸ばしました。

CREATE INDEX `visit_history_id_idx` ON visit_history (`tenant_id`, `competition_id`, `player_id`);

このvisit_historyテーブルは初期データでは32万ぐらいのレコードがあるため、Indexの作成時間もかなりかかりました。

10:57 いつもの設定 (スコア: 5094)

  • いつもと同じ設定を追加して、少しスコアが増えました。
    • MySQLに接続するオプションのinterpolateParams=trueに設定する
    • アプリケーションのDebugモードを外して、e.Use(middleware.Logger())をコメントアウトする

12:04 Nginxとアプリケーションの様々なチューニングを行う (スコア: 9955)

11:00からの一時間ぐらいでいくつかの修正を行いました。

  • Staticファイル (.css, .jsなど)をNginxに任せて配信したり、Expiresを設定したりする
  • playerテーブルとcompetitionテーブルはほぼInsertで、Updateがあまりないため、Goでオンメモリにした
  • competitionScoreHandlerメソッドのplayer_scoreのデータをCSVから挿入する処理は N+1になっているので、Bulk Insert化した

チューニングした結果、2倍近くスコアを伸ばしました

12:14 MySQLでID Generatorをやめる (スコア: 11884)

予選のアプリはマルチテナントの仕組みで、複数のテナントデータベースがあるが、テナントのテーブルのIDを発行する処理は共通データベースに任せる仕組みです。

// システム全体で一意なIDを生成する
func dispenseID(ctx context.Context) (string, error) {
//省略
ret, err := adminDB.ExecContext(ctx, "REPLACE INTO id_generator (stub) VALUES (?);", "a")
//省略

id_generatorテーブルの構成ですが、PKのIDカラムが AUTO_INCREMENTと設定されて、順番的にIDを生成する必要があるため、高負荷のシステムに適切ではない仕組みです (参考: MySQL AUTO_INCREMENT ハンドリング)。なので、その仕組みをやめて、Goのxidを採用して、アプリケーション側で一意なIDを発行できるように修正しました。

// システム全体で一意なIDを生成する
func dispenseID(ctx context.Context) (string, error) {
  guid := xid.New()
  return fmt.Sprintf("%x", guid.String()), nil
}

この修正を反映して、ベンチマーカーを実行したら、初めて1万点を超えるチームになりました。リーダーボードでも一位になったことを気づいて、自信を持てました。

12時ごろから初めて1万点を超えるチームになりました

12:36 playerの最新スコアを latest_player_scoreで保存できるようにSQLiteのトリガーを工夫する (スコア: 12472)

alpを確認したら、/api/player/player/:player_id/api/player/competition/:competition_id/rankingは遅いエンドポイントなので、なんとかしないとなあと思いました。

+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+----------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
| COUNT | 1XX | 2XX  | 3XX | 4XX | 5XX | METHOD |                  URI                  |  MIN  |  MAX   |   SUM    |  AVG  |  P90  |  P95  |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+----------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
|  5029 |   0 | 4977 |   0 |  52 |   0 | GET    | /api/player/player/                   | 0.008 | 22.848 | 1283.200 | 0.255 | 0.360 | 1.624 |  3.768 |  0.982 |     0.000 |   2124.000 |  5922926.000 |  1177.754 |
|  2823 |   0 | 2790 |   0 |  33 |   0 | GET    | /api/player/competition/              | 0.004 | 22.316 |  585.556 | 0.207 | 0.224 | 0.680 |  3.528 |  1.039 |     0.000 |  12265.000 | 26362948.000 |  9338.628 |

両方のAPIはテナントデータベースのplayer_scoreテーブルに触って、playerの最新スコアを取得しています。player_scoreテーブルはplayerのベンチマーカーの実行回数とスコアを履歴として保存するテーブルです。なので、playerの最新スコアがあるレコードだけは別のテーブルで保存した方がより効率になると思って、latest_player_scoreテーブルを作成しました。player_scoreにレコード追加・更新が発生したら、トリガー経由でlatest_player_scoreも追加・更新できるように設定しました。SQLiteのトリガーは書いたことがないですが、無事に以下のように実装できました。

CREATE TABLE latest_player_score (
  id VARCHAR(255) NOT NULL,
  tenant_id BIGINT NOT NULL,
  player_id VARCHAR(255) NOT NULL,
  competition_id VARCHAR(255) NOT NULL,
  score BIGINT NOT NULL,
  row_num BIGINT NOT NULL,
  PRIMARY KEY(tenant_id, competition_id, player_id)
);

CREATE TRIGGER IF NOT EXISTS tr1 AFTER INSERT ON player_score
BEGIN
  INSERT OR REPLACE INTO latest_player_score(id, tenant_id, player_id, competition_id, score, row_num) VALUES(NEW.id, NEW.tenant_id, NEW.player_id, NEW.competition_id, NEW.score, NEW.row_num);
END;

それにより、アプリケーション側でplayerの最新スコアを取得する処理はlatest_player_scoreでクエリできるように修正して、不要なORDER BYも外せました。

-- 修正前
SELECT * FROM player_score WHERE tenant_id = ? AND competition_id = ? AND player_id = ? ORDER BY row_num DESC LIMIT 1

-- 修正後
SELECT * FROM latest_player_score WHERE tenant_id = ? AND competition_id = ? AND player_id = ? LIMIT 1

14:12 N+1解消とsort.Sliceを外す (スコア: 15175)

  • /api/player/player/:player_idではlatest_player_scoreでクエリした後、まだN+1の問題が残っているので、解消した
  • /api/player/competition/:competition_id/ranking ではplayerのランキングを取得する処理はGoのsort.Slice処理などをやめて、全部クエリに任せた
SELECT * FROM latest_player_score WHERE tenant_id = ? AND competition_id = ? ORDER BY score DESC, row_num ASC LIMIT ?

14:31 /api/organizer/players/add のBulk Insert 化 (スコア: 18581)

/api/organizer/players/addでplayerを追加する処理が N+1なので、Bulk Insert化しました。

14:55 終了した大会の請求金額計算をキャッシュ (スコア: 22705)

alpを確認したら、請求金額計算APIが遅くて、15s以上かかるAPIがありました。

+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+--------+--------+--------+-----------+------------+--------------+-----------+
| COUNT | 1XX | 2XX  | 3XX | 4XX | 5XX | METHOD |                  URI                  |  MIN  |  MAX   |   SUM   |  AVG  |  P90  |  P95   |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+-----+------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+--------+--------+--------+-----------+------------+--------------+-----------+
|    36 |   0 |   34 |   0 |   2 |   0 | GET    | /api/admin/tenants/billing?before=xxx | 0.004 | 16.256 |  84.792 | 2.355 | 3.400 | 14.224 | 16.256 |  3.246 |     0.000 |    988.000 |    31387.000 |   871.861 |
|    90 |   0 |   89 |   0 |   1 |   0 | GET    | /api/organizer/billing                | 0.004 | 20.320 |  40.444 | 0.449 | 0.556 |  0.836 | 20.320 |  2.266 |     0.000 |   6489.000 |   260072.000 |  2889.689 |

slowログでも 一番遅いクエリも請求金額計算処理にあるクエリです。

Count: 5500  Time=0.01s (50s)  Lock=0.00s (0s)  Rows=135.9 (747469), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

コードを読んだら、大会が終了している場合のみ請求金額が確定するので、終了している大会の請求金額をキャッシュした方が良いではないかと仮説を立てました。

func billingReportByCompetition(ctx context.Context, tenantDB dbOrTx, tenantID int64, competitonID string) (*BillingReport, error) {
  cachedKey := fmt.Sprintf("%d_%s", tenantID, competitonID)
  if comp.FinishedAt.Valid {
      if cached, ok := cachedFinishedBillingReport.Load(cachedKey); ok {
          result := cached.(BillingReport)
          return result, nil
      }
  }
  // 省略
  result := &BillingReport{..}
  cachedFinishedBillingReport.Store(cachedKey, result)
  return result, nil

キャッシュした後、エラーはほぼなく実行できました。alpをみたら、請求金額計算のAPIの負荷が一気に減らせたと確認できました。

+-------+-----+-------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
| COUNT | 1XX |  2XX  | 3XX | 4XX | 5XX | METHOD |                  URI                  |  MIN  |  MAX   |   SUM   |  AVG  |  P90  |  P95  |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) |
+-------+-----+-------+-----+-----+-----+--------+---------------------------------------+-------+--------+---------+-------+-------+-------+--------+--------+-----------+------------+--------------+-----------+
|    56 |   0 |    55 |   0 |   1 |   0 | GET    | /api/admin/tenants/billing?before=xxx | 0.024 | 11.284 |  74.860 | 1.337 | 2.128 | 7.032 | 11.284 |  2.054 |     0.000 |    999.000 |    49014.000 |   875.250 |
|   112 |   0 |   112 |   0 |   0 |   0 | GET    | /api/organizer/billing                | 0.004 | 10.556 |  30.120 | 0.269 | 0.060 | 0.108 |  8.684 |  1.506 |   242.000 |   6810.000 |   315668.000 |  2818.464 |

請求金額計算処理にある遅いクエリも呼ばれる回数は5500回から3779回まで抑えました。

Count: 3779  Time=0.01s (46s)  Lock=0.00s (0s)  Rows=179.0 (676478), isucon[isucon]@localhost
  SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = N AND competition_id = 'S' GROUP BY player_id

更なる改善ですが、大会を終了する時点からすぐ非同期で請求金額計算処理を走らせた方が効率的ではないかと思って、大会が終了させるcompetitionFinishHandlerのメソッドで、非同期で請求金額計算のキャッシュを更新できるように修正しました。

func competitionFinishHandler(c echo.Context) error {
  // 省略
  // 大会を終了させる処理が終わったら
  go billingReportByCompetition(ctx, tenantDB, v.tenantID, id)

15:40 MySQLを3つ目のサーバーに設定した (スコア: 〜25000)

MySQLは3つ目のサーバーに移動してみた結果、少しスコアだけが増えました。例年なら、スコアが急増するという記憶があったので、あまりスコアが伸びず残念でした。

15:56 Fileでロックする方法をやめて、Golangのsync.Mutexにした (スコア: 31452)

テナントデータベースでクエリする時に、トランザクションをかけるために、ロックファイルを作成するという仕組みがあると気づけました。

// 排他ロックする
func flockByTenantID(tenantID int64) (io.Closer, error) {
    p := lockFilePath(tenantID)

    fl := flock.New(p)
    if err := fl.Lock(); err != nil {
        return nil, fmt.Errorf("error flock.Lock: path=%s, %w", p, err)
    }
    return fl, nil
}

わざわざファイルを作成して、ファイルロックを取ることは非効率ではないかと思って、Goのsync.Mutexに変更してみたら、点数が急増しました。それぞれテナントごとにsync.Mutexのインスタンスが必要なので、sync.Mapを合わせて実装しました。一回目の実装で不具合なく無事に動いたので今大会の自分の一つのハイライトでした。

   // / DELETEしたタイミングで参照が来ると空っぽのランキングになるのでロックする
    // fl, err := flockByTenantID(v.tenantID)
    // if err != nil {
    //     return fmt.Errorf("error flockByTenantID: %w", err)
    // }
    // defer fl.Close()
    var lock sync.Mutex
    if cached, ok := mapTenantLock.Load(v.tenantID); !ok {
        lock = sync.Mutex{}
        mapTenantLock.Store(v.tenantID, lock)
    } else {
        lock = cached.(sync.Mutex)
    }

    lock.Lock()
    defer lock.Unlock()

16:06 既存の100個のテナントデータベースにIndexをまだ適用していないと気づいたので、修正した (スコア: 37056)

SQLiteで動いているマルチテナントデータベースのクエリslowログはどうすれば監視できるのかものすごく悩みました。アプリケーションで発行するクエリをみて、Indexを追加したのですが、全部のテナントで機能しているか分かりませんでした。調べてみたら、やっばり 10_schema.sqlで追加Indexを定義しても、既存の100個のテナントDBに影響がないと気づいて、initializeの時点で、全てのテナントにIndexを適用できるように修正しました。

16:52 請求金額計算処理で重いクエリを2sでキャッシュして、sqlx.DBのMax Connsを50まで増やした (スコア: 〜60000)

請求金額計算処理はリアルタイムで反映しなくてもいいかもしれないとマニュアルを読みつつ気づきました。それで、slowログの一番遅いクエリのランキングにアクセスした参加者のIDを取得するクエリを2sでキャッシュしてみました。

visitCacheKey := fmt.Sprintf("%s_%d", tenantID, comp.ID)
now := time.Now()
if cachedTime, ok := cacheVisitHistoryTimeAt.Load(visitCacheKey); ok {
    if cached, ok := cacheVisitHistory.Load(visitCacheKey); ok && now.Sub(cachedTime.(time.Time)) < 2*time.Second {
        vhs = cached.([]VisitHistorySummaryRow)
    }
} else {
    if err := adminDB.SelectContext(
        ctx,
        &vhs,
        "SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = ? AND competition_id = ? GROUP BY player_id",
        tenantID,
        comp.ID,
    ); err != nil && err != sql.ErrNoRows {
        return nil, fmt.Errorf("error Select visit_history: tenantID=%d, competitionID=%s, %w", tenantID, comp.ID, err)
    }
    cacheVisitHistoryTimeAt.Store(visitCacheKey, now)
    cacheVisitHistory.Store(visitCacheKey, vhs)
}

ベンチマーカーを実行したところ、「へ〜」、「すごい〜」、「マージか〜」という感じでスコアとモニタリング結果を確認しました。キャッシュした一番重いクエリの実行回数が一気に減らせるため、MySQLサーバーのCPU利用率は100%ぐらいから安定に20%で動いていました。逆にアプリケーションのCPUが150~170%までピークになっていて、「大量のリクエストが来るのではないか」と思って、スコアも一気に伸ばしました。この実装は正直GoのSingleFlightを使った方が良いと思いますが、時間があれば修正したいけど、結果がいい感じなのでそのままにしました。

MySQLサーバーのCPU利用率はものすごく低くなったので、sqlx.DBのConnection Poolingを有効にするための設定を行いました。色々試した結果、50で行くと決めました。

adminDB.SetMaxOpenConns(50)
adminDB.SetMaxIdleConns(50)

17:02 Appを2つ目のサーバーに移動して、最終構成を固めた (スコア: 〜75000)

アプリケーションのCPU利用率が高すぎて、限界になってしまうので、どうしてもスコア伸ばせないと思いました。 CPU利用率を減らすため、いくつかのAPIを2つ目のサーバーに分散できないかいろいろ調べましたが、SQLiteの制約でやっばり今の技術スタックでは不可能だと分かりました。それで、APIを分散しないで、システムレイヤを分けました。1つ目のサーバーはNginxにして、アプリケーション自体は全て3つ目のサーバーに移動しようと決めました。その結果、チームの最終の構成で、当日の最大スコアの〜75000点 (+86000点、 -11000点) に到達できました。

17:52 競技終了まで挑戦したが、最終スコア: 59911ぐらいだった

最後まで、alpで一番時間がかかる/api/player/player/:player_id/api/player/competition/:competition_id/ranking にはなんとかしたいと工夫したが、どれでも改善できなさそうなので、諦めました。 17:35ぐらいで、掃除して、再起動試験を行いました。何度もベンチマーカーを実行した結果はスコアは安定せず、60000〜72000ぐらいでしたが、マニュアルを確認したら、再現スコアが最終スコアの85%以下の場合は失格 というルールがあると気づいたので、6万ぐらいで全然本戦に出られると思うので、安全のため、最終スコアは6万のままにしました。

最後に

今年の予選の問題は上手く作られていたと思っています。一番の謎を解いたら、点数が一気に伸ばせるという問題じゃなくて、小さい改善を積み重ねて、こまめにリリースしていくことでスコアがどんどん高くなると感じました。ISUCON12の運営の皆様がいい感じなIsuconを開催してくださって、感謝してもしきれません。

yasuoチームは今年でポート公開したミスで本戦に出られないのがすごく悔しいですが、まだまだ本戦で挑戦したい気持ちがあるので、また来年楽しみに参加しようと思います。

以上です。

データ基盤の品質向上への取り組み

こんにちは、データエンジニアの石井です。 先日公開した記事「社内向けのデータ基盤から集計結果をReverse ETLしてサービスに組み込んだ話」で、ダッシュボード機能のリリースにより、Classiのデータ基盤が「社内用データ基盤」から「ユーザー影響あるシステムの一部」へ進化した話をしました。「ユーザー影響あるシステムの一部」への進化に伴い、データ基盤の品質担保は必要不可欠です。今回は、データ基盤の品質向上に取り組んだKANTプロジェクトについてご紹介します。

KANTプロジェクト

背景・課題

Classiのデータ基盤がユーザー影響あるシステムの一部になる前、つまり社内用データ基盤だった頃には以下のような課題がありました。

  • データ基盤の状態把握
    • マルチクラウドにおけるデータ基盤全体の状態把握ができていなかった
    • データ基盤の実行状態(SUCCESS, FAIL, RUNNINGなど)の把握が、大量にSlack通知されているのみで全体像をつかめていなかった
    • 結果として、データ連携がうまくできていなかったことが社内ユーザーからの連絡でわかったり、気づかないまま放置されたりすることがあった
  • データ基盤の安定性
    • 「データ基盤が安定している」という状態を明確に「定義」できていなかった
    • それゆえ、定義されていないものは「計測」もできていなかった
    • 計測したメトリクスをもとにした効果的な改善サイクルを回すことができておらず、どのような優先順位で「改善」を実施していけばよいかわからない状態だった
  • データ基盤の処理の連続性
    • 後段のGoogle Cloud側から前段のAWS側の処理の状態を把握できていなかった
    • それゆえ、AWS側からGoogle Cloud側へ処理が移る部分で、処理が終わっているであろう時間にスケジュール実行する実装になっていた
    • 最悪の場合、AWS側の処理が未完了の状態でGoogle Cloud側の処理が始まるとエラーとなりデータ連携が止まってしまう状態であった

目的

KANTプロジェクトの目的は以下の2点です。

  • Classiのデータ基盤のジョブの実行状態の収集・集約・把握・監視および実行制御を責務としたデータ基盤監視システム(KANT)の構築(Classiではデータ関連のシステム基盤には哲学者の名前をつける慣習があります。)
  • KANTで集約した情報をもとに、SLA/SLOを定め、KANT外のBIツールで可視化し、改善アクションを行う

KANTの構築

アーキテクチャ

実装内容

前提として、既存のデータ基盤では、Amazon RDSからAmazon S3にデータを出力する処理をAWS Glueで、S3→Google Cloud Storage→BigQuery部分の処理をCloud Composerで行っています。KANTが収集する実行状態はこの2箇所となります。データ基盤の詳しい実装にご興味があればこちらの記事で紹介しています。(Classiのデータ分析基盤であるソクラテスの紹介

ジョブの実行状態の収集・集約

  • Glueの実行状態

    AWS Lambdaで実装し、1時間おきにGlueのジョブの実行状態を取得し、Cloud Loggingに送っています。Glueのジョブの中でログを送らず、Lambdaでの外形監視をしている理由はジョブだけでなくGlue全体の監視を行うためです。例えば、何らかの理由でトリガーが発火せずジョブが実行されなかった場合、ログが送られず実行状態を把握できない、といったことを防ぐためです。収集したログはCloud LoggingのLogs Routerを使い、BigQueryに集約しています。 メトリクスは各ジョブの開始時刻と終了時刻、実行ステータスを収集しています。なお、ある処理が何月何日の実行分として動いているのかという日付も取得したかったのですが、ジョブとしてはこの情報は保持しておらず、取得は断念しました。

  • Cloud Composerの実行状態

    Cloud Composerの処理のログをCloud Loggingに出力しています。収集したログはCloud LoggingのLogs Routerを使い、BigQueryに集約しています。メトリクスは各タスクの実行日(execution_date)、開始時刻(start_date)と終了時刻(end_date)、実行ステータスを取得しています。

ジョブの実行状態の監視

監視は、「Glueのエラー監視」「データ基盤の連携遅延監視」の2パターンを実装しています。

  • Glueのエラー監視

    Cloud ComposerのDAGで実装しています。DAGは、ジョブの実行状態を集約したBigQueryを定期的に確認し、エラーがあればSlackへ発報するようにしています。以前は、Glueからのエラー発報ができておらず、後段の処理のCloud Composerが動かないことでようやく気づくという状態でしたが、KANT実装後はGlueのエラーにも気づくことができる状態となっています。

  • データ基盤全体の遅延監視

    上記と同様、Cloud ComposerのDAGで実装し、ジョブの実行状態を集約したBigQueryを定期的に確認します。データ基盤がプロダクトレベルになるにあたり顧客に遅延なくデータを届けられるよう、各処理ごとに「〇〇時までに処理が完了している」というSLOを定め、そのSLOを満たしていない場合に遅延アラートをSlackに発報するようにしています。

ジョブの実行制御

前述のように、以前は前段のGlueの実行状態にかかわらず、後段のCloud Composerをスケジュール実行していました。KANT実装後は、Cloud Composerの1つ目のDAGの前に新たなDAGを追加しています。新たなDAGは、ジョブの実行状態を集約したBigQueryを見に行き、Glueの処理が完了しているかを確認します。Glueの処理が完了していれば、既存のデータ基盤の1つ目のDAGをキックするように実装しています。

可視化とその効果

可視化は、集約したBigQueryをデータソースとしTableauで行いました。以下で可視化の一部をご紹介します。

Glueの各ジョブ、Cloud Composerの各タスクの実行時間の可視化

GlueとCloud Composerの処理ごとの実行時間を可視化しました。可視化以前は数百ある処理ごとに開始時刻と終了時刻をSlack通知で垂れ流しているのみだったところ、現在では以下の情報をまとめて表示でき、一目で全体像が把握できるようになりました。

  • 各処理の開始時刻と終了時刻
  • 各処理に要した時間
  • 各処理と他の処理との処理時間の比較
  • 各処理の終了時刻が終了想定時刻より遅いのか否か

処理の状態を把握するだけではなく、実行に長時間要している処理や終了時刻が想定時刻を過ぎている処理をこのビューから把握し、該当する処理をチームで優先的に改善していきます。例えば、下図の終了時刻が想定時刻(AM6:00)を過ぎている処理に対し、分散処理の実装や開始時刻の調整を行い、実行に要する時間と終了時刻の改善をしました。

Before
After

SLO/SLAの達成度の可視化

Classiのデータ基盤では、「◯◯時までに処理が完了している」というSLOと「障害の際にその日中に復旧させる」というSLAを定めています。上部にSLO/SLAを達成した日数の割合(%)を表示し、その下に日付ごとのSLO/SLAの達成状況を表示しています。HEGELの部分はGlueの処理を表し、SOCRATESの部分はCloud Composerの処理を表しています。 この可視化により、「現在のデータ基盤が安定しているかどうか」の共通認識をチームで持つことができるようになりました。

また、下の2つの図は2021/08時点(※)と、2022/06時点のSLA/SLOの達成状況です。前述の「Glueの各ジョブ、Cloud Composerの各タスクの実行時間」のビューや今回紹介しきれなかったその他のビューをもとに改善点の優先度をつけてデータ基盤の改善に取り組んでいった結果、データ基盤全体の連携速度が向上し、SLOが飛躍的に改善したことがわかるかと思います。

チームでは、2週間に一度のレトロスペクティブの際にSLO/SLAの達成率を確認しています。毎回改善されていくSLO/SLAに達成感を感じ、チームの士気が高まることもKANTプロジェクトの効果だと思っています。

Before
After

※KAMOGAWAはHEGELの前身です。千葉の鴨川が由来です(哲学者じゃないんかいw)。KANTで明らかになったGlue処理の問題点をリファクタした際にHEGELに生まれ変わりました。

データ監視基盤の進化とさらなる品質の向上に向けて

データ監視基盤は実行状態の収集・集約・把握・監視および実行制御が責務である、と冒頭で紹介しました。しかし、障害時の再実行や自動復旧など実行制御についてはまだ不十分な点があります。また、今回はデータ基盤の品質向上に取り組みましたが、この他にも、データ基盤で処理する中でデータの欠損や重複が発生していないか、データソースと同じデータが連携されているか、など連携されるデータそのものの品質向上にも今後取り組んでいかなければなりません。

上記のように「データ監視基盤」「品質」というキーワードだけでもまだまだやることはあります。 Classiのデータ基盤を開発しているチームでは、各人が主導するプロジェクトを持ちながらも時にはプロジェクトのスピードアップや各人のスキルアップを目的に、タスクを共有し協力して開発を行なっています。そういった環境でスキルアップをしてみたい方は以下よりご応募ください!

hrmos.co

hrmos.co

チームトポロジーを参考に新組織の編成を考えた話

みなさん、こんにちは。開発本部で本部長をしている徹郎(id:tetsuro-ito)です。 Classiでは今年度より組織のあり方を少し見直し、チームトポロジーの考え方も導入してみたので、今回はその過程の話を紹介します。

Classiのこれまでの組織

Classiでは、2020年に起こしてしまったセキュリティインシデントおよび高負荷障害の対策を全社でとるべく、組織のあり方を変えていました。

7月からは、動作しているすべてのコードに対して、チームの責任範囲を明確にしました。また、技術的な課題をそれぞれのチームの責任において改善するような動き方にも変えました。やるべきことが明確(「再建プロジェクト」と「セキュリティ強化」が最優先)で、かつ、チームが主体となって意思決定する形にしたことで、現在は各チームが担当する機能やリポジトリをしっかりとメンテナンスしていく、そんな体制になってきたと思います。

Classiで発生した2つの問題を繰り返さないために我々が取り組んでいることより抜粋

過去の組織図

このようにClassiのプロダクトに対して、それぞれの機能を担うフィーチャーチームが組成され、それぞれのチームが持つコードやリポジトリの責務も明確にし、プロダクトの再建とセキュリティの強化に取り組んできました。

当時の組織変更はとてもうまくいき、それまでなかなか改善ができていなかったプロダクトの内部品質を向上させたり、古くなってしまったライブラリや言語のアップデートやEOL対応も主体的に進めました。 その甲斐もあり、1年後には当時大障害を起こしてしまうようなアクセスが来ても、プロダクトは止まることもなく、繁忙期のアクセスも捌けるような状態に改善しました。

徐々に顕在化した課題

しかし、個々の責務を明確にし、それぞれの主体性をもって問題解決にあたれるようになった結果、それぞれのチーム内の責務については順調に解決できる一方、チームをまたぐ問題や全社に共通するような大きな課題が徐々に顕在化するようになりました。

組織的な課題を解決した結果、ボトルネックが移動したと認識しており、着実に進んでいるという認識ではあったものの、一つ一つが大きな課題や問題のため、経営陣やマネジメントレイヤーはこの問題をどのように解決していくべきかを検討し始めました。

そんな折に、「チームトポロジー 」が出版されました。「はじめに」で引用されているコンウェイの法則の文言に魅了され、この考え方をベースにしながら新組織のあり方を検討していきました。

システムを設計する組織は、その構造をそっくりまねた構造の設計を生み出してしまう、というのが基本的な主張だ。この事実がシステム設計の管理において重要な意味を持つことがわかった。主要な発見は設計を行う組織を構造化するための基準である。コミュニケーションの必要性に応じた設計活動を行うべきなのだ。

個々のチームで扱いにくい大きな問題に対処できるような組織体制を構築し、これまでうまくいっていた部分を引き継ぎながら新たな組織の形を見出すことで、課題の解決を目指しました。

新たな組織体制

新たな組織体制

新たな組織として、4つのストリームアラインドな領域を定義しました。

  • 学習I(ラーニング)
  • 学習II(コーチング)
  • コミュニケーション
  • サービスオペレーション

学習I(ラーニング)

学習I(ラーニング)領域は生徒のラーニングサイクルを確立し、目標に向けて学ぶことをミッションとした領域です。Classiの機能では先生が生徒に向けてテストや宿題を配信するWebテスト、オンラインで自主的に問題に取り組むWebドリル、オンライン授業を視聴することのできる学習動画などの学習機能があり、それらを担当しています。 また、5月末にリリースしたAIを搭載した個別学習機能もこちらの領域が担っています。 https://corp.classi.jp/news/2710/

学習II(コーチング)

学習II(コーチング)領域は学習I(ラーニング)領域の表裏一体となり、先生が生徒と向き合いコーチングを行うサイクルを確立するためのミッションを持った領域です。 生徒が自分の学習時間などを記録する学習記録や、生徒一人一人の情報を確認することができる生徒カルテなどの機能があり、それらを担当しています。 こちらも今年度、学校内のClassiの利用状況が一元的に把握できるダッシュボード機能をリリースしています。

コミュニケーション

コミュニケーション領域はClassiのコミュニケーション機能全般を担っています。先生から生徒へ、先生から保護者へ連絡をすることができる校内グループや保護者から学校へ連絡ができる欠席連絡、他にもアンケートやコンテンツボックスといった機能も担当しています。 それぞれのユーザーや各機能をつなぐハブとして重要なミッションを持った領域です。

サービスオペレーション

サービスオペレーション領域はClassiを利用するにあたって根幹となる機能である設定登録やログイン/認証、連携サービスなどの機能を担当する領域です。ミッションとしてはClassiの利用開始から終了までの顧客体験に一本の筋を通すことを担っています。また、Classiではいくつかのネイティブアプリを運用していますが、それらの責務もこの領域が担っています。*1

これらのストリームアラインドチームを支えるためにプラットフォームチームとして、システムプラットフォーム領域を設定し、イネーブリングチームとしてはシステムイネーブリングチームを置いています。

Classiでは、この組織体制でそれぞれのラーニングサイクルとコーチングサイクルを確立させ、それらを有機的に紐づけていくことで、学校活動をつなげていくことをミッションとして活動しています。

ミッションのイメージ図

組織は作って終わりではありません。まずはこのように設計し、走り出しましたが、その時の最善が今後も最善であり続ける保証はありません。チームトポロジーでは冒頭で下記のようにチームを説明しています。

組織は単に自律的なチームを求めるだけでは不十分で、顧客に素早く価値を届けるためにチームのことを考え、チームを進化させなければいけないのだ

私たちも、この教えに従い、顧客に素早く価値を届けられる組織として継続的に進化していけるようにしていきたいと思っています。

*1:サービスオペレーション領域は責務分割が完全ではなく、現在の組織を運用しながらもう一歩踏み込んだ責務分割を検討していく予定です。

Content Security Policy のレポートを収集するためにやったこと

はじめに

こんにちは、開発本部所属エンジニアの id:kiryuanzu です。 現在、Classi ではサービスのセキュリティリスクをできる限りなくすために Content Security Policy を導入して脆弱性を検知する仕組みの導入を進めています。

本記事ではこの仕組みを導入する上でどのような手順が必要であり、どのような箇所で苦戦するポイントがあったかについて紹介していきます。

筆者は今まで CSP対応に携わったことがなかったのですが、導入段階の時点で想定していたよりも様々な知識が必要なことがわかり、記事にしたいと思いました。 もし数ヶ月前の自分と同じように初めてCSP対応に関わる人の一助となれば幸いです。

Content Security Policy (通称: CSP) って何?

Content Security Policy とは、HTTPヘッダの種類の1つであり、クロスサイトスクリプティング(XSS) やデータインジェクション攻撃といった、特定の種類の攻撃を検知し影響を軽減するために追加できるセキュリティレイヤーです。 MDN のドキュメント内に詳細な記事が存在するため、より具体的に知りたい方はそちらの記事も参照ください。

developer.mozilla.org

CSP導入の方針

今回 Classi で CSP対応を導入することになった経緯としては、EOL を迎えたフロントエンドライブラリが含まれており、なおかつクローズ時期が迫ったサービスに対して、バージョンアップの対策を取らない代わりにCSPを導入して脆弱性が発生した時にすぐ検知できるようにすることになったためです。クローズ時期が近いサービスに対してバージョンアップ対応に時間を割くよりも、CSP対応を行ってEOLオーバーを許容する環境を用意することで運用コストを低くかつEOLによるリスクを抑えられると判断し、導入を決定しました。

まずは上記のような緊急性の高いサービスを優先してCSP対応を行い、バージョンアップを定期的に行いクローズ時期の予定がないサービスに対しても順次CSPを導入していくことにしました。

今回 CSP 対応をする中での基本方針は以下の通りでした。

  • Content Security Policy Report-Only ヘッダを使うことで違反内容の検知をレポートだけにとどめる
  • できる限り厳格な規格に沿ってポリシーを設定する
  • セキュリティ部署との密な連携

方針に対して具体的にどのような取り組みを行ったかひとつずつ紹介していきます。

Content Security Policy Report-Only ヘッダを使うことで違反内容の検知をレポートだけにとどめる

CSP には2種類のヘッダがあり、 1つ目は Content-Security-Policy ヘッダです。このヘッダを設定するとポリシーの違反内容に引っかかった時に違反内容をコンソール上に表示し、サイトのブロッキングまで行います。

2つ目は Content Security Policy Report-Only ヘッダがあります。このヘッダはサイトのブロッキングはせず違反内容のレポートのみになります。

もし Content Security Policy ヘッダを本番環境で設定した場合、想定しない場面でサイトがブロックされユーザーに不便が生じるリスクがあります。このリスクを回避するため、まずは staging・production 環境においてContent Security Policy ヘッダを設定しレポート系のサービスに収集させることにしました。

Content-Security-Policy-Report-Only の report-uri ポリシーを使って Sentry などのバグ収集サービスと連携させることで、CSPが検知した違反内容を Sentry に飛ばしイシューとして管理・Sentry に積まれた違反レポート内容を slack 連携してチャンネルに流す運用を今回行いました。

docs.sentry.io

できる限り厳格な規格に沿ってポリシーを設定する

CSP では以下のようにポリシーを指定して特定の記述やオリジンを許可したりブロックしたりできます。

Content-Security-Policy: default-src ‘self’; script-src example.com ‘sha256-xxx’’; style-src: example2.com ‘sha256-yyy’

特定のインラインコードやインラインスタイルを許可したい時はハッシュ値や nonce (一時的に利用する乱数のこと)を設定して対応するように進めました。もしひとつひとつ許可する方法に対してコストを高く感じる場合は、unsafe-inlineunsafe-styleを各ポリシーに設定することで許可できます。しかし、意図していないコードの実行を許可してしまうことに繋がるためこれらの設定は極力使わないようにしました。

CSP の規格として現在推奨されている規格として CSP Level 3 といった内容が存在しています。

w3c.github.io

この Level 3 は今まで示されていた Level 2 の対策よりもよりセキュアな仕様とされています。CSP Level2 から Level3 における XSS対策は pixiv さんの記事で詳しく紹介されているので、気になる方はぜひ読んでみてください。(今回のCSP対応を進める中でも大変参考になりました)

inside.pixiv.blog

セキュリティ部署との密な情報連携

対応を進める中で、現状のサービス設計だと厳格なポリシー設定を守れないケースが出てきたり、 CSP 違反で検知されたコードの調査が難しい場面がありました。 そういった問題に対しては、セキュリティ部署のエンジニアと一緒に現状の設定でセキュリティリスクがないかどうかや違反されたレポートが無害であるかどうか相談をする場を設けて現状の運用に対しての合意を取るように進めました。

CSP対応を進めるうえで必要だった実作業の紹介

初めてCSP対応を進める上でキャッチアップが必要かつ時間をかけて考える必要のあった作業を紹介していきます。

サービスの技術セットに合わせてヘッダの設定場所を調査する

実装に手をつける前に、まずはCSPのヘッダを設定できる場所を調査する作業から始めました。

Report-Only ではない方のCSPは HTML の metaタグを設定するだけでCSPの設定を適用できますが、Report-Only では対応されていません。サーバー側でレスポンスヘッダを設定する方法で対応を進める必要があります。

この方法で対応する場合、Rails 等のフレームワーク内の標準機能を利用する方法や nginx などのリバースプロキシ上で設定する方法など、サービスの技術セットに応じて設定する箇所を見つける必要がありました。

1つ例を紹介すると、Rails では、CSP を設定するための DSL が提供されています。 railsguides.jp

今回 CSP対応を行ったサービスの1つは、開発上の都合で S3に用意したフロント側のファイルを public 以下に配置する設計となっており、Railsは静的ファイルを配信するサーバーとして使われている構成だったため、上記の DSL は利用しませんでした。(なぜこうなっているかは CSP の文脈から逸れてしまうので割愛します)

application.rb 内で config.public_file_server.headers の設定を利用することで CSP のレスポンスヘッダを設定しました。 この設定は Cache-Control 等の他のヘッダを指定したい時にも使われています。

railsguides.jp

# application.rb

# CSP記述箇所以外は省略
    config.public_file_server.headers = {
      'Content-Security-Policy-Report-Only' => "default-src 'self';

このようにしてサービスの設計に合わせて設置場所を定めるための調査をする必要がありました。

その中で、設置場所によっては容易にできる実装だったものが自力で用意しなければならない局面があり、少し時間をかけてしまいました。 次のパートで詳しく紹介していきます。

特定のスクリプトに値を動的に変更する nonce の設置対応

Level3 の仕様の中では以下のコードのように、 nonce を使って javaScript の実行を制限する方法が推奨されています。

# index.html

<script nonce="hogehoge111">
  alert(“piyo”)
</script>
# nginx.conf

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'nonce-hogehoge111'

nonce とは「一回限り有効なランダムなデータ。リプレイ攻撃などを防ぐために用いる」といった意味の単語です。CSP ではこの nonce を固定値のまま設定して制御する方法も可能ですが、その方法だとポリシーの設定によっては nonce の値を攻撃者から悪用されるリスクもあるため、リクエストごとに変更する設定にしました。

特定のスクリプトに nonce を設置する際、前者で紹介した DSL では用意された設定を用いることで容易に nonce の自動生成を行うことができます。(具体的な設定方法は https://railsguides.jp/security.html#content-security-policy%EF%BC%88csp%EF%BC%89を参照してください)

しかし、Rails の public_server を使った設定や nginx で設定する場合はこちらで nonce を動的に変更にするための実装を考える必要がありました。

nginx で nonce の値を動的に変更できる対応を行うために、 ngx_http_sub_module というライブラリに用意された sub_filter メソッドを利用してフロントエンドのコードに 仮の nonce の値(placeholder)を埋め込んで nginx の設定の中で placeholder の値からリクエストID に置き換えることでリクエストごとに nonce を用意しました。

Module ngx_http_sub_module

Easy nonce-based Content-Security-Policy with Nginx | Infrastructure and Application Security

# index.html

# placeholder の値を CSP_NONCE にしている
<script nonce="CSP_NONCE">
  alert(“piyo”)
</script>
# nginx.conf

load_module /usr/lib64/nginx/modules/ngx_http_opentracing_module.so;
opentracing on;
# リクエストIDを変数で使えるように設定
opentracing_tag nginx_request_id $request_id;
opentracing_trace_locations off;

load_module  /usr/lib64/nginx/modules/ngx_http_subs_filter_module.so;
sub_filter_once off;
# フロント側でplaceholder の値として設定した CSP_NONCE 文字列をリクエストIDに置き換えて nonce の値として使うように
sub_filter CSP_NONCE $request_id;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'nonce-$request_id'

placeholder の値(ここではCSP_NONCEの文字列として記述)自体も、固定値のままにしておくとバレてしまった際に攻撃者から利用されるリスクがあるので、できるだけ動的に変更にした方が良いというアドバイスを社内でいただきました。

そのため、CircleCI の中で placeholder にあたる値を乱数に置換するコマンドを用意し、nginx.conf 内にある placeholder も置換するようにしてデプロイごとに変更する仕組みを用意しました。

# .circleci/config.yml

  set_nonce:
    steps:
      - run:
          name: Set NONCE
          shell: /bin/bash
          command: |
            nonce=$(cat /dev/urandom | base64 | fold -w 16 | head -n 1)
            sed -i "s@CSP_NONCE@$nonce@g" src/index.html
            echo "export NONCE=${nonce}" >> $BASH_ENV
      - run: echo ${NONCE}
# *この設定は必要最低限抽出したもので、実際には他の引数やパラメータも用意してビルドしています
jobs:
   image-build-and-push:
     steps:
       - checkout
       - set_nonce
       - aws-ecr/build-and-push-image:
         repo: xxx
         dockerfile: docker/frontend/Dockerfile
         extra-build-args: "--ssh default --build-arg NONCE=$NONCE"

ビルド時に CircleCI 側で用意した環境変数を Dockerfile の中で 置換する処理を入れ、デプロイ時に placeholder を書き換える処理にしました。

# nginx.conf

ARG NONCE
RUN sed -i "s|CSP_NONCE|${NONCE}|g" /etc/nginx/nginx.conf

このようにして、nginx 内の設定でも nonce の値を動的に変更するように設定し、よりセキュアなやり方で nonce方式でCSPを運用できるようにしました。

ただし、上述で紹介した Rails の config.public_file_server.headers の設定では自力で実装することが難しかったため、先ほどのコードで示したような nonce の値を CircleCI の中で置換する仕組みを用いて nonce 自体をデプロイごとで動的に変更できる設定を行うことにしました。

この対応は厳格なやり方から外れてしまいますが、他のポリシーで必要最低限の脆弱性から守るようにすることをセキュリティ部署のメンバーと合意し運用するようにしました。

CSP のポリシーの設定・運用フェーズへの体制を整える

上記のような nonce 対応も行う必要がありますが、基本的には実際に CSP のポリシーをサービスに適用するうえで現状の違反内容をチェックし、1つずつ解消していく作業をやっていきました。

ポリシーをdefault-src: selfのみ適用することで、現時点で存在する違反内容がコンソール上で表示されるため、この違反内容を1つずつ精査し、特定のハッシュ値や nonce の適用・不要なインラインスクリプトの削除を進めました。

この作業の中を進める上で、特定のスクリプトに対応するハッシュ値を記録するドキュメントを残して対応するスクリプトが不要になった時は削除できるように管理しています。 特に大変だったこととして、動作する環境やブラウザによって発見されるレポートが変わることが多々あり、原因を調査をしていくのにかなり時間がかかりました。

今は対処できる違反レポートを一通り対処し終わった段階で、Sentry のレポートを slack に流す連携を入れ、社内ドキュメントに運用ルールを記載し、トリアージやエスカレーションの方法を取り決めて運用体制を整えました。

まとめ

上記のような導入作業を終わらせてCSPのレポートを運用する状態に持っていくことができました。ただ、導入してからこそが本番でこのレポートたちの情報を元に傾向をまとめたり必要な対策を実施していく必要があると考えています。

jxck さんが執筆された CSP Report 収集と実レポートの考察 | blog.jxck.io のような活動を社内のレポートを元に行っていくのが次の目標です。

今後CSP対応に関わる方に参考になる話があれば幸いです。読んでいただきありがとうございました。

Classiの技術選定に対するスタンス

VPoTの丸山です。本日はClassiがいまのところどういうスタンスで技術選定に臨んでいるのかについてお話しします。これは「いまのところ」のスタンスであり、未来永劫このようなスタンスでいくかどうかというのは定かではありませんが、考え方のひとつとして参考になれば幸いです。

技術選定にハードリミットはかけない

結論から言うと、Classiでは「この技術スタックを必ず使ってください」という制限はかけていませんし、かけるつもりもいまのところありません。その理由は大きくふたつあります。

ひとつは、組織全体の視点でみた時に、技術スタックに関する健全な新陳代謝の機会を奪わないため、もうひとつは、メンバーレベルの視点でみても、複数の技術に触れる機会が成長につながるからです。

新陳代謝を行う機会を奪わない、というのは、どういうことでしょうか。

ひとつの技術スタックを極めていくことにも良い点はあります。ノウハウ、知識が集約されるので、高い効率で業務を進めていくことができるのはその最たる例でしょう。一方、すべての技術にはその得意とするところと苦手とするところがあります。たとえば、Go言語などは、高い並行性が求められるような要件に対しては得意だが、一方でコレクション処理を楽にかけた方が嬉しいような領域については少し苦手とするところ(まあ腕力でかけばいいので、そんなに問題にならない、という見方もできますが)でしょう。そして、プロダクトに技術的に必要とされることは決して現在過去未来を通じて一定ではありません。世の趨勢が変わり始めたときに、求められる要件に柔軟に対応していくためには、社内での技術の新陳代謝が健全に行われる必要があるでしょう。これはそんなに難しく考えなくても、たとえばEC2でapp serverを動かしていたのをECS Fargateで動かすようにしていくというような例を考えたらわかりやすい話だと思います。実行基盤のみに限らず、フレームワークや言語などについても「変化のための余白」を残しておくことは重要です。

もちろん、これが「正解のスタンス」だとは思っておらず、Classiにとってはそのほうがよい、というだけなので、採用戦略や、現在のメンバーの人数など様々な変数を見た結果、ノウハウ、知識が集約されるメリットを取るような判断も十分にあり得るはずです(この技術といえばこの会社! というのは強いですよね)。

では「メンバーにとっても複数の技術に触れる機会が成長につながる」というのはどういうことでしょうか。

この話題は、事実判断ではなくてかなり価値判断よりの話になってしまうのですが、すくなくとも私は、異なった思想をもった言語や、異なった思想をもったフレームワーク、あるいは実行環境を学んだときが、エンジニアとして非連続な成長を実感できるときでした。それもそのはずで、エンジニアリングというのは常に判断の連続です。ある変数の名前をどういう名前にするのかだって判断ですし、どういうクラスをつくてどうモデリングするのかも判断の連続です。「正解」が用意されていてそれを選べばいいだけの仕事ではなく「より良い方法」を常に探し続けるのがエンジニアの仕事のはずです。そのとき、異なった考え方、異なった視座を身につけていることは、とても強力な武器になります。ひとつの問題を複数の視点から検討することで、「より良い正解」に辿り着けるはずです。言い方を変えれば、ひとつの見方しかできないエンジニアは「正解」に従うことしかできないが、複数の見方を身につけている場合複数の選択肢から「より良い正解」を導ける可能性がぐんと上がるはずです。

もちろん、業務で使っている技術スタック以外にもプライベートで学習することでこのような複数の見方を身につけることは可能ですが、結局のところ業務で扱うのが一番よく身に付く、というのはあると思います。

別の考え方として「ひとつの技術を深く掘っていった方が、隣接領域をすぐに理解できる」という考え方もあり、それはそれで十分に合理的な考え方だと思いますが、少なくとも弊社としては「業務で複数の考え方に触れるチャンスがある(必ずしも全員が触れるわけではないが……)」というほうがより良いだろう、という価値判断をしています。

どうやってガバナンスを効かせるか

一方、技術スタックを制限しない場合、無制限に好き勝手技術スタックを選択されてしまうと、今度は管理上の問題が生まれます。「やる気のあるメンバーがめっちゃ新しい技術スタックでキラキラした開発をおこなったけど、それをメンテ、運用できる知見は誰ももっていないあるいはだれにも継承されず、あとには触るに触れないコンポーネントが残った……」というような話は、残念ながらよく聞く話ではないでしょうか。

いまのところ、Classiでは「新しいことやるときは相談してね」くらいのゆるいガバナンスで運用しており、大きな問題につながっていませんが、そろそろこのあたりのガバナンス設計あるいは知識の流通の設計を始めていかないと、結局のところ先に見たメリット(組織としても新陳代謝ができるし、個人の成長にもつながるよね)も得られないという未来が見えつつあります。そのあたりはマネージャー陣と相談しながら、今後の課題として取り組んでいく必要がありそうです。

まとめ

今回はClassiの技術選定に対するスタンスと、今後課題になりそうな部分を紹介しました。技術選定についてはかなりいろいろな要因が絡む難しい話題であり、それこそその組織の置かれている状況によって「より良い解」が異なるトピックです。Classiのスタンスをあくまで一例として紹介しましたが、なにか参考になるような部分が少しでもあれば幸いです。

Classi初の新卒データサイエンティストが研修を終えて

こんにちは、今年の4月にClassi初の新卒データサイエンティストとして入社した白瀧です。 私が入社して2ヶ月強の新卒研修を終えた振り返りを書きたいと思います。

アウトプット会をgatherで開催した時の図

新卒研修をやるにあたっての私の心構え

新卒入社前にインターン生としてデータAI部にjoinしていたため、データAI部でのオンボーディングや研修は済んでいました(インターン内容はインターン体験記に書いています)。そのため新卒研修ではエンジニア研修に参加する形になりました。
学生時代はPython, R, SQLくらいしか触ったことがなく、エンジニアっぽいことを全くしたことがない状態でした。(なんなら避けてきてました…)

エンジニア研修をやることで以下のようなメリットがあると考えました。

  • 今後のキャリアを考えるとエンジニアのスキルもあると良さそう
  • 各言語やサービスでどういうことができるのかを知っておくと視点の幅が広がりそう
  • コードをなんとなく読める状態にしておくとできることの幅も広がりそう、エンジニアと多少の会話ができそう
  • データサイエンティストとしてのキャリアで進んだら、今後経験できる機会がなさそう

この考えから、とにかく知見を増やすことと、幅広く体験してみることを意識して研修を受けるようにしました。

新卒研修の内容

新卒研修を進めていく中で、先輩エンジニアが新卒それぞれにサポーターとして付いていただき、いつでも質問ができるような環境でした。 新卒研修での主な内容は以下になります。

  • Git & GitHub
    • 今まで個人開発が多く、割と適当に使っていたので体系的に学び直すいい機会だった
  • HTTP + Webセキュリティ
    • アウトプット会の実施
  • HTML & CSS & JavaScript
    • 自己紹介ページの作成(人生初のwebページ作成)
  • AWS
  • Ruby on Rails
  • Webセキュリティ
    • KENRO
    • アウトプット会の実施
  • SQL
  • データベース設計
  • RSpec
  • CI
  • Docker
    • ずっと後回しにしていたので、しっかり勉強できてよかった
  • TDDBootCamp
  • 万葉カリキュラム
  • メンタルヘルス研修の一環で外部の人に協力してもらった
  • 社内の方からの講義
    • webセキュリティ講義
    • そーだい塾
    • igaigaさんによるRuby, Rails, テーブル設計講義
    • 金融教育講座
    • アジャイル研修
    • 質問力研修

研修に使用した参考書

私は配属や配属後に参加するプロジェクトも決まっていたことから、研修期間内に以下のようなことも行いました。

その他にも、雑談タイムやウェルカムランチ(話してみたい人とランチをする会)などを通して様々な人と交流する機会も多くありました。 このような研修期間中に社内の情報をキャッチアップできたため、研修から実務へもスムーズに移行することができました。

新卒研修を通して学び/感じたこと

学んだことを書こうと思うとほぼ全部書くことになってしまうので、今回は技術的なところ以外での学びや気づきを書きます。

エンジニア研修での学び

「新卒研修をやるにあたっての私の心構え」で記載した知見を増やす・幅広く体験してみるという目標を設定していました。 この目標についてサポーターの方と認識が揃っていたため、すごく動きやすかったです。また進捗に応じてスケジュール調整していただき、自分のペースでじっくり作業ができたため周辺知識を得ながら幅広く経験できたと思います。

またデータベースを含めたアプリケーションの開発は、データサイエンティストとして働く上でエンジニアとの関わりが重要になる部分だと感じました。それを自分で設計から考えたというのは今後エンジニアの方とのコミュニケーションを図る上でもいい経験になったのではないかと思います。

アクションを起こせ

とにかくやってみる、聞いてみる、手を上げてみるということを学びました(これが一番の学びかもしれない)。 この学びのきっかけをいくつか挙げてみます

  • 同期の行動力
  • アクションを起こした時にリアクションが多い
    • 新卒カードも追い風か?
  • アクションを起こした時に応援してくれる
  • アクションを起こした後、サッとサポートしてくれる

アクションを起こしやすい環境であり、アクションが推奨されていることを実感しました。ClassiのValueであるMake Happenはこういうことなのかなと学びました。

私自身も2ヶ月後面談で「良い意味で空気を読まない方がいい」ということを部長から言われて、少しずつアクションが起こせるようになってきているのかなと感じています。 具体的に私が起こしたアクション例は以下です。

  • 読んでおいた方がいいと言われた記事をまとめる
  • サービスを触ってみる会の企画
  • ペアプロのドライバーを名乗り出た
  • 依頼された分析タスクに手を上げた

この姿勢をこれからも続けて、よりアクションを起こせるように頑張っていきたいです!

Classiを触ってみる会を告知した時の社内のリアクション

サポートの仕方

サポーターの方に質問した時に、サポーターの方はすぐに答えを教えるのではなく、その人の力で解決できるようにサポートしていただきました。自分で解決する力も必要ですし、そういう仕方のサポートがいいな!と思いました。 具体的には以下のような方法でした。

  • この資料とか参考になるかも?
  • こうやって調べてみるとなんかいいかもよ
  • この辺りのドキュメントを探ると良さそう
  • 「なにが原因だと思う?」などの質問を通して状況を整理してもらう

これによって、どういうところがわからない時に何をどうやって調べると良さそうかを学ぶことができました。自分がサポートする時にはこのスキルを身につけたいです。

最後に

このブログをまとめている中で、あっという間の2ヶ月だったなと思うと同時にすごいたくさんのことに触れることができたいい機会だったなと感じました。 それも充実したカリキュラムを用意していただき、スケジュールを柔軟に調整して、手厚いサポートがあったおかげです。ありがとうござました!!!

この恩を配属後のチームで還元していきます!!新卒らしく色々動いて、スポンジのように吸収していきます!

アラート対応で気をつけていること

こんにちは!開発本部の id:lime1024 です。

アラート対応について社内で esa にまとめていたところ、参考になると社内でフィードバックをいただいたので開発者ブログでも紹介します。

前提として、平日日勤帯でのアラート発生を想定しています。

対応するときに気を付けていること

初動は早くする

アラートが発生しているという状況を把握して報告するのは、一分一秒でも早くするように心がけています。

Classi では Datadog の Monitor を使っており、その通知先が Slack になっています。基本的には各アプリケーションのオーナーであるチームにメンションが飛ぶようになっているので、メンションが飛んできたらまずは該当のアラートを「見ること」を、アラート対応のためのチャンネルに書きます。 「見ること」を書くことで、アラートが出ても誰も見ていないという状態を防ぐことや、一人で見る自信は無いけれど誰かと一緒であれば見てみたいという人も反応しやすくなります。

なにはともあれ反応する

自分にメンションが飛んできているということは、対応する必要があるからです。もし、メンションが来ているけれど反応しない・または反応する必要がないときは、メンションを投げる先が間違えているか、しきい値が間違っているのでアラートがオオカミ少年にならないように見直しを行います。

もしも、通話中だったり忙しいときは今やっていることが障害対応より優先されるものかどうかをまず考えます。とは言え、本当に忙しくて対応が難しいときはあるので、そのときは "誰か見れませんか?" と書きます。

何を見ているか・何を対応しているかを外からわかるようにする

アラートに対していま何をしているかは、アラート対応のためのチャンネルでオープンにやるようにしています。オープンにやるというのは以下の 2 点です。

  • 基本的には Slack のスレッドには書かない・書いてもスレッドの外に出す (Also sent to #channel)
  • アラート対応のためのチャンネル以外で対応しない

アラート対応の様子が気になって後から覗きに来た人の目線で考えると、Slack のスレッド機能では状況が見えにくいです。ただし、すでに収束していて調査内容をまとめるときや状況整理を行うとき等はスレッドに書くこともあります。同様にアラート対応のためのチャンネル以外に状況が記載されていると後から来た人がキャッチアップをしづらくなります。

また、どんなに対応が進んでいても、状況が書かれていないと外から見ても分からないため、定期的に対応状況のサマリを書くようにしています。特に複数人で通話しながら対応しているときに通話内で情報共有が進み、通話外の人への共有漏れが発生しがちになるため、気を付けるようにしています。

アラートの内容がわからないとき

わからないことを表明する

わからないからアラート対応をしなくてもいいわけではないので、わからないときはわからないことを表明するようにしています。すると、人がわらわら集まってきます。

もし誰も集まらなかったときは、ちょっとドキドキしますが Slack で @channel します。

何がわからないか深堀りする

いま出ているアラートに対して、自分がわからないのはアラートの内容なのか、それともアラートが発生しているアプリケーションのドメイン知識が無いのかを深堀りします。

アラート発生時の初動の時点では、アプリケーションのことはわからなくてもある程度はなんとかなりますし、アラート内容のこともアプリケーションのこともわからなくても見れるものはあります。自分がドメイン知識を持っていないアプリケーションでアラートが発生したときは以下のことを確認しています。

  • 該当のアプリケーションがいま動いているのか・体感で遅くなっていないかをブラウザから実際にアクセスして確認する
  • レスポンスタイムが遅くなっているのであれば、それはどのエンドポイントなのかを Datadog から特定する
  • 5xx エラーが出ているのであれば、それは ALB からなのか ECS からなのかを確認する
  • 該当のアプリケーションの ECS コンテナは生きているか・再起動を繰り返していないか
  • 該当のアプリケーションで直近でリリースは無かったか

以上のことを確認している間に、アラートに気付いた他の人達が集まってくれるはずです。

解決しようとしない

わからないものはわからないので、アラートに反応して対応もしたけど解決できなかった...と思わないようにしています。実際に解決する人・解決できる人の一助になれたら良いくらいの温度感でいるようにしています。

さいごに

アラートが発生してどうしよう!?となったときにこの記事を思い出して頂けたら嬉しいです。

© 2020 Classi Corp.