続・何でも屋になっている SRE 的なチームから責務を分離するまでの道のり 〜新設チームでオンコール体制を構築するまで〜

こんにちは、Platform Team というチームでマネージャーをしている荒引 (@a_bicky) です。 Platform Team は、データエンジニア・アーキテクト的な役割を担う Repro Core Unit と、インフラエンジニア・SRE 的な役割を担う Sys-Infra Unit から成るチームです。

先月 SRE Lounge #15 で「何でも屋になっている SRE 的なチームから責務を分離するまでの道のり 〜新設チームでオンコール体制を構築するまで〜」と題して次の発表をしたんですが、時間の都合上話せなかった内容があるので、それらについて触れたいと思います。

なお、当日の発表内容は動画でも視聴可能です。

アジェンダ

本エントリーのアジェンダは次のとおりです。

  • SRE Lounge #15 での発表内容の要約
  • Repro Core と Sys-Infra の棲み分け
  • Repro Core でオンコール体制を構築するまで
    • チームビルディングの実施
    • コンポーネントのキャッチアップ
    • アラートの向き先変更
  • 終わりに

なお、Repro 社内では Repro Core や Sys-Infra は unit という単位になりますが、会社によってチーム、グループ、係等様々な呼び方があると思うので、呼称は「チーム」で統一します。

SRE Lounge #15 での発表内容の要約

発表内容も把握しておいた方が理解しやすいと思うので簡単に要約します。

Repro Core が発足する前の Repro の開発組織の状況

  • Repro はリアルタイムの One to One コミュニケーションを実現可能にするマーケティングオートメーションサービスで、扱うデータ量は膨大であり、使われている技術も多岐にわたる
  • SRE 的なチーム(Sys-Infra)は各種 AWS リソース、ミドルウェア等のバージョンアップ・メンテナンスだけでなく、全ての機能のコアとなるコンポーネントの開発・運用も担当していた
  • 次のような要因がこの状況を招いていた
    • プロジェクト制による機能開発や Sys-Infra が中心に開発したアプリケーションの存在で Sys-Infra の責務が曖昧
    • Chief Architect が導入する新基盤の受け入れ先が Sys-Infra に集中

組織体制の変更

  • 紆余曲折あって開発組織の体制が変わり、Repro Core という新しいチームが誕生し、プロジェクト制も廃止された
  • Repro Core 誕生を機に、各チームの役割や担当コンポーネントを整理した

Repro Core におけるオンコール体制の構築

  • 既存のコンポーネントのうち、Repro Core の担当コンポーネントのアラートの向き先を変えるために、まずは PagerDuty サービスの作成単位の方針を決定した
  • 最初のうちは今までオンコールに入っていた他チームのメンバーとオンコールに入るようにしたり、不慣れなメンバーは日中時間帯にプライマリーになって場数をこなしてもらったりするようにした
  • Repro Core は業務内容的にまだまだ人が足りない!

Repro Core と Sys-Infra の棲み分け

発表でサラッと言及して質問もいただいたのでもう少し詳しく説明します。

まず、Repro Core のミッションや責務は今のところ発足当時から次のように定めています。

  • ミッション
    • Repro の機能を提供する上で中核となる基盤を提供し、顧客向けの機能を開発するチーム(以降 Feature Team)が顧客価値の提供に集中できる組織を作る
  • 責務
    • SDK が収集したデータを様々なコンポーネントから利用できるように各種データストアに保存する
    • Feature Team が機能開発する際に利用する共通基盤・ライブラリの開発
    • 上記共通基盤・ライブラリを使った開発の支援
    • Feature Team の設計相談

また、発表でも言及したように、Repro Core と Sys-Infra の担当コンポーネントを決める際に次のような基準を定めました。

  • Repro Core
    • 顧客に直接的な価値は届けないが、Feature Team が顧客に価値を届ける上で必要となる共通基盤を提供する
  • Sys-Infra
    • 極端な話 Repro 固有の知識がなくても成り立つ基盤の提供

Sys-Infra の役割の具体例として、Kafka クラスタの構築・運用が挙げられます。Repro のワークロードの特性やプロビジョニングの作法などを理解する必要はあるでしょうが、基本的に Kafka を運用する上で Kafka に入っているデータの各フィールドの仕様などまでは理解する必要はありません。また、基盤には Feature Team の生産性を上げるものも含まれます。例えば専門知識のないエンジニアでもコンポーネントに必要なリソースを手軽に用意できるように仕組みを整備するとかですね。 以上から、Sys-Infra はチームトポロジーではプラットフォームチームに相当すると考えています。

では Repro Core はどうかというと、Repro Core もプラットフォームチームです。Sys-Infra との違いは Repro 固有の知識が必要なことです。例えば、SDK 等からデータが送られてくる度に、エンドユーザがアプリ内メッセージなどの配信対象条件を満たすかどうかを計算して、その結果を各種コンポーネントから参照できるすることが Repro Core の責務の一つとして挙げられます。これを実現するためには、ユーザ同定の仕様や配信対象条件の仕様等、Repro 固有の知識に対する深い理解が不可欠です。一方で、このような仕組みを構築しても、利用するコンポーネントがなければ顧客に価値を届けることができません。コンプリケイテッド・サブシステムチームとも捉えることができるかもしれませんが、単機能に特化せず、複数機能から利用可能な基盤を提供することから、プラットフォームチーム要素の方が強いと考えます。

Repro Core と Sys-Infra の関係を表したのが次の図です。

Repro Core は時には Sys-Infra が提供する基盤の上に Feature Team 向けの基盤を提供し、Feature Team は Sys-Infra が提供する基盤を直接利用したり、Repro Core が提供する基盤を利用したりします。現時点ではドキュメントが不十分だったり、Feature Team にとって基盤の内部を意識しなければならない部分もあったりして密に連携しなければならない状況ですが、最終的に X-as-a Service のコミュニケーションスタイルになることが理想です。

Repro Core でオンコール体制を構築するまで

新しい PagerDuty サービスの導入やオンコールへの入り方については発表で触れたので、それ以外の内容について説明します。

チームビルディングの実施

Repro Core のメンバーはほぼ全てのメンバーが元々異なるチームで活動していたということもあり、オンコール体制を構築する前にチームビルディングから始める必要がありました。 具体的には次のようなことを実施しました。

  • キックオフミーティングの開催
  • Working Agreement の整備
  • 会議体の整理
  • スキルマップの作成

キックオフミーティングの開催

キックオフミーティングでは、チームビルディングをとおして次の状態の達成を目指すことを説明しました。

  1. チームとしての方向性について全員が理解している
  2. お互いに対する期待値(役割)を理解している
  3. 普段どう行動すべきか・どう目標を達成するか全員が理解している
  4. お互いの特性(性格・価値観等)を理解している

上記の状態を達成するため、ミーティング内では次のようなことを行いました。

  1. アイスブレイク(ソーシャルスタイル診断)
    • お互いの特性(性格・価値観等)をちょっと理解できている状態にする
    • より深い内容は別途実施
  2. チームの責務・半年で達成することについての確認
    • チームとしての方向性について全員が理解している状態にする
    • どう目標を達成するか(どのようなタスクがあるか)全員が理解している状態にする
  3. 自分が想定している役割・相手に期待する役割の確認
    • お互いに対する期待値(役割)を理解している状態にする

なお、「自分が想定している役割・相手に期待する役割の確認」は自分がチーム内で担うべきと考えている役割と、他のメンバーに求める役割を書き出してもらいましたが、後に「ドラッカー風エクササイズ」というものがあることを知ったので、次回はこれに倣って実施しようと思います。

Working Agreement の整備

普段どう行動すべきかを理解している状態を作るための活動で、キックオフミーティングで足りていなかった部分です。ある人にとっての「普通」は他の人にとって普通のこととは限りません。例えば、プルリクエストのレビュアーに指定されたらどのタスクよりも優先してレビューする人にとって、プルリクエストを出してから数日経ってようやくレビューされるのは遅いと感じるかもしれないし、プルリクエストを出してからレビューに 1 週間程度かかることが常態化しているチームで活動していた人にとって、数日以内にレビューすることは十分速いと感じるかもしれません。こういった期待値のズレを調整するのが Working Agreement です。

Working Agreement には例えば次のような内容を含めました。

  • プルリクエストのルールについて
    • 基本的にレビュアーは 2 人指定し、レビュアーに指定した人全員から approve をもらう
    • 1 営業日以内には一旦何かしらのコメントを返す(「明日見ます」等も可)
  • Slack のメンション等について
    • 通常の業務時間外、休暇中に Slack などでメンションがあってもメンションされた人は反応しなくて良いし、メンションした人はすぐの反応を求めるべきではない
    • 緊急で連絡を取らなければならない場合は電話で連絡をする
    • すぐの反応を求めないのであれば時間帯を気にせずメンションしても良いし、メンションされた人は次に稼働を開始した時に反応すれば良い

会議体の整理

各組織何かしらの定例があると思いますが、Repro Core では次の定例を設定しました。

  • Sync-Up ミーティング
    • 週 2 回、タスクの進捗共有とチームへの情報共有・相談を同期的に行う時間
  • 週次定例
    • 目標の達成状況の確認、振り返り、プランニング等
  • 合同コードリーディング
    • チームのメンバーが 1 週間で作成したプルリクエストやレビューしたプルリクエストを確認して、気になったプルリクエストについて深掘りする会
    • 口頭で直接疑問を解消できる場の提供、知見共有、直近あった変更に関する背景と実現方法についての理解を深めることなどが目的
  • 読書会
    • 足りていない知識のキャッチアップが目的
    • 約 10 ヶ月で「データ指向アプリケーションデザイン」と「ソフトウェアアーキテクチャ・ハードパーツ」を読了
    • 毎週最初の 1 時間黙々と本を読んで、残り 30 分でディスカッションする形式

なお、現在読書会は行っていませんが、状況に応じて復活する可能性もあります。

スキルマップの作成

スキルマップ作成の目的は次の 2 点です。

  • Repro Core に求められるスキルを明確にすることで、タスクのアサインや個人の方向性に役立てる
  • チームメンバー全員が全てのスキルを最上位まで上げなくても大丈夫な状態にする
    • 属人化や個人の負担増加を避けつつ、チーム全体でスキルをカバーできるようにする

スキルと言うと、Ruby、Go のようなプログラミング言語の知識だったり、Amazon EC2、ECS のような AWS サービスだったりを連想するかもしれませんが、Repro Core の各担当コンポーネントを 1 つのスキルと定義しました。よって、各コンポーネントで使われるプログラミング言語AWS サービスはそのコンポーネントのスキルに包含されます。

スキルマップではスキルのレベルを 0 〜 3 の 4 段階用意し、次のような前提を置きました。

  • 評価には使わない
  • 優劣を可視化するものではない
  • 全てのスキルについて、レベル 2 以上の人がチーム内に 3 人以上いる状態を目指す
  • ソフトスキルは扱わず、各コンポーネントを開発・保守・運用できるかを主眼と置く

例えばレベル 0、レベル 1 の定義は次のように定めています。

  • レベル 0
    • 存在すら知らない or 存在は知っているが、どんな機能かやどこで使われているかほとんど知らない
    • レビュアーとして入る場合はキャッチアップ目的で入り、レビュアーにはカウントしない
  • レベル 1
    • 期待される状態
      • どんな機能であるかある程度理解している
      • どこで使われているかある程度理解している
      • その機能が全く使えなくなった時の影響について言語化できる
      • デプロイ方法を知っている
    • 開発における立ち位置
      • 基本的にレビュアーとしてカウントされるが、本番に影響のある変更で全レビュアーがレベル 1 になるのは避ける
      • 一人で業務時間外にオンコールになることは避ける

コンポーネントのキャッチアップ

細かな技術についてのキャッチアップは普段の業務をとおして各自随時行いますが、チーム全体の取り組みとして次のようなことを行いました。

staging 環境での障害対応訓練は 2 つのコンポーネントに対して実施しました。これらのコンポーネントは Datadog のモニターがアラート状態になると Slack と PagerDuty に通知されるようになっており、Datadog からのメッセージには Datadog のダッシュボードへのリンクと運用手順書へのリンクが含まれています。そのため、staging でそれらのアラートが鳴るような負荷をかけたり、アプリケーションコードに細工を入れたりした上で、実際に通知を受け取り、メッセージの内容に従って対応してもらいました。

Slack Message from Datadog

アラートが鳴るようにするために、具体的に次のようなことを行いました。

  • 特定のユーザから大量のデータを送られることをエミュレートしたスクリプトを実行して処理を詰まらせる
    • 稀によくあるケース
  • stress コマンドで特定のインスタンスにだけ負荷を与える
  • dokcer update --cpus で特定のコンテナで利用できる CPU リソースを制限してスループットを低下させる
    • 試しに ECS service を再起動すれば問題が解消する状況を作る
  • アプリケーションコードの途中で短い sleep を実行してスループットを低下させる
    • アプリケーションコードのどこがボトルネックになっているか確認する必要がある状況を作る

座学の中でダッシュボードを見たりコマンドの説明を聞いたりするのと、実際にアラートを受け取って作業するのとでは習熟度に雲泥の差が出るので、特に重要なコンポーネントについては実際にオンコールに入る前にこのような時間を作ると良いでしょう。

アラートの向き先変更

アラートの向き先変更は基本的に粛々とやるだけですが、いくつか役に立ちそうなテクニックをご紹介します。

PagerDuty の Integration Key の一覧の取得

PagerDuty と他のサービスを連携させるには PagerDuty サービスに integration を追加して、integration key をそれらのサービスで指定する必要があります。サービスに integration key 登録する際はあまり問題にならないかもしれませんが、現在登録されている integration key がどのサービスに紐付いているか調べようと思うと骨が折れます。 そこで、次のようなスクリプトを作成して、PagerDuty に登録されている integration key の PagerDuty サービス名、名前などを出力できるようにしました。

require "json"
require "net/http"

API_TOKEN = ENV.fetch("PAGERDUTY_API_KEY") do
  raise "PAGERDUTY_API_KEY is required. Please specify an account or user API token."
end
HEADERS = {
  "Accept" => "application/vnd.pagerduty+json;version=2",
  "Authorization" => "Token token=#{API_TOKEN}",
  "Content-Type" => "application/json",
}
PAGERDUTY_URI = URI("https://api.pagerduty.com")

integration_queue = Queue.new
Net::HTTP.start(PAGERDUTY_URI.hostname, PAGERDUTY_URI.port, use_ssl: true) do |http|
  0.step do |i|
    resp = JSON.parse(http.get("/services?limit=100&offset=#{100 * i}", HEADERS).body)
    resp["services"].each do |service|
      service["integrations"].each do |integration|
        integration_queue << [service["id"], integration["id"]]
      end
    end

    break unless resp["more"]
  end
  integration_queue.close
end

integrations = []
Array.new(5) do
  Thread.new do
    Net::HTTP.start(PAGERDUTY_URI.hostname, PAGERDUTY_URI.port, use_ssl: true) do |http|
      loop do
        service_id, integration_id = integration_queue.pop
        break if service_id.nil?

        resp = JSON.parse(http.get("/services/#{service_id}/integrations/#{integration_id}", HEADERS).body)
        integration = resp["integration"]
        integrations << {
          service_name: integration.dig("service", "summary"),
          integration_name: integration["summary"],
          integration_key: integration["integration_key"],
        }
      end
    end
  end
end.each(&:join)

puts JSON.pretty_generate(integrations)

変数を用いた Datadog モニターの向き先変更

例えば、次のような変数と 2 つのリソースを導入することで、共通の CPU モニターと専用クラスタの CPU モニターを分けることができます。

variable "services" {
  type = map(object({
    clusters    = list(string)
    on_alert    = string
    on_recovery = string
  }))
}

locals {
  dedicated_clusters = flatten(values(var.services).*.clusters)
}

resource "datadog_monitor" "cpu_utilization_common" {
  name = "[{{clustername.name}}] CPU utilization is greater than {{threshold}} ({{value}}) on {{host.name}}"
  type = "metric alert"

  query = "avg(last_5m):100 - min:system.cpu.idle{clustername NOT IN (${join(",", local.dedicated_clusters)})} by {clustername,host} > 90.0"

  message = <<-EOF
    {{#is_alert}} @pagerduty-Sys-Infra {{/is_alert}}
    {{#is_recovery}} @pagerduty-resolve {{/is_recovery}}
  EOF
}

resource "datadog_monitor" "cpu_utilization_dedicated" {
  for_each = var.services

  name = "[{{clustername.name}}] CPU utilization is greater than {{threshold}} ({{value}}) on {{host.name}}"
  type = "metric alert"

  query = "avg(last_5m):100 - min:system.cpu.idle{clustername IN (${join(",", each.value.clusters)})} by {clustername,host} > 90.0"

  message = <<-EOF
    {{#is_alert}} ${each.value.on_alert} {{/is_alert}}
    {{#is_recovery}} ${each.value.on_recovery} {{/is_recovery}}
  EOF
}

分岐を用いた Datadog モニターの向き先変更

次の例では、クエリの grouping by tag に consumer_group を指定していることから、consumer_group で連携先を分岐させています。

resource "datadog_monitor" "consumer_lag" {
  name = "[{{consumer_group.name}}] consumer lag is high"
  type = "metric alert"

  query = "min(last_10m):max:kafka.consumer_lag{*} by {consumer_group} > 1000"

  message = <<-EOF
    {{#is_alert}}
      {{#is_match "consumer_group.name" "xxxxx" }}
        @pagerduty-Push-Service
      {{else}}
        @pagerduty-Repro-Core-Service-1
      {{/is_match}}
    {{/is_alert}}
  EOF
}

Datadog モニターのメンション先のチェック

Datadog モニターで PagerDuty や Slack と連携しようとすると、@pagerduty-@slack- で始まるメンションをメッセージに含める必要があります。ところが、terraform のように直接手入力すると、Datadog と連携されていない PagerDuty サービスや Slack チャンネルを指定してしまうことがあります。 それを防ぐために、次のようなスクリプトを CI で実行するにして、無効なメンションが指定されていると CI がコケるようにしました。

require "datadog_api_client"

class MentionChecker
  def check(mention)
    service = extract_service(mention)
    case service
    when "slack"
      has_slack_mention?(mention)
    when "pagerduty"
      has_pagerduty_mention?(mention)
    else
      raise "Unsupported service: #{service}"
    end
  end

  def extract_service(mention)
    mention.split("-").first.delete_prefix("@")
  end

  private

  def has_pagerduty_mention?(mention)
    pagerduty_mentions[mention]
  end

  def pagerduty_mentions
    @pagerduty_mentions ||= Hash.new do |h, k|
      unless k == "@pagerduty-resolve"
        DatadogAPIClient::V1::PagerDutyIntegrationAPI.new.get_pager_duty_integration_service(k.split("-", 2).last)
      end
      h[k] = true
    rescue DatadogAPIClient::APIError => e
      raise unless e.code == 404

      h[k] = false
    end
  end

  def has_slack_mention?(mention)
    !!slack_mentions[mention]
  end

  def slack_mentions
    return @slack_mentions if @slack_mentions

    mentions = {}
    api_instance = DatadogAPIClient::V1::SlackIntegrationAPI.new
    api_instance.get_slack_integration_channels("Main_Account").each do |channel|
      mentions["@slack-#{channel.name.delete_prefix("#")}"] = true
    end
    api_instance.get_slack_integration_channels("Repro").each do |channel|
      mentions["@slack-Repro-#{channel.name.delete_prefix("#")}"] = true
    end

    @slack_mentions = mentions
  end
end

DatadogAPIClient.configure do |config|
  config.api_key = ENV.fetch("DATADOG_API_KEY")
  config.application_key = ENV.fetch("DATADOG_APP_KEY")
end

mentions = []
Dir[File.join(__dir__, "..", "**/*.tf")].each do |f|
  mentions |= File.foreach(f).flat_map do |line|
    line.scan(/(@(?:slack|pagerduty)-[-\w]+)/).flatten
  end
end

queue = Queue.new
mentions.each { |m| queue << m }
queue.close

checker = MentionChecker.new
mutex = Mutex.new
unknown_mention_found = false
Array.new(4) do
  Thread.new do
    loop do
      mention = queue.pop
      break unless mention

      unless checker.check(mention)
        unknown_mention_found = true
        mutex.synchronize do
          $stderr.puts "Unknown mention #{mention}. Please make sure the integration is registered on https://app.datadoghq.com/integrations/#{checker.extract_service(mention)}."
        end
      end
    end
  end
end.each(&:join)

if unknown_mention_found
  exit(1)
end

Rollbar の連携先の変更

Repro ではアプリケーションアラートの管理に Rollbar というサービスを利用しています。Rollbar の連携先の変更についてはスライドのおまけ部分に載せているのでそちらを参照してください。

今回は Rollbar の 1 つのプロジェクトで細かく通知先を分けましたが、プロジェクト自体を分けるのもありだと思います。

最後に

以上、Repro Core 新設からオンコール体制を構築するまでの道のりについてご紹介しました。まだ設立から 1 年ちょっとで、これまでチームの立ち上げをメインに進めてきましたが、来月には自分たちの理想の姿を見直し、新しいフェーズに入ろうとしています。 そんな Repro Core に興味を持ってくれた方は是非僕とカジュアル面談をしましょう。次のページからのご応募お待ちしております!

Systems Architect | サービスのコアを支えるハイトラフィックなストリーミングデータパイプラインを開発・運用したいエンジニア募集 - Repro株式会社