一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

WordPressで爆速Canonical AMPサイトを構築した方法と3つの理由

文責

新規プロダクト開発部の伊勢( id:hayatoise )です。

新規プロダクト開発部は一休の新規事業の開発とデザインを担当する部署です。現在、新規プロダクト開発部は主に『一休.comスパ』、『一休コンシェルジュ』および『KIWAMINO』を担当しています。

はじめに

エグゼクティブや秘書の方々が会食先を探す際のお悩みを解消するためのオウンドメディア『KIWAMINO』をローンチしました。

『KIWAMINO』は WordPress をベースに Canonical AMP サイトにしました。AMP とは、ウェブ高速化のための HTML フレームワークです。そして、Canonical AMP サイトとは、全てのページが AMP で構成されているサイトのことです。

こちらのツイートから分かる通り、WordPress と Google が提供する AMP プラグイン(以下、AMP プラグイン)で Canonical AMP サイトを構成することはまだ事例としては少ないようです。

そこで、『KIWAMINO』をどうやって構築したのか紹介します。加えて、なぜ WordPress と AMP プラグインで Canonical AMP サイトを構成したのかについても説明します。

『KIWAMINO』をどうやって構築したのか

さくらの VPS 上に Docker で Nginx、WordPress ( PHP-FPM ) および MariaDB のコンテナを作成しました。そして、AMP プラグインで Canonical AMP サイトにしました。CDN は Fastly と imgIX を導入しています。

WordPress と AMP プラグインで Canonical AMP サイトを構成した方法

WordPress の構築もさることながら AMP プラグインで Canonical AMP サイトにすることもカンタンです。

まず WordPress を構築し、Twenty Ten 以降 Twenty Nineteen までのコアテーマを有効にします。そして、AMP プラグインも有効にします。

AMP プラグインの設定画面
AMP プラグインの設定画面

AMP プラグインの設定画面の Experiences 項目の Website にチェックを入れます。次に、Website Mode 項目を Standard にすると Canonical AMP サイトになります。以上です。

前述した通り、AMP HTML を書く必要はありません。AMP プラグインが HTML を自動で変換するからです。

<img src="https://resq.img-ikyu.com/asset/image/about/bn_about.png" alt="KIWAMINOについて" >

例えば、img タグを記述すると...

<amp-img src="https://resq.img-ikyu.com/asset/image/about/sd_bn_about.png" alt="KIWAMINOについて" width="640" height="400" class="amp-wp-unknown-size amp-wp-unknown-width amp-wp-unknown-height amp-wp-enforced-sizes i-amphtml-element i-amphtml-layout-intrinsic i-amphtml-layout-size-defined i-amphtml-layout" layout="intrinsic" i-amphtml-layout="intrinsic">
    <i-amphtml-sizer class="i-amphtml-sizer">
        <img alt="" role="presentation" aria-hidden="true" class="i-amphtml-intrinsic-sizer" src="data:image/svg+xml;charset=utf-8,<svg height=&quot;400px&quot; width=&quot;640px&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; version=&quot;1.1&quot;/>">
    </i-amphtml-sizer>
    <noscript>
        <img src="https://resq.img-ikyu.com/asset/image/about/sd_bn_about.png" alt="KIWAMINOについて" width="640" height="400" class="amp-wp-unknown-size amp-wp-unknown-width amp-wp-unknown-height">
    </noscript>
    <img decoding="async" alt="KIWAMINOについて" src="https://resq.img-ikyu.com/asset/image/about/sd_bn_about.png" class="i-amphtml-fill-content i-amphtml-replaced-content">
</amp-img>

表示する際は上記のような AMP HTML に自動で変換されます。

AMP プラグインの AMP Stories 作成画面
AMP プラグインの AMP Stories 作成画面

AMP プラグインの設定画面の Experiences 項目の Stories にチェックを入れると、開発コストを掛けることなく記事と同様に WYSIWYG で AMP Stories を作成することが可能になります。

インフラ

  • さくらの VPS( 2G プラン )
  • Fastly
  • imgIX( 画像に特化した CDN です。詳しくはこちら

ミドルウェア

  • Docker
  • Nginx
  • PHP-FPM
  • MariaDB

さくらの VPS 上に WordPress を構築せず、Docker を使用した理由は同じ環境を構築しやすいからです。

もう一つのオウンドメディア『一休コンシェルジュ』はローカル、ステージングおよびプロダクション環境が揃っておらず、ステージング環境でテストしたのにも関わらず何度か事故を起こしてしまいました。

この経験から各環境を統一することが比較的容易な Docker を採用しました。

WordPress

  • WordPress Core( 常に最新版を使用 )
  • AMP( HTML を AMP HTML に自動で変換 )
  • Fastly( コンテンツの投稿、更新および削除を検知して自動でキャッシュをパージ )
  • Media Cloud( S3 と imgIX の設定と連携を一括で可能に )
  • Yoast SEO( SEO 関連の設定を最適化 )
  • Glue for Yoast SEO & AMP( Yoast SEO の AMP 対応版 )
  • WP Pusher( 任意のブランチへのマージを検知して自動で差分をデプロイ )

WordPress Core のメジャーバージョンを除いて、全てのアップデートを自動に設定しています。

Lighthouse

『KIWAMINO』の Lighthouse のスコア
『KIWAMINO』の Lighthouse のスコア

修正できる点はいくつか残っていますが、Lighthouse の Performance スコアは常に 99 前後です。

なぜ WordPress と AMP プラグインで Canonical AMP サイトを構成したのか

WordPress と AMP プラグインで Canonical AMP サイトを構成した理由は以下の 3 つです。

(1) AMP の制約によって、サイトスピードが速くなるから

(2) エンジニア・デザイナーの学習および開発コストが低いから

(3) 巨大な組織・コミュニティの恩恵を受けられるから

(1) AMP の制約によって、サイトスピードが速くなるから

AMP の制約上、CSS および JavaScript のファイルサイズは軽くせざるを得ないので、それらがサイトスピードを必然的に妨げることがなくなります。

AMP は CSS の総量を 50 KB 以下にしなければなりません。また、JavaScript に関しては AMP が提供する公式のコンポーネントしか使えません。

したがって、CSS および JavaScript ファイルは必然的に軽量化されます。その結果、半強制的にサイトスピードを保つことができます。

(2) エンジニア・デザイナーの学習および開発コストが低いから

AMP プラグインが HTML を AMP HTML に自動で変換するため、デザイナーの学習コストは殆ど掛かりません。さらに Canonical AMP サイトにすると通常ページと AMP ページの二重管理がなくなるので、開発コストも低下します。

(3) 巨大な組織・コミュニティの恩恵を受けられるから

インターネット上の約 3 分の 1 のサイトは WordPress で動いているので、情報が沢山あります。かゆいところに手が届かないこともありますが、基本的に検索すれば大抵の技術的問題を解決することができます。したがって、エンジニアであれば誰でも担当できると思います。

当然、外注する場合も候補になり得る人材が沢山いるので、依頼しやすいかと思います。プラグインも豊富なので、その気になれば非エンジニアが新機能を追加してみることも可能です。

オウンドメディアを運営する上で欲しいと感じる機能は全てプラグインとして提供されていると言っても過言ではありません。巨大なコミュニティの元だからこそ得られるメリットが多々あります。

大半のオウンドメディアは Google 経由のトラフィックに依存しているかと思います。その良し悪しは別として AMP を採用することで、Google 経由の多くのユーザの体験が良くなります。その結果、それが SEO 対策になり、流入数の向上も見込めます。

さらに、AMP の制約を守り続けることでサイトスピードが維持されるため、サイト全体の離脱率も高まりにくいです。AMP 提供元の Google がプラグインを開発しているので、安心してプラグインに頼ることができます。サイトを放置しても自動でアップデートされるので、細かい仕様変更でエンジニアの手を借りる必要がなくなります。

おわりに

『KIWAMINO』は WordPress と AMP プラグインで Canonical AMP サイトにしました。WordPress と Canonical AMP を採用した理由は 3 つです。

(1) AMP の制約によって、サイトスピードが速くなるから

(2) エンジニア・デザイナーの学習および開発コストが低いから

(3) 巨大な組織・コミュニティの恩恵を受けられるから

以下、実際に運用してみた結果です。

(1) 数ヶ月間、実運用しましたが、問題なくサイトスピードは保たれています。少し困っていることは AMP プラグインが新しい JavaScript ファイルを検知した際、管理画面でそのファイルを許可するまで AMP が無効になることです。それ以外は特に困っていません。

(2) 予想通り、エンジニア・デザイナーの学習および開発コストは低かったです。

『KIWAMINO』のデザイナーさんに質問
『KIWAMINO』のデザイナーさんに質問

『KIWAMINO』のデザインを担当した方に技術ブログに掲載することは伝えず、Slack の DM で質問しました。

hayatoise「AMP HTML は一切書いたことがなかったという認識で OK ですね?!」

designer「YES! でも今も書いてる認識はないですけどね〜うふふ」

AMP プラグインは HTML を AMP HTML に自動で変換してくれます。なので、デザイナーの方は一切 AMP HTML を書く必要がないです。HTML と CSS の知識だけでコーディングが可能です。本人も一切書いてる認識はないようです。新しい技術を採用しましたが、デザイナーの学習および開発コストは殆ど高まっていません。

また、エンジニアの学習および開発コストも殆ど高まっていません。一度、AMP プラグインと他のプラグインが競合し、AMP エラーが発生しました。しかし、WordPress に関する情報量が多かったため、あまり詰まること無く問題を解決できました。オウンドメディアを開発する程度だと、AMP の学習コストは殆ど必要ないことも分かりました。

(3) 前述した通り、WordPress に関する情報量は非常に多いです。大抵の問題の解決方法は検索すれば見つかります。今のところ、あまり詰まったことはありません。

また、ちょっと機能を試したい時、WordPress プラグインに頼れることは非常に有り難いです。例えば、記事内でアンケートを取りたいと言われた場合、それを可能とするプラグインが存在するので、インストールするだけでビジネスサイドに提供できます。

結論、WordPress, Twenty Nineteen & AMP Plugin で Canonical AMP サイトは良いぞ

採用情報

hrmos.co

【イベント告知】一休のデータドリブン経営を"超具体的"に解剖

f:id:ikyuinouen:20190903140458p:plain

■応募方法
応募はこちらから

一休 × SmartNews イベント応募ページ

■イベントについて

一休はデータ活用を最大限にレバレッジした「データドリブン経営」を実践し、 第二創業期に入った現在も成長を続けています。 一休におけるデータサイエンティスト・マーケターは、経営を補完する役割ではなく 「経営・事業を動かす最重要な役割」を担っています。 自らもデータサイエンティストの代表・榊を中心としたチームで取り組んでいます。

「データドリブン経営」の最前線にいらっしゃるスマートニュース社 西口一希様と共に、 一休の実データを踏まえた"超具体的"な解剖や、強いデータサイエンティスト・マーケター になるためのポイントなどをセッションします。

■具体的には

・強いデータサイエンティスト、マーケターになるためのポイントとは
・「PL責任をもつ、ビジネス感度を上げる」といった、本当の意味で活躍できるデータサインティストについて
また当日は一休の具体的なデータを用い、セッションを行います。
■応募方法

すべての説明をお読みの上、下記「応募フォーム」よりお申し込みください。

一休 × SmartNews イベント応募ページ

■タイムテーブル

時間  内容
18:20 受付開始
19:00 一休のデータドリブン経営の紹介&トークセッション
20:40 懇親会(自由参加)
22:00 終了

■注意事項

①名刺2枚お持ちください
②電源設備のご用意がございません。ご注意下さい。
③参加人数に限りがございますので、事前にご参加が難しくなった際はお早めにキャンセルのご協力をお願いいたします
その他、ご不明点がある方は下記メールアドレスよりご連絡下さい。
【問い合わせ先】business_event@ikyu.com

■榊の過去資料

bdash-marketing.com

logmi.jp

AWS Elastic beanstalkからAmazon EKSへ移行する

以前の記事でも簡単に紹介した通り、一休では、アプリケーションのAWS Elastic beanstalkからAmazon EKSへの移行を進めています。

user-first.ikyu.co.jp

この記事では、その背景や、実際の設計、実際にAmazon EKSを活用してみて気付いた点、困った点、今後の展望を紹介したいと思います。

AWS Elastic beanstalkの辛い点

新しい環境の構築や運用が大変

  • 一休ではAWSのリソースをTerraformを使って管理しています。新しくウェブアプリケーションを立ち上げて、Elastic beanstalkで動かす場合、以下の作業をする必要があります。

  • Terraformで、Elastic beanstalkの定義を作ってリリースする。

  • 新しいアプリケーションのデプロイを通知するように自前で作ったAWS lambdaを修正。
  • アプリケーションのCI/CDの構築。
  • (必要に応じて)Route53の調整。

これを検証環境と本番環境の両方で実施する必要があります。面倒です。 さらに、TerraformとElastic beanstalkはあまり相性がよくないようで、意図しない変更差分が発生してしまったりします。 また、新しいインスタンスタイプが出てきたときに、環境によっては、完全に再作成しないと使えない場合があります。 実際、一休では、c5系やt3系のインスタンスが既存環境では使えずに、かなりの工数をかけて環境を再構築しました。 EC2とALBやAutoscalingをなまで使うよりElastic beanstalkを使うほうがはるかに楽なのは間違いないのですが、もっと楽に環境構築や運用ができる方法があれば、そっちに移行したい。

計算リソースを最適に使えていない

Elastic Beanstalkの場合、↓のようなアプリ配置をしなければならず、その結果、計算リソースの余剰を抱え込まざるを得ないです。

  • どんなに小さいアプリケーションでも可用性を確保するため、2台のec2インスタンスを割り当てている。
    • 2台ないとデプロイするときにダウンタイムが発生してしまいます。
  • ひとつのECインスタンスでひとつのアプリケーションだけを動かす。
    • 厳密にいえば複数のアプリを動かすことも可能ですが、設計的な無理が生じます。

実際に本番環境で動作しているすべてのEC2のCPU利用率の平均を算出してみたのですが、大半が使われていないことがわかりました。メモリも同様です。 オートスケールに依存しない設計にしているので、リソースはある程度余裕をもって割り当てています。なので、計算リソースの余剰がある程度あるのは設計通り、なのですが、さすがにかなりもったいない。

Amazon EKSへの移行による解決

Amazon EKSへ移行することで上述の2点は以下のように解決されると考えました。

  • 環境構築のTerraformから脱却しアプリケーションの構成や運用に関する定義はなるべくひとつのリポジトリに集約する。
  • コンテナオーケストレーション基盤で動かすことで、ECインスタンスとコンテナの関係が、1 対 1から 多 対 多になり、計算リソースを効率的に使える。

なぜAmazon EKSにしたのか

AWSの場合、ECSを使うという選択肢もあります。ECSを使うか、EKSを使うかの2択になりますが、EKSを選びました。 KSはKubernetesという業界標準であり今後も大きく進化していく仕組みを提供するため、ECSよりもコミュニティ、業界による改善の恩恵を受けやすい、と考えたからです。

構成と利用しているツールやアドオン

構成は下図の通りです。

f:id:s-tokutake:20190826124302p:plain

  • クラスタをふたつ構築し、Spinnakerを使い、同じアプリをデプロイし、Fastlyでロードバランシングします。
  • AWS ALB Ingress Controller とexternal-dns(https://github.com/kubernetes-incubator/external-dns)を使うことで、ロードバランサの定義とroute53の設定をKubernetesの管理下に置きます。
  • DockerイメージはAWS ECRに置きます。

eksクラスタの作成には、eksctl を利用しました。定義は以下の通りです。

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: cluster01
  region: ap-northeast-1
  version: "1.13"

vpc:
  id: "vpc-xxxxxx
  cidr: "xx.xx.xx.xx/16"
  subnets:
    private:
      ap-northeast-1a:
        id: "subnet-aaaaaaaa"
        cidr: "xx.xx.xx.xx/22"

      ap-northeast-1c:
        id: "subnet-bbbbbbbb"
        cidr: "xx.xx.xx.xx/22"

      ap-northeast-1d:
        id: "subnet-ccccccccc"
        cidr: "xx.xx.xx.xx/22"

nodeGroups:
  - name: ng1
    labels: {role: workers}
    tags: {Stack: production, Role: eks-node, k8s.io/cluster-autoscaler/cluster01: owned, k8s.io/cluster-autoscaler/enabled: "true"}
    instanceType: c5.2xlarge
    desiredCapacity: 5
    maxSize: 8
    volumeSize: 100
    privateNetworking: true
    securityGroups:
      attachIDs: [sg-xxxx,sg-xxxx]
      withShared: true
    ssh:
      allow: true
      publicKeyPath: xxxxxxx
  • EKS作成時に新規作成される専用VPCではなく既存のVPCを利用します。
    • 新規で作成される専用VPCを使うと既存のVPCとの接続や設計上の衝突の解決など付随するタスクが多く発生すると考えたからです。
    • サブネットも同様です。
  • デフォルトのままだとディスクのサイズが小さいので volumeSize: 100 にすることで、ある程度の大きさを確保します。

利用したhelmチャート

helmはKubernetesのパッケージ(=チャート)管理ツール。helmでインストールできるチャートはなるべくhelmでインストールし、helmfileで宣言的な記述にして管理しています。 利用しているチャートは以下の通りです。

  • AWS ALB Ingress Controller

    • KubernetesのIngressとして、ALB を使えるようにするコントローラです。
  • external dns

  • kube2iam

    • Podに対してIAMポリシーを適用する仕組みを提供します。
  • datadog

    • 一休は、モニタリングにDatadogを使っています。また、APMやログもすべてDatadogを使うよう、現在移行作業を実施中です。Kubernetesでも引き続き使っていきます。
      • ログについては全部Datadog Logsに送信するとコストが高くついてしまうので、日々の運用で検索や分析に使うログだけをDatadog Logsに送りつつ、すべてのログをfluentdを使ってS3に送り、何かあったときに後から調査できるようにしてあります。 fluentdはDeamonSetで動かしています。

CI/CDをどのように構築するか

クラスタを運用するにあたって、管理する必要のある各種定義ファイルは以下の通りです。

  • 上述のeksctlのパラメータとなるクラスタの定義ファイル
  • helmチャートなどすべてのクラスタに共通の設定
  • Kubernetesのマニュフェストファイル
  • Spinnakerのパイプライン定義

このうち、eksctlのパラメータとなるクラスタの定義ファイルはそれほど頻繁にapplyするものではないので、Githubで管理しつつCI/CDの仕組みは構築しませんでした。 helmチャートは、クラスタに共有の設定になります。これは、ひとつのリポジトリにまとめて、circleciで CI/CDを構築しました。 KubernetesのリソースのマニュフェストファイルとSpinnakerのパイプライン定義はすべてのアプリケーションの定義をひとつのリポジトリににまとめてCI/CDを構築しました。 アプリケーションごとに別々のリポジトリにする設計やアプリケーションのリポジトリに入れてしまうというやり方も検討しましたが、まずはまとめて管理してみて、難点が出てきたら再度検討する、ということにしました。 また、アプリケーションのデプロイはコンテナリポジトリへのPushをトリガにするように、Spinnakerのトリガを設定しています。これによって、アプリケーション側はEKSを意識する必要をなくしました。

f:id:s-tokutake:20190828123537p:plain

苦労した点、気づいた点

DNS関連の課題

CoreDNSのポッドが落ちました。幸い少量のトラフィックを流してテストしている時期だったので大事には至りませんでした。 原因はわからないのですが、調査してみると、以下の記事が見つかりました。欧州のファッションサイトで発生した、DNS起因のkubernetes障害の振り返りの記事です。

kubernetes-on-aws/jan-2019-dns-outage.md at dev · zalando-incubator/kubernetes-on-aws · GitHub

DNS関連に次のような課題があることがわかりました。

"ndots 5 problem"

このコメント に詳しいのですが、kubernetesで動かしているPodのデフォルトの/etc/resolv.confは、以下の通り、ndots:5 が指定されます。

nameserver 172.20.0.10
search default.svc.cluster.local svc.cluster.local cluster.local ap-northeast-1.compute.internal
options ndots:5

この場合、解決対象の名前に含まれているドットの数が5より小さいと、search に含まれているドメインを順に探して、見つからなかった場合に、最後に与えられた名前を完全修飾名として、探します。 例えば、www.ikyu.com を解決するなら、www.ikyu.com.default.svc.cluster.local を探す => ない => www.ikyu.com.svc.cluster.local cluster.local を探す => ない ... を繰り返して、search に書かれているドメインで見つからない => www.ikyu.com で探す => みつかった。という流れになります。その結果、ひとつのクラスタ外の名前を解決するだけで想定以上の名前解決リクエストが発生します。

この問題は、Podの(dnsConfig)https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-configndots:1 になるように記述をして対処しました。 PodのdnsConfigと(dnsPolicy)https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-policyを記述することで、/etc/resolv.confをカスタマイズすることができます。

CoreDNS自体の信頼性

Amazon EKS(kubernetes 1.13)が使うCoreDNSのバージョンは、1.2.6で、最新のバージョンと比べると古いです。 configmapを覗いて、Corefileの中身を見てみると、proxy プラグインを使っているのがわかります。 で、この proxy プラグインは、CoreDNSの最新のバージョンではソースコードごとなくなっています。 経緯は次のissueに詳しいです。

deprecate plugin/proxy · Issue #1443 · coredns/coredns · GitHub

  • proxy はバックエンドに対するヘルスチェックに問題がある。
  • forward プラグインのほうがコードもシンプルでソケットをキャッシュするので高速に動作する。

kubernetesも1.14から forward を使うようになっているようです。公式のライフサイクルから判断すると、Amazon EKSが1.14に対応するのは9月。この問題についてはバージョンアップするしか解決のしようがなさそうなので、EKSで1.14が使えるようになったら速やかにバージョンアップすることにしました。

また、EKSではデフォルトのCoreDNSのPodの数は2つになっています。処理自体は2つで十分捌けるのですが、Podが落ちるという現象に当たってしまったので安全を見てPodの数を増やしました。

また、以下を参考にしながら、Nodelocal DNS Cacheを導入してみたのですが、なぜか名前解決の速度が劣化してしまい、導入を断念しました。また、詳しく調査してチャレンジしてみたいと思います。

kubernetes/cluster/addons/dns/nodelocaldns at master · kubernetes/kubernetes · GitHub

Descheduler は入れたほうがいい

運用を開始してみるとPodがノード上で偏り、あるノードは50%を超えてメモリを使っているのに別のノードはスカスカ、というようなことが起きました。 対策としてdescheduler をCronJobで動かすことで、定期的にPodの再配置を行い、ノードのリソース利用状況を平準化しています。 descheduler自体はとても簡単に導入できます。

負荷試験はやったほうがいい

最初は、現状のElastic Beanstalkのリソースの利用状況やリクエスト数を見ながら計算をしてPod数やcpu/memoryの requests/limits を割り出していたのですが、実際にリリースしてみると想定通りリクエストを捌けませんでした。ある程度、トラフィックのあるアプリケーションの場合は、机上計算だけではなくきちんと負荷試験をやったほうがよさそうです。

SIGTERMを受けたらグレースフルにシャットダウンするアプリケーションにしておく

Podの終了については以下の記事が大変詳しいです。

Kubernetes: 詳解 Pods の終了 - Qiita

KubernetesはPod内のプロセスにSIGTERMを送信することでPodを止めます。 SIGTERMを受けたらグレースフルにシャットダウンするアプリケーションにしておく必要があります。

当初の目的は達せそうか

冒頭に書いた通り、以下のふたつがAmazon EKSへの移行で解決したい課題でした。

  1. アプリケーションの動作環境の構築を簡単にする。
  2. 計算リソースをより効率的に使えるようにする。

1.については、Elastic beanstalkに比べてはるかに簡単かつ素早くに環境が作れるようになりました。が、そう感じるには慣れと習熟が必要なのも確かです。 多くの人が書いていますが、マニュフェストファイルを理解するのはどうしても時間がかかります。 すべてのエンジニアが簡単に環境を構築できるようにするのなら、なんらかのscaffolding的なものが必要だと感じました。

計算リソースの利用の効率化は手ごたえを感じています。Elastic beanstalkからすべてをEKSへ移行できたら大きくコストダウンできそうです。

まとめと今後の展望

現時点で、Amazon EKSで動いているのは、pythonのwebアプリケーションとgoのgrpcサーバです。その他の、一休で動かしているLinux系のアプリケーションは、Elastic beanstalk時代からDockerで動いていたため、Amazon EKSへの移行はスムーズにできそうで、すでに、移行の目途が立っています。

あとは、Windows系のアプリケーションをどうするか、ですが、kubernetes 1.14からWindowsのコンテナがサポートされます。 一休のWindows系のアプリケーションはコンテナでは動いていませんが、これを機にコンテナ化にチャレンジしたいと考えています。 kubernetesは複数のクラウドプロバイダの差異を抽象化するレイヤとして進化しているように思います。 例えば、あるクラウドプロバイダで大規模な障害が起こって、マルチクラウドにデプロイせよ!!となったときに、kubernetesで動作しているアプリケーションなら、クラウドプロバイダの違いを意識せずに、アプリケーションのデプロイができるはずです。実際に動作するどうかは別問題ですが。 そう考えると、Windows系のアプリケーションもコンテナ化してkubernetesで動かせるようにすることで、今後のクラウド/コンテナ技術の恩恵をしっかり受けれるようにしておきたいと感じています。 また、バッチ実行基盤もkubernetesベースのジョブエンジンに切り替えることで、可用性や信頼性を改善し、効率的にリソースを使えるようにする、ということにも取り組んでいきたいです。

採用情報

一休では、クラウド/コンテナ技術に経験がある方 or 興味がある方やSREやDevopsを通じて価値あるサービスを世に届けたい方を募集しております。

hrmos.co

hrmos.co


この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、開発支援、SREを行なっています。

Amazon EKSでgRPCサーバを運用する

以前の記事でも紹介した通り、一休では、gRPCを使ったサービスを導入し始めています。

user-first.ikyu.co.jp

この記事では、このサービスをAmazon EKSで提供するための設計や気をつけたポイントについて紹介します。

背景

一休では、ウェブアプリケーションの実行環境としてAWS Elastic Beanstalkを採用しています。 そして、この4月からElastic BeanstalkをAmazon EKSへ移行するプロジェクトを進めています。 このgRPCサービスもElastic Beanstalkで運用をしていましたが、以下の問題を抱えていました。

  • 適切にロードバランシングできない。
    • Elastic BeanstalkでgRPCサービスを運用しようとするとNetwork Load Balancer(NLB)を使うことになります。NLBはレイヤ4のロードバランサです。一方で、gRPCはhttp/2で動作し、ひとつのコネクションを使いまわします。このため、NLBでは、特定のサーバに負荷が偏ってしまう場合があります。

また、EC2サーバのdockerdのcpu利用率が突然上昇する、アプリケーションのデプロイに時間がかかるというような問題も起き始めていたため、Amazon EKSへ移行することでこれらの問題の解決を試みました。

KubernetesでgRPCサーバを動かす場合の注意点

Amazon EKSは、AWSが提供するマネージドなKubernetesです。KubernetesでgRPCサーバ動かす場合の注意点は上述のNLBの場合と同様、以下の点になります。

  • KubernetesのServiceはL4のロードバランサであり適切に負荷分散できない。

このため、KubernetesのServiceではなく別の仕組みを使って負荷分散を実現する必要あります。

Envoyを導入する

多くの事例でEnvoyを使ってこの問題を解決しています。

www.envoyproxy.io

AWSのApplication Load Balancer(ALB)がhttp/2に対応しているのはクライアントとALB間の通信でありALBからバックエンドへの通信はhttp/2に対応していない、だから、gRPCサーバの場合はNLBを使う必要がある。そしてNLBを使うと上述の問題にぶつかる、、、ということでしたが、Envoyは、公式サイトに書かれている通り、クライアントとEnvoyの間もEnvoyとバックエンドサーバの間も両方ともHTTP/2とgRPCをサポートします。一休でもEnvoyを使うことに決めました。

構成

GitHub - GoogleCloudPlatform/grpc-gke-nlb-tutorial: gRPC load-balancing on GKE using Envoy

このGKEでの事例を参考にしつつ、以下のような配置構成にしました。

f:id:s-tokutake:20190826085145p:plain

Kubernetesクラスタ外部からのアクセスを受ける必要があるため、type: LoadBalancer のService経由でEnvoyが外からのアクセスを受けます。EnvoyのバックエンドはHeadless Serviceにします。Headless Serviceを使うと背後にあるPodに直接アクセスできるレコードがクラスタ内部DNSに作成されます。EnvoyはこのDNSレコードからPodのIPを取得し、Envoy側の構成にしたがって負荷分散を行います。

Envoyの設定

Envoyの構成情報はKubernetesのComfigMapで設定します。内容な以下の通り。

apiVersion: v1
kind: ConfigMap
metadata:
  name: grpc-service-proxy
data:
  envoy.yaml: |
    static_resources:
      listeners:
      - address:
          socket_address:
            address: 0.0.0.0
            port_value: 50051
        filter_chains:
        - filters:
          - name: envoy.http_connection_manager
            config:
              access_log:
              - name: envoy.file_access_log
                config:
                  path: "/dev/stdout"
              codec_type: AUTO
              stat_prefix: ingress_http
              route_config:
                name: local_route
                virtual_hosts:
                - name: https
                  domains:
                  - "*"
                  routes:
                  - match:
                      prefix: "/"
                    route:
                      cluster: grpc-service
              http_filters:
              - name: envoy.health_check
                config:
                  pass_through_mode: false
                  headers:
                  - name: ":path"
                    exact_match: "/healthz"
                  - name: "x-envoy-livenessprobe"
                    exact_match: "healthz"
              - name: envoy.router
                config: {}
      clusters:
      - name: grpc-service
        connect_timeout: 0.5s
        type: STRICT_DNS
        dns_lookup_family: V4_ONLY
        lb_policy: ROUND_ROBIN
        drain_connections_on_host_removal: true
        http2_protocol_options: {}
        hosts:
        - socket_address:
            address: grpc-service.default.svc.cluster.local
            port_value: 50051
        health_checks:
          timeout: 3s
          interval: 5s
          unhealthy_threshold: 2
          healthy_threshold: 2
          grpc_health_check: {
            service_name: Check
          }
    admin:
      access_log_path: /dev/null
      address:
        socket_address:
          address: 127.0.0.1
          port_value: 8001

注意点としては、以下の通りです。

  • drain_connections_on_host_removal: true を設定すること。
    • この設定がない場合、バックエンド側のServiceからPodが外れても、そのPodのヘルスチェックが失敗するまで、Envoyは外れたPodにトラフィックを流し続けます。その結果、デプロイ時にリクエストを落としてしまう可能性が発生します。
  • grpc_health_check を指定すること。
    • バックエンド側がgRPCサービスなのでこれを設定します。

gRPCサービス側の構成

gRPCサービスはElastic BeanstalkでもDockerを使って動かしていました。したがって、EKSに動かすために大きな変更は必要ありませんでした。唯一必要だったのがヘルスチェックです。 上述の通りNLBではgRPCサービスのヘルスチェックをすることができません。NLBのヘルスチェックはTCPのポートの疎通確認ですが、厳密にいえばこれはアプリケーションの死活監視になりません。一方、Kubernetesではヘルスチェック( livenessprobereadinessprobe)は重要な役割を果たしますので、設定する必要があります。 gRPCサービスに対するヘルスチェックについては以下の記事に方法が書いてあります。

Health checking gRPC servers on Kubernetes - Kubernetes

この記事に従って、以下の2点を行いました。

  • gRPCサービスにヘルスチェックのエンドポイントを実装する。
  • Dockerイメージ内にgrpc-health-probeを同梱する。

そして、gRPCサービスのPodの定義を以下のようにしました。

...略...
spec:
      containers:
        - name: grpc-service
          image: ["ImageName"]
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 50051
          readinessProbe:
            exec:
              command: ["/bin/grpc_health_probe", "-addr=:50051","-service=Check"]
            initialDelaySeconds: 5
          livenessProbe:
            exec:
              command: ["/bin/grpc_health_probe", "-addr=:50051","-service=Check"]
            initialDelaySeconds: 10

...略...

リクエストを落とさずにデプロイをする

ここまで構築してリクエストを流してみると、ひとつのコネクションで多数のリクエストを送信しても、きちんと複数のPodにトラフィックが分散されることがわかりました。

あとは、EnvoyもgRPCサービスもリクエストを落とさずにデプロイできるように設定する必要があります。

Envoyの場合

Envoyの場合は、以下のissueがとても参考になりました。

Graceful HTTP Connection Draining During Shutdown? · Issue #7841 · envoyproxy/envoy · GitHub

このissueによれば、/healthcheck/fail というエンドポイントにPOSTリクエストをすることでEnvoyはそれ以後受け付けたリクエストの接続をクローズするようです。これを活用して、EnvoyのPodの定義のlifecycleに以下の通り書けばリクエストを落とさずにPodの入れ替えができそうです。

...略...
lifecycle:
            preStop:
              exec:
                command:
                  - /bin/sh
                  - '-c'
                  - >-
                    wget -qO- --post-data=''
                    http://127.0.0.1:8001/healthcheck/fail && sleep 10
...略...

preStopフックで wgetを使ってhttp://127.0.0.1:8001/healthcheck/failにPOSTリクエストを行い、その後10秒待っています。 127.0.0.1:8001 には Envoyの管理用のAPIが立ち上がっています。 この設定によって、Podが停止する流れは大まかに以下のようになります。

  1. PodがTerminating 状態になる。同時にpreStopフックが動き、healthcheck/fail にPOSTリクエストがされたあと、10秒待つ。
  2. Podには、リクエストは来るが、レスポンスを返すときにコネクションは閉じられる。
  3. 10秒待っている間に、ServiceからPodが外れる。以後、新しいリクエストは来なくなる。接続中のリクエストも10秒間ですベて処理される、はず。
  4. 10秒停止後、SIGTERMで、コンテナが停止される。すでに接続がない状態なので、リクエストは落とさない。

この設定をして、トラフィックを流しつつ、Envoyのデプロイを行ってみたところリクエストを落とさずにPodが入れ替わることが確認できました。

gRPCサーバの場合

注意点は以下のふたつです。

  • 上述の通り、Envoyの構成で drain_connections_on_host_removal: true の設定をする。
    • この設定をしないとEnvoyはヘルスチェックが通り続ける限り、古いReplicaSetに属するPodにもトラフィックを流し続けます。
  • アプリケーションはSIGTERMを受け取ったら、グレースフルにシャットダウンする処理を実行する。
    • 今回のサービスはGoのgRPCサービスなので、GracefulStop()を呼び出すようにしています。

このふたつをやっておけば、ローリングアップデートでもBlue/greenでもリクエストを落とさずにPodの入れ替えができることを確認できました。

まとめ

今回は、gRPCサービスのEKSでの動かした方を紹介しました。 一休では、現在、主要サービスのAmazon EKSへの移行を進めています。この移行の全体像については、別途、紹介するつもりです。

参考資料

文中に紹介したリンク以外にも以下のサイトを参考にさせて頂きました。

Kubernetes上でgRPCサービスを動かす | SOTA

kubernetesでgRPCするときにenvoy挟んでみたよ - Qiita

Kubernetes: 詳解 Pods の終了 - Qiita

spinnaker/solutions/bluegreen at master · spinnaker/spinnaker · GitHub

Manage Traffic Using Kubernetes Manifests - Spinnaker


この記事の筆者について

  • システム本部CTO室所属の 徳武 です。
  • サービスの技術基盤の開発運用、開発支援、SREを行なっています。

go-mssqldbでタイムゾーンが常にUTCになる

f:id:ryo-utsunomiya:20190614171536p:plain:w320

こんにちは。宿泊事業本部の宇都宮です。

一休では、基幹データベースにSQL Serverを使用しています。また、Goアプリケーションでは、go-mssqldbというライブラリを使用して、データベースとのやりとりを行っています。

このgo-mssqldbには、タイムゾーンに関して厄介な挙動があります。タイトルにもあるように、タイムゾーンが常にUTCになってしまうのです。本記事では、go-mssqldbのタイムゾーン関係の振る舞いと、go-mssqldbを使いつつ正しくタイムゾーンを扱うための対処法を紹介します。

go-mssqldbのタイムゾーン問題

go-mssqldbのタイムゾーン問題は、以下のコードで再現できます。

package main

import (
    "database/sql"
    "fmt"
    "log"
    "os"
    "time"

    // blank import必須
    _ "github.com/denisenkom/go-mssqldb"
)

func main() {
    conn, err := sql.Open("sqlserver", "sqlserver://user:password@dbhost:1433?ApplicationIntent=ReadWrite&MultiSubnetFailover=Yes&database=DB")
    if err != nil {
        fmt.Fprint(os.Stderr, err)
        os.Exit(1)
    }

    row := conn.QueryRow(`
select
    GETDATE()
`)

    var dbnow time.Time
    if err := row.Scan(&dbnow); err != nil {
        fmt.Fprint(os.Stderr, err)
        os.Exit(1)
    }

    now := time.Now()
    after1Hour := now.Add(1 * time.Hour)

    fmt.Println(after1Hour.After(now))
    fmt.Println(after1Hour.After(dbnow))

    fmt.Println(now)
    fmt.Println(after1Hour)
    fmt.Println(dbnow)
}

dbnowにはDBの現在時刻から取得した時刻が、nowにはサーバの現在時刻が入ります。after1Hourはサーバの現在時刻から1時間後です。したがって、 after1Hour.After(now)after1Hour.After(dbnow) は、DBとサーバの時計が1時間以上ずれていない限り、trueになるはずです。

しかし、DBとサーバの両方のタイムゾーンがJST(+09:00)の状態でこのコードを実行すると、 after1Hour.After(now) はtrue、after1Hour.After(dbnow)はfalseになります。

原因は、このコードの出力を見ると分かります。

f:id:ryo-utsunomiya:20190823155940p:plain:w320

dbnow は日時は同じ(2019-08-23 15:54)ですが、タイムゾーンがUTCになっています。UTCでJSTと表面上の日時が同じと言うことは、実質9時間後の日時ということです。そのため、after1Hour.After(dbnow)はfalseになってしまうのです。

タイムゾーン問題の対処方法

go用の mysqlドライバ のように、Data Source NameにDBサーバのロケーションを指定できる機能があればよいのですが、go-mssqldbにはそのような機能はありません。

この問題を解決しようとしているissueやプルリクエストはいくつか見つかりますが、近いうちに取り込まれそう、というステータスではありません。

そこで、ライブラリ側の解決を待つのではなく、利用者の側で対処する必要があります。

最も根本的な対応は、datetimeoffset型の使用です。datetimeoffset型はタイムゾーンも保持しているため、日時の保存にこの型を使っておけば、go-mssqldbを使っていても問題なくタイムゾーンを扱えます。

すでにdatetime型で日時を保存していて、datetimeoffset型での保存が難しい場合は、TODATETIMEOFFSET関数を使ってデータを取得する際に型変換しましょう。

   row := conn.QueryRow(`
select
    GETDATE()
  , TODATETIMEOFFSET(GETDATE(), '+09:00')
`)

    var dbnow time.Time
    var dbnowoffset time.Time
    if err := row.Scan(&dbnow, &dbnowoffset); err != nil {
        fmt.Fprint(os.Stderr, err)
        os.Exit(1)
    }

    now := time.Now()
    after1Hour := now.Add(1 * time.Hour)

    fmt.Println(after1Hour.After(now))
    fmt.Println(after1Hour.After(dbnow))
    fmt.Println(after1Hour.After(dbnowoffset))

    fmt.Println(now)
    fmt.Println(after1Hour)
    fmt.Println(dbnow)
    fmt.Println(dbnowoffset)

dbnowoffset は+0900(JST)になっているため、after1Hour.After(dbnowoffset) はtrueになります。意図したとおりの動作といえます。

f:id:ryo-utsunomiya:20190823161158p:plain:w320

C#のgRPCクライアントではChannelを再利用しよう

f:id:ryo-utsunomiya:20190801150551p:plain:w160

こんにちは。宿泊事業本部の宇都宮です。6月に、Go + gRPCという構成のサービスを運用開始したという記事を書きました。

Go + gRPCによるマイクロサービス構築 - 一休.com Developers Blog

本番運用開始から2ヶ月ほどたち、いくつかのトラブルがありつつ、現在も元気に稼働中です。

運用していく中で定常的に発生していたgRPCのタイムアウトエラーについて、その対処法がわかったので、紹介します。

なお、本記事の知見はC#でのgRPCクライアント実装においては有用でしたが、他の言語では適用できない可能性が高いです。各言語のドキュメントもあわせてご参照ください。

gRPCのタイムアウト

以前の記事で紹介したマイクロサービスは、30~60req/sec程度のリクエストを常時受け付けます。

社内のサービスの中で、このマイクロサービスに最も頻繁にリクエストを送るのは認証基盤で、全リクエストの9割以上を占めます。なお、認証基盤はC#(ASP.NET MVC)で実装されています。

この認証基盤のログをみると、1時間に5件程度、gRPCの「Deadline Exceeded」エラーが発生していることがわかりました。これは通信タイムアウト時に発生するエラーです。

認証基盤では、当初、gRPCのタイムアウトを5秒に設定していました。これは大幅に余裕を持たせた数字で、実際、99%のリクエストは0.2秒以内にレスポンスを受け取っていました。また、gRPCサーバでも処理時間のログを取っていますが、こちらには1秒以上レスポンスにかかっているようなログは出ていませんでした。

つまり、gRPCのタイムアウトはレスポンスの遅延に起因するものではなく、クライアント-サーバ間のコネクションの確立失敗に起因するのでは? と仮説を立てました。

対応(1) タイムアウトの短縮とリトライ

当初、タイムアウトを短くして、代わりにリトライを行うようにすればいいのでは? と考えました。

C#でPollyを使うと、こんな感じのコードになります:

using Polly;

...

var client = new MemberServiceClient() { DeadLineSeconds = 1 };
var response = Policy.Handle<Exception>()
    .Retry(3)
    .Execute(() => client.GetMember(memberSeq));

この実装では、タイムアウトは 改善しませんでした。リトライは確かに行われるのですが、一度失敗した状態からリトライしても、失敗し続けてしまい、何回リトライしても同じ、という状態でした。

対応(2) Channelの再利用

C#向けのgRPCライブラリのドキュメントはここにあります。中でも特に重要なのは、サーバとの接続を管理する Grpc.Core.Channel クラスのドキュメントでしょう:

https://grpc.github.io/grpc/csharp/api/Grpc.Core.Channel.html

このドキュメントの冒頭には、以下のように書いてあります。

Channels are an abstraction of long-lived connections to remote servers. More client objects can reuse the same channel. Creating a channel is an expensive operation compared to invoking a remote call so in general you should reuse a single channel for as many calls as possible.

要するに、Channelはコネクションプールなので、インスタンスを使い回した方がいいよ、ということです。

もとの実装では、gRPCクライアントをnewするたびに新しいChannelを作っていました。この実装を改め、Channelは一度作成した後は、static変数に保持してサーバのライフタイムに渡って使い回すようにしました。

using Grpc.Core;

...

// もとの実装(ClientのDisposeでChannelをShutdown)
using(var client = new MemberServiceClient(new Channel(GetServerHost(), GetServerPort(), ChannelCredentials.Insecure)))
{
  //
}

// Channelを使い回すようにした実装(Application_EndでChannelをShutdown)
var client = new MemberServiceClient(GetChannel());

...

private static Channel _channel { get; set; }
private static object channelLock = new object();

public static Channel GetChannel()
{
    if (_channel == null)
    {
        lock (channelLock)
        {
            _channel = new Channel(GetServerHost(), GetServerPort(), ChannelCredentials.Insecure);
        }
    }

    return _channel;
}

この変更をしたところ、認証基盤のgRPCと通信する部分のスループットが2倍になり、さらに、gRPCのタイムアウトもほとんど発生しなくなりました。タイムアウトが発生した場合でも、Channelを頻繁に再作成していた場合と異なり、リトライすれば成功するようになりました。

むすび

gRPCは、ハイパフォーマンスなサービスを作るための強力なスタックですが、ややクセがあります。特に、コネクション周りは実装(言語)依存の部分が大きいため、クライアントを書く言語向けのドキュメントには一通り目を通した方がいいな、と思いました。

社内マーケター向けの機械学習プラットフォームを作りました

はじめに

こんにちは。データサイエンス部の平田です。

一休でのデータ分析はJupyter NotebookやJupyter Labを用いてDWHにアクセスして行われることが多いですが、サービスそのものと分析環境が乖離していることにより、分析結果を継続的にサービスに取り込むのが難しい状況でした。 また、マーケティング部の方々がJupyterを使用して分析した結果に基づいて継続的に施策を行おうとしても、Airflowに組み込む際のエンジニアの負担はそこそこありますし、修正するたびに依頼をしなければならないなどコミュニケーションコストも発生します。 さらに、マーケティングに機械学習を取り入れたい場合でもairflow側で全部やってしまうと密結合になってしまいます。

そこで、Airflowから別の場所にあるJupyterを直接実行することによりエンジニアの負担は最小限にとどめ、自由に施策を打てるような仕組みとして機械学習プラットフォーム、通称ml-jupyterが生まれました。

f:id:ikyu_com:20190711124818p:plain
模式図

AirflowからJupyterをキックする

①まず、日次で実行しているAirflowからml-jupyter上のAPIをキックする関数を作ります。

キックするコードは以下のようになります。 大まかな流れとしては、カーネル起動→コードを取得→ウェブソケットで通信→カーネル終了となります。

Jupyter notebookでは一つのnoteごとにカーネルが割り当てられるため、まずその起動から始まります。

import json
import requests
import datetime
import uuid
from work.dags.base_taskset import BaseTaskset
from websocket import create_connection

class TriggerJupyterNotebook(BaseTaskset):

    @classmethod
    def execute(cls, **kwargs):
        ipynb_file = kwargs['ipynb_file']
        folder_name = kwargs['folder_name']

        # 施策ごとのノートブックファイル (Airflowから実行されるもの)
        notebook_path = f'/{folder_name}/{ipynb_file}.ipynb' 
        host = [host_name]
        base = f'http://{host}'

        # カーネルを起動
        url = base + '/api/kernels'
        response = requests.post(url)
        kernel = json.loads(response.text)
        print('kernel_id:', kernel['id'])

        # コードを取得
        url = base + '/api/contents' + notebook_path
        response = requests.get(url)
        file = json.loads(response.text)

        # セルのコードのみを抽出
        code = [c['source'] for c in file['content']['cells'] if len(c['source']) > 0 and c['cell_type'] == 'code']

        # WebSocketのオープン
        ws = create_connection(
            f'ws://{host}/api/kernels/' + kernel['id'] + '/channels'
        )

        # WebSocket上でメッセージを送る

        # カレントディレクトリを施策用のディレクトリに変更
        # (パッケージ等もカレントディレクトリを参照する)
        # 最後にカーネルの処理が完了したことを知るために、特定の文字列を出力する
        terminated_signal_str = uuid.uuid1().hex
        code = ['import os', f"os.chdir('/tf/{folder_name}')"] \
        + code + ["print('" + terminated_signal_str + "', end='')"]

        for c in code:
            msg_type = 'execute_request'

            content = {
                'code': c,
                'silent': False
            }

            hdr = {
                'msg_id': uuid.uuid1().hex,
                'username': 'airflow',
                'session': uuid.uuid1().hex,
                'data': datetime.datetime.now().isoformat(),
                'msg_type': msg_type,
                'version': '5.0'
            }

            ws.send(json.dumps({
                'header': hdr,
                'parent_header': hdr,
                'metadata': {},
                'content': content
            }))

        # WebSocketのレスポンスを取得
        error_flag = False
        while True:
            msg_type = ''
            while msg_type != "stream":
                rsp = json.loads(ws.recv())
                msg_type = rsp['msg_type']
                if rsp['content'].get('status') == 'error':
                    print('jupyter notebook error:', rsp['content']['evalue'])
                    error_flag = True
                    break

            # エラーを返却した場合、WebSocketをクローズして、処理を終了
            if error_flag:
                ws.close()
                break

            # 特定の文字列を含む場合、WebSocketをクローズして、処理を終了
            if terminated_signal_str == rsp['content']['text']:
                ws.close()
                break

        # カーネルを終了
        url = base + f"/api/kernels/{kernel['id']}"
        response = requests.delete(url)
        response.status_code  # 204ならOK

        return not error_flag

②PythonOperatorを作り、この関数を実行します。

PythonOperator(
    task_id='[task_id]',
    provide_context=True,
    python_callable=TriggerJupyterNotebook.execute,
    dag=subdag,
    op_kwargs={'folder_name': '[folder_name]', 'ipynb_file': '[file_name]'}
)

③新たな施策をAirflow上で組み込むときは、エンジニア側は「Jupyter上の〇〇ファイルを実行してほしい」 「〇〇が終わった後や〇〇テーブルが更新された後に実行してほしい」などの要望を聞き、上のPythonOperatorをコピペして少し変え、適切な順番で実行されるように実装するだけになります。

ただ、Airflowから無事キックできるようにするにはいくつか決めなければならないことがあります。

ライブラリ競合問題

施策ごとにライブラリのバージョンが異なる可能性がある場合、それぞれその施策独自のライブラリを見に行く必要があります。 Jupyter上で、

!pip install [hogehoge] -t .

とすることで、カレントディレクトリにライブラリがインストールされるようになります。

施策ごとにフォルダを切り、上記を実行することでそれぞれのフォルダ内で独立してライブラリをインストールできます。 Airflowからキックするコードにchdirコマンドを追加しています。(第1項)

code = ['import os', f"os.chdir('/tf/{folder_name}')"] \
+ code + ["print('" + terminated_signal_str + "', end='')"]

さらに、pandas, boto3など共通としてよさそうなライブラリは別途インストールしています。

終了検知問題

Jupyterでの実行終了を検知するのは結構難しい問題です。若干泥臭くなりますが、Airflowでランダムに生成した特定文字列をJupyter側で最後に出力させることで終了とみなすようにしました。もっといい方法があれば教えてほしいです。(第3項)

code = ['import os', f"os.chdir('/tf/{folder_name}')"] \
+ code + ["print('" + terminated_signal_str + "', end='')"]

また、Jupyter自体がエラーで終了することもあり得るのでフォローしています。

ml-jupyter上からできること

サービスAPIとマーケターをつなぐ

基本的に一休のCRM施策はDWHのデータを元に、SQLで対象者データやコンテンツ情報を抽出し、Ikyu Marketing Cloudという社内ツールから各種チャネルに配信しています。

ただし、DWHのデータは基幹DBの当日2:00までのデータであり、リアルタイムに基幹DBから情報を取得・追加・更新することができません。 上記のDB分離問題からマーケティング側の課題として、特定のユーザーに対して何らかのアクション(クーポンの付与や会員ランクの変更)をフレキシブルに行えないという問題がありました。

そこで、ml-jupyter上から基幹側の各種APIを呼べるようにライブラリを整備することで、各Notebookのインスタンス上でpythonを用いてAPIをコールするなど自由自在にクーポン付与等の施策を行えるようになりました。

機械学習のアプローチを用いたデータ生成

今まではMarketing Cloud上で配信する対象者等のデータ抽出については、SQLを用いて複雑なクエリやストアドプロシージャを組んで行っていましたが、SQL自体の言語仕様が貧弱なところもあり、機械学習のアプローチを用いてデータの作成をするのが困難でした。

ml-jupyterではJupyter Notebook上で、pandasscikit-learn等の強力なpythonライブラリを用いた機械学習のアプローチで各種データの生成ができるようになり、より一歩進んだCRM施策が行えるようになりました。

宿泊のリマーケティングクーポンの施策は機械学習のアプローチにてデータが作られ、Marketing Cloud上で日々配信が行われており、現状かなりの数値のリフトが見られています。

細かい話

マーケティングの方々はおそらくバージョン管理に興味ありません。(偏見)

そこで、jupyter notebookをcronでこっそり自動pushしています。

5年間の改善を経て、現在の一休がどうなっているのかを7/4(木)にお話します

レストラン事業本部の田中( id:kentana20 )です。

先週末にDevLOVE Xというイベントで開発組織改善の取り組みについて5年間の取り組みと今後、というテーマでお話しました。

5年間でどれくらい一休の開発組織が変わったのか

  • 技術面
  • 組織面

それぞれで実施した改善について、改善の裏側で起こっていたことや自分の所感も含めてお話しました。

現在の一休について7/4(木)にお話します

来週開催するエンジニア向けの説明会で、上記の5年間の取り組みを経て、現在の一休がどうなっているのかをCTOの伊藤がお話します。

ikyu.connpass.com

説明会と書いていますが、会社・事業や開発体制のお話だけでなく

  • 現在の開発組織やプロダクトの状況がどうなっているか
  • 今後どのように改善を進めていくのか

を含めてお話する予定です。

一休で働くことに興味がある方だけでなく

  • いまの現場を改善するためのヒントがほしい
  • 改善を進めている一休のエンジニアと直接お話したい

などなど、どなたでもご参加いただけます。

また、現場のエンジニアと個別にお話する時間も設けていますので、ご興味のある方はぜひご参加ください!

みなさんとお会いできることを楽しみにしています。

hrmos.co