Quantcast
Channel: クックパッド開発者ブログ
Viewing all 734 articles
Browse latest View live

Cookpad Online Summer Internship 2020 を開催します!

$
0
0

f:id:sankichi92:20200519200452j:plain

エンジニア新卒採用を担当しているユーザー・決済基盤部の三吉です。

クックパッドでは、毎年恒例のサマーインターンシップを今年も開催します! スプリングインターンシップに続き、新型コロナウイルスの影響をふまえ、サマーインターンシップもオンラインでの開催です。

以下のインターンシップ特別サイトからご応募いただけます。

5 Day Engineer コース

エンジニア向けは、プラットフォーム別に以下の3コースを用意しました。

  • Webアプリ開発コース
  • Androidアプリ開発コース
  • iOSアプリ開発コース

前半2日間は講義・ハンズオン形式です。 クックパッドのアプリケーション開発手法や、サービス開発のノウハウについて学びます。 後半3日間は実践です。 社員エンジニアのサポートのもと、各自でテーマに沿ったアプリケーションを開発します。

昨年の 10 Day Tech コース と比べると期間は短くなっていますが、プラットフォーム別にコースを分けて領域を絞ることでグッと密度を上げています。 また、今年は以下の日程で2回開催予定なので、より参加しやすくなっています。

  • 日程A: 8/24(月) 〜 8/28(金)
  • 日程B: 9/7(月) 〜 9/11(金)

昨年の様子については、以下の記事をご覧ください。

オンラインでの開催について

インターンシップには、参加者各自の PC から Zoom や Slack 等を利用してオンラインで参加していただく予定です。 すでにスプリングインターンシップや各種勉強会、イベントをオンラインで開催しており、そこで得た知見をサマーインターンシップに活かすべく準備しています。

3 Day Product Designコース

デザイナー向けのコースもあります。 こちらは、デザインツール Figma を使って、クックパッドのデザインプロセスを講義と実習を通して学ぶコースになります。 以下の日程で開催予定です。

  • 8/8(土) 〜 8/10(月・祝)

詳細はインターンシップ特別サイトをご覧ください。


参加してくださる学生の皆さまのため、サマーインターンシップには毎年会社を挙げて取り組んでいます。 オンラインでの開催は初めてですが、オンラインならではの工夫を凝らしたいと考えています。

また、サマーインターンシップは5日間の短期ですが、長期の就業型インターンシップも通年で募集しています。 興味のある方は、以下のページからご応募ください。 https://cookpad.wd3.myworkdayjobs.com/ja-JP/new_grad

学生の皆さまのご応募をお待ちしています!


KPI 設定の難しさについての思索とそれに付随した細かな考察

$
0
0

こんにちは、事業開発部でプロジェクトマネージャー兼エンジニアをやっている新井(@SpicyCoffee)です。10 万円の申請書を書く前に 20 万円のパソコンを買いました。

クックパッドでは、毎日の料理を楽しみにするべく日々サービス開発がおこなわれています。本稿では、サービス開発の中でも重要かつ難解な「KPI の設定」について、私がプロジェクトマネージャーとして普段考えていることや注意している点を紹介します。

KPI を決めるのは難しい

サービス開発において KPI を設定し、それを改善するような施策や検証を繰り返していくことは基本中の基本です。しかしながら、現実には「KPI を設定する」という行為自体の難易度が非常に高く、日夜頭を悩ませている開発者のみなさんも多いのではないでしょうか。 以下では、その要因の一つである「KPI は複数の要件を満たす必要がある」ことについて考えます。

満たすべき要件

具体的にどういった要件を満たす必要があるかはケースによって変わることもあると思いますが、私は普段以下の3点を KPI が満たすべき要件として考えています。

  • ユーザー体験を表現する指標であること
  • 事業の収益に繋がる指標であること
  • 自分たちの施策で 動かすことが可能な指標であること

ユーザー体験を表現する指標であること

KPI を設定する行為は取り組むべき課題の設定であり、すなわちサービスの中で自分たちが次に改善するべきポイントを定義する行為であるとも言えます。このことから KPI は、その指標を改善することで なぜサービスを利用するユーザーの体験が向上するのかを説明できるものでなくてはなりません。

KPI を改善する方法は複数ある中で、この部分の論理立てが不十分なままプロジェクトを進めると「KPI はよくなるがユーザー体験は悪くなっている」というような方法を選んでしまうことにも繋がります。たとえば EC サイトの検索結果において「SEO に効果がある → "ページ閲覧数/セッション"を KPI に据えましょう」という意思決定をするのと、「複数の商品を見比べてもらうことで、ユーザーは真に満足する買い物をすることができるという仮説がある → "ページ閲覧数/セッション"を KPI に据えましょう」という意思決定をするのとでは、結果として設定した KPI が同じであっても意味合いが大きく異なります。

極端な例ではありますが、前者では "ページ閲覧数/セッション"を伸ばすために「1ページ辺りに表示される商品数を少なくする」といったような「指標しか見ていない施策」が実施される可能性が高くなります。一方、後者のようにユーザー体験と KPI を紐付けておくことで、例に上げたような施策はメンバーから異議が唱えられる可能性が高くなり、実施されづらくなります。このことは、ユーザー体験の担保が施策の実行に置いて一種の制約条件のように働いていると捉えることができるかもしれません。

事業の収益に繋がる指標であること

ユーザー体験と同様に重要になってくるのが、その指標を改善することで 事業の収益にいい影響を与えることができるか、そしてその規模は十分かという観点です。

長期間に渡ってユーザー体験をよくし続けるためには、その源泉となる収益を得ることについても必ず考えなければなりません*1。どれほど質の高いサービスや機能を提供できたとしても、それが収益につながらなければ継続的に改善を続けることは難しく、結果として機能を落としたりその領域から撤退したりすることになってしまいます。

したがって KPI は、現状存在しているマネタイズ方法に繋がるか、新しくマネタイズ方法を定義しそこに繋がるものである必要があります。

自分たちの施策で動かすことが可能な指標であること

当然のことではありますが、KPI は自分たちで 観測・改善ができるものでなくてはなりません。それを担保するためのポイントとして「実装・集計の容易さ」と「外部要因の少なさ」があげられます。

前者については、実際にログを仕込んだり集計をする作業が自分たちが持っているリソースで可能かどうかを考える必要があります。たとえば、サービスのログ基盤が大量のログを収集・加工するのに十分なほど整っていないのであれば、複数の画面操作を組み合わせたような複雑な指標は避けるべきでしょう*2。プロジェクトに与えられた時間が3ヶ月なのであれば「一ヶ月後の再利用率」のような観測に時間のかかる指標は避けた方が無難です。

後者については、KPI の変動要因が多すぎないかどうかを考える必要があります。たとえば、施策を打っていない状態で A/A テストをした結果に差があるような指標は、平時からの変動が激しすぎる(≒多くの外部要因がある)と考えて避けるべきです。また、一般的には指標を実現するために必要な操作が多くなるほど、ノイズに近い離脱・誤操作が発生しやすくなり、施策を打ったときの効果が見えづらくなってしまう傾向にあります。

どう考えるべきか

以上に挙げたポイントを満たすような KPI を設定するのは非常に難しく、どこから手を付ければいいかわからなくなることも多々あります。しかし、一段抽象化して考えれば、これは複数の制約条件を持つ問題をいかに解くかという話になります。一般に複数の制約を満たす必要がある問題を考える際には、制約の最も厳しい条件から考えた方が後の手戻りが少なくなります。したがって、まず最初に手を付けるべきは、自分たちの環境において どの条件が最も厳しい制約かを考えることです。

「複数の制約を満たす」と聞くといわゆるベン図が頭に浮かぶ人も多いかもしれません。たとえば『ビジョナリー・カンパニー2』には、「情熱をもって取り組めるもの」「自社が世界一になれるもの」「経済的原動力になるもの」のすべてを満たすコトを対象にビジネスをしなさいと書かれており、その図解として以下のベン図が掲載されています。

f:id:spicycoffee:20200612162408p:plain
『ビジョナリー・カンパニー2 飛躍の法則』より作成

私自身この考え方は好きで似たような図もよく頭に思い浮かべますが、一つだけ疑問があるのは「この円のサイズは果たして本当におなじなのだろうか?」という点です。そして上記の主張をこの疑問に沿って捉え直すと、「まずは 最も小さな円について考えよう」という話になります。

たとえば、すでに多くの機能を持ち、提供できるユーザー体験やそれを表現するための指標は大量に考えつくが、マネタイズの方法自体はそれほど多くないような大規模サービスでは下図左側のような図になります。この場合はまず事業の収益に繋がる指標をいくつか思い浮かべ、 その指標をよくしながらユーザー体験を向上させるにはどうすればいいか?という考え方をした方がよいでしょう。
逆に立ち上げたばかりのサービスでは、実現するべきユーザー体験はこれと決まっているが、マネタイズの方法については模索中で多くの可能性があり、ベン図は右側のような図になるかもしれません。この場合は、ユーザー体験を表現する指標をまず設定し、 その指標をよくすることで収益を上げるにはどうすればいいか?という考え方をするのがよさそうです。

f:id:spicycoffee:20200612162400p:plain

このように、組織や置かれている状況や個人の知識・経験によってそれぞれの円の大きさが変化する中で、最も小さな円=取りうる選択肢の少ない円についての要件から満たすように KPI を考えることで、すべての制約を満たした指標を設定しやすくなるのではないでしょうか。

またこれは、裏を返すと KPI や取り組むべき課題を設定する際には 円の一番小さいところから考えざるを得ないということでもあります。つまり、たとえば先にあげた大規模サービスの例においてよりよいユーザー体験を作りたいなら、逆説的に一番小さな収益性の円を大きくする必要があるのです。これは個人の行動にするとたとえば書籍等からビジネスに関する知識を得たり、組織の置かれている状況について情報を収集したりして、収益に繋げる方法を新たに発見するといった行為になります。ログ基盤が整っていないせいで実現可能性の円が小さいのであれば、ログ基盤を整えることで取りうるユーザー体験の選択肢が広がるということです。これは、サービス開発に技術力が必要になる証左でもあります。

f:id:spicycoffee:20200612162404p:plain

施策を実行する際の注意点

ここまでの話は KPI の設定について述べたものでした。ここからは、私が実際に実施する施策の中で指標に関して注意している以下の 3 点について述べます*3

  • KPI そのものも改善サイクルの中で変化しうる
  • 施策で追う指標は3点セットで設定する
  • 施策の採用ラインは必ず事前に設定する

KPI そのものも改善サイクルの中で変化しうる

KPI そのものも絶対に不変のものであるわけではありません。KPI の設定が課題の設定と密になっている以上、事業を取り巻く環境や自分たちのサービスに対する理解が変化する中で取り組むべき課題そのものが変化し、KPI を変更した方がよい可能性があることは頭に入れておくべきです。
もちろん中長期で改善を進めていく指標として設定する以上、あまりにコロコロ変化するのは好ましくありませんが、時には「この KPI は本当に追うべきなんだろうか(=この課題を本当に解決すべきなのだろうか)」という思考を持つことも重要です。特にプロジェクトが発足してすぐのタイミングでは、先にあげた3条件に対する理解がチームの中でも不十分な可能性が高く、施策を重ねる中でその精度を上げた結果 KPI が変化することはよくあることかと思います。

施策で追う指標は3点セットで設定する

実際に KPI を改善するために施策を実施する際には、観測する指標を以下の3点セットで設定するようにしています。

  • KPI
  • 機能利用率
  • 副作用指標

KPI

設定した KPI です。

機能利用率

施策の意図が実現できているかを確認するために、実装した 機能が実際に利用されているかが確認できる指標を設定します。たとえば「直帰率」という KPI を設定し、その改善のために LP に新しいコンテンツを設定した場合、そのコンテンツのタップ率等を設定することになります。この指標を確認しなかった場合、KPI が動いたとしてもそれが意図したユーザー体験の変化によるものであるということが担保できなくなってしまいます。

副作用指標

実施した施策によって 既存コンテンツに影響を与える可能性がある場合、その影響も観測する必要があります。先にあげた直帰率を改善するためのコンテンツの例であれば、その LP にもともと存在していた別導線のタップ率等を設定することになります。この指標を設定しなかった場合、意図したとおりに KPI が改善できたとしても「他の指標が悪化してしまい事業全体としてはマイナスになってしまっていた」というケースに気がつけなくなってしまいます。

施策の採用ラインは必ず事前に設定する

それぞれの指標がどの程度の数値になったときに 施策を採用するのかという目安の数字は必ず事前に設定します。事後になってから議論しようとすると、せっかく作ったのだから採用したい気持ちが勝ってしまったり、最悪の場合ロクな議論もなく施策が採用されたりすることになりかねません。むやみやたらに機能を増やしてもユーザーの混乱を招くことに繋がるため、施策の採用については慎重になるべきであり、そのためにも事前に期待される効果等から採用ラインを設定することには大きな意味があります。
加えて言うと PM の立場であれば、施策が成功したときと失敗したときのそれぞれで次にどういった手を打つのかということも事前に想定しておく必要があります。

終わりに

冒頭にも述べましたように、この記事は私が実際に KPI を設定したり、それに基づいて施策を実施したりする際に注意している点をまとめたものです。サービス開発についての知見はその性質上「絶対の正解」が存在せず、また、それゆえに明文化されることが少ないものでもあると思います。私自身にとっても、この記事は「言語化しづらい思考を明文化して残す」という挑戦の一つであったのですが、これがみなさんのサービス開発の参考になればうれしいです。

クックパッドでは、このようにサービス開発について考えを巡らせながら、自分で手を動かして実際に開発を進めることのできるエンジニアを大募集しております。興味のわいた方や、この記事の内容について話がしたい!と感じた方はぜひ気軽に声をかけていただければと思います。

採用ページ: https://info.cookpad.com/careers/

*1:事業が投資フェーズであり、会社全体としては別事業の収益でバランスを取っているケースなどは別です

*2:あなたがエンジニアであれば自ら基盤を整える選択肢を取ることもできます

*3:後半2つについては以前書いた記事でも触れているのでよければ合わせてお読みください → https://techlife.cookpad.com/entry/2018/02/10/150709

iOSでモダンなカスタムボタンを作ってみよう

$
0
0

お久しぶりです。モバイル基盤部のヴァンサン(@vincentisambart)です。

iOS標準のボタンクラスUIButtonが10年前に作られたものであって、当時存在していなかったAuto LayoutやDynamic Typeとの相性がよくありません。

Auto Layout、Dynamic Type、複数行表示、を活用するカスタムなボタンクラスを作ってみれば少し勉強になるかもしれません。

因みにDynamic Typeはあまり使われていない機能だと思われることがあるようですが、気になって調べてみたら、クックパッドのiOSアプリのユーザーの中で、3分の1がシステム標準でない文字サイズを使っていました。その半分が標準より小さい設定を使っていて、もう半分が標準より大きい設定を使っています。「さらに大きな文字」を有効にすると選べる「アクセシビリティサイズ」を使っているユーザーは全ユーザーの1%未満でした。

まずはシンプルに

ボタンを作るとき、適切な親クラスを考えるとき、UIButtonが最初に頭に浮かぶかもしれません。しかし、UIButtonの標準のサブビュー(titleLabelimageView)の配置はAuto LayoutやUIStackViewを活用できませんし、ボタンに別のUILabelを入れるとUIButton標準のtitleLabelも残っていて分かりにくいと思います。

UIButtonの代わりにその親クラスであるUIControlを使ってみましょう。実は、UIButtonに期待されている挙動の多くはUIControlがやってくれます。

カスタムボタンベースは以下のコードでいかがでしょうか。

publicfinalclassMyCustomButton:UIControl {
    privatestaticletcornerRadius:CGFloat=4privatelettitleLabel= UILabel()

    privatefuncsetUp() {
        // ユーザーの文字サイズの設定によってサイズの変わるフォントを使います// `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです
        titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        // Dynamic Typeの設定が変わるたびに、上記のフォントのサイズを新しい設定に合わせてほしいです。// 自動調整を有効にするには、この指定だけでなくフォントを`UIFont.preferredFont(forTextStyle:)`または`UIFontMetrics.default.scaledFont(for:)`で作成する必要があります。
        titleLabel.adjustsFontForContentSizeCategory =true
        
        titleLabel.numberOfLines =0// 行数制限なし
        titleLabel.textAlignment = .center

        // titleLabelがボタン全体を覆うように
        titleLabel.translatesAutoresizingMaskIntoConstraints =false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo:topAnchor).isActive =true
        titleLabel.bottomAnchor.constraint(equalTo:bottomAnchor).isActive =true
        titleLabel.leadingAnchor.constraint(equalTo:leadingAnchor).isActive =true
        titleLabel.trailingAnchor.constraint(equalTo:trailingAnchor).isActive =true// 角丸を忘れず
        layer.cornerRadius =Self.cornerRadius
        clipsToBounds =true// 色をつけておく
        backgroundColor = .orange
        titleLabel.textColor = .white
    }

    publicoverrideinit(frame:CGRect) {
        super.init(frame:frame)
        setUp()
    }

    publicrequiredinit?(coder:NSCoder) {
        super.init(coder:coder)
        setUp()
    }

    publicvartitle:String {
        get {
            titleLabel.text ??""
        }
        set {
            titleLabel.text = newValue
        }
    }
}

実行してみると以下のようになります。

f:id:vincentisambart:20200616071827p:plain:w320

上記のコードだけでも、addTargetを使ってみればちゃんと動きます。ただし、ボタンを押すとタッチフィードバックがないので改善が少し必要です。

色変更

ボタンの色は押されているかどうかだけではなく、無効(disabled)になっているかどうかでも色が変わります。 色に影響ある状態を表現するためのenumを用意しておきましょう。

// `UIControl.State`と違って、この`enum`にはこのボタンの表示に影響ある状態しか入っていません。privateenumDisplayState {
    case disabled
    case enabled
    case enabledHighlighted
}

privatevardisplayState:DisplayState {
    // `isEnabled`と`isHighlighted`は`UIControl`の標準のプロパティです。if isEnabled {
        if isHighlighted {
            return .enabledHighlighted
        } else {
            return .enabled
        }
    } else {
        return .disabled
    }
}

その状態によって色を変えたいので、色を変えてくれるメソッドを用意しておきましょう。 以下のコードは選んだ色がちょっと適当ですし、文字や背景の色だけではなく、ふちの色も変えても良いかもしれないので、見た目に関してデザイナーに相談しても良いかもしれません。

privatefuncupdateColors() {
    lettextColor:UIColorletbackgroundColor:UIColorswitch displayState {
    case .disabled:
        textColor = .white
        backgroundColor = UIColor.white.darkened
    case .enabled:
        textColor = .white
        backgroundColor = .orange
    case .enabledHighlighted:
        textColor = UIColor.white.darkened
        backgroundColor = UIColor.orange.darkened
    }

    self.backgroundColor = backgroundColor
    titleLabel.textColor = textColor
}

因みに上記のdarkenedの定義は以下の通りです。もっと正しい計算があるかもしれませんが、ここはこれで十分でしょう。

privateextensionUIColor {
    vardarkened:UIColor {
        letdarkeningRatio:CGFloat=0.9varhue:CGFloat=0varsaturation:CGFloat=0varbrightness:CGFloat=0varalpha:CGFloat=0if getHue(&hue, saturation:&saturation, brightness:&brightness, alpha:&alpha) {
            return UIColor(
                hue:hue,
                saturation:saturation,
                brightness:brightness* darkeningRatio,
                alpha:alpha
            )
        } else {
            returnself
        }
    }
}

updateColors()を用意するだけではなく、正しいタイミングで呼ぶ必要もあります。 setUp()の最後で呼ぶのはもちろん、状態が変わるタイミングでも呼んでおきましょう。

publicoverridevarisHighlighted:Bool {
    didSet {
        updateColors()
    }
}

publicoverridevarisEnabled:Bool {
    didSet {
        updateColors()
    }
}

ボタンが押されている間に色が変わるようになりました。

f:id:vincentisambart:20200616071836p:plain:w320

ボタンが無効のときも色がちゃんと変わります。

f:id:vincentisambart:20200616071841p:plain:w320

サブタイトルと余白

タイトルだけではなく、サブタイトルも追加しておきましょう。そしてその周りに余白を入れておきましょう。

privatelettitleLabel= UILabel()
privateletsubtitleLabel= UILabel()
// シンプルさのためにinsetsを固定にしてあるが、変えられるようにした方が良さそうprivatestaticletinsets= NSDirectionalEdgeInsets(
    top:5,
    leading:5,
    bottom:5,
    trailing:5
)

privatefuncsetUp() {
    // ユーザーの文字サイズの設定によってサイズの変わるフォントを使います// `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです
    titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
    titleLabel.adjustsFontForContentSizeCategory =true
    subtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
    subtitleLabel.adjustsFontForContentSizeCategory =true

    titleLabel.numberOfLines =0// 行数制限なし
    titleLabel.textAlignment = .center
    subtitleLabel.numberOfLines =0// 行数制限なし
    subtitleLabel.textAlignment = .center

    letverticalStackView= UIStackView()
    verticalStackView.axis = .vertical
    verticalStackView.alignment = .center
    verticalStackView.translatesAutoresizingMaskIntoConstraints =false
    addSubview(verticalStackView)
    // 左右上下の制約にinsetsの値を活用しても良いのですが、今回はUIStackView.directionalLayoutMarginsを使ってみました
    verticalStackView.topAnchor.constraint(equalTo:topAnchor).isActive =true
    verticalStackView.bottomAnchor.constraint(equalTo:bottomAnchor).isActive =true
    verticalStackView.leadingAnchor.constraint(equalTo:leadingAnchor).isActive =true
    verticalStackView.trailingAnchor.constraint(equalTo:trailingAnchor).isActive =true// stack view内に余白を少し入れておきます
    verticalStackView.isLayoutMarginsRelativeArrangement =true
    verticalStackView.directionalLayoutMargins =Self.insets

    verticalStackView.addArrangedSubview(titleLabel)
    verticalStackView.addArrangedSubview(subtitleLabel)

    // stack viewのおかげで隠れたビューがスペースをとりません
    subtitleLabel.isHidden =true

    layer.cornerRadius =Self.cornerRadius
    clipsToBounds =true

    updateColors()
}

publicvarsubtitle:String {
    get {
        subtitleLabel.text ??""
    }
    set {
        subtitleLabel.text = newValue
        subtitleLabel.isHidden = newValue.isEmpty
    }
}

もちろんupdateColors()の最後にsubtitleLabelの色の更新も必要ですね。

subtitleLabel.textColor = textColor

f:id:vincentisambart:20200616071851p:plain:w320

タップ反応

見た目は大丈夫そうに見えるが、試してみたら、なぜかタップするとき反応しなくなりました…

実は、タップはverticalStackViewが全部受け取るようになりました。タップがボタン自体にたどり着きません。 以前動いていたのはUILabelisUserInteractionEnabledが標準でfalseだからです。UIStackViewはシンプルなUIViewのようにisUserInteractionEnabledが標準でtrueです。

setUp()の中で以下の1行を入れておけば上手く動くようになります。

verticalStackView.isUserInteractionEnabled =false// タッチイベントはこのボタンまで来てほしい

このボタンの中のタップが全部ボタンにたどり着いてほしいので、stackView.isUserInteractionEnabled = falseが良いのですが、UIStackViewの中のものにたどり着いてほしければ使えません。

これでボタンがちゃんと動くはずです。あとはレイアウトは自分のニーズに合わせて色々できます。

UIButtonを使わないおかげで、不要なサブビューが作られることはないが、UIButtonがやってくれて、UIControlがやってくれない機能を失ってしまう。その機能の1つがアクセシビリティです。

アクセシビリティ

アクセシビリティとは利用しやすさ、もっと多くの人がもっと多くの状況でアプリを使えるのを目指すことだと言っても良いのかな。今の自分がアプリを問題なく使えたとしても、メガネのない時の自分、30年後の自分、自分の親戚、にはアクセシビリティ機能が必要かもしれません。

上記のコードにadjustsFontForContentSizeCategory = trueが入っていて、Dynamic Typeというアクセシビリティ機能の一つを既に活用しています。

でもVoice Overなど、画面の中身を見て操作できるアクセシビリティ機能にとって、各ビューがどういうものなのか、どういう風に使えるのか、知るすべが必要です。

上記のコードのままだと一応Voice Overで操作はできるけど、「ボタン」として認識されていないので、操作できることに気づかれないかもしれません。

今回、アクセシビリティ対応は難しいことではありません:

  • 標準のUIControlが「accessibility element」ではないので、アクセシビリティ機能に無視されてしまいます。isAccessibilityElement = trueで認識されるようになります。
  • このビューがボタンであることをaccessibilityTraits = .buttonでシステムに伝えましょう。
  • isAccessibilityElement = trueをやったことで、Voice Overが中に入っているUILabelを音読しなくなるので、accessibilityLabelでボタンの中身を伝えましょう。\ 因みにUIButtonがaccessibility elementなので、UIButtonの中にUILabelを入れるときも同じ問題が起きます。
  • ボタンに画像しか入っていないときでも、何をやるボタンなのか分かるすべがないのでaccessibilityLabelにひとことを入れておきましょう。

以下のようになります。

isAccessibilityElement =truevaraccessibilityTraits:UIAccessibilityTraits= .button
if!isEnabled {
    accessibilityTraits.insert(.notEnabled)
}
self.accessibilityTraits = accessibilityTraits
accessibilityLabel = [title, subtitle].filter { !$0.isEmpty }.joined(separator:"\n")

もちろん上記のコードはtitlesubtitleisEnabledの変更時に呼んで情報を更新する必要がありますね。

最後に

iOSクックパッドアプリでは、このボタンの拡張したバージョンが一部の画面で使われています。 作った時、細かいところいくつかに引っかかったので、この記事が少しでも役に立っていただければと思って書いてみました。

iOSクックパッドアプリのDynamic Type対応はまだ対応していない画面がまだありますが、少しずつ改善していこうとしています。

すべてのコードを以下にまとめておきました。このコードをご自由に自分のアプリにお使いください。\ 必要であれば、ライセンスがないと困る人のためにちゃんとしたライセンスも入れておきました。

// This project is licensed under the MIT license.// // Copyright (c) 2020 Cookpad Inc.// // Permission is hereby granted, free of charge, to any person obtaining a copy// of this software and associated documentation files (the "Software"), to deal// in the Software without restriction, including without limitation the rights// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell// copies of the Software, and to permit persons to whom the Software is// furnished to do so, subject to the following conditions:// // The above copyright notice and this permission notice shall be included in// all copies or substantial portions of the Software.// // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN// THE SOFTWARE.publicfinalclassMyCustomButton:UIControl {
    privatestaticletcornerRadius:CGFloat=4privatelettitleLabel= UILabel()
    privateletsubtitleLabel= UILabel()
    privatestaticletinsets= NSDirectionalEdgeInsets(
        top:5,
        leading:5,
        bottom:5,
        trailing:5
    )

    privatefuncsetUp() {
        // ユーザーの文字サイズの設定によってサイズの変わるフォントを使う// `UIFont.preferredFont(forTextStyle:)`の代わりに`UIFontMetrics.default.scaledFont(for:)`を使っても良いです
        titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        titleLabel.adjustsFontForContentSizeCategory =true
        subtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
        subtitleLabel.adjustsFontForContentSizeCategory =true

        titleLabel.numberOfLines =0// 行数制限なし
        titleLabel.textAlignment = .center
        subtitleLabel.numberOfLines =0// 行数制限なし
        subtitleLabel.textAlignment = .center

        letverticalStackView= UIStackView()
        verticalStackView.axis = .vertical
        verticalStackView.alignment = .center
        verticalStackView.isUserInteractionEnabled =false// タッチイベントはこのボタンまで来てほしい
        verticalStackView.translatesAutoresizingMaskIntoConstraints =false
        addSubview(verticalStackView)
        // 左右上下の制約にinsetsの値を活用しても良いのですが、今回はUIStackView.directionalLayoutMarginsを使ってみました
        verticalStackView.topAnchor.constraint(equalTo:topAnchor).isActive =true
        verticalStackView.bottomAnchor.constraint(equalTo:bottomAnchor).isActive =true
        verticalStackView.leadingAnchor.constraint(equalTo:leadingAnchor).isActive =true
        verticalStackView.trailingAnchor.constraint(equalTo:trailingAnchor).isActive =true// stack view内に余白を少し入れておきます
        verticalStackView.isLayoutMarginsRelativeArrangement =true
        verticalStackView.directionalLayoutMargins =Self.insets

        verticalStackView.addArrangedSubview(titleLabel)
        verticalStackView.addArrangedSubview(subtitleLabel)

        // stack viewのおかげで隠れたビューがスペースをとりません
        subtitleLabel.isHidden =true

        layer.cornerRadius =Self.cornerRadius
        clipsToBounds =true

        updateColors()
        updateAccessibility()
    }

    privateenumDisplayState {
        case disabled
        case enabled
        case enabledHighlighted
    }

    privatevardisplayState:DisplayState {
        if isEnabled {
            if isHighlighted {
                return .enabledHighlighted
            } else {
                return .enabled
            }
        } else {
            return .disabled
        }
    }

    privatefuncupdateColors() {
        lettextColor:UIColorletbackgroundColor:UIColorswitch displayState {
        case .disabled:
            textColor = .white
            backgroundColor = .lightGray
        case .enabled:
            textColor = .white
            backgroundColor = .orange
        case .enabledHighlighted:
            textColor = UIColor.white.darkened
            backgroundColor = UIColor.orange.darkened
        }

        self.backgroundColor = backgroundColor
        titleLabel.textColor = textColor
        subtitleLabel.textColor = textColor
    }

    publicoverridevarisHighlighted:Bool {
        didSet {
            updateColors()
        }
    }

    publicoverridevarisEnabled:Bool {
        didSet {
            updateColors()
            updateAccessibility()
        }
    }

    publicoverrideinit(frame:CGRect) {
        super.init(frame:frame)
        setUp()
    }

    publicrequiredinit?(coder:NSCoder) {
        super.init(coder:coder)
        setUp()
    }

    publicvartitle:String {
        get {
            titleLabel.text ??""
        }
        set {
            titleLabel.text = newValue
            updateAccessibility()
        }
    }

    publicvarsubtitle:String {
        get {
            subtitleLabel.text ??""
        }
        set {
            subtitleLabel.text = newValue
            subtitleLabel.isHidden = newValue.isEmpty
            updateAccessibility()
        }
    }

    privatefuncupdateAccessibility() {
        isAccessibilityElement =truevaraccessibilityTraits:UIAccessibilityTraits= .button
        if!isEnabled {
            accessibilityTraits.insert(.notEnabled)
        }
        self.accessibilityTraits = accessibilityTraits
        accessibilityLabel = [title, subtitle].filter { !$0.isEmpty }.joined(separator:"\n")
    }
}

クックパッドのサービスメッシュ基盤を改善した話

$
0
0

こんにちは、技術部 SRE グループの ryojiro (@flyhigh_ro) です。今回はクックパッドでのサービスメッシュ基盤を改善した話を紹介します。クックパッドでのサービスメッシュの構成については以前の記事をご覧ください。

クックパッドでは多くのサービス間通信において Envoy を利用していますが、以下のような問題を抱えていました。

  • 改善前の Envoy のバージョンは v1.9.0 (2018/12 リリース) と古く、開発者はそれ以降に実装された機能を利用することが出来なかった。
  • CDS/RDS を cookpad/itachoによって生成しているため、 v1.9.0 で利用出来る機能であっても cookpad/itacho で実装されていなければその機能を利用できなかった。利用するためには cookpad/itacho にその設定を実装する必要があり、面倒だった。
  • cookpad/itacho で既に実装されている機能でも、ドキュメンテーションが不十分で目的の Envoy での設定に対応する itacho の設定が調べられず、cookpad/itacho の実装を調べることがあった。

上記の理由から、サービス開発者が Envoy v1.9.0 以降の機能や cookpad/itacho で実装されていない機能を利用したくても、すぐにその機能を利用することができずに、その機能を利用することを諦めることが何度かありました。SRE としては、サービス開発者にサービスメッシュを積極的に活用してもらいたいと考えていたので、サービスメッシュをもっと手軽に利用してもらうことを目的として、以下の内容でサービスメッシュ基盤を改善しました。

  • Envoy のアップデート
  • v1 xDS API の廃止
  • cookpad/itacho での itacho generate 廃止
  • xDS API の CI 整備

Envoy のアップデート

クックパッドで利用されている Envoy のバージョンは v1.9.0 と 2018/12 にリリースされた古いバージョンを利用していました。v1.9.0 でも機能としては十分でしたが、脆弱性が報告されていたり、古いバージョンを使い続けることでアップデートがどんどん大変になっていくことに懸念がありました。そのため、今回を機に最新のバージョンまで上げることにし、以降もバージョンアップしやすい環境を目指すことにしました。

段階的な移行

最初は Envoy を一気に v1.9.0 から v1.14.2 まで上げようと考えていましたが、以下の理由から一度 v1.12.0 にしてから v1.14.2 に上げることにしました。

v1.14.2 だと既に deprecated になっている設定があり、v1.9.0 と v1.14.2 で互換性のない設定があった

envoy.api.v2.route.HeaderMatcher.regex_match を例にすると、 v.1.14.2 では既に deprecated となっているため envoy.api.v2.route.HeaderMatcher.safe_regex_match へ移行する必要がありました。しかし envoy.api.v2.route.HeaderMatcher.safe_regex_match は v1.9.0 では実装されていません。一旦全ての Envoy を envoy.api.v2.route.HeaderMatcher.regex_match と envoy.api.v2.route.HeaderMatcher.safe_regex_match に対応しているバージョンへアップデートし、envoy.api.v2.route.HeaderMatcher.regex_match を envoy.api.v2.route.HeaderMatcher.safe_regex_match へと移行してから v1.14.2 にアップデートする必要がありました。

cookpad/itacho で利用しているライブラリの protobuf 定義が古く、v1.12.0 までの xDS リクエストにしか対応していなかった

cookpad/itacho で利用しているライブラリの protobuf 定義が古く、v1.13.0 以降の Envoy から送信される xDS request のデシリアライズに失敗していました。cookpad/itacho に原因があることはわかっていましたが、cookpad/itacho を開発した経験がなく、この対応にどの程度工数がかかるのか見積もることができませんでした。そこで、一旦 v1.12.0 へアップデートすることにして、その間に cookpad/itacho へ対応することにしました。

v1 xDS API の廃止

Envoy v1.10.0 で Bootstrap config の deprecated_v1 sds_config と command line config の –v2-config-only オプションが廃止、 v1.13.0 で v1 xDS API が廃止となりました。クックパッドではいくつかのアプリケーションで v1 xDS API を利用してたので、それらを全て v2 xDS API へと移行しました。Envoy 以外から v1 xDS API を利用しているアプリケーションもあったので、それらも v2 xDS API を利用するように変更しました。

cookpad/itacho での itacho generate 廃止

クックパッドでは CDS/RDS のレスポンスの生成に itacho generate を使用していました。itacho generate は指定された設定に沿って CDS/RDS を生成します。しかし、Envoy の設定名とそれを生成する itacho generate の設定名が一致していなかったり、ドキュメントが整備されていないことから、どのような記述をすればいいのかわからないとの声が上がっていました。実際に itacho generate の設定を確認するために直接実装を確認することもありました。また、新規の機能を利用する場合も cookpad/itacho へその機能を実装する必要があり、手軽に新規の機能を利用することが困難でした。これらの課題を解決するために、itacho generate で xDS API レスポンスを生成することをやめ、直接 xDS API レスポンスを記述するように変更しました。そのまま全てのレスポンスを記述すると冗長になってしまうので Jsonnet で記述するようにしました。共通の設定は関数化し、upstreams 毎に設定を libsonnet ファイルにまとめて、それらを import して利用することで簡潔に記述できるように工夫しています。以下は itacho generate での記述例とxDS API レスポンスをそのまま記述したときの例です。

itacho generate での記述例

https://gist.github.com/ryojiro/baac94ceb615949c7ea54e36ba94b70a

xDS API をそのまま記述した例

https://gist.github.com/ryojiro/cde4f0024cd29b6ed4ee10467519f1fb

このような記述にすることで、upstreams の設定を1箇所で管理しつつ、サービス毎に独自に upstream の設定を上書きすることも可能となっています。また、新しい設定を記述する時にも Jsonnet へ設定を追加するだけなので、手軽に Envoy の機能を利用できるようになりました。

xDS API の CI 整備

これまでは xDS API レスポンスを itacho generate によって生成していたので、正しい xDS API の形式となっていることが保証されていました。しかし Jsonnet で xDS API レスポンスを生成するように変更したことで、生成される xDS API レスポンスが正しいことが保証されなくなってしまいました。そこで、CI を整備して生成される xDS API レスポンスが正しい形式となっていることを事前に検証するようにしました。Envoy のドキュメントを読むと mode オプションvalidateを渡して起動することで、Envoy の設定が正しいかを検証できそうでしたが、ネットワーク通信が発生しないので xDS API サーバーを立てて生成した xDS API レスポンスを検証することはできませんでした (静的な設定ファイルのみ検証されます) 。検証したいのは CDS/RDS のレスポンスで、Envoy の static_resources との設定はほとんど同じだったので、CI では設定した xDS API レスポンスを静的な設定ファイルに変換し、その設定ファイルで Envoy を起動することで、設定した xDS API レスポンスが正しい形式で記述されているかを検証するようにしました。クックパッドでは現在 v1.12.0 と v1.14.2 の Envoy が混在しているので、どちらも valid な設定のみ追加できるように、それぞれのバージョンで検証するようにしています。

最後に

今回はサービスメッシュをサービス開発者により手軽に利用してもらうために、サービスメッシュ基盤を改善した話を紹介させていただきました。この改善によって、実際にサービス開発者が新しい Envoy の設定を追加して利用する事例も生まれています。Envoy は比較的新しいアプリケーションでまだ知見も少ないと思うので、これからサービスメッシュ基盤の改善を考えている方の参考になれば嬉しいです。

このエントリを読んで興味を持った方や、数千の規模で Envoy が利用されているサービスメッシュ基盤を改善したい方はぜひ以下のサイトよりご応募ください。

クックパッド採用サイト: https://cookpad.jobs

Trivy + AWSによるコンテナイメージ脆弱性検査パイプラインの構築

$
0
0

技術部セキュリティグループの水谷(@m_mizutani)です。最近はPCゲーム熱が再燃しており、今はCities: Skylinesに時間を溶かされ続けています。

クックパッドでは レシピサービスの継続的なサービス改善の他にも、生鮮食品販売プラットフォームの クックパッドマートやキッチンから探せる不動産情報サイト たのしいキッチン不動産をはじめとする新しいサービス開発にも取り組んでいます。さらに内部的なシステムも多数あり、動かしているアプリケーションの数は300以上に及びます。これらのアプリケーションには多くのOSSパッケージが利用されており開発を加速させますが、同時にOSSパッケージのアップデート、とりわけ脆弱性の修正にも向き合う必要があります。

これまでクックパッドでは(重大な脆弱性が見つかった場合を除いて)各サービスを担当するエンジニアが事業や開発の状況にあわせてパッケージのアップデートなどをしていました。しかし、管理すべきアプリケーションが多くなってきていることから、全社で統一したパッケージの脆弱性対応の仕組みを整える必要がでてきました。その一環として各アプリケーションのデプロイで使われるコンテナに含まれるパッケージの脆弱性を把握するための仕組みを整えました。

この記事では社内でのパッケージ脆弱性の検査に対してどのような要求があり、それをどうやって実現したのかを紹介します。

脆弱性スキャンのパイプライン構築における要件

現在、クックパッドでは大部分のアプリケーションがコンテナ化され、Amazon ECS(Elastic Container Service)上で動作しています。また、そこへのデプロイも主にCodeBuildを使ったCI(Continuous Integration)の環境が整備されています。そのため、このCIの仕組を利用することで脆弱性スキャンの機能を構築することにしました。

構築にあたってはいくつか解決しないといけない課題や要件があったため、それをまず紹介します。

要件1) 観測からはじめる

CI/CDにおける脆弱性管理の文脈では「CIのパイプラインで脆弱性を検査し、脆弱性があった場合はCIを止める」といったものが多く語られているように思います。検出されている脆弱性をすべて無くしてからしかデプロイできないようにする、というのは確かに理想形ではありますが、実際の事業に照らし合わせてみると必ずしも正しいとは言えないと考えています。

例えば1つのパッケージのバージョンを上げることで破壊的な変更が入る、あるいは連鎖的に複数のパッケージも更新する必要があり、結果的に大幅な改修が必要になってしまう、ということはままあることと考えられます。これが事業的に一刻も早くデプロイしなければならない状態だとすると、現場判断で脆弱性スキャンの機能を無効にせざるをえない、ということがありえます。

もちろん、攻撃が成功しやすい・影響が大きいような脆弱性の場合は事業を止めてでも修正する必要があります。しかし、脆弱性の中には複数の条件を突破しないと攻撃が成立しないような種類のものも少なからずあります。そしてそれはアプリケーションの設定や実行環境に依存するため、一律に判断するのは困難です。CVSSなどによるスコアリングでも、結局は環境などに依存してリスクが変動してしまい、これをセキュリティチームから開発チームに押し付けることは互いにとってあまり良い結果にならないのではと考えています。

そのため、まずはコンテナ内のパッケージの脆弱性がどのくらいあって、どのように変動しているかを把握し、どうすればリスクの極小化ができるかの仮設をたてて検証していく必要があります。そのためにも全体像を把握できるようにまずは観測できる環境を整えるという要求事項を設定しました。

要件2) CIと密結合にしない

いくつかの脆弱性スキャンツールはCIの途中で実行することを想定して作られており、CIのスクリプトなどに埋め込んでシンプルに実行することができます。しかし、アプリケーション数が多くなってくるとそれに比例して脆弱性スキャンツールを動かすための管理・統制にかかるコストが大きくなってしまいます。これは脆弱性スキャンツールの導入だけでなく、例えばツールの仕様が変わるなどしてうまく動かなくなった際の障害対応とメンテナンスの手間も含まれてきます。

先述したとおり、クックパッド内では300を超えるアプリケーションが動いており、それら全てのCIでそういった管理をするのはあまり現実的ではありませんでした。そのため、既存のCIの仕組みとは完全に独立させ、CI側に影響を与えないような疎結合なシステムを構築する必要がありました。これによって、今後さらにアプリケーションの数が増えても容易にスケールできることが期待されます。

要件3) 脆弱性の発見だけでなく修正もとらえる

脆弱性スキャンツールを使う主な目的は脆弱性のあるパッケージの発見であるため、検査結果をそのまま閲覧・通知することでこれは達成できます。しかし継続的にコンテナをメンテナンスしていく場合、コンテナに含まれる脆弱性が修正された、という情報も役に立つことがあります。

  • 脆弱性のあるパッケージが含まれていたコンテナイメージ修正の進捗状況を把握できる
  • 脆弱性のあるパッケージを更新したつもりのコンテナイメージをビルドした際、意図したとおりにパッケージが修正できたのか把握できる
  • 脆弱性が発見されてから修正されるまでの期間を計測できる

これらを実現するためには各コンテナイメージの脆弱性の状態を管理する必要があります。

要件4) ベースイメージに含まれているパッケージの脆弱性を識別できるようにする

クックパッドではアプリケーション用のコンテナイメージを作成する際に利用できる、社内共通のベースイメージが用意されています。このイメージにはおおよそ共通して使われるであろうパッケージが事前にインストールされており、これを使うことでアプリケーション用イメージごとのビルドのステップを短縮しています。

しかし、ベースイメージからビルドされたコンテナイメージの脆弱性をスキャンすると、ベースイメージにもともと入っていたパッケージの脆弱性とアプリケーション用に新たにインストールしたパッケージの脆弱性が混在した結果が出力されてしまいます。発生ポイントがどこであれ修正するべき脆弱性は修正しなければなりません。ですが、ベースイメージを管理しているチームとアプリケーションを開発しているチームが異なるため、脆弱性の発生レイヤが混在して通知されてしまうと、どのチームが対応するべき脆弱性なのかが判断しにくくなってしまいます。このため、検出された脆弱性がどのイメージをビルドした際に入り込んでしまったのかを識別できるようにしたい、という要求が生まれました。

ベースイメージが1つだけであれば、そのイメージの検査結果との差分をみることで脆弱性の発生ポイントを判定できますが、ベースイメージが複数あるとその紐付けの情報を管理する必要がでてきます。Dcokerfileからビルドする場合は FROMを見ることでベースイメージのレポジトリはわかりますが、いつビルドされたイメージが実際に使われているのかまではわかりません。とはいえ手動で管理するのはあまりにも煩雑なので、自動的に判定するような仕組みが必要になります。

脆弱性スキャンツールの選定

脆弱性スキャンのツールとしてはTrivyを採用しました。選定にあたって他のOSSや製品の脆弱性スキャンツールとも比較をしたのですが、

  1. 単体のバイナリだけで簡単にスキャンが実行でき、小回りがきくこと
  2. 入力や出力もシンプルになっており自分たちのシステムとのインテグレーションが容易であること
  3. OSのパッケージおよびrubyなどランタイムのパッケージの脆弱性もまとめて把握できること

という3つの理由からTrivyを使うことにしました。

ちなみに、クックパッドではCI/CDにおけるコンテナイメージの保存にはAmazon ECR(Elastic Container Registry)を利用しており、ECRのImage Scanningの機能を利用することも検討しました。しかし、スキャンできる対象がOSのパッケージのみだったことから採用を見送りました。

ちょうど先日、AWS Security Blog で How to build a CI/CD pipeline for container vulnerability scanning with Trivy and AWS Security HubというTrivyをCIに取り入れるというブログが公開されていました。このブログでもCodeBuildでのCIを想定しており、CIの中にTrivyによる脆弱性スキャンを実行して、その結果をSecurity Hubに格納するというアーキテクチャについて述べられています。このアプローチも小さくはじめるにはよい構成なのですが、先述した要件をクリアするのは十分ではなかったため、我々は別のアーキテクチャによって脆弱性スキャンのパイプラインを実現しました。

アーキテクチャと実装

TrivyとAWSの各種マネージメントサービスを利用し、コンテナイメージの脆弱性スキャンパイプラインを構築しました。AWSのサービスと接続することから、基本的な制御の部分にはLambdaを利用し、サーバレスなアーキテクチャになっています。デプロイにはAWS CDK(Cloud Development Kit)を利用しています。

また、アーキテクチャ図からは省いていますが、スキャン結果から得られたデータを確認するためのWeb管理コンソールも用意しています。

イメージのスキャン

f:id:mztnex:20200713200112p:plain
イメージスキャンに関連するAWS構成

クックパッドでは原則コンテナイメージをCodeBuildでビルドし、ECR(Elastic Container Registry)にプッシュしたのち、ECS(Elastic Container Service)へデプロイするという構成になっています。要件2の疎結合なアーキテクチャにするという観点から、今回はCodeBuild内で実行されるビルドのプロセスには一切手を加えず、ECRにプッシュされたイメージを利用することで、CI/CDのパイプラインに一切影響しないような構成にしました。

スキャンの開始は2つのトリガーがあります。1つはイメージがプッシュされた際にCloudWatch Events経由で送信されるECRイベント、もう1つは定期的(現在は24時間ごと)に発行されるCloudWatch EventsのScheduledイベントです。それぞれのトリガーによって起動されたLambdaがスキャンすべき対象のイメージの情報をキューとしてScanQueueに詰めます。定期的に実行されるトリガーはECRからレポジトリの一覧を取得し、そこからスキャンが必要なイメージを選定します。

ECRにプッシュされたイメージの中身は後からは変更されないため、同じ脆弱性を見つけるためには何度もスキャンする必要はありません。しかし脆弱性スキャンツールにTrivyを使う場合、新たに発見された脆弱性を見つけるためには脆弱性DBを更新して、再度検査をするというのがシンプルな対応になります。そのため、イメージがプッシュされたイベントとは別に定期実行の仕組みを取り入れました。

Trivyを使った実際のスキャンはFargate上で実行することにしました。Fargateを選択した主な理由は、1) 実行環境が独立しているため、ECSのように他のタスクに影響を及ぼさない、2) スケールアウトが容易、の2つになります。特に定期スキャンでは数百のイメージをスキャンするためのキューが一度に発生するため、スケールアウトによって短時間でスキャンを完了させられます。Fargate上ではこのパイプラインを制御するためのプログラムを動かしており、それがTrivyを起動させます。具体的には、次のような制御をしています。

  1. ScanQueueからスキャン対象イメージの情報を取得
  2. 脆弱性DBの更新(図中では割愛)
  3. Trivyの起動とスキャン結果の保存
  4. 対象イメージのレイヤ情報をECRから取得
  5. スキャン結果をS3に保存
  6. スキャン完了通知をResultQueueに送る

Trivyのスキャン結果は多少のメタデータを付与したあと、なるべくそのままS3に保存します。これのデータをもとに結果処理のLambdaが管理コンソールからの検索に必要なインデックス情報などをDynamoDBに保存します。

脆弱性の状態管理

f:id:mztnex:20200713200216p:plain
脆弱性の状態を管理するためのAWS構成

脆弱性の状態を管理するのに必要なのは「直前のスキャン結果との比較」です。これはRDBを使って管理するというようなアプローチもありますが、今回はS3に保存してあるスキャン結果を単純に比較してコンテナイメージに含まれる差分を計算する、という方法にしました。これによってイメージごとの差分計算処理が1つのLambdaに集約され、大量のリクエストがきても容易にスケールアウトできます。

差分計算の処理はシンプルに最新のスキャン結果と直前のスキャン結果を比較しているだけです。最新のスキャン結果が保存されたS3パスが(「イメージのスキャン」のアーキテクチャ図にもあった)スキャン結果処理のLambdaから送信されたQueueに、直前のスキャン結果が保存されたS3パスがDynamoDBにあります。これらをもとに、それぞれのスキャン結果をS3からダウンロードし、新しく出現した脆弱性と削除された脆弱性の情報を比較結果としています。比較結果のデータサイズがSQSのデータサイズ制限(256KB)を超える可能性があるので、比較結果を直接SQSには流さずS3へ保存しています。その後、SNS → SQS を経由して Lambda に通知を送り、DynamoDB上にある脆弱性の状態(未修正・修正済み)を更新したり、Slackに通知したりしています。

f:id:mztnex:20200713200248p:plain
新たな脆弱性が発見された、あるいは脆弱性が修正された際のSlack通知

管理コンソールからはどのコンテナイメージのどこにその脆弱性があり、それぞれの修正状況も把握できるようなユーザインターフェイスを用意しました。これによって社内での脆弱性対応の進捗が可視化されています。

f:id:mztnex:20200713200401p:plain
脆弱性の修正状況を確認できる管理コンソールのビュー

ベースイメージの判定

f:id:mztnex:20200713200445p:plain
ベースイメージを判定する手順の概要

「要件4) ベースイメージに含まれているパッケージの脆弱性を識別できるようにする」で説明したとおり、ベースイメージに含まれているパッケージの脆弱性とアプリケーション開発によって追加されたパッケージの脆弱性とを区別する仕組みを取り入れました。この判定には各イメージのLayer Digestを利用しています。ベースイメージを利用してイメージをビルドする場合、ビルドしたイメージは一部のレイヤーをベースイメージと共有しています。そのため、Layer Digestが一致すればそれ以前のレイヤーは基本的にすべてベースイメージのものである、と判断することが出来ます。

Trivyのスキャン結果には各脆弱性が含まれるレイヤーのLayer Digestが記載されているため、アプリケーションイメージのどのレイヤーがベースイメージ由来なのかがわかっていれば、脆弱性を含むパッケージがどちらに属しているのかも判断できます。どのレイヤーからベースイメージなのかを後から判定するため、スキャン結果とLayer Digestの一覧を組み合わせて保存しておく必要がありますが、残念ながらTrivyのスキャン結果に記載されていません。しかしLayer Digestの一覧はECRに保存されているため、代わりにECRへアクセスすることで取得できます。先述したとおり、fargate上でのスキャン時にはTrivyのスキャン結果とECR上のレイヤ情報の両方を取得し、組み合わせてS3へ保存しています。

このような仕組みでベースイメージを検出するために、検索用のデータストアとしてDynamoDBを使っています。DynamoDBに全てのイメージの最新レイヤーのLayer Digestをキーとして保存し、アプリケーションイメージの脆弱性一覧を表示するタイミングで全てのLayer Digestをバッチで問い合わせ、その結果からどこからベースイメージかを判定します。一覧表示のタイミングで検索しているのは、ベースイメージとアプリケーションイメージがほぼ同時に更新された際、スキャン結果の到着が前後する可能性があるためです。

この仕組を使うことで、どのレポジトリやタグがベースイメージとして使われているのかという情報をメンテナンスしなくても、自動的に判定ができるようになりました。また、ベースイメージが複数ある(ベースイメージAからベースイメージBが作られ、ベースイメージBからアプリケーションイメージが作られる)場合でも、同じ仕組みによって正確に複数のベースイメージを判定できます。管理コンソールでは次の図のようにベースイメージ由来の脆弱性はリンク先で確認するようなUIにしました。

コスト

今回のアーキテクチャではコスト削減を目的としていたわけではないのですが、結果としては一日あたりの動作コストが$6弱になりました。

その中でも支配的なのがDynamoDBで、1日あたり$4ほどのコストになっています。これはCapacity設定の最適値が読めないため on-demand capacity mode で動作させているためと考えられ、これは今後適切な値でRead/Write Capacityを設定しAuto scalingと併せて使うことで改善できると考えています。また、クエリについても改善の余地がありそうな部分はあり、そちらも今後リファクタしていきたいと考えています。

一方、CPUリソースが必要とされるTrivyのスキャンに関しては一日あたりおよそ$0.5ほどになっています。これはスケールイン・アウトがうまく機能していること、そしてFargate spotを使っていることで大きくコストを抑えていると見ています。Fargate spotなので処理の途中で停止してしまう可能性もありますが、どの段階で処理が止まってもやり直しがきき、かつ複数回処理が実行されても冪等になるように実装しているため、特に問題なく利用できています。

まとめ

この記事ではTrivyとAWSのマネージドサービスを使った、CI/CDと疎結合にコンテナイメージの脆弱性スキャンパイプラインの要件、アーキテクチャと実装の一部を紹介しました。これは永続的に疎結合のまま運用することを目指しているわけではなく、CI/CDの中に直接組み込むとしたらどのような仕組みや運用ポリシーが必要になるか?という課題を解くための前段階という意味合いもあります。技術部セキュリティグループでは引き続きどのようなパッケージの脆弱性管理の戦略をとれば事業開発のスピードへの影響を最小化しつつセキュリティを担保していけるか、という問題にチャレンジしていこうと考えています。

このようなエンジニアリングのチャレンジをするにあたり、クックパッドでは(引き続き)セキュリティエンジニアを募集しています。情報セキュリティに強い方だけでなく、むしろサービス開発を得意としつつセキュリティにも強い関心がある、という方にも興味を持っていただければ幸いです。

エンジニア社内留学制度を利用してAndroidアプリ開発を体験した話

$
0
0

こんにちは、事業開発部でデータ分析やデータエンジニアリングをやっている佐藤です。最近の楽しみはクックパッドマートで買ったコーヒー豆を挽いて淹れることです。

今日はクックパッド社内で実施されているエンジニア社内留学制度について紹介します。

エンジニア社内留学制度とは

エンジニア社内留学制度は「異動をすることなく短期的に他の部署でその部署の仕事をする制度」というもので2019年4月に作られました。 この制度は異動をせずに視野を広げたり自分のキャリアを考えるための制度であり、普段自分が関わらない技術や分野に対して新しいチャレンジをする機会を提供するための制度です。

エンジニア社内留学制度を利用することで、最大2ヶ月の間もとの部署の仕事から離れて留学先部署の業務に取りかかれます。これは全エンジニアが利用可能な制度です。
この制度の概要は上記のとおりですが、制度を利用して留学させる・受け入れる側を含めた関係者の狙いは下記のようなものとなります。

  • 留学生側
    • 他部署の業務に取り組むことで、視野を広げ、技術や分野において新しいチャレンジをする機会とする
  • 留学元部署
    • メンバーの目線を広げ、技術や分野の違うチャレンジをするなど成長の機会とする
    • 他部署の業務を詳細に知る社員を増やすことで、留学終了後もより円滑に協力できるようにする
  • 留学先部署
    • 短期的な開発リソースの確保
    • 自部署の業務を詳細に知る社員を増やすことで、留学終了後もより円滑に協力できるようにする

この制度が作られた後、サービス開発を行う部署から技術基盤の部署へのエンジニア留学が何件か実施されました。 自分はこの制度を利用して5月〜6月の2ヶ月間モバイル基盤部でAndroid留学を行いましたので、以降の内容ではそのAndroid留学に関して書いていきます。

Android留学の流れ

当記事の冒頭に書いたとおり、自分は普段は事業部でデータ分析やデータ整備作業などを主務として行っていました。 そんな自分が今回エンジニア社内留学制度を使ってAndroid開発に関する知識を身に着けようと思った動機はおおまかに下記の3つです。

  1. Androidエンジニアが足りないということで丁度モバイル基盤部がAndroid留学を募集してた(下記の図を参照)
  2. 部署でデータ分析をしているうちにモバイルの知識が必要になってきた
  3. Android留学を一回しておくと今後iOSで同じようなことをしたくなったときの取っ掛かりにもなりそう

f:id:ragi256:20200716142952p:plain
Android留学募集の様子

というわけで上長に相談し、次の目標を掲げての2ヶ月の社内留学が決定しました。

  • Android版クックパッドアプリのどの部分のコードでどうやってログデータを送ってるか把握する
  • Androidアプリのロギング処理をクライアント側で調査・デバッグできるようになる
  • 誰かが新たにロギング処理を仕込む際に、相談相手になったりコメントできるようになる
  • 今後もモバイル基盤部と協力してモバイルのログ周辺がより良くなるよう整備をしていけるようになる
  • モバイルエンジニアに依頼するばかりでなく自分でもログを仕込めるようになる

この時点でAndroidアプリ開発もKotlinもJavaも全く触れたこともありませんでした。完全に未経験の状態です。 このあたりの留学決定に関する流れは4月頭の1on1で相談したら即留学用チャンネルにinviteされ、3週間ほどの調整期間の後、留学を実施というスピード感でした。調整期間というのは元いた部署の仕事から離れても大丈夫なよう片付けるための期間だったので、特に何かしらの準備があったわけではありません。

やってみてどうだったか

留学期間で実際に着手したタスクは下記の4つでした。

  1. アプリ画面リファクタリングに伴うログ変更に関する調査と周知
  2. 古いコードのVIPER化
  3. モバイルアプリにあるロギング実装に関するドキュメント整備
  4. 旧ロギング実装のリファクタリング

各タスクについて個別に書いていきます。

1. アプリ画面リファクタリングに伴うログ変更に関する調査と周知

クックパッドが提供しているレシピアプリはiOS・Androidの両プラットフォームともにVIPERというレイヤードアーキテクチャを採用しています。このVIPERアーキテクチャ採用は2018年に決定したもので、今利用しているコードの中には旧アーキテクチャのままになっている箇所もあります。よって既存コードをVIPERのアーキテクチャに置き換える作業(通称VIPER化)が行われています。
最近行われたとある画面のVIPER化に伴って、意図せずログ送信内容が書き換わっている可能性が高いことがわかりました。そのため、その問題の調査と社内周知に留学初タスクとして取り掛かりました。実際にやったことはVIPER化の手順を追いかけ、ログ実装を読み、実際に送られたログデータの変化を確認するだけです。

2. 古いコードのVIPER化

初タスクでVIPER化の作業を追いかけて読んだため、Android開発の素振りとしてVIPER化に取り組むこととなりました。しかし、結論から言えばこのタスクは断念することとなりました。
理由は初めてのモバイルアプリ開発に対して、あまりに知識が足りなかったためです。開発するためのキャッチアップに時間を浪費してしまい、そのままでは定められた期間で留学の目的を達成することが困難と判断したためです。VIPERもそうですが、Rx・DI・マルチモジュール・Android知識など予め備えておくべき知識の諸々を学びながらの期間であったため、見てもらうためのPRの実装を作るまでに時間がかかってしまいました。初めてレシピアプリ開発に取り掛かる開発者も困らないようにと初学者用ドキュメントは整っており、それを読みながらの実装でしたがとにかく初めての概念が多いため覚えることがたくさんありました。
この点に関してはまずGoogle CodeLabsをやるのが良かっただろうというのが反省です。

3. モバイルアプリにあるロギング実装に関するドキュメント整備

VIPER化を断念した後、自分が何をするべきかを留学当初の目標に立ち返って考え、取り組むべき課題を考えることとしました。元々の目標の中心にあった「ログ周辺」の課題がなにかないか考えたところ、「レシピアプリ内で使われるロギングの実装がとっちらかっているように見えるのでなんとかしたい」という課題を留学期間中に感じていました。
そこで実際に取り組んだタスクがこのドキュメント整備と次の旧ロギング実装のリファクタリングです。
レシピアプリはiOS・Androidともに開発に参加しやすい状況を維持すべく、開発参加者への支援が手厚く用意されています。オンボーディングや開発者向けドキュメントなどがそうです。ですが、アプリから送られるログ周りに関しては専門家がいなかったため、包括的なドキュメントがありませんでした。 そこで留学という機会を利用して、レシピアプリ開発へ新規に参加するエンジニアでもロギング実装に困らないようなドキュメントを書きました。

4. 旧ロギング実装のリファクタリング

3番目のドキュメント整備タスクと並行して、古いログ送信処理を置きかえる作業を実施していました。旧ロギング実装はアプリ開発からしてみれば何か大きく問題点があるわけではなかったため、誰にも気づかれずそのままとなっていました。しかし、実際に送信されたログを保守・加工・分析を行っている側では微妙に扱いづらいものであり、ログデータを利用する側(分析者やデータ整備者)ではちょっとした負債となっていました。この分析サイドからみた負債を解消することが、旧ロギング実装リファクタリングの目的でした。 こういった負債の指摘やリファクタリング作業やドキュメント整備はログデータを送る側からも利用する側からも扱いやすい、より良いログデータ環境を目指そうという意識付けにも繋がりました。データ基盤はは送信箇所や分析箇所などの特定の箇所の改善では使いやすくなりません。実際の利用フローに合わせ、足並みを揃えてトータルの改善をすることで多くの人から喜ばれるデータ基盤となります。

上記4つのタスクをひたすらにこなしているうちに気づけば2ヶ月が経過してしまい、エンジニア社内留学が終了となりました。留学自体は終わりましたが、自分自身がクックパッド社内でデータに関わるいちエンジニアであるということには変わりがないため、今回得た経験を活かして今後もデータ分析環境の改善に取り組んでいくつもりです。

エンジニア社内留学からの副産物的成果

実際にやってみたところ、予想していなかった副産物的成果がいくつかありました。自分としては「完全なAndroid初心者では手取り足取り教えてもらうだけになりそう」と思っていたのですが、留学をしてみたら意外と好影響もあったようです。

1. Android入門者用のドキュメントが改善された

初めてのAndroid開発に参加するため、レシピアプリに関する全ドキュメントに目を通すこととなりました。この際に疑問に思ったところは片っ端から質問をするようにしていたため、ドキュメントの不備・陳腐化した内容・分かりにくい説明などはどんどん修正されていきました。

2. ログに関する議論が活発になった

留学先のモバイル基盤部はお昼会という名のデイリーミーティングと、週次で行われる振り返りミーティングがありました。リモート期間中だったので1これらのミーティングは全てZoom越しに行われました。このミーティングで同僚の着手タスクの概要や進捗状況を把握するわけですが、こういった日々の会話の中で常にログデータの取り扱いに関する話に対して質問やコメントをしたりし続けていました。
折しも社内でログの取り扱いに関する話題が活発化しているタイミングで、そういった議論に関して「今こういう話が活発ですよ」「このチャンネルでこういう議論がかわされていますよ」という誘導を会話の中でし続けていました。
ロギングのドキュメント整備で話し合う機会もあり、「他部署ではログデータをこう取り扱っている」といった部署横断的な知識の提供に繋がりました。

3. 今まで方針の定まっていなかったロギング実装に関して、話し合いの場を設けて合意をとった

「やったこと」の3つ目に書いてあるとおり、留学後にこなした業務の中で「ロギングのドキュメント整備」がありました。このドキュメント整備ですが、今まで明文化されていなかったものをドキュメントに書き起こすだけで済むかと思いきや、そうではありませんでした。
これまで言語化されていなかったため、明確になっていたなかった点がいくつもあったのです。ドキュメントを制定するに当たり、同時にプルリクエストレビューで多くの人と意識のすり合わせがなされました。また、PRだけでは決まりそうにない、ロギング実装に関する大きな意思決定のため有識者会議を開くこともありました。
多くの人が関わるクックパッドのレシピアプリ開発の方針決定に関わることになるとは留学前には考えていもいませんでした。

終わりに

クックパッドでのエンジニア社内留学制度の紹介と、その制度を利用したAndroid留学体験を紹介しました。
社内で異動することなく、別分野のエンジニア業務を体験してみるのは新鮮なことでしたし、自分が取り組める業務の幅も広がったと思います。また、初心者かつ異分野エンジニアが留学してみると、留学ならではの好影響も与えられるという発見がありました。

留学を終了した証書
無事に留学修了証書をもらいました


  1. クックパッドでは新型コロナウイルス感染症の拡大に伴い、2月から全従業員を対象に在宅勤務を実施しています。在宅勤務に対する取り組み例はこちら。記事1記事2

RailsアプリケーションのCIにDynamoDB Localを導入した話

$
0
0

こんにちは、事業開発部 サーバーサイドエンジニアの堀江(kentarohorie)です。

今回はRailsアプリケーションのCIにDynamoDB Localを導入した事例をご紹介します。

広告入稿システムとCI

クックパッドでは自社製の広告入稿システム・配信サーバーを運用しています。また広告の一部はDynamoDBを利用したアーキテクチャで入稿・配信されています。詳細は以前の記事「広告配信サーバーにおける DynamoDB Accelerator (DAX) 活用事例の紹介」で紹介されています。この入稿・配信のうち、広告入稿システムのCIに対してDynamoDB Localの導入を行いました。

広告入稿システムのCIではブランチへのpush、またはmasterへの変更をトリガーにCIサーバー上でスクリプトが実行されていました。CIサーバーにはMySQLやPostgreSQLの環境が用意されており、スクリプトが実行されるとサーバー上のDBを初期化してrspecが実行されていました。多くのテストでそれらのDBを利用したテストが実行されていましたが、DynamoDBに関しては実際のDBを使用できていませんでした。

そのため、DynamoDBを利用している箇所ではAWS SDK DynamoDBClientのput_itemやdelete_itemなどのメソッドを一つ一つstubしたテストが書かれていました。これは例えばDynamoDBを利用したコードが増えたり、その箇所を間接的に利用する必要があるコードが生まれた場合に、DynamoDBの利用を気にしながら必要に応じて都度stubするといった作業が必要になるということです。
例えば以下のようなstubがit句毎に書かれていました。

it "..."do
  expect(dynamodb_client).to receive(:delete_item).with(
    hash_including(
      table_name: "table_name",
      key: { pk: "product_key" },
    )
  )

  expect { subject }.to change { ... }.to(false)
end

こうした状況の中でDynamoDBを利用している箇所で、stubせずともテストを書けるようにしようというモチベーションがありました。

DynamoDB Local導入に必要な環境を整備

DynamoDB Localの導入にあたっては執筆時点で3つの方法がAWSで紹介されています。

  • Apache Mavenリポジトリとして利用
  • Java環境を用意して実行
  • Dockerイメージを利用

これら方法のうち、Dockerイメージを利用してDynamoDB Localを導入しました。理由は全社的にCodeBuildの利用が推進されており、CodeBuild上でDockerを利用してCIを回すという事例が社内に既に多く存在していたためです。CodeBuildはAWSが提供するCI/CD用ビルドサービスであり、Androidアプリ CIをCodeBuildに切り替えた事例などクックパッドでは広く活用されています。

上記検討の後、まずは既存のビルド部分をCodeBuildに置き換え、Codebuild上のDockerでテストを実行できる環境を用意しました。ビルド部分の置き換えはJenkinsのCodeBuildプラグインを利用しました。次に社内で用意されているCodeBuild用Dockerイメージをベースに広告入稿システムのDockerイメージを作り、MySQLやPostgreSQLを利用する処理はスクリプトを用意してdocker-compose up時に実行されるようにしました。具体的にはDBの初期化やrspecの実行などです。

f:id:kentarohorie:20200721123459p:plain
Before

f:id:kentarohorie:20200721123513p:plain
After

この置き換え作業では、既存のCIと比べた場合に可能な限りCI時間が長くならないことを意識して進めました。CodeBuildに置き換える場合これまでになかったDockerイメージのビルドや立ち上げといった工程が増えるためにCI時間が長くならざるをえません。しかしCI時間は短ければ短いほうが良いので、許容できる程度までCodeBuildでのCI時間を縮める必要がありました。

具体的には以下の工夫を行いました。

  1. CodeBuild上でのDockerイメージビルドはキャッシュを利用する
  2. docker-composeでマウントするファイルを可能な限り減らす

広告入稿システムはRailsで動いており、ビルド時間でネックになっていたのはnode_modulesとgemのインストール工程でした。当初はCodeBuildのS3キャッシュを利用してnode_modulesとgemをキャッシュする方針で作業を行っていました。しかしその方法ではnode_modulesとgemファイル群をCodeBuildサーバー(コンテナの外)に持つ必要があり、docker-composeでマウントする必要のあるファイルが多くなり結果コマンド実行時間が遅くなるという問題が発生しました。

次にDocker Layer Cacheを利用する方法を試しました。はじめはCodeBuildで用意されている「ローカルキャッシュ」のDocker Layer Cacheモードを利用していましたが、ライフスパンが30分程度と短いため、CIの稼働頻度が30分に一度回るほどは高くない広告入稿システムではあまり恩恵を受けれませんでした。

そこで最終的に、ECRを利用してDocker Layer Cacheすることになりました。具体的にはCodeBuildのPOST_BUILDフェーズでECRへDockerイメージをpushし、次のBuild時にそのイメージをキャッシュとして利用する、というようにしました。

phases:pre_build:commands:- ....
      - docker pull "${REPO}:latest" || true- ...
  build:commands:- ...
      - docker build --tag "rspec" --tag "${REPO}:latest" -- cache-from "${REPO}:latest" -f Dockerfile .
      - ...
  post_build:commands:- ...
      - docker push "${REPO}:latest"- ...

DynamoDB Localをテストへ導入

CodeBuildへの置き換えが完了した後はdocker-compose.ymlにAmazonが公式に配布しているDynamoDB Localイメージを組み込み、テスト時にそれを読み込むように設定しました。具体的にはAWSのconfigをアップデートする処理をテスト実行前に読み込むようにしました。広告入稿システムのテストでは他にAWSリソースを使用していなかったため、DynamoDBリソースに絞った設定はしませんでした。

次にテスト実行時にDBが初期化されるようにしました。 広告システム関連で使われているDynamoDBにはdynaというgemを利用したDB初期化の仕組みがあります。 dynaはDynamoDBをDSLで管理できるものです。したがって、テスト実行時のDB初期化はdocker-compose up時に走らせるscript内にDB初期化を行うdynaコマンドを実行することで達成しました。

最後に、広告入稿システムのテストでDynamoDBに関するstubを外していく作業を行いました。これでDynamoDB LocalのCI導入が完了しました。

導入結果

DynamoDB LocalをCIに導入することで以下を達成できました。

  • DynamoDBに関する処理のstubを考えずにテストが書けるようになった
  • DynamoDBに関するテストコードを、各人の環境で実行できるようになった
  • DynamoDBを利用したコードの保守性を向上させることができた
  • DynamoDBやClientの仕様変更に耐えやすいテストになった

導入後、DynamoDBに関する最初の作業としてDynamoDBのクライアントgem aws-sdk-dynamodbのアップデート作業を行いました。specではstubせずにDynamoDB Localにアクセスしているのでテストが通った結果に安心感を持つことができ、導入によるメリットを実感しました。

最後に

以上、広告入稿システムのCIにDynamoDB Localを導入した事例をご紹介しました。

クックパッドにはユーザーが触る画面を改善しているサービス開発領域や、収益を支えている広告領域など、様々な領域でエンジニアが活躍しています。そしてそれらの領域ではエンジニアを随時募集しています。興味を持っていただいた方のご応募をお待ちしております。

新卒採用: https://info.cookpad.com/careers/new-graduates/

キャリア採用: https://info.cookpad.com/careers/jobs/

大規模なiOSアプリの画面開発を効率化するために動作確認用ミニアプリを構築する

$
0
0

こんにちは、モバイル基盤部の大川(@aomathwift)です。

iOSアプリの開発途中で画面のレイアウトなど僅かな変更を確認したい場合、最も確実な方法はアプリをビルドして該当の画面まで手動で遷移して確認する方法です。

この方法は特別なセットアップが必要なく単純明快な確認方法ですが、効率の面で問題があります。例えば一番の問題として挙げられるのがビルド時間の長さという問題です。アプリ開発の規模が拡大していくと、ちょっとした変更でもビルド待ちの時間が無視できないものとなっていきます。

本稿では、クックパッドアプリの開発において、機能単体で動作するミニアプリを構築して、プレビューサイクルを改善した取り組みについてお話しします。 f:id:aomathwift:20200731184036p:plain

iOSアプリの動作確認における問題点

クックパッドアプリの開発は、開発規模の拡大によって、ビルド時間の改善が大きな課題になっていました。 そこで、最近はその問題を解決すべく、大きなアプリを複数のモジュールに分け、分割してビルドできるようなマルチモジュール化に取り組んできました。

詳しくは2019年のCookpad Tech Confでの講演、「 〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」をご覧ください。

f:id:aomathwift:20200731184155p:plainこれは、マルチモジュール化が活発化した2019年9月から2020年6月まで、同一のマシンで計測した平均ビルド時間を集計したものです。 このマルチモジュール化の取り組みの結果、一回あたりのビルド時間が徐々に改善されてきているのがわかります。

しかしながら、小さな変更を確認したい場合やレイアウトを調整したい場合を考えると、まだまだストレスを感じる長さです。

また、新規のモジュールは、アプリを起動してからそのモジュールの機能や画面への接続が出来ていない状態から開発を始めます。 そのため、開発するモジュールの画面への遷移を先に実装することが必要です。

これをすべて同じ開発者が担当しているなら然程問題ではないかもしれませんが、この起動画面からの導線部分の実装とモジュールの開発を別の開発者が行っていた場合、モジュール開発を担当する人は仮の画面遷移を実装するなどの余計なコストが生じてしまいます。

Sandbox - 機能ごとに動作するミニアプリ

f:id:aomathwift:20200731184247p:plain
TechConf2019より引用

クックパッドのマルチモジュール化では、レシピの表示画面や、検索結果画面など、1機能に関連するいくつかの画面を1つのモジュールとして扱っています。この単位をFeature Moduleと呼んでいます。

Feature Moduleの導入により、アプリ全体をビルドせずとも、部分的にビルドすることができるようになりました。

これらのFeature Moduleはframeworkとしてクックパッドのメインターゲットでimportして利用しますが、先に述べたような動作確認における問題を解決するため、Feature Moduleを単体のアプリとして動作可能にしたのがSandboxアプリです。

以降、この部分的にビルドするSandboxアプリに対して、アプリ全体を結合してビルドするアプリは本体アプリと呼ぶことにします。

Sandboxアプリのメリット

本体アプリをビルドするより速くビルドできる

このSandboxアプリのわかりやすい恩恵は、本体アプリよりも極めて短い時間でビルドが終わる点です。

同じ少量の差分のビルドにかかる時間の計測結果を比較すると、本体アプリのビルドでは平均約20秒かかるのに対しSandboxアプリのビルドでは約5秒で済みます。 単純計算でビルド時間を1/4に抑えられるということになります。

実際のビルドの様子を見ても、Sandboxがものの一瞬で起動できることは一目瞭然です。

f:id:aomathwift:20200731184359g:plain
本体アプリのビルド
f:id:aomathwift:20200731184538g:plain
Sandboxアプリのビルド

Viewのレイアウトの僅かな値を変更して差分を確認したいときなどでも、ストレスなく開発することが可能になりました。

確認したい画面にすぐ辿り着ける

クックパッドのように機能の多いアプリでは、アプリトップから開発している画面にたどり着くまでがやや面倒な場合があります

また、決済終了後の画面など、表示する条件が複雑な画面も存在しています。

そこでSandboxアプリを利用すると、確認したい画面を一番最初に、好みの条件で起動することが出来ます。

繰り返し起動したい画面は一覧になっていて、ここから選択して表示することができます。

f:id:aomathwift:20200731184643g:plain

先に述べたように、まだアプリ起動時の画面からの導線が出来ていない画面のデバッグも容易に可能です。

Sandboxアプリの実現方法

上記のようなメリットを得られるSandboxアプリを実現するために、解決しなければならない問題がありました。

一つは、本体アプリでは多くの外部ライブラリに依存していますが、ビルド速度向上のためにできるだけこの依存ライブラリの利用を避けなければいけないということ、 もう一つは、Feature Module内の画面から、別のFeature Module内の画面に遷移するケースがありますが、このためには検証に必要ないモジュールのビルドも必要になるため、これもまた避けなければならないということです。

これらを考慮した上で、画面遷移やネットワークリクエストといった副作用を本体アプリに近い形で提供する工夫が必要になります。

そこで、クックパッドアプリでは Dependency Injection を利用した副作用を取り出すためのオブジェクトを用意し、これを経由して各画面から副作用を呼び出せるようにしています。 この仕組みをEnvironmentと呼んでいます。

これを利用し、Sandboxアプリではスタブ可能なダミーの実装を注入することで、本体アプリに影響を与えずに副作用を実現できるようにしました。

これによって、例えばネットワークリクエストも実際にリクエストを送るのではなく、予め用意したデータを注入して表示することができるようになっています。

f:id:aomathwift:20200731184817p:plain

Sandbox用のターゲットは各Feature Moduleごとに作成し、ターゲットごとにビルドすることでモジュール単位でのミニアプリの起動を実現しています。

Sandboxアプリを開発者に快適に利用してもらうための工夫

Sandbox用のコードはできるだけ自動生成する

Sandboxアプリを動かすためのコードは実際のプロダクションコードとは別に実装する必要があります。

画面の実装自体はプロダクションコードを参照するとはいえ一つのアプリとして立ち上げるわけですから、データのスタブやEnvironmentの実体の注入などそれなりにコードを記述する必要があります。

この手間が障壁となり、導入当初はSandboxアプリを利用せずアプリ全体をビルドするという開発者が多い状況でした。

そこで、Sandboxアプリのセットアップを簡単に行えるよう、コード生成の仕組みを用意しました。

コード生成にはGenesisというSwiftで実装されたOSSを利用しています。 これは、同じくSwiftで実装されたテンプレートエンジンであるStencilを利用し、簡単な設定とテンプレートを用意すれば、ソースコード生成の仕組みを実現できるツールです。

options:- name: sceneName
    question: Sandbox scene name?
    description: new Sandbox scene name to generate. (e.g. RecipeDetails).
    type: string
    required:true- name: moduleName
    question: Destination target?
    description: module name to generate new sandbox scene for. (e.g. RecipeDetails)
    type: string
    required:truefiles:- template: AppDelegate.swift.stencil
    path:"{{ moduleName }}AppDelegate.swift"

例えばこのようなコード生成定義を書いて、オプションとして作成したいSandboxのモジュール名や画面の名前を与えると、以下のテンプレートファイルの中で展開されます。

@testable import {{ moduleName }}
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    private let environment = StubbableEnvironment()
    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        
        // Inject Scenes to RootTableViewController
        let rootViewController = {{ sceneName }}ViewBuilder.build(environment: environment)
        window?.rootViewController = rootViewController
        window?.makeKeyAndVisible()
        return true
    }
}

そして、この定義に基づくコード生成スクリプトを実行すると、自分であれこれコードを書かなくとも、Sandboxを生成したいモジュール・画面の情報が反映されビルドできるようになります。

$ ./scripts/generate-sandbox
[14:00:14]: Welcome to Sandbox Scene generator
[14:00:14]: What target do you want to make sandbox for1. MyFeature
2. MyAwesomeFeature
?  1[14:00:22]: Enter new Sandbox Scene name to generate. Upper camel case is recommended. (like RecipeDetails)
MyFeatureDetail
[14:00:40]: Generating MyFeature/MyFeatureDetail
[14:00:40]: $ /path/to/ios-cookpad/scripts/mint run Genesis genesis generate /path/to/ios-cookpad/templates/SandboxScene.yml --destination /path/to/ios-cookpad --option-path /var/folders/p7/g0t6l0zx00sbdxxrnm7wq8d80000gp/T/options20200714-98239-1lwms1g.yml
[14:00:40]: ▸ Generated files:
[14:00:40]: ▸   Sandbox/MyFeature/MyFeatureDetailSandboxScene.swift
[14:00:40]: ▸   Sandbox/MyFeature/AppDelegate.swift

できるだけ実際のアプリに近い挙動になるようにする

Sandboxアプリでは、マルチモジュール化での依存関係の問題により、他のモジュールにある画面に遷移することはできません。

基本的に一つの画面をプレビューすることを想定しているSandboxアプリでこの画面遷移を厳密にプレビューできるようにする必要はありませんが、スムーズな動作確認ができるように、簡易的なViewをモックとして表示できるようにしました。

これにより、本体アプリとほぼ同じ挙動を想定した動作確認をすることができるようになっています。

f:id:aomathwift:20200731190028g:plain

この機能は、先に述べたコード生成により自動で実装されるほか、自分で実装する場合も一つのイニシャライザメソッドを呼べばセットアップできるように整備されています。

今後の展望

先日のWWDC2020で、SwiftUIの新しいPreview機能についての発表がありました。

https://developer.apple.com/videos/play/wwdc2020/10149

昨年発表されたXcode Previewsは、SwiftUIで構築した画面を、Xcode上でリアルタイムに確認できるような仕組みです。

今回のアップデートでは、プレビュー中にサンプルデータを流し込んで利用したり、Xcode Previewsによって起動した画面を実機上でインタラクティブに操作しながら確認したりする機能が加わり、今まさにSandboxアプリで実現していることがXcode Previewsで実現できるようになります。

加えて、Dynamic TypeやダークモードのPreviewなどの機能とも併せることで、より効率的に開発を行うことが可能になるでしょう。

現在、クックパッドアプリの画面はほぼUIKitで実装されたものですが、新しい機能の実装にSwiftUIを利用できないか試しているところです。 来る新しいXcode Previewsが利用可能になる日に向けて、SwiftUIによるView実装への移行と共に、プレビュー機能全体をXcode Previewsを利用したものに移行していく必要があると考えています。

既存のUIKitによる実装に関してはXcode PreviewsとSandboxアプリの機能を併せて利用したものを試行しながら、プレビュー確認環境全体の改善を進めていく予定です。

Xcode PreviewsとUIKitの併用については、メルカリさんが下記の記事で自社の事例を紹介しています。

まとめ

この記事では、クックパッドアプリにおけるSandboxアプリを利用した動作確認の効率化について紹介しました。

開発効率を上げるために、スピーディーで快適な動作確認環境は必要不可欠です。

クックパッドでは、より便利なプレビュー機能への改善を一緒に行っていただけるエンジニアを募集しています。


Amazon RDS/Auroraをクローンするシステムを作った話

$
0
0

こんにちは、技術部SRグループの菅原です。

最近、Ninja650からNinja1000に乗り換えました。パワーがあるせいで3速発進・4速発進が平気でできてしまい、シフトワークがどんどん下手になっています。精進したいものです。

この記事では、Amazon RDS/Auroraをクローンするシステムを作った話を書きます。

Amazon RDS/Auroraをクローンするシステム

サービス開発を行っていると、調査や検証でプロダクション環境で使われているデータベースが必要になることがあります。開発環境やステージング環境にもデータベースは存在するのですが、プロダクション環境のデータでしか再現しないバグの調査や、プロダクション環境のデータ量でのスキーマ変更の負荷の検証など、開発環境やステージング環境のデータベースではできない作業も多いです。しかし、オペレーションミスや個人情報へのアクセスを考えると、プロダクション環境のデータベースで直接作業をすることは大きなリスクを伴います。

Amazon Auroraのクローン作成機能を使うと、プロダクション環境に影響を及ぼさないクローンを作成できるのですが、個人情報にアクセスできてしまう点は解決できません。また、クローンの作成や削除には強力なIAMの権限が必要なため、管理者がクローンを作成して利用者に渡すような手間が発生していました。

そこで、それらの問題を解決し、開発者が手軽にプロダクション環境のデータベースを触れるように、Amazon RDS/Auroraをクローンするシステムを作成しました。

クローン作成の手順は以下の通りです。

  1. SlackでRubotyに対して @ruboty rds clone db-cluster:my-cluster db.t3.small 4hというコマンドを送る
    • 普段からChatOpsでデプロイが行われていること、作成したクローンDBの情報を共有しやすいことなどからインターフェースとしてSlackを利用しました
  2. RubotyがBarbequeのジョブを起動する
  3. Barbequeのジョブがクローンを作成する
    • Auroraの場合はクローン作成機能、RDSの場合はスナップショットから復元
  4. クローンDBのマスターユーザーのパスワードを変更する
  5. クローンDBのデータをマスキングする
  6. セキュリティグループを変更して、社内ネットワークからクローンDBにアクセスできるようにする

f:id:winebarrel:20200819092303p:plain

f:id:winebarrel:20200819092422p:plain

データのマスキングには同僚の@mozamimyが作ったDumptruckという社内ツールを利用しており、以下のようなJsonnetの設定ファイルに従ってデータをマスキングします。

{
  database: 'db_name',
  except: ['secure_%'], // `secure_`プリフィックスのテーブルはクローンDBにコピーしない
  rules: [
    {
      table: 'users',
      transforms: [
        {
          column: 'tel',
          value: "lpad(id, 12, '0')", // SQLでデータをマスキング
          inline_sql: true,
        },
        {
          column: 'email',
          value: "concat(id, '@example.com')",
          inline_sql: true,
        },
      ],
    },
  ],
}

作成したクローンDBは、利用後に開発者が自分で削除するか、利用期限が切れるとバッチ処理が自動的にクローンDBを削除します。

f:id:winebarrel:20200819092447p:plain

開発者が自分でDBの削除やパラメータの変更を行えるようにするため、クラスタIDやインスタンスIDには rcc-というプレフィックスを付け、IAMの対象リソースをrcc-*とした権限を開発者に付与しています。

まとめ

Amazon RDS/Auroraのクローンが手軽にできるようになったことにより、プロダクション環境のデータの調査や、データベースのパフォーマンスの検証がはかどるようになりました。また、データのマスキングを設定ファイルで管理することにより、どのカラムに秘匿情報が入っているかもわかりやすくなったと思います。プロダクション環境のデータベースを使った作業は管理者やSREに作業が集中しがちなので、このような形でなるべく開発者に権限を委譲していきたいです。

Rubyの開発を支える技術

$
0
0

こんにちは、遠藤(@mametter)です。RubyKaigi Takeout 2020お疲れさまでした。

現在クックパッドには、フルタイムでRubyの開発をしている人が2人います(笹田と遠藤)。 それぞれ、Ruby 3の目標である並列性と静的解析の実現をメインミッションに据えて活動していますが、実はそれ以外にもRubyの開発を支えるための活動をいろいろやっています。

今回は、遠藤が関わっている範囲で、「Ruby開発者会議を支える技術」「Ruby開発のリモート議論を支える技術」「Rubyの品質を支える技術」についてざっと紹介してみます。

1. Ruby開発者会議を支える技術

Rubyに対する機能提案などの議論は、原則として、バグトラッカ上で行われます。 しかし、設計者であり最終決定権を持つmatzの多忙などの理由で、それだけでは議論が停滞してしまうのも事実です。 そこでRubyでは、開発促進のために月例で開発者会議を行っています。 私はここ数年、この会議を運営することにコミットしています。

会議のプロセス

毎月、次のことを行っています(検討以外はだいたいすべて私がやってます)。

  • 開催の約1ヶ月前に議題を募集するチケットを作る(例:先月のチケット)。
  • 開催の数日前、挙げられた議題をmarkdownにまとめ、有志のコミッタとともに事前検討をする(準備会)。
  • 会議当日、matzおよび有志のコミッタと本検討をする。結論の出た提案については、なるべくその場でmatzに回答してもらう。
  • 開催の数日後、議事録を清書して公開する(例:先月の議事録)。

コミュニケーション技術

会議当日のコミュニケーションには、クックパッド提供のzoomと、オンラインmarkdown共同編集ツールであるhackmdを使っています。 コロナ以前は東京近郊でのオフライン会場もありました(matzは大体リモート参加)が、今年は完全オンラインです。 アジェンダおよび議事録の共同編集はいろいろ試した末、「リアルタイムの共同編集ができ、markdownで書けて、コード断片も書きやすい」ということでhackmdに落ち着いています。

準備会

当日の会議は午後半日、約5時間をかけていますが、20件程度の議題をさばくには十分とは言えず、効率化が課題でした。 そのため今年から、数日前に有志での事前検討を行っています。 これにより、当日の参加者が誰も議題を理解していない、という非効率が回避できるようになりました *1。 またこのフェーズで、事前に担当者に話題を振っておいたり、議題が曖昧なチケット・matz判断が不要そうなチケットに事前にフィードバックを返したりもします。 これにより、当日に議題をさばき切れないことはだいぶ減りました。

非互換の影響検討

仕様変更を議論する際には、かならず非互換の影響が議論されます。かつては互換性を気にしない言語とたびたび揶揄されていたRubyですが、現代ではかなり気を使うようになりました。

たとえば先日から、set.rbを組み込みにするという提案がたびたび議論されています(結論は出ていない)。 こういうとき、「トップレベルにSetクラスを自力定義している人は実際どのくらいいるか?」というような疑問が浮かびます。 このとき役に立つのがgem-codesearchです。

gem-codesearchがセットアップされているコミッタ用共用サーバにログインし、次のようにすることで、全最新版gemに対する高速grepができます。

$ gem-codesearch "^class Set$"
/srv/gems/ConstraintSolver-0.1/lib/extensions.rb:class Set
/srv/gems/GoNodes-0.0.1/lib/monkeypatch/set.rb:class Set
/srv/gems/Narnach-minitest-0.3.3/lib/set_ext.rb:class Set
/srv/gems/Ron-0.1.2/lib/ron.rb:class Set
/srv/gems/acapela-0.8.1/script/create_voices.rb:class Set
/srv/gems/annlat-0.0.1/lib/annlat/LaRuby.rb:class Set
/srv/gems/antlr4-0.9.2/lib/antlr4/base.rb:class Set
...

もちろん、この検索対象はあくまで公開gemだけなので、非公開アプリケーションのコードでどうかはわかりません。一方ここでコンフリクトする例が1つでも見つかったら諦めるというわけでもありません。それでも、非互換の影響の見積にはそうとう腐心しています。

なお、gem-codesearchはバックエンドにgoogle/codesearchを利用しています。codesearchはインデックスサイズに4GB制限があり、最近gemのコードサイズがこの制限に触れてしまったので、頭文字のアルファベット26文字+非アルファベット文字の27個にインデックスを分割するという延命をしたりしました(google/zoektも試しましたが、検索の起動がかなり遅く感じたので)。

クレジット:gem-codesearchはakrさんが開発し、hsbtさんがメンテナンスしています。コミッタ用共用サーバはRuby Associationのご提供で、gem-codesearchは私が管理しています。

dev-meeting-bot

議題を募集するチケットはコピペと手編集で作っていましたが、毎月となると、意外と重労働でした。 そこで、Slackボットを作りました。上述のチケットは、このボットにポンと作らせています。

他にも、最新の会議チケットを教えてくれる機能や、参加者にzoomやhackmdのURLをメールする機能が付いています。

クレジット:dev-meeting-botを含め以後紹介するSlackボットの多くは私が開発していますが、チケット作成機能は yebis0942 さんや znz さんのコントリビューションです。また、Herokuが計算機リソースをご提供してくれています。

2. Ruby開発のリモート議論を支える技術

開発者会議以外でも、我々は日々Rubyの開発のための議論を行っています。 必要に応じてzoom(クックパッド提供)を使うこともありますが、ほとんどはSlack上での議論です。 そのためRuby開発者のSlackでは、議論を円滑にするためにいろいろなボットがうごめいています。 ざっとご紹介。

クレジット:Ruby開発者のSlackはRuby Associationにご提供いただいています。

all-ruby

all-rubyとは、これまでにリリースされたほぼすべてのruby(0.49から最新版まで)をビルドするスクリプト、およびその生成実行ファイルを集めたDockerイメージです。 みなさんのお手元でも、次のようにdocker pullして試すことができます。

$ docker pull rubylang/all-ruby
$ docker run --rm -ti rubylang/all-ruby
root@50d2785e9b39:/all-ruby# ./all-ruby -e 'puts "Hello"'
ruby-0.49             -e:1: syntax error
                  exit 1
ruby-0.50             -e:1: syntax error
                  exit 1
ruby-0.51             -e:1: undefined method `puts' for "main"(Object)
                  exit 1
ruby-0.54             -e:1:in method `puts': undefined method `puts' for "main"(Object)
                  exit 1
ruby-0.55             -e:1: undefined method `puts' for "main"(Object)
                  exit 1
...
ruby-0.76             -e:1: undefined method `puts' for "main"(Object)
                  exit 1
ruby-0.95             -e:1: undefined method `puts' for main(Object)
                  exit 1
...
ruby-1.0-961225       -e:1: undefined method `puts' for main(Object)
                  exit 1
ruby-1.0-971002       -e:1: NameError: undefined method `puts' for main(Object)
                  exit 1
...
ruby-1.0-971225       -e:1: NameError: undefined method `puts' for main(Object)
                  exit 1
ruby-1.1a0            -e:1: NameError| undefined method `puts' for main(Object)
                  exit 1
ruby-1.1a1            -e:1: NameError| undefined method `puts' for main(Object)
                  exit 1
ruby-1.1a2            -e:1: NameError:undefined method `puts' for main(Object)
                  exit 1
ruby-1.1a3            -e:1: NameError: undefined method `puts' for main(Object)
                  exit 1
...
ruby-1.1a9            -e:1: NameError: undefined method `puts' for main(Object)
                  exit 1
ruby-1.1b0            Hello
...
ruby-2.7.1            Hello
root@50d2785e9b39:/all-ruby#

Kernel#putsが入ったのはruby-1.1b0からだとわかりますね。このように、Rubyの仕様変更やバグを議論しているとき、どのバージョンから変わったのかを調べるのに役に立ちます。

これをより手軽に・議論中に試すために、Ruby開発者のSlackには「all-rubyボット」がいます。

all-ruby-bot

ボットのソースコードは公開されているので、興味がある人は設置してみてください(dockerコマンドが起動できて、かつSlackからのWebhookが受けられる計算機が必要です)。

クレジット:all-rubyはakrさんが作り(akrさんによる発表資料)、dockerイメージはhsbtさんがメンテナンスしています。Slackボットは私が作っています。

rubyfarm

all-rubyはリリース版ごとの実行ファイルでしたが、コミットごとの実行ファイルをためたイメージも作っています。 それがrubyfarmです。

これは、rubyfarm-bisectというgemから使うことを想定しています。 git bisectは、挙動が変わった(エンバグした・バグ修正された)タイミングをコミット単位で特定できるツールですが、rubyを毎回ビルドするのは比較的大変でした。 rubyfarm-bisectを使うと、コミットごとにビルドではなくdocker pullで済むので、待ち時間が大幅に減り、試行錯誤も容易になります。自動git bisectはいろんな理由で失敗することで有名なので、ビルド失敗がない&試行錯誤がやりやすい、というのはだいぶストレスを下げます。

ただ、Docker Hubがストレージを制限する方針を発表したので、非公開ローカルのレジストリに移行する予定です。

commit-link

挙動変更のあったコミットを特定できたら、それについてSlackで議論をするでしょう。 そのとき、コミットハッシュをコピペするだけでは、読む人が自分でgit showにかけたり、GitHubのリンクをたどったりする必要があって、ストレスフルでした。

そこで、Ruby開発者のSlackにはcommit-linkというボットがいます。

commit-botは全発言をウォッチしていて、10桁以上のコミットハッシュっぽいものがあったらGitHubを見てコミットが実在するか確認し、発見したらリンクを貼ってくれます。 地味ですが、非常に便利です。

その他

他にも、標準添付ライブラリのメンテナを教えてくれる"who-is-maintainer"や、脆弱性報告を議論する専用チャンネルを作ってくれる"h1-channel-creator"など、いろいろなボットがRuby開発議論を支えています。

3. Rubyの品質を支える技術

最後に、Rubyの品質を高めるためのCIの活動について。

Rubyには、CIがたくさんあります。 私が把握している限りで、GitHub Actions、Travis、AppVeyor CI(Windows)、弊社笹田による独自CI、そしてrubyci.orgです。 このようになっているのは(歴史的経緯もありますが)テストの目的がいろいろあるためです。 たとえば笹田独自CIは、まれにしか発生しないハイゼンバグ(GCやVM最適化周りでしばしば発生する)を洗い出すために同じコミットを何度も繰り返しテストし続けます。

rubyci

rubyci.orgは、たぶんRubyで一番古くからあるCIです。

もともとは、chkbuildというRuby専用のテスト実行ツールがあり、有志が自分の環境でchkbuildを実行した結果をキュレーションしたのがrubyci.orgでした。 しかし現在では、通常のCIはGitHub ActionsやTravisで十分になったので、主な特徴が変わっています。

  • GitHub Actionsにないような、ややマイナーな環境もカバーしている
  • コミッタは(ほとんどの)テスト環境に直接sshログインでき、デバッグができる
  • テスト中に表示される警告数も監視している

とくに、再現性の低いバグと戦わなければならないRubyコア開発においては、2番目の特徴は重要になっています。

現在では多くのテスト環境はAWS EC2インスタンス上で動作しています。これらの環境をセットアップしたり、コミッタのアカウントを自動定義したりするために、mitamaeを使った自動化もなされています(ruby/ruby-infra-recipe)。

クレジット:chkbuildはakrさんが作り、rubyci.orgはnaruseさんが作りました。その後のメンテナンスは主にhsbtさんと私が行っています。mitamaeによるプロビジョニングはhsbtさんが整理しました。AWSの費用はRuby Associationがカバーしてくれています。他にも、一部の環境はご提供を受けています(rubyci.org末尾のスポンサーリストを参照のこと)。

alerts-emoji

CIの結果を監視するのは苦行なので、みんなSlackなどへの通知を活用していると思います。 しかしRubyには多数のCIがあり、それぞれ好き勝手なフォーマットで通知を投げていたので、そのチャンネルを眺めて理解するだけでも苦行となっていました。

また、テスト実行に数時間かかる環境などもあるため、ちょっと古いコミットに対する通知が遅れてくることが多く、一体どのコミットに対する通知なのかパッと見でわからない、という問題もありました。

そこで、すべての通知を集約し、統一フォーマットで通知するようにしました。 また、どのコミットに対する通知かを視覚的にわかりやすくするため、コミットに適当な幾何学図形を割り当てることにしました。

なお、絵文字については非常に議論がありました。 私は10桁程度の16進数をパッと見で比較するのが苦手 *2なので、ランダムに選んだ絵文字を振るようにしました(絵文字のほうが視覚的に比較しやすい)。 しかしそうすると、絵文字から意味を読み取ってしまう人から、逆に認知コストが上がったという反対の声があがりました(たとえばスマイルマークや㊙のように意味のある絵文字)。 議論と試行錯誤の末、色違いの幾何学図形の絵文字を使うようにすることで、だいたいの人が満足できるようになりました。

クレジット:まず笹田が独自CI用の通知チャンネルを作りました。そのチャンネルに各種通知が参入して破綻したので、この統一チャンネルを遠藤が作り、その後k0kubunさんがいい感じに整理してくれました。

まとめ

Ruby開発は、多数の技術、および(自分を含めた :-)多数の人々によって支えられています。

ここに上げたものはあくまで遠藤がかかわっているものの一部だけで、上げきれなかったものもいろいろあります(すみません)。みなさんに感謝しながらやっています *3

*1:正確には、去年も私がすべての議題を理解するように努めていたのですが、丸一日消費してしまうことに加え、一人では議題を誤解したり、論点を洗い出せなかったりして、いまいちだったので、笹田と相談して準備会にトライすることになりました。

*2:ハッシュの桁数が、GitHub Actionsは7桁、rubyciは10桁、というように通知ごとにバラバラだったのが特に最悪でした。

*3:特にCIのAWS費用がなかなか大変なので、その費用をカバーしてくれているRuby Associationに寄付をいただけるとありがたいです :-)

Cookpad Pad 2 という自作キーボードノベルティをつくった話

$
0
0

こんにちは、高井です。みなさま Cookpad Online Summer Internship 2020、お疲れさまでした。

さて、今回はインターンのノベルティとして Cookpad Pad 2 という自作キーボードキットをノベルティとしてつくったので、その紹介と解説です。今年のインターンシップはリモート開催ということもあり、ふだんよりも豪華なノベルティをつくることができました。写真では見えませんが、裏側に Cookpad Online Summer Internship 2020 のロゴがプリントされていて、限定感を出しています。

f:id:takai_naoto:20200910181821j:plain

というわけで、本記事では Cookpad Pad 2 を例に取りながら、自作キーボードキットを作成する方法について解説します。キーボードの開発はさまざまなノウハウが公開されているため、実際のところそれほど難しくはありません。本記事ではキーボード開発についての完全な手順を説明するというよりも、有益な記事やリソースを紹介することで、全体の流れとして個々のノウハウを結びつけることを目指します。

キーボードを設計、開発するときの流れは次のようなものです。この流れに沿いながら、どのようにキーボードを開発するのかを見ていきましょう。

  • プロダクト構想
  • キーボード基板設計
    • 回路図設計
    • パーツ選定
    • プリント基板設計
  • キーボードケース設計
    • 設計
    • 検証(アセンブリ)
  • 製造
    • キーボード基板
    • キーボードケース
  • ファームウェア開発
    • QMK Firmware
    • VIA

また、Cookpad Pad 2 はオープンハードウェアとして GitHub でプロジェクトを公開しています。記事とあわせてこちらも参照してください。

https://github.com/cookpad/cookpad-pad/tree/cookpad-pad2

プロダクト構想

まず、最初に行なうのは、どのようなキーボードをつくるのか構想することです。Cookpad Pad 2 は 6 キーのマクロパッドです。デフォルトでは「C」「O」「K」「P」「A」「D」の 6 文字が打てるので、「cookpad」と打つときに便利なキーボードです。初代のモデルは、2019年頃に製作をして、ノベルティとして個人的に配布をしていました。

デザインは、この初代 Cookpad Pad を踏襲するものとします。 6 キーを格子状に配置したシンプルなデザインです。また、ケースは PCB を 2 枚のプレートで挟み込んだ、サンドイッチマウント構造にします。この構造は自作キーボードでよく採用される構造で、安価に作成できることがメリットです。

それから、コネクターとして USB Type-C を採用します。そのために、 Pro Micro というマイコンボードを利用しない方針とします。 Pro Micro は自作キーボードに必要な電子部品が実装されているので、基板の設計を単純にすることができます。一方で、Micro USB Type-Bコネクタが採用されていたり、ピンヘッダでマイコンボードを取り付けるため、設計上の制約がでてしまうという欠点があります。そこで、 Pro Micro の利用をするのではなく、直接基板に同等の機能を実装していきます。

キーボード基板設計

ここまで決まったら、基板の設計を行なっていきます。今回は構造が単純なサンドイッチマウントを採用しているため基板を先に設計しますが、アルミ切削ケースなどの場合はケースの設計から先に行なった方がよいでしょう。

基板の設計には KiCadをつかいます。 KiCad はフリーソフトウェアとして配布されているEDAツールです。独特の操作性を持つので、はじめての人であれば、「KiCadことはじめ」などを参考に、一通り触ってみてください。

回路図

キーボードの基板設計については「ai03's Keybaord PCB Designer Guide」を参照してください。 KiCad の利用方法から回路設計、PCB設計まで一通りが解説されているので、これを読むだけでもキーボード設計ができるようになります。

自作キーボードの MCU は ATmega32u4 を利用することが一般的ですが、今回は ATmega32u2 を利用しています。ATmega32u2 の方が若干フットプリントが小さいため、部品配置が楽になるかもしれないというのが選定理由です。どちらもキーボードファームウェアとして広く利用されている QMK Firmwareでサポートされている MCU です。特に理由がないのであれば ATmega32u4 を採用するのがよいとおもいます。

その他、TVSダイオードによる静電気放電対策やポリスイッチによる過電流対策を行なっています。キーボードのUSB-C関連の設計については、ai03氏が中心となって公開している Unified Daughterboard Projectが参考になるでしょう。今回のキーボードでは、それよりも簡略化された実装にしています。

回路図シンボルなども ai03氏が公開しているものを利用するのが便利です。ai03氏、いったい何者なんだ……。

実際の回路図は下記のようなものになります。左上がMCU関連、右上がキーマトリクス、左下がUSB関連、右下が電源関連となります。

f:id:takai_naoto:20200910181904p:plain

なお、このサイズのキーボードであれば、MCUのピンとスイッチを一対一で対応させればよいので、本来ならばキーマトリックスは不要です。このプロジェクトを拡張することで、好きなキーボードつくってもらえるようにという意図を込めてキーマトリクスを採用しています。そのとき、「KICADの他のプロジェクトから回路をコピーする」のやり方でコピーすると便利でしょう。

パーツ選定

プリント基板設計に進むにあたって、パーツの選定をします。今回は基板製造を Elecrowへ依頼するつもりでしたので、Elecrowが在庫しているElecrow Parts Libraryや、部品の取り寄せができるDigi-Key Electronicsなどから部品を選定します。

パーツの選定は既存のキーボードで利用されている部品から選定するのがよいでしょう。ai03氏のリポジトリからUnified Daughterboard ProjectKBD8X MKIIOrbitなどを参考にしています。USB-C のコネクタは TYPE-C-31-M-12を採用します。この部品は、USB2.0にのみ対応したコネクターで、その分安価です。

それから、コンデンサや抵抗など一般的な部品は、Elecrowの方で代替品に差し替えてもいいかと確認されることがあります。そちらの方がコスト的にもメリットがあるので、それらのパーツについては仕様を示すくらいの気持ちで選定しています。

Component Package Footprint URL
Ceramic Capacitor, 22pF 0603 Capacitor_SMD:C_0603_1608Metric Elecrow Parts Library
Ceramic Capacitor, 1uF 0603 Capacitor_SMD:C_0603_1608Metric Elecrow Parts Library
Ceramic Capacitor, 0.1uF 0603 Capacitor_SMD:C_0603_1608Metric Elecrow Parts Library
Ceramic Capacitor, 10uF 0603 Capacitor_SMD:C_0603_1608Metric https://www.digikey.com/product-detail/en/murata-electronics/GRM188R61E106KA73D/490-18214-2-ND/9867922
Diode, Generic SOD-123 Diode_SMD:D_SOD-123 https://www.digikey.com/product-detail/en/micro-commercial-co/1N4148W-TP/1N4148WTPMSCT-ND/717311
TVS Diode, 5.5V SOT143B random-keyboard-parts:SOT143B https://www.digikey.com/product-detail/en/nexperia-usa-inc/PRTR5V0U2X215/1727-3884-1-ND/1589981
Polyfuse, 500mA hold, 1A trip 1206 Fuse:Fuse_1206_3216Metric https://www.digikey.com/product-detail/en/bel-fuse-inc/0ZCJ0050AF2E/507-1803-1-ND/4156312
Resistor, 5.1k 0805 Resistor_SMD:R_0805_2012Metric https://www.digikey.com/product-detail/en/panasonic-electronic-components/ERA-6AEB512V/P5-1KDACT-ND/1465964
Resistor, 22 0805 Resistor_SMD:R_0805_2012Metric https://www.digikey.com/product-detail/en/panasonic-electronic-components/ERA-6AHD220V/P123893CT-ND/9467822
Resistor, 10k 0805 Resistor_SMD:R_0805_2012Metric https://www.digikey.com/product-detail/en/panasonic-electronic-components/ERA-6AEB103V/P10KDACT-ND/1465971
Low Profile Tactile Switch 5.2x5.2mm  5.25.21.5mm random-keyboard-parts:SKQG-1155865 https://www.digikey.com/product-detail/en/c-k/RS-187R05A2-DS-MT-RT/CKN10361CT-ND/2747199
ATMEGA32U2 Microcontroller 32-TQFP 7x7mm Package_QFP:TQFP-32_7x7mm_P0.8mm https://www.digikey.com/product-detail/en/microchip-technology/ATMEGA32U2-AU/ATMEGA32U2-AU-ND/2187167
Crystal, 16MHz 3.2x2.5mm, 4 pad Type-C:HRO-TYPE-C-31-M-12-Assembly https://www.digikey.com/product-detail/en/CX3225SB16000D0GZJC1/1253-1698-1-ND/5995245/
HRO-TYPE-C-31-M-12 8.94x7.3mm Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm https://lcsc.com/product-detail/USB-Type-C_Korean-Hroparts-Elec-TYPE-C-31-M-12_C165948.html

プリント基板設計

回路が決まり、部品も決まりましたので、あとは PCB に配置していくだけです。配線をしているとパズルを解いているような気分になります。どのようにしたら効率的で美しい配線ができるのか、工夫の見せどころです。KiCad Pcbnew をつかって設計をしていくのですが、細かい操作方法についてはここでは説明しません。前述の「KiCadことはじめ」や「ai03's Keybaord PCB Designer Guide」を参照してください。

最初に ai03 - Keybaord Plate Generatorをつかって、スイッチプレートを生成します。 Keyboard Layout Editorの Raw Data をコピーアンドペーストするだけでスイッチプレートを生成することができます。ファイルは DXF 形式でダウンロードできます。

次に、 KiCad Pcbnew にスイッチプレートファイルをインポートします。プレートにあわせてキースイッチのフットプリントを配置していきます。それから、その他の部品を配置します。USB 周辺や MCU 周辺から配線をしていくとよいでしょう。最後に、キーマトリクスをつくっておしまいです。このプロセスは、あまりキーボードに特有なことはありません。電源とGNDのパターンは太くする、水晶発振器はできるだけMCUに近いところに配置する、バイパスコンデンサは電源ピンの近くにする、USBは差動ペア配線なのでパターンを可能な限り平行に行なう、などに注意をして配線します。

f:id:takai_naoto:20200910181942p:plain

その他、ちょっとしたノウハウとなりますが、キー数が多いときにはスイッチ毎にダイオードを配置するのが面倒なときがあります。KiCad PcbnewはPythonでのスクリプティングが可能ですから、次のようなスクリプトをつくって配置しています。定数を変更してつかってみてください。

import pcbnew

START    = 1
STOP     = 42

X_OFFSET = 5937250
Y_OFFSET = 4857750
ORIENTATION = 90 * 10

board = pcbnew.GetBoard()

for i inxrange(START, STOP + 1):
    mx = board.FindModuleByReference("MX%d" % i)
    mx_pos = mx.GetPosition()

    new_pos = pcbnew.wxPoint(mx_pos.x + X_OFFSET, mx_pos. y + Y_OFFSET)

    diode = board.FindModuleByReference("D%d" % i)
    diode.SetPosition(new_pos)
    diode.SetOrientation(ORIENTATION)

    print("D%d moves to (%d, %d)" %(i, new_pos.x, new_pos.y))

pcbnew.Refresh()

キーボードケース設計

設計

次にサンドイッチマウントのケースをつくっていきます。サンドイッチマウントとは、PCBをスイッチプレートとボトムケースで挟み込むような構造です。キーボードのマウント方式については「Cheat sheet: Custom keyboard mounting styles」がよくまとまっています。

今回は、サンドイッチマウント構造でもPCBを2枚のプレートによって挟み込む、よりシンプルな構造です。当初、プレートにはアクリルを採用しようと考えていたのですが、ご時世によりアクリルが品薄になっていたため、プリント基板(FR4)で製作することにしました。

ケースの設計は Autodesk Fusion 360 をつかいます。 Fusion 360 の基本的な操作については「Fusion 360 で簡単なケースを作る(初心者向け) - Self-Made Keyboards in Japan」などを参照してください。また、公式サイトの 入門セミナーも参考になるでしょう。

まず、スイッチプレートのDXFファイルを読み込み、プレートを支えるための M2 スペーサーのスペースをつくります。廣杉計器 M2 スペーサーの直径は 4mm ですので、キースイッチと干渉しないようにします。

f:id:takai_naoto:20200910182015p:plain

それから、「押し出し」て、 M2 ねじを通すために 2.2mm くらいで「穴」を作成し、「フィレット」で角を丸めます。できた面にスケッチを作成して、それを DXF としてエクスポートすればスイッチプレートの完成です。ボトムプレートも同じような構造ですので、コピーをして履歴を編集すれば簡単に作成することができます。

f:id:takai_naoto:20200910182038p:plain

検証(アセンブリ)

PCB とケース用のプレートの設計が完成したので、実際に組み立てて問題が起こらないか確認してみましょう。KiCad では基板の3Dデータを STEP ファイルを出力することができます。これを Fusion 360 に取り込み、先程つくったプレートファイルと組み合わせます。

スイッチプレートとPCBの間隔は 3.4 mmです。これは、Cheryr MX シリーズのデーターシートを確認するとボトムハウジングの高さは 5 mm ですので、ここからプレートの厚みである 1.6 mmを引いた数字です。ボトムプレートとPCBの間隔は、PCBに実装されている USB-C コネクターの高さが 3.21 mmですから、それ以上あればよさそうです。 9 mm のスペーサーを利用することを考えて 4mm にします。

完成したら、部品の干渉など問題が発生していないことを確認します。

f:id:takai_naoto:20200910182057p:plain

製造

キーボード基板

基板の製造と電子部品の実装は Elecrow に発注します。 Elecrow は深圳にある工場で、品質と価格のバランスが良いことから、製品の製造のときにはいつも利用しています。発注にあたっては、 KiCad でガーバーフォーマットのデータを作成します。オプションを次の画像のように選択します。さらに、ドリルファイルも作成します。

f:id:takai_naoto:20200910182121p:plainf:id:takai_naoto:20200910182144p:plain

さらに、 Elecrow では生成されたファイルの拡張子を変更する必要があります。この作業は面倒で間違いやすい作業ですので、私はちょっとしたスクリプトを利用して拡張子の変更を行なっています。拡張子を変更したら、ガーバーデータをZip形式でアーカイブします。

#!/bin/bashPROJECT=$1OUTPUT=elecrow

mkdir"$OUTPUT"

cp "$PROJECT"-F_Cu.gtl      "$OUTPUT"/"$PROJECT".GTL
cp "$PROJECT"-B_Cu.gbl      "$OUTPUT"/"$PROJECT".GBL
cp "$PROJECT"-F_SilkS.gto   "$OUTPUT"/"$PROJECT".GTO
cp "$PROJECT"-B_SilkS.gbo   "$OUTPUT"/"$PROJECT".GBO
cp "$PROJECT"-F_Mask.gts    "$OUTPUT"/"$PROJECT".GTS
cp "$PROJECT"-B_Mask.gbs    "$OUTPUT"/"$PROJECT".GBS
cp "$PROJECT"-Edge_Cuts.gm1 "$OUTPUT"/"$PROJECT".GML
cp "$PROJECT"-PTH.drl       "$OUTPUT"/"$PROJECT"-PTH.TXT
cp "$PROJECT"-NPTH.drl      "$OUTPUT"/"$PROJECT"-NPTH.TXT

次に Excel で発注書をつくります。 Elecrow でも 発注テンプレートを用意していますし、実際に発注するときに利用したファイルもリポジトリに入れておきますので、参考にしてください。

Zip形式でアーカイブしたガーバーファイル、Excelの発注書を添付して service@elecrow.com へメールで発注をします。このとき、配送方法についても指定をするとよいでしょう。英語については自信がないのですが、下記のような文面でいつも送っています。

To whom it may concern,

I'd like to order PCBA service. Could you give me a quotation for attached files? The shipping address is as follows. I would prefer OCS as a shipping method.

XXXXX XX-XX-XX, XXXX, Tokyo XXX-XXX, Japan

Best Regards,

ほどなく担当者から見積が来るので、内容に問題がなければ PayPal で支払いをします。製造には、だいたい1ヶ月くらいかかるようです。製造のプロセスで何か問題が見付かれば、それも連絡してくれます。私は、ガーバーファイルでのフットプリントと部品のパッケージが一致しないミスをよくします。

キーボードケース

今回は FR4 、つまり PCB でプレートをつくるので、 KiCad でデータを作成します。 DXF ファイルをインポートするときにグラフィックレイヤーで Edge.Cuts 指定すれば、外形情報として取り込むことができますので、そのデータをもとにガーバーフォーマットのデータを書き出します。

発注は Elecrowのフォームから行ないます。プレートの厚さはデータシート上で 1.5mm なので、PCBではその厚さに近い 1.6mm を選択します。こちらは1週間ほどで届きます。

アクリルプレートを発注するときは、レーザー加工サービスによって指定のテンプレートがありますので、そのフォーマットに従って発注をします。個人的な利用であれば工房Emerge+のレーザー加工サービスを利用することが多いです。Illustrator形式ファイルのテンプレートにDXFファイルを取り込み、データを作成します。アクリルは 2mm のものを選びます。フォームから見積を依頼したうえで支払いをすると、こちらも1週間ほどで届きいます。

ファームウェア開発

ハードウェアができたらファームウェアをつくっていきましょう。今回は最低限キーボードとして動作するための実装を目指し、 QMK Firmeware や VIA のリポジトリに取り込んでもらうことを考えないようにします。

QMK Firmware

ファームウェアはフリーソフトウェアとして開発されている QMK Firmwareを利用します。QMK Firmware の開発は、事前にセットアップが必要です。公式ドキュメントの「Setting Up Your QMK Environment」を読み、セットアップをしてください。

QMK Firmware のトップディレクトリで util/new_keyboard.shを実行すると、 keyboards ディレクトリ以下にテンプレートができますので、そのファイルを編集していきます。Cookpad Pad 2 の差分を例にとりながら、編集する部分を見ていきましょう。

config.h:23-28ではUSBのベンダーID、プロダクトIDを定数として定義します。USBのベンダーID、プロダクトIDについては衝突しないことが求められていますが、テスト目的のIDなどは定義されていません。なので、「いい感じにする」必要があります。詳しくは「 USBのベンダーIDとプロダクトIDの話」などを参照してください。

#define VENDOR_ID 0xFEED#define PRODUCT_ID 0x9009#define DEVICE_VER 0x0002#define MANUFACTURER Cookpad Inc.#define PRODUCT Cookpad Pad#define DESCRIPTION A six keys macro pad made by Cookpad.

config.h:31-45では、キーマトリクスの定義を行ないます。Cookapd Pad は 2行 × 3列 のマトリクスですので、そのように定義をします。さらに回路図を確認し、ピンとの対応も定義します。

#define MATRIX_ROWS 2#define MATRIX_COLS 3

...

#define MATRIX_ROW_PINS { C6, C7 }#define MATRIX_COL_PINS { B7, B6, B5 }

Cookpad Pad 2 は MCU として ATmega32u2 を利用していますので rules.mk:2の部分も変更します。 ATmega32u4 であれば変更は必要ありません。

MCU = atmega32u2

cookpad_pad.h:29-36はキーボードの物理配列の定義です。キーボードによっては、行と列の二次元配列として定義されているキーマトリクスの全てが入力可能というわけではありません。そこで、キーボードの物理配列とキーマトリクスを対応させるためのマクロを定義します。

#define LAYOUT( \    K00, K01, K02, \    K10, K11, K12  \) \{ \    { K00, K01, K02 }, \    { K10, K11, K12 }  \}

keymaps/default/keymap.c:18-23で、キーボードの論理配列のデフォルト値を定義します。キーコードについては「Full List」を参照してください。

constuint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
    [0] = LAYOUT(
        KC_C, KC_O, KC_K,
        KC_P, KC_A, KC_D
    )
};

変更が完了したらビルドが通るかどうかを確認してください。ファームウェアを書き込むには工場出荷時に書き込まれているブートローダのDFU機能をつかって、USB経由で書き込みをします。

$ make cookpad_pad:default
$ make cookpad_pad:default:dfu

VIA

VIAは QMK を前提につくられたキー設定ツールです。それまで、 QMK Firmware でキーレイアウトを変更するためには、プログラムを修正してコンパイルする必要がありました。 VIA を使うと、 GUI でコンパイルなしにキーマップを変更することができます。

f:id:takai_naoto:20200910182237p:plain

キーボードを VIA に対応させるためには QMK Firmware で VIA に対応したキーマップを作成する必要あります。対応する方法については公式サイトの「Configuring QMK」や「(設計者向け)VIA対応のファームウェアを作ろう」などを参照してください。

Cookpad Pad 2 でいうとkeymaps/via/rules.mkが VIA 対応となります。主だったものは VIA_ENABLEだけです。その他は、 VIA では対応していない機能を無効にしたり、ファームウェアのサイズを減らすためのオプションとなります。

VIA_ENABLE = yes
LINK_TIME_OPTIMIZATION_ENABLE = yes
EXTRAKEY_ENABLE = no
MOUSEKEY_ENABLE = no
CONSOLE_ENABLE = no
MIDI_ENABLE = no

それから、 VIA でつかう cookpad_pad.jsonも用意します。便宜上、 QMK のリポジトリに含めていますが、このファイルを含んだままプルリクエストを送ると、余分なファイルがあるとレビューで指摘されるので注意してください。

{"name": "Cookpa Pad",
    "vendorId": "0xFEED",
    "productId": "0x9009",
    "lighting": "none",
    "matrix": {"rows": 2, "cols": 3 },
    "layouts": {"keymap": [["0,0","0,1","0,2"],
            ["1,0","1,1","1,2"]]}}

まとめ

本記事では Cookpad Pad 2 を事例として、キーボードの基板設計からケース設計、製造、ファームウェア開発について簡単に解説をしてきました。実際のところ、まだまだ細かな解説をする部分はありますが、大まかな流れについては理解いただけたのではないでしょうか。

Cookpad Pad 2 は オープンハードウェアとして公開していますので、このプロジェクトを改変して新しいキーボードをつくってみることにチャレンジしてみてください。

Cookpad Online Summer Internship 2020 5 Day Engineer コースを開催しました

$
0
0

ユーザー・決済基盤部の三吉です。今年になってエンジニアの立場から新卒採用を担当しています。

5月の記事で告知したサマーインターンシップのうち、5 Day Engineer コースを 8/24〜28、9/7〜11 の2日程で開催しました。この記事ではその内容を紹介します。

3 Day Product Design コースについては、以下の記事をご覧ください。

5 Day Engineer コースは、前半2日間が講義形式、後半3日間が実践形式でした。 前半はプラットフォーム別の技術講義とサービス開発講義の2本立てです。 後半は PBL (Project-Based Learning) として、サービス開発の実践に取り組みます。

今年はオンラインでの実施となりましたが、例年同様たくさんの学生に参加いただきました。 以下、各パートの詳細です。

技術講義

初日のオリエンが終わった直後から2日目の午前までが技術講義でした。 申込時に選択した Web、Android、iOS のプラットフォーム別にクックパッドの開発手法について学びます。 例年はサーバーサイドの経験しかない学生にもモバイルの講義を受けてもらっていましたが、今年は募集時からプラットフォーム別だったので、例年より一歩踏み込んだ内容になっています。

Web

クックパッドで実際に行われている開発フローをベースに、AWS 上でのデプロイパイプラインの構築や Ruby on Rails アプリケーションの実装・改善を体験してもらいました。

Android

レシピの閲覧・投稿ができるアプリ MiniCookpad の開発を通して、Android 版クックパッドアプリで採用されているアーキテクチャやテスト手法を体験してもらいました。

iOS

Android と同じく、MiniCookpad の開発を通して iOS 版クックパッドアプリで採用されているアーキテクチャやテスト手法を体験してもらいました。内容の大筋は Android・iOS 間で事前にすり合わせています。

講義資料は リポジトリの Documents 以下にあります。

サービス開発講義

2日目午後はサービス開発講義です。 半日という短い時間でしたが、クックパッドのサービス開発手法について講義したのち、あるユーザーの課題を解決するアプリケーションの考案やプロトタイプの作成まで行いました。 技術講義とは打って変わってコードを一行も書かない講義でしたが、参加者から「クックパッドのサービス開発手法を知ることができた」と好評でした。

PBL

後半、3日目からはサービス開発を実践する PBL です。 「一人暮らしをしている人の料理が楽しみになるアプリケーション」というテーマをもとに各自でアプリケーションを開発し、最終日に成果発表します。 PBL 中は社員がメンターとしてつき、サービスの相談に乗ったり開発をサポートしたりしました。

オンライン開催にあたって

スプリングインターンシップもオンラインでの開催でしたが、複数日にまたがって実施するのはこのサマーインターンシップが初めてでした。 ここでは、オンライン開催にあたっての取り組みを紹介します。

まず、オンライン開催にあたり、インターンシップ全体を通して利用したツールは次のとおりです。

  • Zoom
    • インターンシップ開催中は常に Zoom ミーティングに参加してもらうようにしました。チームに分かれる場合や、PBL の作業時間、懇親会等ではブレイクアウトルームを活用し、こちらからの一方通行のコミュニケーションにならないよう意識しました。
  • Slack
    • Zoom のチャット機能は使わず、文字ベースのコミュニケーションは Slack に統一しました。講義中など声を出しにくい状況でも、随時 Slack で質問を受け付けました。
  • Kibela*1
    • 連絡事項の共有や日報の提出に利用しました。Slack がフロー情報を扱うのに対し、Kibela ではストック情報を扱います。

オンライン開催による難点として、学生がオフィスの様子を知ることができないという点があります(当然ですが)。 これについて、オンラインでのオフィスツアーを実施しました。 社員がオフィスからスマートフォンで Zoom ミーティングに参加し、都度解説しながらオフィスを一周するというものです。

一方で、オンライン開催ならではの出来事もありました。 PBL 期間中、ある学生は開発中のアプリを友だちに使ってもらってユーザーインタビューしていました。 また、スプリングインターンに続き、海外から参加する学生もいました。 これらはオンラインで物理的制約がないからこそ可能なことです。

おまけ: ノベルティ

f:id:sankichi92:20200915115625j:plain
Tシャツ、エコバッグ、ステッカー、クックパッドロゴウォーター
f:id:sankichi92:20200915115728j:plain
自作キーボードキット Cookpad Pad 2(組み立て済み、裏側)

写真のノベルティセット(Tシャツ、エコバッグ、ステッカー、自作キーボードキット)を休憩時用の軽食と合わせて事前に送付しました。 また、この他にサマーインターンシップのロゴが入った Zoom のバーチャル背景用の画像も配布しています。

自作キーボードキット Cookpad Pad 2 について詳しくは以下の記事をご覧ください。


以上が、Cookpad Online Summer Internship 2020、5 Day Engineer コースの開催報告です。 ご参加いただいた皆さま、本当にありがとうございました!

今年のサマーインターンシップは終わってしまいましたが、クックパッドでは就業型インターンシップを通年で募集しています。 興味のある方はぜひご応募ください!

*1:これまでのサマーインターンシップでは Groupad という内製の情報共有ツールを利用していたのですが、例年と違って学生各自の PC から参加してもらう形をとった都合上使用できませんでした。

iOSDC Japan 2020 に社員2名が登壇します

$
0
0

こんにちは!とくなり餃子大好き( id:tokunarigyozadaisuki)です。
すっかり秋の気配がしてきましたね。餃子を焼きやすい気候になって嬉しい!

さて、iOSと周辺技術を題材としたカンファレンス、iOSDC Japan 2020が今年は9月19日(土)〜9月21日(月・祝)にオンラインにて開催されますね!

トークのご紹介

クックパッドは、ゴールドスポンサーをさせていただいております。 9月21日(月・祝)12:30〜 Track A では、スポンサーセッションとして、@yujif_@n_atmarkが登壇します。

これまで公開したことのない、新しい取り組みに関するトークです。お楽しみに!

「クックパッドが、革新的な方法でまったく新しい買い物体験を皆様にお届けします」

クックパッドは「毎日の料理を楽しみにする」をミッションに、約20年前から料理のアイデアや工夫が流通する仕組みづくりに取り組んできました。現在も日本から世界中に広がる「プラットフォームをつくる」挑戦を続けています。

一方で、共働きの増加や核家族化、外食やデリバリーの普及など、毎日の料理を囲む環境は目まぐるしく変わってきています。

クックパッドはこれからの100年に向けて「レシピ」のその先にも取り組んでいます。 次の100年のスタンダードになる「食」の流通をつくるため、2018年に生鮮食品ECプラットフォーム「クックパッドマート」を開始しました。

「クックパッドマート」では今までにない仕組みで、毎日の献立づくり、買い物、調理すべてを変えることに挑んでおり、「新鮮で美味しい食材」を当たり前に安く買えるように、ゼロから食品流通プラットフォームの構築を進めています。

本セッションでは、クックパッドの新しい挑戦である「クックパッドマート」における開発や、既存の「レシピ」を組み合わせたまったく新しい買い物体験の実現、それらを支えるiOSアプリのアーキテクチャや SwiftUI の活用についてお話しします。

おわりに

カンファレンスには、他にも多くの社員が参加する予定です。スポンサーセッションなどに関してご質問やご感想等ございましたら、お気軽にお声がけください! 

クックパッドでは、iOSのサービス開発に一緒に取り組んでくれる仲間を募集しています。トークを見て少しでも興味を持っていただいた方にはこちらをご参照いただけましたら幸いです。

募集要項:iOSエンジニア

info.cookpad.com

クエリログを使ったAurora MySQLの負荷テスト

$
0
0

最近はZX-25Rが気になっている菅原です。4気筒250ccといえば、以前バリオス2に乗っていたんですが、あれもよく回るよいバイクでした。足つきの良さが懐かしいです。

この記事では、クエリログを使ったAurora MySQLの負荷テストの話を書きます。

MySQLの負荷テスト

サービスに使われているデータベースは、Webサーバと比べて自動的なスケールアップ・スケールアウトが簡単ではないためキャパシティプランニングは非常に重要です。サービスへのアクセス増による負荷増大の結果、急激に性能が低下するためなるべく事前にキャパシティを把握しておきたいところです。

クックパッドではサービスのデータベースとして主にAurora MySQLを利用しているのですが、キャパシティを把握するための負荷テストには以前から苦労してきました。

1. シナリオを書くのが大変

サービスで使われているデータベースの負荷テストのシナリオを人間が書こうとすると、あるシナリオでは極端に性能がよくなり、またあるシナリオでは極端に性能が悪くなるということがあり、実際のサービスを模してシナリオを書くのが非常に困難で、意味のある負荷テストをすることが難しいことが多くありました。

2. 大きなサイズのシナリオを実行する負荷テストツールがない

データベースの負荷テストツールは大きく2種類あり、一つはTCP-xのような決められたモデルを実行するツールで、もう一つは任意のシナリオを実行するツールです。TCP-xを実行するツールはクックパッドのサービスの性質とは異なるため、任意のシナリオを実行するツールを用いて負荷テストを行っていたのですが、MySQLで任意のクエリやシナリオを実行するツールはあまり多くなく、私の知る限りは以下の通りでした。

クエリログを使ったMySQLの負荷テスト

今までは上記のツールを使って負荷テストを行っていました(特にJdbcRunnerはとても便利でした!)。

しかし、既存のツールにはいくつか不満がありました。

1. 基本的にシナリオを人間が書く必要がある&大きなシナリオを読み込むのにメモリが必要

mysqlslapを除き任意のシナリオを実行することができるのですが、当然、人間がDSLやJavaScriptでシナリオを書く必要があり、前述の通り、実際のサービスに沿ったシナリオを書くことが困難でした。また、シナリオはメモリに読み込まれるため、大量の異なるデータをテストデータにしようとすると、クライアント側にデータを読み込むための大量のメモリが必要になりました。

2. 結果のデータの解析が難しい

負荷テストの実行結果について、平均応答時間だけではなく、中央値やヒストグラムなどが欲しいのですが、あまり多くの情報は出力されなく、出力されるデータは単純なテキストであったため、データをパースして結果を集計するにもやや手間がかかりました。

Webサーバの場合、アクセスログをテストデータとして負荷テストを行うことが多いと思います。MySQLに関しても同じようなことができないかと以前から考えていたのですが

  1. データが大きく(GB単位)、メモリに乗せるのが難しい
  2. データの羅列をDSLやJavaScriptに変換するのに手間がかかる

という問題がありました。

それらの問題を解決するためqrnという負荷テストツールを新たに作成しました。

qrn

データベース負荷テストツールqrnの大きな特徴は次の通りです。

  1. JSON Linesをテストデータとする
  2. テストデータは逐次ディスクから読み取り、全体をメモリにはロードしない
  3. テスト結果をJSONで細かく出力する

たとえば、以下のデータを

{"query":"select 1"}{"query":"select 2"}{"query":"select 3"}

4並列・5 qps/userで10秒間実行する場合、コマンドと出力結果は次のようになります。

$ qrn -data data.jsonl -dsn root:@/ -nagents4-rate5-time10
00:07 | 4 agents / run 184 queries (20 qps){"DSN": "root:@/",
  "Files": ["data.jsonl"],
  "Started": "2020-05-13T11:18:14.224848+09:00",
  "Finished": "2020-05-13T11:18:24.559912+09:00",
  "Elapsed": 10,
  "Queries": 189,
  "NAgents": 4,
  "Rate": 5,
  "QPS": 18.287694303306097,
  "MaxQPS": 21,
  "MinQPS": 18,
  "MedianQPS": 19,
  "ExpectedQPS": 20,
  "LoopCount": 15894,
  "Response": {"Time": {"Cumulative": "78.389862ms",
      "HMean": "392.47µs",
      "Avg": "414.761µs",
      "P50": "418.565µs",
      "P75": "462.099µs",
      "P95": "532.099µs",
      "P99": "735.68µs",
      "P999": "760.585µs",
      "Long5p": "632.823µs",
      "Short5p": "218.38µs",
      "Max": "760.585µs",
      "Min": "182.384µs",
      "Range": "578.201µs",
      "StdDev": "90.961µs"},
    "Rate": {"Second": 2411.0260584461803},
    "Samples": 189,
    "Count": 189,
    "Histogram": [{"57µs - 115µs": 1},
      {"115µs - 173µs": 1},
      {"173µs - 231µs": 4},
      {"231µs - 289µs": 14},
      {"289µs - 346µs": 12},
      {"346µs - 404µs": 48},
      {"404µs - 462µs": 63},
      {"462µs - 520µs": 34},
      {"520µs - 760µs": 12}]},
  "Token": "a579889e-97f9-4fd1-8b33-93ab2c78e6ad"}

クエリログのテストデータ化

MySQLのクエリログ(general log)をqrnのテストデータとして使用する場合、general logをJSON Linesに変換する必要があります。そのためのツールgenlogも新たに作成しました。

以下のようなgeneral logをJSON Linesに変換できます。

2020-05-27T05:03:27.500301Z   11 Query   SET @@sql_log_bin=off
2020-05-27T05:03:27.543379Z   11 Query  select @@session.tx_read_only
2020-05-27T05:03:27.683485Z   11 Query  COMMIT
$ genlog general.log # or `cat general.log | genlog`{"Time":"2020-05-27T05:03:27.500301Z","Id":"11","Command":"Query","Argument":"SET @@sql_log_bin=off"}{"Time":"2020-05-27T05:03:27.543379Z","Id":"11","Command":"Query","Argument":"select @@session.tx_read_only"}{"Time":"2020-05-27T05:03:27.683485Z","Id":"11","Command":"Query","Argument":"COMMIT"}

また、クエリログからSELECT文のみを抽出したい場合は、jqを使ってフィルタリングします。

$ jq -rc'select(.Command == "Query") | select(.Argument | test("select" ; "i"))' general.log.jsonl > mysql-general-select.jsonl

general logの出力については負荷増大の懸念から、今まで避けているところがあったのですが、実際に出力してみるとそれほどCPU使用率は上がらなかったため、現在では必要であれば出力するようにしています。

ただしAurora MySQLでgeneral logをCloudWatch Logsにエクスポートした場合は、それなりにCPU使用率が上がるため、Aurora MySQLのgeneral logを使用する場合にはエクスポートせずに、DBインスタンスの各ログをダウンロードするようにしました(ダウンロード用のツールも作成しました)。

テスト結果の集計

テスト結果は前述のようにJSONで出力しているのですが、どのクエリがどの程度応答時間を占めているのかを分析したい場合、負荷テストを実施しているテスト用データベースのlong_query_timeを0にして、すべてのクエリのスロークエリログを出力し、それをpt-query-digestで分析するということを行っています。

ウェイトを占めるクエリがわかるため、実際にサービスでスロークエリログが出力する前に重いクエリを改善するというようなことに役立ちます。

まとめ

データベースの負荷テストツールを自作することにより、クエリログをテストデータとした負荷テストが行えるようになり、Webサーバのような感覚で簡単にデータベースの負荷テストを行えるようになりました。これにより、事前のデータベースのキャパシティ把握が容易になったと考えています。

今はまだ手動で負荷テストを実施しているので、今後は負荷テストの自動化を目指したいところです。

負荷試験用 Web コンソールの開発

$
0
0

技術部 Site Reliability (SR) グループの id:itkqです。2020 秋タイトルで一番期待しているのはおちこぼれフルーツタルトです。本エントリでは、Web サービスの負荷試験に対する障壁を下げるために、汎用的な Web コンソール開発に至ったまでの話を書きます。

Web サービスの負荷試験の障壁を下げたい

クックパッドでは、マイクロサービスを支える基盤が成熟しており、新規サービス開発や、サービスリニューアルなどの機能開発の場面では、疎結合な新規のマイクロサービスとして実装されることが多いです。このようなサービスをリリースする際は、予想されるトラフィックに対して、実際にそれを捌ききれるかどうかテストする、いわゆる負荷試験をすることは一般的です。これまで、サービスリリース時に、負荷試験をきちんと行うこともあれば、負荷試験を行わないこともありました。負荷試験が行われない理由は、そのコストの大きさにあると私は考えました。本質的であるテストシナリオの用意だけでなく、負荷試験ツールの選定・負荷試験環境の構築・負荷試験ツールの動作環境の構築などの手間がかかります。

SR グループでは、負荷試験の障壁を下げ、開発チームが気軽に負荷試験を行えるようにすることで、自分たちが開発するサービスのボトルネックの認識やキャパシティプランニングを行えるようにすることを考えました。そのために、SR グループがメンテナンスする負荷試験用プラットフォームの提供を目指しました。

Serverless-artillery を利用したプロトタイピング

このプラットフォームで用いる負荷試験ツールは、サーバーレスであることが理想だと考えていました。負荷試験ツール側のリソースが不足してしまうことはしばしばあり、リソースの不足の心配を減らすためです。また、pay-as-you-go であることは、負荷試験のユースケースにマッチしており、コストを最適化できる見込みがあったからです。サーバーレスで動作する現代的な負荷試験ツールを自前で実装することも考えましたが、同じような思想で作られた Serverless-artilleryを見つけ、検証を行いました。このツールは、NodeJS 製の負荷試験ツール Artilleryと、サーバーレスアプリケーションをデプロイするためのフレームワーク serverless frameworkを組み合わせ、Artillery を AWS Lambda 上で実行するものです。Lambda function の実行時間やリソースの制限に合わせてテストシナリオを分割し、同時並列に Lambda function を実行し、目標負荷を実現する仕組みです。テストシナリオはドキュメントが充実している Artillery の記法 (YAML) で書くことができます。例えば、https://example.com/に対して 10 秒間、1 秒間に 1 仮想ユーザが GET リクエストする設定は以下のように記述します。

config:target:"https://example.com"phases:- duration:10arrivalRate:1scenarios:- flow:- get:url:"/"

Serverless-artillery を使うには、上記の YAML (script.yml) に加えて、次のような内容の serverless.yml を用意します。

service: serverless-artillery-minimum
provider:name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
functions:loadGenerator:handler: handler.handler
    timeout:300

次の操作で、負荷を発生させる Lambda function を含む CloudFormation stack がデプロイされます。

$ npm i -g slsart
(snip)

$ slsart deploy

        Deploying function...

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
........
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service serverless-artillery-minimum.zip file to S3 (17.91 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
...............
Serverless: Stack update finished...
Service Information
service: serverless-artillery-minimum
stage: dev
region: ap-northeast-1
stack: serverless-artillery-minimum-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  loadGenerator: serverless-artillery-minimum-dev-loadGenerator
layers:
  None
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

        Deploy complete.

負荷試験の実行は次のようになります。

$ slsart invoke --path script.yml

        Invoking test Lambda

{
    "timestamp": "2020-10-20T09:53:48.491Z",
    "scenariosCreated": 10,
    "scenariosCompleted": 10,
    "requestsCompleted": 10,
    "latency": {
        "min": 425.7,
        "max": 449.8,
        "median": 440.5,
        "p95": 449.8,
        "p99": 449.8
    },
    "rps": {
        "count": 10,
        "mean": 1.07
    },
    "scenarioDuration": {
        "min": 429.5,
        "max": 571.1,
        "median": 444.2,
        "p95": 571.1,
        "p99": 571.1
    },
    "scenarioCounts": {
        "0": 10
    },
    "errors": {},
    "codes": {
        "200": 10
    },
    "matches": 0,
    "customStats": {},
    "counters": {},
    "scenariosAvoided": 0,
    "phases": [
        {
            "duration": 10,
            "arrivalRate": 1
        }
    ]
}

        Your function invocation has completed.

(snip)

Serverless-artillery もドキュメントが充実しており、動作や設定について丁寧に説明されているため、詳しくは READMEを参照してください 。自分でいくつかテストシナリオを用意して想定通り実行できることを確認したり、長期間の ramp-up テストが期待通り動作することを確認しました。

次に、この Serverless-artillery を利用して、社内の環境で負荷試験をするための最低限の準備をしました。具体的には以下の項目です。

アクセストークンのハンドリング

社内の API サービスは OAuth2 をベースとした自前の認証認可サービスで認証認可を行うことが一般的です。これらの API サービスに対して負荷試験を行う場合、アクセストークンをリクエストヘッダにセットしたり、アクセストークンの有効期限が切れていた場合にリフレッシュする処理が必要になります。Artillery には、リクエストの前後やテストシナリオの前後でフックする仕組みがあり、これを使ってアクセストークンのハンドリングを実装しました。

リクエスト結果のメトリクスを扱うプラグインの改善

Artillery には、Pluginという仕組みがあり、Artillery 内部で扱うイベントに反応する処理を追加することができます。なかでも有用なのは、リクエスト結果をメトリクスとして外部に保存する monitoring plugin です。Serverless-artillery は AWS Lambda を前提としているため、手間が少ない monitoring plugin として artillery-plugin-cloudwatchをまず試しました。しかし他の plugin である artillery-plugin-influxdbなどに比べ欲しい機能が不足していたため、自分でいくつか機能を追加しました。 それについて upstream にコメントを求めましたが、長らく反応がなかったため、現在はフォーク版を使っています。

Jsonnet による設定の抽象化

Serverless-artillery には、先述の通り 2 つの設定ファイルが必要で、1 つは serverless フレームワークの設定である YAML ファイル、もう 1 つは Artillery のテストシナリオである YAML ファイルです。それぞれの YAML ファイルは、対象が異なる負荷試験であっても共通部分が多いため、Jsonnet で抽象化するようにしました。Jsonnet は、社内で設定を記述するのに広く使われているテンプレート言語です。使用例として、ECS へのデプロイを行うための Hako の定義ファイルがあります 。

以下に抽象化のイメージを示します。

recipes/serverless.jsonnet

local serverless = import '../lib/serverless.libsonnet';
local config = serverless.productionConfig('recipes');
std.manifestYamlDoc(config)

lib/serverless.libsonnet

local tags = {
  Project: 'serverlss-artillery',
};

{
  productionConfig(name):: {
    service: std.format('serverless-artillery-%s', name),
    provider: {
      name: 'aws',
      region: 'ap-northeast-1',
      runtime: 'nodejs12.x',
      stage: 'prod',
      role: 'arn:aws:iam::XXXXXXXXXXXX:role/LambdaServerlessArtillery',
      deploymentBucket: {
        name: 'dummy-bucket',
      },
      stackTags: tags,
      logRetentionInDays: 7,
    },
    functions: {
      loadGenerator: {
        handler: 'handler.handler',
        timeout: 300,
      },
    },
  },
}

recipes/script.jsonnet

local script = import '../lib/script.libsonnet';

local config = {
  config: script.productionBase('recipes') {
    phases: [
      {
        duration: 1800,  // 30 min
        arrivalRate: 1,
        rampTo: 500,
      },
    ],
    variables: {
      recipe_id: [
      1, 2, 3, 4, 5,
      ],
    },
  },
  scenarios: [
    {
      flow: [
        {
          get: {
            url: '/v1/recipes/{{ recipe_id }}', // ランダムに variables.recipe_id が選ばれる
            beforeRequest: 'ConfigureAccessToken', // アクセストークンの処理
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

lib/script.libsonnet

{
  productionBase(name): {
    target: 'https://cookpad-dummy-api.com',
    processor: './custom-functions.js', // ConfigureAccessToken が定義されたファイル
    defaults: {
      headers: {
        'user-agent': 'serverless-artillery',
      },
    },
    http: {
      timeout: 10,
    },
    plugins: {
      cloudwatch: self.cloudwatchPlugin(name),
    },
  },
  cloudwatchPlugin(name):: {
    region: 'ap-northeast-1',
    namespace: 'serverless-artillery',
    dimensions: {
      name: name,
      stage: 'prod',
    },
  },
}

レシピサービスリニューアルリリースにおける負荷試験

実は Serverless-artillery を検証していた段階で、レシピサービスのリニューアルリリース前に負荷試験を行いたい、と開発チームから声がかかっており、プロトタイピングしたものを実際に利用することにしました。

レシピサービスは社内で最も歴史のあるサービスで、内部のマイクロサービス化やリファクタリングは進んでいるものの、それ専用の仕組みがあったりと複雑な構成です。レシピサービスについて、専用の負荷試験環境を構築することは非常に難しく、また大きな労力がかかることは予想できたため、細心の注意を払いながら「本番環境」で負荷試験を行いました 1。テストシナリオは基本的に開発チーム側で準備してもらいつつ、レビューは SR グループでも行いました。負荷試験はテストシナリオを微修正しつつ何度か実行し、ミドルウェアのボトルネックなどいくつかの脆弱な箇所が洗い出されました。

今回のリニューアルでは、新たに 2 つのマイクロサービスが BFF の下に追加されました。その 2 つのサービスが関わるエンドポイントをリストアップし、各エンドポイントの予想アクセスパターンとアクセス量を考慮しながら、開発チームを中心にテストシナリオを作ってもらいました。実際に使われたテストシナリオの 1 つは次のようになっていました (URL など一部加工しています)。

local constants = import '../lib/constants.libsonnet';
local script = import '../lib/script.libsonnet';

local name = 'renewal-0221';
local config = {
  config: script.productionBase(name) {
    phases: [
      {
        duration: 3600,  // 1 hour
        arrivalRate: 1,
        rampTo: 655,
        name: 'Warm up the application',
      },
      {
        duration: 900,  // 15 min
        arrivalRate: 655,
        name: 'Sustained max load',
      },
    ],
    payload: [
      {
        path: './data/payload.csv',
        fields: [
          'kiroku_image',
        ],
      },
    ],
    variables: {
      recipe_id: constants.recipe_ids,
      clipped_at: [
        '2020-02-18T12:33:22+09:00',
      ],
      time_zone: [
        'Asia/Tokyo',
      ],
      keyword: constants.keywords,
      order: [
        'date',
      ],
      tsukurepo_count: [
        10,
      ],
    },
  },
  scenarios: [
    {
      name: 'deau/sagasu',
      weight: 200,
      flow: [
        {
          get: {
            url: '/dummy/app_home/deau_contents',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
        {
          post: {
            url: '/dummy/app_home/sagasu_search_result',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              keyword: '{{ keyword }}',
              order: '{{ order }}',
            },
          },
        },
      ],
    },
    {
      name: 'clip',
      weight: 450,
      flow: [
        {
          post: {
            url: '/dummy/clip',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              clipped_at: '{{ clipped_at }}',
              time_zone: '{{ time_zone }}',
            },
          },
        },
        {
          get: {
            url: '/dummy/{{ resourceOwnerId }}/bookmarks?recipe_ids={{ recipe_id }}', // resourceOwnerId は ConfigureAccessToken により挿入される
            beforeRequest: 'ConfigureAccessToken',
            capture: [
              {
                json: '$[0].id',
                as: 'bookmark_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/{{ bookmark_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
    {
      name: 'kiroku',
      weight: 5,
      flow: [
        {
          post: {
            url: '/dummy/kirokus',
            beforeRequest: 'ConfigureAccessToken',
            json: {
              recipe_id: '{{ recipe_id }}',
              items: [
                {
                  item_type: 'PHOTO',
                  data: '{{ kiroku_image }}',
                },
              ],
            },
            capture: [
              {
                json: '$.id',
                as: 'kiroku_id',
              },
              {
                json: '$.items[0].id',
                as: 'kiroku_item_id',
              },
            ],
          },
        },
        {
          delete: {
            url: '/dummy/kirokus/{{ kiroku_id }}/items/{{ kiroku_item_id }}',
            beforeRequest: 'ConfigureAccessToken',
          },
        },
      ],
    },
  ],
};
std.manifestYamlDoc(config)

まずシナリオのフェーズは、最大 RPS を 655 として、1 時間かけて ramp-up した後にそれを 15 分維持するように設定されています。これは、ピークタイムに向けてアクセスが伸びる現実を反映し、増加する負荷をシステムがオートスケールで対処できることを試験するためです。シナリオは機能ごとに 3 つを用意しました。それぞれには weight で重み付けをして、予想アクセスパターンを反映しています。recipe_id などのテストデータは別途事前に準備しておきました。

負荷試験中は、関連するメトリクスのダッシュボードを注視していました。想定外の事態が起き、負荷をすぐに中断することが何度か起きました。しかし、サーキットブレイカーの発動や、デグラデーションの考慮がなされていたことにより、実ユーザに大きな影響を与えることはありませんでした。負荷試験により発見された脆弱な点と、その対応の例を以下に挙げます。

  • サービス X の ECS サービスのスケーリングポリシーの最大タスク数に当たってしまい、リソースが不足しレイテンシが増加した。最大タスク数を引き上げた
  • サービス Y のバックエンド Elasticsearch が CPU リソース不足になりレイテンシが増加した。Elasticsearch の Data ノードのスケールアウト、N+1 クエリの解消、追加でレスポンスのキャッシュを実装が行われた
  • サービス Z のバックエンド MySQL が CPU リソース不足になりレイテンシが増加した。Z 内でのキャッシュの実装の見直しが行われ、さらに MySQL 接続ユーザやコネクション周りの設定不備も見つかった

最初のうちは、開発チームと SR グループで一緒に負荷の見守りを行っていましたが、終盤はほとんど開発チームだけで負荷試験の実行や中断ができるようになっていました。結果的に想定のテストシナリオをすべてクリアし、キャパシティに自信を持って 100% リリースすることができ、キャパシティ起因の問題は発生しませんでした。本番環境での負荷試験は、それ自体が大きなテーマであるため、このエントリではあえて詳細を書いていません。近いうちに別のエントリとして公開したいと考えています。

Web コンソールの開発

レシピサービスのリニューアルリリースにおける負荷試験を通して、Serverless-artillery を利用したプロトタイプが実用に耐えそうなことが分かりました。このプロトタイプでは以下の問題点があることが分かっていました。

  • 負荷試験の操作が CLI で、かつ強い IAM 権限を持つ人しか実行できない
  • 負荷試験が今行われているのか、いないのかがすぐに分からない

これをより利用しやすくするため、Web コンソールを開発しました。普通の Rails アプリケーションとして実装し、F5 と名付けました。コスト最適化のため、データベースは Aurora Serverless (MySQL) を利用しています。例として、10 分間で 50 RPS まで ramp-up し、50 RPS を 3 分間継続するというデバッグ用の負荷試験を実行したときの様子が以下になります。

f:id:itkq:20201021212900p:plain
負荷試験中の F5 のスクリーンショット

f:id:itkq:20201021211348p:plain
itkq/artillery-plugin-cloudwatch により収集したメトリクスの Grafana ダッシュボード

f:id:itkq:20201021211408p:plain
負荷試験対象側の Grafana ダッシュボード

また、開発にあたり工夫した点は次の通りです。

開発チームへの移譲

この取り組みの当初の課題意識を解決するため、開発チームが自分たちで負荷試験を操作できる仕組みを入れることにしました。G Suite の OAuth2 を利用した認証認可を実装し、特定のサービス (エンドポイント) のオーナー権をユーザに与え、オーナーのユーザはそのサービスに対して負荷試験の実行や中止を行えるようにしました。また、すべての操作はログとして残すようにして、後から追跡可能にしました。

テストシナリオを GitHub で管理して同期する

テストシナリオは負荷試験において本質的で、ピアレビューしたい場面があります。標準的なレビューフローをとれるようにするため、テストシナリオは Jsonnet として GitHub で管理し、それを同期するようにしました。このように設定ファイルを GitHub で管理するのは社内でよくある手法のため、受け入れられやすいと考えました。

まとめ

社内での Web サービスの負荷試験について、現状と改善の余地を述べ、Serverless-artillery を使った負荷試験の検証、より利用しやすくするための Web コンソールの開発に至るまでを説明しました。開発した Web コンソールは、実際に数回負荷試験に利用されています。テストシナリオのレビューで SR グループが最初関わることもありますが、その後は開発チームがほとんど自分たちで負荷試験のサイクルを回せているという所感です。これ以外にも、負荷試験に利用できる周辺の仕組み2が整ってきており、負荷試験、さらにはキャパシティプランニングが開発において当たり前となっていくような開発体制になることを目指していきたいです。


  1. 先日のイベントの発表資料も参考になります https://speakerdeck.com/rrreeeyyy/cookpad-tech-kitchen-service-embedded-sres

  2. 例えば Aurora のクローンシステム https://techlife.cookpad.com/entry/2020/08/20/090000


日々の簡単なプロトタイピングに Flutter を活用する

$
0
0

こんにちは、 CTO 室の山田です。 私は新卒入社から現在までずっと Amazon Alexa や LINE Clova などのいわゆるスマートスピーカーやスマートディスプレイ向けのアプリケーション開発に携わっています。

特に Amazon Alexa に関しては、日本だけでなく、スペイン、メキシコ、アメリカ、ブラジルの計 5 カ国にてサービスを展開しています。

現在は上記のプラットフォームへアプリケーションを公開する形でサービスを提供していますが、私たちが掲げている目標は「Voice User Interface の特性を活かし毎日の料理をもっと楽しみにする」ことであり、必ずしも特定のデバイスやプラットフォームに特化をして開発をするわけではありません。

例えば iOS/Android などのモバイルデバイス上での方が今より良いサービスを提供できるかもしれませんし、私たちでハードウェアを開発した方が良いこともあるかもしれません。 今のスマートスピーカーはいわゆるウェイクワードで呼び出してから指示を出しますが、実は料理のシーンではもっと別の良い方法があるかもしれません。

こういった可能性を模索する上で、 Voice User Interface というまだまだ技術的に発展途上な領域に関しては、そもそも実用できるレベルで動くのか。という技術的な検証が非常に重要となります。

しかしながら、さまざまな領域の技術的検証をする場合、当たり前ですがそれぞれについて小さくない学習コストがかかります。

これを理由にその可能性を掘ることは諦めたくないですが、出来る限り検証したい部分以外は最低限のコストで済ませたいです。

Flutter を用いたプロトタイプの開発

このような背景から、 低い学習コストで iOS/Android 純正ライブラリの性能を検証する用途で Flutter を活用することができるのではないかと実際にプロトタイプを作成し検証を行いましたので事例として紹介させていただきます。 今回はキッチンで料理をしている状況で iOS/Android 純正の音声認識ライブラリの認識精度がどの程度の性能かを検証するプロトタイプを作成しました。

まずはこちらをご覧ください。


目を見て話せるレシピアプリのプロトタイプ


目を見て話せるレシピアプリのプロトタイプ 2

人の目線に気付くと話を聞く姿勢になってくれて、手順を読み上げてくれるレシピアプリです。ゲームによくある感じの近づくと注意を向けてくれて会話ができる CPU みたいな感じのをイメージしてみました。

今回のプロトタイプでは、手順の読み上げや、以下の動画にある通り材料の分量の確認と、動画には無いのですが作った料理を写真に撮ってもらう機能を用意してみました。

リモートワークなので自宅での撮影になるのですが、リアルの日常的なプロトタイピングの風景だと思っていただければ幸いです。実際にこの機能を使って料理も作ったのですが、特に大きな問題もなく作り切ることが出来ました。 今回は技術的検証なので、例えば換気扇や水を流している状況下での認識精度も簡単にテストをしてみたのですが、問題なく認識していました。


目を見て話せるレシピアプリのプロトタイプ 3

今回のテストから以下のことがわかりました。

  • 意外とスマートフォンのマイクでもキッチンで十分に使える音声認識精度だった
    • 離れた位置から声で操作することを念頭に置いて開発されたデバイスではないため、あまりマイクの性能には期待をしていなかったが、料理中全体を通しても認識エラーは数えるほどだった。
    • 特に換気扇や水が流れている状態でも認識率に大きい影響がなかったことが印象的だった
    • 今回は認識できる発話のバリエーションが少なかったため、もっとさまざまな発話に対応させた時の認識精度についてもテストをしたい
  • 「目を合わせると話ができる」のコンセプトは微妙だった
    • やはり音声インターフェースの強みは「手」と「目」がいらないことなんだと改めて感じた。いちいちスマートフォンを覗き込みにいかないといけないとなると便利さ半減だなと料理している時に感じた。結局はスマートフォンが置いてある場所に動線が影響を受けるように感じた
    • 意外とスマートフォンが目を認識する精度は高く、逆に高すぎるせいで変に検知されないよう検知しづらい場所に置いて使っていたため、ちょっと不自然な動きをする羽目になった
    • 目を検知しづらい場所に置くのではなく、もう少し別の置き方とか、そもそも目を検知するルールを変更すればもう少し良くなる可能性を感じた。今は目をあわせなくとも両目の存在が検知されたら反応するようにしているが、それだと目が届くところに置きづらいのできちんと目を合わせないと反応しないようにすればもう少し使いやすくなるかもしれないと感じた
    • もしくは目を合わせる。ではなく、 Hand Pose Detection に置き換えても良いかも知れない
    • ウェイクワード無しで目を見ると話しかけられるのはそれなりに簡単だし楽だと感じた一方、ファミリーの環境だと誰に話しかけてるのか明示的でなくなってしまうのでちょっと微妙なのかも知れないなと感じた

これらの機能は、 Flutter のパッケージ越しに SFSpeechRecognizer や AVSpeechUtterance など iOS 純正の Framework のみで実現しています。また、同じパッケージで Android の純正 API のみで実現することも可能です。 さらに、まだ stable channel では提供されていないため今回は取り組みませんでしたが Web app や Linux app の開発もサポートを予定しているようなのでこれらのプラットフォームについてもコストをかけずに検証が可能になるかもしれません。

このように、さまざまなプラットフォームの技術的な検証を小さい学習コストで実現出来ました。

終わりに

今回は、モバイル向け OS 純正の音声認識ライブラリの性能を、出来る限り小さい学習コストで検証するために Flutter を活用しました。

Flutter を使い開発したアプリケーションはキッチンで料理をするのに十分使えるレベルのクオリティであったため、実際にそのアプリケーションを使いキッチンで料理をするプロトタイピングを行いました。

これにより、音声認識ライブラリの認識性能について、私たちが想定する利用シーンに特有の事柄に対しても検証をすることが出来ました。例えば今回は換気扇や水が流れているシーンでも問題なく認識できうることがわかったのは大きな収穫でした。

また、今回コンセプトとして据えた「目を見て話せる」というのはキッチンで料理をするシーンでは微妙で、もう少し洗練させるか、もっと別の良いトリガーを考える必要があると感じました。 これもモバイル向け OS の画像認識ライブラリの性能や仕様によるところがあるため、例えばペーパープロトタイピングなどでは実感するのが難しく、実際に簡単なアプリケーションを作って料理をしたおかげで得られた収穫でした。

このように、技術的な検証を含む日常的なプロトタイピングにおいて、 Flutter を活用することでモバイル向け OS のアプリケーションを簡単に開発し、プロトタイピングすることが出来ました。 今後も、より多くのプラットフォームで動くようになることを期待しつつ、さまざまなプロトタイピングのシーンで活用していこうと思います。

ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築

$
0
0

こんにちは。PlayStation 5が一向に買えない@giginetです。普段はモバイル基盤部というところでiOSの基盤開発をしています。

皆さん、行動していますか?我々は日々Webサービス上で様々な行動をしています。サービス開発において、改善に活かすための効率的な行動ログの収集方法はしばしば課題になります。

今回は、サービス開発者がモバイルアプリ上で簡単にログを定義し、分析を行えるログ基盤を導入した事例について紹介します。

行動ログとは何か

モバイルアプリの行動ログとは、ユーザーのアプリ上の操作や利用状況を取得、集積するためのものです。 例えば、特定の画面を表示したり、特定のボタンをタップしたり、といったユーザー操作を起点として送信されています。

集められたログは、サービス開発のための分析や実態把握に役立てられます。

最近はFirebase Analyticsなど、PaaSの形態で提供されるログ基盤も増えてきました。 一方で、クックパッドのようなサービス規模になると、流量やコスト、ニーズへの適合という面から独自のログ基盤を構築しています。

以下の記事では、クックパッドを支えるログ基盤の概要について説明しています。主にバックエンドの構成などに興味がある方はご覧ください。

一般的な行動ロガーの実装

この記事ではクライアントサイドのログ実装に着目していきましょう。 なお、今回紹介するログ基盤は、どのクライアント実装によっても利用できる仕組みですが、この記事では、Swiftで記述したiOSアプリでの利用を例に取っています。

一般的な行動ロガーの実装として、以下のようなものが思い浮かぶでしょう。

Analytics.logEvent("select_content", parameters:["content_type": "image","content_id": "P12453","items": [["name": "Kittens"]]
])

これはFirebase Analyticsのドキュメントで説明されているロギングの実装例です。

この方式ですと、クライアント側から任意のログを柔軟に送ることができます。しかし、スキーマレスであるこのようなログ実装の規模が大きくなっていったとき、すぐに収集がつかなくなってしまうのは想像に難くないでしょう。

これまでの問題点

クックパッドアプリの規模になると、日々膨大な量のログが送付され、リリースを重ねるごとにログの送信箇所が増えていきます。

また、多くの開発者が関わっているため、実装者のほか、後世でそのログを分析する人やディレクターも細かな仕様を把握する必要があります。実際に起こりうる問題について見て行きましょう。

行動ログへの注釈が難しい

現実のサービス開発における行動ログには様々なコンテキストが含まれています。

例えば、その行動ログがどのタイミングで送信されるのか、どのバージョンから実装されているのか、どのような定義域の値を送るのかなどでしょう。

クックパッドでは、これらのコンテキストに注釈を与えるため、dmemoという社内サービスを用いています。

dmemoを用いると、データウェアハウス(DWH)上の全てのテーブルやカラムにメタ情報を付与することができます。

f:id:gigi-net:20201102143355p:plain
データベースドキュメント管理ツールdmemo

しかし、現実には、柔軟すぎるログ実装には様々な問題があり、その度に各ログの持つコンテキストが膨大な物になっていきます。

バージョンごとの挙動差異の追跡が難しい

モバイルアプリのリリースごとの細かな挙動の変化を追うのが難しい問題もありました。

ログの実装は、バージョン毎に細かな挙動が修正されたり、特定のバージョンにおいては不具合によって期待した値が送られていないという問題も度々起こりえます。

このような場合もdmemoに注釈を付与して管理する必要がありました。

f:id:gigi-net:20201102143437p:plain
dmemoでのバージョン毎の挙動差異の注釈の様子

ログの実装ミスが防げない

冒頭のロガーの例のようなスキーマレスなインターフェイスは、実装時の柔軟性が上がりますが、実装時のミスを防ぐことが難しくなります。

フィールド名のtypoや、必要なペイロードの欠損、型の間違いなどのミスは常に起こりえます。

ログの実装をミスしたリリースが世に出てしまうと何が起きるのでしょうか。そうです、またdmemoへの記述量が増えていきます。

ログイベントの廃止が難しい

ログの廃止の問題もありました。分析の必要性がなくなったり、該当する機能そのものがなくなったりしたケースです。

この場合は、あるバージョンからログが送信されなくなります。同様に、dmemoに全て記述しておく必要があります。

ログの送付し忘れやデグレーションに気付きづらい

逆に、送信しなくてはならないログの送信がされていないケースもあります。

これは、実装時の単なる実装漏れから、リファクタリング時にうっかり処理が消えてしまうことも考えられます。

上記のログの廃止も併せ、それぞれのログが送られているべきなのか、そうではないのか、確認が必要でした。

これらの問題は、全て現実世界における行動ログの持つコンテキストが膨大なことに起因します。このようなドキュメンテーションの多くをdmemoに依存する必要がありました。

ドキュメントからロガーの実装を生成するログ基盤

そこで、今回実現したのが、Markdownで書かれたログ定義ドキュメントからのログ実装の自動生成です。

ログ定義をヒューマンリーダブルなドキュメントとして記述し、そこからクライアント実装のコードを自動生成します。

例を見てみましょう。

1. ログ仕様を決めてドキュメントを記述する

まずログの仕様を決め、そのドキュメントを専用の文法に従ってMarkdownで記述します。

# recipe_search

レシピ検索画面のイベントです

## search

レシピ検索を行った際に送付されます

- keyword: !string 256
    -検索キーワード
- order: SearchOrder
    -検索順
    - latest,popularity

## show_recipe

検索結果画面からレシピ詳細画面に遷移する際に送付されます

- recipe_id: !integer
    -表示したレシピのID

このとき、ログ定義にはドキュメントを追記することもできます。

ログ定義の構成

このMarkdownがどのような構造になっているかを簡単に見て行きましょう。ログ定義はカテゴリ、イベント、カラムの3つの要素で構成されます。

カテゴリ (recipe_search)

複数のイベントの集合です。近しい機能(例えば一つの画面上に実装されているものなど)を同一のカテゴリとして扱います。

大見出しがカテゴリを表現します。カテゴリはいくつかのイベントを持ちます。

イベント (search, show_recipe)

特定の行動に対応する単位です。「検索を行った」(search)や「レシピを表示した」(show_recipe)などの単位が挙げられます。

それぞれの小見出しが、そのカテゴリに属するイベントを表現します。

カラム

各イベントに付与されるペイロードを表します。各カラムは型を持ち、この型がデータベース上の型と一致します。

また、全てのイベントが共通して持つペイロードは別途定義しています。 例えば、送信日時やユーザーID、送信者のOSやアプリケーションバージョンといった項目が挙げられます。

2. クライアント用のログ実装を生成する

次に、トランスパイラを実行します。

$ ./generate-log-classes

これによって、例えばiOSアプリにおいては、以下のようなSwiftのソースコードが生成されます。

ここで生成された RecipeSearchが、アプリケーション上でログイベントを表現する型となります。

例えば、レシピ検索のイベントはRecipeSearch.searchとして利用できます。

/// レシピ検索画面のイベントですpublicenumRecipeSearch:LogCategory {
    publicstaticvarcategoryName:String { "recipe_search" }
    publicvareventName:String {
        switchself {
        case .search:return"search"case .showRecipe:return"show_recipe"
        }
    }
    publicfuncmakePayload() ->[String: Any] {
        switchself {
        caselet .search(keyword, order):return [
                "keyword":keyword.validateLength(within:256).dump(),
                "order":order.dump(),
            ].compactMapValues { $0 }
        caselet .showRecipe(recipeId):return [
                "recipe_id":recipeId.dump(),
            ].compactMapValues { $0 }
        }
    }

    /// レシピ検索を行った際に送付されますcase search(keyword:String, order:SearchOrder)
    /// 検索結果画面からレシピ詳細画面に遷移する際に送付されますcase showRecipe(recipeId:Int64)
}

3. アプリケーション上でログを送信する

最後に生成されたログイベントを用いて、ログを送付します。

ログを送付したいタイミングで以下を呼び出します。

presentRecipeDetailViewController(recipeID:42)
logger.postLog(RecipeSearch.showRecipe(recipeId:42))

このようにアプリケーション開発者は、ログの仕様をMarkdownで記述するだけで、簡単にログの実装を完了できます。

このロガーは、冒頭に紹介したスキーマレスなロガーと違い、各ログイベントが型で保証されています。 これにより、フィールド名の間違い、欠損、型の間違いなどのヒューマンエラーを防いでいます。

ログ基盤の構成

このログ基盤は、以下のような構成で実現されています。

f:id:gigi-net:20201102143935p:plain
ログ基盤の構成

ログ定義

ログ定義はMarkdownで記述され、各アプリケーションのリポジトリ上に置かれています。

ログ定義と実装が紐付くことで、ログ定義の変遷やバージョン毎の挙動の違いも、通常のソースコードと同様にバージョン管理ツールから追跡可能になりました。

Markdownパーサー

ログ定義Markdownをパースし、中間表現に変換します。これを実現するMarkdownコンパイラ daifukuを実装しました。Rubyで記述しています。

daifukuは、Markdownを中間表現(RubyオブジェクトやJSONなど)に変換することのみを担当します。ここで重要なのは、daifukuはコード生成の責務を負わないことです。

コード生成は各プロジェクトがパースされた中間表現を読み取って行います。

これにより、変換先の実装言語によらず、汎用的にこのログ基盤を利用できるようになりました。

クックパッドでは、これと同様のログ生成の仕組みを、iOSアプリのほか、Androidアプリと、Webフロントエンドでも実現しています。 daifukuが生成した中間表現を、それぞれSwift, Kotlin, TypeScriptのコードにトランスパイルすることで実現しています。

コンパイル時にログ定義の簡易的なバリデーションも行っています。定義が命名規則に合致するかチェックしたり、利用できないカラム名を定義していないかなどです。

トランスパイラ(コード生成機)

daifukuが生成した中間表現を、テンプレートエンジンを用いて、各言語の実装にトランスパイルします。 このトランスパイラは、各プロジェクト毎に用意されています。多くはRubyで実装されていますが、任意の言語で実装できます。

ロガー

トランスパイラが生成したログイベントを解釈し、ログ送信ライブラリに渡す層です。

アプリケーション開発者は、ログを送りたい場面でログイベントオブジェクトを作り、ロガーに渡します。

logger.postLog(RecipeSearch.showRecipe(recipeId:42))

ログ送信ライブラリ

最後が、収集されたログをログバックエンドに送信するための層です。

iOSアプリでは、以前からクックパッドで利用されている、ログ収集ライブラリのPureeを用いています。

Pureeはログのバッファリングや永続化を自動的に行い、非同期でログバックエンドへのバッチ送信を行うライブラリです。

PureeはOSSとして公開されており、他社のプロダクトでも多く利用されています。

さらに簡単で安全なログ開発基盤へ

この仕組みを運用することで、冒頭に挙げた問題の多くを解決することができるようになりました。

ここからは、利便性と堅牢性をさらに高める、発展的な機能について説明していきます。

廃止になったログイベントの表現

ログイベントの廃止を、特殊なアノテーションで指定することもできます。

## [obsolete] tap_promotion_banner
    -バナーをタップしたときに送付されます
    -バージョンXX.Xからバナーは表示されなくなりました

ログ定義に[obsolete]指定を追加すると、このログイベントをコード生成から除外することができます。

単純なドキュメントからの削除ではなく、特殊なアノテーションを付加して記述できるようにしているのは、ログ調査の簡略化のための工夫です。 これにより、Markdown上にドキュメントを残しつつ、アプリケーションからは送ることができないログを表現できます。

ログ定義の静的解析による実装し忘れの検知

冒頭で挙げた、ログの送信し忘れ、実装漏れはある程度の静的解析でチェックすることができるようになりました。 前述のobsolete指定がされていないかつ、アプリケーション上のどこからも送信されていないログは、実装忘れである可能性が高くなります。

このチェックをCI上で実行し、利用されていないログイベントが見つかった場合は、実装か廃止を促します。

IDEのドキュメンテーションとの統合

トランスパイル時に、各言語のドキュメンテーションの仕組みに沿ったコメントを生成することで、ドキュメントをIDEに統合することもしています。

Markdownに記述したドキュメントは、コード生成時にログイベントのDocumentation Commentとして出力されます。 これにより、実装時に簡単にログの仕様を把握することができます。

f:id:gigi-net:20201102144026p:plain
Xcode上でログ定義をコード補完している様子

言語機能による型安全なログイベント

コード生成時に出力先の言語の型システムを利用することで、さらに堅牢なロガーの実装を生成できます。

もう一度、レシピ検索の行動ログの例を見てみましょう。

# recipe_search

## search

- keyword: !string 256
    -検索キーワード
- exclude_keyword: !string? 256
    -除外キーワード
- order: SearchOrder
    -検索順
    - recent,popularity

このとき、各カラムの型としてデータベースに格納するプリミティブな型(!から始まります)以外に、特殊な型指定を利用することで、ログイベントが要求するペイロードの型を細かく制御することができます。

いくつか例を見ていきましょう。

オプショナル型

型指定に?を付けることで、仕様上nullが入りうるカラム、そうではないカラムを区別することができます。

exclude_keywordカラムは検索時の除外キーワードです。通常のキーワードが必須であるのに対し、除外キーワードは必須ではありません。

オプショナル型は、上記の例にある exclude_keywordのような、状況により付与しないカラムの表現に役立ちます。

オプショナル型でないカラムは、自動的にnullを非許容とするため、実装時に空の値が送られてしまうことを型システムで防ぐことができます。*1

logger.postLog(RecipeSearch.search(keyword:nil, excludeKeyword:nil)) // keywordにnilは渡すことができない

カスタム型

カスタム型を使って、任意の型をペイロードとして渡すこともできます。代表的な使用例は、特定の定義域しか取らない文字列型の表現です。

orderカラムは検索順を記録する文字列型のカラムです。新着順(recent)か、人気順(popularity)のいずれかの値を取ります。

このカラムを単なる文字列型として扱ってしまうと、アプリケーション実装者のミスにより、定義外の値が混入してしまう可能性があります。

logger.postLog(RecipeSearch.search(order:"oldest")) // このorderは渡せない

そこで、このようなenumを定義し、ログイベントがこの値を要求することで、定義外の値を送ってしまうことを型システムで防いでいます。

publicenumSearchOrder:String, ColumnType {
    case recent
    case popularity
}

この場合は実態はStringのenumなので、ログバックエンドには文字列型として送信されます。

これらの工夫により、アプリケーション実装者が利用するロガーは型が保証されています。これにより、意図しない値が入り込むことを実装レベルで防いでいます。

logger.postLog(RecipeSearch.search(keyword:"いちごどうふ", 
                                                             excludeKeyword:nil, 
                                                             order: .popularity))

まとめ

今回はクックパッドのログ基盤のフロントエンドでの取り組みを中心にお伝えしました。

ログ定義がドキュメントベースで管理されるようになったことで、冒頭で挙げた種々の問題を一度に解決することができました。

このログ基盤の導入以前には、ログの実装コンテキストを追ったり、実装ミスを防ぐのが難しい状況でした。 しかし、導入後は、ドキュメントとログ実装が紐付くことで、開発・分析時共に、ログの設計、実装、利用が全て容易に行えるようになったことがおわかり頂けたと思います。

ログ基盤は、組織によってニーズが異なり、なかなか汎用的な仕組みを作ることが難しい領域だと感じています。 この記事が最高のログ基盤を作るための一助となりましたら幸いです。

クックパッドでは最高の行動ログ基盤を使って開発したいエンジニアを募集しています。

*1:なお、どれだけ気をつけてもDBに到達する頃にはなぜかnullになっているケースを完全に防げないことはあまりにも有名である

nerman: AllenNLP と Optuna で作る固有表現抽出システム

$
0
0

事業開発部の @himktです.好きなニューラルネットは BiLSTM-CRF です. 普段はクックパッドアプリのつくれぽ検索機能の開発チームで自然言語処理をしています.

本稿では,レシピテキストからの料理用語抽出システム nermanについて紹介します. nerman の由来は ner (固有表現抽出 = Named Entity Recognition) + man (する太郎) です. クックパッドに投稿されたレシピから料理に関する用語を自動抽出するシステムであり,AllenNLP と Optuna を組み合わせて作られています. (コードについてすべてを説明するのは難しいため,実際のコードを簡略化している箇所があります)

料理用語の自動抽出

料理レシピには様々な料理用語が出現します. 食材や調理器具はもちろん,調理動作や食材の分量なども料理用語とみなせます. 「切る」という調理動作を考えても,「ざく切りにする」「輪切りにする」「みじん切りにする」など,用途に合わせて色々な切り方が存在します. レシピの中からこのような料理用語を抽出できれば,レシピからの情報抽出や質問応答などのタスクに応用できます.

料理用語の自動抽出には,今回は機械学習を利用します. 自然言語処理のタスクの中に,固有表現抽出というタスクが存在します. 固有表現抽出とは,自然言語の文(新聞記事などの文書が対象となることが多いです)から人名や地名,組織名などの固有表現を抽出するタスクです. このタスクは系列ラベリングと呼ばれる問題に定式化できます. 系列ラベリングを用いた固有表現抽出では,入力文を単語に分割したのち各単語に固有表現タグを付与します. タグが付与された単語列を抽出することで固有表現が得られます.

固有表現抽出の例
一般的な固有表現抽出(上)と料理用語への応用例(下)

今回は人名,地名などの代わりに食材名,調理器具名,調理動作の名前などを固有表現とみなしてモデルを学習します. 詳細な固有表現タグの定義は次の章で説明します.

データセット

機械学習モデルの学習には教師データが必要です. クックパッドでは言語データ作成の専門家の方に協力していただき,アノテーションガイドラインの整備およびコーパスの構築に取り組みました. レシピからの固有表現抽出については京都大学の森研究室でも研究されています(論文はこちら. PDF ファイルが開かれます). この研究で定義されている固有表現タグを参考にしつつ,クックパッドでのユースケースに合わせて次のような固有表現タグを抽出対象として定義しました.

固有表現タグの一覧
固有表現タグの一覧

この定義に基づき,クックパッドに投稿されたレシピの中から 500 品のレシピに対して固有表現を付与しました. データは Cookpad Parsed Corpus と名付けられ,社内の GitHub リポジトリで管理されています. また,機械学習モデルで利用するための前処理(フォーマットの変更など)をしたデータが S3 にアップロードされています.

Cookpad Parsed Corpus に関するアウトプットとして論文化にも取り組んでいます. 執筆した論文は自然言語処理の国際会議である COLING で開催される言語資源に関する研究のワークショップ LAW(Linguistic Annotation Workshop)に採択されました. 🎊

タイトルは以下の通りです.

Cookpad Parsed Corpus: Linguistic Annotations of Japanese Recipes
Jun Harashima and Makoto Hiramatsu

Cookpad Parsed Corpus に収録されているレシピは固有表現の他にも形態素と係り受けの情報が付与されており, 現在大学等の研究機関に所属されている方に利用いただけるように公開の準備を進めています.

準備: AllenNLP を用いた固有表現抽出モデル

nerman ではモデルは AllenNLPを用いて実装しています.

github.com

AllenNLP は Allen Institute for Artificial Intelligence (AllenAI) が開発している自然言語処理フレームワークであり, 最新の機械学習手法に基づく自然言語処理のためのニューラルネットワークを簡単に作成できる便利なライブラリです. AllenNLP は pipでインストールできます.

pip install allennlp

AllenNLP ではモデルの定義や学習の設定を Jsonnet 形式のファイルに記述します. 以下に今回の固有表現抽出モデルの学習で利用する設定ファイル(config.jsonnet)を示します. (モデルは BiLSTM-CRF を採用しています.)

  • config.jsonnet
local dataset_base_url = 's3://xxx/nerman/data';

{
  dataset_reader: {
    type: 'cookpad2020',
    token_indexers: {
      word: {
        type: 'single_id',
      },
    },
    coding_scheme: 'BIOUL',
  },
  train_data_path: dataset_base_url + '/cpc.bio.train',
  validation_data_path: dataset_base_url + '/cpc.bio.dev',
  model: {
    type: 'crf_tagger',
    text_field_embedder: {
      type: 'basic',
      token_embedders: {
        word: {
          type: 'embedding',
          embedding_dim: 32,
        },
      },
    },
    encoder: {
      type: 'lstm',
      input_size: 32,
      hidden_size: 32,
      dropout: 0.5,
      bidirectional: true,
    },
    label_encoding: 'BIOUL',
    calculate_span_f1: true,
    dropout: 0.5,
    initializer: {},
  },
  data_loader: {
    batch_size: 10,
  },
  trainer: {
    num_epochs: 20,
    cuda_device: -1,
    optimizer: {
      type: 'adam',
      lr: 5e-4,
    },
  },
}

モデル,データ,そして学習に関する設定がそれぞれ指定されています. AllenNLP はデータセットのパスとしてローカルのファイルパスだけでなく URL を指定できます. 現状では httphttps,そして s3のスキーマに対応しているようです. (読んだコードはこのあたり) nerman では train_data_pathおよび validation_data_pathに S3 上の加工済み Cookpad Parsed Corpus の学習データ,バリデーションデータの URL を指定しています.

AllenNLP は自然言語処理の有名なタスクのデータセットを読み込むためのコンポーネントを提供してくれます. しかしながら,今回のように自分で構築したデータセットを利用したい場合には自分でデータセットをパースするクラス(データセットリーダー)を作成する必要があります. cookpad2020は Cookpad Parsed Corpus を読み込むためのデータセットリーダーです. データセットリーダーの作成方法については公式チュートリアルで 説明されているので詳しく知りたい方はそちらを参照いただければと思います.

設定ファイルが作成できたら, allennlp train config.jsonnet --serialization-dir resultのようにコマンドを実行することで学習がはじまります. 学習のために必要な情報すべてが設定ファイルにまとまっていて,実験を管理しやすいことが AllenNLP の特徴の1つです. serialization-dirについては後述します.

今回の記事では紹介しませんが, allennlpコマンドには allennlp predictallennlp evaluateなどの非常に便利なサブコマンドが用意されています. 詳しく知りたい方は公式ドキュメントを参照ください.

nerman の全体像

以下に nerman の全体像を示します.

nerman の全体像
nerman の全体像

システムは大きく分けて 3 つのバッチから構成されています.それぞれの役割は以下の通りです.

  • (1) ハイパーパラメータ最適化
  • (2) モデルの学習
  • (3) 実データ(レシピ)からの固有表現抽出(予測)

本稿では,順序を入れ替えて モデルの学習 =>実データでの予測 =>ハイパーパラメータ最適化 の順に解説していきます.

モデルの学習

モデルの学習バッチは以下のようなシェルスクリプトを実行します.

  • train
#!/bin/bash

allennlp train \
  config/ner.jsonnet \--serialization-dir result \--include-package nerman

# モデルとメトリクスのアップロード
aws s3 cp result/model.tar.gz s3://xxx/nerman/model/$TIMESTAMP/model.tar.gz
aws s3 cp result/metrics.json s3://xxx/nerman/model/$TIMESTAMP/metrics.json

準備の章で解説したように, allennlp trainコマンドでモデルを学習します. --serialization-dirで指定しているディレクトリにはモデルのアーカイブ(tar.gz 形式), アーカイブファイルの中にはモデルの重みの他に標準出力・標準エラー出力,そして学習したモデルのメトリクスなどのデータが保存されます.

学習が終わったら, allennlp trainコマンドによって生成されたモデルのアーカイブとメトリクスを S3 にアップロードします. (アーカイブファイルにはモデルの重みなどが保存されており,このファイルがあれば即座にモデルを復元できます.) また,メトリクスファイルも同時にアップロードしておくことで,モデルの性能をトラッキングできます.

S3
S3 の様子.実行日ごとにモデルのアーカイブとメトリクスがアップロードされる.

  • metrics.json

生成されるメトリクスファイル.性能指標だけでなく学習時間や計算時間などもわかります)

{"best_epoch": 19,
  "peak_worker_0_memory_MB": 431.796,
  "training_duration": "0:29:38.785065",
  "training_start_epoch": 0,
  "training_epochs": 19,
  "epoch": 19,
  "training_accuracy": 0.8916963871929718,
  "training_accuracy3": 0.8938523846944327,
  "training_precision-overall": 0.8442808607021518,
  "training_recall-overall": 0.8352005377548734,
  "training_f1-measure-overall": 0.8397161522865011,
  "training_loss": 38.08172739275527,
  "training_reg_loss": 0.0,
  "training_worker_0_memory_MB": 431.796,
  "validation_accuracy": 0.8663015463917526,
  "validation_accuracy3": 0.8688788659793815,
  "validation_precision-overall": 0.8324965769055226,
  "validation_recall-overall": 0.7985989492119089,
  "validation_f1-measure-overall": 0.815195530726207,
  "validation_loss": 49.37634348869324,
  "validation_reg_loss": 0.0,
  "best_validation_accuracy": 0.8663015463917526,
  "best_validation_accuracy3": 0.8688788659793815,
  "best_validation_precision-overall": 0.8324965769055226,
  "best_validation_recall-overall": 0.7985989492119089,
  "best_validation_f1-measure-overall": 0.815195530726207,
  "best_validation_loss": 49.37634348869324,
  "best_validation_reg_loss": 0.0,
  "test_accuracy": 0.875257568552861,
  "test_accuracy3": 0.8789031542241242,
  "test_precision-overall": 0.8318906605922551,
  "test_recall-overall": 0.8214125056230319,
  "test_f1-measure-overall": 0.8266183793571253,
  "test_loss": 48.40180677297164}

モデルの学習は EC2 インスタンス上で実行されます. 今回のケースではデータセットは比較的小さく(全データ = 500 レシピ), BiLSTM-CRF のネットワークもそこまで大きくありません. このため,通常のバッチジョブとほぼ同じ程度の規模のインスタンスでの学習が可能です. 実行環境が GPU や大容量メモリなどのリソースを必要としないため,通常のバッチ開発のフローに乗ることができました. これにより,社内に蓄積されていたバッチ運用の知見を活かしてインフラ環境の整備にかかるコストを抑えつつ学習バッチを構築できています.

また, nerman のバッチはすべてスポットインスタンスを前提として構築されています. スポットインスタンスは通常のインスタンスよりもコストが低く, 代わりに実行中に強制終了する(spot interruption と呼ばれる)可能性があるインスタンスです. モデルの学習は強制終了されてしまってもリトライをかければよく,学習にかかる時間が長すぎなければスポットインスタンスを利用することでコストを抑えられます. (ただし,学習にかかる時間が長ければ長いだけ spot interruption に遭遇する可能性が高くなります. リトライを含めた全体での実行時間が通常のインスタンスでの実行時間と比較して長くなりすぎた場合, かえってコストがかかってしまう可能性があり,注意が必要です.)

実データでの予測

以下のようなシェルスクリプトを実行して予測を実行します.

  • predict
#!/bin/bashexport MODEL_VERSION=${MODEL_VERSION:-2020-07-08}export TIMESTAMP=${TIMESTAMP:-`date '+%Y-%m-%d'`}export FROM_IDX=${FROM_IDX:-10000}export LAST_IDX=${LAST_IDX:-10100}export KUROKO2_PARALLEL_FORK_INDEX=${KUROKO2_PARALLEL_FORK_INDEX:--1}export KUROKO2_PARALLEL_FORK_SIZE=${KUROKO2_PARALLEL_FORK_SIZE:--1}if [$KUROKO2_PARALLEL_FORK_SIZE=-1]||[$KUROKO2_PARALLEL_FORK_INDEX=-1];thenecho$FROM_IDX$LAST_IDX' (without parallel execution)'elseif (($KUROKO2_PARALLEL_FORK_INDEX>=$KUROKO2_PARALLEL_FORK_SIZE));thenecho'$KUROKO2_PARALLEL_FORK_INDEX'=$KUROKO2_PARALLEL_FORK_INDEX'must be smaller than $KUROKO2_PARALLEL_FORK_SIZE'$KUROKO2_PARALLEL_FORK_SIZEexitfi# ==============================================================================# begin: FROM_IDX ~ LAST_IDX のデータを KUROKO2_PARALLEL_FORK_SIZE の値で等分する処理# ==============================================================================NUM_RECORDS=$(($LAST_IDX - $FROM_IDX))echo'NUM_RECORDS = '$NUM_RECORDSif (($NUM_RECORDS % $KUROKO2_PARALLEL_FORK_SIZE!=0));thenecho'$KUROKO2_PARALLEL_FORK_SIZE = '$KUROKO2_PARALLEL_FORK_SIZE'must be multiple of $NUM_RECORDS='$NUM_RECORDSexitfiDIV=$(($NUM_RECORDS / $KUROKO2_PARALLEL_FORK_SIZE))echo'DIV='$DIVif (($DIV<=0));thenecho'Invalid DIV='$DIVexitfiLAST_IDX=$(($FROM_IDX + (($KUROKO2_PARALLEL_FORK_INDEX + 1) * $DIV)))FROM_IDX=$(($FROM_IDX + ($KUROKO2_PARALLEL_FORK_INDEX * $DIV)))echo'$FROM_IDX = '$FROM_IDX' $LAST_IDX = '$LAST_IDX# ============================================================================# end: FROM_IDX ~ LAST_IDX のデータを KUROKO2_PARALLEL_FORK_SIZE の値で等分する処理# ============================================================================fi

allennlp custom-predict \--from-idx$FROM_IDX\--last-idx$LAST_IDX\--include-package nerman \--model-path s3://xxx/nerman/model/$MODEL_VERSION/model.tar.gz

aws s3 cp \--recursive\--exclude"*"\--include"_*.csv"\
    prediction \
    s3://xxx/nerman/output/$TIMESTAMP/prediction/

予測バッチは学習バッチが作成したモデルを読み込み,固有表現が付与されていないレシピを解析します. また,予測バッチは並列実行に対応しています. クックパッドには 340 万品以上のレシピが投稿されており,これらのレシピを一度に解析するのは容易ではありません. このため,レシピを複数のグループに分割し,それぞれを並列に解析しています.

並列処理の様子
並列処理の様子

FROM_RECIPE_IDXLAST_RECIPE_IDXで解析対象とするレシピを指定し, KUROKO2_PARALLEL_FORK_SIZEという環境変数で並列数を設定します. 並列実行されたプロセスには KUROKO2_PARALLEL_FORK_INDEXという変数が渡されるようになっていて,この変数で自身が並列実行されたプロセスのうち何番目かを識別します. プロセスの並列化は社内で利用されているジョブ管理システム kuroko2の並列実行機能 (parallel_fork) を利用して実現しています.

custom-predictコマンドは上で定義した変数を用いて対象となるレシピを分割し, AllenNLP のモデルを用いて固有表現を抽出するコマンドです. AllenNLP では自分でサブコマンドを登録でき,このようにすべての処理を allennlpコマンドから実行できるようになっています. サブコマンドは以下のように Python スクリプト(predict_custom.py)を作成して定義できます. (サブコマンドについての公式ドキュメントはこちら

  • custom_predict.py
import argparse

from allennlp.commands import Subcommand

from nerman.data.dataset_readers import StreamSentenceDatasetReader
from nerman.predictors import KonohaSentenceTaggerPredictor


defcreate_predictor(model_path) -> KonohaSentenceTaggerPredictor:
    archive = load_archive(model_path)
    predictor = KonohaSentenceTaggerPredictor.from_archive(archive)
    dataset_reader = StreamSentenceDatasetReader(predictor._dataset_reader._token_indexers)
    return KonohaSentenceTaggerPredictor(predictor._model, dataset_reader)


def_predict(
  from_idx: int,
  last_idx: int,
  model_path: str,
):

    # predictor の作成
    predictor = create_predictor(model_path)
    ...  # Redshift からデータを取ってきたりモデルに入力したりする処理(今回の記事では解説は割愛します)defpredict(args: argparse.Namespace):
    from_idx = args.from_idx
    last_idx = args.last_idx
    _predict(from_idx, last_idx)


@Subcommand.register("custom-predict")
classCustomPrediction(Subcommand):
    @overridesdefadd_subparser(self, parser: argparse._SubParsersAction) -> argparse.ArgumentParser:
        description = "Script to custom predict."
        subparser = parser.add_parser(self.name, description=description, help="Predict entities.")

        subparser.add_argument("--from-idx", type=int, required=True)
        subparser.add_argument("--last-idx", type=int, required=True)
        subparser.add_argument("--model-path", type=str, required=True)

        subparser.set_defaults(func=predict)  # サブコマンドが呼ばれたときに実際に実行するメソッドを指定するreturn subparser

model_pathという変数にはモデルのアーカイブファイルのパスが指定されています. アーカイブファイルのパスは load_archiveというメソッドに渡されます. load_archiveは AllenNLP が提供しているメソッドであり,これを利用すると保存された学習済みモデルの復元が簡単にできます. また, load_archiveはデータセットのパスと同様 S3 スキーマに対応しているため,学習バッチでアップロード先に指定したパスをそのまま利用できます. (load_archiveの公式ドキュメントはこちら

文字列をモデルに入力するためには AllenNLP の Predictorという機構を利用しています. 公式ドキュメントはこちらです. 系列ラベリングモデルの予測結果を扱う際に便利な SentenceTaggerPredictorクラスを継承し,以下に示す KonohaSentenceTaggerPredictorクラスを定義しています. predictメソッドに解析したい文字列を入力すると,モデルの予測結果を出力してくれます.

from allennlp.common.util import JsonDict
from allennlp.data import Instance
from allennlp.data.dataset_readers.dataset_reader import DatasetReader
from allennlp.models import Model
from allennlp.predictors import SentenceTaggerPredictor
from allennlp.predictors.predictor import Predictor
from konoha.integrations.allennlp import KonohaTokenizer
from overrides import overrides


@Predictor.register("konoha_sentence_tagger")
classKonohaSentenceTaggerPredictor(SentenceTaggerPredictor):
    def__init__(self, model: Model, dataset_reader: DatasetReader) -> None:
        super().__init__(model, dataset_reader)
        self._tokenizer = KonohaTokenizer("mecab")

    defpredict(self, sentence: str) -> JsonDict:
        return self.predict_json({"sentence": sentence})

    @overridesdef_json_to_instance(self, json_dict: JsonDict) -> Instance:
        sentence = json_dict["sentence"]
        tokens = self._tokenizer.tokenize(sentence)
        return self._dataset_reader.text_to_instance(tokens)

nerman では,日本語のレシピデータを扱うために日本語処理ツールの konohaを利用しています. KonohaTokenizerは Konoha が提供している AllenNLP インテグレーション機能です. 日本語文字列を受け取り,分かち書きもしくは形態素解析を実施, AllenNLP のトークン列を出力します. 形態素解析器には MeCab を採用しており,辞書は mecab-ipadic を使用しています.

github.com

次に,作成したモジュールを __init__.pyでインポートします. 今回は nerman/commandsというディレクトリに custom_predict.pyを設置しています. このため, nerman/__init__.pyおよび nerman/commands/__init__.pyをそれぞれ次のように編集します.

  • nerman/__init__.py
import nerman.commands
  • nerman/commands/__init__.py
from nerman.commands import custom_predict

コマンドの定義およびインポートができたら, allennlpコマンドで実際にサブコマンドを認識させるために .allennlp_pluginsというファイルをリポジトリルートに作成します.

  • .allennlp_plugins
nerman

以上の操作でサブコマンドが allennlpコマンドで実行できるようになります. allennlp --helpを実行して作成したコマンドが利用できるようになっているか確認できます.

得られた予測結果は CSV 形式のファイルとして保存され,予測が終了した後に S3 へアップロードされます.

次に, S3 にアップロードした予測結果をデータベースに投入します. データは最終的に Amazon Redshift (以降 Redshift) に配置されますが, Amazon Aurora (以降 Aurora)を経由するアーキテクチャを採用しています. これは Aurora の LOAD DATA FROM S3ステートメントという機能を利用するためです. LOAD DATA FROM S3ステートメントは次のような SQL クエリで利用できます.

  • load.sql
load
    data
from
    S3 's3://xxx/nerman/output/$TIMESTAMP/prediction.csv'intotable recipe_step_named_entities
    fields terminated by','
    lines  terminated by'\n'
    (recipe_text_id, start, last, name, category)
    set created_at = current_timestamp, updated_at = current_timestamp;

このクエリを実行することで, S3 にアップロードした CSV ファイルを直接 Amazon Aurora にインポートできます. LOAD DATA FROM S3については AWS の公式ドキュメントが参考になります. バッチサイズやコミットのタイミングの調整の手間が必要なくなるため,大規模データをデータベースに投入する際に非常に便利です.

Aurora のデータベースに投入した予測結果は pipelined-migratorという社内システムを利用して定期的に Redshift へ取り込まれます. pipelined-migrator を利用することで,管理画面上で数ステップ設定を行うだけで Aurora から Redshift へデータを取り込めます. これにより, S3 からのロードと pipelined-migrator を組み合わせた手間の少ないデータの投入フローが実現できました.

解析結果をスタッフに利用してもらう方法として,データベースを利用せずに予測 API を用意する方法も考えられます. 今回のタスクの目標は「すでに投稿されたレシピからの料理用語の自動抽出」であり,これはバッチ処理であらかじめ計算可能です. このため, API サーバを用意せずにバッチ処理で予測を行う方針を採用しました.

また,エンジニア以外のスタッフにも予測結果を使ってみてもらいたいと考えていました. クックパッドはエンジニア以外のスタッフも SQL を書ける方が多いため, 予測結果をクエリ可能な形でデータベースに保存しておく方針はコストパフォーマンスがよい選択肢でした. 予測結果を利用するクエリ例を以下に示します.

  • list_tools.sql
select
    , s.recipe_id
    , e.name
    , e.category
from
    recipe_step_named_entities as e
    innerjoin recipe_steps as s on e.step_id = s.id
where
    e.category in ('Tg')
    and s.recipe_id = xxxx

このクエリを Redshift 上で実行することで,レシピ中に出現する調理器具のリストを取得できるようになりました.

SQL の実行結果
SQL の実行結果

Optuna を用いたハイパーパラメータの分散最適化

最後にハイパーパラメータの最適化について解説します.

github.com

nerman では Optunaを用いたハイパーパラメータの最適化を実施しています. Optuna は Preferred Networks (PFN) が開発しているハイパーパラメータ最適化のライブラリです. インストールは pip install optunaをターミナルで実行すれば完了します.

Optuna では,各インスタンスから接続可能なバックエンドエンジン(RDB or Redis)を用意し,それをストレージで使用することで, 複数インスタンスを利用した分散環境下でのハイパーパラメータ最適化を実現できます. (ストレージは Optuna が最適化結果を保存するために使用するもので,RDB や Redis などを抽象化したものです) インスタンスをまたいだ分散最適化を実施する場合,ストレージのバックエンドエンジンは MySQL もしくは PostgreSQL が推奨されています (Redis も experimental feature として利用可能になっています). 詳しくは公式ドキュメントをご参照ください. 今回はストレージとして MySQL (Aurora) を採用しています.

Optuna には AllenNLP のためのインテグレーションモジュールが存在します. しかしながら,このインテグレーションモジュールを使うと自身で最適化を実行するための Python スクリプトを記述する必要があります. そこで, AllenNLP とよりスムーズに連携するために allennlp-optunaというツールを開発しました. allennlp-optunaをインストールすると,ユーザは allennlp tuneというコマンドで Optuna を利用したハイパーパラメータ最適化を実行できるようになります. このコマンドは allennlp trainコマンドと互換性が高く, AllenNLP に慣れたユーザはスムーズにハイパーパラメータの最適化を試せます.

github.com

allennlp tuneコマンドを実行するには,まず pip install allennlp-optuna.gitallennlp-optunaをインストールします. 次に, .allennlp_pluginsを以下のように編集します.

  • .allennlp_plugins
allennlp-optuna
nerman

allennlp --helpとコマンドを実行して,以下のように retrainコマンドと tuneコマンドが確認できればインストール成功です.

$ allennlp --help
2020-11-05 01:54:24,567 - INFO - allennlp.common.plugins - Plugin allennlp_optuna available
usage: allennlp [-h][--version]  ...

Run AllenNLP

optional arguments:
  -h, --help     show this help message and exit--version      show program's version number and exitCommands:    best-params  Export best hyperparameters.    evaluate     Evaluate the specified model + dataset.    find-lr      Find a learning rate range.    predict      Use a trained model to make predictions.    print-results                 Print results from allennlp serialization directories to the console.    retrain      Train a model with hyperparameter found by Optuna.    test-install                 Test AllenNLP installation.    train        Train a model.    tune         Optimize hyperparameter of a model.

allennlp-optunaが無事にインストールできました. 次に allennlp-optunaを利用するために必要な準備について解説します.

設定ファイルの修正

はじめに,準備の章で作成した config.jsonnetを以下のように書き換えます.

  • config.jsonnet (allennlp-optuna用)
// ハイパーパラメータを変数化する
local lr = std.parseJson(std.extVar('lr'));
local lstm_hidden_size = std.parseInt(std.extVar('lstm_hidden_size'));
local dropout = std.parseJson(std.extVar('dropout'));
local word_embedding_dim = std.parseInt(std.extVar('word_embedding_dim'));

local cuda_device = -1;

{
  dataset_reader: {
    type: 'cookpad2020',
    token_indexers: {
      word: {
        type: 'single_id',
      },
    },
    coding_scheme: 'BIOUL',
  },
  train_data_path: 'data/cpc.bio.train',
  validation_data_path: 'data/cpc.bio.dev',
  model: {
    type: 'crf_tagger',
    text_field_embedder: {
      type: 'basic',
      token_embedders: {
        word: {
          type: 'embedding',
          embedding_dim: word_embedding_dim,
        },
      },
    },
    encoder: {
      type: 'lstm',
      input_size: word_embedding_dim,
      hidden_size: lstm_hidden_size,
      dropout: dropout,
      bidirectional: true,
    },
    label_encoding: 'BIOUL',
    calculate_span_f1: true,
    dropout: dropout,  // ここで宣言した変数を指定する
    initializer: {},
  },
  data_loader: {
    batch_size: 10,
  },
  trainer: {
    num_epochs: 20,
    cuda_device: cuda_device,
    optimizer: {
      type: 'adam',
      lr: lr,  // ここで宣言した変数を指定する
    },
  },
}

最適化したいハイパーパラメータを local lr = std.parseJson(std.extVar('lr'))のように変数化しています. std.extVarの返り値は文字列です.機械学習モデルのハイパーパラメータは整数や浮動小数であることが多いため,キャストが必要になります. 浮動小数へのキャストは std.parseJsonというメソッドを利用します.整数へのキャストは std.parseIntを利用してください.

探索空間の定義

次に,ハイパーパラメータの探索空間を定義します. allennlp-optunaでは,探索空間は次のような JSON ファイル(hparams.json)で定義します.

  • hparams.json
[{"type": "float",
    "attributes": {"name": "dropout",
      "low": 0.0,
      "high": 0.8}},
  {"type": "int",
    "attributes": {"name": "lstm_hidden_size",
      "low": 32,
      "high": 256},  },
  {"type": "float",
    "attributes": {"name": "lr",
      "low": 5e-3,
      "high": 5e-1,
      "log": true}}
]

今回の例では学習率とドロップアウトの比率が最適化の対象です. それぞれについて,値の上限・下限を設定します. 学習率は対数スケールの分布からサンプリングするため, "log": trueとしていることに注意してください.

最適化バッチは次のようなシェルスクリプトを実行します.

  • optimize
#!/bin/bashexport N_TRIALS=${N_TRIALS:-20}# Optuna の試行回数を制御するexport TIMEOUT=${TIMEOUT:-7200}# # 一定時間が経過したら最適化を終了する(単位は秒): 60*60*2 => 2hexport TIMESTAMP=${TIMESTAMP:-`date '+%Y-%m-%d'`}export OPTUNA_STORAGE=${OPTUNA_STORAGE:-mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST_NAME/$DB_NAME}export OPTUNA_STUDY_NAME=${OPTUNA_STUDY_NAME:-nerman-$TIMESTAMP}# ハイパーパラメータの最適化
allennlp tune \
  config/ner.jsonnet \
  config/hparam.json \--serialization-dir result/hpo \--include-package nerman \--metrics best_validation_f1-measure-overall \--study-name$OPTUNA_STUDY_NAME\--storage$OPTUNA_STORAGE\--direction maximize \--n-trials$N_TRIALS\--skip-if-exists\--timeout$TIMEOUT

このコマンドを複数のインスタンスで実行することで,ハイパーパラメータの分散最適化が実行できます. オプション --skip-if-existsを指定することで,複数のインスタンスの間で最適化の途中経過を共有しています. Optuna は通常実行のたびに新しく実験環境(studyと呼ばれます)を作成し,ハイパーパラメータの探索を行います. このとき,すでにストレージに同名の study が存在する場合はエラーになります. しかし, --skip-if-existsを有効にすると,ストレージに同名の study がある場合は当該の study を読み込み,途中から探索を再開します. この仕組みによって,複数のインスタンスで --skip-if-existsを有効にして探索を開始することでだけで study を共有した最適化が行われます. 上記のスクリプトによって,最適化バッチは与えられた時間(--timeoutで設定されている値 = 2 時間)に最大 20 回探索を実行します.

このように, Optuna のリモートストレージ機能によって,複数のインスタンスで同じコマンドを実行するだけで分散最適化が実現できました! Optuna の分散ハイパーパラメータ最適化の詳しい仕組み,あるいはより高度な使い方については Optuna 開発者の 解説資料が参考になるので, 興味のある方は合わせてご参照ください.

モデルの再学習

最後に,最適化されたハイパーパラメータを用いてモデルを再学習します. 再学習バッチは以下のようなシェルスクリプトで実行します.

  • retrain
#!/bin/bashexport TIMESTAMP=${TIMESTAMP:-`date '+%Y-%m-%d'`}export OPTUNA_STORAGE=${OPTUNA_STORAGE:-mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST_NAME/$DB_NAME}# 最適化されたハイパーパラメータを用いたモデルの再学習
allennlp retrain \
  config/ner.jsonnet \--include-package nerman \--include-package allennlp_models \--serialization-dir result \--study-name$OPTUNA_STUDY_NAME\--storage$OPTUNA_STORAGE# モデルとメトリクスのアップロード
aws s3 cp result/model.tar.gz s3://xxx/nerman/model/$TIMESTAMP/model.tar.gz
aws s3 cp result/metrics.json s3://xxx/nerman/model/$TIMESTAMP/metrics.json

このシェルスクリプトでは allennlp-optunaが提供する retrainコマンドを利用しています. allennlp retrainコマンドはストレージから最適化結果を取得し,得られたハイパーパラメータを AllenNLP に渡してモデルの学習を行ってくれます. tuneコマンド同様, retrainコマンドも trainコマンドとほぼ同じインターフェースを提供していることがわかります.

再学習したモデルのメトリクスを以下に示します.

  • metrics.json
{"best_epoch": 2,
  "peak_worker_0_memory_MB": 475.304,
  "training_duration": "0:45:46.205781",
  "training_start_epoch": 0,
  "training_epochs": 19,
  "epoch": 19,
  "training_accuracy": 0.9903080859981059,
  "training_accuracy3": 0.9904289830542626,
  "training_precision-overall": 0.9844266427651112,
  "training_recall-overall": 0.9843714989917096,
  "training_f1-measure-overall": 0.9843990701061036,
  "training_loss": 3.0297666011196327,
  "training_reg_loss": 0.0,
  "training_worker_0_memory_MB": 475.304,
  "validation_accuracy": 0.9096327319587629,
  "validation_accuracy3": 0.911243556701031,
  "validation_precision-overall": 0.884530630233583,
  "validation_recall-overall": 0.8787215411558669,
  "validation_f1-measure-overall": 0.8816165165824231,
  "validation_loss": 61.33201414346695,
  "validation_reg_loss": 0.0,
  "best_validation_accuracy": 0.9028672680412371,
  "best_validation_accuracy3": 0.9048002577319587,
  "best_validation_precision-overall": 0.8804444444444445,
  "best_validation_recall-overall": 0.867338003502627,
  "best_validation_f1-measure-overall": 0.873842082046708,
  "best_validation_loss": 38.57948366800944,
  "best_validation_reg_loss": 0.0,
  "test_accuracy": 0.8887303851640513,
  "test_accuracy3": 0.8904739261372642,
  "test_precision-overall": 0.8570790531487271,
  "test_recall-overall": 0.8632478632478633,
  "test_f1-measure-overall": 0.8601523980277404,
  "test_loss": 44.22851959539919}

モデルの学習の章で学習されたモデルと比較して,テストデータでの F値(test_f1-measure-overall)が 82.7から 86.0となり, 3.3ポイント性能が向上しました. ハイパーパラメータの探索空間をアバウトに定めて Optuna に最適化をしてもらえば十分な性能を発揮するハイパーパラメータが得られます.便利です.

Optuna はハイパーパラメータを最適化するだけでなく, 最適化途中のメトリクスの推移やハイパーパラメータの重要度などを可視化する機能, 最適化結果を pandas DataFrame で出力する機能をはじめとする強力な実験管理機能を提供しています. より詳しく AllenNLP と Optuna の使い方を学びたい方は AllenNLP の公式ガイドなども合わせて読んでみてください.

まとめ

本稿では AllenNLP と Optuna を用いて構築した固有表現抽出システム nerman について紹介しました. nerman は AllenNLP を用いたモデル学習・実データ適用, Amazon Aurora を活用したデータ投入の手間の削減, および Optuna を活用したスケーラブルなハイパーパラメータ探索を実現しています. AllenNLP と Optuna を用いた機械学習システムの一例として,読んでくださった皆さんの参考になればうれしいです.

クックパッドでは自然言語処理の技術で毎日の料理を楽しくする仲間を募集しています. 実現したい価値のため,データセットの構築から本気で取り組みたいと考えている方にはとても楽しめる環境だと思います. 興味をもってくださった方はぜひご応募ください! クックパッドの {R&D, サービス開発現場} での自然言語処理についてカジュアルに話を聞きたいと思ってくださった方は @himktまでお気軽にご連絡ください.

2020年のクックパッドAndroidアプリのアーキテクチャ事情

$
0
0

こんにちは、モバイル基盤部の加藤です。普段はモバイルアプリの基盤技術の整備や品質管理の業務に携わっています。 今回はクックパッドAndroidアプリ(以後クックパッドアプリ)の2020年時点でのアーキテクチャの紹介をしたいと思います。

アーキテクチャ導入以前のクックパッドアプリ

2017年以前クックパッドアプリにはアーキテクチャと呼べるようなものが存在していませんでした。大まかに API 通信や DB 操作等のデータ取得箇所を分離し、複雑なロジックを持つ場合は Manager, Util 等の強いオブジェクトが生成されていましたが、それ以外は Activity / Fragment に処理を直接記述することがほとんどでした。

そういった状況の中で今後もアプリを継続的に開発可能にすることを目的にアーキテクチャの導入が始まりました。クックパッドアプリでは iOS/Android 両プラットフォームで VIPER アーキテクチャを採用し、現在に至ります。

VIPER アーキテクチャ

クックパッドアプリで VIPER アーキテクチャを選定した理由を説明する前に、簡単に VIPER アーキテクチャを紹介します。

VIPER は View, Interactor, Presenter, Entity, Routing の頭文字を並べたもので、アーキテクチャはこれらの要素と Contract (契約)を元に構成されます。クックパッドアプリでは VIPER の要素を画面(Activity / Fragment)ごとにまとめ、VIPER の1かたまりを シーン(Scnene)と読んでいます。 これらの要素は大まかにそれぞれ以下のような責務を持ちます(フローに合わせて順序を変えています)。

  • View
    • Entityを描画する。実装クラス(Activity / Fragment)は UI を更新し、UI操作をもとに Presenter を呼び出す。
  • Presenter
    • Presentation Logic の起点を示す。実装クラスは Interactor, Routing を呼び出してPresentation Logic を実装する 。
  • Interactor
    • Presentation Logicを実現する為のBusiness Logicを示す。実装クラスは Presenter からリクエストを受け、ビジネスロジックを処理し、結果を Presenter に返す。
  • Routing
    • 発生しうる画面遷移を示す。実装クラスは Presenter からリクエストを受け画面遷移を行う。
    • 一部の記事では Routerとなっていますが、社内では Routing と読んでいます。
  • Entity
    • VIPER シーン中で利用されるデータそのもの。
  • Contract
    • 上記の要素を内容を定義する VIPER の核。

例えばレシピを描画するような画面の場合、以下のように Contract を定義し VIPER を構築します。

interface RecipeContract {
    interface View {
        fun renderRecipe(recipe: Recipe)
    }

    interface Presenter {
        fun onRecipeRequested(recipeId: Long)
        fun onNavigateNextRecipe(recipeId: Long)
    }

    interface Interactor {
        fun fetchRecipe(recipeId: Long): Single<Recipe>
    }

    interface Routing {
        fun navigateNextRecipe(recipeId: Long)
    }

    dataclass Recipe(
        id: Long,
        title: String
    )
}

さらに詳しい内容については https://www.objc.io/issues/13-architecture/viper/等の他記事を参照してください。 クックパッドアプリではこの VIPER を少し拡張して利用しています。具体的に拡張した箇所については後述します。

また先日サマーインターンシップでクックパッドの VIPER を題材にした技術講義を行ったので、より詳しい実装についてはこちらを参照してください。

スライド: https://speakerdeck.com/ksfee684/cookpad-summer-internship-2020-android

リポジトリ: https://github.com/cookpad/cookpad-internship-2020-summer-android

選定理由

クックパッドアプリで VIPER を採用した理由は主に3つありました。

5年先を見据えて選定

Android というプラットフォームは常に進化を続けています。プラットフォームの進化はアーキテクチャにも大きく関わり、新たな要素が使いづらいようなアーキテクチャでは継続的に開発を行うことは難しいです。実際にアーキテクチャ選定時の2017年から今までで Jetpack Compose や Kotlin Coroutines 等、Android アプリ開発において新たな要素が登場しています。こういった新たな要素を吸収することが可能であり長期的に開発を継続することが可能なアーキテクチャ、具体的には 5年を見据えて選定を行いました。

VIPER アーキテクチャが5年耐えると判断した根拠は後述の2つの要素が中心となっています。

Contract による制約

VIPER は上述したように VIPER の各要素の内容を Contractとして定義し、それに基づいて実装します。この Contract による制約は他のアーキテクチャではほとんど見られない要素であり、各要素の責務とその内容を可視化し、非常に見通しのよいコードを実現できます。

またプラットフォームの進化に合わせて VIPER の概念を拡張する場合には、この Contract を拡張すればよく、Contract でいかに定義するかを考えながらチームで議論することで、よりよいアーキテクチャを育てていける非常に拡張性の高いアーキテクチャだと判断しました。

View を中心としたイベントフロー

VIPER は View(UI) のイベントトリガーを中心にフローが構成されています。View を中心にしてフローを構築する場合、ユーザの操作仕様を直接反映するようにコードを実装する必要があります。そこで VIPER アーキテクチャに合わないような実装が必要となった場合、ユーザ体験を損ねる状態になっていると判断ができることを期待し、VIPER を選択しました。

実際にアーキテクチャに適合しないような無理のある実装があった場合には、実装やそもそもの機能仕様に問題が無いかを考えるきっかけとなっており、他のアーキテクチャではなかなか実現できなかったことだと捉えています。

2020年のクックパッドアプリのアーキテクチャ

VIPER アーキテクチャを拡張させながらクックパッドアプリに最適なアーキテクチャを今も模索しています。現在のクックパッドアプリのアーキテクチャは大雑把に以下の図のようになっていますが、その中でもアーキテクチャ導入時点からクックパッドアプリで導入した内容についていくつか紹介します。

f:id:ksfee:20201116204359p:plain

Rx によるデータフロー

VIPER の概要を説明した際に記述したように、Presenter からリクエストをうけた Interactor はデータを Presenter に返す必要があります。クックパッドではこの処理を Rx を利用してフローを構築しており、Interactor からは Observable が返ります。Presenter は受け取った Observable を subscribe し、そこで流れる Entity を View に受け渡し、UI の更新を促します。後述する Interactor から先の Domain レイヤーでも同様に Rx を利用してフローが構築されています。

最近では一部の実装で Kotlin Coroutines も利用されていますが、まだ Rx から乗り換えるという判断までは至っていません。今後 Kotlin Coroutines / Flow 等の Jetpack コンポーネントでのサポートが拡大した際には乗り換えるかもしれません(Presenter, Interactor 間のやり取りは非常に簡素なものが多いため、Coroutines への乗り換えも比較的簡単に行えるようになっています)。

Domain レイヤー

VIPER から下、具体的には Interactor から下のレイヤーについて説明します。

Interactor は Presenter からリクエストを受けた際、必要なデータを集め Presenter に返します。この時必要なデータを API や DB などから取得しますが、クックパッドアプリではここを Domain レイヤーとしてレイヤー構造を築いています。レイヤーは DataSource, DataStore, UseCase の3つからなり、それぞれ以下のように役割を分けています。

  • DataSource
    • API / DB / メモリからデータの操作を行う
    • 例: API の CRUD 操作
  • DataStore
    • 同じデータに対して複数の DataSource を参照する場合、それらの操作を抽象化して操作を行う
    • 例: API とインメモリキャッシュの操作を抽象化
  • UseCase
    • 共通化したいビジネスロジックを Interactor から切り出したもの
    • 例: 複雑な条件のダイアログ表示の判定

Interactor は DataSource, DataStore, UseCase からそれぞれ必要なデータを取得し、ビジネスロジックを構築します。Domain レイヤーとVIPERレイヤーで世界を分断することで、互いに及ぼす影響を最小限に抑えることでできるよう、Domain レイヤーで扱うデータ型と各 VIPER シーンで利用するデータ型(Entity) は異なっており、Interactor で VIPER の Entity への置き換えが行われています。

Paging の追加

2018年に発表された AAC の1つである Paging ライブラリはページング処理を RecyclerView で扱う際に非常に便利です。クックパッドアプリでもこの Paging ライブラリによるページネーションを実装していますが、Paging の DataSource (以降 PagingDataSource)をどのように実装するかが議論になりました。

PagingDataSource から返る PagedList は直接 Adapter に読み込むため、VIPER では PagedList で扱うオブジェクトは Entity である必要があります。通常であればこのような変換は Interactor で行いますが、Interactor は Presenter からのみ呼び出すことにしており PagingDataSource のどこで変換を行うかが問題になりました。

そこで Domain レイヤーから返るオブジェクトの変換と周辺ロジックをまとめて Pagingという新たな VIPER の要素として定義し、ページング処理が必要となる画面はそれほど多くないため必要な画面のみ Paging を別途用意する方針としました。

今後の課題

現在のアーキテクチャは決して完璧なものではなく、開発を続けていくなかでいくつも課題は出てきます。その中でも現在進行系で直面している課題について少し紹介します。

ViewModel の位置付け

現在クックパッドアプリでは AAC の ViewModel は、Activity / Fragment でのみ利用する状態管理オブジェクトとして利用されています。先程紹介した Paging を持つ ViewModel も存在しますが、状態管理オブジェクト以上の責務を持つことはありません。

しかしただの状態管理オブジェクトとしては Android 開発において存在が大きく開発の混乱の元となってしまっており、現在アーキテクチャへ組み込む方法を検討しています。今のところは View を廃止して ViewModel に実装を寄せ、Paging を ViewModel に取り込むという意見が強く、今後議論を重ねてさらにアーキテクチャを拡張する予定です。

ボイラープレートの多さ

Contract を定義する事でコードの見通しが良くなるというメリットはありますが、その一方でVIPER は構成する要素が多く、新たに VIPER シーンを構築するために多くのファイル及び実装が必要となります。

この課題に対して AndroidStudio の LiveTemplate でファイル生成の簡略化を試みましたが、コストがかかるのは実装であり、あまりコストの軽減にはつながらずうまく行きませんでした。これはユニットテストにおいても同様のことがあり、こちらについては自動生成を行うことでコストの軽減につなげる余地がありそうなため今後検討していきたいと考えています。

まとめ

クックパッドでは今回紹介したようなアーキテクチャの改善を開発に関わる全てのメンバーで共有しながら進めています。こういった開発スタイルに興味がある Android エンジニアの方はぜひご連絡ください。

https://info.cookpad.com/careers/

Ruby に Software Transactional Memory (STM) を入れようと思った話

$
0
0

技術部でRubyインタプリタの開発をしている笹田です。コロナの影響で、リモート勤務などに移行し、新しい生活スタイルを満喫されている方々がたくさんいらっしゃるんじゃないかと思います。ただ、私は以前から自主的に自宅勤務することが多かったので、正直生活がぜんぜん変わっていません。

さて、家で私が何をしているかというと、Ruby 3の準備です。その中でも、数年取り組んできた Ruby で並列処理をするための仕組みである Ractor の開発をしています(以前は Guild というコードネームで呼んでいました)。Ractor という名前は、Ruby の Actor みたいな意味で付けました。Erlang とか Elixir で有名な Actor model というアレです。厳密には、Actor model でよく言われる特性をすべて備えているわけではないのですが、並列で動く Ractor を複数作ることで並列計算機上で気楽に並列処理を行うことができます(少なくとも、それができることを目標にしています)。

Ractor は、意図的に Ractor 間でメモリの共有を排除するように設計されています。しかし、どうしても共有したいなぁ、というときのために、Software Transactional Memory (STM) という仕組みを入れようと思いました。STM を使うと、DB のトランザクションのように、何か競合したらなかったことにしてやりなおすメモリを作ることができます。

本稿では、その背景と、実際にどう作ったか、そしてどう試すのか、などについてご紹介します。

Ractor のちょっとしたご紹介

本題に入る前に、本稿を読むために必要になる、Ractor についての基礎知識を少しご紹介します。 しっかりしたリファレンスは ruby/ractor.md at master · ruby/rubyにありますので、よかったら参照してみてください。

Ractor を作って並列計算する

Ractor は、複数作ってそれらが並列に動く、ということで、並列計算機上で並列に動かすことができます。

# Ractor を生成する
r = Ractor.new do
  expr # expr は、他の Ractor とは並列に動くend

r.take #=> expr の実行、つまり Ractor 自体の処理が終了を待ち、#   expr の結果を得る

この例では、Ractor.newで新しい Ractor を作り、そこで exprを実行し、その結果を Ractor#takeで受け取る(r.take)、という意味になります。ここでは1つしか作っていませんが、n 個作れば、n 個の Ractor が並行に処理され(Thread と一緒)、それらがシステムで許された並列度で並列に実行されます(Thread と異なる)。

ちなみに生成時に引数を渡すと、それをブロック引数で受け取ることができます。

r = Ractor.new 10do |n|
  p n #=> 10end
r.take

Ractor 間ではオブジェクトは(あんまり)共有されない

Ractor 上ではたいていの Ruby のプログラムを動かすことができます。つまり、上記 exprに、いろんな Ruby の式が書けます。が、Ractor 間でオブジェクトを共有することは、基本的にはできません。

# s から参照される文字列を、新しく作った Ractor と main Ractor で共有する例# エラーになります

s = "hello"
r = Ractor.new do
  s << "world"end

s << "ko1"
p r.take

この例では、sで参照される "Hello"という文字列を、2つのRactor(起動時からあるmain Ractorと、Ractor.newで作る子Ractorの2つ)で共有してしまう例です。それぞれの Ractor で、String#<<で文字を結合、つまり破壊的操作をしようとしています。一般的には、並列処理において、ロックなどで排他制御をしなければならない場面です。

例えば、スレッドが並列に実行されるようなJRubyなどでは、RactorではなくてThreadでこのようなコードを動かすと、Javaレベルのエラーが起きることがあります(手元で連結を何度も繰り返すようにして試してみたら、java.lang.ArrayIndexOutOfBoundsExceptionが出ました)。余談ですが、MRIは、GIL/GVLによって並列に動くことはなく、String#<<の処理中にスレッドの切り替えが起こらないことを保証しているため、問題なく動かすことができます。が、Ruby レベルの処理ではどこで切り替えがおこるかわからないため、やっぱり排他制御ちゃんと考えないと、となります。

というわけで、もしこのようなコードによって、どんなオブジェクトも複数の Ractor から同時にアクセスできるようになってしまうと、Ractor 間での同期が必須になってしまいます。

Ractorでは、Ractor間で文字列などの共有による、排他制御が必要な状況になるのを防ぐために、いろいろな工夫をしてあります。例えば、ブロックの外側のローカル変数を参照するようなブロックを Ractor.newに渡そうとすると、Ractor.newのタイミングでエラーになります。

in `new': can not isolate a Proc because it accesses outer variables (s). (ArgumentError)

こんな感じでオブジェクトを共有できないので、「ロックをちゃんとしなきゃ」といった、難しいスレッドプログラミングに関する問題から解放されます。やったね。

Ractor 間のコミュニケーション

そうは言っても、何か状態を共有したいことはあります。また、複数の Ractor が協調して動くように作る必要もあるでしょう(何かイベントをまったり、イベントが起こるまで別の Ractor を待たせたり)。そこで、Ractor では、メモリを共有するのではなく、オブジェクトをメッセージとしてコピーして送ったり受け取ったりすることで、データを共有します。

Go で言われているらしい "Do not communicate by sharing memory; instead, share memory by communicating."ということですね。Go と異なるのは、Go はいうてもメモリをいじってコミュニケーションできてしまう(メモリを共有しているので)のですが、Ractor ではコピーしちゃうので、そもそも共有ができません。Go は「気をつけようね」というニュアンスですが、Ractor では「絶対にさせないでござる」という感じです。

Ractor 間のコミュニケーションは Ractor#sendRactor.receiveおよび、Ractor.yieldRactor#takeのペアで行います。

r1 = Ractor.new dowhile msg = Ractor.receive
    Ractor.yield msg
  end:finend

r1.send 1
r1.send 2
r1.send 3
r1.send nil
p r.take #=> 1
p r.take #=> 2
p r.take #=> 3
p r.take #=> :fin

この例では、main Ractor が、作成したRactor r1に対して、1, 2, 3, nil という値を Ractor#sendでメッセージとして送っています。

r1 では、Ractor.receiveで send されたメッセージを受け取って(送られるまで待つ)、それをそのまま Ractor.yieldに渡しています。Ractor.yieldは、他の Ractor がそのオブジェクトを Ractor#takeで持っていくまで待ちます。つまり、1, 2, 3 について、Ractor.yield しているわけです。最後、Ractor.yieldは nilを返すので、while 文が止まり、ブロックは :finを返して Ractor は終了します。

main Ractor では、Ractor#takeによって、Ractor.yieldされた 1, 2, 3 を受け取り、表示します。 また、4 回目の Ractor#takeによって、ブロックの返値 :finを取ります。

というのが、コミュニケーションの方法になります。

さて、メッセージとして渡すオブジェクトは毎回コピーするとご紹介しましたが、いくつかの場合、コピーなしで受け渡されます。コピー無しで受け渡されるオブジェクトのことを「共有可能オブジェクト」と呼びます。

共有可能オブジェクトの定義はこちら:

  • 不変オブジェクトは共有される
    • 不変オブジェクトとは、そのオブジェクトが freezeされており、参照するオブジェクトがすべて共有可能であること
    • 例えば、整数オブジェクトや nil とかは、frozen で参照するオブジェクトは無いので共有可能です
  • クラス・モジュールは共有される
  • その他、特別に共有可能に作られたオブジェクト
    • たとえば、Ractor オブジェクトは共有可能
    • 今回ご紹介する Ractor::TVarも共有可能オブジェクト

共有可能オブジェクトを他の Ractor に送るときは、コピーせずにリファレンスだけ送ります(共有されても、おかしなことは起こらないだろうから。もしくは、共有されても、おかしなことが起こらないように特別に設計されているから)。

それから、渡すときにコピー以外にも move が選べますが、ちょっと長くなってきたのでこの辺で。Ractor に関しては、いろんな話があります。再掲になりますが、詳細は ruby/ractor.md at master · ruby/rubyをご参照ください。

Software Transactional Memory (STM)

Ractor ではメッセージのやりとりで共有できるんですが、やっぱり一部はメモリを直接共有したいこともあるかもしれません(ないかもしれません、ちょっとわからない)。そこで、Software Transactional Memory (STM) という仕組みを入れるのはどうかと考え、実装してみました。最新の Ruby で gem でインストールすれば使えるようになっているので、よかったら試してください。

以降は、その STM の話をご紹介します。

STM が必要な背景

ちょっとしたデータを Ractor 間で共有したい例として、例えば、何かプログラム全体で数を数えたい、ってのがあります。さばいたリクエスト数かもしれません。処理したデータの総サイズを数えたいかもしれません。こういう、Ractor 間でちょっとしたデータを共有する方法が、今はありません。強いて言えば、そのデータを管理する専用のRactorを作ることで行うことができます(専用じゃなくてもいいけど、何か管理するやつ)。

counter = Ractor.new do
  cnt = 0while msg = Ractor.receive
    case msg
    in [:increment, n]
      cnt += n
    in [:decrement, n]
      cnt -= n
    in [:value, receiver]
      receiver.send cnt
    endendend

counter << [:increment, 1] # Ractor#send は Ractor#<< という alias を持っています
counter << [:increment, 2]
counter << [:increment, 3]
counter << [:value, Ractor.current]

p Ractor.receive #=> 6

この例では、カウンターを管理するためだけの Ractor を用意してみました。実際、Actor モデルの言語では、こんな感じで作ることが多いんじゃないかと思います。そして、こういうのを簡単につくるためのライブラリが用意されています。例えば Elixir なんかだと Agent(Agent — Elixir v1.11.2。日本語での詳細は、12月に出版される プログラミングElixir(第2版) | Ohmshaとかがお勧めですよ!)とかですかね。複数の Ractor 間で安全に共有できる、変更可能な状態を作るときは、こんな感じにします。

が、もうちょっと楽に書きたいなぁ、という気分があります。「カウンターごとに Ractor 作るんですか?」って感じです(まだ、Ractor の生成は遅いのです。Thread と同程度に)(べつに、カウンターごとに作らないで、すべてのカウンターを管理する Ractor を作る、みたいな方法でできんこともないです。単純なカウンターの集合だけなら)。

そこで、メモリを共有する仕組みを用意するのはどうでしょうか。cnt = Counter.new(0)としたら、cntは複数の Ractor で共有できる、みたいな感じです。ただ、値の increment でも、ロックが必要です(Thread-safe の説明の例でよくあるアレです)。

じゃあ、ロックしないとアクセスできないようなインターフェースにすると、どうでしょうか。ロックを持たないでアクセスするのを禁止すれば、うっかりロックを忘れてしまうこともなさそうです(エラーになって気づく)。ちゃんとロックをするようにすれば、Ractor 間で排他制御されるので、まずい問題が起こらない気がします。

やってみましょう。

cnt = Counter.new(0)

r = Ractor.new cnt do
  cnt.lock{
    cnt.value += 1
  }
end

cnt.lock{ cnt.value += 2 }

r.take
p cnt.lock{ cnt.value } #=> 3

良さそうです!

さて、ここでカウンタを 2 個にしてみましょう。そして、2つのカウンタは同時に動く必要があるとしましょう。そうですね、c2 = c1 * 2 となるような関係になるという特殊なカウンタです。ロックをうまく使えば大丈夫ですかね?

c1 = Counter.new(0)
c2 = Counter.new(0)

r1 = Ractor.new do
  c2.lock do
    c1.lock do
      c1.value += 2
      c2.value = c1.value * 2endendend

c1.lock do
  c2.lock do
    c1.value += 1
    c2.value = c1.value * 2endend#...?

こんな感じでしょうか。

実は、このプログラムはデッドロックしてしまいます。というのも、main Ractor は c1 -> c2 の順でロックをしていきます。r1 は、c2 -> c1 の順です。このとき、運悪く次のような順にロックしていくと、デッドロックしてしまいます。

  • main: c1.lock
  • r2: c2.lock
  • main: c2.lock ->できないので待つ
  • r2: c1.lock ->できないので待つ

こうならないためには、ロックの順番を、複数の Ractor でそろえる(c1->c2とか)必要があります。

とか考えていくと、ロックのアプローチはいまいちです。うっかり順番間違えるとか、普通にありそうじゃないですか。

STM のよさ

そこで使えそうなのが STM です。DB なんかで Transaction の話はよくご存じの方は多いと思いますが、これをメモリに適用したのが STM で、2010 年くらいに言語処理系界隈で研究が盛んでした。でも、今ではあんまり聞かないですねえ。言語についている STM としては、Clojure とか Haskell (Concurrent Haskell) が有名だと思います。Erlang/Elixir における mnesia も STM と Wikipedia には書いてありました、があれは DB だよなぁ。

STM は、DB のトランザクション(楽観的ロック)と同じように、とりあえずなんか読み書きして、あとで、「あ、別の Ractor とアクセスが被った!」となったらロールバックしてしまいます。簡単ですね。

ロック(悲観的ロック)と何が違うかというと、さっきの順序の問題が現れないんですよね。そもそも「ここからトランザクションです」のように指定するので、ロックの順序がない。この性質を、composable であるといいます。複数の排他制御が必要とする操作を、まとめても問題ないという良い性質です。

STM のデメリットは、操作が衝突してロールバックが多発するとむっちゃ遅くなっちゃうんですよね。この辺はフロー制御をなんとかする、みたいな研究がいろいろあります。たとえば、衝突しまくってそうなら、実行可能なスレッド(今回は Ractor)を絞っちゃうとか。

まぁあと、楽観ロックなので、みんなが read しかしないような場合は、どの処理も並列に実行可能なので速そうです。それから、進行性保証的な話もあったりして、いろいろメリットがあります。

どんな STM を作るのか

STM にもいろいろな流派があります。

  • そもそも、Software じゃない Hardware でやる HTM って分野があります。CPU がサポートしたりしています。が、あんまり最近聞かないですねえ。
  • メモリ操作を全部 transaction の対象にしてしまうという STM があります。C++ とかで多いですね。X10 という昔ちょっとかかわってた言語では、言語組み込みにこういう STM がありました。
  • 特定のメモリを transaction 対象にするという STM があります。特定のメモリしか扱わないので、それ以外のメモリはロールバックしてももとに戻りません。
  • 操作の衝突の定義もいろいろあります。

Ruby の場合は、全部ロールバックできないので(作るのスゴイ大変)、一部のメモリだけを対象にする、というようにします。具体的には、Ractor::TVar.newTVarは Transactional Variable の略)が保持する値のみ、transaction で何か問題があったらロールバックします。そして、Transaction の範囲は Ractor.atomicallyに渡すブロック中ということにします。

というインターフェースが、実は Class: Concurrent::TVar — Concurrent Rubyにあったんですよね。Concurrent Ruby は、Thread を対象にしています。このインターフェース踏襲し、Ractor でも使えるようにしたのが Ractor::TVarです。

先ほどのカウンターの例だと、こんな感じで書けるようにするといいでしょう。

c1 = Ractor::TVar.new(0)
c2 = Ractor::TVar.new(0)

r1 = Ractor.new c1, c2 do |c1, c2|
  # 外側のローカル変数は見えないから引数で渡すRactor.atomically do
    c1.value += 2
    c2.value = c1.value * 2endendRactor.atomically do
  c1.value += 1
  c2.value = c1.value * 2end

main Ractor と子 Ractor で、変更が競合してしまった場合は、どちらかのブロックが再実行されます。先に紹介した通り、ロールバックされるのは Ractor::TVar#valueの値だけなので、例えばインスタンス変数への代入などは残ってしまいます。IO 処理なんかも取り返しがつきません。そのため、Ractor.atomicallyに渡すブロックは、できるだけシンプルにする必要があります。

Ractor.atomicallyは自由にネストすることができます。この性質が、composable である、という話です(ロックですと、ロックの順番に気を付けないといけませんでした)。

TVar は共有可能オブジェクトなので、他の Ractor に渡すことができます。TVar に設定できる値は、他の Ractor から見えることになるので、共有可能オブジェクトに制限されます。たとえば、mutable な文字列などは渡せません。

トランザクションは、次のようなプロセスで管理されます。

  • (1) トランザクションの生成・開始
  • (2) TVar の読み書き
  • (3) トランザクションのコミット

このとき、(2) および (3) のタイミングで競合を検知し、必要ならロールバックを行って (1) に戻ります。

  • (a) (2) において、read した値がすでに他の Ractor に書き換えられていた→ロールバック
  • (b) (3) において、read した値が、すでに他の Ractor で書き換えられていた
  • (c) (3) において、write しようと思ったら、すでに他の Ractor に書き換えられていた

(c) は直観的だと思いますが(git で push しようとしたら、先に他の人が変更していて書き換えられなかった、みたいな話です)、(a), (b) はちょっと意外ではないでしょうか。つまり、書き換えの行わないトランザクションでも、ロールバックは発生し得る、という話です。

この読み込みだけでロールバックしてしまう、という挙動は、2つ以上の値を読み込むときに重要になります。tv1.value, tv2.value 2値を取り出すとき、tv1を読み込んだ後で、他の Ractor が tv2を書き込み、それを main Ractor で読み込んだ時、tv1tv2が一貫性を持たない状態である可能性が出てきます。そのため、(b), (c) のタイミングで、適切な tv1, tv2を読み込めているかチェックする、という話になります。まだちょっとわかりづらいですね。

例えば tv1に配列のインデックス、tv2に配列が格納されているとき、tv1のインデックスを読み込んだ後、なんやかんやがあって他の Ractor で tv2の配列が切り詰められたとします。このとき、すでに読み込んだインデックスは tv2の配列の長さを超えているかもしれません。問題です。

これはつまり、tv1tv2の一貫性が取れていない、という状況です。TVar では、このようなことが起こらないように、上記 (a)~(c) をトランザクションのロールバックタイミングとしています。

さて、1つの値だけを読みだすとき、Ractor.atomicallyが必要かどうかは議論が必要なところです(例えば、p Ractor.atomically{ c1.value }と書かなければならないのか、p c1.valueと書くだけでよいのか)。というのも、この処理は複数読み込みもせず、write もないので、一貫性制御が要らないような気がするからです。実際、Clojure の STM や、Concurrent-ruby の TVar は、トランザクション内でなくても値を読みだすことだけはできるようになっています。

我々は、このときも Ractor.atomicallyを必須としました。というのも、c1.value + c2.valueのように、2つ以上の値を読み込むために、うっかり Ractor.atomicallyを書き忘れそうな気がしたからです。

あと、カウンタとして使おうとすると、increment 処理をよくやると思うので、Ractor.atomically{ tv.value += 1 }のショートカットである Ractor::TVar#increment(n=1)を用意しています。

STM の限界

composable に記述できる STM ですが、たとえば同一トランザクション内で処理しなければならないのに、複数のトランザクションに分けてしまう、という問題はいかんともしがたいです(意図的かもしれないのでエラーにできません)。

c1 = Ractor::TVar.new(0)
c2 = Ractor::TVar.new(0)

r1 = Ractor.new c1, c2 do |c1, c2|
  Ractor.atomically do
    c1.value += 2end# 本当はここで transaction を切ってはいけない!!Ractor.atomically do
    c2.value = c1.value * 2endend

c1 を変更後、c2 を変更する前に他の Ractor が c1, c2 を観測すると、c2 = c1 * 2 という関係が崩れている(一貫性がない)瞬間を目撃できるのです。

ちなみに、何かのカウンタなら多少の誤差は許されることもあるかもしれませんが、例えば STM でよく例に出てくる銀行口座の残高の移動というタスクにおいては大問題になってしまうかもしれません。例えば、A さんから B さんに n 円送金するとき、A さんから残高を減らして、B さんに残高を追加する、という処理になります。このとき、Aさんから残高を減らしたタイミングで他の Ractor から A, B 各氏の口座が観測され、世界から n 円消える、という瞬間を目撃していまいます。それはまずい、あってはならないことです。

(STM 自体はこのように、口座残高のような、同時に複数のデータをいっきに変える(一貫性のない状態を、ほかから見えないようにする)ときに使うことが多いと思います)

さて、この例では恣意的で、こんなミスは起こさないような気がするのですが、例えば、

defadd_2 tv
  Ractor.atomically{ tv.value += 2 }
enddefset_twice tv1, tv2
  Ractor.atomically{ tv2.value = tv1.value * 2}
end

のように定義していれば、add_2(c1); set_twice(c1, c2)のように記述してしまう可能性は十分あります。

どーにかなんないか考えてみたのですが、トランザクションでの read/write のログを取れるようにしておいて、問題が発覚したら、そのログを見つめるなり自動解析ツールなりを作って、トランザクションが分かれていないかチェックする、みたいなことくらいしかできないかなぁ、と思っています。良いアイディアをご存じでしたら教えてください。

そういえば、TVarという名前は concurrent-ruby からとりましたが、T って色々ありますよね。なので、TxVar とかもう少し冗長でもいいかなぁ、などという気分があります。どうしよっかな。

STM の実装

あんまり中身の話をしてもしょうがないような気がしますが、こんなアルゴリズムで実現しています。

  • (1) トランザクション開始時に、現在の時刻 T を取得する
  • (2) TVar の読み込み時 / 書き込み
    • 書き込み時には、TVar には書かず、Ractor local なトランザクションログに書き込む
    • 読み込み時には、トランザクションログにその TVar の書き込み履歴があれば、Ractor local なその最新の値を返し、なければ読み込む。このとき、TVar に記録された最終書き込み時間と、開始時に記録した T を比較し、T が古ければればロールバック。新しければ読み込み完了だが、ついでにトランザクションログに載せておく(次の read 時は、TVar を読む必要がなくなる)
  • (3) コミット
    • コミット時、トランザクションログに記録された TVar たちについて、最終書き込み時間が T より新しくないことを確認
    • 時刻を1進める。この時の時刻を T'とする。
    • 書き込みが必要な TVar には、変更を反映。このとき、その TVar の最終書き込み時間が T'となる。

あんまり難しそうじゃないんですが、なんかこれで動くみたいです。論文的には、TL2 という方式(をちょっと弄っている)なんだそうです。

ちなみに STM を作った本当の経緯。以前から STM が欲しいと思っていました。そこで、9月にとった夏休みに STM の実現方法についてのアイディアが思いついたので、実装したら動いたヤッター、俺スゴイ、となったのです。で、調べてみたら、すでに誰かが提案していて、しかも自分が考慮していなかった箇所とかあったり、名前までついていたという。新しいことを考えるのは大変ですね(いや、別に今回は新しいことは目指してはいなかったんですが)。

ロールバックは、(2) の時は単純に例外を投げるようにしています。(3) のときは、コミットする関数で成功失敗を返し、失敗していたら最初からやりなおす、という実装にしています。

なお、Ractor と言ってますが、Thread 間でも同じように TVar が使えます。なので、Ractor ごとにトランザクションログをもつのではなく、Thread ごとに持つようにしています。この辺で Thread::TVar にするのか Ractor::TVar にするのか悩んだんですが、結局 Ractor::TVar がいいかなぁ、と思い至りました。

Ruby 3.0 における STM

この提案を、Ruby 3.0 の機能として提案してみたのですが、良さがわからん、ということで reject されました(Feature #17261: Software transactional memory (STM) for Threads and Ractors - Ruby master - Ruby Issue Tracking System)。残念。まぁ、確かに Actor っぽい仕組みでメモリを分けたのに、また別の仕組みを入れるのか、という気はしないでもないです。

ただ、実際これないとプログラム書きづらいと思うんだよなー。どうかなー。

ということで、gem を用意しました。

ractor-tvar に Ractor::TVarだけ入っていて、ractor gem は、ractor-tvar への依存があるため、gem install ractorすれば入ります。ractor gem のほうは、今は空ですが、Ractor に関するいろいろなユーティリティーを入れられるようにしようと思っています。

require 'ractor/tvar'で使えるようになります。なお、当然ですが、開発中の Ruby 3.0 上でしか動かせません(そもそも拡張ライブラリがビルドできません)。もう入っていますか?

最初は本体組み込みを前提に STM を実装していたのですが、gem に切り出すために変更が入り、性能が若干落ちています。また、コンテンションマネージメントをまじめにやっていないため、ロールバックが多発するようなシチュエーション(つまり、ある TVar への書き込みが激しいとき)では性能が凄く下がります。逐次実行時より下がります。

性能評価はまじめにやる時間がないのでスキップしますが、いくらかの評価が先のチケット(Feature #17261: Software transactional memory (STM) for Threads and Ractors - Ruby master - Ruby Issue Tracking System)にありますのでご参照ください。

おわりに

本稿では、Ruby に STM を入れたいと思った話と、それからその仕様と実装を軽くご紹介しました。Ruby 3.0 には入らないのですが、gem で使えるので、お試しいただけると良いかもしれません。

STM については、いろいろ偉そうに書きましたが、だいたいこの書籍の受け売りです: Amazon | Transactional Memory, 2nd Edition (Synthesis Lectures on Computer Architecture) | Harris, Tim, Larus, James, Rajwar, Ravi, Hill, Mark | Network Administration

Ruby に STM 入ると、あまり注目されない STM もまた盛り上がる気がします。性能チューニングや記事中に書いたデバッグ支援など、いろいろやることがあるので、興味ある言語処理系の研究者の方とか、共同研究とかどうでしょうか。ちゃんとやれば、学生さんの卒論修論くらいにはなるんじゃないかと思います。

さて、Ruby 3.0 は、そんなわけで Ractor も入るし他にもいろいろ入るし、夢いっぱい楽しさイッパイのリリースです。多分。そんなすてきな Ruby 3.0 をいち早くご紹介するイベントを開催するので、年の瀬ですが、もしよかったらご参加ください。

Ruby 3.0 release event - connpass

では、12月の Ruby 3.0 リリースをお楽しみに!

Viewing all 734 articles
Browse latest View live