PHPerKaigi 2022 やっぱオフライン楽しいね

この記事は個人ブログの転機です

PHPerKaigi 2022 やっぱオフライン楽しいね - メガネの日記

はじめに

今年も無事にPHPerKaigi 2022 開催できたこと、本当にありがとうございます!
なんと今年はオフラインと、オンラインのハイブリット開催でした!

今回も PHPerKaigi 2021 に引き続き、コアスタッフとして参加させていただきました

f:id:akki_megane:20220413144423j:plain

オフライン楽しいですね

そしてオフラインはやっぱり楽しいですね!

久しぶりに合った方と近況を雑談したり、
初めて参加くれている方とも話せてとても楽しいものです

ベテラン勢がいつもの感じで、フリースペースで雑談してたり、
みんな血眼になってトークンを探しているを見ると、いつもの感じだなーとほっとしました

↓PHPerの刻印押してドヤって自分です f:id:akki_megane:20220413162143p:plain

アンカンファレンス 今年も無限LTをしました

f:id:akki_megane:20220413164820j:plain

実は2019年から、毎年恒例で無限LTという狂気のLT回を実施しているんですが、
2022年の今年も、現地でアンカンファレンスを実施することができました!

開始当初は 参加表明してくれたのは5人だったのですが、 LTをしているうちに飛び入りで参加したいと手を上げてくれるひとが増え、最終的には10名の方がLTをしてくれました

無限LTにテーマの縛りありません、自分は最近発表された、Lambda Function URLs の話しを当日作って話ました 他にも、notion、kotlin、Dockerの壊し方、高専、などなど今年もバラエティー豊かでとても面白かったです!

LTしてくれたみなさん、LTを聞いてくれたみなさんありがとうございました!
ぜひ来年もやりたいので、みなさんお待ちしております

無限LTの説明
speakerdeck.com

感想

やっぱオフラインは最高でした、設営等の会場準備は大変でしたが、
久しぶりの再開や、新しい出会いなどオンラインならではの楽しさをたくさん味わえました

来年あるならぜひ、懇親会でお酒を飲みながら話したいなー

最後に宣伝

現在 back check 開発チームは一緒にはたらく仲間を募集中です!!

herp.careers herp.careers herp.careers herp.careers herp.careers herp.careers

ZaPASSコーチ養成講座特別セミナーに参加してみた

この記事は個人ブログと同じ内容です

ZaPASSコーチ養成講座特別セミナーに参加してみた


こんにちは、back check の開発メンバーのぐっきーです。

チームではスクラムマスターの役割を担当しています。 日頃どうしたらチームにアジャイルな考え方が身につくかを考える中で、自分が気づけることは少しずつでてきたのですが、そこにメンバーの共感を得て改善を促せるようになるにはまだまだ自分の力不足だと感じています。

そんな中で、社内の別の開発チームで長年スクラムマスターをやっている方は、チーム合同で行う輪読会や、スクラムマスター同士の共有会で、自分が持てていなかった視点について考え、気づきを得られるようなコミュニケーションをとってくれています。 そこで、その方に相談したところ、ZaPASS JAPAN 株式会社様が開催されるコーチングの「基本スキルとスタンス」とは?〜ZaPASSコーチ養成講座 特別セミナー〜をご紹介頂き、この度参加してきたので、そこで感じたことをまとめます。

zapass.co

コーチングとはなにか?

そもそもコーチングとはどんなことをするものなのか、という明確な定義はありません。

コーチングを認識するには、コーチの語源を理解するとイメージがしやすいかと思います。コーチは、馬車が最初にハンガリーのKocsという街で作られたことに由来して、馬車のことをcoachと呼ぶようになってできた言葉です。

つまり、コーチングの文脈ででてくるコーチは、馬車のように相手の方がたどり着きたいと思っているところにたどり着けるように一緒に関わっていく人のことを指しています。

改めて、コーチングをオーソドックスに定義をするのであれば、夢や目標実現のサポートのために、なんでも幅広くやることです。

学んだこと

このセミナーでは、1. インプット、2. インプット後に感じたことの話し合い、3. 共有を繰り返す方法で進められました。 その中で、インプットとしては主に以下の内容について触れられました。

  • なぜ現代社会でコーチングのニーズが高まっているのか
  • ティール組織について
  • SINIC 理論
  • コーチングの語源
  • コーチに求められるコンピテンシー
    • 対話力
  • コーチングの対話モデル
    • GROWモデル + H
  • 3大スキル
    • 聴く
    • 質問する
    • FB / 承認
  • ティールで重要な観点
    • パーパス
    • ホールネス
    • セルフマネージメント
  • コーチングはこうではない
    • 指示命令型ではない→問う
    • 延長線上ではない→Moon shotを目指す
    • 予定調和ではない→Jazz
    • 理路整然にではない→あるがままに
  • 4大傾聴スキル
    • うなずく
    • あいづち
    • ANGEL EYE
    • おうむ返し

特に印象的だった話

コーチとは、いままでの当たり前の先を共に探求していくパートナー

一昔前は、コーチングとはゴールに向かってどうしたら早く達成できるかの壁打ち相手でした。 現在は、ゴール達成のニーズはありつつ、より重要な動きとして、組織に所属するメンバーの人生そのものにフォーカスを当てたコーチングに会社がOKを出すように変わってきました。

なぜなら人生そのものにフォーカスした方が、結果として会社でのパフォーマンスに直結すると考えられているからであり、今後もよりますますその考え方は強くなっていきそうとのことでした。

その上で、現在は企業が組織として進化していく上で、ボトムアップ型でセルフマネジメントと自己実現を満たすことで、業務のパフォーマンスの向上を狙うような組織運営のナレッジが少ないことから、企業としてコーチと二人三脚で新しいスタイルを模索する企業がでてきているとのことでした。

ソフトウェア開発でもウォーターフォールからアジャイルに切り替えようとする企業が増えてきている中でアジャイルコーチやスクラムマスターという職種が出現している点でとても共感しました。

ZaPPAS では、このようにコーチングをスキル単体としてではなく、大きな視野にスコープを持って、社会に求められているコーチングとはどんなものなのかを捉えて学ぼうとしているとのことでした。

場の雰囲気

場の雰囲気もうひとつ、小寺毅 講師の場づくりがとても心地よかったことが強く印象に残っています。穏やかな喋り口調、自分のルーツや考え方(あるがまま)の開示、会話のテンポ、話者の言葉を受け止めて承認する聴き方、柔和な表情など、会話の中のひとつひとつの所作から自分も話していいんだという気持ちにさせてくれるような不思議な体験でした。 まるでひとつのコーチとしての理想像をみた気がしました。

twitter.com

おわりに

今回のセミナーを受けて、私自身含め、チームが自己管理型の組織として、目標達成のために進化するモチベーションを保ち続けられるようになるためには、その目標を達成することがメンバーひとりひとりがやりがいや自己実現など、人間としての欲求を満たすことにもつながると認識できる状態が必要だと感じました。 今後、メンバーが特に大切にしたいことを可能な範囲でチームで共有し、働くことで充実感を得られるような、上質なコラボレーションのできるチームをみんなで築いていきたいなと考えています。

さいごに少しだけ宣伝です。

現在 back check 開発チームは一緒にはたらく仲間を募集中です!! 私たちと一緒に、 back check を通して「信頼が価値を持ち、信頼によって報われる社会の実装」に挑戦してみませんか?

herp.careers herp.careers herp.careers herp.careers herp.careers herp.careers herp.careers

ポイント制&相対見積もりへの移行の経緯とプランニングツールを自作したお話

こんにちは、匠平@show60です。

back check 開発チームではプランニングポーカーを用いたタスクの見積もりを実施しています。

その作業を支えるべく、フルリモート勤務のチームでもオンラインでプランニングポーカーを行えるツール Okimochi Planning を作りました。冬休みの自由研究として (勝手に) 取り組んだのですが、チームで実際に約3ヶ月ほど運用し、改良を重ねて基本仕様としては問題ないものになったかと思っています。

今回は back check 開発チームが行ってきたタスク見積もりの経緯と、自作プランニングツールについて紹介させていただきます。

時間&絶対見積もりから、ポイント&相対見積もりへの移行

back check 開発チームがスクラム開発を導入した当時、チケットの見積もりはタスクの消化時間を単位として、絶対見積もりを行っていました。

この見積もり方法には2つの大きな問題があったのですが、チームのスクラム開発の成熟度向上とともに徐々ストーリーポイントを用いた相対見積もりへと移行していきました。

フィボナッチ数列を用いて不確実性を認める

大きいチケットの見積もりは不確実性が高くなります。小さいタスクが "1時間で終わるか2時間で終わるか" は見積もれるかもしれませんが、"6時間かかるか7時間かかるか" は不確実性が高すぎるため見積もりの難易度が上がります。

これを解消するため、「大きいものほど不確実が高い」を表すことができるフィボナッチ数列を見積もりに用いました。数列が "0, 1, 2, 3, 5, 8, 13 ..." という単位で上がっていくので、例えば「6時間かかりそうだな」と思った場合は、6は数列に含まれないので近接する 5 か 8 とする必要があります。

ja.wikipedia.org

フィボナッチ数列の導入により、不確実なものの見積もりは不確実であることをチームとして認めることができ、"6時間か7時間か" といった不毛な議論を防ぐことができます。

こうした非線形の数列も、見積もりのスケールにふさわしい。見積もりの対象が大きくなると不確実性が増えていくという様子にうまく対応しているからだ。

参考: アジャイルな見積りと計画づくり ~価値あるソフトウェアを育てる概念と技法 - P75より

相対見積もりを用いてスキル差をなくす

この時点での見積もりは、「このタスクは自分なら6時間かかる」といったような見積もりを行っていました。

個人のスキル差があるため、当然見積もりの結果にも差異が出るという問題があります。特に back check ではメンバーのマルチスタックな働きを推奨し、自分が得意でない分野のタスクにも取り掛かる機会があるためこの課題は顕著に現れていました。

この解消のために、相対見積もりを導入しました。あるタスクの見積もりを決め、それと比較したときのボリュームを見積もるという方法です。このときタスクの消化時間に換算しません。

例を挙げてみます。

【前提】
・タスクA: すでに完了しているタスク
・タスクB: これから見積もるタスクで、タスクAよりも実装ボリュームが大きい
・開発メンバー1: タスクAは2時間で実装可能。タスクBはタスクAより3倍程度の大きさだと考える。
・開発メンバー2: タスクAは4時間で実装可能。タスクBはタスクAより2倍程度の大きさだと考える。

【絶対見積もりの場合】
タスクBを見積もるとき、
開発メンバー1は3倍程度と考えるため、6時間と見積もった。
開発メンバー2は2倍程度と考えるため、8時間と見積もった。

【相対見積もりの場合】
開発チームとして、タスクAを1ptと据え置いて、他のタスクはタスクAと比較して見積もるものとする。
タスクBを見積もるとき、
開発メンバー1は3倍程度と考えるため、3ptと見積もった。
開発メンバー2は2倍程度と考えるため、2ptと見積もった。

最終的にチームの見積もりとして収束させる過程において、相対見積もりではタスクの大きさだけを考慮して議論すればいいのに対して、絶対見積もりでは個人のスキル差も考慮する必要があるため複雑です。場合によっては「開発メンバー1が実装することになるだろうから6時間にしよう」と個人に依存した結果にもなりかねません。

相対見積もりを用いることで、個人のスキル差を排除し、タスクの大きさだけに注目した見積もりを実施できるようになります。

また相対見積もりは絶対見積もりと比較して見積もりがしやすいという利点もあります。

人は10倍以内のものならうまく見積もれる、という研究がある。自分の住んでいる街のことなら、いろいろな場所への相対的な距離をうまく見積もれるはずだ。 [中略] もしこれが、月面までの距離や隣の国の首都までの距離だったら、はるかに不正確な見積もりしかできない。

参考: アジャイルな見積りと計画づくり ~価値あるソフトウェアを育てる概念と技法 - P74より

ここで重要なのは、相対見積もりのための基準を何にするかですが、back check 開発チームでは、過去に完了したタスクから適切なものをピックアップして基準チケットと据え置いています。新規参画メンバーには分かりにくいという課題も出てくるのですが、そのときは基準チケットを更新するなど柔軟に対応する姿勢を取っています。

自作プランニングポーカーツールの紹介

開発チームは基本フルリモートのため、オンラインでプランニングが可能なツールを探していました。

チームでいくつかのツールを試してみましたが、どれも痒いところにあと一歩手が届かないものばかりだったため、「なければ作れ」ということで作ってみました。

f:id:show-hei:20220331172259p:plain
okimochi planning

okimochi_planning

2022年始の冬休みの期間で作り、新年一回目のリファインメントから実際に利用を開始しました。

実際に運用してみると必要な機能が見えてきたので、ユーザー (チームメンバー) からのフィードバックを元に次のリファインメントまでに実装しておくという開発サイクルを続けました。運用開始から約1ヶ月ほどで現在の形に落ち着いています。

ファビコンが Nuxt のままだったり、デザインがシンプルすぎるなどせめてそこくらいは直してから出せよって感じなんですが、自分に "Second best tomorrow" と言い聞かせてこの場でシェアします。

基本的な使い方

  1. 「Create New Room」でプランニング用のルームを作成
  2. 作成されたルームの URL を共有し、メンバーがルームに入室できる
  3. カードを選択しポイントを見積もる
  4. 「Reveal」で全カードをオープンする
  5. 「Clear」で全カードをリセットする

ここがポイント🤘

  • Reveal されたカードはポイント数値昇順でソートしているのでカード数のカウントがしやすいです
  • 入室するが見積もりには参加しない人 (PO など) のために、Voter ボタンで切り替えることで閲覧モードにすることができます
  • Reveal されているときに入室すると、次の投票からしか参加できません (遅れずに投票しようね)

といった簡単な機能ではありますが、基本機能は備えているので、ぜひ試してみていただけると嬉しいです。

さいごに

私たち back check はリファレンスチェックを日本の採用のスタンダードにすべく日々奮闘中です。一緒に市場を盛り上げたいという仲間を募集しております。

herp.careers

herp.careers

その他のポジション もありますのでぜひこちらもご覧ください!

Udemyのデータサイエンティストコースを一通りやった

こちらは個人ブログの転載です

note.com

PMとしてデータを正確に把握することは非常に重要だと考えたときに、統計学的な有意性、ディープラーニングを用いたより正確なデータモデリングの作成において体系的な知識が必要になると感じ、Udemyを探していたところ 【世界で37万人が受講】データサイエンティストを目指すあなたへ〜データサイエンス25時間ブートキャンプ〜 という講座が95%OFFで販売されており、興味本位で購入してみた。 実際にやってみたら定価でも十分なクオリティーであり、自分のような非データサイエンティストでも理解しやすい内容だったので、感想を書いてみる

学習できる内容

この講座では下記が学習できると書いてある

  • データサイエンティストになるために必要な一連のツールについて学ぶことができます
  • 統計分析、NumpyやPandasなどを使ったPythonのプログラミング、高度な統計学上の手法、Tableaau、StatsModelとScikitLearnを使った機械学習の実装、TensorFlowを使ったディープラーニングの実装
  • データの前処理の方法
  • 機械学習の背景にある考え方
  • Pythonを使って統計上の分析をする方法
  • Pythonを使った線形回帰とロジスティック回帰分析
  • クラスター分析と因子分析
  • 実生活における実践問題を通じた深い理解
  • TensorFlowをはじめとした、ディープラーニングを進める上で必要とされるツール
  • 過学習・過少学習とその解決方法について
  • 訓練用データ、検証用データ、テストデータの概要と具体的な実装方法について
  • 最先端の機械学習アルゴリズム(Adamなど)の概要と実装方法について
  • 信頼区間や検定など、少し難易度が高い統計上の知識
  • 機械学習の全体像と、それぞれの用語の深い理解
  • 汎用性の高い実装方法について
  • p値やt値といった統計上の指標と回帰分析との関係について
  • バッチ処理の概要と実装方法

かんたんにサマリをすると、

  • データに対峙したときに、統計学上の有意な形でデータをサマライズできるようになる
  • 各種分析手法(回帰、分類など)をどういうときに適応すべきかわかるようになる
  • 統計学をもとに、各種フレームワークを用いた機械学習ディープラーニングの実装の方法がわかるようになる
  • 上記をPythonを使って実際にプログラミングして求める手法がわかるようになる

というところで、非学習者でも一個一個丁寧に教えてくれるので、講義が終わったときには、たんにツールを使えるという状態ではなく、なぜそのツールを使う必要があるのか、そのツールを使うとどういうものが求まるのかということが体系的に理解できるようになる。

特に印象的だったもの

このコース、全部で26hもあるそうで、当然全部を紹介しているととんでもない量になってしまう。特に印象的だったものを書いてみる。

  • p値やt値といった統計上の指標と回帰分析との関係について
    • さらっと書いてあるものの、前半の統計学の手法においては、このテーマがかなり中心的にあったように思う。p値やt値は仮設検定(何かしらの仮設が有意であるといえるかどうかの判定)において重要な数値を持たらす。この数値によって仮設の有意性が図れるということで、具体的な例をもとに検定を行うことで実際のビジネスのシーンでどうやって活用されるのかの片鱗が見えた。
  • 機械学習の背景にある考え方
    • 機械学習はインプットとアウトプットの間のブラックボックスをなすものであるという考え方がとても腑に落ちた。ここでこう書くだけだと当たり前のようにも思えるが、実装を通じて意識するべきは「適切な問い(=ほしい結果)」の設定と「適切な情報の提供(=前処理などを通じたデータの整理)」にのみ集中することで結果を得ることができるというのが結構衝撃だった。
    • 基本的なこの考え方と、ベースとなる知識を持っていれば、仮に今後何かしらの壁にぶつかっても有識者に適切な質問の投げかけができ、彼らの返答も理解できるようになるのではという実感を得られたので、機械学習を業務に取り入れる心理的ハードルが一気に下がった。
  • Pythonを使って統計上の分析をする方法
    • 今回はロジスティック回帰だけやり、他の分析手法は一旦飛ばしてしまった。一方でどういう分析がどういう意味を持つのかというのは理解できたので、今後必要に応じて選択できるようにインデックスは貼っておく

学習をする上で用いた環境

本講座ではAnacondaを用いた環境設定方法を紹介していた。一方で自分の環境はMacであり、Anacondaを使った経験もないことから、環境の差異が発生すると面倒であるという点、また今後の実務においての使いやすさを鑑みてDockerの環境を選定した。

docker のimageとしては jupyter/docker-stacksにあるイメージが非常に使い勝手が良かったので、こちらを用いている

また、エディターはVSCodeのRemote Containerで作業。カーネルの選択などもDockerイメージ内部のPythonを選択することで簡単に実行できるのがとても良い

{
        "name": "Tensorflow Env",
        "dockerComposeFile": [
                "../docker-compose.yml"
        ],
        "service": "tensorflow",
        "workspaceFolder": "/home/jovyan/work",
        "settings": {},
        "extensions": [
                "ms-toolsai.jupyter",
                "ms-python.python"
        ]
}
version: "3"

services:
  tensorflow:
    image: jupyter/tensorflow-notebook
    ports:
      - 10000:8888
    volumes:
      - ./src:/home/jovyan/work

上記設定だけで、あとはVSCodeのReopen in Containerを選択するだけで環境が立ち上がる。とても楽。

本講座はGoogle Drive上に講義に用いるソースコードやデータなどが保管されており、都度ダウンロードしてzip解凍する形式をとっていた。 zip解凍後のディレクトリをそのままVSCodeDnDすれば環境に展開できたので、これも開発者体験としてはとても楽なのでおすすめ

今後の活かし方

本講座ではあくまで与えられた数値をもとにモデル化し、評価するというところまでで収まっている。十分実案件でも使える知識は身についているとは思うものの、実際に自分でやろうとすると理解不足なところが浮き彫りになるので、その際にこの講座に立ち返り、復習していくことで知識を身に着けていこうと思う。 また、問の立て方は理解したものの、有意性が事前にわかっている問であることが前提であるため(講義である以上は仕方ない)、適切な問いを建てることが難しそうだなと思った。ここはいくつか実際のデータを活用することで感覚的に理解できるようになれればと思っている

back check スクラム開発体制における PjM の役割

こんにちは、匠平@show60です

昨年10月、ROXX の back check 事業部に開発エンジニアとして入社して3年が経つころにプロジェクトマネージャー (以下 PjM) へと肩書きが変わりました。

ここではスクラム開発を採用する back check 開発チームにおける PjM の役割について紹介したいと思います。

チームが直面してきた課題

2021年の春ころ、back check 開発チームはプロダクトバックログアイテム (以下 PBI) の不足という課題に直面していました。PBI がないとはつまり開発チームのお仕事がないことであり喫緊の課題でした。

主にプロダクトオーナー (PO) が中心となって顧客・他部署からのフィードバックの収集の見直し、リファインメントの効率化などを進めることで解消に向かっていったのですが、将来に渡って継続的に PBI を安定供給できる体制を整える必要があります。

PO、PjM の役割

一般的にイメージされる PjM のお仕事としては、プロジェクト範囲の定義・要件定義、予算・リソース・品質・リスクの管理、進捗管理・報告などがあります。

The roles and responsibilities of the Project Manager are as follows:

  • Define project scope
  • Gather requirements
  • Identify activities, dependencies, sequencing, and time estimates.
  • Identify resources needed
  • Manages the budget
  • Reports to business leadership on project progress
  • Focuses on process
  • Allocates tasks
  • Prioritizes features
  • Ensure quality
  • Manage vendors
  • Manages risk

参考: Project Manager vs Scrum Master vs Project Owner

固定のスクラムチームで自社プロダクトを運用する back check では、予算・リソース管理などは PO が持っており、品質・リスク・進捗に関することは開発チームが責任を持っています。

PO の役割についてはスクラムガイド (記述時点最新の2020年版) にその定義が存在します。

プロダクトオーナーは、効果的なプロダクトバックログ管理にも責任を持つ。たとえば、

  • プロダクトゴールを策定し、明⽰的に伝える。
  • プロダクトバックログアイテムを作成し、明確に伝える。
  • プロダクトバックログアイテムを並び替える。
  • プロダクトバックログに透明性があり、⾒える化され、理解されるようにする。

参考: スクラム公式ガイド 2020年版

この説明に加えて、「上記の作業は、プロダクトオーナーが⾏うこともできるが、他の⼈に委任することもできる。いずれの場合も、最終的な責任はプロダクトオーナーが持つ。」とあります。back check における PjM はまさにこの部分を担っており、これら PO の仕事の一部を委任される形で PBI の作成を行っています。

現在は主に CS や顧客からの要望・フィードバックから課題を見つけ出し、その課題に対するアプローチを考えて要件定義するまでの役割を担っています。作成された解決策は PO との確認を経て、プロダクトバックログは PO の責任下から外れることなく PBI として開発チームへ届けられます。

チーム分割の布石として

PjM という役割の採用はチームが直面していた PBI 不足の解消という短期的な目的だけでなく、将来的な開発チームの構成に向けた中長期な目的のための一ステップでもあります。

back check 開発チームはこの1年でスクラムガイドで推奨される10名を越えるまでにメンバー拡充してきました。1枚岩のスクラムチームでは小回りが効きづらく、すでに身動きが取りづらいところも散見されておりチームの分割の検討が開始されています。

チーム分割は、人数規模の問題を解決するためだけではなく、技術的・ビジネス的にも価値のある必要があります。

分割案のチェックリスト

  1. ミッションがあるか
  2. そのミッションはチームに限定合理的な意思決定をもたらす重力を生み出さないか
  3. 定常的に仕事が存在するか
  4. アーキテクチャ、ビジネス境界、チーム境界が一致しているか
  5. 分割後のチームのチームリーダーを任せられる人物はいるか
  6. 分割後、メンバー増加に十分耐えうるか

texta.pixta.jptexta.pixta.jp

各チームにはスクラムマスターと開発メンバーが存在し、PO が各チームに開発してもらいたい PBI を割り振るようなチーム分割をイメージしています。その際に PjM は自分が要件定義を担当した PBI の受け渡しの説明だけでなく、その後のメンバー間との対話を引き続き担うことも可能です。

PO とのコミュニケーションで齟齬があると致命的な問題になってしまうため、普段のコミュニケーションや信頼関係の構築が重要です。また当然他部署との連携も必要なので、全方面において良い関係を構築していく必要があります。

まとめ

ご紹介させていただいたように back check 開発チームの PjM は、CS や顧客からの要望・フィードバックからの課題発見から、解決方法の提案、要件定義までを主に行っています。 ユーザーにとっての負の解消だけでなく、その期待を超えるようなアイデアを考えて提案することもできます。

私がこれまで開発メンバーとして関わってきたころと比べ、ユーザーへの価値を決める初期のステップに携わることが多いため、実際にユーザーに届いて使ってもらえるときの喜びをより一層感じることができています。

当然それだけの責任も伴いますし実際に使われるまでは不安も大きいのですが、チームメンバーはその実現のための技術的な相談や必要なサポートをしてくれます。みんなで良いものを作ることにとことん向き合えることにとてもやりがいを感じています。

最後に

そんな私たち back check チームに興味をお持ちいただける方、リファレンスチェックという新しい市場のスタンダードを一緒に作っていきたいという仲間を絶賛募集しています。 ご紹介させてもらった PjM のポジションだけでなく、開発ポジション、デザイナーも募集していますので

herp.careers

herp.careers

herp.careers

MySQL 8.0 パーティショニングを理解する

この記事は個人ブログと同じ内容です

www.ritolab.com


パーティショニングは MySQL 5.1 から利用できますが、パーティショニングとは何者なのか。MySQL 8.0 でパーティショニングを理解していきます。

パーティショニング

パーティショニングでは、指定したルールに従いテーブルの各行をパーティション分割したファイルシステムに配分します。

これによって同じテーブルのレコードでもそれぞれ配分された箇所が内部的にはあたかも別のテーブルのように個別の場所に格納されます。

テーブルは 1 つで操作もこれまでと変わりませんが、ファイルシステムにおける内部的なデータの持ち方としてはパーティション分割されたそれぞれの場所に保存されていきます。

こうすることでパーティショニングすべき状況と合致する場合はデータの読み出しをはじめとした操作を効率的に行うことができ、クエリ実行時のパフォーマンスを向上させることができます。

dev.mysql.com

環境

MySQL 8.0 にて操作を行います。ストレージエンジンは InnoDB です。

MySQL 8.0 のパーティショニング方式

MySQL 8.0 でのパーティショニングは「水平パーティショニング」という方式が採用されています。これは、テーブルの各レコードを異なる物理パーティションに割り当てる方式です。

一方で、「垂直パーティショニング」と呼ばれる、カラムベースで異なる物理パーティションに割り当てる方式がありますが、これについては MySQL 8.0 ではサポートされていません。

対応ストレージエンジン

MySQL 8.0 でのパーティショニングついて対応しているストレージエンジンは InnoDB と NDB のみです。その他(MyISAM など)のストレージエンジンではパーティショニングを利用できません。

パーティションテーブル作成時の制約

テーブルのパーティション分割を行う際に、パーティションキーとして指定するすべてのカラムは、主キーを含めテーブルに含まれる可能性のあるすべての一意キーの一部である必要があります。

つまり、パーティション分割を行う際に指定するカラムは、そのテーブルにプライマリーキー、もしくはユニークキーを設定するのであればそれを構成するカラムである必要があります。(逆にこれらのキーを設定しない場合はその制約を受けません)

パーティショニングタイプ

MySQL 8.0 で利用可能なパーティショニングのタイプは、大きく分けて 4 つのタイプ +α があります。

RANGE パーティショニング

パーティショニング時に指定したカラムの範囲に含まれる値(整数)に基づいて行をパーティションに割り当てます。

  • ある数値を収録しているカラムの数値範囲(〜1000000、〜2000000, ... )
  • ある日付を収録しているカラムの年範囲(〜2022, 〜2023, ...)
CREATE TABLE `logs` (
    `logged_at` datetime NOT NULL,
    .,
    .,
    .
)
PARTITION BY RANGE (year(`logged_at`)) (
    PARTITION p2019 VALUES LESS THAN (2020),
    PARTITION p2020 VALUES LESS THAN (2021),
    PARTITION p2021 VALUES LESS THAN (2022),
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION pmax VALUES LESS THAN MAXVALUE
);

RANGE COLUMNS パーティショニング

DATE 型・DATETIME 型のカラムをパーティショニングキーとしてパーティション分割したい場合は RANGE COLUMNS パーティショニングを用います。

CREATE TABLE `employees` (
    `hired` date NOT NULL,
    .,
    .,
    .,
)
PARTITION BY RANGE  COLUMNS(hired) (
    PARTITION p202201 VALUES LESS THAN ('2022-02-01'),
    PARTITION p202202 VALUES LESS THAN ('2022-03-01'),
    PARTITION p202203 VALUES LESS THAN ('2022-04-01'),
    PARTITION p202204 VALUES LESS THAN ('2022-05-01'),
    PARTITION pmax VALUES LESS THAN (MAXVALUE)
);

LIST パーティショニング

パーティショニング時に指定したリストに基づいて行をパーティションに割り当てます。

  • あるグループを識別するための番号リストによるパーティション(A=3,5,6,9,17, B=1,2,10,11,19,20, ...)
CREATE TABLE `members` (
    `name` varchar(255) NOT NULL,
    `country_id` smallint unsigned NOT NULL
)
PARTITION BY LIST (`country_id`) (
    PARTITION pAfricaZone VALUES IN (247,213,244,256,20,251,291,233),
    PARTITION pAmericaZone VALUES IN (1,53,54,57,297,503,598,593),
    PARTITION pAsiaZone VALUES IN (66,81,94,357,850,886,963),
    PARTITION pEuropeZone VALUES IN (7,39,44,353,355,372,380,998),
    PARTITION pOceaniaZone VALUES IN (61,64,672,677,683,685,687,690)
);

LIST COLUMNS パーティショニング

文字列や DATE 型などのリストでパーティション分割したい場合は LIST COLUMNS パーティショニングを用います。

CREATE TABLE `residents` (
    `name` varchar(255) NOT NULL,
    `prefecture` varchar(255) NOT NULL
)
PARTITION BY LIST  COLUMNS(prefecture) (
    PARTITION pHokkaidoRegion VALUES IN ('北海道'),
    PARTITION pTohokuRegion VALUES IN ('青森県','岩手県','宮城県','秋田県','山形県','福島県'),
    PARTITION pkantoRegion VALUES IN ('東京都','茨城県','栃木県','群馬県','埼玉県','千葉県','神奈川県'),
    PARTITION pChubuRegion VALUES IN ('新潟県','富山県','石川県','福井県','山梨県','長野県','岐阜県','静岡県','愛知県'),
    PARTITION pKinkiRegion VALUES IN ('京都府','大阪府','三重県','滋賀県','兵庫県','奈良県','和歌山県'),
    PARTITION pChugokuRegion VALUES IN ('鳥取県','島根県','岡山県','広島県','山口県'),
    PARTITION pShikokuRegion VALUES IN ('徳島県','香川県','愛媛県','高知県'),
    PARTITION pKyushuRegion VALUES IN ('福岡県','佐賀県','長崎県','大分県','熊本県','宮崎県','鹿児島県','沖縄県')
);

HASH パーティショニング

指定したパーティション数に基づいて、行をパーティションに均等に割り当てます。

RANGE や LIST では集めたいものでパーティショニングしていましたが、HASH パーティショニングにおいては、指定値のハッシュに基づいて均等にパーティションに配分されていきます。

CREATE TABLE `diaries` (
    `create_date` date NOT NULL,
    `title` varchar(255) NOT NULL,
    `content` text NOT NULL
)
PARTITION BY HASH (to_days(`create_date`)) PARTITIONS 3;

指定するカラムの値、または式の返り値は整数である必要があります。(上記の例では DATE 型を整数に変換(to_days())した値を指定している)

例えばパーティションを 3 つに分割するとして、振り分け先パーティションの決定は以下のように行われます。

2022-03-01 -> MOD(TO_DAYS('2022-03-01'),3) -> 1
2022-03-02 -> MOD(TO_DAYS('2022-03-02'),3) -> 2
2022-03-03 -> MOD(TO_DAYS('2022-03-03'),3) -> 0
2022-03-04 -> MOD(TO_DAYS('2022-03-04'),3) -> 1
2022-03-05 -> MOD(TO_DAYS('2022-03-05'),3) -> 2
2022-03-06 -> MOD(TO_DAYS('2022-03-05'),6) -> 0
.
.
.

このように剰余計算で配分先パーティションを決定しているため、値が y=ax のような直線的、比例的に増加していくものであると配分が均等になり良いです。

LINEAR HASH パーティショニング

CREATE TABLE `diaries` (
    `create_date` date NOT NULL,
    `title` varchar(255) NOT NULL,
    `content` text NOT NULL
)
PARTITION BY LINEAR HASH (year(`create_date`)) PARTITIONS 3;

HASH パーティショニングとの違いは配分するパーティションを決定するための算出方法です。

HASH パーティショニングは剰余で算出していた一方で、LINEAR HASH パーティショニングではビット論理積を使ったアルゴリズムを利用するので計算が速いという利点があります。

// 配分先パーティションの決定
V = POWER(2, CEILING(LOG(2,3))) = 4 // パーティション数(=4)以上の 2 の累乗を算出
N = YEAR('2022-03-01') & (V(=4) - 1)
   = 2022 & 3
   = b'11111100110' & b'11'
   = 2 // パーティション p2 へ格納

2022 年のレコードであれば パーティション p2 へ配分されることを実際に insert して確認してみます。

INSERT INTO diaries (create_date, title, content) VALUES ('2022-03-01', 'test', 'test');
mysql> SELECT PARTITION_NAME,TABLE_ROWS FROM INFORMATION_SCHEMA.PARTITIONS WHERE TABLE_NAME = 'diaries';
+----------------+------------+
| PARTITION_NAME | TABLE_ROWS |
+----------------+------------+
| p0             |          0 |
| p1             |          0 |
| p2             |          1 |
+----------------+------------+

パーティション p2 へ配分されることを確認できました。

ちなみに計算上パーティション数以上の数値になってしまうことがあり、その場合は算出された値を元に更に処理が行われます。

// 算出された値がパーティション数以上の場合
V = POWER(2, CEILING(LOG(2,3))) = 4
N = YEAR('2023-03-01') & (V(=4) - 1)
   = 2023 & 3
   = b'11111100110' & b'11'
   = 3 // パーティション数以上であるのでこの値を元に更に計算
N' = N(=3) & (V(=4)/2 - 1)
   = 3 & 1
   = b'11111100111' & b'1'
   = 1 // パーティション p1 へ格納

2023 年のレコードであれば パーティション p1 へ配分されることを実際に insert して確認してみます。

INSERT INTO diaries (create_date, title, content) VALUES ('2023-03-01', 'test', 'test');
mysql> SELECT PARTITION_NAME,TABLE_ROWS FROM INFORMATION_SCHEMA.PARTITIONS WHERE TABLE_NAME = 'diaries';
+----------------+------------+
| PARTITION_NAME | TABLE_ROWS |
+----------------+------------+
| p0             |          0 |
| p1             |          1 |
| p2             |          1 |
+----------------+------------+

パーティション p1 へ配分されることを確認できました。

この再計算が行われることについては、V としてパーティション数以上の 2 の累乗を算出している関係上発生しています。

---
P = 3(パーティション数)
V = POWER(2, CEILING(LOG(2, P))) = 4
---

つまり、

  • P=2 なら V=2
  • P=3 なら V=4
  • P=4 なら V=4
  • P=5 なら V=8

といった具合になるので、パーティション数が 5 の場合には初回の計算時点では N=0-8 の値が算出されてくることになります。そして、値が 5 以上であれば再計算が走ります。(しかも 5-8 で再計算すると N=0-1 にしかならないので配分されるパーティションに偏りがでます)

ただしこれは回避可能で、それにはシンプルにパーティション数を 2 の累乗で設定すればパーティション数よりも大きな V にはならないため、再計算を回避でき、一度の計算で算出しきれるようになります。(P= 2|4|8|16|32|64|128|256|512|1024)

KEY パーティショニング

こちらも HASH パーティショニング同様、指定したパーティション数に基づいて、行をパーティションに均等に配分します。

HASH パーティショニングでは、判定する元となる値の算出をユーザー側で定義(DATE型を整数に変換したり)しましたが、KEY パーティショニングの場合は MySQL 側で提供されます。

パーティショニングキーを指定しない場合はプライマリキー、ユニークキーの順で存在するカラムがパーティショニングキーとして利用されますが、パーティショニングキー(カラム)を指定する場合も含めて、制約がいくつかあります。

  • プライマリキーがある場合は、その一部かすべてを構成しているカラムであること。
  • プライマリキーは無くてユニークキーがある場合はユニークキーがパーティショニングキーとして使われる。
    • この場合も同じく、カラムがユニークキーの一部かすべてを構成しているカラムであること。
    • ユニークキーの場合、NOT NULL 制約がついていること。
CREATE TABLE `players` (
    `id` bigint unsigned NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    PRIMARY KEY (`id`)
)
PARTITION BY KEY () PARTITIONS 10; -- id がパーティショニングキーとして利用される

サブパーティショニング(複合パーティショニング)

パーティション分割したものを更にパーティション分割するというものです。

RANGE または LIST でパーティション化されたテーブルに対して、 HASH もしくは KEY でのサブパーティショニングが可能です。

CREATE TABLE `logs` (
    `logged_at` datetime NOT NULL,
    .,
    .,
    .,
    .
)
PARTITION BY RANGE (year(`logged_at`))
    SUBPARTITION BY HASH (to_days(`logged_at`)) (
    PARTITION p2019 VALUES LESS THAN (2020) (
        SUBPARTITION s0 ENGINE = InnoDB,
        SUBPARTITION s1 ENGINE = InnoDB
    ),
    PARTITION p2020 VALUES LESS THAN (2021) (
        SUBPARTITION s2 ENGINE = InnoDB,
        SUBPARTITION s3 ENGINE = InnoDB
    ),
    PARTITION p2021 VALUES LESS THAN (2022) (
        SUBPARTITION s4 ENGINE = InnoDB,
        SUBPARTITION s5 ENGINE = InnoDB
    ),
    PARTITION p2022 VALUES LESS THAN (2023) (
        SUBPARTITION s6 ENGINE = InnoDB,
        SUBPARTITION s7 ENGINE = InnoDB
    ),
    PARTITION pmax VALUES LESS THAN MAXVALUE (
        SUBPARTITION s8 ENGINE = InnoDB,
        SUBPARTITION s9 ENGINE = InnoDB
    )
);

パーティショニングでのパフォーマンス向上を試す

パーティショニングを行い、パフォーマンスがどれくらい向上するのかをみてみます。

CREATE TABLE `logs` (
    `logged_date` datetime NOT NULL
)
PARTITION BY RANGE (year(`logged_date`)) (
    PARTITION p2020 VALUES LESS THAN (2021),
    PARTITION p2021 VALUES LESS THAN (2022),
    PARTITION p2022 VALUES LESS THAN (2023)
);

RANGE パーティショニングを採用しました。ここに約 2400 万件のレコードを挿入して、それぞれのパーティションに配分してあります。

+--------------------+
| count(logged_date) |
+--------------------+
|           24300030 |
+--------------------+
+----------------+------------+
| PARTITION_NAME | TABLE_ROWS |
+----------------+------------+
| p2020          |    8086082 |
| p2021          |    8086074 |
| p2022          |    8086026 |
+----------------+------------+

他に、パーティションなしと、パーティション無しだがインデックスありのパターンで 3 回試して比較してみます。

-- パーティションなし・インデックスなし
mysql> SELECT logged_date FROM logs WHERE logged_date >= '2022-01-01 00:00:00' AND logged_date <= '2022-12-31 23:59:59';
 -> 8100010 rows in set (15.18 sec)

-- パーティションなし・インデックスあり
mysql> SELECT logged_date FROM logs WHERE logged_date >= '2022-01-01 00:00:00' AND logged_date <= '2022-12-31 23:59:59';
 -> 8100010 rows in set (6.37 sec)

-- パーティションあり
mysql> SELECT logged_date FROM logs PARTITION (p2022);
 -> 8100010 rows in set (4.98 sec)

パーティションもインデックスも無いテーブルに比べて、インデックスだけ張ったテーブルでは約 2.4 倍早く、パーティションありだと約 3 倍早くなりました。

パーティション無くてもインデックス張っていればそれなりですが、パーティションあると更に早くなっています。

以下は実行計画です。

-- パーティションなし・インデックスなし
+----+-------------+-----------+------------+------+---------------+------+---------+------+----------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows     | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+----------+----------+-------------+
|  1 | SIMPLE      | logs      | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 23707015 |    11.11 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+----------+----------+-------------+

-- パーティションなし・インデックスあり
+----+-------------+----------+------------+-------+----------------------------+----------------------------+---------+------+----------+----------+--------------------------+
| id | select_type | table    | partitions | type  | possible_keys              | key                        | key_len | ref  | rows     | filtered | Extra                    |
+----+-------------+----------+------------+-------+----------------------------+----------------------------+---------+------+----------+----------+--------------------------+
|  1 | SIMPLE      | logs     | NULL       | range | our_logs_logged_date_index | our_logs_logged_date_index | 5       | NULL | 11708590 |   100.00 | Using where; Using index |
+----+-------------+----------+------------+-------+----------------------------+----------------------------+---------+------+----------+----------+--------------------------+

-- パーティションあり
+----+-------------+---------+------------+------+---------------+------+---------+------+---------+----------+-------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows    | filtered | Extra |
+----+-------------+---------+------------+------+---------------+------+---------+------+---------+----------+-------+
|  1 | SIMPLE      | logs    | p2022      | ALL  | NULL          | NULL | NULL    | NULL | 8086026 |   100.00 | NULL  |
+----+-------------+---------+------------+------+---------------+------+---------+------+---------+----------+-------+

注目すべきは rows(黄色の部分)で、やはりパーティションで区切られている分、スキャンしようとしている行数も比較的少ないです。

パーティションなし・ありで比較すると、パーティションありは、インデックスなしの約 34%、インデックスありの約 70 % ほどでした。

次に、これらのレコードを削除してみます。

-- パーティションなし・インデックスなし
DELETE FROM your_logs WHERE logged_date >= '2022-01-01 00:00:00' AND logged_date <= '2022-12-31 23:59:59';
 -> 8100010 rows affected (44.61 sec)

-- パーティションなし・インデックスあり
DELETE FROM our_logs WHERE logged_date >= '2022-01-01 00:00:00' AND logged_date <= '2022-12-31 23:59:59';
 -> 8100010 rows affected (6 min 38.57 sec)

-- パーティションあり
ALTER TABLE my_logs DROP PARTITION p2022;
 -> 0 rows affected (0.17 sec)

パーティションなしの DELETE ステートメントは 1 行ずつ削除していくのでやはり時間がかかりますが、パーティションありの DROP PARTITION は TRUNCATE TABLE ステートメント的に処理してくれるため早いです。

パーティションなし・ありで比較すると、インデックスもないテーブルの 263 倍早く、インデックスありの方は index 再構築入る分更に遅いので、それに比べると 2345 倍早い。という結果になりました。

まとめ

大規模データを扱う時にとても有利になりそうなパーティショニング。概念を知っておくことは大切だなと思います。

今回で全てに触れたわけではないので、またの機会に更に潜ってみたいと思います。

dev.mysql.com


現在 back check 開発チームでは一緒に働く仲間を募集中です!!

herp.careers

https://herp.careers/v1/scouter/GMnGlADgFBtQherp.careers

herp.careers

herp.careers

herp.careers

herp.careers

herp.careers

M1 mac で MySQL コンテナを使う方法

この記事の Canonical はこちらです。

toyo.hatenablog.jp

backcheck事業部の前田です。

つい先日、我が家にM1 Macが届きました。
「Dockerまわりがつらいよ」という話は聞いていましたが、私もしっかりと MySQL のコンテナが立ち上がりませんでした。
軽くググっても「むむ・・・?🤔」と思う結果だったので、自分でしっかりまとめてみようと思います。

そもそもの問題

M1 MacDocker 公式の MySQL コンテナをそのまま使おうとすると、以下のエラーが出て使用できません。

ERROR: no matching manifest for linux/arm64/v8 in the manifest list entries

これは雑に説明すると、「このコンテナは ARM64 に対応してないよ」ということです。
そして、 M1 チップは ARM64 アーキテクチャを採用しています。

解決する方法

色々と調べてみると、主に3つの方法があるようです。

  • arm64v8/mysql イメージを使用する
  • mysql/mysql-server イメージを使用する
  • --platform linux/amd64 オプションを使用する

順番に見ていきましょう。

MySQLのみを使いたいなら arm64v8/mysql

Docker の公式リポジトリに、 AMD64 (Intel の CPU のアーキテクチャ) 以外のプラットフォームの案内があります。

github.com

それによると、

Some images have been ported for other architectures, and many of these are officially supported (to various degrees).

とあり、程度差はありますが、公式にサポートされているイメージがあることがわかります。
そして、その MySQL のイメージが arm64v8/mysql です。

hub.docker.com

使い方は公式の MySQL イメージとほぼ同じなので、イメージを書き換えるだけで使用できます。

チーム開発をしていて、 Intel CPU の人と M1 チップの人の両方がいる場合、 docker-compose.override.yml を使用すると便利です。

# docker-compose.yml

version: '3'  
services:  
    mysql:  
        # デフォルトでは mysql イメージを使う  
        image: 'mysql:8.0'  
        ports:  
            - '${FORWARD_DB_PORT:-3306}:3306'
        environment:  
            MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'  
            MYSQL_DATABASE: '${DB_DATABASE}'  
            MYSQL_PASSWORD: '${DB_PASSWORD}'  
            MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'  
        volumes:  
            - 'sailmysql:/var/lib/mysql'
        networks:  
            - sail
        healthcheck:  
            test: ["CMD", "mysqladmin", "ping"]  

    some_other_containers:  
        ...  
# docker-compose.override.yml

version: "3"  
services:  
    mysql:  
        # M1 チップの人は arm64v8/mysql で上書きする  
        image: 'arm64v8/mysql:8.0-oracle'  

一方で、 mysql はOSの選択肢が debianoracle があるのに対し、 arm64v8/mysqloracle しかありません。
なので、 監視ツールなどで Debian でしか動かないツールを使いたい場合には使用できません。
また、本番は Debian のコンテナで動いていて、なるべく本番に近い環境で開発したい場合も向きません。

設定ファイル差異をなくすなら mysql/mysql-server

Docker 公式のイメージは ARM64 をサポートしていませんが、 MySQL 公式のイメージは ARM64 をサポートしています。
MySQL 公式のイメージが mysql/mysql-server です。

hub.docker.com

このイメージを使用すれば、M1 チップでも (docker-compose.override.yml を書かずとも) 同じ docker-compose.yml で動かすことができます。

一方で、 Docker 公式のイメージではないため、 MYSQL_PASSWORD のような環境変数による初回設定には対応していないようです。
そのため、既存で Docker 公式のイメージを使用しており、環境変数による初回設定を行っている場合は、mysql/mysql-server へのマイグレーションはコストがかかりそうです。

MySQL コンテナをカスタマイズしてるなら --platform linux/amd64

MySQL コンテナで MySQL 以外のミドルウェアをインストールしていたり、込み入った設定をしていたりなど、他イメージに移行することが難しい場合があります。
その際に有効なのが --platform linux/amd64 オプションです。

Docker for Mac の公式ページに、以下のように書かれています。

docs.docker.com

Not all images are available for ARM64 architecture. You can add --platform linux/amd64 to run an Intel image under emulation. In particular, the mysql image is not available for ARM64. You can work around this issue by using a mariadb image.

雑に和訳すると、「全部のイメージが ARM64 で動くわけじゃないよ。 --platform linux/amd64 オプションを付ければ Intel イメージをエミュレーション上で動かすことができるよ。特に mysql イメージは ARM64 じゃ動かないよ。 mariadb のイメージを使って解決することもできるよ。」
といった感じでしょうか。

docker-compose.yml を使用する場合は、以下のように書くといけます。

# docker-compose.yml

version: '3'  
services:  
    mysql:  
        image: 'mysql:8.0'  
        platform: 'linux/amd64'  

ひとつ気になることがあります。
docker-compose file v3 では、ドキュメントから platform オプションが消えています。 (v2 にはあります。)
ですが、リリースノートを見ても「 platform オプションを消した」という記述は見当たりません。
現状サポートされているのか、 非推奨な機能なのかわからない状態です。

ところで、このオプションを指定するとエミュレーション上で動作するということが重要です。エミュレーションをするということは、以下のことを指します。

なので、 --platform linux/amd64 オプションを使用するのは、最終手段にすることをおすすめします。


現在 back check 開発チームでは一緒に働く仲間を募集中です!!

herp.careers

herp.careers

herp.careers

herp.careers

herp.careers

herp.careers

herp.careers