OCF準拠のリソースエージェントの作り方

# 自作アプリをPacemakerでクラスタ管理したい

[OCFリソースエージェント開発者ガイド](https://linux-ha.osdn.jp/wp/archives/4328)を読めば簡単に作ることができますが、文章量が多いので、「とりあえず動くものが作りたいぜ」って人向け。

## OCFの基本
OCFはOpenClusterFrameworkの略称で、アプリケーションをクラスタ化する際のインターフェースを定義しています。
クラスタ管理を行うPacemakerなどのクラスタマネージャは、管理対象のアプリケーションや仮想IPアドレスなどを「リソース」として管理します。
Pacemakerはリソースに対して起動・停止・マイグレーション・masterへの昇格・slaveへの降格などを命令します。
Pacemakerなどのクラスタ管理ソフトウェアとリソース間のインターフェースであOCF準拠のプログラムを組むことで、自作アプリをPacemakerを使ってクラスタリングすることができます。
このリソースエージェントを自作していくのが今回の目標です。

具体的には、OCFに準拠したクラスタ管理ソフトウェアは環境変数`$__OCF_ACTION`に実行するアクションを入れてリソースエージェントをキックします。
アクションには起動・停止・マイグレーション・masterへの昇格・slaveへの降格などがあり、定義が必須のアクションと必須ではない（オプション）アクションがあります。
リソースの制御の際には、実行ファイルがキックされます。この実行ファイルをリソースエージェントといい、リソースの動作を実際に管理します。
リソースエージェントは環境変数`$__OCF_ACTION`を見て、実際にそのアクションを実行するように動作します。
各アクションにおいてパラメータが必要な場合は、`$OCF_RESKEY`をプレフィックスとする環境変数で渡されます。
実行ファイル形式はOCFのAPI要件さえ満たしていれば、言語などに制限はありませんが、一般的にはシェルスクリプトで実装されるようです。

## 一番簡単なリソースエージェント
### OCF準拠の一番簡単なコード

``` shell:sample-resource
#!/bin/sh

#Initialize
: ${OCF_FUNCTIONS_DIR=${OCF_ROOT}/resource.d/heartbeat}
. ${OCF_FUNCTIONS_DIR}/.ocf-shellfuncs

RUNNING_FILE=/tmp/.running

sample_meta_data() {
    cat << EOF
<?xml version="1.0"?>
<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
<resource-agent name="sample-resource" version="0.1">
    <version>0.1</version>
    <longdesc lang="en">sample resource</longdesc>
    <shortdesc lang="en">sample resource</shortdesc>
    <parameters>
    </parameters>
    <actions>
        <action name="meta-data" timeout="5" />
        <action name="start" timeout="5" />
        <action name="stop" timeout="5" />
        <action name="monitor" timeout="5" />
        <action name="validate-all" timeout="5" />
    </actions>
</resource-agent>
EOF
    return $OCF_SUCCESS
}

sample_validate(){
    return $OCF_SUCCESS
}

sample_start(){
    touch ${RUNNING_FILE}
    return $OCF_SUCCESS
}

sample_stop(){
    rm -f ${RUNNING_FILE}
    return $OCF_SUCCESS
}

sample_monitor(){
    if [ -f ${RUNNING_FILE} ];
    then
        return $OCF_SUCCESS
    fi
    return $OCF_NOT_RUNNING
}

sample_usage(){
    echo "Test Resource."
    return $OCF_SUCCESS
}

# Translate each action into the appropriate function call
case $__OCF_ACTION in
meta-data)      sample_meta_data
                exit $OCF_SUCCESS
                ;;
start)          sample_start;;
stop)           sample_stop;;
monitor)        sample_monitor;;
validate-all)   sample_validate;;
*)              sample_usage
                exit $OCF_ERR_UNIMPLEMENTED
                ;;
esac

```



### 初期化
[OCFリソースエージェント開発者ガイド](https://linux-ha.osdn.jp/wp/archives/4328)にも記載されているおまじないです。

```
#Initialize
: ${OCF_FUNCTIONS_DIR=${OCF_ROOT}/resource.d/heartbeat}
. ${OCF_FUNCTIONS_DIR}/.ocf-shellfuncs
```

### 必須アクションを実装する
以下は実装が必須になっているアクションです。
これ以外はオプション扱いなので、ひとまず以下のアクションを定義してみます。

- meta-data
- start
- stop
- monitor
- validate-all

各アクションの実行時に、適切な返り値を返す必要があります。
返り値はOCFで[定義](https://linux-ha.osdn.jp/wp/archives/4328#3)されています。


#### リソースエージェント内で使用する環境変数
各必須アクションの具体的な定義に入る前に使用する環境変数を説明します。

##### $__OCF_ACTION変数
リソースエージェントに要求する処理が格納されており、リソースエージェントの呼び出し時にセットされます。
リソースエージェントはまずはじめにこの環境変数を読んで、それぞれのアクションをディスパッチします。

実装例
[OCFリソースエージェント開発者ガイド](https://linux-ha.osdn.jp/wp/archives/4328)を丸パクリ

``` shell
case $__OCF_ACTION in
meta-data)      sample_meta_data
                exit $OCF_SUCCESS
                ;;
start)          sample_start;;
stop)           sample_stop;;
monitor)        sample_monitor;;
validate-all)   sample_validate;;
*)              sample_usage
                exit $OCF_ERR_UNIMPLEMENTED
                ;;
esac
```

##### $OCF_RESKEY_パラメータ名
リソース作成時に設定されるパラメータが入る変数です。
この例では使っていませんが、後述のTCPサーバのデーモンをクラスタ化する際に使用しています。

#### meta-data
メタデータは、そのリソースエージェントの名前や提供するアクションや、受取可能なパラメータといった、そのリソースエージェントの基本的な情報を提供します。
XMLで記述されており、リソースエージェントはメタデータを要求されると標準出力でメタデータを返します。

#### start
リソースの起動処理を実装します。具体的にはデーモンの起動スクリプトなどを記述します。
今回は実際のデーモンは起動せずに、とあるファイルを作成するだけの処理にします。
起動に成功した場合は`$OCF_SUCCESS`を返します。

#### stop
リソースの終了を実装します。デーモンの停止などを記述します。
今回は、startで作成したファイルを削除する処理をします。

停止処理に問題がなければ`$OCF_SUCCESS`を返します。（`$OCF_NOT_RUNNING`ではないので注意）

注意点としては、stopアクションはリソースの「強制停止」を意味します。
そのリソースが安全な停止が不可能な場合でも、とにかく停止を行うアクションです。
stopアクションに失敗した場合、致命的な問題が発生する可能性があるため、クラスタマネージャはノードのフェンシング（強制シャットダウンなどによる隔離）を行う場合があります。
stopアクションはリソースの停止にありとあらゆる手を使って、それでも停止ができなかった場合のみエラーコードを返すべきです。

#### monitor
リソースの状態を取得する処理を実装します。
起動していれば　`$OCF_SUCCESS` を返し、起動していなければ `$OCF_NOT_RUNNING` を返します。
なにかエラーがあればエラー内容によって、`$OCF_ERR_`で始まるエラーの定数のうち適切なものを返します。

#### validate-all
リソースの設定を検証します。
パラメータが正しく設定されているか、リソースで使用するファイルの権限が適切かどうかなどを確認します。
返り値は以下のいずれかでなければなりません。

|返り値|意味|
|:--|:--|
|$OCF_SUCCESS |問題なし|
|$OCF_ERR_CONFIGURED|設定に問題有り|
|$OCF_ERR_INSTALLED|必要なコンポーネントが存在していない（起動対象のデーモンがインストールされていないなど）|
|$OCF_ERR_PERM |リソース管理に必要なファイルのアクセス権限に問題あり|

（今回は簡単のため、常に`$OCF_SUCCESS`を返します。）



### テストしてみる
ocf-testerでテストができます。

```
#ocf-tester -n [リソース名] [リソースエージェンのパス]

salacia@ha1:~/ocf-scr$ sudo ocf-tester -n sample-resource ./sample-resource
Beginning tests for ./sample-resource...
* Your agent does not support the notify action (optional)
* Your agent does not support the demote action (optional)
* Your agent does not support the promote action (optional)
* Your agent does not support master/slave (optional)
* Your agent does not support the reload action (optional)
./sample-resource passed all tests
```

### 実際にPacemakerで起動してみる
#### リソースエージェントを配置する
Pacemakerのリソースエージェントの配置場所は`/usr/lib/ocf/resource.d/`配下です。
ここに提供者名でディレクトリを作成し、その中に作成したリソースエージェントを配置します。

ぼくのハンドルネームはかまぼこなので、提供者名はkamabokoとして、上記のsample-resourceを配置します。
（クラスタの全ノードにリソースエージェントを配置する必要があります）

```
salacia@ha1:~/ocf-scr$ ls -al /usr/lib/ocf/resource.d/kamaboko/
total 16
drwxrwxr-x 2 root root 4096 Aug 11 14:07 .
drwxr-xr-x 6 root root 4096 Jun 21 03:36 ..
-rwxr-xr-x 1 root root 1547 Aug 11 14:07 sample-resource
-rwxrwxr-x 1 root root 2103 Jun 21 03:36 sample-tcp-server
```

これだけでPacemakerからリソースエージェントが利用可能になるので、pcsコマンドからリソースを作成してみます。

```
salacia@ha1:~/ocf-scr$ sudo pcs resource create SAMPLE ocf:kamaboko:sample-resource

salacia@ha1:~$ sudo pcs status
Cluster name: c1
Stack: corosync
Current DC: ha2 (version 1.1.18-2b07d5c5a9) - partition with quorum
Last updated: Tue Aug 11 14:15:47 2020
Last change: Tue Aug 11 14:15:45 2020 by root via cibadmin on ha1

2 nodes configured
1 resource configured

Online: [ ha1 ha2 ]

Full list of resources:

 SAMPLE (ocf::kamaboko:sample-resource):        Started ha1

Daemon Status:
  corosync: active/enabled
  pacemaker: active/enabled
  pcsd: active/enabled

```

自分で作成したリソースエージェントをPacemakerで動作させることができました。

## もう少し実用的な例
サンプルコードはこちらです。
[OCF Resource Agent Samle](https://github.com/kamaboko123/OCF_resource_agent_sample)

とりあえず.debパッケージのみ対応しているので、DebianやUbuntu等で動作できると思います。
(開発環境はUbuntu18.04)
Pacemakerは導入済みの前提です。

### クラスタ化するアプリ

適当なデーモンを作ればいいので、TCPで接続すると挨拶するだけのアプリを作りました。
（ただのサンプルなのでコードそのものには言及しないで...）
アプリを起動してtelnet等で接続すると、環境変数に設定した挨拶文と、自身のノード名を返します。

``` daemon/tcp-server.py.py
#!/usr/bin/python3
import os
import socket
import threading
import time
import signal
import sys

PORT = 5678
PID_FILE_DIR = "/var/run/sample-tcp-server"
PID_FILE_NAME = "tcp-server.pid"
PID_FILE = "%s/%s" % (PID_FILE_DIR, PID_FILE_NAME)
EXIT = False
GREET = os.environ.get("GREET", "Hello!")

def signal_handler(signum, stack):
    EXIT = True

def server():
    os.makedirs(PID_FILE_DIR, exist_ok=True)
    if os.path.isfile(PID_FILE):
        raise Exception("Already running")
    
    with open(PID_FILE, "w") as f:
        f.write(str(os.getpid()))
    
    print("Create Socket")
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', PORT))
    s.listen(5)

    try:
        while True:
            if EXIT:
                raise Exception("Stop daemon due to receive signal")
            
            (con, addr) = s.accept()
            t = threading.Thread(
                target=handler,
                args=(con, addr),
                daemon=False
            )
            t.start()
    except Exception as e:
        sys.stderr.write("%s\n" % e)
    finally:
        print("Close Socket")
        s.close()
        os.remove(PID_FILE)
        return

def handler(con, addr):
    con.send(("%s This is %s!\n" % (GREET, socket.gethostname())).encode())
    con.close()

if __name__ == '__main__':
    signal.signal(signal.SIGINT, handler)
    signal.signal(signal.SIGTERM, handler)
    server()
    
```

標準モジュールだけで作ったので、ライブラリのインストール等は必要ありません。
プロセスの起動確認用としてPIDファイルを生成するようにしています。
一応アプリ終了時にPIDファイルを削除するので、PIDファイルの存在でアプリの起動確認ができますが、SIGKILLなどで落とされると削除されないのであまりよくないと思います。（今回はただのテストなので簡単のためにこうしています）

起動

```
GREET=Hii! python3 tcp-server.py
```

telnetで接続してみる

```
salacia@ha1:~$ telnet localhost 5678
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hi!, This is ha1!
Connection closed by foreign host.
```

サンプルコードでは、このアプリをsystemdで起動・停止ができるようにするためにserviceファイルを含めています。
今回はこのアプリを高可用性クラスタで動作させて、冗長化するようにしてみます。


### インストール

ダウンロードしてmakeでパッケージが作成できるので、あとはdpkgからインストールするだけです。
（クラスタの全ノードでインストールする必要があります）

```
git clone https://github.com/kamaboko123/OCF_resource_agent_sample.git
cd OCF_resource_agent_sample
make
sudo dpkg -i dist/sampletcpserver_1.0_amd64.deb
```

### 動作確認
ひとまずVIP(VRRP)を2つのノード間で設定し、故障時にフェイルオーバーさせます。

```
#VIPをサービス登録
sudo pcs resource create VIP ocf:heartbeat:IPaddr2 ip=172.16.0.50 cidr_netmask=24 op monitor interval=10s on-fail="standby"

#サンプルアプリをサービス登録
sudo pcs resource create TCP-SERVER ocf:kamaboko:sample-tcp-server greet=Hi!

#VIPとサンプルアプリのACTIVEノードが同じになるように制約を設定する
sudo pcs constraint colocation add TCP-SERVER with VIP INFINITY
```

外部のノードからtelnetで仮想IPアドレス宛に接続します。

```
salacia@Vega:~$ telnet 172.16.0.50 5678
Trying 172.16.0.50...
Connected to 172.16.0.50.
Escape character is '^]'.
Hi!, This is ha1!
Connection closed by foreign host.
```

接続しているノードををshutdownして、フェイルオーバーさせ、サービスが継続して提供できていることを確認します。

```
#現在VIPとTCP-SERVERリソースが起動しているノード(ha1)を落とす
salacia@ha1:~$ sudo pcs status
[sudo] password for salacia:
Cluster name: c1
Stack: corosync
Current DC: ha2 (version 1.1.18-2b07d5c5a9) - partition with quorum
Last updated: Tue Aug 11 14:31:30 2020
Last change: Tue Aug 11 14:17:26 2020 by root via cibadmin on ha1

2 nodes configured
2 resources configured

Online: [ ha1 ha2 ]

Full list of resources:

 VIP    (ocf::heartbeat:IPaddr2):       Started ha1
 TCP-SERVER     (ocf::kamaboko:sample-tcp-server):      Started ha1

Daemon Status:
  corosync: active/enabled
  pacemaker: active/enabled
  pcsd: active/enabled

salacia@ha1:~$ sudo shutdown -h now
Connection to 172.16.0.51 closed by remote host.
Connection to 172.16.0.51 closed.



#外部ノードからVIP宛でサービスの提供が継続しているか確認する
salacia@Vega:~$ telnet 172.16.0.50 5678
Trying 172.16.0.50...
Connected to 172.16.0.50.
Escape character is '^]'.
Hi!, This is ha2!
Connection closed by foreign host.

#フェイルオーバー先のノード(ha2)で状態確認
salacia@ha2:~$ sudo pcs status
[sudo] password for salacia:
Cluster name: c1
Stack: corosync
Current DC: ha2 (version 1.1.18-2b07d5c5a9) - partition with quorum
Last updated: Tue Aug 11 14:35:51 2020
Last change: Tue Aug 11 14:17:26 2020 by root via cibadmin on ha1

2 nodes configured
2 resources configured

Online: [ ha2 ]
OFFLINE: [ ha1 ]

Full list of resources:

 VIP    (ocf::heartbeat:IPaddr2):       Started ha2
 TCP-SERVER     (ocf::kamaboko:sample-tcp-server):      Started ha2

Daemon Status:
  corosync: active/enabled
  pacemaker: active/enabled
  pcsd: active/enabled
```

ちなみに、ocf-testerのテストを行う場合は、フルパスを指定すればOKです。

```
sudo ocf-tester -n sample-tcp-server /usr/lib/ocf/resource.d/kamaboko/sample-tcp-server
```



### 解説

先に説明した通り、リソースエージェントはシェルスクリプトで記述しています。

``` shell:/usr/lib/ocf/resource.d/kamaboko/sample-tcp-server
#!/bin/sh

#Initialize
: ${OCF_FUNCTIONS_DIR=${OCF_ROOT}/resource.d/heartbeat}
. ${OCF_FUNCTIONS_DIR}/.ocf-shellfuncs

#default value
OCF_RESKEY_greet_default="Hello!"
: ${OCF_RESKEY_greet=${OCF_RESKEY_greet_default}}

#environment variables for systemd
DAEMON_PID_FILE=/var/run/sample-tcp-server/tcp-server.pid

sample_meta_data() {
    cat << EOF
<?xml version="1.0"?>
<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
<resource-agent name="sample-tcp-server" version="0.1">
    <version>0.1</version>
    <longdesc lang="en">sample tcp server</longdesc>
    <shortdesc lang="en">sample tcp server</shortdesc>
    <parameters>
        <parameter name="greet" unique="0" required="0">
            <longdesc lang="en">greet message</longdesc>
            <shortdesc lang="en">greet message</shortdesc>
            <content type="string"/>
        </parameter>
    </parameters>
    <actions>
        <action name="meta-data" timeout="5" />
        <action name="start" timeout="5" />
        <action name="stop" timeout="5" />
        <action name="monitor" timeout="5" />
        <action name="validate-all" timeout="5" />
    </actions>
</resource-agent>
EOF
    return $OCF_SUCCESS
}

sample_validate(){
    return $OCF_SUCCESS
}

sample_start(){
    mkdir -p /var/run/sample-tcp-server
    echo "GREET=${OCF_RESKEY_greet}" > /var/run/sample-tcp-server/env
    systemctl start sample-tcp-server
    sleep 1
    return $OCF_SUCCESS
}

sample_stop(){
    systemctl stop sample-tcp-server
    return $OCF_SUCCESS
}

sample_monitor(){
    if [ -f ${DAEMON_PID_FILE} ];
    then
        return $OCF_SUCCESS
    fi
    return $OCF_NOT_RUNNING
}

sample_usage(){
    echo "Test Resource."
    return $OCF_SUCCESS
}

# Translate each action into the appropriate function call
case $__OCF_ACTION in
meta-data)      sample_meta_data
                exit $OCF_SUCCESS
                ;;
start)          sample_start;;
stop)           sample_stop;;
monitor)        sample_monitor;;
validate-all)   sample_validate;;
*)              sample_usage
                exit $OCF_ERR_UNIMPLEMENTED
                ;;
esac
```



#### meta-dataとデフォルト値
meta-dataでは、必須項目に加えてパラメータの定義をしています。
パラメータはリソースの作成時に設定される値で、リソースエージェント中では変数`OCF_RESKEY_パラメータ名`で取得できます。
今回は挨拶の文面を、パラメータ名`greet`で定義しています。
必須パラメータの場合、`required`属性を1にしますが、今回は0なのでオプション扱いです。
そのため、指定されなかった場合のデフォルト値を定義も含んでいます。
（指定されなかったら、このデフォルト値`Hello!`が`$OCF_RESKEY_greet`に入る。）

```shell
#default value
OCF_RESKEY_greet_default="Hello!"
: ${OCF_RESKEY_greet=${OCF_RESKEY_greet_default}}

sample_meta_data() {
    cat << EOF
<?xml version="1.0"?>
<!DOCTYPE resource-agent SYSTEM "ra-api-1.dtd">
<resource-agent name="sample-tcp-server" version="0.1">
    <version>0.1</version>
    <longdesc lang="en">sample tcp server</longdesc>
    <shortdesc lang="en">sample tcp server</shortdesc>
    <parameters>
        <parameter name="greet" unique="0" required="0">
            <longdesc lang="en">greet message</longdesc>
            <shortdesc lang="en">greet message</shortdesc>
            <content type="string"/>
        </parameter>
    </parameters>
    <actions>
        <action name="meta-data" timeout="5" />
        <action name="start" timeout="5" />
        <action name="stop" timeout="5" />
        <action name="monitor" timeout="5" />
        <action name="validate-all" timeout="5" />
    </actions>
</resource-agent>
EOF
    return $OCF_SUCCESS
}
```

#### start
systemdでserviceを立ち上げているだけです。
serviceファイルは後ろで説明します。
service起動時の環境変数として、OCFからのパラメータを渡すので、`${OCF_RESKEY_greet}`をファイルに書き込んでいます。

```shell
sample_start(){
    mkdir -p /var/run/sample-tcp-server
    echo "GREET=${OCF_RESKEY_greet}" > /var/run/sample-tcp-server/env
    systemctl start sample-tcp-server
    sleep 1
    return $OCF_SUCCESS
}
```

#### stop
とくに説明することはなくserviceの停止です。

```shell
sample_stop(){
    systemctl stop sample-tcp-server
    return $OCF_SUCCESS
}
```

#### monitor
今回はPIDファイルを見ています。

```shell
sample_monitor(){
    if [ -f ${DAEMON_PID_FILE} ];
    then
        return $OCF_SUCCESS
    fi
    return $OCF_NOT_RUNNING
}
```

簡単のためこうしていますが、じつはあまり良い実装とは言えないと思います。
プロセス終了時にPIDファイルを削除する実装になっていますが、SIGKILLで殺されるとPIDファイルが削除されません。
serviceファイルの作りにもよりますが、stopアクションはありとあらゆる手段でリソースの停止を行うと考えられるため、最終的にはSIGKILLが発行されるかもしれません。
そうなると、停止してもPIDファイルが残り続けて、monitorアクションでの確認した状態と、実際の状態が異なる可能性が出てきます。
（せっかくサービス管理にsystemd使ってるんだから、systemd経由にすればよかった）

### validate-all
特に何もしません。

```shell
sample_validate(){
    return $OCF_SUCCESS
}
```


#### serviceファイル
特殊なことはしておらず、リソース設定時に作成された環境変数ファイルから環境変数を読みつつ、デーモンを起動するだけです。

```/lib/systemd/system/sample-tcp-server.service
[Unit]
Description=Sample TCP Server

[Service]
Type=simple
ExecStartPre=/bin/touch /var/run/sample-tcp-server/env
EnvironmentFile=/var/run/sample-tcp-server/env
ExecStart=/usr/bin/tcp-server.py
ExecStop=/usr/bin/pkill -F /var/run/sample-tcp-server/tcp-server.pid

[Install]
WantedBy=multi-user.target
```

## まとめ
OCFのリソースエージェントは意外と簡単に作れます。
今回は入り口として、まずは必須アクションだけで実際に動作するリソースエージェントを作成しましたが、実際に作成する際には細かな注意点等もあります。
そのあたりは、[OCFリソースエージェント開発者ガイド](https://linux-ha.osdn.jp/wp/archives/4328)にヒントが色々書かれているので、参考になると思います。

### 余談
LPIC304勉強してるときにPacemakerが何やってるのかイメージがあまりわかなくて、色々調べてるうちにリソースエージェントが自作できそうなことに気づいて、この記事が生まれました。