Edited at

Kubernetes Job(CronJob)でNode-RED flowを動かす


KubernetesでNode-REDを動かす

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

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


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

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

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


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

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

// 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が以下です。

[{

"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?」)をチェックすることで実現します。


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

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

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


Kubernetes CronJobの設定

今回はDocker for MacのローカルKubernetes環境を使っています(参考:Docker for MacでKubernetes インストールからデプロイまで


Dockerイメージの作成

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

まずは、本家のnode-red-dockerをクローンします。

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

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

こんな感じ。

  .

├── 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を編集します。

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

 "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は環境変数で指定しないように。


"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を作成したエディタが参照していた各ファイルをビルド時にイメージへコピーするよう設定します。また、ホスト名から生成される各ファイル名を決め打ちしています。

 # 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ファイルでもホスト名を指定する必要があります)

 # 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します。

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

docker imagesで確認。

$ docker images

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


CronJobのManifestファイルを作成

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


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を使って雑務を自動化する


  • Allow (default): 同時実行を許可する。

  • Forbid: 一つ前のジョブが動いていた場合は、スキップする。

  • Replace: 現在動いているジョブをキャンセルして、新しいジョブと入れ替える。

concurrencyPolicy: Replace

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

schedule: "*/1 * * * *"

ホスト名を指定。

hostname: sendmail-nodered-job

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

image: sendmail-nodered-job

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

imagePullPolicy: IfNotPresent

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

env:

- name: NODE_ENV
value: production

Podやコンテナの障害処理ポリシーを指定(参考:Kubernetes "ジョブ" についての自習ノート

restartPolicy: OnFailure


CronJob実行

CronJobを作成します。

$ kubectl create -f sendmail-nodered-job.yaml 

cronjob.batch "sendmail-nodered-job" created

CronJobの確認。

$ kubectl get cronjob

NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
sendmail-nodered-job */1 * * * * False 0 <none> 37s

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

$ kubectl get cronjob

NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
sendmail-nodered-job */1 * * * * False 0 22s 1m

Job一覧。

$ kubectl get job -a

NAME DESIRED SUCCESSFUL AGE
sendmail-nodered-job-1538130960 1 0 5s

Podも確認。

$ kubectl get pod

NAME READY STATUS RESTARTS AGE
sendmail-nodered-job-1538130960-9zjwp 0/1 Completed 0 18s

ログを見てみるとNode-REDの起動から終了まで5秒くらい( httpAdminRootfalse にした場合)

$ 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を触る がわかりやすいです。

そしてメールが来た。

CrobJobの削除

$ kubectl delete -f sendmail-nodered-job.yaml

cronjob.batch "sendmail-nodered-job" deleted

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

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


おまけ

こういうJob系のDockerイメージはプライベートなDocker Registryに置きたいところですが、そういう場合はgitlab.comGitLab Container Registry使うと良いですよ!なんと無料