【2021年4月版】Hyperledger Fabric 2.0 を K8s 上に構築してみたらローカルストレージのクセが強いので Job と Deployment についてちゃんと考えてみた

# Hyperledger Fabric 2.0 を Kubernetes 上に構築してみました

まず、Hyperledger Fabric 1.4 を Kubernetes 上にデプロイする手順はIBMから以下の手順が公開されています。

https://github.com/IBM/blockchain-network-on-kubernetes

こちら K8s は IBM Cloud の K8s が前提になっていますが、IBM 以外のクラスタでもデプロイ可能です。
これに以下の Hyperledger Fabric 2.0 の構築手順をマージすれば基本的にはOK、なハズです。

https://github.com/hyperledger/fabric-samples/

test-net の構築スクリプトを再構成して、k8s にデプロイできればOKじゃん、と当初はそう考えていました。あの頃は・・・

# Job と Deployment でタスクの分割

fabric-samples の test-net 構築にはいくつかのステップに分かれております。

1. 設定に従って公開鍵の生成
1. Fabricネットワークの立ち上げ
1. チェーンコードのデプロイ

の大きく３ステップです。

なんとなくですが、1. 公開鍵の生成と 3. キーチェーンデプロイ は単発のタスクなので Job、2. Fabricネットワークは永遠なので Deployment とざっくり分けることは想像できます。

blockchain-network-on-kubernetesの手順でもだいたいこのような区分けになっております。

```github.com/IBM/blockchain-network-on-kubernetes/blob/master/configFiles/generateArtifactsJob.yaml
---
apiVersion: batch/v1
kind: Job
metadata:
  name: utils
spec:
  backoffLimit: 1
  template:
    metadata:
      name: utils
    spec:
      restartPolicy: "Never"
      volumes:
      - name: sharedvolume
        persistentVolumeClaim:
          claimName: shared-pvc
      - name: dockersocket
        hostPath:
          path: /var/run/docker.sock
      containers:
      - name: cryptogen
        image: hyperledger/fabric-tools:1.4
        imagePullPolicy: Always
        command: ["sh", "-c", "echo 'Cryptogen Starts'; ls -l /shared/artifacts; while [ ! -d /shared/artifacts ]; do echo Waiting for configFiles; sleep 1; done; cryptogen generate --config /shared/artifacts/crypto-config.yaml && cp -r crypto-config /shared/ && for file in $(find /shared/ -iname *_sk); do echo $file; dir=$(dirname $file); echo ${dir}; mv ${dir}/*_sk ${dir}/key.pem; done && find /shared -type d | xargs chmod a+rx && find /shared -type f | xargs chmod a+r && touch /shared/status_cryptogen_complete;"]
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume
      - name: configtxgen
        image: hyperledger/fabric-tools:1.4
        imagePullPolicy: Always
        command: ["sh", "-c", "echo 'Configtxgen Starts'; ls -l /shared; sleep 1 && while [ ! -f /shared/status_cryptogen_complete ]; do echo Waiting for cryptogen; sleep 1; done; cp /shared/artifacts/configtx.yaml /shared/; cd /shared/; export FABRIC_CFG_PATH=$PWD; configtxgen -profile FourOrgsOrdererGenesis -outputBlock genesis.block && find /shared -type d | xargs chmod a+rx && find /shared -type f | xargs chmod a+r && touch /shared/status_configtxgen_complete && rm /shared/status_cryptogen_complete;"]
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume
```
`cryptogen`コマンドと`configtxgen`コマンドの実行は`Job`で実行されています。

次に、ordererやpeerは`Deployment`として実行されています。

```yaml:IBM/blockchain-network-on-kubernetes/blob/master/configFiles/peersDeployment.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: blockchain-orderer
spec:
  replicas: 1
  selector:
    matchLabels:
      name: orderer
  template:
    metadata:
      labels:
        name: orderer
    spec:
      volumes:
      - name: sharedvolume
        persistentVolumeClaim:
          claimName: shared-pvc

      containers:
      - name: orderer
        image: hyperledger/fabric-orderer:1.4
        command: ["sh", "-c", "sleep 5 && while [ ! -f /shared/status_configtxgen_complete ]; do echo Waiting for configtxgen; sleep 1; done; orderer"]
        env:
        - name: ORDERER_CFG_PATH
          value: /shared/
        - name: ORDERER_GENERAL_LEDGERTYPE
          value: file
        - name: ORDERER_FILELEDGER_LOCATION
          value: /shared/ledger/orderer
        - name: ORDERER_GENERAL_BATCHTIMEOUT
          value: 1s
        - name: ORDERER_GENERAL_BATCHSIZE_MAXMESSAGECOUNT
          value: "10"
        - name: ORDERER_GENERAL_MAXWINDOWSIZE
          value: "1000"
        - name: ORDERER_GENERAL_ORDERERTYPE
          value: solo
        - name: ORDERER_GENERAL_LISTENADDRESS
          value: 0.0.0.0
        - name: ORDERER_GENERAL_LISTENPORT
          value: "31010"
        - name: ORDERER_GENERAL_LOGLEVEL
          value: debug
        - name: ORDERER_GENERAL_LOCALMSPDIR
          value: /shared/crypto-config/ordererOrganizations/example.com/orderers/orderer.example.com/msp
        - name: ORDERER_GENERAL_LOCALMSPID
          value: OrdererMSP
        - name: ORDERER_GENERAL_GENESISMETHOD
          value: file
        - name: ORDERER_GENERAL_GENESISFILE
          value: /shared/genesis.block
        - name: ORDERER_GENERAL_GENESISPROFILE
          value: initial
        - name: ORDERER_GENERAL_TLS_ENABLED
          value: "false"
        - name: GODEBUG
          value: "netdns=go"
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume
```
となります。
k8sはPodに載せるものの種別によって管理ポリシーがありまして、

https://kubernetes.io/docs/concepts/workloads/controllers/

- Deployments
- ReplicaSet
- StatefulSets
- DaemonSet
- Jobs
- ...

などなどですが、これらの種別によって Pod の振る舞いが変わってきます。
`Deployments`はデプロイされた Pod を維持しますが、`Jobs`は Pod がデプロイされて、コンテナが異常終了したら Pod は再デプロイされ、コンテナが正常に終了したらそのタスクは終了します。
このように振る舞いが異なるので、振る舞いが違う=Podが違うとなります。

# タスク間でのファイルの受け渡しはどうするのか問題

さて、Pod が違うと何が困るのかというと・・・**Job のPodで生成したファイルをどうやってDeploymentのPodに渡すのか？**という問題です。

そこで必要になってくるのが **Persistent Volumes(PV,永続化ボリューム)** というファイルの置き場所です。
提供されるPVクラス(ドライバ)の機能次第ですが、複数のPodで共有可能なファイルシステムを提供してくれます。
PVクラスの中身については次節触れますが、PVを使用してJobのPodとDeploymentのPodに同じPVをマウントすることでお互いに同じファイル、ディレクトリを参照することができるようになります。
上記のyamlでそれぞれのPodの`/shared`にボリュームをマウントしているのが分かると思います。
JobのPodで`/shared`に書き込み、DeploymentのPodから`/shared`を読み取ることで、`configtxgen`で生成されたで設定ファイルを参照して peer や orderer が動作する、ということが実現できるようになります。

ただしPodでは実行のタイミングの完全な同期がとれないため、上記の yaml でもありますが、コンテナで実行されるコマンドで必要なファイルに対する**待ち**=`while [ ! -f /shared/status_xxxxx ];... done;`を入れていきます。

このように、

1. 共有用にボリュームをマウント
1. Jobs の Pod でファイルを生成
1. Deployment でファイルを待ち、ファイルが生成されたら実行

の手順で各タスクを組み立てていくこととなります。

シンプルなタスクはこれでもよいのですが、fabric-sample の test-net では公開鍵の生成手順がそこそこ複雑なため、これらの仕組みを駆使していくことになります。

# Job の Pod の中からファイルをダウンロードするには？

生成された公開鍵は fabric のネットワーク内でも使用されますが、当然、クライアントからの接続に使用しますので、Job の Pod で生成された公開鍵のファイル群を Pod の中からダウンロードする必要があります。

単に Pod からファイルをダウンロードするのは以下のようなコマンドで可能です。

```
$ kubectl cp $pod:/shared ./generated 
```

ですが、**JobのPodは実行後速やかにアクセスできなくなります**。Job の Pod からは素直にファイルを取得できないのです。
Jobで生成されたファイルを取ってくるには、いくつか迂回路を使用する必要があります。

1. Job に対応する Deployment を用意し、Deploymentからファイルを取得する
1. Job のコマンドでメインのコマンドが終了後、`while [ ! -f /shared/status_xxxxx ];... done;` を設置し、ファイルダウンロードしたら`kubectl cp status_xxxxx $pod:/shared`を送ってJobを終わらせる
1. ↑の手順をメイン処理とファイルコピー待ちのコンテナに分けて、メインの処理が落ちたかどうかをわかりやすくする。

上記の手順、それぞれにデメリットがあります。

1.の手順で Deploymentを使うのは簡単なのですが、DeploymentのPodは存在し続けます。ファイルのダウンロードのためだけのPodをk8sのクラスタに生かしておくのはリソースの無駄です。
2. 3. の手順では Podが生き続けているので Job の終了ステータスはチェックできません。しばらく sleep してからエイヤで kubectl cp を叩くことになります。そうなると Job が失敗したか成功したかで次の手順を実行するかどうかの判断ができなくなります。ダウンロードされたファイルのフォルダ構成などを別途チェックして成否を判断する必要があります。

また、`kubectl cp` 内部で **非同期で** tar コマンドを使用している模様です。フォルダの中身が大きくなっていくと `kubectl cp` のコマンドが抜けてもしばらく待たないとダウンロードが完了しません。
`kubectl cp`が抜けてすぐにダウンロード完了のステータスファイルを送信してしまうと、即座に Pod が終了してしまうため**ダウンロードが切断**されてしまいます。**適度に sleep しつつ**`kubectl cp`を実施する必要があります。

3.の手順でコンテナを分ける場合、`kubectl cp` に `-c` を追加すると特定のコンテナを対象にファイルのコピーができるようになります。

```
$ kubectl cp ./complete_copy $pod:/shared -c waitfilecopy 
```

このような感じでメインのJob用コンテナの実行とファイルのコピーを待つ`waitfilecopy`コンテナを分けて、メインの処理を行うコンテナが異常終了すれば即座に Job はリトライしますので、ステータスチェックが多少やりやすくなります。

# 永続化ボリュームの選択肢が多い

さて、いよいよ永続化ボリュームの話です。[以前の記事で永続化ボリュームの種類について列挙いたしました](https://qiita.com/koinori/items/20c71563dcd2c14f0058#csi-%E3%83%97%E3%83%AD%E3%83%90%E3%82%A4%E3%83%80)が、１つのクラウドサービスについて大抵2,3のPVクラスが提供されています。これはPVが利用される範囲(単一/複数Pod、単一/複数リージョン)によって同期する方式・料金が異なるためで、k8sのクラスタとは別でストレージを作成しておいて、そこを PV として使うように向ける、という手順となります。

例えば、blockchain-network-on-kubernetes の大元である IBM Cloud の k8s サービスでは、以下のようなストレージの比較表があります。

- [非永続ストレージ・オプションの比較](https://cloud.ibm.com/docs/containers?topic=containers-storage_planning#non_persistent_overview)
- [単一ゾーン・クラスター用の永続ストレージ・オプションの比較](https://cloud.ibm.com/docs/containers?topic=containers-storage_planning#single_zone_persistent_storage)
- [複数ゾーン・クラスター用の永続ストレージ・オプションの比較](https://cloud.ibm.com/docs/containers?topic=containers-storage_planning#persistent_storage_overview)

これ、どの範囲までの永続性を求めるかを事前に検討してストレージを作成しておき、アクセス用のAPIキーやサービスアカウントを取得し、k8s に対応するドライバーをインストールして、ボリュームを作成し、マウントする、というなかなかの手順を踏む必要があります。場合によっては k8s の secret と Policy を利用してロールバインドしたりと、採用するストレージとドライバによってセットアップの手順が異なります。
**かなり~~めんどくさい~~大変である**ことは間違いないですし、料金のことも考えると萎えます。。。

となると"とりあえず"は`local`ストレージを使用することとなります。

# Local ストレージのクセが強い

## その1. Affinity が必須 

この Local ストレージは**どこのLocalか**というと、**Nodeのローカル** であります。
つまり、上記で散々、Job の Pod と Deployment の Pod 間でのファイル共有について語ってまいりましたが、Localストレージを使う場合はそもそも**同じノードじゃないと無意味**なのです。。。
Pod が実行される Node は通常、k8sのクラスタ管理にお任せでリソースの配分に従って決定され、Pod を宜しく振り分けてくれますが、Local ストレージを使ってファイル共有をしたければ、明示的に Job と Deployment を同じ Node で実行されるように`affinity`の指定が必要になってまいります。

例えば、

```yaml
---
apiVersion: batch/v1
kind: Job
metadata:
  name: joinchannel1
spec:
  backoffLimit: 3
  template:
    metadata:
      name: joinchannel1
    spec:
      restartPolicy: "Never"
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
                - key: name 
                  operator: In 
                  values:
                  - check-channeltx
            topologyKey: kubernetes.io/hostname
```
のように指定した場合、`check-channeltx` という `name` の `label` を持っているPodと同じNodeでこのJobを実行する、という意味になります。
ここで `nodeAffinity` を使用しない理由は後ほど述べますが、`podAffinity` を使うということは、その条件に当てはまる Pod がないとダメ、ということになります。ややこしいですが、podAffinityの条件にマッチしなくなると Deployment の Pod でも削除されます。`requiredDuringScheduling` ってわけで、スケジューリングされているからには条件を満たしていないとダメで、条件が満たされないとスケジューリングから解除される＝Podが削除される、という仕組みになっております。

ここではじめのJobとDeploymentの話に戻りますが、`Deployments`はデプロイされた Pod を維持しますが、`Jobs`はコンテナが正常に終了したらそのPodは即終了です。
つまり、JobのPodを条件にDeploymentのPodをデプロイすると、どちらも即終了してしまうので、DeploymentのPodをデプロイしておいて、それを条件にJobをデプロイする、という順番が必須なのです。
上記の手順では、

1. Jobs の Pod でファイルを生成
1. Deployment でファイルを待ち、ファイルが生成されたら実行

としていましたので、Jobのデプロイを先に計画していたのですが、対象の Deployment は事前にデプロイしておかなくてはいけない、ということになります。

`nodeAffinity` がイマイチな理由が、Deployment をデプロイする際に `nodeAffinity` で node に範囲を持たせると Pod が特定できず、node を固定すると Deployment の自動スケジューリングが阻害されてしまうのです。特定の node が落ちるとそのままその Deployment も終了、という構成になってしまいます。Deployment が起動するNodeを固定せずにJobのPodを追随させるには**PodAffinityしかない**のです。。。

## その2. いつ消えるの？

Fabric の設定ファイル書いて生成された鍵ファイルのチェックして設定の見直して・・・と繰り返していくとふと気づきます。Fabricが全然立ち上がらなくなっていると。
fabric-samples のtest-net 起動時のエラーを調べると大抵、**ファイルをすべて綺麗に消してからもう一回やればうまくいくよ** というものです。そりゃそうだ。

・・・ん？上記で **Nodeのローカル** である、と書きましたが、そのNodeのローカルに生成されているファイルはいつ消えるのか。。。？
ズバリ言いますと、これが**自動で削除されることはありません**。もちろん、NodeそのものもDockerなどのコンテナで実行されていれば、Nodeの削除と共にコンテナが消されて中のファイルが消える場合もあります。ただし、ローカルでNodeを実行していた場合はそのまま残ります。単に k8s のサービスが停止しただけなので。
また、タスクのリトライの度に Node をいちいち削除するのは現実的ではないでしょう。というか、そんなことしないでしょう。
つまり、実質的に Local ボリュームは明示的に削除しなければ、残り続けます。だって、**永続化ボリュームなのです**から当たり前といえば当たり前なのですが。タスクのリトライ時にも前回の結果がそのまま残っています。
で、さらにやっかいなのは、Node が複数起動していて Deployment の Pod が宜しく散らばっていくと、前回使用されていない Node では永続化ボリュームが作成されていないので新規にボリュームが作成され、空の状態からタスクが実行されるので test-net は起動に成功するけど、前回と同じ Node に当たると前回の結果が残っているので test-net の起動に失敗する、という不安定な状態になってしまいます。
不具合に対応してて厄介なのがこの`再現性`の見極めですが、k8s のお蔭で Pod が宜しくバラバラのNodeで実行されると成功する確率が上がってしまう、ということです。で、リトライするたびに`前回のデータが残ったNode`が増えていき、ある時点で完全に成功しなくなる、という状況になります。

というわけで、どのNodeにデプロイされたかどうかにかかわらず、**まず削除から始める** ということが必須になります。
fabric のネットワークの準備にまず最初に必要な、fabric-ca のデプロイ用yaml が以下のように悲惨な結果になりました。

```fabric-ca.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: clearstorage
spec:
  selector:
    matchLabels:
      name: clearstorage
  template:
    metadata:
      labels:
        name: clearstorage
        bc-network: ca
    spec:
      volumes:
      - name: sharedvolume
        persistentVolumeClaim:
          claimName: shared-pvc
      containers:
      - name: clearstorage
        image: hyperledger/fabric-tools:2.3
        imagePullPolicy: IfNotPresent
        command: ["sh", "-c", "while [ ! -d /shared ]; do echo Waiting for Mounting; sleep 1; done; echo Start Clear; rm -rf /shared/*; find /shared; touch /shared/complete_clear; sleep 10; rm /shared/complete_clear;sleep infinity;"]
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume
---
apiVersion: batch/v1
kind: Job
metadata:
  name: clearstoragecheck
spec:
  backoffLimit: 1
  template:
    metadata:
      name: clearstoragecheck
    spec:
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: bc-network
                operator: In
                values:
                - ca
            topologyKey: kubernetes.io/hostname
      restartPolicy: "Never"
      volumes:
      - name: sharedvolume
        persistentVolumeClaim:
          claimName: shared-pvc
      containers:
      - name: clearstoragecheck
        image: hyperledger/fabric-tools:2.3
        imagePullPolicy: IfNotPresent
        command: ["sh", "-c", "while [ ! -f /shared/complete_clear ]; do echo Waiting for clear File; sleep 2; done; echo Complete Clear;"]
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume

---
apiVersion: batch/v1
kind: Job
metadata:
  name: castatuscheck
spec:
  backoffLimit: 1
  template:
    metadata:
      name: castatuscheck
      # labels:
      #   bc-network: ca
    spec:
      restartPolicy: "Never"
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: bc-network
                operator: In
                values:
                - ca
            topologyKey: kubernetes.io/hostname
      volumes:
      - name: sharedvolume
        persistentVolumeClaim:
          claimName: shared-pvc
      containers:
      - name: castatuscheck
        image: hyperledger/fabric-tools:2.3
        imagePullPolicy: IfNotPresent
        command: ["sh", "-c", "while [ ! -f /shared/complete-ca-start ]; do echo Waiting for Start CA Server; sleep 2; done; echo Started CA Server;"]
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: blockchain-ca
spec:
  replicas: 1
  selector:
    matchLabels:
      name: ca
  template:
    metadata:
      labels:
        name: ca
    spec:
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: bc-network
                operator: In
                values:
                - ca
            topologyKey: kubernetes.io/hostname
      volumes:
      - name: sharedvolume
        persistentVolumeClaim:
          claimName: shared-pvc

      containers:
      - name: ca
        image: hyperledger/fabric-ca:1.4.9
        command: ["sh", "-c", "while [ ! -d /shared/fabric-ca ]; do echo Waiting for Copy setting file; sleep 2; done; touch /shared/complete-ca-start; fabric-ca-server start -b admin:adminpw -d"]
        env:
        - name: FABRIC_CA_HOME
          value: /shared/fabric-ca
        - name: FABRIC_CA_SERVER_CA_NAME
          value: "CA1"
        - name: FABRIC_CA_SERVER_DEBUG
          value: "true"
        - name: FABRIC_CA_SERVER_TLS_ENABLED
          value: "true"
        - name: GODEBUG
          value: "netdns=go"
        volumeMounts:
        - mountPath: /shared
          name: sharedvolume
```

なんてこったい、という状態ですが各 Pod の役割を簡潔に説明いたします。

1. Deployment: `clearstorage` → `/shared` がマウントされるまで待ち、マウントされたら`/shared`を `rm -rf` して削除終わったら `/shared/complete_clear` を `touch` し、さらに10秒後、`/shared/complete_clear` を削除する。
1. Job: `clearstoragecheck` → `/shared/complete_clear` を待って終了。
1. Job: `castatuscheck` → `/shared/complete-ca-start` を待って終了。
1. Deployment: `blockchain-ca` → `/shared/fabric-ca`を待ち、`/shared/fabric-ca`が作成されたら`fabric-ca-server start` して `/shared/complete-ca-start` を `touch`

1.で`touch`した`/shared/complete_clear`を10秒後に削除するのは、`/shared/complete_clear`そのものも即座に消さないと次回の実行時に 2.の手順が実行されてしまうのでtouchして次のタスクが進行したら速やかに削除されるようにしています。
続いて2.の`clearstoragecheck`Jobの終了を待って、ローカルからca用の設定ファイルを`blockchain-ca`のPodにアップロードします。アップロード先は`/shared/fabric-ca`です。これにより無事に`fabric-ca-server start`がスタートし、`castatuscheck`のジョブが終了することで後続の処理が実行されます。

これで何度リトライしても確実に動くセットアップ手順が構築できました。いや～めでたしめでたし!! ではないのです。

## その3. 依存性の破綻

とりあえずリトライしても確実に動くセットアップ手順が構築できました。
ところが、これが正しく動作するにはちゃんと削除処理されたDeployment: `clearstorage`と同じ Node で"後続の処理用のJob"がすべて実行される必要があります。で、Jobの前にDeploymentが正しく配置されていないといけないということは、"後続の処理用のJob"どころか ordererやPeer用のDeployment も同じNodeに配置されないと正しく実行できなくなってしまいます。
せっかく、k8s 上に fabric のネットワーク構築したのに、**ordererや peer が同一Nodeに配備されないとダメ**って・・・ダメじゃん。。。

# 結果、惨敗。。。

というわけで、**Local Storageでのファイル共有を使用したfabricネットワークの構築は失敗** という結果になりました。
test-net の構築では Local Storage は使用せず素直に、というかダサく公開鍵生成後に毎度 `kubectl cp` でローカルにダウンロード、orderer や peer にも都度都度、`kubectl cp` でアップロードという手順に修正しました。
peerを追加するには`kubectl cp`で設定ファイルをアップロードする必要がありますので peer のDeploymentをオートスケールしてk8sウェーイな構成は断念しました。。。

やっぱりちゃんとお金払って "ストレージクラス" を準備しないとダメ、ということで。

たかが fabric の test-net の構築でしたが k8s クラスタ管理の深淵に触れたような気がします。