Equal SRE On-call 24h Rotation

Background

自分の所属している Cookpad UK Global SRE チームでは、Primary On-call を一人あたり 24h 体制で回している。

つい先日までは、だいたい 4 人から 5 人体制で回していた。ロテーションは純粋なラウンドロビン形式。言い換えると、5 日に一回担当日が回ってくる。

最近の採用の成果や社内のチーム再編に伴って、半年前から新たに 3 人のメンバーを Onboarding してきたが、つい先日全員とも無事 Onboarding を終了し、正式に Primary On-call を担当することとなった。その結果、チームメンバーが、合計で 7 人となった。

そこで問題となったのが、どのような体制でロテーションを組むか、という点だ。今までのように純粋な「24h・ラウンドロビン形式」で回すとすると、誰かが毎週土曜日と日曜日を決まって担当するようになり、不公平が生じてしまう。

Alternatives

そこで、幾つか案が出てきた。

  • (A) 平日と週末のロテーションを分ける
  • (B) 一回あたり 24h ロテーションを 18h または 36h ロテーションにする

(A) の選択肢は妥当に見えるが、実際に試してみると問題があることがわかった。平日のロテーションと週末のロテーションを回した場合、特定のタイミングで特定のメンバーが、「金・土」または「日・月」で連続してロテーションの担当に当たることになったのである。

(B) の選択肢も妥当に見えたが、自分が担当になる日によって午後が開くのか午前が開くのか予測が付きづらく、日常生活との整合性が取りづらい(例:子供の送り迎え、買い物、家族との予定調整)ことも判明した。

Requirements

そこで要件を整理してみると、いわゆる "平等な" ロテーションというのは、以下の要件を満たす場合であることがわかってきた。

  • 週末のロテーションが、中長期的にみたとき、均等分散される
    • 予測が難しいので、祝日はその限りではない(特にタイムゾーンによって祝日が異なる)
  • 二日間連続で 24h * 2d のロテーションが組まれることは避けたい
  • 一回あたり 24h 以上のロテーションをなるべく避けたいメンバーがいる
  • 可能な限りロテーションの予測可能性が高くあってほしい(中長期的な休暇予定を組みやすい)

Solution

考えた結果、提案したのが、「(A) 平日と週末のロテーションを分ける」の案の発展形であるのだが、「(A') 平日と週末のロテーションを "それぞれ別の順序付き配列で" 分ける」という案であった。

これであれば、要件の最後の「予測可能性」については若干疑念の余地が残るものの、他の要件を十分に満たし、考えつく中で最善の妥協策であることが結論付けられた。結果として、今の所この案を採用している。

Evaluation

(A)「平日と週末のロテーションを分ける」の案の場合、例えば平日・週末のろテーションをどちらも [1, 2, 3, 4, 5, 6, 7] (※ それぞれのインデックスは特定のメンバーを指す)でそれぞれラウンドロビンするイメージ。

以下はシュミレーション結果だが、ご覧の通り特定のメンバー(ここでは「金・土」で 3 のメンバー、「日・月」で 4 のメンバー)が2日連続で担当日を当てられることとなる。

==================================
10 weeks
Weekday rota: [1, 2, 3, 4, 5, 6, 7]
Weekend rota: [1, 2, 3, 4, 5, 6, 7]
==================================
Mon Tue Wed Thu Fri Sat Sun
[1, 2, 3, 4, 5, 1, 2]
[6, 7, 1, 2, 3, 3, 4] <-- Fri/Sat in row!
[4, 5, 6, 7, 1, 5, 6] <-- Sun/Mon in row!
[2, 3, 4, 5, 6, 7, 1]
[7, 1, 2, 3, 4, 2, 3]
[5, 6, 7, 1, 2, 4, 5]
[3, 4, 5, 6, 7, 6, 7]
[1, 2, 3, 4, 5, 1, 2]
[6, 7, 1, 2, 3, 3, 4] <-- Fri/Sat in row!
[4, 5, 6, 7, 1, 5, 6] <-- Sun/Mon in row!
==================================

一方、(A')「平日と週末のロテーションを "それぞれ別の順序付き配列で" 分ける」という案の場合、例えば平日のロテーションを [1, 2, 3, 4, 5, 6, 7] という順番で回し、週末のロテーションを [1, 7, 6, 5, 4, 3, 2] という順番で回すということである。

実際のロテーションのシュミレーションとしては、以下のようになる。

==================================
30 weeks
Weekday rota: [1, 2, 3, 4, 5, 6, 7]
Weekend rota: [1, 7, 6, 5, 4, 3, 2]
==================================
Mon Tue Wed Thu Fri Sat Sun
[1, 2, 3, 4, 5, 1, 7]
[6, 7, 1, 2, 3, 6, 5]
[4, 5, 6, 7, 1, 4, 3]
[2, 3, 4, 5, 6, 2, 1]
[7, 1, 2, 3, 4, 7, 6]
[5, 6, 7, 1, 2, 5, 4]
[3, 4, 5, 6, 7, 3, 2]
[1, 2, 3, 4, 5, 1, 7]
[6, 7, 1, 2, 3, 6, 5]
[4, 5, 6, 7, 1, 4, 3]
[2, 3, 4, 5, 6, 2, 1]
[7, 1, 2, 3, 4, 7, 6]
[5, 6, 7, 1, 2, 5, 4]
[3, 4, 5, 6, 7, 3, 2]
[1, 2, 3, 4, 5, 1, 7]
[6, 7, 1, 2, 3, 6, 5]
[4, 5, 6, 7, 1, 4, 3]
[2, 3, 4, 5, 6, 2, 1]
[7, 1, 2, 3, 4, 7, 6]
[5, 6, 7, 1, 2, 5, 4]
[3, 4, 5, 6, 7, 3, 2]
[1, 2, 3, 4, 5, 1, 7]
[6, 7, 1, 2, 3, 6, 5]
[4, 5, 6, 7, 1, 4, 3]
[2, 3, 4, 5, 6, 2, 1]
[7, 1, 2, 3, 4, 7, 6]
[5, 6, 7, 1, 2, 5, 4]
[3, 4, 5, 6, 7, 3, 2]
[1, 2, 3, 4, 5, 1, 7]
[6, 7, 1, 2, 3, 6, 5]

NOTE: 上記に利用した簡易シュミレーションコードはこちら link

Implementation

自分たちは PagerDuty を On-call SaaS として利用し、スケジュールを terraform で管理している。その場合、上記要件を実装するとなると以下のようになる。

Layer を用いて平日・週末それぞれのロテーションを表現し、平日のロテーションは単純に任意の順序付き配列、週末のロテーションはそれを利用し distinct(concat([local.user_ids[0]], reverse(local.user_ids))) とすることで、上記シュミレーションと同じ結果を実装している。

locals {
  user_ids = [
    data.pagerduty_user.member1.id, data.pagerduty_user.member2.id, data.pagerduty_user.member3.id, data.pagerduty_user.member4.id, data.pagerduty_user.member5.id, data.pagerduty_user.member6.id, data.pagerduty_user.member7.id,
  ]
}

resource "pagerduty_schedule" "primary" {
  layer {
    name = (length(local.user_ids) % 7 == 0) ? "Layer 1 - Weekdays" : "Layer 1"
    restriction {
      type             = "weekly_restriction"
      duration_seconds = (length(local.user_ids) % 7 == 0) ? 432000 : 604800
    }
    users = local.user_ids
  }
  layer {
    name = (length(local.user_ids) % 7 == 0) ? "Layer 1 - Weekends" : "Layer 1 - Weekends [Inactive]"
    restriction {
      type             = "weekly_restriction"
      duration_seconds = 172800
    }
    users = (length(local.user_ids) % 7 == 0) ? distinct(concat([local.user_ids[0]], reverse(local.user_ids))) : []
  }
}
2021-06-26