| @音楽

Circle 2018

昨年、 Sunset Live 2017 に行ってペトロールズのことを好きになり( 3 月のライブにも行った)、 Circle に彼らがやってくるというので海の中道海浜公園まで行ってきた。

Circle 、 Sunset Live のような感じなのかと思っていたけど非常に快適でよかった。

Circle の良い点としては以下がある。

  • 来てる人が色白で体感治安がよい。夏のあとのフェスだとみんな真っ黒に日焼けしてて遊び人風情がある。音楽が好きというよりお祭り好きでやってきてとりあえず服を脱いで異性の目を引くような男女がいないのが良い。みんなちゃんと音楽を聞きに来ている感じがする。
  • 入場時に手荷物検査があったりしないのがよい。スタッフが高圧的でない。お客さんにぐでんぐでんに酔っ払って暴れてる人とかいないので高圧的になる必要がないのだろう。
  • ステージの合間に地元ローカルタレントによる MC が入らないのがよい。淡々とアーティストが出てきて演奏していく。
  • 出演アーティストがよい。カクバリズムの人たちが沢山出ててシャレオツだった
  • バスが混んでおらず異常な並び方をしなくて良いのが良い。会場までも渋滞などすることなくサクッと着いた。バスは綺麗な車輌で快適そのものだった。通常の路線バスにぎゅうぎゅうに押し込まれ渋滞地獄のなか向かわないといけなかったりすると本当に最悪。
  • 混雑具合が程よく、疲れたら後ろの方のござエリアやテントエリアから座って音楽を聴くことができるのが良い。キャンプ椅子やテーブルを持ち込めるのもよい。

トイレの配置が偏っててめっちゃ並ぶところと空いてるところがあるのが困ったが、それ以外は特に問題がなく快適な野外音楽イベントだった。ぜひまた来年も行きたい。

| @技術/プログラミング

A summer storm

問題点

Rails でデファクトスタンダードとなっているページネーション gem に Kaminari というのがある。

めっちゃ最高便利で大好きなのだけど、巨大なテーブルに対して COUNT 文を投げると遅いという問題にぶち当たった。このような巨大なテーブルで Kaminari を使うために COUNT 文を発行しない without_count というメソッドが用意されている( Kaminari 1.0.0 でやってくる 5 つの大きな変更 - Qiita )が、これを使うと next_pageprev_pagetotal_pages が取れなくなる(当たり前)。次のページがあるかどうかはばくち状態になってしまう。

本当は DB のスキーマを見直すべき(インデックスがちゃんと効くようにスキーマ変更するべき)だが、 Rails からもレガシーアプリからも同時に同じ DB にアクセスしており、並行運用しているような状況ではなかなか大胆な変更は実行できない。

DB 構造をなおせないとなるとキャッシュを思いつく。 HTML も Rails でレンダリングするのであれば partial cache などでページャー部分だけをキャッシュすれば良いが、 API 選任野郎と化した Rails ではビューのキャッシュはできない。

どうしたか

total_count をキャッシュする。公開範囲を設定できるようなリソースだと全員同一のキーでキャッシュするわけにはいかないのでユーザーごとにキーを作ってキャッシュする必要あり。全ユーザーの全リクエストでスロークエリになってたやつが 5 分に一回スロークエリになるくらいだったら何とか許容できる。

例えば以下のようなコントローラーがあったとする。 HeavyModel には数千万レコードあって、普通に COUNT 文を投げると遅い。 Paginatable という名前でモジュールを定義して、 render メソッドを上書きし、ページネーションを間に挟み込む。

class HeavyModelController < ApplicationController
  include Paginatable

  before_action :login_required

  def index
    resources = HeavyModel.all
    render json: resources, paginate: true
  end
end

モジュールはこんな感じ。車輪の再発明をしている感はあるが、 COUNT 文の結果が current_user 、コントローラーのクラス名、アクション名のそれぞれをつなげたものをキーにしてキャッシュされる。

module Paginatable
  def render(*args)
    options = args.extract_options!
    resources = options[:json]
    if options[:paginate]
      resources, meta = paginate(resources, cache_total_count: options[:cache_total_count])
      options[:json] = resources
      options[:meta] ||= {}
      options[:meta].merge!(meta)
    end
    args << options
    super(*args)
  end

  def paginate(resources, options = {})
    parse_params_for_pagination
    paginator = Paginator.new(
      resources:         resources,
      page:              @page,
      per:               @per,
      cache_total_count: options[:cache_total_count],
      cache_key:         total_count_cache_key
    )
    [paginator.resources, paginator.meta]
  end

  def total_count_cache_key
    @total_count_cache_key ||= "#{current_user&.id}_#{self.class.name}_#{action_name}_count"
  end

  class Paginator
    attr_reader :cache_total_count

    UNCOUNTABLE = -1

    def initialize(resources:, page:, per:, cache_total_count: false, cache_key: nil)
      @_resources        = resources
      @page              = page.to_i
      @per               = per.to_i
      @cache_total_count = cache_total_count
      @cache_key         = cache_key
    end

    def resources
      @resources ||= if cache_total_count?
                       @_resources.page(page).per(per).without_count
                     else
                       @_resources.page(page).per(per)
                     end
    end

    def page
      @page.zero? ? 1 : @page
    end

    def per
      @per.zero? ? Kaminari.config.default_per_page : @per
    end

    def meta
      {
        current_page: current_page,
        next_page:    next_page,
        prev_page:    prev_page,
        total_pages:  total_pages,
        total_count:  total_count
      }
    end

    alias cache_total_count? cache_total_count

    def paginatable?
      !cache_total_count? && resources.respond_to?(:total_count)
    end

    def current_page
      resources.current_page || page
    end

    def next_page
      paginatable? ? (resources.next_page || UNCOUNTABLE) : next_page_fallback
    end

    def next_page_fallback
      return UNCOUNTABLE if page < 1
      return UNCOUNTABLE if per > resources.length
      total_count_fallback > current_page * per ? current_page + 1 : UNCOUNTABLE
    end

    def prev_page
      paginatable? ? (resources.prev_page || UNCOUNTABLE) : prev_page_fallback
    end

    def prev_page_fallback
      return UNCOUNTABLE if page < 2
      (total_count_fallback.to_f / per).ceil >= page ? current_page - 1 : UNCOUNTABLE
    end

    def total_pages
      paginatable? ? resources.total_pages : (total_count.to_f / per).ceil
    end

    def total_count
      paginatable? ? resources.total_count : total_count_fallback
    end

    def total_count_fallback
      @total_count_fallback ||=
        begin
          cached_total_count = Rails.cache.read(@cache_key)
          if cached_total_count
            cached_total_count
          else
            real_total_count = @_resources.page(page).total_count
            Rails.cache.write(@cache_key, real_total_count, expires_in: 5.minutes)
            real_total_count
          end
        end
    end
  end
end

これで 5 分間はキャッシュが効くようになる。

| @WWW

先日書いた Day One のバックエンドで障害 - portal shit! について、 Day One のヘルプページで詳細を説明する記事が掲載されていました。

ウェブアプリケーションエンジニアの皆さんが読むと参考になるのではないかと思い、翻訳の可否をたずね許可をもらったので翻訳します。


2018 年 5 月の Day One 障害報告

執筆者: Paul Mayne (訳注:Day One の創業者)
今週更新(訳注:障害復旧日の午後に公開されました)

2018 年の 5 月 7 日から 10 日にかけて、 Day One は重大な Sync サービスの停止に陥りました。ユーザーの皆さんから堅牢な Sync サービスを期待されていることはわかっていますし、この出来事は我々の基準を満たすものでもありません。何が起こったのか、そして将来にわたってどのようにこの問題を回避していくかをユーザーの皆さんにお伝えしたいと思います。

簡単なまとめ

5 月 7 日にハードウェア障害が発生し、最初の Sync サービスの停止が発生しました。バックアップデータが不完全だったことが原因で、 5 月 8 日の Sync サービスの復旧処理中、一部のユーザーのアカウント ID が既存のユーザーのものと重複してしまいました。結果、一部の新規登録ユーザーは他人の記事を見ることができる状態となっていました。問題に気がついてすぐに Sync サービスを再び停止させました。対象のユーザーは 106 人で、これは我々のユーザー全体の 0.01% よりも少ない値です。

現在、 Sync サービスは正常に復旧しており、記事が他人に見られることはありません。近日中(訳註:すでにリリース済みです)に意図せず共有されてしまった記事を削除するアップデートを提供します。

End-to-end の暗号化を施していた記事は今回の事故の対象外です。

いかなる偶発的な個人情報の漏出も信用を毀損するものであると我々は認識しています。この不幸な状況にあって我々は、正直に状況を説明することが最善だと思っていますし、また信頼を回復するために全力を尽くしています。今回、個人情報の流出被害にあった 106 人のユーザーには終身の Premium メンバーシップを無償提供し、それぞれ個別に連絡を取っています。自分たちにできるあらゆることを行ってみなさんの信頼を回復していきたいと思っています。

障害の詳細

5 月 7 日月曜日、 Day One の社員が DB サーバーでハードウェア障害が発生していることに気がつきました。問題のあるサーバーをデータベースクラスターから取り除く作業とデータを残りのサーバーに分散する作業を始めました。

データの分散処理に失敗します。これが最初の障害です。すぐに Sync サービスを復旧させたかったため、新しいデータベースクラスターを作成して最新のバックアップデータを読み込むことにしました。新しいクラスターがセットアップされ、バックアップが読み込まれました。

5 月 8 日火曜日の早い時間に復旧処理が完了し、 Sync サービスを再開しました。当初は順調に動いているように見えました。しかし数時間で「自分の記事ではない記事が見える」という問い合わせが何人かのユーザーからありました。これは由々しき事態であり、我々はすぐに Sync サービスを再停止し、これ以上被害が拡大しないようにしました。

この時点で、問題と対応方法の調査を行う間、 Day One Sync を無期限に停止する旨をソーシャルメディアに投稿しました。何が原因なのか、何が起こっているのか、そしてもう問題が起こらないと確信できるまで Sync サービスは再開できませんした。

5 月 9 日水曜日の午前、問題の根本原因を突き止めました。復旧処理に用いたバックアップデータが不完全だったのです。記事データは完全なものでしたが、ユーザーアカウントデータに欠落がありました。具体的には、 3 月 22 日よりも後に作られたユーザーアカウントデータが含まれていなかったのです。その結果これらのユーザーはログインすることができていませんでしたし、特定の記事データが意図せず他人から見えてしまうという問題につながりました。

それぞれの記事データベースは “accountID” というフィールドを持ち、どのアカウントがその記事の所有者であるかを判断しています。全ての記事データは正しく復旧されましたが、ユーザーアカウントデータはそうではなかったため、データベースに所有者が存在しない記事ができてしまいました(例えば “My Travel Journal” という記事は 123456 というアカウントのものだったとしましょう。しかしそのアカウントが存在しなくなってしまったということです)。新しいユーザーアカウントの ID は連番で作られます。復旧されたデータは最新のユーザー情報を含んでいなかったため、 5 月 8 日に新規登録したユーザーは本来よりも小さな値の ID で登録され、既存のユーザーアカウントと重複することになったのです。その結果、これらの新規ユーザーはすでに存在する他人が書いた記事を読めるようになってしまったのです。

5 月 8 日の問題が発生していた期間のうちに、 326 アカウントが正しくない account ID で作成されました。その 326 個の ID のうち、 106 個が別人によって書かれた既存の記事データに結びついていました。 2018 年の 3 月 22 日から 23 日に作成されたアカウントは他人から記事が見られる状態になっていたということです。それらは account ID 1104506 から 1104831 の人たちでした。

現在のところそれらの記事のうちどれくらいが end-to-end の暗号化処理をされていたかはわかっていませんが、 end-to-end の暗号化をしていた記事は今回の問題でも他人に見られることはありませんした。

水曜日の調査の後、元のデータベースでデータの分散処理に失敗する問題を解決することが最善の選択肢だと判断しました。いくつか設定ミスがあり、データベースの負荷が高まる原因になっていることがわかりました。この負荷が原因でデータの分散処理が失敗していました。

水曜日の午後、この問題を修正し、元のデータベースクラスターで分散処理が正しく完了することを確認しました。しかしエンジニアチームとサポートスタッフが万全の態勢で臨めるよう、 Sync サービスの再開を木曜の朝まで遅らせることにしました。山岳時間で 5 月 10 日木曜の午前 8 時、 Sync サービスは再度有効化されました。

同期サーバーには大量の待機処理があったため、それらの処理が終わるまでは少しパフォーマンスに問題があるかもしれませんが、なるべく遅延が発生しないように対処しています。

今後どうするのか?

意図せず共有されてしまった記事を削除する機能が入った Day One.app のアップデート(バージョン 2.6.4 )をすぐにリリースします(訳注:すでにリリース済みです)。このアップデートをインストールすると、未ログイン状態のユーザーの端末からは非公開の記事はすべて削除されます。対象ユーザーはアップデートのインストール後 30 日以内に Day One アカウントにログインすることが必要で、このログインをもって Day One.app はログインユーザーを記事の所有者であると認定し、記事を復元します。 Day One.app は影響を受けるユーザーに対してこの変更内容を通知します。

今後、同様の問題が起こらないように、近々以下の改修を同期サーバーに対して施します。

  1. account ID の新規作成時、すでにその ID で記事が作成されていないかをチェックします
  2. 新規の account ID に対しては、連番の数字に加えてランダム生成された二桁の数字を末尾に付加することにします。将来、同じような問題が発生して連番 ID が若返ってしまったとしても ID の衝突が起こる可能性は非常に小さくなります。
  3. いくらかのユーザーアカウントがバックアップ対象から除外されてしまう問題を修正します。

他人に記事を見られてしまった 106 人のユーザーに対しては真摯に謝罪します。対象のユーザーには終身 Premium メンバーシップを提供するとともに、その他懸念点がないか個別に連絡をとっています。今回、大規模な情報流出は起こっていません。第三者がデータベースに侵入したということもありませんし、すべてのデータは我々のサーバーで安全に保管されています。しかしながら 106 人のユーザーの我々に対する信頼は失われてしまいました。信頼は獲得するものであり、与えられるものではないということを承知していますし、再び皆さんからの信頼を取り戻せる機会を得たいと思っています。ユーザーの皆さんには 2017 年 6 月にリリースした end-to-end の暗号化機能を利用することを推奨します。この機能は今回のような事故やその他の問題があったときにもあなたの個人情報を保護します。

今回の障害で Sync サービスを利用できなかった間、辛抱して下さったユーザーの皆さんに感謝します。皆さんが Day One を使って大事な思い出を記録していることを理解しています。今後、よりよくしていくことをお約束します。信頼を回復するため、全力を尽くします。

— Paul

| @技術/プログラミング

以前、 AWS ECS で試験運用したことがあったので Docker 化自体は済んでいた。 ECS などマネージドコンテナサービスを使わずに Docker 運用ができないか試してみた。

動機

関連記事の更新処理、諸々障害があって自動化できておらず、 DB を clone してきて手元で実行してサーバーにエクスポートするという運用が続いていた。これを自動化したかった。

二つ問題があって、以下の通りだった。

  1. 関連記事の更新処理時に日本語の分かち書きをする必要があるが、 VPS インスタンスのメモリ上限があり MeCab の拡張辞書をサーバー上でインストールできない
  2. VPS 上で SQLite の算術計算を行うためには追加拡張が必要で、そのためには SQLite をソースコードからコンパイルする必要がある

1 は Docker イメージにして手元でイメージをビルドすれば解決できた。 2 の問題も Docker のなかでコンパイルを行うことで解決できた。

どうやるか

  • nginx.conf の修正
  • コンテナの create
    キャッシュのためにファイルシステムを利用しているのでホストとコンテナで public ディレクトリを共有する必要があった。
docker create \
  -e DATABASE_URL=db_url \
  -e RACK_ENV=production \
  -v /home/morygonzalez/sites/portalshit/public:/app/public \
  -p 3001:3001 --name portalshit -it morygonzalez/portalshit bundle exec puma -p 3001
  • コンテナの起動
docker start portalshit

結果どうだったか

サイトを Docker で公開することはできたが、 docker create して docker start するまでの間、ダウンタイムが発生する。

ダウンタイムなしで deploy するためには deploy のタイミングで Nginx conf を書き換えて service nginx reload する必要が出てくる。個人のブログレベルでそこまでやりたくない。

コンテナを管理するサービス( AWS ECS や Kubernetes )があるんだったら Nginx conf の書き換えなどしなくてもいい感じに deploy できると思うが、こちらも個人のブログレベルで使うものではないと思った。

結論

  • サイトの deploy はこれまで通り cap で行い、 puma はホスト OS で普通に動かす(コンテナ化しない)
  • 関連記事表示のバッチ処理のみコンテナ化することにした

| @散財

五円玉と五十円玉

真似して書いてみます。

キャッシュレス済み

Apple Pay

大手資本が関与しているものは基本的に Apple Pay ( iD )で払っている。めっちゃ快適。

  • コンビニ
  • ドンキホーテ
  • カルディ
  • ドラッグストア
    • ココカラファイン
    • マツモトキヨシ

カード払い

  • 酒のやまや
    毎週末酒を買いに行ってるやまやは Edy が使えるが、 Edy のポイント還元率はしょぼいのでクレジットカード払いしてる。やまやはドコモの d ポイントも貯まるのでカードのポイントと d ポイントでマイル乞食がはかどる。
  • ジュピターコーヒー
    カルディのパクリみたいな店でコーヒー豆を買うときにカード払いする。交通系電子マネーで払えるがポイントが付かないのでカード払いしてる。
  • 昼食
    普段は弁当なので昼飯に金を使わないが、稀に弁当を準備できなかった日は会社近くのスーパー( SUNNY )でカード払いする。
    マイル乞食したいのでカード払いできない飲食店に入って食べるということをしなくなった。
  • コストコ
    もう会員証が失効したけど行ってた頃はわざわざコストコ用に Saison Walmart American Express カードを作って払ってた(コストコは提携カードブランドでないとクレジットカード使えない)
    いまは提携カードブランドが AMEX から Master に変わってる

その他

  • ガソリン
    Esso で Express 給油。 Speedpass で Apple Pay 並みに高速に金が払える。
  • 高速代
    ETC カードで払う。
  • 税金
    去年までは楽天カードから nanaco にチャージしてポイント還元を受けながら納税してた。固定資産税と自動車税合わせて年間 14 万くらい。
    今年から楽天カードが nanaco チャージにポイントを付与しなくなったので SEVEN CARD を作って nanaco チャージして払う方法に切り替えた。
    nanaco は税金を払うのにしか使わずセブンイレブンでも買い物は基本的に Apple Pay ( iD )で払う。

キャッシュレス未完

  • 自宅近辺のスーパー
    どこもカード払いできないのでちまちま現金払いしてる。
    福岡のスーパーでカード払いできるのはサニーか西鉄のレガネットか高級スーパーのボンラパスくらいだと思う。
  • 近所のコーヒー豆屋、パン屋、飲食店
    個人商店ではカード払いできない。現金での買い物は極力したくないので結果的に本当に気に入っている店でしか買い物しなくなった。

所感

守銭奴なので基本的に快適性や利便性は二の次で、ポイントが貯まることを重視している。なので交通系電子マネーか Edy のみが使える店では電子マネーを使わずカード払いするしカード払いできないなら現金払いしてる。中途半端に残高が分散するのが嫌なのでチャージ式の電子マネーは nanaco だけ使うようにしてる。それも税金の支払いにしか使わない。一時期は Apple Pay で払うのが面白くて iPhone の Mobile Suica を多用していたけど ANA VISA カードによる Mobile Suica チャージではポイントが付かないことを知って使うのをやめた。

ベストエフォートキャッシュレス化のおかげでデカい財布いらなくなって Bellroy のカード入れに非常用現金数千円を畳んで忍ばせてあとは小銭入れと Apple Pay 付き iPhone だけ持ってうろついてる。 Bellroy のカード入れはめっちゃ薄いので身軽になって嬉しい。

Apple Pay やその他の支払い方法で挙げた ETC カードや Speedpass 、 nanaco も結局はクレジットカードが終端にあり、大意ではクレジットカード払いであると言える。これがネックになってキッャシュレス決済に店側の負担(決済手数料)が発生して大手資本以外でキッャシュレス決済が普及しない遠因になってる気がする。カード決済は個人商店だと 4.5% くらい決済手数料を持ってかれる。 Square とかを使ってもせいぜい 3.25% ( JCB に至っては 3.95% !)までしか落ちないはず。電子マネー決済も似たり寄ったりだと考えられる。クレジットカードを介さないキャッシュレス決済が普及したら手数料が下がり個人商店でも導入できて便利になりそう。

ちなみに中国人観光客が多いキャナルシティ博多では普通に中国人が WeChat Pay だか Alipay だかで QR コード払いしてるので、同じように外国人観光客が多く利用する中洲の屋台なんかの方がスーパーや個人商店よりも先にキャッシュレス化されるかも知れない。

福岡ではまだまだ完全キャッシュレスな生活を送ることは難しいが、頑張れば現金を使う機会を結構減らすことができる。現金は飲み会の割り勘とか祝儀・不祝儀を包むためだけに存在するようになるかもしれない。


東京の富裕なエンジニアのお二方にも書いてもらえたみたいだった。

| @Mac/iPhone

iMac 5K

三井住友 VISA カードで払うと 12 回まで分割手数料が無料だったのでそれで買った。税込み 25 万くらい。 SSD だけ 256GB から 512GB に変えた。あとはキーボードを Magic Keyboard の US 配列にした。マウスは Magic Mouse 2 を選んだけど正直使いづらい。 Magic Trackpad 2 にすればよかった。キーボードを売って Magic Trackpad を買おうか検討中。 MacBook Pro 2009 の方はデータを抜き取ったらメルカリに出そうと思う。 2 〜 3 万くらいでは売れそう。

MacBook Pro の Touch Bar なしのやつとどっちにするか迷ったけど、最近はアイフォ〜ンが進化してて帰省や旅行に Mac を持って行かなくなった(雑な日記書きやインターネット閲覧などはアイフォ〜ンで事足りる)。ほとんど家で使ってるだけ。仕事で必要なときは会社から借りてる MacBook Pro を使うので私物は据え置き型で良いなという結論に至った。同じくらいの値段ででかい 5K ディスプレイが手に入るなら iMac の方が良い。

CPU を Core i7 にしなかったのはミスったかなと思ったけど全く問題無く快適に動く。普通にコードを書いて写真を整理する程度であれば Core i5 で問題無いと思う。ディスプレイでかくて解像度高いのがなによりすばらしい。 MacBook Pro + 外部ディスプレイを使うのよりも省スペースになり机を広々と使えるのもよい。ディスプレイが一枚なので首を左右に動かす負担もなくなる。

メモリを増やしておこうと思って、いまはちょっと値段が高いが 16GB のやつを 2 枚 Amazon で注文した。これでまた 8 年くらいは粘りたい。

| @技術/プログラミング

会社でプロジェクトのふりかえりをしていて自分でエモ発言をしてしまって結構いい話だと思ったのでブログに書いておきます。

新しいプロジェクトを始めるとき、誰しもナウでモダンなフレームワークや技術を使いたいと思うだろう。しかし、 Qiita でみんながその技術についての記事を書いてるから、他の会社の友だちがそのフレームワークを使っているから、という理由で技術選択をしてはいけないと思う。新しいものを使うな、という話をしているのではない。社内によく知ってる人がいない技術を自分の意思で取り入れるからには「覚悟」が必要だという話。わからない挙動に遭遇したときにフレームワークのソースコードを読んで問題を解決したり、バグを踏んだときに修正する Pull Request を送ったりする気概があるか、という意味だ。自分以外の人がそのコードを触るときのためにユニットテストのカバレッジを高く保つということも「覚悟」に含まれるだろう。

その話をしていたらジョジョの奇妙な冒険に有名な台詞があるということだったので、 Slack で覚悟と入力したらその画像が表示されるようにしておいた。

スクリーンショット 2018-04-26 18.43.39.png

「覚悟」とは!!
暗闇の荒野に!!
進むべき道を切り開くことだッ!