RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

CloudFront + S3 でオリジンが更新されたら自動でキャッシュ削除する仕組みを作る

在宅勤務で引きこもりの才能に目覚めた DevOps チームの Shino です。

美味しいラーメンを食べたい衝動が時々訪れるのが悩みです。 おすすめの取り寄せラーメンがありましたら教えて頂きたく。(ジャンル問わず)

今回は CloudFront + S3 の構成で S3 上のコンテンツが更新されたら自動で CloudFront 上のキャッシュを削除する仕組みを作ったので簡単にまとめます。

概要

状況

CloudFront + S3 の構成で静的 Web ページなどの配信を行っている。

CloudFront が S3 上のコンテンツをキャッシュしてくれるので配信自体のパフォーマンスは悪くない。

課題

S3 上のコンテンツを更新するたびに、手動で CloudFront 上のキャッシュを削除しているため手間がかかる。

∵ キャッシュされたままの古いコンテンツが配信されてしまうのを防ぐため

解決策

Lambda を用いて S3 上のコンテンツが更新されたら自動で CloudFront 上のキャッシュを削除する。

f:id:shino8383:20200507001849p:plain

やったこと

全体の流れ

  1. S3 のオブジェクトが更新・作成されると Lambda にバケット名・オブジェクトのパスの情報を含んだイベントが渡される
  2. Lambda 上で、渡された S3 バケットをオリジンとする CloudFront ディストリビューションを取得する
  3. 取得した CloudFront ディストリビューションに対して、渡されたオブジェクトのパスを元にキャッシュ削除を実行する

実装内容

■ S3 - Lambda 間連携

1.S3 のオブジェクトが更新・作成されると Lambda にバケット名・オブジェクトのパスの情報を含んだイベントが渡される

これは Lambda のトリガーに S3 を設定すれば自動でイベント通知してくれます。 すべての更新イベントに対して設定しておいて問題ないかと思います。

f:id:shino8383:20200507024817p:plain

■ Lambda の実装

叩く API は実装する言語を問わず共通のはずなので、API リファレンスを記載しながら説明します。

2.Lambda 上で、渡された S3 バケットをオリジンとする CloudFront ディストリビューションを取得する

これは CloudFront API

ListDistributions - Amazon CloudFront

を使ってディストリビューションの一覧を取得します。

ただ、ListDistributions にはフィルターの機能はないので目的のオリジンを持つディストリビューションかどうかの判定は自前で条件分岐を作るしかないかと思います。

次に、

3.取得した CloudFront ディストリビューションに対して、渡されたオブジェクトのパスを元にキャッシュ削除を実行する

これは CloudFront API

CreateInvalidation - Amazon CloudFront

を用いてキャッシュ削除を実行します。 引数でディストリビューションの指定とオブジェクトのパスの指定を行います。

実装上の注意点

今回、実装するにあたって注意した点が3つあります。

  • Lambda 関数の冪等性
  • API のリクエスト数制限
  • Lambda の利用料

■Lambda 関数の冪等性

Lambda 関数の冪等性については調べれば色々情報が出てくると思いますので詳しくは触れません。 簡単に説明しますと

"実行した回数に関わらず同じ結果が得られる性質"

のことを冪等性といいます。

そして、

Lambda は複数回実行される可能性がある

という仕様があるので冪等性を考慮しています。 これは今回のケースで言えば S3 上のオブジェクトを 1 回更新しても、Lambda は 1 回以上実行される可能性があるということです。

今回実装したキャッシュ自動削除では、

オブジェクトの更新 1 回に対して、キャッシュ削除が 1 回だけ実行される

という結果が欲しかったので、キャッシュ削除の API を叩く前に 実行中のキャッシュ削除がないかを判定するロジックを設けました。 この判定がなければ、同じオブジェクトに対する複数のキャッシュ削除が重複することになります。

キャッシュ削除の一覧は

ListInvalidations - Amazon CloudFront

で取得しました。

f:id:shino8383:20200507041107p:plain

API のリクエスト数制限

AWSAPI には一定時間当たりのリクエスト上限があります。 一定時間内にリクエスト上限を超えるとエラーが返却されます。

例えば SNSAPI の一つ、 Subscribe は 1 秒あたり 100 回の制限があります。

Subscribe - Amazon Simple Notification Service

This action is throttled at 100 transactions per second (TPS).

CloudFront の API リファレンスには回数の記載がないですが、制限自体はあるはずです。

実装の際にリクエスト回数が増えすぎないかテストしていましたが、ThrottlingException が何度か返ってきていた記憶があります。

Common Errors - Amazon CloudFront

つまり今回のケースでは、オブジェクト更新が一度に大量に行われる場合などを考慮して必要以上に API をコールしないような実装が必要です。

冪等性の件でキャッシュ削除を行わない分岐を設けたのは、影響は軽微ですがリクエスト数の観点からも良いと言えるのではないかと思います。

■Lambda の利用料

最後にお金の話です。

最低でも更新されたオブジェクトの数だけ Lambda が実行されるので Lambda の利用料にも気をつかうことにします。

Lambda の利用料は

利用料 = 実行回数 * 割り当てたメモリの量

で決まります。

Lambda 上のロジックでは実行回数(=更新するオブジェクトの数)のコントロールはできないので、割り当てるメモリの量が小さくなるように配慮します。

具体的に言えば、一度に大量にデータを保持する処理は避けることにします。

今回の実装の中で、データを大量に取得しやすいのが

ListInvalidations - Amazon CloudFront

だと思ったのでこちらの処理に対して手を加えることにします。引数で何も指定しなければ指定したディストリビューションについて過去のキャッシュ削除履歴を相当な件数引っ張ってくることになるかと思います。 (15000 件近く返却されるところまでは確認しました)

今回は、弊社の CloudFront + S3 の利用状況も考慮した上で

  • 取得するキャッシュ削除履歴の数を引数で指定
  • キャッシュ削除の履歴を新しいものから 10 件取得する。
  • 実行中の履歴があればもう 10 件取得する。
  • これを最大で直近 30 分以内の履歴について行う。

という実装をしました。

この実装はリクエスト数を増やしている側面もあるので、リクエスト数の増加とデータ取得量最適化の天秤になるかと思います。

最後に

CloudFront + S3 の構成はよくある構成だと思いますので、実装の際に考えていたことを改めて言語化してみました。

この Lambda (とその中で叩かれる API )は大量に実行される可能性があるので、 キャッシュ自動削除の対象にする S3 バケットやオブジェクトのパスはちゃんと考察するべきかと思います。

ちなみに今回は Slack にキャッシュ削除の開始通知を飛ばすところまで実装しました。

f:id:shino8383:20200514192553p:plain

これからもすきあらば自動化等で日々の業務のコストを下げていけたらいいなと思っています。

では今回はこのへんで。

DevOps チームは仲間を募集中です👏

👏採用/求人情報 | アピール | 未来の教育を作る人のマガジン インフラエンジニア(AWS)

UIWebViewからWKWebViewへのリプレイス作業が完了したのでまとめてみた

APP / UX チームで iOS アプリを担当しています玉置(@tamappe)です。

今回はレアジョブアプリでリリース当初から使われていた UIWebView を全て WKWebView にリプレイスできましたので、その時に取り組んだ事を知見としてまとめることにしました。 iOS アプリ開発において UIWebView の撲滅作業は一つの鬼門ですね。

リプレイスの背景

UIWebView とは Apple が提供するアプリ内ウェブブラウザの機能を持つ UIKit です。 簡単にいえば、アプリ内ウェブビューを実現するものになります。

ですが Apple が去年リリースした iOS13 の誕生とともに、正式に2020年4月からほぼ使えなくなることになります。 UIWebView が使えなく代わりに iOS8 から追加された WKWebView を使うようにと強制されるようになりました。新規アプリに至っては2020年4月以降から UIWebView を使ったアプリの申請はリジェクトが確定しています。既存アプリに関しても1度警告が入るだけですが、2020年12月末以降からはアップデートの申請が出せなくなります。

Updating Apps that Use Web Views

レアジョブアプリも例に漏れず UIWebView を使っている画面がありますので、今回を機に WKWebView へのリプレイスを実施することにしました。

注意すべきこと

では、 WKWebView へのリプレイス作業に関してやるべきことと注意すべきことをを箇条書きにします。

  1. UIWebView のインスタンスを WKWebView のインスタンスに置き換える
  2. WKWebView でのページのローディングで WKNavigationDelegate を使うようにする
  3. アプリ側で JavaScript 実行(同期)をしている箇所は、 WKWebView の場合は非同期(コールバック)になる
  4. ネイティブ画面でログインAPIを叩いている場合、 Cookie の共有の扱いに注意する
  5. UIWebView で UserAgent の操作をしている場合には、取得の仕方を考え直す

以上、5点になりました。

それでは一つずつ見ていきます。

UIWebView のインスタンスを WKWebView のインスタンスに置き換える

ほとんどの場合は UIWebView は storyboard から使って操作していたと思います。それに対して、 WKWebView は Xcode 10 まで Apple のバグにより storyboard から参照できませんでした。そのため、Xcode 10 まではコード上で WKWebView のインスタンスを生成するしかありませんでした。それが Xcode 11 からは Apple がこのバグを直したためか storyboard 上で使ってもビルドエラーが起こらなくなりました。

そのため、「だいたいの場合」は storyboard から WKWebView を使えば置き換え可能になりました。

UIWebView

f:id:qed805:20200223190550p:plain
UIWebViewの場合

WKWebView

f:id:qed805:20200223190654p:plain
WKWebViewの場合

WKWebView の背景色のデフォルトはグレーがかかっています。これだけで WKWebView への置き換えが可能ですが、後述する Cookie の共有ではこのロジックが使えなくなるのでコードでの生成も記載します。

var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        let webview = WKWebView(frame: .zero, configuration: configuration)
        return webview
    }()

computed property を使ってクロージャーの中で初期化宣言すればそのままプロパティとして利用できます。ただし、これだけでは画面上の view には乗らないのでどこかで乗せないといけません。

    override func viewDidLoad() {
        super.viewDidLoad()
        setupWKWebViewLayout()
    }

    private func setupWKWebViewLayout() {
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
        webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
        webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
    }

オートレイアウトを使いたいので translatesAutoresizingMaskIntoConstraints を false にしています。コードから生成する場合はこれで事足ります。

WKWebView でのページのローディングで WKNavigationDelegate を使うようにする

次にウェブブラウザのローディングで使うデリゲートメソッドが変わりますので、それぞれ置き換える必要が出てきます。ものすごく省略していますが、UIWebView と WKWebView とでこのように変わります。

/// UIWebView
extension ViewController: UIWebViewDelegate {
    func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebView.NavigationType) -> Bool {
        guard let host = request.url?.host else {
            // ページのローディングを許可しない
            return false
        }
        // ページのローディングを許可する
        return true
    }
}

/// WKWebView
extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let host = navigationAction.request.url?.host else {
            // ページのローディングを許可しない
            decisionHandler(.cancel)
            return
        }
        // ページのローディングを許可する
        decisionHandler(.allow)
        return
    }
}

大きく異なるのが UIWebView ではページの読み込みを許可するかどうかは Bool で判断するところが、 WKWebView では decisionHandler で処理するように変わります。 またロードしたページの url は request からではなく navigationAction から取得するようになりました。こちらは気をつければいいだけでした。

アプリ側で JavaScript 実行(同期)をしている箇所は、 WKWebView の場合は非同期(コールバック)になる

UIWebView での JavaScript 実行は同期的でしたが、 WKWebView では非同期的に変わります。

/// UIWebView
let string = webView.stringByEvaluatingJavaScript(from: "JavaScriptのスクリプト")

/// WKWebView
webView.evaluateJavaScript("JavaScriptのスクリプト", completionHandler: { result, error in
    if let result = result as? String {
        let string = result
    }
})

そのため、戻り値を String で返す関数がある場合はロジックが破綻してしまうので completionHandler などを用意して関数にコールバック引数を用意してあげる必要が出てきます。ちなみに非同期を同期的に無理やり変換しようとするとデットロックしてしまうので注意です。

ネイティブ画面でログインAPIを叩いている場合、 Cookie の共有の扱いに注意する

これが一番大変でした。 アプリ自体にネイティブ実装のログイン画面が存在しログインAPIを使ってログインしています。そして、レアジョブアプリはレッスンルームと教材の部分でアプリ内WebViewを使っています。このアプリ内 WebView はクッキー共有が前提の設計になっていて、ログインAPIで取得した sessionId をアプリ内 WebView に渡して表示させています。ですので、アプリがログイン中であればアプリ内 WebView はログインを維持していないといけないです。 (途中でセッションが切れてしまった場合はログインページを表示させています)

UIWebView でもこの Cookie 共有が実装されていましたので、 WKWebView でも Cookie 共有が実装されていなければなりませんでした。結論からいえば、これは解決しました。 UIWebView の時に Cookie のセットができていました。 HTTPCookieStorage に保存すれば共有できます。

    class func setCookie(_ cookie: [HTTPCookiePropertyKey: Any]) {
        let newCookie = HTTPCookie(properties: cookie)
        // HTTPCookieStorageにクッキーを保存する
        HTTPCookieStorage.shared.setCookie(newCookie!)
    }

このあとに WKWebView で保存した Cookie をセットしなければ行けなかったのですが、どうやってセットすればいいのかが情報が少なすぎてわかりにくかったです。

答えは WKProcessPool に存在していました。この WKProcessPool を WKWebView の WKWebViewConfigurationインスタンスにセットして WKWebView を生成すれば Cookie を設定できます。

extension WKProcessPool {
    static let shared = WKProcessPool()
}

class ViewController: UIViewController {
    var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)
        return webview
    }()

    /*
     中略
     */
}

ちなみに WKWebView には configuration プロパティが存在するからコードではなく storyboard から @IBOutlet で接続してセットしなおせばいいのでは?と思われるかも知れませんが、 WKWebView のインスタンスを生成した後に WKProcessPool.shared を設定しても共有されません。

var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        /// これは共有される
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)
        /// これは共有されない
        webview.configuration.processPool = WKProcessPool.shared
        webview.backgroundColor = UIColor.white
        return webview
    }()

つまり、 Cookie を維持したい場合には storyboard にある WKWebView では実現できなく、コード生成でのみ実現可能です。これは謎の仕様ですが、ハマリポイントでもありますので注意が必要です。

最後に

このような手順を踏むことで WKWebView へのリプレイス作業が完了しました。 UIWebView に未練はありませんが、もうちょっと WKWebView の使い勝手を改善してほしいですね。まだ UIWebView を使っているアプリがあればこちらの記事が役に立てば幸いです。

補足

レアジョブアプリは RxSwift という外部ライブラリを使っていて、この RxSwift のライブラリの中でも UIWebView が使われていました。 正確には RxSwift の RxCocoa で UIWebView が使われています。

github.com

もちろん今回の Appleガイドラインにおいて、外部ライブラリに含まれる UIWebView も例外ではありません。 ガイドラインの対象になりますのでこの問題をどうしようかと悩んでいました。 ですが先月3月に、RxSwiftのライブラリがこのガイドラインに対応したバージョンをリリースしたそうです。

https://github.com/ReactiveX/RxSwift/releases/tag/5.1.0

RxSwift と RxCocoa のバージョンを 5.1.0 にアップグレードすると UIWebView がなくなっていることが分かります。 これで正式に UIWebView の撲滅が達成したことになりますね!

では、引き続きレアジョブアプリの改善を続けていこうと思います。いつもながら長文になりましたが最後まで読んでいただきありがとうございます。

在宅勤務での取り組み&在宅でのナレッジ

こんにちは、CTOに続き一年ぶりとなりましたレアジョブとディズニーをこよなく愛している岩堀です。
基本弊社は在宅勤務になり、ディズニーも休園が続いて、家からほとんど出ることもないので、 仕事中もディズニーの音楽かけて夢の国にいる気分で仕事をしています。 皆さんはどのように在宅勤務をこなしていますかね?

今回は弊社での在宅勤務の取り組みについてと、ちょっとした在宅に役立つナレッジを書きたいと思います。

社内の取り組み

弊社は緊急事態宣言を前に全社にて在宅勤務へ移行しました。 また弊社の海外子会社は日本よりも先にロックダウンが発生したことにより、 強制的に在宅勤務に移ることになりました。 準備もできていないうちに在宅勤務に移ったことで社内システムを担当する ITソリューションチームでは現在も一丸となって対応しております。 それでは在宅勤務が行われる前と後でどのように変わったか、少し書きたいと思います。

在宅勤務前の状況

弊社はオフィス勤務がメインでした。各ツールに対してもオフィスからのIPに制限しておりました。 外部にいるときもVPNを経由して、各ツールへアクセスを行っておりましたので、 外部からアクセスが必要な社員に対してのみVPNの設定を行っておりました。 これは海外の子会社でも同様の対応で行っておりました。

在宅勤務前
before environment

在宅勤務後の状況

在宅勤務後は基本的にオフィスでの業務がなくなりますので、すべての従業員がVPNでつなぐ状況となりました。

在宅勤務後
after environment
急遽在宅勤務の環境へ移行したことで問題となってきたのは2点ありました。

  • VPNライセンス数
  • VPN機器のリソース負荷

弊社ではVPNとしてCiscoのAnyConnectを使っており、ライセンス数は利用されるユーザー数分必要になります。
元々全従業員がVPNへ接続することを想定していなかったので、全従業員数分のライセンスの購入は行わず、機器の最大接続数分のみを購入しておりました。
ライセンスの追加購入を考えていたところ、CiscoのほうでCovid-19による在宅勤務への移行をサポートするように、 期間限定のライセンスを無償でプログラム提供しているのを知り、こちらを利用してライセンス数をクリアしようとしております。

www.cisco.com

またVPN機器のリソースは定期的に監視を行っており、負荷状況が高い状態であるので、こちらに関しては何かしらの対応を行っていこうと思います。 現状、VPNを利用する前提として、Web会議はVPN機器のリソースだけでなく通信トラフィックも無駄に使ってしまうため、VPNを経由しないようにしていただくことと、VPNが不必要な状態のときには切ってもらうように周知しておりますが、やはり会議で必要な資料を開く上でVPNが必要な場合もあるので、VPN接続し続けた上で行われることがあるようです。このあたりの回避できる方法を考えていかなければないですね。 

在宅勤務でのナレッジ

在宅勤務へ移行したことで、リモートにてヘルプデスクとして数々の問い合わせいただく中で、 在宅で勤務をする上で家での環境が整っていない状況が数多くあるということを知りました。 そこで、現在の環境でも快適な物にできるようにちょっとしたナレッジを書きたいと思います。

通信環境

今回、このような状況になって知ったのですが、現在自宅にネット回線を引いていない方が結構いるということを知りました。 その方たちは在宅で仕事する際にはスマホテザリングなどで対応しているようですが、通信容量が多くなって通信制限にかかってしまう方がいらっしゃるようです。 そこで在宅ワークにおいてテザリングを使う中で通信容量を抑える方法をあげたいと思います。

動画やWeb会議などはあまり利用しないのは当然となりますが、業務でどうしても必要な状況があります。 その場合にはVPNは経由しないほうが通信容量は抑えることができます。 VPN通信には通常の通信と比べて暗号化等を行っているため付加情報がついており、通常よりも通信容量が増えます。 検証のため自分の端末を使って、みんな大好きなミニーのショー動画(2分半)を見ながら試してみました。

www.tokyodisneyresort.jp

テザリング端末での通信容量チェッカーアプリを使った大雑把な値ではありますが、VPNなしのほうが10MB程度節約になりました。

通信環境 通信容量
VPNなし 29MB
VPNあり 42MB

実際Web会議をされるときは長時間となると思いますので、可能な限りVPNは経由しないほうが、 通信容量を抑えることができます。

またブラウザを開いているだけで通信が行われることもあるので、PCからしばらく離れる場合には テザリングをOFFにして通信を行わないようにしておくことでチリツモを防げますので大事かと思います。

作業環境

オフィスではラップトップの他にディスプレイも割り当てられて、拡張ディスプレイを使って、 業務をやられている方も結構いるかと思いますが、在宅勤務でも同じように行う上で、ディスプレイの購入をされた方もいるかと思います。
私も買おうかと思いましたが、置くスペースもないので、家にあるものでどうにかならないか考え、テレビにHDMIケーブルを使って外部ディスプレイとして使うことを考えました。

ただガジェット好きな私の家では既にHDMIポートをフルに使っており、毎回抜き差しするのも面倒と思ってしました。 そこで思いついたのが、FireStickTvをAirPlayとして使ってできないか。

早速やってみたところあっさり行うことができました。

ケーブルもなくスッキリした状態で作業ができるので、結構便利です。 やり方は以下の手順で実施しました。

  1. FireStickTVにAirPlay用アプリをインストールする
    私は評判の良かったAirReceiverを入れました。

  2. AirReceiverでAirPlayをOnにする

  3. MacにてAirPlay検索より該当のディスプレイを選択(この場合はAFTT-26)

  4. 拡張ディスプレイにするために、「個別のディスプレイとして使用」を選択

これであっという間に拡張ディスプレイとして機能します。ちなみに裏で別アプリを動かすことも可能なので、AmazonMusicをかけながら拡張ディスプレイとして使うことができるようになります。

私のマシンはMacなのでAirPlayでやりましたが、Windowsの場合はMiracastで同じことができます。 こちらはアプリをインストールすることなくFireStickTVのミラーリング機能で対応可能です。
こちらについては別の機会で記載したいと思います。

ぜひ皆さんも快適な在宅勤務をして、このコロナに負けずに頑張りましょう!

国境を超えてスクラムを実践する

こんにちは。一年ぶりの登場となりますCTOの山田です。

さて、RareJob Tech Blogも無事一年が経ち、当初の宣言通り週1ペースで様々な記事が投稿されてきました。また、この一年、レアジョブは様々な変化がありました。中でもレアジョブ英会話のシステムリニューアルの意思決定をしたことが開発部門にとって大きな変化です。背景や目的としていることなどはRareJob Appealの記事をご覧下さい。

このシステムリニューアルPJでは、日本だけではなくフィリピン開発部隊との連携が必要となります。レアジョブは、連結子会社6社ありますが開発部門を持っているのが日本のレアジョブとフィリピン ケソン市にオフィスを構えるRareJob Philippines, Inc.の2社だけですので、この2社間の連携が非常に重要です。この2社間でどのような体制、開発プロセスでシステムリニューアルPJを進めるのかを紹介したいと思います。 国を跨り協働開発する際の参考になればと思います。なお、RareJob Philippines, Inc.ではレアジョブレッスンを提供する講師に関わるすべてのシステム開発・運用を担っております。

開発組織間の連携

リニューアルプロジェクトでは、多くの不確実性と変化が伴うためスクラムで開発を推進していきます。アジャイル開発は経験主義を基本としているので、いきなり大きなプロジェクトを立ち上げてもうまく行きません。普段からどのような仕組みでコミュニケーションを取っているかで立ち上がり方が違います。言語、文化、時差など様々な違いをどう吸収するかを考えながらこれまで組織を構築してきました。現在では各レイヤーでコミュニケーションを密に取り、レアジョブ英会話サービスを共に支える組織となりました。
日本とフィリピン共に機能組織としての技術本部があり、ほぼ同じような組織構造で構成しております。

体制イメージ
各レイヤー間の連携

  1. Engineer Manager間での情報共有
    RareJob Philippines, Inc.の開発責任者へは週次で会社の動きを共有。特に日本を軸に計画が大きく変わることがあるため、影響ある情報をいち早く伝えることを心がけています。
    また、Engineering Manager間は月一で情報共有会を開催。プロジェクトの動きや課題の議論などをこの場で行ないます。もちろん必要であれば随時オンラインミーティングを開催しています。

  2. Lesson Roomの協働開発
    Skypeに変わるWebRTCを利用したレッスン提供機能であるLesson Roomを生徒側は日本、講師側はフィリピンで開発を行っております。
    コアとなるロジックを共通化しているため、密に連携しながら生産性を向上させる取り組みをしております。

  3. 生徒向け、講師向けに関わる開発、リリース情報の共有
    基本的には双方のサービス要件に沿ったドメスティックな案件が多いです。ただし、現在は共通のDBを利用して生徒、講師システムが動いておりお互い影響し合うため、リリース情報の共有は会議やチャットなどで行ないます。システムリニューアルPJでは、この共通DBをどのように分割するかを議論しています。

  4. レアジョブグループの共通基盤協働開発
    Golangで開発されたプロダクト共通の情報資産を有する共通基盤。各プロダクトからはAPIで利用可能で、レアジョブ英会話サービスでは、リニューアルプロジェクトを通じて一部の機能を共通基盤へ移管していきます。この共通基盤は、生徒システム、講師システム双方で利用するため、協働で開発しております。

  5. AWSの協働運営
    レアジョブでは、複数のプロダクト、メディア、社内向けのサブシステムなど多くのシステムが稼働しています。講師の給与計算などフィリピン固有の重要なシステムもあります。これらを安定的に運営していくため、ルールや権限など厳格に設計し、協働で運営してます。

システムリニューアルPJの進め方

プロトタイプとしてMVPを創る

レアジョブ英会話も気がつけば多くのお客様に利用されている規模の大きなシステムとなりました。この規模のシステムリプレイスをいきなり進めるには、多くの手戻りが発生することが容易に想像でき、またビジネスへの影響も広範囲に渡るため、2019/11〜2020/3まではプロトタイプという位置づけでレアジョブ英会話のMVPを開発しました。
このプロトタイプフェーズでは、MVPの開発だけではなく以下も行ないました。

国境を超えてスクラムを実践する

プロトタイプ開発での経験を経て、この時の最終的な仕組みをベースに、フルバージョンの開発は下記のような整理をしました。

体制

  • 4つのスクラムチームから構成(日本とフィリピンの混同チームもある)
  • 全体の意思決定者であるプロダクトオーナーがおり、各スクラムチームにチーム毎の意思決定をするサブプロダクトオーナーを配置
  • スクラムチームに技術的な意思決定をするテックリードを配置
  • 全チームのプロダクトオーナー、テックリードからなるステアリングコミッティを設置

Global TimelineとPBL

  • 開発効率観点から開発していく順をEpic単位で定義したGlobal Timelineを策定し、ステアリングコミッティで全体の進捗を管理する
  • ユーザーストーリーを一元管理し、PBLに分解し各スクラムチームで開発を担う

スプリントとスクラムイベント

  • 期間は2週間で、開始、終了をすべてのチームで合わせ、スクラムイベントを同日に一斉開催する
  • スクラムチームでのプランニング前に全体プランニングとして、次スプリントで必要となるPBLを他チームへ依頼する
  • スクラムチームの振り返り後、全体での振り返りを実施

全体像

システムリニューアルPJの進め方全体像
システムリニューアルPJの進め方全体像

その他の取り組み

  • 共通の課題リストを持ち、PJ全体で解決に向けた取り組みを行なう
  • マイクロサービスとは言え、原則の技術選定に準ずる
  • 運用が複雑化しないようにCI/CD、監視などの仕組みを統一していく
  • 利用するツール(ドキュメンテーション、コミュニケーションツールなど)を統一する
  • 原則英語でドキュメントを書き、分散させない
  • 各自英会話スキルをレアジョブ英会話で磨く
  • 定期的に出張しFace to Faceで理解を深める(新型コロナ問題で当面厳しそう)
  • 背景、目的、方針は何度も同じことを伝える
  • 技術顧問の広木さんに、移行方法をレビューして頂く(第三者がレビューすることで気づきが多い)

最後に

システムリニューアルPJでは、モノリシックアーキテクチャからマイクロサービスアーキテクチャへとシステムそのものを変えていきます。これだけでも難易度が高いですが、フィリピン開発部隊と進めていくことでさらに難易度が上がります。
また長期プロジェクトになるため、問題が発覚したらすぐに解決に向けて動く必要があり常に神経を尖らせて、関係者と密な連携を取り進めていきます。 このプロジェクトを通じてレアジョブグループ全体の開発機能が一段高いレベルに上がることを確信しており、またこれから立ち上がる新規事業へも貢献出来ると思います。

ということで、また来年(!?)お会いしましょう。

電動式昇降デスクのすゝめ

コロナの影響で世間が騒がしい中、皆さんお元気ですか。ジャンボです。

弊社でもリモートが標準となり、これまでリモートをしてこなかったメンバーも新しいワークスタイルに戸惑いつつも新しいメリットなどに気付き始めている頃かと思います。

今日はですね、そんなリモートを支える技術・・・いや、ツールとして「電動式昇降デスク」という概念を紹介したいと思います。

自分のための机を探す

prtimes.jp

昔はデスクの高さを調整したり、調整用の補助器具を買ってみたこともあるんですがやはりしっくり来ず・・・ いろいろ見ていた時にIkeaショールームで出会ったのが昇降式のデスクという概念でした。いろいろ見ていると電動ではなく、手動の昇降式デスクという商品も多く、検討したんですがハンドル式の調整バーなどをぐんぐん回して高さを30cm前後毎回調整することになり、これは結構疲れる作業なので電動式のものを導入しました。

私の持っているデスクはすでに廃盤になってしまったのですが、大体の電動式昇降デスクは高さを記録することができ、 私だと以下のようにシチュエーション別に高さを調整しています。

  1. 座り仕事用モード(75cm
  2. 立って仕事する用モード(110cm
  3. コード読む用モード(80cm

一般的なデスク高は70cmとされており、これと比べると1, 3は少し高く設定しています。 ただ私は名前の通りジャンボなので、身長188cmの体には一般規格はちとやりづらく腰を痛めることも多々ありました。

最適なデスクの高さを決める要素

一般的にデスクの高さを決めるには以下の3つの要素があるとされています。

  1. 身長
  2. 椅子の高さ
  3. 椅子の高さとデスクの高さの差異

またデスクの高さは1により求められるとされ

デスクの高さ(cm) = {身長(cm) * 0.25} - 1 + {身長(cm)* 0.183} - 1

という計算式が存在します、私の場合だと 79cm となります。 標準と比べて10cmほど差があることがわかります、他の要素でも調整は可能で一概には言えないですが、この差異をストレスに感じることも少なくなく・・・ これが姿勢の悪化を生んでいた気がします。

電動でなくとも、昇降式でなくとも、自分のために作業環境をしっかり計測して揃えるだけでパフォーマンスは大きく変わると思います。

参考 https://www.isunokoujyou.com/hpgen/HPB/entries/59.html

使いこなすために

2年ほど使ってみて、今では自宅作業時にはコーヒーと並んで欠かせない存在となっています。☕️ 椅子やPCと同じかそれ以上にパフォーマンスに響くものだと思うので、この機会に導入を一考ください。

モードの使い分け

「立って仕事する用モード」は主に朝方や食後に集中できない時に使っています、仮眠を取るより気持ちを切り替える方が効果的に感じる体質なので重宝しています。 「コード読む用モード」は普段より視線を少しあげて延々コードレビューしたり、OSS読んだりする時に使います。位置高めであんまキーボード使わない時ですね。

これらを使い分けることで、作業に応じて環境と気分をスイッチングするのが楽しいです。

時間を区切って使う

長時間立っているのはもちろん健康上の負担がないわけではありません、そのため立ち作業をする場合はある程度時間幅を決めたり、タスクで区切ったりして終わりをちゃんと設定します。 また腰の負担を軽減するために寄りかかる手段や、高めのスツールがあるとよりGoodです。 私は椅子のヘッドレストがちょうどよくこれに寄りかかっています。

実は音が出る

また音に関しても注意が必要で、まったくの静音というわけではなく多少唸ります。 音もモーター音なので多少ノイズに感じる人もおり、寝てる人とか猫ちゃんはびっくりするかもしれません。

私的には「さーて、いっちょやっちゃいますか」という時に作動音とともに環境がセットアップするのはめちゃくちゃアガるので好きです。

コード類を整理する

高さ調節時にモニターのケーブルが動いて絡まってガチャガチャすることがありました、ケーブル類はデスク裏に格納することをお勧めします。もし天板を別途用意するのであればケーブルを通す穴を持っていることをお勧めします。

ケーブルハックであれば Guild の go ando さんの記事がおすすめです。

note.com

まとめ

いろいろ書きましたが、やはり電動式昇降デスクはいいです。

立っても座っても仕事ができるし、なにより立って仕事ができます。

この記事を書いていて、なぜ立って仕事をすることがいいのか?ということを改めて考えてみるとやっぱり「"早く座りたい"という気持ちが仕事を早く終わらせる」というのが一番大きいなと改めて思いました。

今週もお疲れ様でした。

USM(User Story Mapping)をやってみた

はじめに

お久しぶりです。改善したいマンの三上です。
世間は新型ウィルスで混乱していますが、レアジョブのブログは元気に更新中です!
私事ですが、少し前に丸2日間の研修を経てLSM(Licensed Scrum Master)の資格を取得しました。

レアジョブではアジャイル開発を推進しており、各チームやプロジェクトで積極的に採用されています。
そんな中で弊社のメインプロダクトである「レアジョブ英会話」のリプレース案件を実施することになりました。
この超大型案件を進めるにあたり、まず始めに何から始めたらいいのか考えていく中で
まずはプロトタイプ開発をしよう!スクラムでやってみよう!
という運びになり、まずはユーザーストーリーマッピング(USM)をやってみたので
今回はその記事を書こうと思います。

USMとは?

スクラム開発においてプロダクトの価値を決めるのはプロダクトバックログです。
プロダクトバックログはいくつかのPBI(プロダクトバックログアイテム)が優先度の高いものから順番で並んでいます。
PBIはユーザーストーリー形式になっていて、誰にとって、どんな価値があるのか表現されている必要があります。

ユーザーストーリーは以下の形式にします。

WHO = ○○として
WANT = △△したい
WHY = なぜなら✗✗だからだ

実際に例を上げると、

WHO = レアジョブ英会話の会員として
WANT = 日本語も話せる講師とレッスンしたい
WHY = なぜなら分からない時に日本語で質問したいからだ

のように書きます。
上記は例ですが、ユーザーストーリーの粒度は大小様々になりえます。
このようなユーザーストーリーを優先度が高く直近のスプリントで着手するものほど、より詳細なストーリーにしていく必要があります。

(ちなみにストーリーの詳細化・見積もりの事をリファインメントと呼びます。)

USMは上記のようないくつかのユーザーストーリーで構成されます。
そのユーザーストーリーをナラティブフローと呼ばれる物語の流れに沿って左から右へ時系列順に並べていきます。

USMを作成するメリットはプロダクトの全体像を俯瞰して見れることです。
個人的に、この複数のサブストーリーを経て、全体としてのストーリーが出来上がる感じが
RPG感があって楽しいです。笑

というわけでやってみた

f:id:rarejobmikami:20200323024258j:plain

ざっくり以下のような形で進めました。

Timebox

3時間

Agenda

  1. 目的説明
  2. ユーザーストーリーマッピング説明
  3. 現状の機能概要のおさらい
  4. ペルソナの共有
  5. ユーザーのアクションを書き出す(全員)
     時系列に並べていく。
     同じ目的はグループにまとめる。
     注:ストーリーとして書く。〇〇として△△したい
     注:ストーリーをタスクに分解してはならない
     「青い付箋」にユーザーのアクションを書き出す
     「オレンジ付箋」にアクションのグループをまとめる
  6. 改善ポイントや欲しい機能を書き出す
     「赤い付箋に」ユーザーの気持ちになって欲しい機能を書き出す
  7. 整理(優先順に並び替える)
  8. 最優先で作りたいものを決める(MVPの確定)
     MVP(Minimum Viable Product)とは、まず最初にリリースしたいものになります。
     今回はMUST(必ず必要)、WANT(出来れば欲しい)の2つに分類し、MUSTをMVPとします。
     ※余裕あれば次に達成したいことを決める
  9. プロダクトバックログに組み込む

参加者は開発チームはもちろんPO(プロダクトオーナー)に当たる企画チーム、CTOまでバラエティに富んだメンバーで行いました。

完成したUSMはこちら

f:id:rarejobmikami:20200326204520j:plain

オレンジ色の付箋がエピックと呼ばれる単位になります。
例としては「ユーザー登録」、「検索」、「レッスン予約」のような粒度です。

また上部に貼ってある付箋ほど優先度が高いものになります。

やってみて良かったこと

大きく以下の2つ。

・ペルソナを明確にした
・プロトタイプ開発のスコープが明確だった

です。これは企画チームに感謝です。

上記の写真でプロジェクターに映し出しているのが、
レアジョブ英会話の重要ペルソナを言語化したものになります。
ここが明確だと何が良いかというと、ユーザーストーリーを考えるにあたって
最も重要なのがユーザー視点に立つことです。

これは簡単なようで難しいです。なぜなら人それぞれ求めることが違うので当然意見が分かれるときがあります。
またエンジニアあるあるだと思うのですが、
パッと要件を聞いた時に、すぐ実装方法を考えてデータ構造とかアーキテクチャとかを想像しちゃうと思うんです。

それはそれで重要なんですが、今回の場では不要です!

そんな時に役に立つのがペルソナの存在です。
意見が別れたり悩んだときには、ペルソナの気持ちになってその都度振り返ることが出来て、円滑に議論を進めることが出来ます。

また、プロトタイプ開発のスコープが明確だったことにより、どこまでやる・やらないの判断にあまり時間を取られずに進行することが出来ました。

最後に

このUSMを経て、プロダクトバックログを作成し、無事にスプリントが開始することができました。
またスプリント開始時にやったことについても次の記事で書きたいと思います。

それでは、また。

マイクロサービスのロギングベストプラクティスとGoの実装の場合

こんにちは、プラットフォームチームの池田と申します。初投稿です。

プラットフォームチームではマイクロサービスアーキテクチャの構成を採用し開発を進めています。
どんな構成でも忘れてはいけないのがロギング。いわゆる非機能要件の1つで地味な存在ですが、サービス運用を支える上で非常に重要です。

直近でマイクロサービスにおけるロギングの構成を調査し、プラットフォームチームでメインで採用しているGo言語での実装を検証しました。
今回の記事ではそのまとめを紹介します。

目次

ロギングベストプラクティス for マイクロサービス

マイクロサービスにおけるロギングの方針に関して記載している日本語の記事が少なく感じたので、はじめに調べた結果のまとめを記載します。

リクエストにユニークなIDを付与し紐付けができるようにする

マイクロサービスでは、あるサービスAがサービスBを呼びさらにサービスCを呼ぶといった形になるので、呼び出しチェーンにユニークなIDを与えることで調査の見通しが良くなります。

このとき、アプリケーションがHTTPレスポンスなどでエラーを返す場合にもリクエストのユニークIDを入れると良いようです。そうすることで、問題が発生し際ユーザが受け取ったエラーとユニークIDを素早く紐付けて調査を開始することができます。

ユニークなIDをどこでどのように生成かということも重要なポイントでしょう。原則としてはユニークIDでの追跡範囲のエントリーポイントとなる箇所で生成します。そしてそういった箇所で利用されるロードバランサのサービス(AWSではELB)やKongなどのAPI Gatewayミドルウェアプラグイン的にCorrelation ID(ユニークID)生成の機能を提供しているので、それを利用するのが一般的なようです。そういった機能では、生成したユニークIDをオリジナルのHTTPヘッダーへ挿入します。

ログは一箇所に集める

上述での各サービスが出力するユニークID付きのログを横断的に調査するために、各サービスのログを一箇所に集中させることが次に重要になります。

このとき、アプリケーションがPush型として能動的にHTTPリクエストなどを使って集約場所へ登録するのではなく、ローカルストレージのファイルやAmazon Elastic File Systemといったクラウドのストレージに一旦預けた後に、LogstashFluentdといったツールで集約場所へ連携することが望ましいとのことです。そうすることで、アプリケーションからログ集約という役割を切り離すことができます。

以下の図はここまでの2つのポイントを踏まえた構成の一例です。Amazon ELBがCorrelation ID(ユニークID)を生成しマイクロサービスサービス間で伝搬され、出力されたログはCloudWatch Logs Subscriptionの機能でElasticsearch Serviceへと集約させています。

f:id:ochataro:20200316183408p:plain

ログデータを構造化する

マイクロサービスではログに持たせるフィールドは柔軟にしておきたい一方で、サービス共通でロギングデータのパースができるようにもしておきたいです。

そこでサービス共通でログデータのフォーマットを合わせましょう。JSONやLTSVといった構造化の形式を統一させることで持たせるフィールドも柔軟になり、共通で必須なフィールドを容易にパースすることができます。

ログに有益な情報を持たせる

マイクロサービスアーキテクチャにおいてログ情報として持つことが望ましいフィールドが以下になります。

どのサービスでも共通で持つのが望ましいフィールド

リクエストのエントリーポイントとなるサービスで持つのが望ましいフィールド

  • リクエスト元のIPアドレス
  • ユーザが利用したブラウザ名またはモバイルのOS名
  • HTTPレスポンスコード

Go言語での実装例

上述のベストプラクティスの内容を踏まえて、Go言語の場合の共通ロギングライブラリとアプリケーションのサンプルを作っていきます。

はじめに、重要なのは具体的にどういったフィールドやフォーマット、出力先にするかをチーム間で議論し合意した上でそのルールを決めることでしょう。

ここでは、ロギングルールを決定できているという前提で進めていきます。

今回の記事ではソースコードすべて載せられていませんが、後述のサンプルAPIサーバを実行し、下記のようにユニークIDを想定したHTTPヘッダー付きでリクエストすると、

curl -H 'X-Transaction-ID:00A-abcdef-99B' http://service-hostname/v2/sample

APIサーバから以下のようなJSON形式のログが標準出力へ出力されます。(フォーマッティングは後付しました。)

{
   "level":"error",
   "msg":"SearchSample panic!",
   "request-id":"00A-abcdef-99B",
   "service-name":"MicroService01",
   "stack":"goroutine 6 [running]...スタックトレースが続く...",
   "time":"2020-03-15T23:39:58+09:00"
}

マイクロサービス共通で利用するロギングライブラリ

上述したマイクロサービスのロギングの共通ルールは共通ライブラリとしてサービス(チーム)間で共有されるべきです。

package logger

import (
    "github.com/sirupsen/logrus"
)

const (
    // XTransactionID はユニークIDのためのHTTPヘッダーのキー名です。
    XTransactionID = "X-Transaction-ID"
)

var (
    // Log は本パッケージのグローバルインスタンスです。
    Log = defaultLogger()

    // ServiceName は各アプリケーションのビルド時に ldflags を利用して埋め込まれます。
    // 下記はビルドの例です。
    // go build -ldflags "-X path/microservice-logging/logger.ServiceName=MicroService01"
    ServiceName = "not-set"
)

// Logger は公開インターフェースです。
type Logger interface {
    Debug(xTxID interface{}, msg string, fields ...Field)
    Info(xTxID interface{}, msg string, fields ...Field)
    Error(xTxID interface{}, msg string, fields ...Field)
}

// NewLogger は Logger インターフェースのコンストラクタです。
// 基本として、マイクロサービスのアプリケーションはこれを利用せずに Log インスタンスを利用することがルールです。
// 開発をする場合や調査をする場合などで Config を設定しこのメソッドを呼び出す。
func NewLogger(conf *Config) Logger {
    return newLogger(conf)
}

func defaultLogger() Logger {
    return newLogger(NewConfig()) //NewConfig() で共通ルールに基づくデフォルト設定がされる。
}

type logger struct {
    *logrus.Logger
    config *Config
}

func newLogger(config *Config) Logger {
    var l = logrus.New()
    {
        l.Level, _ = logrus.ParseLevel(config.minLevel.String())
        l.Formatter = config.formatter
        l.Out = config.out
    }
    return &logger{
        Logger: l,
        config: config,
    }
}

func (l *logger) Debug(xTxID interface{}, msg string, fields ...Field) {
    ...
}

func (l *logger) Info(xTxID interface{}, msg string, fields ...Field) {
    ...
}

func (l *logger) Error(xTxID interface{}, msg string, fields ...Field) {
    ...
}

上記の共通ロギングライブラリにおいて実装のポイントをピックアップすると以下になります。

  • Goのロギングのライブラリには現時点(2020年3月時点)で、最も多くのGitHubスター数のあるlogrusを利用
  • グローバル変数(Log)をエクスポートしそれだけを利用させるようにしている
  • ログのフィールドに入れるサービス名(ServiceName)はアプリケーションのビルド時に組み込むようにしている
  • インターフェースのメソッドの引数にユニークID(XTransactionID)を指定する

これらのポイントはあくまでも一例に過ぎません。どのように実装するかはチームでのコーディングポリシーなどに依存するでしょう。

続いて以下はロギング設定のコードです。デフォルト設定はマイクロサービスのルールとして決定した内容になります。

package logger

import (
    "io"
    "os"
)

// Config は Logger 設定の構造体です。
//
// formatter: Format type of logging, TEXT or JSON
// out:       io.Writer of the logger output
// minLevel:  Minimum level to out
type Config struct {
    formatter Formatter
    out       io.Writer
    minLevel  Level
}

// ConfigOption はFunctional Options Patternで
// Configのフィールドを設定するための関数型です。
type ConfigOption func(*Config)

// NewConfig は *Config のコンストラクタです。
//
// マイクロサービスのルールである各デフォルト設定
// Formatter: JSON
// Out:       STD OUT
// MinLevel:  Info
func NewConfig(options ...ConfigOption) *Config {
    config := &Config{
        formatter: Formatters.JSON,
        out:       os.Stdout,
        minLevel: Levels.Info,
    }
    for _, option := range options {
        option(config)
    }
    return config
}

func WithMinLevel(minLevel Level) ConfigOption {
    return func(c *Config) {
        c.minLevel = minLevel
    }
}

func WithFormatter(formatter Formatter) ConfigOption {
    return func(c *Config) {
        c.formatter = formatter
    }
}

func WithOut(out io.Writer) ConfigOption {
    return func(c *Config) {
        c.out = out
    }
}

上記の例では

  • ログデータはJSON形式 (formatter: Formatters.JSON)
  • ログの出力先は標準出力 (out: os.Stdout)

というルールをデフォルト設定としています。(脱線ですがGoのio.Writer, io.Readerはとても良いものです。)

これらの設定に関してもチームが持つインフラ構成や方針に影響されるものです。

共通ロギングライブラリを利用するアプリケーションコード

上記の共通ロギングライブラリを利用するアプリケーションの実装例は以下のようになります。

ここではGinフレームワークを利用したAPIサーバを想定しています。Ginのミドルウェア内でプログラムpanic時にスタックトレース付きでロギングします。

import (
    "fmt"
    "net/http"
    "runtime"

    "github.com/gin-gonic/gin"

    "path/microservice-logging/logger"
)

var (
    defaultStackSize = 4 << 10 // 4 kb
)

// Recovery はGinフレームワーク で利用するミドルウェアです。
// 下層からの panic をリカバリーしロギングとHTTPレスポンスセットを実施します。
func Recovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if r := recover(); r != nil {
                err, ok := r.(error)
                if !ok {
                    err = fmt.Errorf("%v", r)
                }
                var (
                    stack  = make([]byte, defaultStackSize)
                    length = runtime.Stack(stack, true)
                )
                logger.Log.Error(
                    /* Request-ID */ c.Request.Header.Get(logger.XTransactionID),
                    /* msg */ err.Error(),
                    /* Additional fields */ logger.F("stack", fmt.Sprintf("%s ", stack[:length])),
                )
                c.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{
                    "errors": []string{"unexpected error"},
                })
            }
        }()
        c.Next()
    }
}

func main() {
    router := gin.New()
    {
        router.Use(
            Recovery(),
        )
    }
    ...
}

おわりに

ベストプラクティスな方針を知ることができましたが、どうやって実現するかはどうしても開発チームごとに工夫しながら作っていく必要があると思います。日々精進です💪

また、今回のロギング実装の例ではパフォーマンスに関する考慮が不足しています。

  • メモリアロケーションがより効率的なzapというライブラリに切り替える
  • リクエストをさばいた最後にまとめて出力するようバッファを利用する

といった改善案が考えられます。
今後こういった面でも検証する予定です。

それでは (^_^)/~

参考