人工知能学会のトップカンファレンス派遣レポータとして NIPS2017 に参加しました

研究開発部の菊田(@yohei_kikuta)です。機械学習を活用した新規サービスの研究開発(主として画像分析系)に取り組んでいます。 最近読んだ論文で面白かったものを3つ挙げろと言われたら以下を挙げます。

人工知能学会の トップカンファレンス派遣レポータ という制度で NIPS2017 に参加しました。 学会への参加に加えて、その後の論文読み会や報告会での発表など様々な活動をしましたので、一連の活動を紹介したいと思います。

NIPS2017 の特徴的な写真として invited talk の一コマを貼っておきます。驚くほど人が多い...

20180327114113

また、参加して自分が面白いと思った内容(deep learning のいくつかのトピック)をまとめた資料も最初に紹介しておきます。

経緯

昨年 トップカンファレンス派遣レポータ という制度がアナウンスされ、新しい取り組みで面白そうな企画でもあるので応募しました。 学会に参加するだけなら応募せずに会社で申請して参加すればいいのですが、NIPS の内容に興味を持つ人が集まりそうな場での発表の機会が得られるということが主たるモチベーションでした。

レポータとして選ばれるのは3名で応募者の統計情報などは明らかにされていません。 3名の内訳は、大学の先生・大学院生・私、という感じでバランスも考慮されている印象を受けました。

応募自体はA4の資料を一枚程度作成すればよいもので(郵送ですが)、それで必要経費を全て出してくれるというなかなか太っ腹な制度だと思います。 今年も同様の内容で募集する可能性が高そうなので、興味がある方は申し込んでみるのもよいと思います。 昨年は5月上旬に人工知能学会のメーリングリストから応募者を募るメールを受け取りました。

NIPS2017

Neural Information Processing Systems (NIPS) は機械学習の主要な国際会議の一つで、私は2015年にも参加していて二回目の参加となりました。

昨今の機械学習ブームを牽引する学会でもあり、2017年は registration が8000人でそれもかなり早い段階で打ち切られたという状態でした。 投稿論文数も3240件(採択率21%)で2016年から30%程度増加しており、年々その熱量が増しています。 企業のスポンサーは84社にも上り、diamond sponsor に関しては展示会さながらの大々的な展示が繰り広げられていました。

論文採択に関しては面白いデータが紹介されていて、事前に arXiv にも submit されていた論文は43%に上り、レビュワーがそれを見た場合の採択率が35%という高い数字であったというものです。 レビュワーが見てない場合も25%と高い水準のため、そもそも質が高めの論文が arXiv に submit されるという傾向はあるかもしれませんが(例えば地力のある研究室がそういう戦略を取っているなど)、arXiv が機械学習分野にも高い影響力を発揮していることが伺えます。 学会では査読されてから publish されるまで時間も掛かるので、論文は arXiv などですぐに共有されて open review などで評価する open science 化が進んでいくかもしれません。

内容に関しては、NIPS において長らく主題の一つである algorithm が最多でありながら、deep learning や meta learning などの勢いが著しく、それ以外にも fairness や interpretability のような分野の台頭が目立つという、様々な側面で盛んに研究が進められているという印象でした。 個々の詳細な内容の説明はここでは省きますが、deep learning 関連のまとめに関しては冒頭の紹介資料にも記載しています。

NIPS2017 では新たな取組として competition track や DeepArt contest が開催されていました。 前者は kaggle のようなコンペを事前に開催して当日に上位入賞者に解説をしてもらうというような形式で、後者は style transfer を使って画像を artistic に変換して投票で入賞者を決めるという形式でした。 学会にこれらの要素が必要なのかということは議論の余地があるかもしれませんが、学会も世の中の動向に合わせて変化を続けていることが伺えるものでした。

その他にはネットでも話題になった苛烈な人材獲得競争のような話題がありますが、参加者としてはそこまで騒ぎ立てるほどではないと感じました。 一部でバブルを感じさせるイベントがあったりしたことは事実ですが、学会の性質を歪めるほどのものではないように思います。 学生としても自分が興味のある企業に直接アプローチする機会が増えて良いのではないでしょうか。

NIPS2017 論文読み会

せっかく参加したので、興味を持った論文をもう少し深く読んで発表しようと思い クックパッドで論文読み会を主催 しました。 読み会の様子です。

20180327114104

私は GAN の学習の収束性に関するいくつかの論文を読んで発表をしました。

本来はどこかで開催される読み会に参加して発表だけしようと思っていたのですが、観測範囲内で望ましいイベントが開催されなかったので主催するに至りました(その後いくつか同様のイベントが開催されました)。

イベントの主催は大変なところもあるのですが、機械学習に興味のある方々に参加していただき盛況でした。 こういったイベントを通してクックパッドに興味を持って頂ける場合も少なくないので、主催してみて良かったなと思います。

今後もこのようなイベントを開催していくことになると思いますので、興味のある方は是非ご参加下さい。

NIPS2017 報告会

派遣レポータの仕事として事前に開催が決まっていた 報告会 でも発表しました。 20180221に大阪大学中之島センターで、20180228に早稲田大学西早稲田キャンパスで報告会が開催されました。

有料イベントにも関わらず満員御礼状態で、特に企業の方々の参加者が多かったと伺っています。 久しぶりの大学での発表だし、NIPS の報告でもあるので、内容は思いっきり deep learning の理論的な話をしました。 参加者の目的と合致していたかは一抹の不安が残りますが、自分が聴衆として聞くなら悪くない内容だったと思っています(自分が話してるので当然ですね)。

その他

人工知能学会紙に参加報告を載せる予定です。 また、それ以外にも付随して何かやるかもしれません。

まとめ

人工知能学会のトップカンファレンス派遣レポータとして NIPS2017 に参加した話と、それに関連するイベントで何をやったのかという紹介をしました。 NIPS は理論的な色合いが濃い学会ではありますが、次々と新しいものが出てくる機械学習界隈ではこういった内容をキャッチアップしていくのは事業会社の研究開発でも重要だと考えていて、そして何より自分が好きなので、参加して得られた知見を今後の業務に活かしていきたいと思います。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。 クックパッド株式会社 研究開発部 採用情報

ハッシュ値の使い方について

モバイル基盤グループのヴァンサン(@vincentisambart)です。

先日以下のツイートを拝見しました。

この変更はSwift 4.1にはまだ入りませんが、4.2か5.0に入るはずです。コードレビューでこの変更が問題を起こそうなコードを指摘したことあるので、ハッシュ値のおさらいをする良いタイミングではないでしょうか。

Swiftのことを考えて書いていますが、多くのプログラミング言語にも当てはまります。ハッシュ値はSwiftではhashValueというプロパティが返しますが、多くの言語では単にhashというメソッド・関数が返します。

ハッシュマップ

ハッシュ値はハッシュマップ(別名ハッシュテーブル)に一番使われるのではないでしょうか。SwiftではDictionary、RubyではHash、C++ではunordered_map、RustではHashMapと呼ばれるものです。

ハッシュマップはマップの一種であって、マップというのはキーに値を結びつけるためのものです。1つのキーに1つの値しか結びつけない場合が多いです(値は配列を使えますが)。例えば漫画の連載開始の年のマップを作ると以下のようになります。

キー
ONE PIECE 1997
DRAGON BALL 1984
青の祓魔師 2009
Levius 2012
宇宙兄弟 2007

ハッシュマップは基本的にキーに順がない場合が多いです。キーが挿入された順で列挙されると保証する実装(例えばRubyのHash)もありますが。

ハッシュマップのキーに一番使われるのは文字列ですが、以下の2つの条件を満たせば何でも使えます。

  • 2つのキーが等しいかどうか比較できる(SwiftではEquatableというプロトコルに準拠すること)
  • キーからハッシュ値を計算できる(SwiftではHashableというプロトコルに準拠すること。比較できないとハッシュ値が使い物にならないのでHashableEquatableに準拠している)

ハッシュマップは別のマップの種類に比べてどういうメリットあるかと言いますと、ハッシュ関数(ハッシュ値を計算する関数)が良ければ、キーが多くても値を早く取得できるところです。

ハッシュ値とそれを生成するハッシュ関数

ハッシュマップに使われるハッシュ値は基本的に32-bitか64-bitの整数です。ハッシュ値を元にキーと値がメモリ上どこに置かれるのか決まります。

ハッシュマップで使うには、ハッシュ値が以下の条件を満たす必要があります。

  • プログラムが終了するまで、ハッシュ関数(ハッシュ値を計算する関数)に同じキーを渡すと必ず同じハッシュ値が返るべき
  • ハッシュ値が違っていれば、ハッシュ関数に渡されたキーが異なるべき
  • 違う2つのキーが同じハッシュ値を持っても良い。可変長の文字列から計算されるハッシュ値が固定長数バイトだけに収まるので、すべてのキーが違うハッシュ値を持つはずがありません。

上記の条件を満たす一番シンプルなハッシュ関数が固定値を返すだけです。それだとハッシュマップは一応動きますが、性能がすごく落ちて、ハッシュマップを使うメリットがなくなります。

ハッシュ値を計算するハッシュ関数なんですが、良いハッシュ関数はハッシュ値の計算が速くて、色んなキーを渡すとできるだけ違うハッシュ値を返してくれた方がハッシュマップの性能が出ます。良いハッシュ関数を作るのはすごく大変なので、既存の研究されたものが使われる場合が多いです。

気を付けるべきところ

ハッシュ値が満たすべき条件に「プログラムが終了するまで、ハッシュ関数に同じキーを渡すと必ず同じ値が返る」と書きましたが、「プログラムが終了するまで」が重要です。プログラムをまた実行すると変わる可能性があります。Rubyで試してみると分かりやすいと思います。

$ ruby -e 'p "abcd".hash'
-2478909447338366169
$ ruby -e 'p "abcd".hash'
3988221876519392566
$ ruby -e 'p "abcd".hash'
-771890369285024305

今までSwiftではプログラムを何回実行しても標準のhashValueが毎回同じハッシュ値を返していましたが、この記事の頭にリンクされていた変更でプログラムが実行される度にハッシュ値が変わるようになります。

どうして変わるようになったのかと言いますと、DoS攻撃のリスクを下がるためです。DoS攻撃というのは簡単にいうとマシンがやるべき処理に追いつけなくなることです。

ハッシュマップに同じハッシュ値を持つキーをたくさん入れると、性能がどんどんと落ちていきます。ハッシュ値を事前に予測できると同じハッシュ値を持つキーを大量用意できます。サーバーがハッシュマップのキーにしそうなもの(例えばリクエストの引数名)に用意された大量のキーを使わせてサーバーがやるべき処理に追いつかなくなります。

ハッシュ値がプログラムの実行ごとに変わると、ハッシュ値の予測がかなり困難になるのでリスクを減らせます。

ハッシュマップ

でもどうして同じハッシュ値が多いとハッシュマップの性能が落ちていくのでしょうか。理解するにはハッシュマップの仕組みをもっと細かく見る必要があります。

ハッシュマップのコアな部分が単なる配列です。配列の項目がバケット(bucket)と呼ばれています。

配列のサイズ(バケット数)に満たすべき条件が特にありませんが、基本的に項目が増えるともっと大きい配列が用意されて、以前の項目を新しい配列に移し替えます(新しい配列でバケットが変わる可能性あるので要注意)。

挿入されるキーと値がどこに入るのかはハッシュ値で決まります。バケット数がハッシュ値の数ほど多いわけではないので、モジュロ(剰余演算)を使ってバケット数以下にします。

let hashValue = key.hashValue
// 負のインデックスだと困るので絶対値を取る
let bucketIndex = abs(hashValue) % buckets.count
// ハッシュ値は後でまた計算できるけど、再計算を減らすために入れておく
buckets[bucketIndex] = Bucket(hashValue: hashValue, key: key, value: value)
0 1 2 3 4 5 6 7

ハッシュ値が-3272626601332557488のキー"ONE PIECE"を挿入すると、abs(hashValue) % 80なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997

ハッシュ値が4799462990991072854のキー"青の祓魔師"を挿入すると、abs(hashValue) % 86なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997
"青の祓魔師"

2009

ただし、それだと同じバケットに別のキーが入っていたら代入すると前のキーがなくなります。同じバケットに複数のキーが入るケースを衝突(collision)といいます。

衝突の扱いは様々あります。一番シンプルなのは連結リストや動的配列ですが、例えばキーを次に空いているバケットに入れることもあります。

ハッシュ値が4799462990991072854のキー"宇宙兄弟"を挿入すると、abs(hashValue) % 86なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997
"青の祓魔師"

2009
"宇宙兄弟"

2007

シンプルなハッシュマップを実装してみると以下のようになります。

import Foundation

struct SimpleDictionary<Key: Hashable, Value> {
    typealias HashValue = Int
    struct BucketElement {
        var hashValue: HashValue // キーから計算できるけど時間掛かるので残しておく
        var key: Key
        var value: Value
    }

    // 連結リストがよく使われるが、実装をもっと分かりやすくするため可変長の配列を使う
    typealias Bucket = [BucketElement]
    // 実質配列の配列
    var buckets: [Bucket]

    init() {
        // 分かりやすさのためバケット数を固定にしているが、普段キーが増えるとデータをもっと大きい配列に移し替える
        // 移し替える時、バケット数が変わってキーのバケットが変わる可能性があるのでハッシュ値を元に新しいバケットを再度計算する必要がある
        let bucketCount = 8
        buckets = Array<Bucket>(repeating: [], count: bucketCount)
    }

    subscript(key: Key) -> Value? {
        get {
            // ハッシュ値でバケットが決まる
            let hashValue = key.hashValue
            let bucketIndex = abs(hashValue) % buckets.count
            for element in buckets[bucketIndex] {
                // ハッシュ値の比較が早いのでまずハッシュ値を比較しておく
                if element.hashValue == hashValue && element.key == key {
                    return element.value
                }
            }
            return nil
        }
        set(newValue) {
            let hashValue = key.hashValue
            let bucketIndex = abs(hashValue) % buckets.count

            // キーが既に使わている場合、バケット内どのインデックスに入っているのか
            let indexInsideBucket = buckets[bucketIndex].index { element in
                element.hashValue == hashValue && element.key == key
            }

            if let nonNilNewValue = newValue {
                let newElement = BucketElement(
                    hashValue: hashValue,
                    key: key,
                    value: nonNilNewValue
                )
                if let nonNilIndexInsideBucket = indexInsideBucket {
                    // キーが既に入っているので置き換え
                    buckets[bucketIndex][nonNilIndexInsideBucket] = newElement
                } else {
                    // キーがまだ入っていなかったので、挿入
                    buckets[bucketIndex].append(newElement)
                }
            } else {
                // newValueがnilなので、削除
                if let nonNilIndexInsideBucket = indexInsideBucket {
                    buckets[bucketIndex].remove(at: nonNilIndexInsideBucket)
                }
            }
        }
    }
}

肝心なところは衝突の扱いです。同じバケットにキーが増えると、バケットに入っている項目のリストが少しずつ伸びます。項目が増えると読み込みも書き込みも比較が増えて処理が重くなります。バケットに項目が1つしかなかった場合、アクセスする時は行われるのはハッシュ値の計算1回と、ハッシュ値の比較1回と、キー自体の比較1回です。同じハッシュ値のキーが100個入っていると、全部同じバケットになるので、アクセスすると行われるのはハッシュ値の計算1回と、ハッシュ値の比較100回と、キー自体の比較100回です。100個目なので、前の99回分の挿入ももちろんあります。

逆にすべてのキーが別のバケットに入ると挿入する度に比較は各1回だけです。ですのでできるだけ多くのバケットが使われる方が性能が出ます。

コードレビューで気づいた間違い

この記事の冒頭でハッシュ値に関する間違いを指摘したと言いましたが、具体的にいうと大きい間違いが以下の2つでした。

  • == の実装が hashValue を比較していただけ
struct Foo: Hashable {
    static func == (lhs: Foo, rhs: Foo) -> Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

lhsrhsが違っても、ハッシュ値が同じの可能性があります。ハッシュ値が違っていたらlhsrhsが必ず違いますけど。

  • hashValueUserDefaults に保存されていた
UserDefaults.standard.integer(forKey: fooHashValueKey)
(...)
UserDefaults.standard.set(foo.hashValue, forKey: fooHashValueKey)

プログラムの次の実行でハッシュ値が変わる可能性があります。元からHashableの公式ドキュメントには明確に書いてありました。

Hash values are not guaranteed to be equal across different executions of your program. Do not save hash values to use in a future execution.

実際冒頭でリンクした変更がSwiftに入ったら、プログラムの次の実行でハッシュ値が変わっていない可能性が極めて低いです。

まとめ

ハッシュ値を扱っている場合、以下の項目を覚えておきましょう。

  • ハッシュ値はプログラムの実行ごとに変わる可能性あるためディスクに保存してはいけない(別の実行でも同じ結果を返すハッシュ関数を意図的に使わない限り)
  • 2つのキーのハッシュ値が違ったら、キーが必ず違う
  • 2つのキーのハッシュ値はが同じだとしても、キーが同じだと限らない

TLS証明書の発行・デプロイについて

こんにちは、インフラストラクチャー部セキュリティグループの三戸 (@mittyorz) です。 クックパッドでは全てのサービスをHTTPSにて提供しています。 今回はHTTPSの使用にあたって必要となるTLS証明書について、申請や発行、管理やサーバへのデプロイなどの運用について書きたいと思います。

TLS終端

クックパッドでは、サービスとユーザーとの通信経路は全てTLSにより暗号化されていますが、通信内容を暗号化するためのTLS終端処理はELBあるいはCloudFrontで行っています。 ELB、CloudFrontともにAWS Certificate Manager(ACM)を用いて証明書を管理*1することが出来ますが、社内向けで外部に公開していないサービスやステージング環境についてはELB背後のリバースプロキシで終端処理をしているものも多く、これらについては証明書ファイルを直接EC2インスタンスへ配置する必要があります。

なお、クックパッドのHTTPS化については Web サービスの完全 HTTPS 化 を御覧ください。

証明書の種類

TLS証明書には、ドメインの所有者について認証局が実在照会を厳格に行ったのちに発行される、Extended Validation(EV)証明書があります。 EV証明書を用いることで、ブラウザのアドレスバーにはそのドメインの所有者の情報が表示され、ユーザーにとって意図したサイトに接続しているかどうかがわかりやすくなります。 クックパッドでは、PCあるいはスマートフォン向けブラウザからユーザーが直接アクセスするページについては、原則EV証明書を設定するようにしています。

なお、EV証明書ではない証明書には、ドメインの所有者であることを確認して発行されるDomain Validation(DV)証明書と、所有者の実在照会まで行うOrganization Validation(OV)証明書が存在します。 OV証明書とEV証明書はいずれも実在照会が行われますが、CA/Browser Forumによって定められたガイドライン*2に従って発行されたものだけがEV証明書となります。

証明書の発行

新規サービスの立ち上げなどで新しいドメインを使用する場合、まずはACMを用いてDV証明書を発行し、APIエンドポイント用のドメインなどを除いて順次EV証明書を配置しています。 以前はドメインごとにEV証明書を一つ一つ購入していたためコストも無視できなかったのですが、後述するマルチドメイン証明書を用いることで年100ドルほどで追加購入できるようになりました。 また、常にEV証明書を設定するというわけでもなく、URLの変更などで使用しなくなりリダイレクトのみ行うドメインについてはEV証明書をやめてACMの証明書に切り替える、ということも行っています。

EV証明書の発行はACMでは行えないため、ACMで用いる場合別途認証局から購入しインポートする必要があります。 またACMから秘密鍵を取り出すことも出来ないため、EC2インスタンスで直接TLS終端している場合も同様に購入しています。

認証局の選定

クックパッドでは、現在はDigiCertから証明書を購入しています。 使いやすいWebコンソールが存在していることや、WebコンソールへのログインがSAMLによるシングルサインオンに対応していることが選定理由ですが、 後述するSANに対応したEV証明書の発行に対応していることやAPIが用意されていることもポイントとして挙げられるかと思います。 また、脆弱性の発生時など特に迅速な対応が必要な場合でも、認証局から直接のサポートが受けられるというのもあります。

証明書のデプロイ

Classic Load Balancer(CLB)の設定にはkelbimを、ECSと組み合わせて用いるApplication Load Balancer(ALB)の設定にはHakoを用いており、 それぞれACMに用意した証明書をARNを使って指定することが出来るようになっています。 CLBは主に社内向けのステージング環境や、Hako化がまだなされていないサービスにおいて使用されています。 最近リリースされたサービスは基本的にHakoを用いてデプロイ出来るようになっているので、以下のようなフローで証明書の設定を行っています。

  1. 証明書の発行の依頼がサービス開発チームからSREチームに来る
  2. EV証明書が必要と判断された場合は認証局へ発行を申請する
  3. ACMへ証明書をインポート、もしくはACMで証明書を発行する
  4. 証明書のARNをサービス開発チームに通知し、Hakoの定義ファイルに記載する
  5. Hakoを用いてデプロイ。ALBに証明書が設定される

Hako自体の説明はここではいたしませんが、定義ファイルでの証明書の指定の仕方はサンプルなどが参考になるでしょう*3

他、設定ファイルや証明書をサーバに直接配置する必要がある場合は、証明書や中間証明書はGitリポジトリに含めておき、itamaeを用いてデプロイしています。秘密鍵はそのままリポジトリに入れるのではなく、変数を用いてデプロイ時に展開されるようになっています。

証明書の有効期限の監視

TLS証明書には有効期限が存在します。 有効期限が切れる前に新しい証明書に更新する必要がありますが、有効期限は1年以上となっていることが多く「忘れた頃に有効期限が来る」ということが起きます。 認証局によっては、例えば30日前などにメールで通知してくれるところがありますし、ACMの場合は2017年の11月からDNSレコードによりドメインの所有者検証を行い自動更新することが出来ます*4。 EC2インスタンスで直接TLS終端している場合、どのインスタンスで証明書が使用されているのか把握しておく必要がありますが、クックパッドではZabbixを用いて監視しています。 また、一部のドメインについてはStatusCakeも併用しています。

社外のインターネット回線からアクセスした場合とオフィスからアクセスした場合とでエンドポイントが違っていて*5、設定されている証明書が異なるため監視漏れで危うく有効期限切れするところだったということもありました。 また、見落としがちなのがオフィスからのみアクセスできるサーバやアプライアンス製品で、特にワイルドカード証明書は思わぬサブドメインで使われていることもあるので、 Route 53からレコードを取得し、登録されているサブドメインも含め全てのドメインに対してチェックするということも行っています。

EV証明書発行のための実在証明

実在証明と書くとなんだか凄そうですが、手順としてはそれほど複雑ではなく、ざっくりと以下のようなことを行いました。

  1. 組織名(Organization)として商号を登録する
    • この部分がサイトにアクセスした際にアドレスバーに表示されます。
    • あわせて、本社所在地などの情報も登録します。
  2. 組織名と所在地が掲載された公的文書を提出する
  3. 担当者の在籍状況について、電話などで確認が行われる

2 について認証局が日本法人であれば登記簿謄本を提出することで証明出来たのですが、DigiCertはアメリカ合衆国の法人なため、アメリカ合衆国において発行されたものが必要となります*6。 今回はアメリカ証券取引委員会に登録された文章を見つけることが出来たため、比較的すんなりと会社の実在証明を行うことが出来ました。

一方 3 については、公的文書には代表電話番号のみ記載されていたためその番号での対応が必要となり、インフラストラクチャー部の直通番号へ入電を期待していたため何度か掛け直してもらうなど混乱もありました。 詳しい手順は前述のガイドラインにも掲載されていますが、受容可能な手順として法的に有効な文書に記載されている住所、電話番号、メールアドレスなどを用いて担当者の確認を行うこととされているため、 担当者直通など任意の電話番号に掛けてもらうにはその番号が記載された公的文書が必要となり注意が必要です。

フィーチャーフォン対応

国内のフィーチャーフォンがターゲットとなっているモバれぴ*7については特段の配慮が必要になりました。

証明書の認証パスにおいて、ルート証明書は本来その名の通り根本に存在し他のどの証明書にも依存せずに信頼される必要があるため、 OSに付属して提供されたり、ブラウザとあわせてインストールされるなど予め信頼されるようになっています。 しかし、フィーチャーフォンでは出荷後のアップデートなどで新しく証明書を追加することが出来ないことが多く、 プリインストールされているルート証明書自体も種類が少ないということがよくあります。 したがって、古いルート証明書しかサポートしていないフィーチャーフォンにおいては、証明書を切り替えてしまうと認証されずエラーとなる可能性があります。

この問題は、サポートされていないルート証明書を別のサポートされているルート証明書で署名する、クロスルート証明書という仕組みで回避することが出来ます。

DigiCertが発行しているルート証明書は多くの環境でサポートされていますが、フィーチャーフォン向けのBaltimoreのルート証明書によって更に署名されており、 この場合具体的には次のような認証パスになります。

  1. CN=Baltimore CyberTrust Root
  2. CN=DigiCert High Assurance EV Root
  3. CN=DigiCert SHA2 Extended Validation Server CA
  4. CN=m.cookpad.com

フィーチャーフォン以外の殆どの環境では2がルート証明書、 3が中間証明書、4がサーバ証明書になりますが、このケースだと2、3が中間証明書であると言えます。 したがって、ACMに証明書を登録する場合は、以下のように登録することになります。

  • Certificate body に、4の証明書
  • Certificate private key に、4の秘密鍵
  • Certificate chainに に、3の証明書へ2の証明書を結合したもの

実際に openssl コマンドを用いて認証パスを表示すると以下のようになります。

$ openssl s_client -connect m.cookpad.com:443 -quiet
depth=3 C = IE, O = Baltimore, OU = CyberTrust, CN = Baltimore CyberTrust Root
verify return:1
depth=2 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert High Assurance EV Root CA
verify return:1
depth=1 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert SHA2 Extended Validation Server CA
verify return:1
depth=0 businessCategory = Private Organization, jurisdictionC = JP, serialNumber = 0104-01-071872, C = JP, ST = Tokyo, L = Shibuya-ku, O = COOKPAD Inc., OU = Infrastructure Division, CN = m.cookpad.com
verify return:1

マルチドメイン証明書

TLS証明書にはSubject Alternative Names(SAN)という属性をもたせることができ、Common Nameとは別の任意のドメインを追加することが出来ます。 この機能により、一つの証明書で例えば example.comexample.jp のように複数の独立したドメインに対応することが可能になります。 ただし、どんなときでもまとめてしまえば良いという訳でもなく、HTTP/2でサービスを提供している場合はコネクションの再利用に注意する必要があります。 例えば、SANに *.example.com が設定された証明書を用いて配信しているサーバがあったとして、このサーバAは a.example.com のコンテンツは配信しているものの b.example.com のコンテンツは配信しておらず、別のサーバBで配信しているとします。 クライアントが a.example.com のコンテンツを取得するためにサーバAとのコネクションを確立したあと、サーバBに存在する b.example.com の取得についてもサーバAとのコネクションを再利用してしまい、 サーバAからエラーが返されてから*8サーバBに改めてコネクションを確立するため、かえってレイテンシが増えてしまいます。 これはHTTP/1.1でよく見られた、画像やCSS、javascriptファイルを別のドメインから提供することでページ全体のレイテンシを低減している場合*9に起こりやすいと言えるでしょう。

この問題については HTTP/2 のコネクション再利用について確認してみる - ブログのしゅーくりーむ に詳しく解説されています。

証明書の発行のためCSRファイルを作成する際、opensslコマンドを用いることが多いと思いますが、マルチドメイン証明書のCSRファイルについてはSANの指定が引数で指定することが出来ません。 設定ファイルのopenssl.cnfに直接記入する必要がありますが、いちいち書き換えるのも面倒なので以下のようなスクリプトで作成しています。

#!/bin/bash

subject="/C=JP/ST=Tokyo/L=Shibuya-ku/O=Cookpad Inc./OU=Infrastructure Division/"


# CN and SAN list
common_name=$1
if [ -z $common_name ]; then
      read -p 'Common name? : ' common_name
fi

sans_file=${common_name}.txt
if [ ! -f "$sans_file" ]; then
    echo $0: "$sans_file" does not exist
    exit 1
fi

# find out where the openssl.cnf is
conf=`openssl version -a | grep OPENSSLDIR | cut -d '"' -f2`
conf=$conf/openssl.cnf

# compose SAN section
sansection=$(cat <(
    echo -n "subjectAltName='DNS:"
    cat $sans_file | perl -pe "chomp if eof" | perl -pe "s/\r?\n$/,DNS:/g"
    echo "'"
))

# display CN and SANs
echo CN: $common_name
echo $sansection

# make csr/key
openssl req -new\
            -newkey rsa:2048\
            -nodes -out ${common_name}-san.csr\
            -keyout ${common_name}-san.key\
            -sha256\
            -subj "${subject}CN=$common_name" \
            -reqexts SAN \
            -config <(cat $conf \
                <(printf "\n[SAN]\n$sansection"))

作成したCSRファイルは例えば以下のようになります。

$ openssl req -text -noout -in cookpad.com-san.csr
Certificate Request:
    Data:
        Version: 0 (0x0)
        Subject: C=JP, ST=Tokyo, L=Shibuya-ku, O=Cookpad Inc., OU=Infrastructure Division, CN=cookpad.com

(中略)

        Requested Extensions:
            X509v3 Subject Alternative Name:
                DNS:info.cookpad.com, DNS:payment.cookpad.com

終わりに

クックパッドでのTLS証明書の運用について紹介しました。 HTTPS化されるインターネットサービスはどんどん増えており、証明書の発行も昔と比べてずっと容易になってきています。 一方で実際に作業してみると、コード管理されていないサーバが見つかって手作業で証明書ファイルを配置したり、認証局と電話でやり取りしたりといったこともありました。 監視対象への追加や、証明書の自動更新などまだ出来ていない部分も多く、これからも改善した点について紹介させていただきたいと思います。

*1:ACMが2016年1月にリリースされるまではIAMを使用していました。

*2:https://cabforum.org/extended-validation/

*3:サンプルでは証明書をIAMで指定していますが、ACMでも同様に指定できます

*4:https://aws.amazon.com/certificate-manager/faqs/#dns_validation

*5:特にステージング環境でよくあるケース

*6:DigiCertの場合 https://www.digicert.com/ssl-certificate-purchase-validation.htm に受容可能な文章について記載されています。

*7:https://m.cookpad.com/

*8:421 Misdirected Request

*9:いわゆるドメインシャーディング