フォルシアのブログ - TECH PLAY

TECH PLAY

フォルシア

フォルシア の技術ブログ

248

これは、 FORCIA Advent Calendar 2021 の7日目の記事です。 こんにちは。エンジニアの長尾と申します。旅行系アプリの開発・運用をしています。 今年の秋頃からSQLの高速化に取り組み、計600分以上の短縮に成功しました。 そのなかで特に効果の大きかった施策を4つほどご紹介させていただきます。 1. たった25行のコードの追加で、165分短縮。 「このTSVファイル20Gを超えているけど、一体これは何なんだ......。」 これを見たときに感じたことは「このデータは本当に全部使われているのだろうか」ということでした。 そこで、そのTSVファイルをもとに作られたテーブルがどこでどう使われているか調査しました。 いろいろな箇所でいろいろな使われ方をしていましたが、ひとつ共通点がありました。 必ず特定のテーブルとINNER JOINされている。 SELECT hoge FROM 巨大テーブル INNER JOIN とあるテーブル そしてJOIN後のレコード数は1/10以下になっていました。 と、いうことは90%以上のレコードは全く使われていなかったのです。 そこで、最上流である元データからTSVを生成する箇所で、そのテーブルをINNER JOINしました。 すると、20Gあった巨大TSVファイルは2G程度になり、関連する全ての処理が高速化しました。 それだけではありません。 DBのダンプ、転送、DBのリストアなども高速化し、計165分の大幅な短縮となりました。 まとめ:不要なレコードは最上流で削ろう 2. 100分かかっていたプレウォームを15分に。 アプリケーションによってはプレウォームを実施していることがあるかと思います。 これは、いろいろなSQLを前もって投げておくことで、 キャッシュをためてオンラインでのパフォーマンスを上げることが目的です。 このプレウォームに時間がかかっていたので原因を調査しました。 そして、SQLをいじって試行錯誤しているときに、あることを発見しました。 WHERE あるパラメータ = hoge ある一つのパラメータをWHERE句から消すと超高速になったのです。 「このパラメータ消したい」 しかし、当然ながら必要だから書かれているわけで、消すわけにはいきません。 どうしよう......。消したいけど消せない......。消せないけど消したい......。 あ! 嗚呼! テーブルのほうを分ければいいんだ。 そのパラメータの取り得る値は、1〜10といった具合に範囲が決まっており、 さらにユーザーは必ずその一つを選ぶ(必須かつ単数)という形式だったのです。 もとのテーブル → 新テーブル1, 新テーブル2, 新テーブル3 ...... 新テーブル10 と、分割し、レコードもそれぞれの新テーブルに分割して登録しました。 これにより晴れてWHERE句から消すことができたのです。 旧クエリ SELECT hoge FROM もとのテーブル WHERE あるパラメータ = '2'; 新クエリ SELECT hoge FROM 新テーブル2 WHERE あるパラメータはもうない; プレウォームが早くなっただけでなく関係するオンラインクエリもおよそ5倍速になりました。 まとめ:必須かつ単数のパラメータの処理が重くて困ったら、テーブルを分割したら解決するかも 3. ループにご注意。何度も繰り返されるSQL。 こんなSQLがありました。 5回ループ { CREATE TABLE 新テーブル_{1~5} AS SELECT hoge FROM Aテーブル INNER JOIN Bテーブル INNER JOIN Cテーブル INNER JOIN Dテーブル INNER JOIN Eテーブル_{1~5} ; } Eテーブルが1〜5で分かれていて、それに対応して新テーブルも5つに分かれます。 どうにかして計算量を減らしたい。さてどうしたものか。 ......。 CREATE TEMPORARY TABLE 一時テーブル AS SELECT hoge FROM Aテーブル INNER JOIN Bテーブル INNER JOIN Cテーブル INNER JOIN Dテーブル ; 5回ループ { CREATE TABLE 新テーブル_{1~5} AS SELECT hoge FROM 一時テーブル INNER JOIN Eテーブル_{1~5} ; } こうですね。 これで、BCDテーブルをAテーブルにJOINする回数が1回で済みました。 これだけで1/5強の処理時間になりました。 これは簡単なことですが、案外やってしまいがちだと思います。 そして、ちゃんと対応したときの効果は絶大です。 面倒くさがらずに一時テーブルを作ろう、というお話でした。 まとめ:ループを書くときは、本当にループさせるべきもの以外は外に出す 4. 超難解なSQLでも諦めないで。高速化できます! この勢いでどんどん高速化してやろう、と意気揚々、次なるターゲット(遅いSQL)を眺めていました。 が、分からないのです。 何が書いてあるのか、全然。 SELECT文のなかで多重ループがされていて、そのループのなかでも関数がガンガン呼ばれていて......。 しかもSELECT自体も多階層になっていて、何が何やらという状態でした。 かろうじて階層の一番深い所の処理だけは理解できました。 SELECT 超複雑な処理 FROM ( SELECT 超複雑な処理 FROM ( SELECT 超複雑な処理 FROM ( -- かろうじて理解できた部分、ここから SELECT hoge FROM Aテーブル INNER JOIN Bテーブル INNER JOIN Cテーブル INNER JOIN Dテーブル INNER JOIN Eテーブル -- かろうじて理解できた部分、ここまで ) ) ); こんなSQLです。 しかも悲しいことには、時間がかかっている処理は、まさにその超複雑な処理の部分なのです。 さすがにこれは高速化は無理だよなぁ、と一度は諦めました。 が、しかし、口惜しい。 このSQLを見るたびに、どうにかしてやっつけられないだろうか、とずっと思っていました。 そんなあるとき、ほかのSQLで、一時テーブルをANALYZEしているものを見掛けました。 一時テーブルのためにわざわざANALYZEなんかして、一体何をやっているんだ......。 あ! これだ! 最深部のSQLを一時テーブル化して、ANALYZEした状態で超複雑な処理を迎えれば......。 CREATE TEMPORARY TABLE 一時テーブル AS SELECT hoge FROM Aテーブル INNER JOIN Bテーブル INNER JOIN Cテーブル INNER JOIN Dテーブル INNER JOIN Eテーブル ; ANALYZE 一時テーブル; SELECT 超複雑な処理 FROM ( SELECT 超複雑な処理 FROM ( SELECT 超複雑な処理 FROM 一時テーブル ) ); なんとこれだけで25%の高速化に成功しました。 まとめ:困ったときのANALYZE さいごに やってみると意外と改善の余地はたくさんあるものだなぁと思いました。 SQLが速くなると気持ちが良いですね。 最後に各項のまとめを再掲しておきます。 不要なレコードは最上流で削ろう 必須かつ単数のパラメータの処理が重くて困ったら、テーブルを分割したら解決するかも ループを書くときは、本当にループさせるべきもの以外は外に出す 困ったときのANALYZE
こんにちは。エンジニアの長尾と申します。旅行系アプリの開発・運用をしています。 今年の秋頃からSQLの高速化に取り組み、計600分以上の短縮に成功しました。 そのなかで特に効果の大きかった施策を4つほどご紹介させていただきます。 1. たった25行のコードの追加で、165分短縮。 「このTSVファイル20Gを超えているけど、一体これは何なんだ……。」 これを見たときに感じたことは「このデータは本当に全部使われているのだろうか」ということでした。 そこで、そのTSVファイルをもとに作られたテーブルがどこでどう使われているか調査しました。 いろいろな箇所でいろいろな使われ方をしていました
これは、 FORCIA Advent Calendar 2021 の6日目の記事です。 こんにちは!2021年入社、新卒1年目エンジニアの小木曽です。 大学では主に言語学を学んでいましたが、色々な経緯があり技術職としてフォルシアに入社しました。 大学の専攻とはかなり違う領域の職種に就いたこともあり、周りからは「珍しいね」と言われることも多いのですが、学生時代の後輩たちと話していると、文系からIT技術職への就職を検討している人は意外と少なくないことが分かりました。 というわけで、この記事では自分の学生時代から現在までを振り返りつつ、フォルシアのエンジニアになっての所感をお伝えできればと思います。 内容は大きく分けて以下の3つです。 大学前半:エンジニアを目指した経緯 大学後半:フォルシアを志望した理由 現在:実際にエンジニアになってみて 同じような進路を検討している就活生の参考になれば幸いです。 1. 大学前半:エンジニアを目指した経緯 私は、最初からずっとエンジニアを志望していたわけではありませんでした。大学1年生の頃は、ただ漠然と専攻分野に関係する仕事に就ければ良いかと考えていたのですが、2年生の夏にとある大手企業でインターンに参加した際、初めて「自分の好きなことを活かせる仕事がしたい」と感じ、将来について能動的に考えるようになりました。 私は昔から絵や音楽、ダンスの振付などの創作活動が好きでした。こういう"何かを作ること"と"社会で必要とされていること"を掛け合わせたらどうなるか考えた時、「プログラミングを学んでエンジニアになる」というところに帰着したのです。その他にも、SEをやっていた親の影響や性格分析の結果に出た向いている職業欄に技術職があったことなどいろいろな要因があり、エンジニアを目指す気持ちが高まりました。 とはいえこの時のプログラミング経験は皆無。大学では情報系の授業がほとんどなく、独学で学ぶのも不安があったため、まずは3年生からプログラミングスクールに通い始めました。ここでは、プログラミングの基礎の基礎を勉強し、成果物として簡単なwebアプリケーションを作ったのですが、自分のアイデアを技術の力で形にすることの楽しさを知ることが出来ました。 スクールを卒業すると、成果物を持ち込みながら実務として開発が出来るインターン先を探し始めました。実務経験がある方が就活をする上でも熱意が伝わりやすいと思ったからです。 文系未経験ながら拾ってくれた会社では、クリエイティブ業界向けのSaaS開発に携わりました。ここでは、個人開発では得られなかった、チームで一つのプロダクトを作る難しさや楽しさを知ることが出来ました。コーディングなどの直接的な技術はもちろんのこと、周りのエンジニアや営業の方とスムーズにプロジェクトを遂行するためのコミュニケーション能力も必要とされていることを実感しました。 2. 大学後半:フォルシアを志望した理由 フォルシアを知ったきっかけは、就活の相談をしていたエージェントでした。「社風が合うのではないか」と紹介され、実際に社員の方と直接何回か話をしました。どなたも親切で、一就活生の拙い質問にも丁寧に答えてくれました。 特に印象的だったのが、非情報系出身で当時2年目のエンジニアの方と話したときのことでした。プログラミングはほぼ未経験の状態から入社したそうですが、充実した教育体制が整っていたことやいつでも先輩に相談できる環境のおかげで業務をこなせるレベルにまで成長できたとのことでした。この話を聞いて、フォルシアの人の温かさや教育にかけるコストを惜しまない風土に魅力を感じ、志望度が高まりました。 また、フォルシアのエンジニアの特徴として、「案件の提案、要件定義から、設計、実装、テスト、運用までを一貫して担当する」というのがあり、「目の前のコードと向き合うだけでなく、コードの先にいるヒトと向き合っていきたい」と考えていた私にとっては働き方という点でも魅力を感じ、この会社でエンジニアとして働きたいと思うようになりました。 いざ選考フローに入ると、履歴書を見ながら「なぜ言語学を専攻していたあなたがここに...?」ということは当然のように毎回聞かれます。そのような時は1節で述べたようなことを説明していたのですが、社長・COOとの最終面接で「今大学で学んでいることとこれからやりたいことを結びつけて話してください」という質問を受けた時には少し悩みました。 緊張で記憶がおぼろげではありますが、「言葉も技術も今では人間が社会を維持していくのに欠かせないもの。生活の中で当たり前の存在でありながら実態の無いものに関わり、支えていきたいという志向を持っているので、このような進路を選んだ」という旨を話しました。 今振り返れば少しこじつけのような気がしなくもないですが、とにかくエンジニアに興味があって、フォルシアという環境で貪欲に学んでいきたい!いう気持ちはアピール出来たと思っています。 大学で学んでいたことと異なる道を選ぶ人は、その道を選ぶに至ったストーリーや過去と未来を結ぶ共通項を、相手に納得してもらえる形で言語化する練習をしておくと良いかもしれません。 3. 現在:実際にエンジニアになってみて そんなこんなで実際にフォルシアで技術職として採用されて今日に至ります。現在はECサイトの検索機能を担当するチームにJOINして日々開発中です。実際にフォルシアのエンジニアになってみて、入社前に社員の方から聞いた内容がその通りだったということを実感しています。 技術研修では、Progateに始まり、JavaScript・SQL・検索アプリ開発のオリジナル教材を進めていくことで、フォルシアのエンジニアになるために必要な基礎知識が自然と入ってきました。やはり情報系専攻の優秀な同期と比べると能力や経験面での差は大きく、焦ることも多々ありましたが、メンター制度のおかげで先輩に気軽に相談・質問することが出来るので、不安や疑問をその日のうちに解消できたのは有り難かったです。 それから仮配属を経て本配属となるのですが、ここでも分からないことはすぐ解決できる体制が整っていると感じました。Slackやesaなど社内知見を溜め込めるツールのおかげで、大抵の問題は検索すると似たようなケースに対処した先人の知恵を享受できたからです。 実務だけでは足りない・もっと知りたい部分については会社の書籍補助制度を利用して技術書を読んでみたり、資格(基本情報技術者試験)取得に向けて勉強したり、ドットインストールなどの外部サービスを使って学習したりしています。 研修のときと比べると、周りと比べ過ぎず自分のペースで頑張っていこうと気持ちを切り替えたことで精神的にも少し落ち着いてきた感覚がありました。 今後は、興味のあるフロントエンドやデザイン・UIUX領域にも携わりたいと思っており、業務の中で学べそうな部分を積極的に吸収したいと考えています。 おわりに もし「文系からでもエンジニアになれるのでしょうか?」と聞かれたら、私は「なれます!」と答えたいです。フォルシアの教育体制はとても充実しているので、志さえあればどんなことを学んできた方でも受け入れる環境となっています。 大事なのは「エンジニアになって何を生み出していきたいのか」という部分だと思っています。これは選考の過程から聞かれる部分でもあるので、非情報系出身なら特にしっかり考えを磨いておく必要があると思います。ここがはっきりしていると、会社という環境を生かして自己実現するスピードも早まっていくというメリットもあるので、ぜひ意識してほしいポイントです。かく言う私も日々考え直している点であります。 実際にエンジニアになってみて感じたのは、「どういう職種に就くかではなく、就いた仕事でいかに自分のバリューを発揮していくかの方が重要だということ」です。たとえ全く異なる専攻出身だったとしても、仕事に生かせることはあると思います。 私の場合、大学では様々な言語を学んできましたが、「基本的な単語や文法を覚えていく→覚えたものを自分で使ってみて習得する」ということをひたすら反復する学習の流れは、自然言語でもプログラミング言語でも共通するところがあると感じています。 また、文章やビジュアルを用いて情報を整理することが好きという志向を生かし、営業の方との細かい仕様確認のときに分かりやすく伝えることを心がけたり、社内に残すドキュメントの体裁を整えたりすることも楽しんでやっています。 そういう意味で、フォルシアの一気通貫した開発スタイルは情報系以外を専攻してきた方にも得意なこと・好きなことを活かせる場になり得ると思うのです。 最後に、働く上で私が大切にしている言葉に「自分が選び取った行動が正解になるように努力を続けることが『成功の秘訣』」というものがあります。これは、就活生の頃初めてフォルシアへ行った時に、人事の方からもらった社長執筆の本にあった内容です。 確かに、学生時代とは全く異なる環境では不安や悩みも尽きません。大学の同期が言語・教育関連の仕事で活躍している話を聞くと、本当にこの道を選んで良かったのかと自問することもあります。 しかし、今出来ることは「目の前の道を最善とするために日々努力すること」だけだと思っています。まだまだ自分一人の力では出来ないことが多いですが、サポートしてもらえる環境・先輩方に感謝し、その分昨日より少しでも分かったこと・出来るようになったことを増やせるよう意識しています。 もし、この記事を読んで「フォルシアのエンジニア」という道に興味を持ってくださった方がいればぜひ採用応募フォームからご連絡ください。 フォルシアという環境で、自分ならではのエンジニア像を一緒に実現させていきませんか?
はじめに こんにちは!2021年入社、新卒1年目エンジニアの小木曽です。 大学では主に言語学を学んでいましたが、色々な経緯があり技術職としてフォルシアに入社しました。 大学の専攻とはかなり違う領域の職種に就いたこともあり、周りからは「珍しいね」と言われることも多いのですが、学生時代の後輩たちと話していると、文系からIT技術職への就職を検討している人は意外と少なくないことが分かりました。 というわけで、この記事では自分の学生時代から現在までを振り返りつつ、フォルシアのエンジニアになっての所感をお伝えできればと思います。 内容は大きく分けて以下の3つです。 大学前半:エンジニアを目指し
これは、 FORCIA Advent Calendar 2021 の5日目の記事です。 こんにちは。プロダクト部 技術研究所の大沢です。 私が大学生のころ、Nintendo DSのアソビ大全というゲームソフトに定番ゲームの1つとして収録されていた「ラストワン」というゲームがあり、必勝法がないものか、あるとしたらどうにか導けないか?と気になっていた時期がありました。何週間か考えていましたが、当時はプログラミングも学びたてで、結局必勝法を編み出すことはできませんでした。 つい最近になってそんなゲームがあったことを思い出し、ふと考え直してみたところ、なんと必勝法を編み出すことができました! その解法には、私がここ数年腕を磨いている、競技プログラミングで得た知識が生きていると感じました。 10数年の時を超えてゲームを攻略した喜びを共有したく、この記事を書くことにしました。 ラストワンというゲームについて ルールは単純です。もしかしたら皆さんも紙や黒板で遊んだことがあるかもしれません。 まず、初期状態として縦棒を並べて書きます。 ピラミッド型じゃなくても良いですが、とにかく横に何個か縦棒を並べたものを何行か書きます。 次に、じゃんけんか何かで、先攻か後攻かを決めます。 ゲームは先攻と後攻で順番を交代しながら進みます。 自分の番の人は、まだ消されていない縦棒を選び、横線で消します。 このとき、まだ消されていない縦棒が横に連続していれば、複数まとめて消すこともできます。 すでに消された縦棒をまたいで消したり、違う行の縦棒をまとめて消すことはできません。 このように順番に縦棒を消していき、 最後に残った1本を消した人の負け です。 なお、Android端末では こちら より実際に遊ぶことができます!(筆者作のアプリです) 必勝法とは? 実はこのゲームはすべての局面が「勝ち」か「負け」かが決まっています。 負けの局面について 例えばこの状態で手番が回ってくると、負け確定もしくは、相手が間違えない限り負けてしまいます。 勝ちの局面について 例えばこの状態で手番が回ってくると、勝ち確定もしくは、自分が間違えない限り勝つことができます。 突き詰めると、初期状態で先攻か後攻かを適切に選ぶゲームになります。 つまり必勝法を知っている人にとっては、初期状態が勝ちの局面であれば先攻を選び、負けの局面であれば後攻を選ぶことができれば必勝となります。 後退解析による解法 実は特殊な知識がなくても、プログラミングが得意な方であれば、すべての局面を勝ちと負けに分類できます。 後退解析という手法を使います (動的計画法の一種) ある局面Aからどれか1つでも負け局面に遷移できればAは勝ち局面といえます。 反対に、勝ち局面にしか遷移できなければAは負け局面といえます。 ゲームの終了状態での勝ち負けは決まっているので、ゲームの終了状態から遡っていくことで勝敗が分かります。 これをシミュレートするのが後退解析です。 局面を圧縮する 一般的に動的計画法で問題を解く際、無数に状態が存在するのを共通の特徴で同一視して、状態を減らすことを考えます。 このゲームでは、たとえば この2つの局面は同じとみなすことができます。どちらも、 (3,2,1,1) という数列で表現できます。 これは、1本の孤立した縦棒が3個、2本並んだ縦棒が2個、3本並んだ縦棒が1個、4本並んだ縦棒が1個 残っているという意味です。 メモ化再帰 この問題は再帰関数で実装するのが都合がよさそうです。 特に、再帰関数を実装する際、同じ状態の判定を2度行わないように結果をメモしておく手法をメモ化再帰と呼びます。 実装上は、状態を表す数列をキーにしてメモしたいので、hash化して使います。 Pythonなら、tuple型であれば勝手にhashを計算してくれますし、自前のhash関数を作っても構いません。 以下にPythonの実装例を記します。 memo = {} #判定結果をメモするdictionary def is_win_state(state): if state == (0,0,0,0,0,0): return True #最後の1本を消したら負け ⇒ すべて消された状態で回ってきたら勝ち if state in memo: return memo[state] #判定済みなのでメモの内容を返す result = False #get_next_states()は state から遷移可能なすべての状態たちを配列で返すメソッド(実装略) for next_state in get_next_states(state): if not is_win_state(next_state): #遷移先に負け局面が1つでもあれば、勝ちと判定する result = True break memo[state] = result #判定結果をメモする return result result = is_win_state((1,1,1,1,1,1)) #6段ピラミッド型の初期状態が勝ち状態であるか負け状態であるかわかる print(result) # True ちなみに先攻は勝ちのようです 後退解析を使うことで、あっさりとすべての局面が勝ちか負けかが求まってしまいました。 プログラミングで求めることはできましたが、残念ながらこのプログラムを頭の中で動かすことのできる人はそう居ないと思います。 (いくつかの局面の勝ち負けを暗記すればそれなりに強いですが...) どんな局面に対しても「計算」で、それも暗算可能な範囲で、求めることができたら、嬉しいと思いませんか? それが、次に紹介するGrundy数の方法です。 Grundy数を使った解法 ラストワンは 不偏ゲーム というジャンルに属するゲームです。 正確には、終了状態(terminal position)にした人が勝ちではなく負けになるので、「逆形の」不偏ゲームという分類になります。 不偏ゲームにはGrundy数の性質を使った必勝法があります。 Nimの例 (正規系不偏ゲーム) Nimという有名な不偏ゲームがあります。 初期状態で、石がいくつかの山になっています。 プレイヤーは自分の番の時にどれか1つの山から、1個以上好きなだけ取り除きます。 これを交互に行い、操作ができなくなった人の負け、つまり最後の1つを取った人の勝ちです。 ある局面のGrundy数を、「その局面から1手で遷移可能な局面のGrundy数のmex」で定めます。 ここで、mexとは、 非負整数の集合に対して、その集合に含まれない最小の非負整数と定義されます。 例) {0,1,2,3} のmex は 4 {0,1,3,4} のmex は 2 {1,2,3} のmex は 0 {} の mex は 0 特に、遷移不可能な局面 (terminal position) のGrundy数は、0です。 山ごとにGrundy数を考えます。次のように求まります。 まず、石が0個の状態は、遷移可能な局面がありませんので、Grundy数は0となります。 次に、石が1個の状態は、石が0個の状態にのみ遷移可能です。遷移可能なGrundy数の集合は{0}なので、これのmex、つまりGrundy数は1です。 次に、石が2個の状態は、石が0個の状態と石が1個の状態に遷移可能です。遷移可能なGrundy数の集合は{0,1}なので、これのmex、Grundy数は2です。 同様に、石が3個の状態は、遷移可能なGrundy数の集合は{0,1,2}なので、これのmex、Grundy数は3です。 ... すると、お気づきかと思いますが、結局NimにおけるGrundy数は、石の数に一致します。 Grundy数を求めると何が嬉しいかというと、 その局面の勝敗がわかります。0の局面は負け、0以外の局面は勝ちです。 このようにGrundy数を定めると、必ず0の局面(負け)から 0の局面(負け)に遷移させることは不可能であり、また必ず非0の局面(勝ち)からは0の局面(負け)に遷移させることが可能という性質を持ちます。 Grundy数にはもう1つ、嬉しい性質があります。 Grundy数同士は (bitwise) xor演算を使って合成できるのです。 本記事では(bitwise) xorの演算子として ⊕ を使います (bitwise) xorとは、ビットごとの排他的論理和です。 排他的論理和はビット演算の1つで、2つのビットのどちらか一方のみが1のときに限り、1となる演算です。 ビットごとの計算なので、2進数になおして、それぞれの位について計算します。 簡単にいえば、「繰り上がりのない足し算」です。 このxorの性質でGrundy数を合成していくことによって、複数ある山をあたかも1つの山とみなして局面を考えられるのがGrundy数の嬉しいところです。 Nimのセオリーをまとめると 山ごとにGrundy数を求めます (Nimでは山の石の個数と一致) それらGrundy数すべてをxor演算で合成した値を求めます 結果が0であれば負け局面、それ以外であれば勝ち局面です 合成したGrundy数が非0であれば、相手にGrundy数0の局面を押し付けられるので、自分の番で非0をキープでき、勝てます(下図参照 上図で(A)が成り立つ理由 Grundy数0の局面とは、各山の石の個数のxor結果が0ということ どこかの山から石を取り除くということは、どこかの山のどこかの位が最低1つは書き変わるということ するとxor結果が0から書き変わり、必ず0以外になる 上図で(B)が成り立つ理由 Grundy数が非0の局面とは、各山の石の個数のxor結果が0以外ということ (この値をxとする) xの最上位bit (2進数で最も上の位の1) に注目する xの最上位bitと同じ位が1である山が必ず1つは存在する (この山の石の個数をyとする) そうでなければ、xの最上位bitが1にはならず、矛盾する yの山を選んで、yをy⊕x個にできれば、xor結果を0にできる これは可能か?結論は可能 xの最上位bitが、yも1であるため、y⊕xにおける当該bitは0となり、y > y⊕xが成り立つ よって、yからy⊕xに「減らす」ことが可能 ラストワンに適用する 逆形不偏ゲーム (Misère Game) ですので、少し注意しなければなりませんが、Grundy数を適用できます。 Grundy数を適用する前に、自明なケース(下記の(1)(2)) から考えます。 (1) まず、1本の孤立した縦棒しか残っていないとき、勝敗は本数の偶奇で決まります 残り本数が奇数ならば負け、偶数ならば勝ちです。 (2) 次に、2本以上並んだ縦棒のかたまりが丁度1つあり、残りが全部1本で孤立しているケースは、勝ちの局面です なぜなら、1本の孤立した縦棒が奇数本ならば並んだ縦棒を全部消せば良く、偶数本ならば並んだ縦棒から1本残して消せば、残りが奇数本の①の局面を相手に渡せるからです。 (3) それ以外のケースをGrundy数を使って考えます Nimと同様に、かたまり毎にGrundy数を定義します。 便宜上、これ以上消せない状態(0本の状態)があるとして、このGrundy数を0とします。 これではNimと同じだから、「逆形」不偏ゲームのラストワンと勝敗が逆になってしまうのでは?と心配になるかと思いますが、大丈夫です。(理由はのちほど) では肝心のGrundy数の求め方なのですが、結論はNimと同じく、かたまりの本数に一致します。 確かに、 1本の状態からは0本にしか遷移できない (Grundy数:1) 2本の状態からは0本と1本に遷移できる (Grundy数:2) 3本の状態からは0本と1本と2本に遷移できる (Grundy数:3) ので、Nimと同じように考えることができそうです。 ですが勘の良い方は、ちょっと待った、と思うでしょう。 ラストワンでは、Nimとは異なる考慮も必要で、それは何かというと、かたまり(Nimでいう山)が増える遷移もあるということです。 3本のかたまりで真ん中の1本を消すと、左右に1本ずつの縦棒が残ります。この遷移は大丈夫なのでしょうか?結論は大丈夫です。 Grundy数の求め方は、「その局面から1手で遷移可能な局面のGrundy数のmex」でした。 さらに、Grundy数同士はxor演算で合成できます。 3本のかたまりで真ん中の1本を消し、1本の縦棒が2つ残った状態は1⊕1で、Grundy数は0です。 これを踏まえて、3本の状態をより正しく解釈するとこうなります。 3本の状態からは0本と1本と2本と「1本が2つ」に遷移できるが、「1本が2つ」のGrundy数は0なので、{0,1,2,0}のmexを求めることになり、Grundy数は3になります。 同じように、4本の状態からは0本と1本と2本と3本と「1本が2つ」と「1本と2本」に遷移できるが、「1本が2つ」のGrundy数は0、「1本と2本」のGrundy数は 1⊕2 で3なので、{0,1,2,3,0,3} の mexを求めることになり、Grundy数は4になります。 このように、ラストワンにおいても、n本のかたまりのGrundy数はnに一致することが示せます。(証明は省略します) ラストワンと勝敗が逆になってしまうのではないか?それが大丈夫な理由 この理由は、ラストワンでは③の状態からゲームが進むにつれて、必ず(2)の状態を経由して、次に(1)の状態を経由するからです。 特に(2)の状態のGrundy数は必ず非0となります。非0はNimでは勝ち局面でしたので、ラストワンの勝敗と一致します。 (2)の状態からはGrundy数を使わずに勝つことができますが、(2)から遷移すべき状態は(1)かつ残り奇数本の状態で、この状態のGrundy数は1です。 Nimでは非0の局面を相手に渡すと負けてしまいますが、ラストワンはこのケースならば勝ちです。 ここで勝敗がNimとは反対になるので、辻褄が合います。 ラストワンのセオリーをまとめると まず、(1)(2)(3)のどの状態であるか見極めます (1)ならば勝敗は決しています (2)ならば奇数本残るように①の状態にすると、勝ちます (3)ならば、Grundy数を使います Grundy数が非0ならば、相手にGrundy数0の局面を押し付けられるので、いずれ(2)の必勝局面が回ってきます。 Grundy数が0ならば、相手が間違えるのを待つしかありません。 初期状態が(3)のとき、先攻後攻を選べる状況ならば、Grundy数が非0ならば先攻、0ならば後攻を選ぶと良いです。 (おまけ) xorを暗算するときのコツ 先ほど暗算できると嬉しい... と書きましたが、実際xorの暗算は慣れていないと難しいです (笑) 2進数への変換は覚えるしかないのですが、少しだけコツを書き記します。 x⊕x = 0という大事な性質があり、同じ数のかたまりが2個あれば相殺されて0になります。 さらに加えて、 1⊕2⊕3 = 0 1⊕4⊕5 = 0 2⊕4⊕6 = 0 あたりを覚えておくとさらに計算を減らせることが出来て良い感じです。
これは、FORCIA Advent Calendar 2021の5日目の記事です。 こんにちは。プロダクト部 技術研究所の大沢です。 私が大学生のころ、Nintendo DSのアソビ大全というゲームソフトに定番ゲームの1つとして収録されていた「ラストワン」というゲームがあり、必勝法がないものか、あるとしたらどうにか導けないか?と気になっていた時期がありました。 何週間か考えていましたが、当時はプログラミングも学びたてで、結局必勝法を編み出すことはできませんでした。 つい最近になってそんなゲームがあったことを思い出し、ふと考え直してみたところ、なんと必勝法を編み出すことができました! その
これは、 FORCIA Advent Calendar 2021 の4日目の記事です。 はじめまして。2021年度新卒入社エンジニアの高嶋です。 外部に公開される記事を書くのは初めてなので緊張しています。 本稿では表題の通りReactを用いて要素の高さを揃える方法についてご紹介します。私自身が最近業務内でハマった内容なのですが、大変勉強になったと感じたため記事にすることにしました。解決策を出すにあたりアドバイスをくださった先輩方ありがとうございました。 やりたかったことは、React, Reduxを用いたWebアプリのとあるページで内部のデータ数によって高さが可変になるような枠A, Bをそろえて表示することです。 問題の画面を簡易的に表すと以下のようになります。 データの数が枠Aと枠Bで同じ時は良いのですが、異なる場合には高さが揃わなくなってしまいます。 要素同士の高さを揃えたいとき、通常はCSSを使用することをまず考えるでしょう。 しかし、今回は高さをそろえたい要素同士を同一のwrapperで包むことができないという事情がありました。 縦方向に構成されたグループに内包されているため、要素高を統一するための横方向のwrapperで該当要素同士を包むことができません。 そのため、ここではReactを利用して要素高の統一を実現することにしました。 案1:useState, useEffect, getElementsByClassNameの合わせ技 案1があるということは案2もあります。初めにお話ししておきますが案1は失敗に終わります。 まず初めに試したのは、 getElementsByClassName メソッドで要素とその要素高を取得し、 useState フックで要素高のうち高い方を記録、それを該当要素の style に指定することで高さを揃える方法です。また、枠内に表示するデータ数はユーザーの操作によって可変である為、 useEffect フックを用いてその都度要素の最大高の計算し適用します。 // 親コンポーネント内 // ~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ // 対象要素の取得 const frameElementArr : HTMLElement [] = []. slice . call ( typeof document !== " undefined " && document . getElementsByClassName ( " frame-class " ) ); // 要素高を記録させるためのstate const [ frameMaxHeight , setFrameMaxHeight ] = React . useState < number | undefined > ( undefined ); React . useEffect (() => { if ( // 枠内に表示するデータ数を比較 data [ frame1 ]. length !== data [ frame2 ]. length ) { // データ数が異なった場合は枠1, 2の要素高の高い方をframeMaxHeightにセット setFrameMaxHeight ( Math . max (... frameElementArr . map ( elment => elment . offsetHeight )) ); } else { // 大きいエレメントに合わせたまま元に戻らなくなってしまうので、データ数が同じである場合は1度リセットする。 setFrameMaxHeight ( undefined ); } }, [ data ]); return ( < frame data = { data [ frame1 ] } // 要素高の最大値を渡す frameMaxHeight = { frameMaxHeight } /> < frame data = { data [ frame2 ] } frameMaxHeight = { frameMaxHeight } /> ); // frameコンポーネント内 // ~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ return ( < div className = "frame-class" style = { // frameMaxHeightがundefind(リセット状態)の時は高さ指定なしとする frameMaxHeight ? { minHeight : ` ${ frameMaxHeight } px` } : { minHeight : " initial " } } > dataを表示(省略) </ div > ) 枠内のデータ数を減らした場合に frameMaxHeight を減らすことができなくなってしまうので、データ数が同一(style指定なしで描画しても高さが揃う)の場合には useEffect 内で frameMaxHeight を undefined に設定して要素高をリセットしています。 frameMaxHeight が undefined の場合には枠要素のminHeight指定を初期値に指定します。 これで一見うまくいったかのように思えたのですが、実は初期描画時は枠A、Bの要素高を揃えることができません。(私の場合、初期描画時はデータ数が同一、ユーザー操作でデータ数が不均一になるような状況だったので初めは気づきませんでした) この部分に着目してください。親コンポーネントの初回レンダリング時に子コンポーネントはまだレンダリングされていません。そのため、子コンポーネント内の要素の参照が取れず要素高を取得できません。 // 対象要素の取得 const frameElementArr : HTMLElement [] = []. slice . call ( typeof document !== " undefined " && document . getElementsByClassName ( " frame-class " ) ); ちなみに、再レンダリング時も同様の状況になるのではと考えたのですが、 HTML DOM 内の HTMLCollection は生きて (live) います。それらは元になった document が変更された時点で自動的に更新されます。 https://developer.mozilla.org/ja/docs/Web/API/HTMLCollection より、親コンポーネントを再レンダリングする際にはすでに前のレンダリングにより枠A, B自体はDOMに存在しており HTMLCollection の取得に成功、子コンポーネントの再レンダリングが走り枠A, Bが再描画されたとしても参照は自動的に更新されるため要素高が取得可能だったのだと考えています。初期描画の場合には getElementsByClassName() の戻り値が undefind だったため、子コンポーネントがレンダリングされても参照が更新されなかったのではないでしょうか。 案2:useState, useEffect, useRefの利用 案1の段階で原因すら分からず困っていたところで、useRefフックを使用した本方法について教えていただきました。 useRef フックについて、要素の操作に利用できるフックだというなんとなくの知識はあったのですが、正直DOMオブジェクトのメソッドを利用する場合との違いが分かっていませんでした。refについて理解する上でも大変参考になったため共有します。 コードとしては以下のようになります。 // 親コンポーネント内 // ~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ - // 対象要素の取得 - const frameElementArr: HTMLElement[] = [].slice.call( - typeof document !== "undefined" && - document.getElementsByClassName("frame-class") - ); + // useRefで参照を持つ + const frame1Ref = React.useRef<HTMLDivElement>(null); + const frame2Ref = React.useRef<HTMLDivElement>(null); // 要素高を記録させるためのstate const [frameMaxHeight, setFrameMaxHeight] = React.useState< number | undefined >(undefined); React.useEffect(() => { if ( // 枠内に表示するデータ数を比較 data[frame1].length !== data[frame2].length ) { // データ数が異なった場合は枠1, 2の要素高の高い方をframeMaxHeightにセット - setFrameMaxHeight( - Math.max(...frameElementArr.map(elment => elment.offsetHeight)) - ); + // frame1Ref.current, frame2Ref.currentが参照可能になったら比較を行う + if (frame1Ref.current && frame2Ref.current) { + setFrameMaxHeight( + Math.max(frame1Ref.current.offsetHeight, frame2Ref.current.offsetHeight) + ); + } } else { // 大きいエレメントに合わせたまま元に戻らなくなってしまうので、データ数が同じである場合は1度リセットする。 setFrameMaxHeight(undefined); } - }, [data]); + }, [ + data, + // refから得られる要素高を依存配列に入れておく + frame1Ref?.current?.offsetHeight, + frame2Ref?.current?.offsetHeight + ]); return ( <frame data = {data[frame1]} // 要素高の最大値を渡す frameMaxHeight = {frameMaxHeight} + frameRef = {frame1Ref} /> <frame data = {data[frame2]} frameMaxHeight = {frameMaxHeight} + frameRef = {frame2Ref} /> ); // frameコンポーネント内 // ~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~ return ( <div className="frame-class" + ref={frameRef} style={ // frameMaxHeightがundefind(リセット状態)の時は高さ指定なしとする frameMaxHeight ? { minHeight: `${frameMaxHeight}px` } : { minHeight: "initial" } } > // dataを表示(省略) </div> ); getElementsByClassName メソッドの代わりに useRef フックを利用して要素を参照します。前者の場合、DOM構築済みの要素から参照を取ってきますが、後者は生成したrefを渡してレンダリングが行われるため、要素がレンダリングされた後に参照することが可能になります。 また、初期描画においては要素高が数段に分けて変化していく様だったので、 useEffect の依存変数に ref から参照した要素高も含めています。 今回初めてuseRefフックを利用したのですが、レンダリング状態に左右されず要素に参照を通すことができReact内で要素を操作する際に非常に便利だと感じました。
これは、FORCIA Advent Calendar 2021の4日目の記事です。 はじめまして。2021年度新卒入社エンジニアの高嶋です。 外部に公開される記事を書くのは初めてなので緊張しています。 本稿では表題の通りReactを用いて要素の高さを揃える方法についてご紹介します。私自身が最近業務内でハマった内容なのですが、大変勉強になったと感じたため記事にすることにしました。解決策を出すにあたりアドバイスをくださった先輩方ありがとうございました。 やりたかったことは、React, Reduxを用いたWebアプリのとあるページで内部のデータ数によって高さが可変になるような枠A, Bをそろ
これは、 FORCIA Advent Calendar 2021 の3日目の記事です。 どうもこんにちは。 タブをスペースに変換するのを忘れてpsqlに怒られがちな新卒2年目の駆け出しエンジニア吉田です。(Web業界に2年近くもいて「駆け出し」を名乗ってよいものか微妙ですが、強気に主張していきます) 私はここ一年間、大規模旅行アプリの開発に携わってきました。旅行アプリの検索機能は、フォルシアの主力事業であるSpookが最も威力を発揮する領域でもあります。 Spookは「膨大で複雑なデータ」を「高速」に検索するための技術基盤です。(参考: https://www.forcia.com/service/spook/) 参考記事にもある通り、Spookの高速検索は「独自の検索最適化技術」と「これまで培ってきたノウハウ」の合わせ技の上に成り立つものです。技術とノウハウを駆使して高速なDB検索をすることがフォルシアエンジニアの仕事の一つ... なのですが、駆け出しエンジニアの私にはまだノウハウが備わっておらず、自分の実装した部分はデータ量を増やすととても重い、検索に失敗するなどの問題が次々に勃発... これを解決すべく、私はかなりの期間クエリとにらめっこし、修正やチューニングに時間を割くことになりました。 本記事は、そんな私がこの一年間クエリ改善活動を行う中ではまった、SQLの「沼」についてまとめたものになります。ドがつくほどの基本的な内容から笑える(笑えない)ミスまで5選用意しましたので、誰かの何かの参考になれば幸いです。 ※「SQLの」と題していますが、私がアサインされたプロジェクトで利用しているPostgreSQL 12系(以後postgresと記述します)での話となるため、RDBMS全体に拡張できない話が含まれることをご承知おきください。 その1 hash joinだから大丈夫!と思っていたら...? 問題 ある日、いつものようにスロークエリをexplain analyzeしていたところ、どうやらテーブルの結合処理(join)に問題があるらしいことがわかりました。 postgresの結合方式はnested loop join, merge join, hash joinの3種類あり、プランナがその時々に応じて最適な結合方式を選んで実行するのですが、今回のケースではhash joinが選択されているようでした。 原因 hash joinは 1. 内部表の結合キーのハッシュテーブルを作成し 2. 外部表の結合キーのハッシュを突き合わせる という方式で実行されるため、内外それぞれ1回だけテーブルが走査されることになり、インデックスの有無にかかわらずある程度速いはずの結合方式になります。 一方でハッシュテーブルのサイズが利用可能なメモリより大きくなってしまう場合、ディスク書き出しが発生し非常に遅くなる可能性もあります。 今回のケースはまさにこれがスロークエリの原因でした。初期開発時はレコード数が小さく無視できていた非効率な処理が、試験時にまとまったデータが用意されたことで顕在化した形になります。 無駄な結合をなくす、結合前に絞り込んで対象レコード数を減らす、などの対応でこれらは解決できました。 その2 set句を付けたのに反映されない! 問題 SQLにはset句というものがあり、実行時のパラメータを制御できます。 例えばひとつ前の問題でプランナが選択する結合方式の話をしましたが、 例えばクエリの実行前に set enable_nestloop to off; と記述することでnested loop join以外の結合方式を強要する、というような使い方ができます。 何らかの理由でプランナが最適な実行計画を選んでくれない状態になった場合など、応急処置的にset句を付与することはよくあり、私のアプリでも何箇所かに利用されています。 ところがある日、いつものようにスロークエリをexplain analyzeしていたところ、set句を付与したのにもかかわらずそれがクエリに反映されていない、という事態に気付いてしまいました。 原因 結論としては、「set句とset句を付与したいクエリが別のトランザクションで実行されていた」ことが理由でした。 set句には session/local というパラメータが指定でき、それぞれset句を同一セッションまたは同一トランザクションに限定できます。 今回のケースではクエリを実行する前に set local ~ という形でパラメータ制御を付与しており、これが記述されたファイルをpsqlというpostgres公式のクライアント経由で実行していました。 psqlから外部SQLを実行する場合には psql -f (ファイル名) というようなコマンドを実行します。ここで実行されるSQL文は単一トランザクションで実行されないため、SQLファイル先頭のset句とクエリが異なるトランザクションで実行されてしまっていまい結果としてset句の制御がクエリに適用されない、という現象が起きていました。 解決策としては set session とする、クエリを begin; ~ commit; で囲って同一トランザクション内で実行する、psqlの実行オプションに --single-transaction を指定する、など色々あります。 ※完全に余談ですが、「nested loop joinは完全にoffにできない」ことが理由の場合も(まれですが)考えられます。 set enable_nestloop to off; とした場合の挙動は、nested loop joinのcostを極端に大きくすることで選ばれなくする、というもののため、それでも選ばれてしまう可能性はあります(例えばenable_nestloop, enable_hashjoin, enable_mergejoinを全てoffにするとnested loop joinが選択されます)。 その3 どうしてそんなにのろいのか join編 さっきからこの人joinの話しかしてないですね。 問題 ある日、いつものようにスロークエリをexplain analyzeしていたところ、大量のテーブルをjoinする部分に時間がかかっていることがわかりました。 今回のケースでは各テーブルのレコード数はそこまで多くなく、結合した結果が膨れ上がるようには思えないテーブル条件にもかかわらず、条件から考えて不可解な時間がかかっていました。 原因 基本的にjoinの順序はpostgresのプランナが最適なものを選んでくれるため、SQL文を書く際にあまり結合順は意識しません。 しかし、結合順序を計算するコストが高過ぎる場合はSQLの記述順に結合されます(基本的に全探索で最もコストの低い結合順が選ばれるため、計算コストは指数オーダーで増えます)。 この「コストが高すぎる」判定として join_collapse_limit というパラメータが使用されており、最終的に結合順の計算対象となるテーブル数がこの値を超えた場合は結合コストの計算が行われず記述順に結合されます(デフォルトでは8となります)。 今回の対応としては、試しによさげな結合順を試してみたところ想定通りの速度でクエリが返ってくるようになったため、 join_collapse_limit の設定値は変更せずクエリ側の修正のみで解決できました。 その4 どうしてそんなにのろいのか and編 問題 ある日、いつものようにスロークエリをexplain analyzeしていたわけではないのですが、試験中にアプリを操作していたところ、とても簡単な条件なのにレスポンスが時間内に返ってこないという問題が発生していました。 原因 いつものようにスロークエリをexplain analyzeしようと思ってクエリをログに出力してみたのですが、その結果 select (columns) from table where column > 0 and column > 0 and column > 0 and column > 0 and column > 0 and column > 0 and column > 0 and ... というクエリが(アプリのバグによって)発行されていることが判明しました。 今回のケースはアプリのバグを修正して事なきを得たのですが、後々気になっていつものようにスロークエリをexplain analyzeで調べたところ、(当たり前と言われればそうですが)同じ条件であっても記述された回数評価されているようだ、ということがわかりました。 極端すぎる例だったかもしれませんが、条件の重複がクエリパフォーマンスの悪化を招いている可能性はアプリのロジックだけを追っていると意外と気付きにくいのではないか、という点ではよい教訓になりました。 その5 計算できるレコードに絞ったのに計算できないぞ? 問題 ある日、いつものようにスロークエリをexplain analyzeしようと思って遅いAPIにリクエストを投げたところ、500エラーが返ってきました。 このリクエストでは以下のようなクエリを実行していました。 -- Step.1: where句で計算が可能なレコードを絞る with target as ( select id from table where [keisan is dekiru] ) -- Step.2: 計算が可能なレコードに対して実際に料金を計算する ,calc as ( select id ,[keisan] as calc from plan inner join target using (id) ) -- Step.3: Step.1 に Step.2 を結合する select id ,calc from target left join calc using (id) エラーログを確認したところ、最初にwith句で計算可能なレコードに絞ったにもかかわらず「計算できないレコードに対して計算しようとしている」ということがわかりました。 原因 今回のケースの特徴は、異なるCTEで何度も同じテーブルを参照するような作りになっている点です。 postgresのプランナが最もコストの小さい実行計画を作る際、このカラムは同じタイミングで取るのが最も適当だと判断した場合は副問い合わせをまたいだ最適化が行われる可能性があります。 また、postgres 12からはCTEのmaterialize(一時テーブルの実体を生成する処理)がデフォルトでoffとなっており、CTEをまたいだクエリの最適化が行われる可能性があります。 さらに今回の場合、Step.1とStep.2のレコードは一対一対応しているにもかかわらず、Step.3で内部結合していない点も問題でした。 これらの要素が重なった結果、Step.2のpriceを計算する部分が先に評価されてしまい、Step.1の絞り込み対象外のレコードに対して実行された結果エラーとなったことがわかりました。 要するにこの記事を通して私が言いたかったことは クエリチューニングをする際はexplain analyzeをしましょう!ということです。 SQLにはexplainという実行計画を問い合わせる句がありますが、postgresの場合はanalyzeというパラメータを付与することで、実際にクエリを実行した結果の情報も含めて取得できます。 今回の例では紹介しませんでしたが、例えば実行計画で見積もった行数と実際に取得できた行数に乖離がある場合などは、プランナの利用する統計情報が実態に即していないことがスロークエリの原因になっていることがわかったりもします。 十八史略にも「先づexplain analyzeより始めよ」とある通り、クエリ改善における最初の一手はexplain analyzeです。迷ったらexplain analyze。困ったらexplain analyze。何はともあれexplain analyze。 そんなわけで、今日も元気にスロークエリをexplain analyzeしてこようと思います。皆さんもよき検索ライフを!
これは、FORCIA Advent Calendar 2021の3日目の記事です。 どうもこんにちは。 タブをスペースに変換するのを忘れてpsqlに怒られがちな新卒2年目の駆け出しエンジニア吉田です。(Web業界に2年近くもいて「駆け出し」を名乗ってよいものか微妙ですが、強気に主張していきます) 私はここ一年間、大規模旅行アプリの開発に携わってきました。旅行アプリの検索機能は、フォルシアの主力事業であるSpookが最も威力を発揮する領域でもあります。 Spookは「膨大で複雑なデータ」を「高速」に検索するための技術基盤です。(参考: https://www.forcia.com/tech
これは、 FORCIA Advent Calendar 2021 の1日目の記事です。 エンジニアの松本( @matsu7874 )です。 FORCIA CUBEには Rustやサマーインターンの記事 を書くことが多いです。 さて、Rustを導入する際、直ちにシステム全体をRustで書き直すのではなく、既存資産を有効活用しながら開発を進められます。 この記事ではFFI(foreign function interface)を使って既に書かれたプログラムを活用しながら、一部をRustに置き換えていく方法について解説します。 特に次の2つのパターンに分けて解説します。 A: C言語やPythonで書かれた一部のモジュール(典型的には速度や安全性が重要な部分)をRustに置き換えたい。 B: 主な実装はRustに変更するが、部分的に別の言語で書かれたモジュールを活用したい。 ディレクトリ構成 作業ディレクトリ直下に下記のディレクトリ・ファイルが置かれているとして以降お読みください。 説明のため、 legendary_c_lib と modest_rs_lib_for_c には、正の整数a,bを受け取り、それらの最大公約数を返すgcd関数を実装しています。 ソースコードは こちら からダウンロードできます。 - legendary_c_lib/ 手が入れられないC言語のライブラリ - legend.c - main_c/ C言語で実装された既存のアプリケーションコード - main.c - main_py/ Pythonで実装された既存のアプリケーションコード - call_c.py - call_rs.py - main_rs/ Rustで新たに実装される legendary_c_lib を呼びだすコード - src/main.rs - build.rs - Cargo.toml - modest_rs_lib_for_c/ Rustで新たに実装される main_c, main_py から呼びだされるコード - src/lib.rs - Cargo.toml パターンA: C言語やPythonからRustを使う C言語から使ってもらえるようにRustを書く まずはC言語で実装されているアプリケーションから、新しくRustで実装するモジュールを使ってもらえるようにしましょう。 端的にいうとRustで実装したコードを共有ライブラリにして、C言語側のビルド時にリンクします。 cargo new --lib modest_rs_lib_for_c から lib.rs に下記のコードを書きます。 // modest_rs_lib_for_c/src/lib.rs #[no_mangle] pub extern "C" fn gcd ( a : u64 , b : u64 ) -> u64 { let mut x = a ; let mut y = b ; if x < y { let t = x ; x = y ; y = t ; } while y > 0 { let t = x % y ; x = y ; y = t ; } x } #[test] fn test_gcd () { assert_eq! ( gcd ( 12 , 4 ), 4 ); assert_eq! ( gcd ( 12 , 3 ), 3 ); assert_eq! ( gcd ( 12 , 7 ), 1 ); assert_eq! ( gcd ( 2 , 70 ), 2 ); } おおよそ普通のRustのコードです。テストコードも普通に書けます。 Rustの世界で完結する場合は関数定義は fn gcd(a: u64, b: u64) -> u64 となりますが、Cから呼びだしたい場合は #[no_mangle] と pub extern "C" をつけます。 こちら(Cと少しのRust - The Embedded Rust Book) に書いてある通りなのですが、コンパイルしたオブジェクトコードをC言語で書かれたプログラムからでも利用できるようにするための宣言です。 続いて Cargo.toml を編集し、 crate-type を cdylib にします。 # modest_rs_lib_for_c/Cargo.toml [lib] crate-type = ["cdylib"] cargo build --release でコンパイルすると target/release/modest_rs_lib_for_c ではなく target/release/libmodest_rs_lib_for_c.so が作られます。 このファイルをリンクすることでC言語側からRust実装の関数を呼びだすことができます。 C言語側の実装を見ていきましょう。 main_c/main.c はどこかにある gcd という関数を呼びだすだけのコードです。 //main_c/main.c #include<stdio.h> // 関数定義のみ int gcd ( int a , int b ); void main (){ printf ( "%d \n " , gcd ( 12 , 8 )); } 例えば ../legendary_c_lib/legend.so に gcd の実装があるとすれば次のコマンドでコンパイル・実行が可能です。 gcc main.c ../legendary_c_lib/legend.so -o main_c.o 実行すれば正しく 4 が表示されます。 ./main_c.o リンクするオブジェクトをRust実装に切り替えましょう。下記のコマンドでコンパイル・実行ができます。 gcc main.c ../modest_rs_lib_for_c/target/release/libmodest_rs_lib_for_c.so -o main_rs.o ./main_rs.o C言語実装のgcd関数を使ったときと同じように 4 と出力されることを確認できると思います。 これでC言語からRustで書かれた関数を呼びだすことができました。 PythonからRustを呼ぶ(ctypesを使ってPython側で面倒を見る) PythonからはCで実装された関数を呼びだすことができ、上記の方法で作った共有ライブラリは同じ方法で、Pythonからも使うことができます。 ctypes --- Pythonのための外部関数ライブラリ -- Python 3.10.0b2 ドキュメント PythonからC言語で実装された関数を実行する例を示します。 # main_py/call_c.py import ctypes legend = ctypes . CDLL ( '../legendary_c_lib/legend.so' ) legend . gcd . argtypes = [ ctypes . c_int , ctypes . c_int ] legend . gcd . restype = ctypes . c_int assert legend . gcd ( 120 , 16 ) == 8 同じようにRustでCから使えるように作った共有ライブラリをPythonから使うことができます。 # main_py/call_rs.py rust = ctypes . CDLL ( '../modest_rs_lib_for_c/target/release/libmodest_rs_lib_for_c.so' ) rust . gcd . argtypes = [ ctypes . c_int , ctypes . c_int ] rust . gcd . restype = ctypes . c_int assert rust . gcd ( 120 , 16 ) == 8 PythonからRustを呼ぶ(PyO3を使ってRust側で面倒を見る) 上記の方法ではPython側で関数の引数や戻り値の型を明示するなど面倒なことがありました。 Python側にあまり手を入れたくない場合Rust側でもう少しカバーすることができます。 PyO3/pyo3: Rust bindings for the Python interpreter ずばりサンプルそのままなので説明は割愛しますが、 pymodule に pyfunction を詰め込んでいく形が分かりやすいと感じました。 maturin を使わない場合は target/release/string_sum.so を target/release/libstring_sum.so にリネームして sys.path に target/release を追加すると import string_sum ができるようになります。 パターンB: Rustから既存資産を使う RustからC言語で実装された関数を呼びだす 続いてRustからC言語で実装された関数を呼びだす方法を説明します。端的に言うと、C言語側でアーカイブライブラリを作成し、rustcでコンパイル時にリンクします。 ※ こちら(RustからCを呼ぶ - Embedded Rust Techniques) で解説されているように rust-lang/rust-bindgen を使ってC言語の実装からRust側のインターフェイスを自動作成する方法もありますが、原理を理解するために自分の手で実装します。 ※ 『実践Rustプログラミング入門』 や Rustと少しのC - The Embedded Rust Book などで cc - crates.io: Rust Package Registry を使って、Rust側のbuild時にC言語側のコンパイルをする方法が紹介されていますが、やはり何が行われているかを理解するために、最低限必要なリンク処理を明示的に実装します。 C言語側でアーカイブファイルを用意します。 gcc -c legend.c -o legend.o ar crs liblegend.a legend.o 続いて cargo new main_rs でクレートを作成し、 main_rs/src/main.rs を編集します。 // main_rs/src/main.rs extern "C" { fn gcd ( a : i32 , b : i32 ) -> i32 ; } fn safe_like_gcd ( a : i32 , b : i32 ) -> i32 { let mut res = 0 ; unsafe { res = gcd ( a , b ); } res } fn main () { let mut res = 0 ; unsafe { res = gcd ( 12 , 8 ); } assert_eq! ( res , 4 ); println! ( "{:?}" , res ); res = safe_like_gcd ( 12 , 8 ); assert_eq! ( res , 4 ); println! ( "{:?}" , res ); } extern "C" のブロック内で定義されるC言語実装の関数はRustコンパイラの検証を受けていないので unsafe ブロックの中でしか使えません。呼びだしごとにunsafeが登場しては不便ですから、 safe_like_gcd のような内部に unsafe を押し込めたラッパー関数を書くことがあります。 ビルド時にリンクをする部分を見ていきましょう。 Cargo.toml でビルドスクリプトを設定します。 [package] build = "build.rs" main_rs/build.rs では liblegend.a をリンクするように下記の記述を追加します。 // main_rs/build.rs fn main (){ println! ( "cargo:rustc-link-search=native=/path/to/legendary_c_lib" ); println! ( "cargo:rustc-link-lib=static=legend" ); } ビルドスクリプトにおいて cargo: で始まる行はCargoを制御するための行であり、今回はリンクしたいオブジェクトの親ディレクトリのパスとリンクしたいライブラリの名前を指定しています。 rustcを直接実行する場合のコマンドで表現すると下記と同じ意味合いです。 rustc src/main.rs -L ../legendary_c_lib -llegend ./main ではCargoでコンパイル・実行してみましょう。 cargo run --release 4 4 gcd , safe_like_gcd それぞれが4を返しており期待通りに動作していることが確認できました。 より詳しく知りたい人は下記の資料をご確認ください。 FFI - The Rustonomicon Build Scripts - The Cargo Book おわりに 本記事ではC言語からRust、PythonからRust、RustからC言語で実装された関数を呼びだす方法について解説し、より詳しい資料へのリンクを提供しました。 これからRustで開発を始める皆様の助けになり、Rustコミュニティが盛り上がっていくことを願います。 今月末に弊社が運営している RustのLT会 Shinjuku.rs #19 @オンライン がございますので、なにかやってみたという方はぜひご参加いただければと思います。 また、明日以降も FORCIA Advent Calendar 2021 の記事が公開されますので、ぜひご覧ください。
RustでFFIを使う・FFIでRustを使う これは、FORCIA Advent Calendar 2021の1日目の記事です。 エンジニアの松本(@matsu7874)です。 FORCIA CUBEにはRustやサマーインターンの記事を書くことが多いです。 さて、Rustを導入する際、直ちにシステム全体をRustで書き直すのではなく、既存資産を有効活用しながら開発を進められます。 この記事ではFFI(foreign function interface)を使って既に書かれたプログラムを活用しながら、一部をRustに置き換えていく方法について解説します。 特に次の2つのパターンに分
こんにちは、第2旅行プラットフォーム部エンジニアの力石です。 早いものでもう12月、年々時間の流れが早くなっているような気がします。 さて、12月といえば毎年恒例になりつつあるアドベントカレンダーですね! 過去のアドベントカレンダーはこちらから! 2020年: https://www.forcia.com/blog/advent-calendar2020/ 2019年: https://www.forcia.com/blog/advent-calendar2019/ 2018年: https://www.forcia.com/blog/advent-calendar2018/ アドベントカレンダーとは 元々はキリスト教において、待降節(advent)にクリスマスまでカウントダウンするためのカレンダーで、毎日1つずつ日付の窓を開き、中に入っているお菓子や小さな贈り物を楽しむものが一般的です。 この風習になぞらえて、インターネット上では12月1日からクリスマスまでの25日間、特定のテーマや団体に関するブログ記事を毎日1件ずつ、持ち回りで投稿するお祭りが様々なサイトで開催されています。 FORCIAアドベントカレンダー2021始まります! 明日12月1日より毎日、フォルシア社員計25人が記事を投稿します。エンジニア以外にも、営業や広報の社員も参加し、幅広いテーマの記事を公開していきます! 記事のテーマを少しだけお見せすると、以下の様なものがあります。 TypeScriptでの型パズル SQL高速化 フォルシアの文化について 新しい記事が公開されたら、下記の特集ページに追加していきます。 FORCIA アドベントカレンダー2021 また、更新情報はフォルシアのSNSでも告知しますので、ぜひフォロー&チェックしてくださいね。 Twitter: @forcia_pr Facebook: @forciapr それでは、明日からの更新をお楽しみに!
今年もこの時期がやってまいりました!FORCIAアドベントカレンダー2021始まります! こんにちは、第2旅行プラットフォーム部エンジニアの力石です。 早いものでもう12月、年々時間の流れが早くなっているような気がします。 さて、12月といえば毎年恒例になりつつあるアドベントカレンダーですね! 過去のアドベントカレンダーはこちらから! 2020年:https://www.forcia.com/blog/advent-calendar2020/ 2019年:https://www.forcia.com/blog/advent-calendar2019/ 2018年:https:
はじめに 第1旅行プラットフォーム部エンジニアの六車と申します。 大手旅行代理店の検索サイトの構築をメイン業務としつつ、社内のコンテナ・クラウド活用推進活動も行っています。 この記事では、社内コンテナ推進活動の一環で行ったDockerfileの書き方のベストプラクティスのまとめを紹介します。 この記事のゴール:効率的かつ保守性の高いDockerfileの書き方を知る。 Dockerfileとはなんぞや Dockerfileはずばり 「docker imageを作るための設計図」 のようなものです。 Dockerfileはdocker imageを自動構築する際に必要となるファイルで、1image = 1Dockerfileと対応します。 以下がDockerfileの例です。 FROM ubuntu:20.04 COPY . /app RUN make app CMD python /app/app.py DockerfileはFROM, COPY, RUNなどの命令文で構成されています。何のimageをもとに何を実行してどのようなimageを構築するか、といった内容が書かれています。実行時は上から順番に読み込まれます。Dockerfileを見ればimageの構築過程を確認することができます。 Dockerfileのカレントディレクトリで docker build -t <IMAGE:TAG> . とすれば、Dockerfileが読み込まれ、imageが構築されます。例えばforciaというimageを2021というタグを付けて作りたいとき docker build -t forcia:2021 . とします。tagをつけない場合自動で:latestというタグが付与されますが、必ずtagをつけたほうが良いとされています。誰が見てもversionを確認できるためです。 Dockerfileをきちんと書くべき理由 DockerfileはDocker imageの設計図なわけですが、意外と簡単にかけます。localのファイルをCOPYで持ってきたり、RUNで通常の環境構築と同じように実行すれば、とりあえず動かすことができます。 しかしながら、Docker imageを商用環境で利用する際には以下の点を考慮する必要があります。 再現性 buildするタイミングによってimageの中身が変わらないようにする セキュリティ コンテナに不正に侵入された際の影響を限定するために、最小限の権限しか持たないようにする 可搬性 imageサイズはなるべく小さい方がbuild時間、配布時間の短縮になる とりわけ、再現性とセキュリティは重要です。例えば同じDockerfileでも、ある環境、あるタイミングではbuildできなかったりすると困ります。またコンテナに入れたらroot権限を得ることができる状態であるのは危険です。したがってコンテナを商用環境で使用することを考えているならば、Dockerfileを きちんと 書く必要があるわけです。そのためのBest practiceを以下で紹介します。 Best practices for writing Dockerfile 本題です。 フォルシアにおいてのDockerfileのガイドラインとアドバイスを列挙します。このベストプラクティスには、Docker社公式のベストプラクティス、世の中一般的によく言われているもの、フォルシア社内特有のルールが混合しています。ですので絶対的に正しいものとしてではなく、あくまで参考として読んでいただけると幸いです。 とりわけ推奨したいものに★をつけています。 不要な特権を避ける★ ビルドコンテキストについて理解しよう .dockerignore を使ったファイル除外の指定 マルチステージビルドの利用★ 不要なパッケージをインストールしない アプリケーションの分割 ビルドキャッシュの利用 レイヤー数は最小に、並び順も意識 信頼できるベースイメージを使用する★ Linterを使う★ 不要な特権を避ける rootlessコンテナ コンテナでプログラムをroot (UID 0) として実行することはやめましょう。 これは一般のLinux環境と同様ですが、root権限が与えられるとすべてのファイルが丸見え、操作可能となるからです。コンテナ内のプログラムがrootとして実行されていると、コンテナを単に実行するだけで、ホストや他のコンテナがのっとられます。 非常に危険です。 またDocker側で生成したファイルの権限がrootになる問題もあり面倒です。 非root 権限として実行するには、Dockerfile にいくつかの追加ステップが必要になります。以下にそのステップを記載します。 FROM alpine:3.12 *# Create user and set ownership and permissions as required* RUN adduser -D myuser && chown -R myuser /myapp-data *# ... copy application files* USER myuser ENTRYPOINT ["/myapp"] 特定の UID にバインドしない Dockerfile内で一般ユーザーを作成する際に困るのが、コンテナ内の実行ユーザーとホストのユーザーのUID/GIDが一致しない問題です。特に開発環境でローカルのファイルをコンテナにマウントする場合によく発生します。 対策として以下のようにDockerfile内でuid/gidを指定する方法があるのですが、ローカルマシンの環境依存するので良くないです。 FROM ubuntu:20.04 ARG UID=1001 ARG GID=1001 RUN useradd -u $UID -o -m myuser RUN groupmod -g $GID -o myuser ... コンテナ内の実行ユーザーとローカルのユーザーのUID/GIDを合わせるのはコンテナ実行時( docker run 時)にしましょう。以下の記事がとても参考になります。 dockerでvolumeをマウントしたときのファイルのowner問題 ビルドコンテキストについて理解しよう ビルドコンテキストとは docker build 実行時に指定するディレクトリのこと。 例えば docker build -t forcia:2020 . では最後の . がビルドコンテキストです。構築時、ビルドコンテキストとして現在のディレクトリ以下にある 全てのファイルやディレクトリ をDocker deamonに送信してしまいます。ビルドコンテキストに余分なディレクトリ・ファイルがあると、build時に時間がかかる、メモリを消費する原因となります。例えば、ビルドコンテキストに100MBのファイルがあるとimageのサイズが100MBプラスとなってしまいます。このような事態を防ぐためにも Dokcerfile用のディレクトリを作成し、そのディレクトリには無駄なファイルは配置しない ようにすべきです。 .dockerignore を使ったファイル除外の指定 Dockerfile用のディレクトリを作ったがそのディレクトリ内にビルドコンテキストとして含みたくないファイルが存在する、もしくはDockerfileをアプリのソースファイルが配置されているディレクトリと同じにしたいということもあると思います。 そんなときに .dockerignore を用いると、ビルドコンテキストとして無視します。 記述ルールについては こちら マルチステージビルドの利用 マルチステージビルドを利用すると、複数の FROM 命令をDockerfileに記述できます。 Docker 17.05以降で使えます。構築手順(プロセス)の最終段階(ステージ)でイメージをビルドするため、ビルド・キャッシュの効果によってイメージ・レイヤを最小化できます。 FROM golang:1.14 as builder WORKDIR /app COPY . /app/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -installsuffix cgo -o main main.go FROM alpine:3.12 COPY --from=builder /app/main /bin/main COPY . . EXPOSE 8080 CMD ["/bin/main"] マルチステージビルドを使うことで、成果物だけ最終ステージにCOPYすればよいため、build用の中間イメージにおいてはRUNをまとめる必要もなくなり可読性がupします。 参考: https://future-architect.github.io/articles/20210121/ 不要なパッケージをインストールしない 複雑さ、依存関係、ファイル容量、構築回数を減らすために「あったほうがいいだろう」くらいの必要性のパッケージのインストールは避けるべきです。例えば、データベースのコンテナにテキストエディタは必要ありません。軽量のベースイメージを使うのもいいです。 また軽量ベースイメージとしては distroless , alpine などが有名です。(ただし 軽量Dockerイメージに安易にAlpineを使うのはやめたほうがいいという話 というのもあったりします。) Tip: Debian及びUbuntu系では --no-install-recommends をつけると、不必要なパッケージのインストールを防ぐことができます。 FROM ubuntu RUN apt-get update && apt-get -y install python FROM ubuntu RUN apt-get update && apt-get -y install --no-install-recommends python アプリケーションの分割 各コンテナは「一つの役割」のみ持つべきです。 アプリケーションを複数のコンテナに分離すると、水平スケールやコンテナの再利用が行いやすくなります。例えば、ウェブ・アプリケーションのスタックであれば、ウェブ・アプリケーション、データベース、インメモリのキャッシュを分離した状態で管理するために、それぞれが自身のユニークなイメージを持つ、3つのコンテナで構成することができます。 ただし、厳密に「1コンテナ1プロセス」にこだわる必要もありません。どの方法が最善かは都度判断が必要です。コンテナが相互に依存する場合、 Dockerネットワーク の活用も有効です。 ビルドキャッシュの利用 imageの構築は、Dockerfile行の命令順(上から順)にしたがって実施されます。Docker は各命令で既存のイメージにキャッシュがあるかどうか検査します。もしキャッシュあれば、新しい(重複する)イメージを作成するのではなく、再利用します。キャッシュ利用のルールは以下の通り。 FROM のimageが既にある場合、Dockerfileの命令と親イメージから派生した子イメージの一致を確認し、一致するものがなければキャッシュを破棄して構築する。 ADD と COPY 命令はチェックサムのみ比較対象。アクセス時間・更新時間は無関係。それ以外の内容変更があればキャッシュ破棄 構築時、Dockerfileの命令行しか見ない(コンテナ内の比較はない) 例えば、 RUN apt-get -y update の実行が古くてもDockerは判断しない。 もしもキャッシュを一切使わないのであれば、 docker build コマンドで --no-cache=true オプションが使えます。 レイヤ数は最小に、 順番も意識する Docker の古いバージョンでは、確実に性能を出すために、イメージ・レイヤ数の最小化が非常に重要でした。この制限を減らすため、以下の機能が追加されました。 RUN 、 COPY 、 ADD 命令のみレイヤを作成します。他の命令では一時的な中間イメージ(temporary intermediate image)を作成し、ビルド容量(サイズ)の増加はありません。 可能であれば マルチステージビルド を使い、最終イメージの中に必要な成果物のみコピーします。これにより、最終イメージの容量は増えずに、中間構築ステージにツールやデバッグ情報を入れられるようになります。 簡単には、 RUN 、 COPY 、 ADD 命令はなるべく少なくする ことが重要です。 またビルドキャッシュを有効活用するために、レイヤ(命令)の実行順番を意識することが重要です。 FROM ubuntu:20.04 COPY . /usr/local/src # よく差分が生じやすいレイヤ RUN apt-get update RUN apt-get -y install vim 以上のDockerfileのように記述すると、Dockerfileのあるディレクトリに差分があるとそれ以降のレイヤもキャッシュは破棄されてやり直しとなります。 頻繁に変更するレイヤはなるべく後ろの方に書くとビルドキャッシュの有効利用ができ、build時間の短縮につながります。 FROM ubuntu:20.04 RUN apt-get update RUN apt-get -y install vim COPY . /usr/local/src # よく差分が生じやすいレイヤは後ろの方に書く 信頼できるベースイメージを使用する ベースイメージには中身が不明なイメージは使わないようにしてください。 Docker Hubにある公式イメージでも、tagを一意に決めていたとしても、中身は変わりうるので突然動かなくなる場合があります。 よってベースイメージは信頼できるものを使用したほうがよいです。例えば社内のインフラチームが管理していて、定期的にupdateを行っており、中身が明確にわかって、問い合わせもできるようなものです。 またベースイメージとして distroless イメージを使うのもよいかもしれません。distrolessは商用環境に特化したimageでランタイムに必要ないということでシェルすらありません。なので軽量でセキュアです。 Linterを使う Dockerfileを書く際はLint toolを使いましょう。 以下のhadolintとdockleを使うことを推奨します。Linterに怒られながらDockerfileを作ればよいので快適です。 hadolint DockerfileのLinterとして hadolint というものがデファクトスタンダードとして存在します。 対象のDockerfileと同じディレクトリに配置した上で docker run --rm -v "$PWD:/work" -w /work hadolint/hadolint hadolint Dockerfile を実行すればよいです。 例えば以下のDokcerfileを対象としてhadolintを実行してみます。 FROM node:14 RUN apt-get update RUN apt-get install -y python3 WORKDIR /app COPY . . RUN yarn install --production # RUN adduser nodejs && chown -R nodejs /app # USER nodejs CMD node src/index.js ❯ docker run --rm -v "$PWD:/work" -w /work hadolint/hadolint hadolint Dockerfile Dockerfile:3 DL3009 info: Delete the apt-get lists after installing something Dockerfile:4 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>` Dockerfile:4 DL3015 info: Avoid additional packages by specifying `--no-install-recommends` Dockerfile:12 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments このように丁寧にアドバイスしてくれます。 VSCodeのextension もあるそうです。こちらを使えばリアルタイムにエディタがメッセージをだしてくれます。 またhadolintのうちどのルールを無視するかなどの設定を.hadolint.yamlとしてas codeで管理できます。 ignored: - DL3000 # Use absolute WORKDIR. - DL3005 # Do not use apt-get upgrade or dist-upgrade. trustedRegistries: - hoge.dkr.ecr.ap-northeast-1.amazonaws.com # 特定のレジストリのベースイメージしか使えなくする たとえばフォルシアではベースイメージはprivate ECRにある、社内のコンテナチームが管理しているものを使用することを推奨しており、hadolintの ruletrustedRegistries で使えるコンテナレジストリをあえて制限しています。 ❯ hadolint --config ./.hadolint.yaml Dockerfile dockle dockle はビルドしたDockerImageをスキャンして、セキュリティ上の問題が無いかチェックしてくれるツールです。 Dockerfileからbuildしたimageを、dockleでスキャンした結果を以下に示しています。rootユーザでないことや、不必要なファイルを追加していないかなども注意してくれます。 ❯ docker build -t lint:test . ❯ dockle lint:test WARN - CIS-DI-0001: Create a user for the container * Last user should not be root INFO - CIS-DI-0005: Enable Content trust for Docker * export DOCKER_CONTENT_TRUST=1 before docker pull/build INFO - CIS-DI-0006: Add HEALTHCHECK instruction to the container image * not found HEALTHCHECK statement INFO - CIS-DI-0008: Confirm safety of setuid/setgid files * setuid file: urwxr-xr-x usr/bin/chfn * setuid file: urwxr-xr-x usr/bin/newgrp * setgid file: grwxr-xr-x sbin/unix_chkpwd * setuid file: urwxr-xr-x bin/mount * setuid file: urwxr-xr-x usr/bin/gpasswd * setgid file: grwxr-xr-x usr/bin/ssh-agent * setgid file: grwxr-xr-x usr/bin/expiry * setgid file: grwxr-xr-x usr/bin/wall * setuid file: urwxr-xr-x usr/lib/openssh/ssh-keysign * setuid file: urwxr-xr-x bin/su * setuid file: urwxr-xr-x bin/umount * setuid file: urwxr-xr-x bin/ping * setuid file: urwxr-xr-x usr/bin/passwd * setgid file: grwxr-xr-x usr/bin/chage * setuid file: urwxr-xr-x usr/bin/chsh INFO - DKL-LI-0003: Only put necessary files * unnecessary file : app/Dockerfile hadolintと同様に適用しないruleを.dockleignoreファイルで管理することもできます。dockleコマンド実行時のカレントディレクトリ下に配置するだけで適応されます。 # Avoid empty password DKL-LI-0001 # Avoid apt-get upgrade, apk upgrade, dist-upgrade DKL-DI-0003 CIで動かす hadolint, dockleともにCIで動かすこともできます。 Dockerfileの差分があればlintを実行することでよりクリーンなDockerfileが維持されることが期待されます。 以下はGitLab CIでの例です。 stages: - dockerfile_test - docker_image_test docker-hadolint: image: hadolint/hadolint:latest-debian stage: dockerfile_test script: - hadolint Dockerfile rules: - changes: - Dockerfile dockle_test: variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: "" DOCKER_DRIVER: overlay2 services: - docker:dind stage: docker_image_test before_script: - apk -Uuv add bash git curl tar sed grep script: - docker build -t dockle-ci-test:${CI_COMMIT_SHORT_SHA} . - docker save dockle-ci-test:${CI_COMMIT_SHORT_SHA} > dockle-ci-test.tar # 何故かdockle実行時にdokcer.ioを見てしまうので一旦tarに固める - | VERSION=$( curl --silent "<https://api.github.com/repos/goodwithtech/dockle/releases/latest>" | \\ grep '"tag_name":' | \\ sed -E 's/.*"v([^"]+)".*/\\1/' \\ ) && curl -L -o dockle.tar.gz <https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz> && \\ tar zxvf dockle.tar.gz - ./dockle --exit-code 1 --input dockle-ci-test.tar rules: - changes: - Dockerfile おわりに Dockerfileのベストプラクティスを社内向けにまとめたものを公開させていただきました。 Dockerは使うだけならば、各フレームワーク公式がDocker Imageを公開していますので簡単に使うことができます。しかし、特に商用環境で利用することを考慮し、セキュアで再現性を高く、かつ可搬性も上げるためには様々な考慮が必要だよね、という話が社内であがったため今回の記事を書きました。 今回紹介したベストプラクティスは、Docker社公式であるものとフォルシアで特に重視しているものが混ざり合っています。特にLinterの使用はベストプラクティスをAS Codeで管理することができるので定着推進がしやすいため推しているところです。 この記事を読んでくださったかたの一助となりましたら幸いです。 おまけ: 参考になりそうなdocker-compose 社外の参考になりそうなdocker-compose docker-compose awesome compose-spec(公式ドキュメント) Best Practices Around Production Ready Web Apps with Docker Compose 参考 公式ドキュメント https://www.slideshare.net/zembutsu/explaining-best-practices-for-writing-dockerfiles Dockerfileを書くためのベストプラクティス【参考訳】v18.09 Dockerfileのベストプラクティス Top 20 DockerでRUNをまとめた方が良いとは限らない ※サムネイル画像出典 https://icons8.com/
はじめに 第1旅行プラットフォーム部エンジニアの六車と申します。 大手旅行代理店の検索サイトの構築をメイン業務としつつ、社内のコンテナ・クラウド活用推進活動も行っています。 この記事では、社内コンテナ推進活動の一環で行ったDockerfileの書き方のベストプラクティスのまとめを紹介します。 この記事のゴール:効率的かつ保守性の高いDockerfileの書き方を知る。 Dockerfileとはなんぞや Dockerfileはずばり 「docker imageを作るための設計図」 のようなものです。 Dockerfileはdocker imageを自動構築する際に必要となるファイル
こんにちは、エンジニアの松本です。この記事はShinjuku.rs #14 において『Rustでロギングってどうすればいいんですか?』というタイトルでデモを中心に発表した内容をブログ記事としてまとめ直したものです。 Rustの標準的なロギングの仕組みとそれを実現するクレート群の使い方を解説します。 ※クレート:他のプログラミング言語でいうところの「ライブラリ」に相当する概念。ある機能を提供する再利用可能なコード群のこと。 ロギングは大切 アプリケーションを運用していくにあたって、様々な場面でアプリケーションの動作を確認したくなることがあります。処理はいつ終了したか、どの処理に時
FORCIAアドベントカレンダー2020 25日目の記事です。 昨年に引き続きFORCIAアドベントカレンダー最終回を担当します、エンジニアの武田です。 今回は、私が担当しているプロジェクトでgit subtreeを利用することになったため、その紹介をしたいと思います。 git subtreeとは gitリポジトリ内で複数のgitリポジトリの履歴を管理することができる、gitのサブコマンドです。その名前のとおり、メインリポジトリの履歴(main tree)と取り込んだリポジトリの履歴(sub tree)を管理することができます。 git subtreeを利用することになった背景 あるプロジェクトのプライベートなリポジトリで、特定のディレクトリをプロジェクト外の開発者も開発できるようにする必要がありました。一部のディレクトリを別リポジトリとして切り出し、プロジェクト側に取り込むには submodule を利用するケースが多いかと思います。フォルシアの各プロジェクトでも共通モジュールを submodule としてリポジトリに取り込むケースが多いです。 一方で、submodule を取り込んでいるプロジェクト側で submodule も併せて開発していくケースでは以下のような課題がありました。 本体側と submodule を同時に修正する場合、それぞれのリポジトリで commit しなければならない それぞれのリポジトリに push し、本体側では submodule のコミットハッシュを更新する必要があり、手順が多い Merge Request/Pull Request を作成したときに submodule 内の差分を確認できない コードレビュー時にリポジトリを行ったり来たりして確認する必要がある submodule が更新された場合、各開発者が submodule update をしないと submodule が更新されず、アプリケーションが動かない、といったことが起きやすい そこで git subtree を利用してみることにしました。 実際にgit subtreeを使ってみてどうか コードレビューで subtree 側の差分についても確認できるというメリットが非常に大きいと感じています。 また、開発者は subtree である、ということを意識せずに開発を進められるため、取り込んだリポジトリに対して変更を加えていく場合は git subtree を利用した方が快適に開発を進めることができます。 一方、 git subtree に限った話ではありませんが、定期的にリモートの履歴を pull しておかないと競合が発生してつらいことになりそうです。git subtree 自体は素晴らしい仕組みですが、それをどのように利用し、運用していくか、といったところに難しさがあるような気がしました。 そちらについては今後運用を進めていく中で改善していければと考えています。 git subtreeコマンドについて 有名な話ですが、git subtree コマンドはシェルスクリプトで実装されています。 https://github.com/git/git/blob/master/contrib/subtree/git-subtree.sh 引数の処理、関数の呼び出し方、エラー処理、デバッグメッセージの出し方など非常に参考になる書き方が多いのでぜひ軽く眺めてみることをおすすめします。 各コマンドの詳細については git subtree --help で表示される説明が非常にわかりやすいため、詳細は省きますが git subtree add/split したときの動きについて簡単に説明します。 git subtree add/split 基本的に add/split で実現したいことは同じです。「本体リポジトリ」に別のコミット履歴を作り、 add は別のリポジトリをサブディレクトリとして取り込む split はすでにあるリポジトリからサブディレクトリに関する履歴を別ブランチに分離する という違いがあります。add は submodule add と似ていてイメージしやすいですが、split は submodule に対応するコマンドがなく、イメージしにくいかもしれません。split の名前のとおり「分離する」ということを意識すると理解しやすいです。 以下にそれぞれのコマンドを実行した場合のイメージ図を貼ります。 クリスマスということでコミットドットをオーナメントで表現してみました!(powered by gitgraph.js) add/split した場合、完全に別の履歴となって管理されるようになります。add の場合は squash することで余計な履歴が発生しないようにすることも可能です。 # add する場合のコマンド例 $ git subtree add --prefix={addしたいサブディレクトリ} {addしたいリポジトリのURL} main # split する場合のコマンド例 $ git subtree split --prefix={splitしたいサブディレクトリ} --annotate='(subtree) ' --branch subtree --rejoin split の場合、該当のサブディレクトリに対して変更を加えたコミットのみ、別のコミット履歴として切り出して分離してくれます。 過去のすべてのコミットをチェックすることになるため、split コマンドの実行に時間がかかるのですが、 --rejoin をつけることで subtree のコミットハッシュ、subtree のディレクトリがどこにあるか、といった情報がメインブランチに記録されます。 以降、git subtree split を実行する場合、このコミットより前の履歴を見なくする、といった工夫がされているため、コマンド実行時間が短縮されます。split に時間がかかる場合は定期的に --rejoin を実行すると良さそうです。 さいごに 非常に単純な仕組みを使ってこれだけ便利な機能が、1,000行に満たないシェルスクリプトで実装されていることに衝撃を受けました。外部のリポジトリを取り込みつつ、取り込んだリポジトリも開発していきたい、というケースでは git subtree の利用を検討してみてください。 FORCIAアドベントカレンダー2020も本日で終了となります。2021年が皆様にとって良い年になることを祈っています。メリークリスマス!良いお年を!
FORCIAアドベントカレンダー2020 25日目の記事です。 昨年に引き続きFORCIAアドベントカレンダー最終回を担当します、エンジニアの武田です。 今回は、私が担当しているプロジェクトでgit subtreeを利用することになったため、その紹介をしたいと思います。 git subtreeとは gitリポジトリ内で複数のgitリポジトリの履歴を管理することができる、gitのサブコマンドです。その名前のとおり、メインリポジトリの履歴(main tree)と取り込んだリポジトリの履歴(sub tree)を管理することができます。 git subtreeを利用することになった背景
本記事は Kubernetes3 Advent Calendar 2020 の 24 日目の記事です。 こんにちは。旅行プラットフォーム部エンジニアの小孫です。 昨年のアドベントカレンダーの記事 で、来年はk8sを本番環境で利用してアドベントカレンダーで書くぞと決意表明しましたが、なんとか今年中に有言実行できました。弊社の「 Masstery 」というクラウド型のデータクレンジングサービスのデータ変換のバッチ処理を、Fargate for EKSでk8sのJobとして動かしています。 k8s化に取り組んだ経緯 昨年k8sに触れてみて、本番環境で利用してみたいと考えたものの、私が普段担当しているアプリは自社サービスではなくインフラも弊社持ちではないため、実績がないままいきなりk8sを導入するのは難しい状態でした。 そんなときに、今年ローンチした弊社の新サービス「Masstery」のチームと話をする機会があり、k8sについて話しました。ちょうどMassteryでも将来のユーザー数の増加に備えて、多数のバッチ処理を同時並行で実行できるようにしておく必要がありました。 Massteryのデータ変換バッチでは商品データのフォーマット統一やカテゴリ情報の付与等を行っていますが、バッチ処理が実行されるタイミングはユーザー次第です。そのため通常のサーバーだと必要十分なリソースの調整が難しく、コストパフォーマンスが悪くなってしまいます。 そこで、AWSのFargate for EKSを利用することにしました。 Fargate はAWSが提供するサーバーレスのコンテナ実行基盤です。通常k8sを利用するには、Podを動かすためのワーカーノード(AWSではEC2)を用意する必要がありますが、Fargateではその必要がなくPod実行時に自動でワーカーノードが用意されます。起動に数分ほどかかるものの、必要な時に必要なだけのリソースを利用できるので、今回のようなバッチ処理にぴったりのサービスです。 k8s化にあたって行ったこと バッチ処理をFargate for EKSで動かすにあたって、大きく分けて以下の4つの対応を行いました。 コンテナイメージの作成 マニフェストやkubectlをラップしたスクリプトの作成 EKSクラスターの構築 実際に動かしてみての課題の対応 一般的にk8sクラスターの構築はインフラエンジニアだけが担当することが多いと思いますが、弊社ではアプリエンジニアとインフラエンジニアの垣根が低く、今回の私のようにアプリエンジニアであっても興味があればインフラ寄りのタスクを担当することがあります。 k8sについては昨年から本を読んだり遊びで動かしたりして、ある程度の予備知識はありましたが、今年 CKAD の合格を目指して勉強したおかげで、実践的な知識を得ることができました。AWSの知識と経験が浅くEKSクラスターの構築では苦労しましたが、インフラエンジニアと協力し、 社内で一足早くk8sを商用利用 したアプリの担当者にもアドバイスをもらいながら構築できました。 ここからは、k8s化の過程で工夫したことや困ったことをいくつか紹介します。 コンテナイメージの軽量化 バッチ処理をk8sで動かすにあたって、まずはコンテナイメージの軽量化から始めました。 こちら の記事にもあるように、Fargateではイメージのキャッシュが使えず毎回ECRからイメージをプルするため、イメージサイズがPodの起動時間に大きく影響します。 バッチ処理ではPythonイメージを使っていますが、 こちら の記事を参考に、pipでインストールしたライブラリをマルチステージビルドで実行用のコンテナにCOPYするようDockerfileを変更しました。これによりビルド時のみ必要で実行時には不要なファイルを省くことができ、イメージサイズを減らせました。Pythonのマルチステージビルドは他にも方法があるようですが、今のところこの方法で問題なく動いています。 # ビルド用コンテナ FROM python:3.x-buster as build-stage WORKDIR /tmp COPY requirements.txt /tmp RUN pip3 install -r requirements.txt # 実行用コンテナ FROM python:3.x-slim-buster # ビルド用のコンテナから、pipでインストールしたライブラリをコピー COPY --from=build-stage /usr/local/lib/python3.x/site-packages /usr/local/lib/python3.x/site-packages COPY --from=build-stage /usr/local/bin /usr/local/bin また、 Docker公式のベストプラクティス にも書かれていますが、apt-getでのライブラリのインストール後に、パッケージキャッシュをクリーンにし /var/lib/apt/lists を削除することでイメージサイズを減らしました。(apt-get updateの実行により、パッケージのinstall時に参照するインデックスファイルがダウンロードされるディレクトリが /var/lib/apt/lists です。) # 必要なライブラリをインストール後、キャッシュを削除 RUN apt-get update \ && apt-get install -y hoge fuga piyo\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* その他、不要にインストールしていたモジュールを削除することでコンテナイメージを軽量化し、運用上支障のないPod起動時間に短縮できました。 EFSの利用で実行時間が延びた原因 コンテナを利用する際には永続データをどう扱うかを考える必要があります。 当初はバッチ処理の変換前後のデータや、データ変換に必要なファイルはS3に置いて、バッチ処理の前後にPodからS3にアクセスする予定でした。ただ、Fargateの一時ボリュームは20GBであり、データ変換で用いる機械学習モデルや自然言語処理の辞書まで持つには心許ない容量です。 しかしちょうど今年8月に Fargate for EKSでもEFSをサポート するという朗報があり、早速Fargate for EKSでEFSを利用することにしました。FargateのPodにEFSをマウントすることで、バッチ処理自体のロジックを変更することなく永続データを扱えるようになり、容量も気にせずに済むようになりました。 ところが実際にFargateでバッチ処理を実行してみると、なぜか特定の処理に通常の10倍ほど時間がかかるようになってしまいました。Kubernetesダッシュボードで確認するとCPUやメモリはボトルネックとなっておらず、I/Oの問題かなと思いつつもなかなか原因がわかりませんでした。 試しにEFSに置いているあるファイルをPod起動時にコンテナ内にコピーして、バッチ処理ではそちらを参照するように変更してみると、処理時間が通常になりました。このファイルに対して頻繁にI/Oが発生するロジックになっていたため、EBSのようなローカルストレージよりレイテンシが大きいEFSでは処理に時間がかかってしまっていたようでした。 Fargate for EKSはEFSのサポートにより利用の幅が大きく広がりましたが、EFSの利用にあたっては今回のように頻繁にI/Oが発生しないようなロジックの工夫が必要だということもわかりました。 また今回は利用しなかったS3ですが、 整合性が強力になった (書き込みの直後に読み取りができるようになった)ことが先日発表されたので、ストレージの選択肢の一つとして今後利用できる場面がさらに増えそうです。 kubectl waitコマンドでPodの監視 監視については、以前からPrometheusを利用してリソースやログの監視を行っていました。バッチのログはPodにマウントしているEFSに書き出すようになっているので、grok_exporterを動かしているEC2でログを監視するようにしました。 また、Job実行後にPodのステータスがReadyになったかどうかはPrometheusでは監視できなかったので、Job実行時に kubectl waitコマンド を実行して、その結果をJob実行をキックしているWebアプリのログに出力するようにしました。このログもgrok_exporterで監視しているので、何らかの理由でPodが起動できなければ検知されるようになっています。 さらにJobやPodの異常終了についても、Job実行時にkubectl waitコマンドでJobの正常終了のタイムアウトを設定することで監視しています。 なお、kubectl waitコマンドはExperimental(実験的)な機能です。たしかに、タイムアウトを2時間に設定したのに結果が返ってくるのが2時間半後ということがありました。現時点では使える場面が限られますが、時間に厳密さを求めないのであれば使いやすい機能だと思います。今回はこれで十分でしたが、ワークフロー処理のように状態変化を即座に検知する必要がある場合には、 社内でモジュールを開発して対応 しています。 完了したJobの削除 バッチ処理ではk8sのJobリソースを使っています。k8sのJobとそれに紐づくPodは、 完了後も自動で削除されずに残り続ける 仕様 です。 これは完了後のPodでログを確認できるようにするためですが、Podが残り続けるとFargateの課金の対象となってしまいます。完了後のリソースを一定時間後に削除する TTL controller という仕組みも存在しますが、現時点ではEKSでは利用できないものです。 そこで こちら の記事を参考に、EKS管理用のEC2からcronで定期的に完了したJobを削除するようにしました。 $ kubectl delete job $(kubectl get job -o=jsonpath='{.items[?(@.status.succeeded==1)].metadata.name}') また、異常終了したJobについては調査を行う時間を考えて、1日に1回、開始後1日以上経っているJobの削除を行うようにしました。 $ kubectl delete job $(kubectl get jobs | awk '$4 ~ /d/' | awk '{print $1}')` 最後に 一年前は本当にk8sを本番運用まで持っていけるのか?というのが正直な気持ちでしたが、一つずつステップを踏んでいくことで実現できました。開発環境でのコンテナ利用にとどまらず、本番環境でk8sを利用することで、アプリ運用の可能性が大きく広がることを実感しました。来年も新しい技術に挑戦していきたいです!