コンテナ/クラウド環境におけるSerfを使った構成管理

クラウドやコンテナ技術の進歩により、近年では動的にサービスを構成するホストを増減させるような構成を容易に取れるようになっている。このような構成の際に問題となるのが、動的に増えたホストをどうやってサービスに組み込むか、という点だ。今回はこういった問題を解決するために有用なツール「Serf」を紹介する。

管理するサーバーが増えると管理の手間も増える

複数台のサーバーを組み合わせてサービスを運用することは昔から行われていたが、かつてはサーバーの導入は大仕事であり、運用中のサービスにおいてサーバーを増やしたり、逆に減らしたりする、といったことは頻繁には行われないものだった。しかし、近年ではクラウドやVPSなど、低コストで迅速にサーバーの追加/削除を行えるサービスが増えており、それに伴ってサービスの運用途中に動的に構成サーバーの数を変動させることも珍しくなくなっている。

このように構成サーバーが頻繁に変更される環境では、サーバーの管理や設定変更が大きな手間となる。たとえばIPアドレスの管理1つを取っても、クラウドやVPSでは異なるサブネットのIPアドレスがサーバー毎にランダムに割り振られるケースが多く、管理者側でそれらをきっちりと把握しなければならない。そのため、実運用環境ではZabbixなどの管理・監視ツールを利用して管理するケースが多かった。

ただ、用途によっては管理・監視ツールを使うのは大がかりすぎる、という場合もある。たとえば単純にクラスタ内に存在するホストの一覧を取得したい、もしくはホストの追加/削除を検知したい、といった場合、多くの管理・監視ツールが備えているリソースやサービスの監視機能は不要だ。また、こういった管理・監視ツールの多くは中央集権型の構造を取っており、利用するには監視用のサーバーを用意する手間が必要だ。

今回紹介する「Serf」は、特にサービスやリソースの監視は不要だがクラスタ内のホストのディスカバリは行いたい、もしくはクラスタにホストが追加されたり、削除されたことを検出してなにか処理を行いたい、といった目的に適した分散型のサーバー管理・監視ツールだ(図1)。

図1 SerfのWebサイト
図1 SerfのWebサイト

シンプルで導入コストが低い「Serf」

Serfは分散型のアーキテクチャを持ち、中央サーバーが不要なのが最大の特徴である。バイナリファイルを1つインストールするだけで利用でき、導入の手間も少ない。

Serfが提供する機能を簡単にまとめると、以下のようになる。

  • クラスタに参加しているサーバーのIPアドレスやホスト名、タグといった情報の一覧表示
  • サーバーの死活管理
  • クラスタへのサーバーの追加や削除といったイベントに応じた処理の実行とその結果の収集

なお、Serfでチェックできるのはあくまでそのサーバーが稼働しているかどうか(厳密には後述するエージェントが稼動しているかどうか)のみで、そのサーバー上で稼動している個別サービスの死活監視機能やリソースの管理機能は用意されていない。こういった需要には、ほかのツールを組み合わせて対応することになる。

Serfのユースケース

Serfでのクラスタ管理は、クラスタに参加しているメンバの状態が変わった際に何らかのアクションを実行する、というのが基本となる。

たとえばこれを使った活用例としては、ロードバランサを使った負荷分散・冗長化を行っている環境で、ホストの追加を自動的に検知して設定を変更しロードバランス対象に追加する、といったものが挙げられる。また、DockerやOpenStackなどを使ったクラスタで、新たに追加されたサーバーをクラスタノードに自動で追加する、といった使い方も可能だ。

また、イベント処理機能を利用し、任意のタイミングで各サーバーで一斉に指定した処理を実行させることもできる。これを利用して、たとえば一斉にソフトウェアの更新や設定の変更を実行する、といったことが可能だ。

Serfのインストールとクラスタの構成

Serfの特徴の1つに、インストールが容易である点がある。管理・監視ツールによっては別途データベースなどを用意する必要があるものがあるが、Serfは特に依存するライブラリやソフトウェアがなく、バイナリファイルを1つインストールするだけで動作する。

Serfのバイナリファイルは公式サイトのダウンロードページからダウンロードが可能だ。Linuxだけでなく、Mac OS XやFreeBSD、OpenBSD、Windows向けのバイナリも用意されている(図2)。

図2 Serfのダウンロードページ
図2 Serfのダウンロードページ

 公開されているバイナリはZIP形式で圧縮されているので、これを展開して/usr/loca/binなどのディレクトリにコピーすればインストールは完了だ。また、ソースコードはGitHubで公開されている。

なお、SerfではTCPおよびUDPの7946番ポートを使用するので、このポートに対し外部からアクセスできるようファイアウォールを設定しておく必要がある。また、RPCを使って外部のSerfに対してAPIコールを実行する場合は7373番ポートが利用される。こちらに関しては必要に応じて設定すれば良い。

Serfでクラスタを構成する

Serfでは、「エージェント」という機能を使ってクラスタを構成する。前述のように分散型のアーキテクチャを採用しており、そのため管理用のノードは存在しない。各ノードで実行されるエージェント同士が直接情報をやり取りすることで、各ノードの状況を監視する仕組みになっている(図3)。

図3 Serfの「エージェント」
図3 Serfの「エージェント」

 さて、クラスタを構築する場合には、すでに起動しているエージェントに対し、新たにクラスタに加入したいノードの情報を通知する必要がある。Serfでは「mDNS」という仕組みを使用し、自動的にネットワーク内で起動しているエージェントを検出してクラスタ加入申請を送信する仕組みがある。これにより、クラスタメンバのIPアドレスといった情報を知らずともクラスタに加入できるようになっている。

この仕組みを使用してエージェントを起動するには、以下のように「-discover」オプションでクラスタ名を指定して「serf agent」コマンドを実行する。クラスタ名は任意のものを使用できる。

$ serf agent -iface=<使用するネットワークインターフェイス> -discover=<クラスタ名>

たとえば、「eth1」というネットワークインターフェイスを使用し、「cluster01」というクラスタ名を持つクラスタに加入する場合、以下のようになる。

$ serf agent -iface=eth1 -discover=cluster01

ただし、mDNSはマルチキャストパケットを使用するため、環境によっては利用できない場合がある。たとえばさくらのクラウドではスイッチ/ルーターによってmDNSで使われるマルチキャストパケットが遮断されるため、この方法は利用できない。

mDNSが利用できない場合、クラスタに参加する2台目以降のノードでは「-join=<IPアドレス>」オプションでクラスタ内で稼動しているいずれかのノードのIPアドレスを指定してエージェントを起動する必要がある(図4)。ここで指定するIPアドレスは、クラスタに参加しているノードのものであればどれでも構わない。

図4 mDNSによる自動認識が利用できない場合、クラスタ内のどれか1つのノードを指定してエージェントを起動する
図4 mDNSによる自動認識が利用できない場合、クラスタ内のどれか1つのノードを指定してエージェントを起動する

 こういった環境でクラスタを構築する場合、まず1台目のノードではクラスタ内にほかのノードが存在しないため、「-join」オプションなしでエージェントを起動する。

$ serf agent -iface=eth1
==> Using interface 'eth1' address '192.168.1.10'
==> Starting Serf agent...
==> Starting Serf agent RPC...
==> Serf agent running!
         Node name: 'centos10'
         Bind addr: '192.168.1.10:7946'
          RPC addr: '127.0.0.1:7373'
         Encrypted: false
          Snapshot: false
           Profile: lan

==> Log data will now stream in as it occurs:

    2016/07/14 20:30:54 [INFO] agent: Serf agent starting
    2016/07/14 20:30:54 [INFO] serf: EventMemberJoin: centos10 192.168.1.10
    2016/07/14 20:30:55 [INFO] agent: Received event: member-join

続いて、クラスタに追加したい別のノードで「-join」オプション付きでエージェントを起動する。先のノードでは「192.168.1.10」というIPアドレスが使われているので、こちらを-joinオプションの引数として指定する。

$ serf agent -iface=eth1 -join=192.168.1.10
==> Using interface 'eth1' address '192.168.1.11'
==> Starting Serf agent...
==> Starting Serf agent RPC...
==> Serf agent running!
         Node name: 'centos11'
         Bind addr: '192.168.1.11:7946'
          RPC addr: '127.0.0.1:7373'
         Encrypted: false
          Snapshot: false
           Profile: lan
==> Joining cluster...(replay: false)
    Join completed. Synced with 1 initial agents

==> Log data will now stream in as it occurs:

    2016/07/14 20:31:08 [INFO] agent: Serf agent starting
    2016/07/14 20:31:08 [INFO] serf: EventMemberJoin: centos11 192.168.1.11
    2016/07/14 20:31:08 [INFO] agent: joining: [192.168.1.10] replay: false
    2016/07/14 20:31:08 [INFO] serf: EventMemberJoin: centos10 192.168.1.10
    2016/07/14 20:31:08 [INFO] agent: joined: 1 nodes
    2016/07/14 20:31:09 [INFO] agent: Received event: member-join

このようにしてクラスタにノードを追加すると、1台目のノードの出力にも以下のような表示が出力され、追加したノードが認識されていることが分かる。

    2016/07/14 20:31:08 [INFO] serf: EventMemberJoin: centos11 192.168.1.11
    2016/07/14 20:31:09 [INFO] agent: Received event: member-join

なお、エージェントはフォアグラウンドで実行され、Ctrl-Cを入力することで終了できる。エージェントを終了すると、自動的にクラスタからそのノードは削除される。

Serfの起動スクリプトを用意する

エージェントはdaemonとして起動されるわけではないため、バックグラウンドで実行させるには工夫が必要だ。たとえばCentOS 7やUbuntu 16.04 LTSなどでは、/etc/systemd/system/serf.serviceとして次のような設定ファイルを用意すれば良い。

[Unit]
Description=Serf cluster agent
Documentation=https://www.serfdom.io/
After=network.target

[Service]
EnvironmentFile=-/etc/sysconfig/serf ←CentOSの場合、/etc/sysconfig/ディレクトリ以下に環境変数設定ファイルを配置する
ExecStart=/usr/local/bin/serf agent $SERF_AGENT_OPTS
KillSignal=SIGINT

[Install]
WantedBy=multi-user.target

この設定ファイルではserfコマンドに与えるオプションを「/etc/sysconfig/serf」という設定ファイルに記述するようにしている。このファイルの内容は以下のとおりだ。

SERF_AGENT_OPTS="<ここに「serf agent」コマンドに与えるコマンドラインオプションを記述する>"

なおUbuntuの場合、/etc/sysconfig/ディレクトリではなく/etc/default/ディレクトリ以下に設定ファイルを配置するのが慣例となっているので、/etc/systemd/system/serf.serviceファイルの「EnvironmentFile=」行を以下のように変更するとともに、「/etc/default/serf」というファイルでオプション設定用のファイルを置けば良い。

EnvironmentFile=-/etc/default/serf

また、設定ファイルを変更したら、その都度systemdのリロードが必要になるので注意しよう。

# systemctl daemon-reload

これで、systemctlコマンド経由でSerfのエージェントの起動や停止といった管理が行えるようになる。たとえばエージェントを起動するには、以下のように実行する。

# systemctl start serf

また、serfのログは以下のようにして確認できる。

# journalctl -u serf

Dockerコンテナ内でSerfエージェントを実行する

Dockerコンテナ内でSerfエージェントを実行する場合、あらかじめコンテナイメージを生成するためのDockerfile内でSerfのバイナリファイルをコンテナ内にコピーしておき、かつコンテナの起動時に自動的に「serf agent」コマンドが実行されるようにしておく必要がある。これは、Dockerfileの「ENTRYPOINT」コマンドでコンテナ実行時にあらかじめ指定しておいたシェルスクリプトを実行するよう指定しておき、そのシェルスクリプト内で「serf agent」コマンドを実行することで実現できる。

たとえばbusyboxイメージをベースとする場合、Dockerfileは次のようになる。これ以外のイメージをベースとする場合も、下記はほぼそのまま利用できるだろう。なお、この例ではSerfのバイナリ(「serf」ファイル)や「entry-point.sh」ファイルはDockerfileと同じディレクトリ内に作成した「files」ディレクトリ内に配置している。

FROM busybox

# ↓serfのバイナリを/usr/local/bin/以下にコピーする
COPY files/serf /usr/local/bin/serf
RUN chmod 755 /usr/local/bin/serf

# ↓エージェントを起動するためのシェルスクリプト「entry-point.sh」をコピーする
COPY files/entry-point.sh /entry-point.sh
RUN chmod 755 /entry-point.sh

# ↓コンテナの起動時にentry-point.shスクリプトが実行されるようにする
ENTRYPOINT ["/entry-point.sh"]
CMD ["sh"]

また、コンテナの起動時に実行させるentry-point.shは以下のようになる。

#!/bin/sh
# ↓パイプ中でエラーが発生した場合処理途中で終了するよう設定する
set -eo pipefail

# ↓「serf agent」コマンドに与えるオプションを指定する
SERF_AGENT_OPT="-discover=container01"

# ↓「serf agent」コマンドを実行し、その出力は/var/log/serf.logファイルに格納させておく
mkdir -p /var/log
/usr/local/bin/serf agent $SERF_AGENT_OPT > /var/log/serf.log &
SERF_PID=$!

# ↓「docker run」コマンドの引数で与えられたコマンドを実行する
"$@"

# ↓SerfエージェントにSIGINTシグナルを送信して終了させる
test -n "$SERF_PID" && kill -s SIGINT $SERF_PID && sleep 3

このシェルスクリプト内では「docker run」コマンドに引数として与えたコマンドの実行完了後、killコマンドでエージェントのプロセスを終了させている。このとき、単純にkillコマンドでエージェントを終了させてしまうと、ほかのノードからはエージェントが異常終了したもの(後述する「failed」状態)と認識されてしまうので注意したい。ここではkillコマンドに「-s SIGINT」オプションを付け、コンソールからCtrl-Cを入力した際に送信されるのと同じSIGINTシグナルを送信することで、エージェントが正常に終了したように認識させている。

なお、Dockerのコンテナ同士を接続する仮想ネットワークは、通常はmDNSで使われるマルチキャストパケットをそのまま通過させることができ、「-discover」オプションによるクラスタの自動追加機能を利用できる。

Serfによるクラスタの管理

Serfでは、「serf members」コマンドでクラスタに参加しているノードの情報を確認できる。たとえば以下の例では、「centos11」というノード(192.168.1.11)と、「centos10」というノード「192.168.1.10」がクラスタに参加していることが分かる。

$ serf members
centos11  192.168.1.11:7946  alive
centos10  192.168.1.10:7946  alive

なお、ここで表示される最後のカラムはそのノードの状態を示しており、「alive」はそのノードが正常稼動していることを意味している。また、クラスタに参加した後にSerfエージェントが正常終了されたノードについては「left」、何らかの問題でSerfエージェントとの通信ができなくなっているノードについては「failed」と表示される。

クラスタ内で何らかのイベントが発生した場合に実行されるイベントハンドラ

Serfでは、クラスタへのノードの加入や脱退、停止、状態の更新といったイベントの発生時に、あらかじめ指定しておいたコマンドを実行する機能が用意されている。実行するコマンドは、「node agent」コマンドの「-event-handler=<コマンド>」オプションで指定する。これはエージェントの起動時に指定しておく必要がある点に注意が必要だ。

たとえばイベントの実行時に「/etc/serf/handler.sh」というスクリプトを実行するには、エージェントの起動時に以下のようなオプションを追加すれば良い。

-event-handler=/etc/serf/handler.sh

コマンドの実行時には、「SERF_EVENT」という環境変数に発生したイベント名が格納されるので、スクリプト内ではこれをチェックして対応する処理を実行させることになる。イベント名としては表1のものが定義されている。

表1 Serfで定義されているイベント名
イベント名 説明
member-join クラスタに新たなメンバーが追加された
member-leave クラスタからメンバーが脱退した
member-failed クラスタのメンバーが反応しない
member-update クラスタメンバーの状態が更新された
member-reap クラスタのメンバーが反応せず、かつその後一定のタイムアウト時間が経過した
user ユーザー定義のイベント
query ユーザー定義のクエリ

これを利用して、たとえばクラスタにメンバーが追加された場合に特定の処理を実行する、といったことが実現できる。

ユーザー定義イベント/クエリを使う

「ユーザー定義のイベント」は、ユーザーが任意のタイミングで発生させられるイベントだ。イベントを発生させるには、「serf event <イベント名> <ペイロード>」コマンドを使用する。この場合、実行されたコマンド(イベントハンドラ)では「SERF_USER_EVENT」環境変数で指定されたイベント名を参照でき、またペイロードとして指定されたデータは標準入力から取得できる。

また、ユーザー定義のイベントと似たものとして「ユーザー定義のクエリ」がある。こちらは「serf query <クエリ名> <ペイロード>」で発生させることができる。ユーザー定義のイベントと異なるのは、各ノード上での実行結果(標準出力への出力)が「serf query」コマンドの実行側で表示される点だ。

そのほか、クエリやイベントの実行時には環境変数経由で次のような情報を取得できる。

表2 Serfのイベントハンドラで利用できる環境変数
イベント名 説明
SERF_EVENT イベント種別
SERF_SELF_NAME イベントハンドラを実行するノードの名前
SERF_SELF_ROLE イベントハンドラを実行するノードのロール
SERF_TAG_${TAG} イベントハンドラを実行するノードに指定されたタグ
SERF_USER_EVENT ユーザーイベント名
SERF_USER_LTIME イベント発生時のタイムスタンプ
SERF_QUERY_NAME ユーザークエリ名
SERF_QUERY_LTIME クエリ発生時のタイムスタンプ

たとえばイベントハンドラとして「/etc/serf/handler.sh」というシェルスクリプトを指定しておき、ここに次のような内容を記述しておけば、「yum_update」というクエリを実行することで各ノードで一斉に「yum update -y」コマンドを実行することができる。

#!/bin/sh

# ↓クエリイベントによってイベントハンドラが実行されたかどうかをチェック
if [ "$SERF_EVENT" == "query" ]; then
        # ↓クエリ名をチェック
        if [ "$SERF_QUERY_NAME" == "yum_update" ]; then
                # ↓条件に合致していればコマンドを実行
                yum update -y
        fi
fi

実際に「serf query」コマンドでこのクエリを実行した場合の出力結果は以下のようになる。

# serf query yum_update
Query 'yum_update' dispatched
Ack from 'centos10'
Ack from 'centos11'
Response from 'centos10': Loaded plugins: fastestmirror, priorities
Loading mirror speeds from cached hostfile
No packages marked for update
Response from 'centos11': Loaded plugins: fastestmirror, priorities
Loading mirror speeds from cached hostfile
No packages marked for update
Total Acks: 2
Total Responses: 2

ここでは、「Response from 'centos10'」以下で「centos10」ホストでの実行結果が、「Response from 'centos11':」以下で「centos11」ホストでの実行結果が表示されている。

シンプルなツールだからこそ使い方に工夫が必要

Serfの備える機能はシンプルであるため、使う側で色々と工夫する必要があるが、その代わりさまざまな機能を実現できる。たとえばイベントハンドラ内でユーザーイベントに応じてサーバーの設定をリロード/変更/再起動するような記述を行うことで、クラスタ内のサーバーの設定をまとめて変更する、といった処理を実現できる。

また、バイナリファイル1つを配置するだけで利用できるため、コンテナとの相性も良い。特に有用なのが、SerfとDockerクラスタの組み合わせだ。コンテナ内にSerfをインストールしてエージェントを起動するよう設定しておくことで、コンテナ同士の情報を簡単に取得できるようになる。

Serfではそれ自体が持つ機能は少ないが、アイデア次第でさまざまな活用が可能だ。コンテナの監視や自動設定の実現に向けて、ぜひ試してみてはいかがだろうか。