700VMを超えるRedis のカーネルアップデート

はじめに

はじめまして、KCS部の辻下(tjst-t)です。
KCS部は、KADOKAWAグループ向けプライベートクラウド(以下KCS)を提供しており、主な利用者は株式会社ドワンゴがサービスを提供している『niconico』です。

私の所属しているDSREチームでは、MySQLやRedis、Elasticsearch/KibanaといったDatastore関連のミドルウェアをマネージドサービスとして、KADOKAWAのグループ会社に提供しています。

今回はRedisのマネージドサービス(KCS Cache基盤 for Redis)について700以上のVMでカーネルバージョンアップを行った事例について紹介します。

KCS Cache基盤 for Redisとは

KCS Cache基盤 for Redis(以下 Cache基盤)はRedisのマネージドサービスであり、KCSで管理しているオンプレミスのVMware vSphere上に構築されています。 使用しているRedisのバージョンは3.2、OSはCentOS 7.3を使用しています。

Cache基盤の設計思想につきましてこちらの記事でも解説がありますが、リソースの集約度を上げるよりは運用負荷を下げることを目的として、1つのRedisノードあたり1つのVMで構成されています。
VM数が多いと運用負荷が上がってしまうのではないか?と思われるかもしれません。しかし、シンプルな構成にすることで、VMはすべてHashiCorpのConsulで容易に管理でき、すべての通常運用はAnsibleとJenkinsを組み合わせて自動化できています。
複雑さを取り除くことで自動化の敷居を下げ、運用負荷を削減する、という方針です。

Cache基盤の現在の規模は以下のとおりです。この全クラスタを他のサービスも掛け持ちしている4人で運用しています。

環境 VM数
本番 440
開発 295
合計 735

提供しているクラスタの構成ですが、可用性が低くても良いRedisは1Master 1Slave構成で提供し、高い可用性が必要なRedisは3Masterと3Slave以上のRedis Clusterで提供しています。

Cache基盤でのカーネルハング問題

Cache基盤では断続的にVMのカーネルがハングするという問題が発生していました。
Redis Clusterで構成しているクラスタが大半であるため、1VMがハングしても冗長性が失われるだけでクラスタの動作には影響はありません。しかし、ハングしたVMの復旧は運用者がマニュアルで行うため*1、VMのハングアップは運用者への負担となります。
1台あたりの発生頻度は低いとはいえCache基盤全体では週に一度程度は発生していました。Cache基盤は少ないメンバーで運用しているため、今後のクラスタが増加してもスケールするよう、VMハングの対応を減らすことが課題となりました。

ハングしたVMのカーネルダンプを調査したところ、Cache基盤で使用しているCentOS 7.3のkernel-3.10.0-514.el7.x86_64以前に存在するバグであり、CentOS7.4のkernel-3.10.0-693.1.1.el7以降にアップデートすれば問題は発生しないことがわかりました。
kernel-3.10.0-693.1.1.el7にあげてRedisの性能試験をおこなったところ、従来のカーネルと比較して性能劣化がないことが確認できたのでCache基盤の全VMのカーネルアップデートを行うことになりました*2

Cache基盤のカーネルアップデート

方針

Redis ClusterとMaster Slave構成のRedisは、それぞれ以下の方針でアップデートを実施しました。

Redis Cluster Master Slave構成のRedis
アップデートのタイミング サービス稼働中 サービス停止メンテナンス中
アップデート方法 クラスタとしては無停止でローリングアップデート Redisを停止してアップデート

Redis Clusterのローリングアップデート手順

カーネルアップデートはredis cluster tutorialを参照して、次の手順で行いました。クライアントへの影響を考慮して、Slaveが複数ある場合でも1VMずつアップデートを行っています。

  1. cluster-node-timeoutを15000に設定
  2. Master毎に以下を実行
    1. Master配下のSlaveをカーネルアップデート
    2. Slaveの一つをFailoverしてMasterにする (redis-cli CLUSTER FAILOVER)
    3. SlaveがMasterになるのを待つ (redis-cli ROLE | grep master)
    4. 10秒Wait
    5. MasterがSlaveになるのを待つ (redis-cli ROLE | grep slave)
    6. Masterをカーネルアップデートする
  3. Failoverを実施してクラスタのVMのRoleがアップデート開始前と同じになるようにする
  4. cluster-node-timeoutを元の値に設定する

cluster-node-timeoutを設定しているのは、デフォルト設定ではFailoverによるSlaveのMasterへの昇格が失敗するケースが有ったためです。詳細については後述します。

Master Slave構成のRedisのアップデート手順

Master Slave構成のRedisのアップデートはメンテナンス時間帯に行いRedisのダウンが許容できたため、特段の工夫はありません。Redis Clusterとの違いとしては、データの揮発を避けるために、systemctl stop redisをアップデート前に実行してストレージに書き出しています。

  1. Slaveをカーネルアップデート
  2. Masterをカーネルアップデート

カーネルアップデート

カーネルアップデートはyumで新しいカーネル関係のモジュールをインストール後、GRUBのデフォルト設定を変更することで行っています。Redisの動作に影響がある可能性を考慮して、カーネル関連以外のモジュールはアップデートしていません。
詳細はAppendixを参照下さい。

カーネルアップデートのスクリプト化

700以上のVM全てについて上記の手順をマニュアルで行うと、ヒューマンエラーが発生する可能性が高いため、スクリプトを作成してアップデートの自動化を行いました。

前述の通り、Cache基盤でのRedisへの主要なオペレーションはすべてAnsibleのPlaybook化されています。それらのPlaybookにはクラスタのFailover処理なども含まれています。今回のアップデートにあたっては、アップデートのスクリプト実装の工数を軽減するため、これらの実績あるPlaybookを使いました。また、Ansibleを使用することでRedisのパスワードなどの秘匿情報の処理をAnsibleに任せることが出来ました。
そのため、実際に作成したPythonスクリプトはConsulから管理情報を読み取り、クラスタに対してAnsibleのPlaybookを上記手順に従って順番に読む処理だけとなりました。

今回使用したPythonスクリプトの一部を抜粋しますが、以下のように実際の処理はAnsibleのPlaybookを起動しているだけです。当初はシェルスクリプトでの実装を検討したのですが、クラスタの管理情報をconsulから取得して加工する処理がシェルスクリプトでは難しかったため、Pythonスクリプトでの実装としました。

def modifyRedisConfig(serviceName, clusterName, configName,configValue):
    global inventory_file
    global vault_file
    group = "redis_{}_{}".format(serviceName, clusterName)
    command = 'ansible-playbook -i {} --vault-password-file {} ./playbooks/configure-redis.yml  -l {} -e mod_key={} -e mod_value={}'.format(inventory_file, vault_file, group, configName,configValue)
    os.system(command)

def updateCluster(cluster):
    if len(cluster) == 1:
        stopRedis = True
    else:
        stopRedis = False
    modifyRedisConfig(serviceName, clusterName, "cluster-node-timeout","15000")
    for masterIp, master in cluster.items():
        if len(master["slave"]) != 0:
            updateKernel(map(lambda x:x["hostName"], master["slave"]),(stopRedis or forceStopMaster))
            if not stopRedis:
                redisFailover(master["slave"][0]["hostName"])
        else:
            stopRedis = True
        printWithOffset("- Update Master")
        updateKernel([master["hostName"]], (stopRedis or forceStopMaster))
    redisFailback(serviceName, clusterName)
    modifyRedisConfig(serviceName, clusterName, "cluster-node-timeout","5000")

発生した問題

事前に開発環境を使用してテストを実施はしたのですが、それでも次のような問題が本番環境で発生してしまいました。これらの問題については対策ができ次第スクリプトにフィードバックしています。

SlaveのFailoverが完了しない

本番環境のいくつかのクラスタでFailoverが完了せず、Masterのアップデートが出来ないという問題が発生しました。Redisのログを確認すると、Manual failover timed out.が発生していました。
当初repl-timeoutの短さが理由なのかと推測して設定値を増やしたのですが、タイムアウトが発生する現象は変りませんでした。最終的にはGithubのIssueを参考にしてcluster-node-timeoutを15秒に伸ばしたところ、現象が消えたため、アップデートスクリプトに設定変更処理を追加しました。

SlaveをFailOverしてMasterに昇格後、クライアントが追従できずにエラー発生

当初SlaveのMaster昇格を確認後、すぐにSlave(降格したMaster)のカーネルアップデートを行っていました。SlaveのMaster昇格とMasterのSlaveへの降格のタイミングにずれがあったため、クライアントが追従できずにエラー発生が起きました。
対策として、MasterのSlave降格を確認後にカーネルアップデートを実行するよう修正しました。念の為10秒のwaitも入れています。

Master以下の全Slaveを同時にUpdateしたとき、クライアントからの接続がタイムアウトしてエラーが発生

f:id:kdx_writer:20200402113045p:plain

当初は時間短縮を優先して、ひとつのMasterに複数Slaveが存在する場合には、同一Master配下の全Slaveを同時にカーネルアップデートを実行していました(図1-1)。しかし、利用者による独自実装されたクライアントでは、Slave同時ダウンが想定されていなかったため、クライアントの接続がタイムアウトしてアプリケーション側のエラーが発生してしまいました。対策として、Slaveは一つずつアップデート行うように変更しました。

次同様のアップデートを行うときは、異なるMasterに属するSlaveのカーネルアップデートを同時に行うことで時間短縮をしようと考えています(図1-2)。

カーネルアップデートのyumを実行中にネットワーク切断が発生し、壊れたカーネルがインストールされた

Master Slave構成のRedisのVMは、サービス停止のメンテナンスに合わせてアップデートを行いました。しかし、メンテナンス中にネットワーク機器のメンテナンスも実施されたため、一時的なネットワーク断が発生しました。運悪くyumコマンドを実施中にネットワーク断が発生したVMでは、壊れたカーネルがインストールされ、アップデート後のリブートで起動に失敗する問題が発生しました。
アップデートに失敗したVMは、ネットワークのメンテナンスが終了したあとに壊れたカーネルをアンインストールして、再度インストールすることで復旧することが出来ました。

これにつきましては、ネットワークのメンテナンスが予告されていた以上、事前にカーネルのインストールまで済ませて、メンテナンス中はリブートだけ行えばいい状態にしておくべきでした。今後は同様のアップデートを行うときは改善予定です。

アップデート時に利用者がアップデートによる変化を検知して問い合わせが来る

こちらは技術的な話ではなく利用者とのコミュニケーションの話です。
あらかじめサービス稼働中にローリングアップデートを実施することは利用者全体に周知していたとはいえ、それぞれのクラスタについてアップデートを行う時間を細かく提示できなかったため、ローリングアップデート時に利用者から利用者による監視起因で問い合わせが来ることがありました。

弊グループでは社内コミュニケーションに主にSlackを使用しています。今後同様のアップデートを行うときはtmpチャンネルを作成し、随時アップデートの情報を利用者が見られるようにして、何かあったときはすぐ確認できるよう改善したいと考えています。

まとめ

Cache基盤のUpdateは数週間かけて実施しました。当初は上記のようにいくつか問題は起きたものの、対策が終わった後はクラスタを指定してスクリプトを流すだけなので、安心して実行することが出来ました。自動化のおかげで作業ミスもなく、システム停止やデータロストなどの致命的な問題も発生しませんでした。
また、カーネルアップデート後は、Cache基盤のVMがハングすることは完全になくなり、全クラスタが安定して稼働するようになりました。これからRedisのクラスタ数が大幅に増えたとしても、運用者の負荷は変わらない見込みです。

Appendix : カーネルアップデート手順

カーネルアップデートは以下のAnsibleスクリプトで行っています。このAnsibleスクリプトは410gone.clickさんのスクリプトを一部改変して使用させていただいています。

- name: install kernel
  yum:
    name: "{{ item }}-{{ kernel_version }}"
    state: installed
  with_items:
    - "kernel"
    - "kernel-devel"
    - "kernel-headers"
    - "kernel-tools"
    - "kernel-tools-libs"
  become: yes

- name: install kexec-tool
  yum:
    name: "kexec-tools-{{ kexec_tools_version }}"
  become: yes

- name: get new kernel name
  shell: awk -F\' '$1=="menuentry " {print $2}' /etc/grub2.cfg | head -1
  register: kernel_name
  changed_when: False
  become: yes

- name: set default kernel
  lineinfile:
    dest: /etc/default/grub
    regexp: "^GRUB_DEFAULT"
    line: 'GRUB_DEFAULT="{{ kernel_name.stdout }}"'
  become: yes
  register: result

- debug: 
    msg: "{{ result }}"

- name: rebuild gurb.cfg
  command: grub2-mkconfig -o /boot/grub2/grub.cfg
  become: yes
  when:  result.changed == true

- name: reboot server and wait
  reboot:
  become: yes
  when:  result.changed == true

DSREチームでは今後も、Cache基盤に限らず提供している全サービスで運用負荷を減らせるよう改善していく予定です。

*1:データが消えた元Masterが自動復旧でクラスタのデータを消してしまうのを防ぐため

*2:当初はCentOS 7.6の3.10.0-957.12.1.el7を使用予定でしたが、性能試験を行ったところ従来のカーネルに比べて3割近く性能が劣化することがわかりました。そのため、ハング問題が対策されていて性能劣化がないことが確認できたCentOS7.4の3.10.0-693.1.1.el7を採用しました。