スプリングインターンシップをオンラインで開催致します!

こんにちは、レシピ事業開発部の赤松( @ukstudio )です。

毎年恒例となっている春のインターンシップですが、今年も開催致します!新型コロナウィルスの影響をふまえ、オンラインで実施することに致しました。当日はお手持ちのマシンからZoomで参加頂く予定です。

大規模トラフィックを支える技術

今年はエンジニア向けに1コース用意しました。題しまして「春ダッシュスペシャル 大規模トラフィックをさばくアプリケーションのパフォーマンスチューニングを学ぼう!」です!以下の日程・場所で開催致します。

  • 開催日: 4月29日(水・祝) 13時〜17時
  • 開催場所: オンライン(Zoom)

クックパッドは現在74カ国/地域・32言語に展開しており、月間の利用者数も約9,300万人にのぼる大規模サービスです。ユーザーからのアクセスだけでもピーク時には秒間数千アクセスにも達します。

このコースではこのような大規模トラフィックを支えるための技術を実践を通して学ぶことができる内容となっています。大規模トラフィックに関する技術の経験は個人で得るにはなかなか難しい部分もあると思いますので、この機会にぜひ体験してみてください。

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

internship.cookpad.com

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

当日は講師がZoomで画面共有をしながら講義を進めることになります。実はこの形式だと、オフラインの時と比べてスクリーンが見やすい、声が聞きやすい部分もあります。

一方でオンライン開催だと質問がしづらいんじゃないか、講義についていけなかったら置いてけぼりになるんじゃないかという不安があるかもしれません。当日はメインで話す講師とは別にTAも用意しております。TAがSlackでのサポートや、場合によっては1対1でのZoom MTGでサポートします。

実際に既にオンラインでの勉強会やイベントを弊社で行なっていますが、スプリングインターンもオンラインでできる!という手応えを感じています。ぜひオンラインというところに気後れせずに応募して頂けたらなと思います。

デザイナー向け UIデザインとサービス開発

デザイナー向けにも1コース用意しています。こちらは「クックパッド流!UIデザインをFigmaで体験しよう」とFigmaを用いたUIデザインとサービス開発の基礎を体験することができるコースです。デザイナーの方はぜひこちらにお申し込みください。詳細については後日noteの方に記事が公開される予定なので、そちらを見てもらえればと思います。

note.com

  • 開催日: 4月25日(土) 13時〜17時
  • 開催場所: オンライン(Zoom)

応募締切は4月6日

エンジニア向けもデザイナー向けも応募の締め切りは4月6日までとなっております。みなさまのご応募お待ちしております!

internship.cookpad.com

iOSアプリのメモリリークを発見、改善する技術

こんにちは。事業開発部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。

先日、アプリケーションが特定の条件で意図せぬ状態に陥り、アプリケーションが重くなって端末が発熱する、というバグが発見されました。 調査の結果、このバグはメモリリークが原因で発生していました。 この反省を踏まえメモリリークを検知するテストを導入したため、本記事ではその事例を紹介したいと思います。

(本記事ではクックパッドアプリとはiOS版の「クックパッド」アプリのことを指すものとします)

クックパッドアプリにおけるメモリリークの影響

クックパッドアプリはレシピの検索をコア機能としています。 検索は重い処理ですがAPIを通してサーバ上で行われるため、アプリは結果を表示するだけです。そのためメモリを多く必要としません。 これまでにも何度かメモリリークが発生している状況はありましたが、メモリを多く必要としないため多少の無駄があってもアプリの動作に影響がありませんでした。

クックパッドアプリで用いられているクラスの大半は自力で動くようなことはせず、RunLoop等のイベントループによって動作します。 UIKitを使用しているとインスタンスのRunLoopからの除去は自然に実現できるため、メモリリークが起こってもそのインスタンスは止まっていて、無害な状態であることが多いです。

しかしながら、今回は不運にも単独でイベントループを発生させるインスタンスがメモリリークしてしまいました。 本来はそのインスタンスがメモリから解放されたタイミングでイベントループが止まるはずでしたが、メモリから解放されなかったことにより停止されないイベントループが永遠に無駄な処理を続けていました。 その結果、そのインスタンスが多数メモリリークしてしまうとアプリケーションの動作に影響するほど負荷がかかってしまいました。

どのようにしてメモリリークが起こってしまうのか

iOSアプリケーション開発の現場で起こるメモリリークは大抵、循環参照が原因となっています。 循環参照によるメモリリークは参照カウント方式のガベージコレクション環境において発生しうる問題で、SwiftやObjective-Cを使っていると起こりうるものです。(なお、ここでは循環参照がどのような状態であるかの説明は省略します。) クックパッドアプリではRxSwiftというライブラリを多用しているため、クロージャを経由してうっかりメモリリークする形の循環参照を引き起こしてしまうケースが多いです。

よくある循環参照の例

実際にクックパッドアプリで起こっていた循環参照の実装の例を紹介します。

自身が所持するObservableのobserverとして自身が所持されるパターン

ちょっとタイトルがややこしいですが、最もシンプルなタイプのものです。

// ViewController.swift
let stream: PublishSubject<Void>
let disposeBag = DisposeBag()
func bind() {
    stream.subscribe(onNext: {
        self.doSomething() // selfがキャプチャされている
    })
    .disposed(by: disposeBag)
}

RxSwiftでは Observable を購読するとその購読がobserverオブジェクトとして Observable に保持されます。(例の PublishSubjectObservable の一種です。) この例ではobserverはさらに onNext: に渡されているクロージャを保持します。そしてさらにクロージャは self を保持しています。ここで self はこの実装を持つ ViewController クラスであるとします。 その結果、 self → stream → observer → クロージャ → self として循環参照となります。

この循環参照の解決策としては、次のように self を弱参照でキャプチャする方法が挙げられます。

stream.subscribe(onNext: { [weak self] in
    self?.doSomething()
})

暗黙クロージャ渡しパターン

次の例はどうでしょうか。これもたまにある例で、気づくことが難しい循環参照です。

// ViewController.swift
let stream: PublishSubject<Int>
let disposeBag = DisposeBag()
func bind() {
    stream.subscribe(onNext: doSomething) // この場合もselfが強参照されてしまう
        .disposed(by: disposeBag)
}

func doSomething(_ value: Int) { ... }

onNext: にクロージャを使わずに関数を渡すことで、すっきりとした表記になっています。 ところがこの doSomething 、一見関数ポインタを渡しているように見えますが、実際はコンパイラが裏側で self をキャプチャしたクロージャを生成しているため、次のコードと同じ意味になっています。

stream.subscribe(onNext: { self.doSomething($0) })

これは先程と同じパターンの循環参照となります。 対処法としては先程と同じように [weak self] を用いるのが良いでしょう。 あるいは、 doSomething の処理が self に依存していないならばそれをstatic関数にしてしまうという手もあります。(static関数になった場合その関数の実行に self が必要なくなるため、上で紹介したコンパイラが裏側でやる処理が無くなります。)

func bind() {
    stream.subscribe(onNext: Self.doSomething)
        .disposed(by: disposeBag)
}

static func doSomething(_ value: Int) { ... }

複数クラスにまたがって循環しているパターン

// Presenter.swift
class Presenter {
    let value: Observable<Int>
    init(view: View, interactor: Interactor) {
    value = interactor.stream
        .filter { view.isXXX } // ここで view を強参照でキャプチャしている
        .map { ... }
    }
}

// View.swift
class ViewController: View {
    var presenter: Presenter!
    var isXXX: Bool {
        ...
    }
    let disposeBag = DisposeBag()

    func bind(presenter: Presenter) {
        presenter.value.subscribe(...)
            .disposed(by: disposeBag)
        ...
        self.presenter = presenter
    }
}

これは複数のクラスにまたがる例です。 Presenter が生成する value には view がキャプチャされており、その川を view である ViewController クラスが購読します。 つまり ViewController からみて、 self → presenter → value → filterの内部で使われるオブジェクト → クロージャ → view(== self) として参照関係が発生し、循環参照が成立します。

self がクロージャにキャプチャされている場合はなんとなくアンテナが反応しやすいのですが、そうでないケースはうっかり見過ごされやすいです。 これの対応も同様に弱参照を用いることになります。

.filter { [weak view] _ in view?.isXXX == true }

メモリリークを検知するテストの導入

「よくある循環参照の例」を見てわかるように、循環参照はうっかり見逃しやすいため人目のレビューをすり抜けてしまいます。 またコンパイラによって検知することもできません。

そこで、XCTAssertNoLeakを使ってテストを書くことにしました。

github.com

XCTAssertNoLeakは対象のインスタンス内でメモリリークが発生しているかを検知する機能を提供するテスト用ライブラリです。 2019年のtry!Swiftで発表されたライブラリで、メモリリークを検知するテストを書くことができます。

f:id:iceman5499:20200303111804p:plain
XCTAssertNoLeak

https://github.com/tarunon/XCTAssertNoLeak のREADME.mdより

ただ引数にオブジェクトを渡すだけで、簡単にリークしているオブジェクトをリストしてくれる素敵なライブラリです。

XCTAssertNoLeakの動作原理

XCTAssertNoLeakはどのようにしてメモリリークを検知しているのでしょうか。 基本的な戦略としては、インスタンスをweakポインタに格納し、スコープが変化したタイミングでポインタの中身が nil になっているかどうかで判定をしています。

インスタンスが持つプロパティ群に格納された子インスタンスや、そのさらに孫インスタンスを全て確保するためには Mirror が用いられています。

Mirrorを使い全プロパティを探索して参照型の値を全てweakポインタに確保し、スコープを抜けたあとそのweakポインタがちゃんと nil になっているか確認する、という感じです。

テスト記述に関する注意点

XCTAssertNoLeakは非常に簡単に利用できるようになっていますが、仕組み上いくつか気をつけないといけない部分があります。

ローカル変数のスコープに注意する

XCTAssertNoLeak は引数に渡したオブジェクトが検査されますが、ローカル変数にオブジェクトを保持してしまうとそのローカル変数が参照を持つためにテストに用いられるweakポインタが nil にならず、メモリリーク扱いになってしまいます。

// NG
let viewController = MyViewController()
XCTAssertNoLeak(viewController) // faild! 

回避するためには XCTAssertNoLeak の引数にオブジェクトを右辺値として渡す必要があります。 クックパッドアプリでは次のようにしてテストを記述しています。

func build() -> AnyObject {
    RecipeDetailsViewBuilder.build(....) // 初期化処理
}
XCTAssertNoLeak(build())

初期化処理をローカル関数にラップし、返り値をそのまま XCTAssertNoLeak に放り込むことでローカル変数にテスト対象のインスタンスを保持しないようにしています。

シングルトンは例外設定

次のテストを見てみましょう。 f:id:iceman5499:20200303111725p:plain

NotificationCenterがリークしていると怒られています。 シングルトンは開放されないため、XCTAssertNoLeakから見るとメモリリークしているものとして判定されます。 このような状況に対応するために CustomTraversable というプロトコルが用意されています。

extension NotificationCenter: CustomTraversable {
    var ignoreAssertion: Bool { true }
}

メモリリークしていると判定されるクラスに対してextensionで ignoreAssertion: Bool を実装することで、そのエラーを無視することができます。 CustomTraversable にはこの手のケースに対応するための口がいくつか用意されています。

シングルトンが保持するオブジェクト

シングルトンを無視設定するところまでは良かったですが、 ignoreAssertion するだけではそのオブジェクトに連なっているオブジェクトがさらにリーク判定されてしまいます。( ignoreAssertion はそのインスタンスの子プロパティ群ごと無視はしません)

class AwesomeObject {}

class MySingleton {
    static let shared = MySingleton()
    private let object = AwesomeObject()
}

extension MySingleton: CustomTraversable {
    var ignoreAssertion: Bool { true }
}

class MyViewController: UIViewController {
    let object = AwesomeObject()
    let dependency = MySingleton.shared
}

class NoLeakTests: XCTestCase {
    func testMyViewController() {
        // failed - 1 object occured memory leak.
        //        - self.dependency.object
        XCTAssertNoLeak(MyViewController())
    }
}

このコードの例の場合、 self.dependency.object がリークしたという判定になります。 あまりこのようなケースはありませんが、現実のアプリケーションは複雑であるためまれにこのケースに遭遇します。

これの対応を考えることは難しいです。例えば次のようにシングルトンに持たれた AwesomeObject のみ例外設定することを考えます。

extension AwesomeObject {
    var ignoreAssertion: Bool {
        self === MySingleton.shared.object // コンパイルエラー: 'object' is inaccessible due to 'private' protection level
    }
}

このように書きたいところですが、アクセス修飾子の関係でこの処理は記述することができません。 対象のオブジェクトがアプリケーション内に実装されていればやりようはあるかもしれませんが、外部のライブラリなどとなると難しくなってきます。

クックパッドアプリではこのケースは諦めて AwesomeObjectignoreAssertion は常に true を返すようにしています。

まとめ

XCTAssertNoLeakのおかげで、メモリリークを検知するテストを実現することができました。 このテストを実装してすぐ、僕の変更で循環参照を引き起こしてしまいテストに怒られてしまったので早速効果を発揮しました。 テストを導入する過程で見つかったメモリリークもいくつかあり、今まで見つかってなかったメモリリークも浮き彫りにすることができました。

このようにしてiOSアプリのメモリリークは解消しましたが、モバイルアプリの品質安定にはまだまだ手が足りていない状況です。 クックパッドではモバイルアプリの品質を安定させたいiOS/Androidエンジニアを募集しています。 https://info.cookpad.com/careers/

AWS リソース管理の Terraform 移行

技術部 SRE グループの鈴木 (id:eagletmt) です。クックパッドでは Codenize.tools を用いて様々なリソースをコードで管理してきましたが、現在では大部分が Terraform へと移行しています。Terraform の使い方等については既に沢山のドキュメントや紹介記事があるので本エントリでは触れず、なぜ Terraform へと移行しているのか、どのように Terraform を利用しているのかについて書いていきます。

Terraform 移行の理由

クックパッドでは自分と同じく SRE グループに所属している菅原 (id:winebarrel) によって開発された Codenize.tools のツール群を利用して IAM、Route 53、CloudWatch Alarm、CloudWatch Events 等をコードで管理し、いわゆる GitOps を実践してきました。Codenize.tools による AWS リソース管理は基本的に1アカウント内のすべてのリソースを対象に動作します *1。これに従うと、ある AWS サービスに属する全てのリソースの管理は1つのリポジトリに集約されることになります。実際、社内には cloudwatch というリポジトリや iam というリポジトリが存在します。この構成は1つのチームが1つの AWS アカウント内のすべてのリソースを管理しているような場合は抜け漏れ無くコードで管理できるため非常に有効です。cloudwatch と iam を1つのリポジトリにまとめるか別のリポジトリに分けるかという選択肢はありますが、1つの AWS アカウント内のすべてのリソースを SRE グループが管理していたクックパッドでは自然な構成でした。

しかしマイクロサービス化が進みセルフサービス化が進むと、様々なチームで様々なリソースが必要になり、SRE グループがあらゆる AWS リソースを管理することが困難になっていきました。新しくアプリケーションをデプロイしたい人たちにとっても、複数のリポジトリに別々の pull-request を出す必要があり面倒に感じられていました。また、ELB + ECS/EC2 + RDS という伝統的な構成ではなく AWS SAM (Serverless Application Model) を利用したサーバレスな構成も選択されるようになり、CloudFormation で管理されるリソースも増えていきました。このような状況では Codenize.tools の「1アカウント内のすべてのリソースを対象に動作」という挙動は次第にフィットしなくなっていきました。

そこで、選択的にリソースを管理することができ、多くの現場で利用されている Terraform へと移行する方針になりました。これまでも Codenize.tools の対象外だった Auto Scaling Group や RDS インスタンス等の管理に Terraform は使われていましたが、Codenize.tools の対象でも Terraform を利用するように移行が始まりました。現時点では IAM、Route 53 以外の AWS リソースは一通り Terraform への移行が完了しています。

Terraform 運用

全面的に Terraform へと移行するにあたって、いくつか工夫した点があるのでそれぞれ紹介します。

tfstate の単位

Terraform では管理対象のリソースに関する情報を state ファイル (以下 tfstate と呼ぶ) にまとめているわけですが、Terraform を利用するにあたってこの tfstate をどのような単位で分割するのかという話題があります。1つの tfstate ですべての AWS リソースを管理するのは少なくともクックパッドの規模では無謀で、もしそうしたら terraform plan の時間が非常に長くなってしまいます。 クックパッドでは1つのリポジトリに Terraform ファイルを集約し、その中でプロジェクト単位でディレクトリを分けて記述していくことにしました。1つのディレクトリが1つの tfstate に対応します。

.
├── service-1
│   ├── aws.tf
│   ├── backend.tf
│   └── rds.tf
├── service-2
│   ├── acm.tf
│   ├── aws.tf
│   ├── backend.tf
│   ├── iam.tf
│   ├── rds.tf
│   ├── security_group.tf
│   └── vpc.tf
└── service-3
     ├── acm.tf
     ├── aws.tf
     ├── backend.tf
     ├── elb.tf
     ├── s3.tf
     ├── sg.tf
     └── vpc.tf

1つのリポジトリにしたのは一覧性を確保するためと、後述する CI の整備を楽にするためです。

linter の整備

AWS リソースの追加、削除、変更は Terraform 用のリポジトリへの pull-request で行うわけですが、pull-request に対する CI として terraform plan の結果を表示したり terraform fmt 済みかチェックしたりすることに加えて、独自に用意した linter を適用しています。Terraform 向けの linter というと tflint が既に存在していますが、tflint がカバーしているような一般的なルールではなく、社内独自のルールを強制したかったため自作しました。 ルールとしては現時点では

  • タグ付け可能なリソースには Project タグを必ず設定する
    • クックパッドの AWS アカウントではコスト分配タグとして Project というタグが設定されており、コスト管理のために Project タグを設定しなければならない
  • Aurora MySQL を使うときに特定のエンジンバージョンを禁止
    • クックパッドでの典型的なワークロードで致命的な問題が発生するエンジンバージョンがあるため、そのバージョンの指定を避ける

を強制しています。

ちなみにこの linter を pull-request に対して実行するにあたって、見易さの観点から GitHub の Checks の機能を利用することにしました。linter のように行単位で指摘する箇所が分かる場合、Checks を使うと見易く表示できます。 https://developer.github.com/v3/checks/

remote state の取り扱い

tfstate の保存場所としては S3 を使っていますが、RDS インスタンスの master user のパスワードのようなセンシティブな値の取り扱いを考慮する必要があります。たとえば aws_rds_cluster を新規に作成するとき、master_password に直接パスワードを書くと GitHub リポジトリで社内全体に公開されてしまいます。そこで SSM の Parameter Store に SecureString として保存して aws_ssm_parameter で参照したり、Vault の KV backend に保存して vault_generic_secret で参照したりといった方法を思い付きますが、これにより Terraform ファイル上からはパスワードが消えても tfstate にパスワードが平文で記録されてしまいます。この問題は upstream でも認識されていて tfstate 自体をセンシティブなデータとして扱うことを推奨しています。 https://www.terraform.io/docs/state/sensitive-data.html

しかしながら社内のエンジニアであればどのプロジェクトでも terraform plan は実行できるという状態を目指したかったので、パスワードのようなセンシティブな値は tfstate にはダミーの値を指定するという方針を試してみています。たとえば aws_rds_cluster を新規作成する場合は

resource "aws_rds_cluster" "my-awesome-app" {
  ...
  master_password = "pasuwa-do"
  ...
}

のように記述して terraform apply し、その後 mysql コマンド経由や ModifyDBCluster API で正式なパスワードに変更します。API を通じて master_password を得る手段が無いので Terraform は tfstate にある値を信じるしかなく、tfstate にも Terraform ファイルにも pasuwa-do と書かれているので差分が発生せず、センシティブな値を tfstate にも Terraform ファイルにも書き込まずに Terraform でリソースを管理することができています。

今後

Codenize.tools から Terraform への移行は進んでいるものの、最初の移行時にはプロジェクト単位に分割することを諦めたため、Terraform 管理へと変更はできていても適切なプロジェクト内の tfstate で管理させることはまだ十分にはできていません。現在はたとえば cloudwatch のような tfstate に様々なプロジェクト向けの CloudWatch Alarm が混ざって管理されている状態です。これを分解していくことは地味な作業ではありますが、今後も少しずつプロジェクト単位で管理された状態へと移していこうとしています。

また、多くの現場で実践されていそうな Terraform の自動適用もまだ実践できていません。Terraform 管理への移行や Terraform 管理内での tfstate の変更も徐々に落ち着いていくと思われるので、master にマージされたら自動的に terraform apply される状態を目指したいです。

*1:--exclude や --target で対象を限定できるようになっているものもあります