EatSmartシステム部ブログ

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

SMTPサーバーの逆引きエラーについて

今回は弊社で運用しているSMTPサーバー(postfix)で発生したメール送信時の逆引きエラーの調査及び対処方法についてを記事にしてみました。

正引き/逆引きとは

ドメイン名に対応するIPアドレスを問い合わせることを「正引き」と言います。正引きは、主にWEBページの閲覧時URLを指定して参照する際に利用されます。
上記に対して、IPアドレスに対応するドメイン名を問い合わせることを「逆引き」と言います。逆引きは、主にサーバ側においてアクセス元を判別したい時に用いられます。

SMTPサーバーのエラーログについて

逆引きエラーが発生した際のSMTPサーバーのログには下記の様なエラーが出力されます

Client host rejected: cannot find your reverse hostname, [100.100.10.10]

実際のログ
2019-10-1T12:52:15.642892+09:00 xxxxxxxxxx postfix/smtp[xxxx]: xxxxxxxxxx: to=<aaa@test.com>, relay=xxx.xxxx.jp[xxx.xxx.xxx.xxx]:25, delay=xxxxx, delays=xxxxxxxxxx, dsn =4.7.1, status=deferred (host xxx.xxxx.jp[xxx.xxx.xxx.xxx] refused to talk to me: 450 4.7.1 Client host rejected: cannot find your reverse hostname, [100.100.10.10])

なぜ逆引きチェックを行うのか?

インターネット上ではスパムメールが頻繁に送信されており、スパムメールを送信する業者は身元を隠すために、わざと「ドメイン」と「グローバル IP」を一致させないように設定しています。
スパムメール送信業者からすると、グローバルIPアドレスを特定されるとISPも特定され、アカウントを削除されたりブロックされてしまうことを防ぐために隠す必要があります。
上記理由から、各メールサーバーはドメイン名とグローバルIPの正引き←→逆引きの結果が一致するかを検証し、一致する場合のみメール送信を許可しています。
※逆引きチェックは全てのメールサーバーが取り入れている訳では無いので、逆引き設定を行っていなくても送信できる場合もありますが、送信不達を防ぐ為にも設定する事をオススメします。

正引き←→逆引きでチェックOKのケース
メールサーバーのドメイン名:test.com
メールサーバーの IP アドレス:100.100.10.10
100.100.10.10 の逆引き結果:test.com
正引き←→逆引きでチェックNGのケース
メールサーバーのドメイン名:test.com
メールサーバーの IP アドレス:100.100.10.10
100.100.10.10 の逆引き結果:aaaa.com

※逆引きチェックで一致しない場合及び逆引きの結果が返却されない場合何れもエラーとなります

逆引設定を行う

弊社はインフラサービスにさくらインターネットを利用しておりますが、逆引き設定は各ベンダーによって異なりますので参考まで。

さくらのクラウド > ルータ+スイッチIPアドレスの逆引き設定

manual.sakura.ad.jp

▼さくらのVPS > DNS逆引きレコードを変更する

help.sakura.ad.jp

さくらのクラウド > DNS逆引きレコードの設定

manual.sakura.ad.jp

▼【さくらの専用サーバ】DNS逆引きレコード設定・変更

help.sakura.ad.jp

正引き/逆引きが正しく設定されているかを確認するには

Windowsで正引き/逆引きが正しく設定されていることを確認する場合は、nslookupコマンドを利用します。

正引きの確認結果
C:\Users\test>nslookup test.com
サーバー:  aaaa.test.local:DNSサーバー
Address:  192.168.10.10

名前:    test.com
Address:  100.100.10.10
逆引きの確認結果
C:\Users\test>nslookup 100.100.10.10
サーバー:  aaaa.test.local:DNSサーバー
Address:  192.168.10.10

権限のない回答:
名前:    test.com
Address:  100.100.10.10
※「権限のない回答とは」DNSキャッシュを使って回答している事を示している

逆引き設定後のSMTPログ確認

逆引き設定後、SMTPサーバーのログにて正常に送信(status=sent)出来ている事を確認します。

実際のログ
2019-10-1T13:22:15.801774+09:00 xxxxxxxxxx postfix/smtp[xxxx]: xxxxxxxxxx: to=<aaa@test.com>, relay=xxx.xxxx.jp[xxx.xxx.xxx.xxx]:25, delay=xxxxx, delays=xxxxxxxxxx, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as xxxxxxxxxx)

まとめ

メール送信時に不達となる原因は色々考えられますが、今回は逆引きエラーを記事にしてみました。
インフラ担当でないとメールサーバーに触れる機会が少ないとは思いますが、トラブルシューティングの参考になりれければ幸いです。

さくらウェブアクセラレータで使うドメインのSSL証明書更新

弊社ではサイトのCDNとして「さくらウェブアクセラレータ」を利用していて、そのドメインの証明書はLet's Encryptで発行しています。 Let's Encryptの証明書は有効期限が90日なので、3ヶ月に一度くらい証明書の入れ替えが必要です。

運用を始めた頃は、実体のサーバーがCDNの背後にあるので、証明書発行を毎回DNS認証で行なっていたのですが、良く考えたらACMEでも認証tokenは毎回発行されるので、オリジンサーバー上でcertbotを実行することで発行することができるなあと思いました。

ネットで色々と調べて、結果、証明書の入れ替えまでを自動化できたので、その方法を記事にしたいと思います。

Let's Encryptの証明書発行

ACMEのためのwebサーバー設定

弊社のサイトのフロントエンドはnginxを使用しているのですが、ACMEのために.well-knownフォルダにアクセスできるようにオリジンサーバーのnginxのconfに以下の設定を追加します。

location /.well-known {
  root /var/www/nginx/certbot;
}

証明書発行

オリジンサーバー上で、以下のコマンドを実行します。

certbot-auto certonly --webroot -w /var/www/nginx/certbot -d ドメイン名 -m root@eatsmart.co.jp --agree-tos -n

これで、オリジンサーバーの/etc/letsencrypt/live/ドメイン名配下に証明書と鍵が生成されます。

証明書更新

さくらウェブアクセラレータのAPIを使用することで、証明書の更新を自動化することができます。

参考)
公開API | さくらのクラウド ドキュメント

まずは、以下のURLからAPIキー(アクセストークン、トークンシークレット)を発行する必要があります。

SAKURA internet Cloud - Home

サイトID取得

初回は、まず操作対象のサイトIDを取得する必要があります。 サイトIDは、以下のコマンドで確認できます。

$ curl -X GET --user "アクセストークン":"トークンシークレット" \
https://secure.sakura.ad.jp/cloud/zone/is1a/api/webaccel/1.0/site
{
    "Total": 3,
    "From": 0,
    "Count": 3,
    "Sites": [
        {
            "Index": 0,
            "ID": "XXXXXXXXXXXXXX",
            "Name": "cdn1.esimg.jp",
            "Domain": "cdn1.esimg.jp",
            "DomainType": "own_domain",
・・・
    ],
    "is_ok": true
}
(JSONを整形しています)

このIDの値を使用します。

証明書更新

更新は以下のコマンドでできます。

curl -X PUT --user  "アクセストークン":"トークンシークレット" \
https://secure.sakura.ad.jp/cloud/zone/is1a/api/webaccel/1.0/site/113000168864/certificate \
-d "{\"Certificate\": { \"CertificateChain\": \"$(perl -pe 's/\n/\\n/' /etc/letsencrypt/live/ドメイン名/fullchain.pem | perl -pe 's/\\n$//')\", \"Key\": \"$(perl -pe 's/\n/\\n/' /etc/letsencrypt/live/ドメイン名/privkey.pem | perl -pe 's/\\n$//')\" }}"

まとめ

上のふたつをまとめ、以下のようなシェルを作って更新するようにしました。

$ cat replace_cdn_cert.sh
#!/bin/sh

TARGET=$1
SITE_ID=$2
echo 'target:'$TARGET
echo 'siteId:'$SITE_ID

certbot-auto certonly --webroot -w /var/www/nginx/certbot -d $TARGET -m root@eatsmart.co.jp --agree-tos -n

curl -X PUT --user "アクセストークン":"トークンシークレット" \
https://secure.sakura.ad.jp/cloud/zone/is1a/api/webaccel/1.0/site/$SITE_ID/certificate \
-d "{\"Certificate\": { \"CertificateChain\": \"$(perl -pe 's/\n/\\n/' /etc/letsencrypt/live/$TARGET/fullchain.pem | perl -pe 's/\\n$//')\", \"Key\": \"$(perl -pe 's/\n/\\n/' /etc/letsencrypt/live/$TARGET/privkey.pem | perl -pe 's/\\n$//')\" }}"

$ ./replace_cdn_cert.sh cdn1.esimg.jp XXXXXXXXXXXXXX

これで、証明書更新の通知が憂鬱ではなくなりました!

Zabbix から Spring Boot アプリケーションのデータベース接続状況を取得する

イートスマートでは、Zabbixで各サーバやコンテナのリソースを管理し、必要に応じてアラートを飛ばしています。 先日、データベースの接続数が増えてきたのでコンテナごとに接続数を把握しようと思い、Zabbixのテンプレートを使い以下の2つの項目を監視するようにしました。

jmx["Catalina:class=javax.sql.DataSource,context=/,host=localhost,name=\"database\",type=DataSource",numActive]
jmx["Catalina:class=javax.sql.DataSource,context=/,host=localhost,name=\"database\",type=DataSource",numIdle]

それぞれ接続数とアイドル数です。 Tomcatを利用するアプリケーションは問題なく取得出来ましたが、Spring Bootを利用するアプリケーション(以降Spring Bootアプリ)で取得出来ませんでした。 Spring Bootアプリに必要となった設定をまとめます。Spring Bootのバージョンは少し古く1.5.2となります。

データベス接続情報

spring:
  profiles: stg
  datasource:
    driverClassName: org.postgresql.Driver
    url: jdbc:postgresql://xxxx.xxxx.xxxx:5432/xxxx
    username: xxxx
    password: xxxx
    jmx-enabled: true

新たに spring.datasource.jmx-enabled: true という設定が必要になりました。 application.ymlへは、以下のように追加しました。

起動オプション

java -Dcom.sun.management.jmxremote \
  -Dcom.sun.management.jmxremote.port=[JMXポート番号] \
  -Dcom.sun.management.jmxremote.authenticate=false \
  -Dcom.sun.management.jmxremote.ssl=false \
  -Dcom.sun.management.jmxremote.rmi.port=[JMXポート番号] \
  -Djava.rmi.server.hostname=[ホストIPアドレス] \
  ....

アプリケーション起動時に、JMXで利用するポート番号等の設定を追加しました。 ホストのIPアドレスを指定しているのは、Dockerコンテナで起動する場合にポートフォワードでは動かないためです。 そのたりは以下に書いています。

eatsmart.hatenablog.com

動作確認

上記の設定を行い起動すると、ログに以下のようにMBeanの登録情報が出力されます。

2019-11-19 16:22:31.077  INFO 15636 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2019-11-19 16:22:31.079  INFO 15636 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Bean with name 'dataSourceMBean' has been autodetected for JMX exposure
2019-11-19 16:22:31.084  INFO 15636 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Located MBean 'dataSourceMBean': registering with JMX server as MBean [org.apache.tomcat.jdbc.pool.jmx:name=dataSourceMBean,type=ConnectionPool]

この情報をもとに接続します。接続には、javaから手軽に利用できる jmxterm を利用します。

docs.cyclopsgroup.org

java -jar jmxterm-1.0.0-uber.jar
$>open xxx.xxx.xxx.xxx:12345
#Connection to xxx.xxx.xxx.xxx:12345 is opened
$>info -b org.apache.tomcat.jdbc.pool.jmx:name=dataSourceMBean,type=ConnectionPool
#mbean = org.apache.tomcat.jdbc.pool.jmx:name=dataSourceMBean,type=ConnectionPool
#class name = org.apache.tomcat.jdbc.pool.jmx.ConnectionPool
# attributes
  %0   - AbandonWhenPercentageFull (int, rw)
  %1   - AccessToUnderlyingConnectionAllowed (boolean, rw)
  %2   - Active (int, r)
  %3   - AlternateUsernameAllowed (boolean, rw)
  %4   - BorrowedCount (long, r)
  %5   - CommitOnReturn (boolean, rw)
  %6   - ConnectionProperties (java.lang.String, rw)
  %7   - CreatedCount (long, r)
  %8   - DataSource (java.lang.Object, rw)
  %9   - DataSourceJNDI (java.lang.String, rw)
  %10  - DbProperties (java.util.Properties, rw)
  %11  - DefaultAutoCommit (java.lang.Boolean, rw)
  %12  - DefaultCatalog (java.lang.String, rw)
  %13  - DefaultReadOnly (java.lang.Boolean, rw)
  %14  - DefaultTransactionIsolation (int, rw)
  %15  - DriverClassName (java.lang.String, rw)
  %16  - FairQueue (boolean, rw)
  %17  - Idle (int, r)
  %18  - IgnoreExceptionOnPreLoad (boolean, rw)
  %19  - InitSQL (java.lang.String, rw)
  %20  - InitialSize (int, rw)
  %21  - JdbcInterceptors (java.lang.String, rw)
  %22  - JdbcInterceptorsAsArray ([Lorg.apache.tomcat.jdbc.pool.PoolProperties$InterceptorDefinition;, r)
  %23  - JmxEnabled (boolean, rw)
  %24  - LogAbandoned (boolean, rw)
  %25  - LogValidationErrors (boolean, rw)
  %26  - MaxActive (int, rw)
  %27  - MaxAge (long, rw)
  %28  - MaxIdle (int, rw)
  %29  - MaxWait (int, rw)
  %30  - MinEvictableIdleTimeMillis (int, rw)
  %31  - MinIdle (int, rw)
  %32  - Name (java.lang.String, rw)
  %33  - NumActive (int, r)
  %34  - NumIdle (int, r)
  %35  - NumTestsPerEvictionRun (int, rw)
  %36  - Password (java.lang.String, rw)
  %37  - PoolName (java.lang.String, r)
  %38  - PoolSweeperEnabled (boolean, r)
  %39  - PropagateInterruptState (boolean, rw)
  %40  - ReconnectedCount (long, r)
  %41  - ReleasedCount (long, r)
  %42  - ReleasedIdleCount (long, r)
  %43  - RemoveAbandoned (boolean, rw)
  %44  - RemoveAbandonedCount (long, r)
  %45  - RemoveAbandonedTimeout (int, rw)
  %46  - ReturnedCount (long, r)
  %47  - RollbackOnReturn (boolean, rw)
  %48  - Size (int, r)
  %49  - SuspectTimeout (int, rw)
  %50  - TestOnBorrow (boolean, rw)
  %51  - TestOnConnect (boolean, rw)
  %52  - TestOnReturn (boolean, rw)
  %53  - TestWhileIdle (boolean, rw)
  %54  - TimeBetweenEvictionRunsMillis (int, rw)
  %55  - Url (java.lang.String, rw)
  %56  - UseDisposableConnectionFacade (boolean, rw)
  %57  - UseEquals (boolean, rw)
  %58  - UseLock (boolean, rw)
  %59  - Username (java.lang.String, rw)
  %60  - ValidationInterval (long, rw)
  %61  - ValidationQuery (java.lang.String, rw)
  %62  - ValidationQueryTimeout (int, rw)
  %63  - Validator (org.apache.tomcat.jdbc.pool.Validator, rw)
  %64  - ValidatorClassName (java.lang.String, rw)
  %65  - WaitCount (int, r)
# operations
  %0   - void checkAbandoned()
  %1   - void checkIdle()
  %2   - java.lang.Boolean isDefaultAutoCommit()
  %3   - java.lang.Boolean isDefaultReadOnly()
  %4   - void purge()
  %5   - void purgeOnReturn()
  %6   - void resetStats()
  %7   - void testIdle()
# notifications
  %0   - javax.management.Notification(INIT FAILED,CONNECTION FAILED,CONNECTION ABANDONED,SLOW QUERY,FAILED QUERY,SUSPECT CONNECTION ABANDONED,POOL EMPTY,SUSPECT CONNECTION RETURNED)

この出力内容から、今回必要なものが NumActive と NumIdle ということがわかったので、Spring Bootアプリ用のテンプレートを作成し、以下の2つの項目を監視するようにしました。

jmx["org.apache.tomcat.jdbc.pool.jmx:name=dataSourceMBean,type=ConnectionPool",NumActive]
jmx["org.apache.tomcat.jdbc.pool.jmx:name=dataSourceMBean,type=ConnectionPool",NumActive]

まとめ

以上の作業で、イートスマートで利用しているコンテナのデータベース接続数を把握することが出来るようになりました。 実際の利用状況と照らし合わせ、コンテナの最小アイドル数が多かったので、減らすことで問題への対処を行うことが出来ました。

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は大きな変動はさせないと(勝手に)信じているのですが、今後、春先と夏終わりあたりにアップデートを実施する感じになるんでしょうか。