Kubernetes Job（CronJob）でNode-RED flowを動かす

## KubernetesでNode-REDを動かす

KubernetesでNode-REDを動かす方法は何種類かあって、[Node-REDが動作するコンテナをビルドして Kubernetes で実行するまでを解説](https://qiita.com/MahoTakara/items/86c0d1072d1cd34187d5)のように、コンテナを外部公開するようなエンドポイントを提供する感じが一般的です。

そうじゃなくて、必要な時にコンテナを起動して処理したらコンテナが落ちるような感じにしたい場合は `Job` や `CronJob` というのを使うっぽいです。

## Node-REDをどうやって終了させるか？

Jobは処理を終了させる必要がありますが、ご存知の通りNode-REDはソレ単体では起動し続けるように作られているので、必要な処理が終わったらNode-REDの外からNode-REDのプロセスを終了させるか、Node-REDのFlowでプロセスを終了させなければいけません。

当初はfunctionノードで `process.exit` してみましたが、functionノードでは `process` が使えなかったので、今回は[node-red-contrib-exit](https://github.com/joeartsea/node-red-contrib-exit)という任意のExit codeを指定してNode-REDを終了させるだけの単純なノードを作りました。

## 実際にJobとして作成したFlow

**Flow作成する前にCredentialの関係でエディタが参照しているsettings.jsを以下のように編集する必要があります。**

```javascript
// By default, credentials are encrypted in storage using a generated key. To
// specify your own secret, set the following property.
// If you want to disable encryption of credentials, set this property to false.
// Note: once you set this property, do not change it - doing so will prevent
// node-red from being able to decrypt your existing credentials and they will be
// lost.
credentialSecret: "<適当な文字列>", 
```

で、実際 `node-red-contrib-exit` を使ってメールが正常に送信されたら `exit 0` を、例外が発生したら `exit 1` で成否に関係なくNode-REDを終了させるFlowが以下です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/21109/a37350f4-5b34-2853-001f-d6a75cf059c8.png)

```json
[{
	"id": "a88f2597.ecc188",
	"type": "subflow",
	"name": "isProd",
	"info": "",
	"in": [{
		"x": 54,
		"y": 69,
		"wires": [{
			"id": "334b358a.c7ab6a"
		}]
	}],
	"out": [{
		"x": 341,
		"y": 125,
		"wires": [{
			"id": "7640fbaf.846f84",
			"port": 0
		}]
	}, {
		"x": 341,
		"y": 177,
		"wires": [{
			"id": "7640fbaf.846f84",
			"port": 1
		}]
	}]
}, {
	"id": "334b358a.c7ab6a",
	"type": "change",
	"z": "a88f2597.ecc188",
	"name": "",
	"rules": [{
		"t": "set",
		"p": "env.node_env",
		"pt": "msg",
		"to": "NODE_ENV",
		"tot": "str"
	}],
	"action": "",
	"property": "",
	"from": "",
	"to": "",
	"reg": false,
	"x": 206,
	"y": 69,
	"wires": [
		["ce0f4b8d.e9cc38"]
	]
}, {
	"id": "ce0f4b8d.e9cc38",
	"type": "process-env",
	"z": "a88f2597.ecc188",
	"name": "",
	"into": "payload",
	"x": 426.5,
	"y": 69,
	"wires": [
		["7640fbaf.846f84"]
	]
}, {
	"id": "7640fbaf.846f84",
	"type": "switch",
	"z": "a88f2597.ecc188",
	"name": "",
	"property": "payload.node_env",
	"propertyType": "msg",
	"rules": [{
		"t": "eq",
		"v": "production",
		"vt": "str"
	}, {
		"t": "else"
	}],
	"checkall": "true",
	"repair": false,
	"outputs": 2,
	"x": 222,
	"y": 132,
	"wires": [
		[],
		[]
	]
}, {
	"id": "abb7cf3b.9f9c3",
	"type": "inject",
	"z": "e618397e.9b1cd8",
	"name": "",
	"topic": "",
	"payload": "",
	"payloadType": "date",
	"repeat": "",
	"crontab": "",
	"once": true,
	"onceDelay": "",
	"x": 113.5,
	"y": 56,
	"wires": [
		["4b7c7186.23f76"]
	]
}, {
	"id": "984fd139.d0db1",
	"type": "exit",
	"z": "e618397e.9b1cd8",
	"name": "",
	"exitcode": "0",
	"x": 526.5,
	"y": 174,
	"wires": []
}, {
	"id": "fa2df919.fd9058",
	"type": "debug",
	"z": "e618397e.9b1cd8",
	"name": "",
	"active": true,
	"tosidebar": true,
	"console": false,
	"tostatus": false,
	"complete": "payload",
	"x": 204.5,
	"y": 128,
	"wires": []
}, {
	"id": "4b7c7186.23f76",
	"type": "subflow:a88f2597.ecc188",
	"z": "e618397e.9b1cd8",
	"x": 255.5,
	"y": 57,
	"wires": [
		["fddf94ba.c3fb78"],
		["fa2df919.fd9058"]
	]
}, {
	"id": "bad0f0c1.9fba4",
	"type": "e-mail",
	"z": "e618397e.9b1cd8",
	"server": "smtp.gmail.com",
	"port": "465",
	"secure": true,
	"name": "to@example.com",
	"dname": "send mail",
	"x": 450.5,
	"y": 128,
	"wires": []
}, {
	"id": "fddf94ba.c3fb78",
	"type": "change",
	"z": "e618397e.9b1cd8",
	"name": "",
	"rules": [{
		"t": "set",
		"p": "from",
		"pt": "msg",
		"to": "from@example.com",
		"tot": "str"
	}, {
		"t": "set",
		"p": "payload",
		"pt": "msg",
		"to": "This email was sent by kubernetes job.",
		"tot": "str"
	}, {
		"t": "set",
		"p": "topic",
		"pt": "msg",
		"to": "Mail from kubernetes job.",
		"tot": "str"
	}],
	"action": "",
	"property": "",
	"from": "",
	"to": "",
	"reg": false,
	"x": 428.5,
	"y": 52,
	"wires": [
		["bad0f0c1.9fba4"]
	]
}, {
	"id": "25c8c61e.4eaeea",
	"type": "status",
	"z": "e618397e.9b1cd8",
	"name": "",
	"scope": ["bad0f0c1.9fba4"],
	"x": 100.5,
	"y": 188,
	"wires": [
		["f5ea97f9.11a008"]
	]
}, {
	"id": "f5ea97f9.11a008",
	"type": "switch",
	"z": "e618397e.9b1cd8",
	"name": "",
	"property": "status.text",
	"propertyType": "msg",
	"rules": [{
		"t": "eq",
		"v": "",
		"vt": "str"
	}, {
		"t": "else"
	}],
	"checkall": "true",
	"repair": false,
	"outputs": 2,
	"x": 258.5,
	"y": 188,
	"wires": [
		["26742b2c.b82bd4"],
		[]
	]
}, {
	"id": "b7318bfe.a1d6f8",
	"type": "catch",
	"z": "e618397e.9b1cd8",
	"name": "",
	"scope": null,
	"x": 95.5,
	"y": 244,
	"wires": [
		["91d21df.ea048e"]
	]
}, {
	"id": "73257e5a.6466",
	"type": "exit",
	"z": "e618397e.9b1cd8",
	"name": "",
	"exitcode": "1",
	"x": 396,
	"y": 242,
	"wires": []
}, {
	"id": "26742b2c.b82bd4",
	"type": "subflow:a88f2597.ecc188",
	"z": "e618397e.9b1cd8",
	"x": 396,
	"y": 181,
	"wires": [
		["984fd139.d0db1"],
		["dc9c3f20.7b90f"]
	]
}, {
	"id": "dc9c3f20.7b90f",
	"type": "debug",
	"z": "e618397e.9b1cd8",
	"name": "",
	"active": true,
	"tosidebar": true,
	"console": false,
	"tostatus": false,
	"complete": "payload",
	"x": 547,
	"y": 227,
	"wires": []
}, {
	"id": "91d21df.ea048e",
	"type": "subflow:a88f2597.ecc188",
	"z": "e618397e.9b1cd8",
	"x": 250,
	"y": 244,
	"wires": [
		["73257e5a.6466"],
		["b45f94ce.59c9e8"]
	]
}, {
	"id": "b45f94ce.59c9e8",
	"type": "debug",
	"z": "e618397e.9b1cd8",
	"name": "",
	"active": true,
	"tosidebar": true,
	"console": false,
	"tostatus": false,
	"complete": "payload",
	"x": 417,
	"y": 297,
	"wires": []
}]
```

以下、Flowの解説になります。

### Node-REDが起動したら1回だけ動くinjectノード

Node-REDをどう終了させるかも大事ですが、Node-RED起動時に自動的にFlowをスタートさせるのも地味に大事です。

今回はinjectノードの「Node-RED起動のx秒後、以下を行う」（英語だと「Inject once at start?」）をチェックすることで実現します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/21109/b73a6b62-fc7e-3dce-424b-8a27f7ec8e3f.png)

### 環境変数で動作を振り分けるSub Flow

Sub Flowがありますが、これは[node-red-contrib-process-env](https://flows.nodered.org/node/node-red-contrib-process-env)を使って、 `NODE_ENV` の値が `production` の場合にだけ、Inject once at startやexitノードによるNode-RED終了が行われるようにしています。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/21109/e43518c7-51b1-ea27-a5c7-66f765c531f7.png)

Flow開発中はSub Flowの後続に別のinjectノードを置いて開発します。

## Kubernetes CronJobの設定

今回は[Docker for Mac](https://www.docker.com/products/docker-desktop)のローカルKubernetes環境を使っています（参考：[Docker for MacでKubernetes インストールからデプロイまで](https://qiita.com/uni-3/items/a4f2afa0973bfc35b498)）

### Dockerイメージの作成

上記Flowを内蔵したNode-REDが動くコンテナイメージを作成します。

まずは、本家の[node-red-docker](https://github.com/node-red/node-red-docker)をクローンします。

```bash
$ git clone https://github.com/node-red/node-red-docker
```

Credentialの関係で直下（package.jsonと同じ階層）に先ほど作成したFlowの `flows.json`, `cred.json`, `settings.js` を作成します。**Flowのエクスポートで取得できるJSONとは異なりますのでFlowを作成したエディタが参照している各ファイルをコピーします**

こんな感じ。

```diff
  .
  ├── CONTRIBUTING.md
  ├── LICENSE
  ├── README.md
  ├── aarch64
  │   └── Dockerfile
  ├── build-rpi.sh
+ ├── cred.json
+ ├── flows.json
  ├── latest
  │   └── Dockerfile
  ├── package.json
  ├── rpi
  │   └── Dockerfile
+ ├── settings.js
  ├── slim
  │   └── Dockerfile
  ├── update.sh
  └── version.sh
```

Node-REDのCredentialファイルには機密情報（今回はe-mailノードのsmtp接続情報とか）が暗号化されて記載されています。これを復号するために必要なキーが冒頭settings.jsに指定した `credentialSecret` です。

今回はJobという性質上、**コンテナを作成してからFlowを実行して終了するまで全て自動化しなければいけません**のでエディタで機密情報を入力する暇がありません。このように全てのファイルをコピーします。

続いて、package.jsonを編集します。

追加インストールするノード。

```diff
 "dependencies": {
-    "node-red": "0.19.4",
-    "node-red-node-msgpack": "*",
-    "node-red-node-base64": "*",
-    "node-red-node-suncalc": "*",
-    "node-red-node-random": "*"
-},
-"engines": {
-    "node": "4.*.*"
+    "node-red": "*",
+    "node-red-contrib-exit": "*",
+    "node-red-contrib-process-env": "*"
 }
```

Credentialの関係でflow.jsonは環境変数で指定しないように。

```diff

  "scripts": {
-    "start": "node $NODE_OPTIONS node_modules/node-red/red.js -v $FLOWS"
+    "start": "node $NODE_OPTIONS node_modules/node-red/red.js"
  },
```

latest/Dockerfileを編集します。

ここもCredentialの関係でFlowを作成したエディタが参照していた各ファイルをビルド時にイメージへコピーするよう設定します。また、ホスト名から生成される各ファイル名を決め打ちしています。

```diff
 # package.json contains Node-RED NPM module and node dependencies
+COPY flows.json /data/flows_sendmail-nodered-job.json
+COPY cred.json /data/flows_sendmail-nodered-job_cred.json
+COPY settings.js /data/settings.js
 COPY package.json /usr/src/node-red/
 RUN npm install
```

したがって、環境変数 `FLOWS` は削除して `HOSTNAME` でホスト名を指定します（ただし、実際にコンテナのホスト名を指定するには後ほど作成するManifestファイルでもホスト名を指定する必要があります）

```diff
 # Environment variable holding file path for flows configuration
-ENV FLOWS=flows.json
+ENV HOSTNAME=sendmail-nodered-job
 ENV NODE_PATH=/usr/src/node-red/node_modules:/data/node_modules
```

docker buildします。

```bash
$ docker build -f ./latest/Dockerfile -t sendmail-nodered-job .
```

docker imagesで確認。

```bash
$ docker images

REPOSITORY            TAG     IMAGE ID      CREATED              SIZE
sendmail-nodered-job  latest  9de0ab5ec677  About a minute ago   718MB
...
```

### CronJobのManifestファイルを作成

CronJobのManifestファイルを作成します。

```yaml:sendmail-nodered-job.yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: sendmail-nodered-job
  labels:
    cronjob: sendmail-nodered-job
spec:
  concurrencyPolicy: Replace
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          hostname: sendmail-nodered-job
          containers:
          - name: sendmail-nodered-job
            image: sendmail-nodered-job
            imagePullPolicy: IfNotPresent
            env:
              - name: NODE_ENV
                value: production
          restartPolicy: OnFailure
```

以下、解説。

並列実行のポリシーを `concurrencyPolicy` で指定します（参考：[KubernetesのCron Jobを使って雑務を自動化する](https://qiita.com/TakiTake@github/items/e8058e26b688003809bc)）

- Allow (default): 同時実行を許可する。
- Forbid: 一つ前のジョブが動いていた場合は、スキップする。
- Replace: 現在動いているジョブをキャンセルして、新しいジョブと入れ替える。

```yaml
concurrencyPolicy: Replace
```

1分間隔でメール送信するNode-RED Flowを起動します。

```yaml
schedule: "*/1 * * * *"
```

ホスト名を指定。

```yaml
hostname: sendmail-nodered-job
```

先ほど作成したDockerイメージを指定。

```yaml
image: sendmail-nodered-job
```

pullするDockerイメージがローカルの場合は以下の設定が必要。

```yaml
imagePullPolicy: IfNotPresent
```

先ほどFlowの処理を環境変数によって振り分けたのでココで `NODE_ENV` に `production` を設定。

```yaml
env:
    - name: NODE_ENV
      value: production
```

Podやコンテナの障害処理ポリシーを指定（参考：[Kubernetes "ジョブ" についての自習ノート](https://qiita.com/MahoTakara/items/82853097a1911671a704#restartpolicy-onfailure-%E3%81%AE%E3%82%B1%E3%83%BC%E3%82%B9)）

```yaml
restartPolicy: OnFailure
```

## CronJob実行

CronJobを作成します。

```bash
$ kubectl create -f sendmail-nodered-job.yaml 
cronjob.batch "sendmail-nodered-job" created
```

CronJobの確認。

```bash
$ kubectl get cronjob
NAME                   SCHEDULE      SUSPEND   ACTIVE    LAST SCHEDULE   AGE
sendmail-nodered-job   */1 * * * *   False     0         <none>          37s
```

`LAST SCHEDULE` が更新されたらJobが動いたということ。

```bash
$ kubectl get cronjob
NAME                   SCHEDULE      SUSPEND   ACTIVE    LAST SCHEDULE   AGE
sendmail-nodered-job   */1 * * * *   False     0         22s             1m
```

Job一覧。

```bash
$ kubectl get job -a
NAME                              DESIRED   SUCCESSFUL   AGE
sendmail-nodered-job-1538130960   1         0            5s
```

Podも確認。

```bash
$ kubectl get pod
NAME                                    READY     STATUS      RESTARTS   AGE
sendmail-nodered-job-1538130960-9zjwp   0/1       Completed   0          18s
```

ログを見てみるとNode-REDの起動から終了まで5秒くらい（ `httpAdminRoot` を `false` にした場合）

```bash
$ kubectl logs sendmail-nodered-job-1538130960-9zjwp

> node-red-docker@1.0.0 start /usr/src/node-red
> node $NODE_OPTIONS node_modules/node-red/red.js "--userDir" "/data"

26 Sep 10:36:07 - [info]

Welcome to Node-RED
===================

26 Sep 10:36:07 - [info] Node-RED version: v0.19.4
26 Sep 10:36:07 - [info] Node.js  version: v6.14.4
26 Sep 10:36:07 - [info] Linux 4.9.93-linuxkit-aufs x64 LE
26 Sep 10:36:07 - [info] Loading palette nodes
26 Sep 10:36:08 - [warn] rpi-gpio : Raspberry Pi specific node set inactive
26 Sep 10:36:08 - [warn] rpi-gpio : Cannot find Pi RPi.GPIO python library
26 Sep 10:36:10 - [info] Settings file  : /data/settings.js
26 Sep 10:36:10 - [info] Context store  : 'default' [module=memory]
26 Sep 10:36:10 - [info] User directory : /data
26 Sep 10:36:10 - [warn] Projects disabled : set editorTheme.projects.enabled=true to enable
26 Sep 10:36:10 - [info] Flows file     : /data/flows_sendmail-nodered-job.json
26 Sep 10:36:10 - [info] Admin UI disabled
26 Sep 10:36:10 - [info] Server now running at http://127.0.0.1:1880
26 Sep 10:36:10 - [info] Starting flows
26 Sep 10:36:10 - [info] Started flows
26 Sep 10:36:12 - [info] [e-mail:to@example.com] Message sent: 250 Ok ...
```

ちなみに、kubectlのCronJobやJobの操作は[kubernetesのCronJobを触る](https://repl.info/archives/879/) がわかりやすいです。

そしてメールが来た。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/21109/b14932ca-c816-cf06-116a-a38118c43eea.png)

CrobJobの削除

```bash
$ kubectl delete -f sendmail-nodered-job.yaml
cronjob.batch "sendmail-nodered-job" deleted
```

本当は例外時をもっと検証しないといけないのですが、とりあえず正常系動いたので公開します。

多分、catchやstatusで制御できないとか処理をブロックするようなノードが入り込んじゃうといつまでも終わらないJobが出来上がる気がする...

## おまけ

こういうJob系のDockerイメージはプライベートなDocker Registryに置きたいところですが、そういう場合は[gitlab.com](https://gitlab.com/)の[GitLab Container Registry](https://qiita.com/masakura/items/802f4b8ce322d2543c80)使うと良いですよ！なんと**無料**！