EatSmartシステム部ブログ

ウェブサイトの開発や運営に関する情報です。

SpringBootで遭遇したルーティング問題

今回は、SpringBootのプロジェクトの中で学んだことについてまとめていきます。 もぐナビのスマホページのamp対応として、別プロジェクトにSpringBootを使用しております。 簡単にプロジェクトの環境としては以下の編成になります。 SpringBoot 1.5.2
Thymeleaf 2.1.5
Doma 1.1.0

今回、実装した内容は以下のような食品ブランド×カテゴリのページのamp版になります。 https://s.mognavi.jp/amp/brand/2113/ranking/konbini-sandwich

使用したアノテーションについて

@RequestMapping
コントローラーの処理の中でurlとの紐づけを行い、value属性でパスを指定します。 method = RequestMethod.GETとすることで、リクエストの形式をGETと指定する ことができます。@RequestMapping(value = {"/index" }) method属性を利用する場合には、@RequestMapping(path = "/input", method = RequestMethod.GET)と 書くことも可能です。

@PathVariable
urlに含まれる値をパラメーターとして動的に取得する場合に利用ができます。 例えば、https://mognavi.jp/food/1(※foodIdと仮定)でアクセスしてきた場合に、 foodIdを取得して利用したい場合に、@PathVariable int foodIdとすることで1という値を取得できます。

SpringBootのルーティングで躓いたこと

最初に実装したパターン(エラーが起こる)

(既存コード)
@RequestMapping(value = "/amp/brand/{key}/ranking/{rankingType}")
    ModelAndView ranking(@PathVariable String key, @PathVariable String rankingType) {
    省略
  }
(追加コード)
@RequestMapping(value = "/amp/brand/{key}/ranking/{categoryPath}")
    ModelAndView rankingCategory(@PathVariable String key, @PathVariable String categoryPath) {
    省略
}

既存のページで既に/amp/brand/{key}/ranking/{rankingType}というパスでアクセスするものがありました。
※keyには、ブランドに紐づいたコードが対応。
※rankingTypeは、注目と評価ランキングに相当。注目→1、評価→2。

今回新規で追加したものは、/amp/brand/{key}/ranking/{categoryPath}というパスの形式になります。
※keyには、上記の説明と同様の値。
※categoryPathには、sweets(スイーツ),konbini-sandwich(コンビニサンドイッチ)など食品のカテゴリが対応。

この実装で、例えば/amp/brand/2113/ranking/konbini-sandwichへアクセスしてみると、
java.lang.IllegalStateException: Ambiguous handler methods mapped for HTTP path 'http://localhost:8888/amp/brand/2113/ranking/konbini-sandwich'
というエラーが返されました。同じ形式のパスが二つあると解釈されてしまいます。

試したこと

@PathVariableで渡すrankingTypeとcategoryPathという型が同一であるため、パスを識別できないのかと思いました。 rankingTypeをStringからIntegerに、categoryPathは、そのままStringに。@PathVariable Integer rankingType、もう一方は@PathVariable String categoryPathとすることでリクエストを別にしてくれることを期待しましたが、同じエラーが発生します。

最終的な解決策

@RequestMapping(value = "/amp/brand/{key}/ranking/{parameter}")
    ModelAndView ranking(@PathVariable String key, @PathVariable String parameter) {
        if ("1".equals(parameter) || "2".equals(parameter)) {
            return ranking(key, Integer.parseInt(parameter));
        }
        return rankingCategory(key, parameter);
    }

ModelAndView ranking(@PathVariable String key, @PathVariable String rankingType) {
    省略
  }

ModelAndView rankingCategory(@PathVariable String key, @PathVariable String categoryPath) {
 省略
}

コードの説明になりますが、1又は2がparameterに渡された場合には、rankingというビューの情報を返し、それ以外の場合にはrankingCategoryのビューを返すという方法をとりました。 こうすることで、パスがうまく識別され、@Pathvariableに渡る値によって異なるビューを返すようになりました。

補足

ちなみに、parameterをparamとすると以下のようにエラーとなりました。 java.lang.IllegalArgumentException: Putting a context variable with name "param" is forbidden, as it is a reserved variable name. param自体がThymeleafに由来するクラスの予約語として扱われているため、使用することができないようです。

まとめ

以上ですが、エラーの原因はメッセージから読み取れるものの、解決に至るまでが難しかったです。 また、今回は書きれませんでしたが、最初の構成で紹介したThymeleaf、Doma(O/Rマッパー)の固有の機能についても便利なものがあり、実務で触れることができて良かったと思いました。

クスパ決済の導入について

現在クスパでは、レッスン予約のオンライン決済サービス(以降「クスパ決済」)の導入を検討しております。今回はオンライン決済の導入に向けて調査・検討している事を記事にしてみました。

cookingschool.jp

クスパ決済の導入

クスパで料理教室のレッスン予約をする際、現状も予約申込はオンラインで行えますが、受講料の支払いは現地払いのみとしておりました。
しかし、他社動向や先生/ユーザー双方の利便性を考慮しオンライン決済の導入を検討しております。

オンライン決済を導入するには?

オンライン決済を導入する場合、決済代行会社を利用するケースが多いかと思われます。

決済代行会社とは、クレジットカード決済やコンビニ決済、携帯キャリア決済等の様々な決済手段を導入したい事業者と各決済機関の間に立ち、一括で契約したり・管理システムを提供する会社のことです。

今回クスパ決済でも、決済代行会社を利用する予定です。 そこで、決済代行会社を利用する上でのメリット・デメリットを簡単にまとめてみました。

メリット

  • いろんな決済手段(クレジットカード、コンビニ決済、キャリア決済etc)を自社で契約せずに利用できる

  • 決済代行会社が用意しているAPI、画面リンク等のインターフェースを利用する為、導入コストを削減できる

  • クレジットカード情報を導入者側で保持しなくて良い

デメリット

  • 決済代行会社に各種手数料を支払う必要がある

オンライン決済のユーザーニーズについて

オンライン決済を導入するに辺り、クスパユーザーはどんな決済方法を求めているのかアンケートを実施してみました。結果は以下の通りです。

1位 クレジットカード決済
2位 現地払い
3位 コンビニ決済

「クレジットカード決済」「現地払い」に続き、「コンビニ決済」に需要があることが分かりました。

決済方法の選定について

上記アンケート結果を踏まえ、「現地払い」に加え「クレジットカード決済」「コンビニ決済」の導入を検討しております。そこで、導入後の決済フローを簡単に紹介します。

クレジットカード決済

f:id:eatsmart:20191029134302j:plain

コンビニ決済

f:id:eatsmart:20191029134430j:plain

コンビニ決済の返金処理

コンビニ決済の場合、ユーザーがレッスンの予約をキャンセルした場合などに返金が必要となります。そこで返金処理をタイムリーに行えないか調査したところ下記サービスがあることが分かりました。こちらも、現在導入を検討しているところです。

www.econtext.jp

今後について

現在クスパ決済の導入に向けて取り組んでおりますが、クスパをご利用頂いているユーザーや料理教室の先生により良いサービスを提供できる様に努めておりますので、サービス開始した際は是非ご利用下さい。

Docker Swarmの障害への対応

イートスマートでは、提供するサービスのフロントエンドで利用するミドルウェアをDockerコンテナ化し、Swarmモード上で稼働させています。 先日発生したDocker Swarmの障害と、それへの対応をまとめました。

アラート

サーバやコンテナの状態はZabbixを利用して監視しています。 9月30日の午後、syslogの監視から一通のアラートが飛んできました。 ログの内容は以下の通りでした。

Sep 30 17:14:47 docker01 dockerd[18844]: time="2019-09-30T17:14:47.363605167+09:00" level=error msg="error receiving response" error="rpc error: code = DeadlineExceeded desc = context deadline exceeded"

サービスへのアクセスは問題なくコンテナの死活監視なども異常が発生していないので、当時は大事になるとは思っていませんでした。 サーバにsshでログインしてDockerコンテナの状態を見ても問題が無かったのですが、ノードの状態を確認しようとした時に事の大きさに気づきました。

# docker node ls
Error response from daemon: rpc error: code = Unknown desc = The swarm does not have a leader. It's possible that too few managers are online. Make sure more than half of the managers are online.

ここで、Docker Swarmで障害が発生していることに気づきました。 コンテナ間の通信にオーバーレイネットワークを構築しているのですが、Swarmモードが機能しないとオーバーレイネットワークへのコンテナを参加させることができなくなります。 具体的には、docker runやrestart時にエラーが発生し、コンテンが起動できません。

原因

各サーバのログを見る限り、クラスタの各ノードは以下の動きをしていました。

  1. リーダーがフォロワへ降格
  2. リーダーを喪失
  3. ノード間の通信にエラーが発生する
  4. ノードAがリーダーに立候補するが過半数を得られない
  5. ノードBがリーダーに立候補し過半数を獲得しリーダーになる
  6. ノードBへノードAから5の時点より新しいリクエストが来たため、ノードBはフォロワへ降格
  7. リーダーの選出が繰り返されるも、ノードBへ投票したノード群が否決を繰り返し誰も過半数を獲得出来ない

以前行った検証ではリーダーの喪失からリーダーの選出が行われクラスタが維持されることを確認したのですが、今回は過半数のノード群が否決を繰り返すようになったため、リーダーの選出する機能しなくなっていました。

検証

このままではコンテナの更新や再起動が出来ないので、Swarmモードを回復させなければなりません。 障害が発生した環境と同等のものを用意し、検証を行いました。 検証の方法としては、以下を行いました。

  1. ノードAとノードBの2台でSwarmモードを構築する
  2. ノードBで"docker swarm leave --force"で強制的に切り離す
  3. ノードAで"docker node ls"を実行すると同様のエラーが発生することを確認する
  4. ノードAで"docker swarm init --force-new-cluster"を実行して再構築する
  5. ノードAで作成したトークンでノードBを復帰させる
  6. コンテナを再起動する

以上の手順でオーバーレイネットワークが回復することを確認できました。

復旧

10月2日の早朝にメンテナンスを実施しました。 復旧にあたり、ノードAをリーダーへ選出するためノードBを含む否決を行うノードを切り離してみましたが、ノードの総数が更新されないため過半数を獲得出来ず失敗しました。 次に、検証で行ったようにノードA以外をを順次切り離し、クラスタの再構築を行いました。

まとめ

今回の復旧手順では、既存のクラスタから一度に全てのノードを切り離しを行った後に新しいクラスタの構築を行ったため一時的にサービスを停止が発生しました。 復旧後のふりかえりでは、既存のクラスタから1台ずつ切り離し新しいクラスタへ参加させればサービス停止が発生しないのではないかという話がありました。 今回は復旧まで十分な検討が出来ませんでしたが、次回以降は今回の経験を踏まえて対応したいと思います。

2019年9月のGoogleコアアルゴリズムアップデートについて

以前、2019年前半に実施されたGoogleのコアアルゴリズムアップデートについて記事を書いたのですが、

eatsmart.hatenablog.com

2019年9月の末にまたコアアルゴリズムアップデートが実施されました。

【September 2019 Core Update】Google、今年3回目のコアアップデートを実施 | 海外SEO情報ブログ

昔は、GoogleのIndex更新とかGoogleダンスとかパンダアップデートとかペンギンアップデートとかで、自然検索流入の変動に一喜一憂していましたが、大きな更新がコアアルゴリズムアップデートとなっても影響度合いの大きさや振り回されることには変わりないですね。

今回は、今年実施された3回のアップデートの影響と対応策をまとめたいと思います。

今までのアップデートでの影響

March 2019 Core Update

冒頭の記事の最後にも触れましたが、このアップデートでは、日付の古いページの検索順位が下がっていたようです。もぐナビニュースで、色々な切り口での人気ランキングの記事を掲載していたのですが、古くなった記事はより新しい(他のサイトの)記事系コンテンツに替わられていました。

3月から4月というのは季節要因もあって例年トラフィックが減るんですが、アップデートの影響もあって10%近くトラフィックが減ったと思います。

ランキングコンテンツは、消費者によるクチコミ・評価サイトである我々だからこそ提供できる大きな価値があるコンテンツですので、それまで商品の一覧だけだったランキングページを、コンテンツの内容を厚くして最新の状態に保てるよう更新するように対応しました。

June 2019 Core Update

このアップデートは6月の後半に実施されたのですが、すぐにトラフィックが減るというよりは、7月から8月の期間で徐々にトラフィックが減っていったという感じでした。7月から8月も季節要因でトラフィックが減るので、こちらもアップデートだけの影響では無いと思いますが、お盆くらいまでかけてさらに10%近くトラフィックが減りました。

この時は、全体的にSearchConsoleの表示件数やサイトへの流入が減っている状態だったので、なかなか状況を把握しきれませんでした。 ただ、もぐナビは数多くの食品情報やクチコミをデータベース化してロングテールでの検索需要をSEOで狙っているのですが、それらのGoogleへのインデックス状況が大きく変動している様子が分かりました。

その状況に対応するために、インデックス数の減っている種類のページを見直して、ユーザーにとって使いやすいようページ・コンテンツ構成を変更したり、クロールバジェットも考慮してsitemap.xmlの構成や内容を見直し、クロール状況やクロール頻度を改善できるようにしました。

September 2019 Core Update

上記の対応を行い、9月くらいから徐々にユーザーのランディングや導線が変わりつつありました。構成を見直したページの滞在時間やPV/SSが良かったので、そのページへ流れていくように、さらに対応を考えている段階でアップデートが実施されました。

アップデートとしては9月末にアナウンスがあり実施されたのですが、トラフィックは9月末と10月始めの2段階で変わったように思います。 影響としてはかなり良いもので、前2回のアップデートで下がった検索順位が元に近いくらいまで戻りました。 また、もぐナビニュースの記事ページがDiscoverで表示されるようになり、そちらからの流入も増えました。

アップデートでDiscoverに影響があるかもしれないことは、公式でもアナウンスしていますね。

Google ウェブマスター向け公式ブログ: Google のコア アップデートについてウェブマスターの皆様が知っておくべきこと

今回のアップデートで思うこと

Googleは公式に「コア アップデート後に掲載順位が下がった場合は、何かする必要があると感じるかもしれません。そのような場合は、できるだけ優れたコンテンツの提供に集中することをおすすめします。」と言っています。その通りで、何が起きたかはある程度分析できても、アルゴリズムをハックして対策することは事実上不可能だと思います。

今年おこなった対応は、アップデートをきっかけとしていますが、全てユーザーがどのように使うかを考えた結果を実現したものです。なので今回のアップデートでトラフィックが増えたとしても、「元に戻せた」ではなく「今の状態が評価された」結果だと思っています。

実際はアップデートで検索順位が上がった、ということよりも、コツコツ対応していることで徐々に伸びていくことの方が達成感があるし、「次はいつ落ちるか分からない」という心配はしたくないんですよね。 まあ、今回は上がったから言えることなんでしょうけど。

例年、クリスマスシーズンに向けてGoogleは大きな変動はさせないと(勝手に)信じているのですが、今後、春先と夏終わりあたりにアップデートを実施する感じになるんでしょうか。

jpegtranを使った画像最適化について

今回は、jpegtranを使用した画像最適化についてまとめていきます。 経緯として、クスパ-料理教室ポータルサイトで使用している先生方のブログ画像 の容量が膨大になっており、ディスク容量を圧迫していたため画像の最適化を検討しました。

jpegtranについて

jpegtranは、jpeg画像のメタ情報を削除することで、画像の圧縮を実現するためのツールになります。 以下のように使うのが基本形です。 jpegtran -copy none -optimize -outfile "出力ファイル" "投入ファイル" ※投入ファイルが最適化したい元画像、出力ファイルが最適化後のファイルになります。

オプションについて
-copy none jpegメタデータを全て削除する。
-optimize ハフマン符号を最適化する。

注意点として、投入ファイルと出力ファイルを同一名にすることが仕様上できないことです。 例えば、投入ファイル名 abc.jpg → 出力ファイル名 abc.jpgという設定はできません。 そのため、出力ファイルを別名とした上で、リネームして投入ファイル名に戻すなどの対応 が必要になります。 ※今回は、拡張子jpgの後に.optを加え元画像と区別した。

画像最適化のために使用したコード例(検証版)

WORK_ROOT=/---/---/---
echo $WORK_ROOT

for TARGET in `find $WORK_ROOT/blog \( -name \*.jpeg -o -name \*.JPEG -o -name \*.JPG -o -name \*.jpg \)
 -and -daystart -mtime +7 | head -n 1000`

do
echo '####target list is '$TARGET
jpegtran -copy none -optimize -outfile "${TARGET}.opt" "${TARGET}"
if [ "${TARGET}.opt" ]; then
find . -name "*.opt" | xargs rename .opt '' * ※1
fi
done

検証段階ではこのような形でシェルスクリプトを作成し、実行していました。 まず、対象となるjpeg画像をfindし、TARGETにセットします。 -daystart -mtime +7としているのは、現在から過去7日目を基準として そこから過去全ての期間を対象とするオプションになります。 負荷対策のため、処理対象をheadで限定し、1000件としました。

$TARGETには、取得した1000件の1件ずつが入り、jpegtranを1件ずつ かけていきます。if文で出力ファイル(.opt)の存在チェックをし、投入ファイル (元画像)を削除するということを行っています。その後、.optファイルを元の 拡張子(jpgなど)に戻すということをしています。

結果的にこのコードを本番でそのまま採用するには至りませんでした。 以下の二点が理由になります。
① if文の存在チェックがスルーされ、jpegtranが失敗した場合に出力ファイル(.optファイル) ができていないのに、元画像を削除しまっていた。
② ※1のfindの処理が不必要な範囲で検索をかけてしまっている。

画像最適化のために使用したコード例(本番)

WORK_ROOT=/---/---/---
echo $WORK_ROOT

count=0

for TARGET in `find $WORK_ROOT/blog \( -name \*.jpeg -o -name \*.JPEG -o -name \*.JPG -o -name \*.jpg \) 
-and -daystart -mtime +7 | head -n 1000`

  echo '####target list is '$TARGET
    jpegtran -copy none -optimize -outfile "${TARGET}.opt" "${TARGET}"
    if [ -f "${TARGET}.opt" ]; then
    rm $TARGET
    mv ${TARGET}.opt $TARGET
    fi

    echo $count
    count=$((count+1))
    if [ $((count%1)) -eq 0 ]; then
       echo 'sleep'
       sleep 0.01
    fi

理由①の改善として、-fオプション(fileが存在し、且つ、通常のファイル) を入れることでファイル存在のチェックが効くようになりました。 理由②の改善は、renameを使った記述ではうまくいかず、moveで対応して 不必要なfindを省略できました。 さらに、処理する際の負荷を一定にするために1件処理する毎にsleep処理を挟むように 改善しました。

結果・まとめ

Blog画像の容量 (before)173G→(after)169G -4Gという結果で当初予想していたより効果が 出なかったので残念ではありますが、一通りの流れと地雷ポイントがわかったことが収穫でした。 元画像を削除してしまうというリスキーな面があるので、バックアップは必須です! 以上となります。

クスパレシピページの有効インデックス数低下について

クスパでは、料理教室の先生が投稿するレシピを掲載していますが、8月下旬辺りからGoogle Search ConsoleのAMP/レシピの有効インデックス数が低下して、本日迄に行った施策を記載したいと思います。

cookingschool.jp

最初に確認したこと

有効インデックス数が低下した原因を調査するに辺り、最初に確認したこととして、AMPページが正しく認識されているかを確認する為に、Google AMPテストを実施しました。

AMPテスト search.google.com

AMPテストでは正常に認識されている事が確認できたので、次に「URLの正規化」が正しく行われているかを改めて確認しました。
クスパの場合、正規(PC)ページ+モバイルページ+AMPページを別のURLで構成している為、以下の様な正規化を行っていました。 f:id:eatsmart:20190918143608j:plain

「URLの正規化」についても特に問題なかったので、次に、構造化データが正しく認識されているかを確認する為にGoogle構造化データテストツールを実施しました。

search.google.com

いずれも、インデックス数低下前と変わらず問題ありませんでした。

モバイルファーストインデック(MFI)

Search Consoleでインデックス数低下の原因を色々調査していたところ、9/1からモバイルファーストインデック(MFI)に切り替わっていることが判明しました。
※MFの移行確認ですが、Search Consoleにアクセスした際にポップアップが出ますので、それで確認できます。その他の確認方法として、Search Consoleの「設定」ページ等でも確認可能です。

www.suzukikenichi.com

MFI対策

「URL正規化」の見直し

MFI対策としてまず対応したこととして、「URLの正規化」の見直しを行いました。具体的には、モバイルページからAMPページへamphtmlを追加しました。 f:id:eatsmart:20190918144331j:plain

その後、1週間程度経過したらAMPの有効インデックス数がほぼ低下前迄回復しました。

構造化データの見直し

クスパでは構造化データにMicroformats(hrecipe)を採用していますが、元々正規(PC)ページには構造化データが設定していましたが、モバイルページにも設定を行いました。

Microformats(hrecipe) microformats.org

その後、3日程度経過したらレシピの有効インデックス数も回復しました。

結論

今回の原因ですが、URLの正規化や構造化データがモバイルページに設定されていない事で正しく認識されずにインデックス数が低下しました。
まだMFIに切り替わっていないサイトについては、事前のMFI対策が必要と思われますので参考にしてみて下さい。

ClamAVのエラーとその解決

イートスマートで提供しているサービスでは、クライアントからファイルをアップロードする機能を提供しているものがあります。 アップロードされたファイルを利用するにあたり、安全に利用するためClamAVを利用してウィルスのチェックを行っています。 ある日、この処理が以下のエラーで失敗していることが判明しました。 手動で実行したところ、以下のようなエラーが出力されました。

# clamdscan /xxx/yyy/zzz.jpg
/xxx/yyy/zzz.jpg: Can't open file or directory ERROR

この内容から、最初は該当ファイルにアクセスする権限が無いのだと思いましたが、読み取り・実行権限がありました。 そもそもその場合は"Permission denied"のようなエラーになるハズです。 いろいろ試すなかで、/tmp以下に置いたファイルは正常に処理出来ます。

# clamdscan /tmp/test.txt
/tmp/test.txt: OK

パーミッションには問題が無く外のバッチ処理は正常に実行出来るので、clamdscanの実行に問題があるのではないかと調査しました。 すると、AppArmorというものがclamdscanの実行を制限していることがわかりました。

ja.wikipedia.org

以下のようにclamdscanの実行に必要な設定を追加しました。

# vim /etc/apparmor.d/local/usr.sbin.clamd
cat /etc/apparmor.d/local/usr.sbin.clamd
# Site-specific additions and overrides for usr.sbin.clamd.
# For more details, please see /etc/apparmor.d/local/README.
/xxx/yyy/    r,
/xxx/yyy/** r,
# apparmor_parser -r -K /etc/apparmor.d/usr.sbin.clamd

以上のことを行うことで、ウィルスのチェック正常に実行することが出来るようになりました。

# clamdscan /xxx/yyy/zzz.jpg
/xxx/yyy/zzz.jpg: OK