ArgoCD の Config Management Plugin (CMP)を理解し、Plugin でマニフェストの変数置換を行う

概要

前職の同僚がずっと、PullRequest ごとにプレビュー環境でアプリケーションをデプロイしたいと言っていた。

確かにそれができれば便利ではあるけど、たとえ k8s の力を借りても実現するまでの手順は多く、遠い夢かと思っていた。

でも ArgoCD で頑張ればその夢は近くなるかもしれない。

これは、ArgoCDの Config Management Plugin (CMP) と呼ばれる機能を使って、動的なマニフェスト生成を行い、さらにPullRequestごとの固有の情報をマニフェストに柔軟に埋め込むための仕組みを考えてみたという話。

想定読者

  • k8s にある程度詳しい
  • ArgoCD にもある程度詳しい
  • ArgoCD の ApplicationSet や Generator の機能を知っている、あるいは調べればわかる方

参考資料

GitブランチやPullRequestごとにプレビューを作ることを試している先人の方の知見を抜きにこの内容は語れない。感謝です。

speakerdeck.com

made.livesense.co.jp

speakerdeck.com

qiita.com

何をやりたいか

PullRequestごとにプレビュー環境を作る場合、PullRequestごとに固有の情報を埋め込む必要が出てくる。 特に、ドメインが顕著な例で、 PullRequestの ID が 3 だとする場合、 https://preview-3.example.com みたいな固有のドメインでプレビューアプリケーションを公開したい。

ドメイン以外にも、データベースなどの外部リソースもPullRequestごとに作る場合は、外部リソースのIDも Podの環境変数などに埋め込みたい。 (その外部リソースをどうやって準備するのかは、また別の課題)

実際に参考資料の多くも Ingress などのドメイン名を可変にするような工夫をされている。

Pull Requestをすぐ動作確認! マイクロサービスでのプレビュー環境の作り方 - LIVESENSE ENGINEER BLOG では、 Helm の template を使った方法があり、Kustomizeの拡張機能でIngressの環境管理を実現する - Qiita では、 ArgoCDの plugin と kustomize の replacementRule を使った方法が紹介されている。

どちらの方法も マニフェスト中のドメインの値だけ置換するのなら十分だと思ったが、色々な場所で置換をかけたい場合は扱いが難しい。 前者の方式だと Helm 化が必須だし、後者の方法は置換する値の分だけ環境変数を作り込まないといけない。

理想的な形は、 マニフェスト中に、 ${環境変数} という変数を書いておいて、ArgoCDが置換してくれる形式、 次のようなマニフェストがあるといい感じにしてくれるものだ。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: sample
spec:
  rules:
  - host: preview-${PULL_REQUST_NUMBER}.example.com

要は、kustomize でビルドしたマニフェストに対して、 enbsubst で環境変数による変数置換を行いたい。 このやり方なら Replacement Rule では対応できない置換、ConfigMapの nginx の設定ファイルのホスト名を置き換えるとか、 にも対応できる。

どうやってやるか?

Kustomizeの拡張機能でIngressの環境管理を実現する - Qiita で紹介されている Transformer (KRM function exec plugin) を使うことを考えてみる。

Transformer は kustomize の仕組みで、ビルド後のマニフェストに編集を加える。 KRM function exec plugin は Transformer の仕組みとして任意のコマンドを実行する方法で、 マニフェストを標準入力としてコマンドに渡し、コマンドの標準出力の結果を最終結果として扱う。

ただし、 exec plugin を使うには現時点では kustomize コマンドのオプションに --enable-alpha-plugins --enable-exec を追加する必要があり、通常の ArgoCDから実行される kustomize では実行できない。

そこで、 ArgoCD の Plugin の仕組み、 Config Management Plugin (CMP) を使ってその制約を突破する。

Config Management Plugin (CMP)

ArgoCDは kustomize や Helm などのビルドに対応しているが、Plugin を使うことで任意の処理でマニフェストのビルドを行うことができる。

注意点として、 Plugin を有効化した Application では KustomizeやHelm オプションは使えなくなる。

実際の手順や使い方はここに詳しく書いてある。

argo-cd.readthedocs.io

ArgoCD2.5の時点では CMPの設定方法は二つある

  1. argo-cd ConfigMapに configManagementPlugins を追加する方法
  2. argocd-repo-server pod に plugin を sidecar コンテナとして追加する方法

1の方法は手軽だが実行可能なコマンドが argocd イメージあるもの(helm, kustomizeなど) に限られる。また ArgoCD2.5時点で非推奨になり2.6で消える。

argo-cd.readthedocs.io

よって、これから適用するには 2の方法しかない。

2の方法は マニフェスト生成に関するコマンド実行をsidecar コンテナ内で実行するため、1より安全でかつ必要に応じて任意のコマンドを実行できる。 ただし、sidecarコンテナの中に必要なコマンド類を全て入れておく必要がある。

このことを抑えておかないと、当初は kustomize すら実行できなくて結構困った。1の方式とは名前は同じだがほぼ別物だと思った方が良い。

sidecar の設定

sidecar コンテナは次の条件を満たす必要がある

  1. argocd-repo-server pod の中に sidecar イメージを差し込むこと
  2. /home/argocd/cmp-server/config/plugin.yaml ファイルがあり、init , generate などの項目が書かれていること
  3. 必要なコマンドやバイナリ、スクリプト類をimageに埋め込むこと

2,3については自分のイメージを作ってその中に入れ込んでもいいし、 ConfigMapや initContainer などを使ってVolume mount で実現してもよい。 このサンプルは後者の方法で行う。

まずは、 plugin.yaml の ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: cmp-plugin
data:
  plugin.yaml: |
    apiVersion: argoproj.io/v1alpha1
    kind: ConfigManagementPlugin
    metadata:
      name: kustomize-exec-plugin
    spec:
      version: v1.0
      init:
        command: ["sh", "-euxc", "plugin init for ${ARGOCD_APP_NAME} PR-${ARGOCD_ENV_PR_NUMBER}"]
      generate:
        command: ["kustomize","--enable-alpha-plugins", "--enable-exec", "build"]
      discover:
        find:
          command: ["sh", "-c", "echo $ARGOCD_APP_SOURCE_PATH | grep preview"]

Plugin では https://argo-cd.readthedocs.io/en/stable/user-guide/config-management-plugins/#environment にある通り、ARGOCD_APPで始まる環境変数のほか、 Application リソースで定義した任意の環境変数が、 ARGOCD_ENV_ の prefix が付与されて参照できる。

init は generate の前に実行されるコマンドで checkout した git repo の内容を変更したりするときに使う。サンプルとして単に変数を echo しているだけだが、 Application の kustomize オプションが使用できなくなるので、 kustomize suffix や commonLabels を使いたい場合はここで設定するといいだろう。

generate でマニフェストを出力する。 後述する exec plugin を有効化するためのオプションを指定して kustomize build を行う。

discover が一風変わっていて、 plugin を適用する条件を記述する。 fileName でglobパターンでファイルを指定するか find.command で実行するコマンドの return code が 0 の場合に plugin が実行されるという動作になる。

(正直なところ、こんな判定方法より 従来通り、plugin の名前で指定させて欲しいという気もするが、多分ArgoCDが KustomizeやHelmを判定する方法もこれに近いやり方なんだろう。)

ここでは環境変数 ARGOCD_APP_SOURCE_PATH (kustomize の実行パス) に preview が含まれているか、という条件としている。

次に sidecar イメージの準備。 sidecar で実行するコマンドは、任意のシェルスクリプトや envsubset, kustomize になる。 今回はカスタムイメージを作らずに用意するので、既存のイメージや ConfigMapなどを使って用意する。

まずはシェルスクリプト

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: cmp-script
data:
  patch.sh: |
    #!/bin/sh
    cat - | /home/argocd/scripts/envsubst

後で sidecar コンテナに入れ込むもので、標準入力を envsubst に渡している。

今更になって気づいたがこれくらいなら、そのままenvsubst を呼び出せば良かった気がする。。。

kustomize は ArgoCD イメージに含まれているのでそれを使わせてもらう。 envsubst は野良イメージよりはマシだろうということで nginx イメージを使ってみた。

上記をまとめると、次のようなマニフェストを argocd-repo-serverのdeploymentに埋め込むことになる。 kustomize で ArgoCD をビルドするなら patch で下記マニフェストをあてれば良い。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: argocd-repo-server
spec:
  template:
    spec:
      initContainers:
      ## ConfigMapのスクリプト、 nginx image 内部の envsubst を /home/argocd/scripts/ にコピーして、 sidecar イメージで使えるようにする。
      - name: envsubst
        image: nginx:stable # this is relyable image that includes envsubst. 
        command: ["/bin/sh","-c", "cp /usr/bin/envsubst /home/argocd/scripts/envsubst && cp /home/argocd/scripts_temp/*.sh /home/argocd/scripts && chmod +x /home/argocd/scripts/*"]
        volumeMounts:
          - mountPath: /home/argocd/scripts
            name: cmp-script-exe
          - mountPath: /home/argocd/scripts_temp
            name: cmp-script
      containers:
      # この辺りの書き方は https://argo-cd.readthedocs.io/en/stable/user-guide/config-management-plugins/#2-place-the-plugin-configuration-file-in-the-sidecar を参照。
      - name: cmp
        command: ["/var/run/argocd/argocd-cmp-server"] # Entrypoint should be Argo CD lightweight CMP server i.e. argocd-cmp-server
        image: quay.io/argoproj/argocd:v2.5.2 # kustomize を使いたいので argocd イメージを使っている。
        securityContext:
          runAsNonRoot: true
          runAsUser: 999
        volumeMounts:
          - mountPath: /var/run/argocd
            name: var-files
          - mountPath: /home/argocd/cmp-server/plugins
            name: plugins
          # Remove this volumeMount if you've chosen to bake the config file into the sidecar image.
          - mountPath: /home/argocd/cmp-server/config/plugin.yaml
            subPath: plugin.yaml
            name: cmp-plugin
          # Starting with v2.4, do NOT mount the same tmp volume as the repo-server container. The filesystem separation helps 
          # mitigate path traversal attacks.
          - mountPath: /tmp
            name: cmp-tmp
          - mountPath: /home/argocd/scripts
            name: cmp-script-exe
      volumes:
        - configMap:
            name: cmp-plugin
          name: cmp-plugin
        - emptyDir: {}
          name: cmp-tmp
        - emptyDir: {}
          name: cmp-script-exe
        - configMap:
            name: cmp-script
          name: cmp-script

これでインストール周りの設定は完了。うまくいけば argocd-repo-server Pod に cmp コンテナが sidecar として入っているはずだ。

ApplicationSet の作成

PullRequest に対応して プレビュー用の Application を動的に作成するための ApplicationSet を用意する。

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: preview-app
  namespace: argocd
spec:
  generators:
      - pullRequest:
          github:
            owner: <your org>
            repo: <your repo>
            tokenRef:
              secretName: <secret name>
              key: <API token( e.g. PAT)  secret key>
          filters:
          - branchMatch: "f*"
  template:
    metadata:
      name: 'app-sample-pr-{{number}}'
      labels:\
        preview: pr-{{number}}
    spec:
      project: sample-proj
      destination:
        namespace: sample
        server: https://kubernetes.default.svc
      source:
        path: application/sample/overlays/preview
        repoURL: https://github.com/<your org>/<your repo>.git
        targetRevision: '{{branch}}'
        plugin:
          env:
            - name: PR_NUMBER
              value: '{{number}}'
            - name: PR_BRANCH
              value: '{{branch}}'

PullRequest Generator は PullRequest に応じて Application を生成する仕組みで、 number, branch といった変数を使ってテンプレート化できる。

plugin の指定がポイントで、ここで Plugin の適用を設定し、 env で環境変数を設定している。 PullRequestのIDやブランチを環境変数で渡すことができる。

注意点としてここで設定した 環境変数は plugin側では 'ARGOCD_ENV_' prefixがつく、つまり PR_NUMBERARGOCD_ENG_PR_NUMBER になる。

preview用 manifest の設定

ここまで来れば 完成までもう少し。

上記の ApplicationSet に記述した application/sample/overlays/preview の下の kustomizaton.yaml には transformers を設定する。

transformers:
  - plugin.yaml

plugin.yaml の内容は次の通り。

apiVersion: kustomize-krm/v1alpha1
kind: Patch
metadata:
  annotations:
    config.kubernetes.io/function: |
      exec:
        path: /home/argocd/scripts/patch.sh
  name: preview_patch

ここで重要なのは、 アノテーションの config.kubernetes.io/function.exec.path に記載するスクリプトのパスのみで、 apiVersionやkind, name はk8sマニフェストの仕様を満たすためのダミーの値となっている。CRDなどは不要でどんな値でもよい。

pathに記載した コマンドに、 kustomize build で生成したマニフェストが標準入力経由で渡され、 コマンドが標準出力に出した内容が最終結果となる。 今回コマンドで行っているのは enbsubst の実行となる。

あとは、プレビュー向けのmanifestは ${環境変数} で置換したい値を書いておけばよい。

例えば Ingressの場合は、

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: test
spec:
  rules:
  - host: preview-${ARGOCD_ENV_PR_NUMBER}.example.com

のように書いておけば PullRequest のIDが host名に差し込まれるし、 nginx config なども、

apiVersion: v1
kind: ConfigMap
metadata:
  name: nignx-conf
data:
  nginx.conf: |
    http {
      server {
          listen       80;
          location /api/ {
            proxy_pass  http://sample-app2-${ARGOCD_ENV_PR_NUMBER}.${ARGOCD_APP_NAMESPACE}.cluster.svc.local:80;
          }

のように書くことができる。

まとめ

sidecar 型 plugin の仕組みを理解するまでは苦労したが、k8s の範囲内では PullRequestに応じた 動的なマニフェストがいい感じにできる予感がしてきた。 今回はenvsubst だが、やろうと思えば go template などの高度なテンプレート機能を差し込むこともできるはずだ。

k8s の範囲外の部分 (例えば、データベースやメッセージキューのようなクラウドリソースもPullRequestごとに作りたい) をどうするかという課題は残るが、結局のところこれらの外部リソースもプレビュー環境の生成・破棄とライフサイクルを合わせるために、 k8sマニフェストとして外部リソースを表現できないかということを考えている。 要は Operator として外部リソースを管理して、CRDでリソースの定義を設定すれば、今回の仕組みを使ってCRDにPullRequestの情報が埋め込める。

パブリッククラウドではすでにそういったOperator が存在しているので、いい線いってそうなアイデアではあるなと感じている。

github.com

cloud.google.com

これでも足りない部分は、 自分でOperatorを作るか、 ArgoCD の Resource Hook を使って外部リソースを作成する Job を作ればいいのではと思っている。

argo-cd.readthedocs.io

というわけで、夢に一歩近づけたと思うことにする。

Airflow の流れを制す

最近バックグラウンドのジョブスケジューラとして使用しているのが、 Apache Airflow だ。

https://airflow.apache.org/

Pythonで複数ジョブ(Operator)の依存関係をDAGとしてDSL的に書けるのは魅力的だが、 一方でスケジューラーとしては、UI付きのcronだと思っていたら相当なハマりどころを感じたので、 同じくハマりそうな人や将来の自分に向けてAirfronのスケジューリングの知見を残しておきたいというのが趣旨。

TL;DR

以下の通り、ちゃんと公式でも書いてあるが、正直なところ自分でやってみないとわかりづらく、 ちゃんと補足してある資料があったので紹介しておく。 自分で試しつつ、以下の資料と照らし合わせて自分の認識が一致したことを再確認した。

airflow.apache.org

towardsdatascience.com

Airflow の DAG は期間を処理対象にする

基本的にcron、あるいはJP1などのジョブスケジューラで処理をする時間を設定した場合、 その時刻になったらタイマーで何らかの処理が動くと想定すると思う。

Airflowの一応、DAGで @daily やあるいは cronで記述した時間になったら処理が動くのだが、 それはその時間で閉じたもっとも直近の時間帯に対してDAGを起動するといった感じで動く。

以下のような 2分間間隔で動くようなDAGを作って実際に試すのがわかりやすい。

from airflow import DAG
from airflow.operators.bash_operator import BashOperator
from datetime import datetime, timedelta


default_args = {
    "owner": "airflow",
    "start_date": datetime(2020, 5, 6),
    "email_on_failure": False,
    "email_on_retry": False,
    "retry_delay": timedelta(minutes=5),
    "depends_on_past": True,
    "wait_for_downstream": True,
}

dag = DAG("tutorial3", default_args=default_args, schedule_interval="*/2 * * * *")

t1 = BashOperator(task_id="print_date", bash_command="date", dag=dag)

t2 = BashOperator(task_id="test1", bash_command="exit 0", dag=dag)

templated_command = """
    {% for i in range(1) %}
        echo "ts={{ ts_nodash_with_tz }}"
        echo "exe_date={{ execution_date }}"
        echo "{{ dag.get_last_dagrun() }}"
    {% endfor %}
"""

t3 = BashOperator(
    task_id="templated",
    bash_command=templated_command,
    params={"my_param": "Parameter I passed in"},
    dag=dag,
)

t4 = BashOperator(task_id="test2", bash_command="exit 0", dag=dag)

t1 >> t2 >> t3 >> t4

これで何度かDAGを実行し、ちょうど 9:40分付近での実行状態のスクリーンショットを撮ったものが次のものだ。

f:id:kencharos:20200507094204p:plain

9:40時点で実行中のDAGがあり、これはcronに従って9:40に起動されたものとわかるが、 execution date には 9:38 と記載されていて、まるで一つ前の時間であるように見える。

別のUIで確認すると(スクショを撮るタイミングが遅れて終了してまったが)、 execution date が9:38 に対し、 staredが9:40:05 と実態に即した時間となっている。

f:id:kencharos:20200507094410p:plain

Airflowを始めて、最初にハマるのがこの execution_date が実際の一つ前の時間になっている問題だと思う。

実際の処理開始時刻は exectution_date の時間に schedule_interval (この場合 cronの2分) を足したものだという風に思えばいいし、実際そういう風に書いてある資料も多いのだけど、なぜそうなるのだろうか。

これは、Airflowが期間を意識していることと、UIの表現の悪さが関係してくる。

Airflowのスケジューラは DAGに定義した start_date から現在時刻までの間に 同じく DAGに定義された schedule_interval の従って、期間(period)を作っていく。
期間の開始となる時刻が execution_date であり、 終了となる時刻は execution_date + schedule_interval となる。
execution_date は DAGがいつ実行されたり、リトライされたりしても絶対に変わらない値となる。

Airflow はこの期間一つに対して、DAGのインスタンス一つを割り当てる。 そしてその割り当てるタイミングは、現在時刻に対して期間が閉じている、
つまり execution_date + schedule_interval < 現在時刻 となるものだ。

図にすると次のようになる。

f:id:kencharos:20200507102510p:plain

つまり、前述の execution_date が現在時刻の2分前となるというのは、 現在時刻(9:40:xx)から見て直近の閉じた期間のDAG(9:38-9:40)の開始時刻のみが UI に出ているためだ。
(正直なところ、UIには期間と実際の開始時刻をちゃんと出してもらえれば、そこまでの混乱はない気がする。)

期間に対して処理をするというAirflowのポリシー上、閉じた期間に対してしか処理できないというのは理にかなっているが、 単純なcronを期待して Airflowを導入すると少々痛い目に合うだろう。

Catchup

start_data から現在時刻までの閉じた期間一つに対してDAGインスタンスを割り当てるのが Airflow の基本である。 そのため、 以下のように 複数のDAGが未実行のまま残るという状態がありえる

  • DAGを初めて作って stard_date から現在時刻の間に、複数期間の未実行のDAGがある
  • pause というDAGの実行を止める仕組みを利用して、そのあと pause を解除した場合

この時、 DAG の Catchup の設定によって、過去分のDAGの実行の仕方が異なる。

  • catchup = True の場合、 未実行のすべてのDAG を逐次実行する
    • 上の図の例でいえば、 execution_dateが9:30から9:38の4つのDAGが対象になる
  • catchup = False の場合、未実行のうち、最新の DAG のみを実行する
    • 上の図の例でいえば、 execution_dateが9:38のDAGのみが対象になる

ちなみに Catchupのデフォルトは True で、これはDAGごとの設定か Airflowの設定ファイルの catch_up_default の設定で変更できる。 なので、基本的にはAirflow は未実行のDAGをすべて実行しきるつもりでいる。

これは、AIrflowがETLツールとしての性質を持っているからだろうと思われる。

DAGのコード中に、execution_date を context や マクロとして取得することが可能となっている。 上で示したコードの "ts_nodash_with_tz" や "execution_date" などだ。

templated_command = """
    {% for i in range(1) %}
        echo "ts={{ ts_nodash_with_tz }}"
        echo "exe_date={{ execution_date }}"
        echo "{{ dag.get_last_dagrun() }}"
    {% endfor %}
"""

t3 = BashOperator(
    task_id="templated",
    bash_command=templated_command,
    params={"my_param": "Parameter I passed in"},
    dag=dag,
)

execution_date は常に不変であることは先ほど述べたとおりなので、 DAGごとに期間に対しての取り込みデータの取得、つまり execution_date から execution_date + schedule_interval のデータを取得して処理するようにしていれば(取り込みファイルのタイムスタンプによるフィルタやDBのWhere条件の期間などに使う)、 何らかの理由で止まっていたスケジューラが再開されて、複数DAGが一気に動き出しても問題は起きないだろう。
(ただし、なるべくDAGの処理はべき等に作るべきだろう)

一方で、常に最新の情報だけ取り込めればよいというのであれば、catchup=False にして最新のDAGだけを動かすようにすればよい。
cron の代わりに使うのであれば、こちらのケースの方が多いのではないだろうか。

catcup=False にまつわるやらかしで多いのが、 pause 解除と同時にDAGが動き出す、というものだ。

0時を起点に3時間ごとに動くDAG(catchup=False)があり、 何らかの理由でAM 0:00からAM7:00 の間のジョブの実行を取りやめたい、 つまりAM9:00からDAGを実行したい。

そこで、pause 機能を利用して、 AM0:00 前くらいに 対象DAGのpause を実行する。
そして、AM7:00 に停止の時間帯が終わったからといって、pause を解除すると何が起きるか?

答えは、期間 3:00-6:00 のDAGがその場で実行される、である。 pauseしている間に未実行となっていたDAG(0:00-3:00, 3:00-6:00) のうち、最新のDAGがpause解除と同時に動き出した訳である。

普通のcronのつもりで、次回の実行タイミングから実行して欲しいと思って AM6:00を過ぎた時点で pauseを解除すればOKだろうと思ってたら面食らう挙動だが、Airflowのスケジュールの実行の仕組みとしては、全く正しい(辛い)。

つまり、AM 9:00からDAGを実行したいのであれば、 AM9:00を過ぎた時点でpauseを解除する必要がある(とても辛い)。

現在の時点で、pause解除の次回のタイミングからDAGを実行するという仕組みは Airflowにはなく、 運用でカバーするか、何らかの拡張を用意する必要があり、個人的には カスタム Operator を作ったらいいのではと思っている。

Airflow 1.10.10 から提供された LatestOnlyOperator は、複数DAGが現在実行中の場合に最新以外のDAGの下流タスクをすべてスキップする Operator だ。

airflow.apache.org

これを参考に、停止期間中であれば下流タスクをスキップするような仕組みが作れないかと思っている。

DAG間のタスク制御

Airflow はリトライの仕組みもよくできていて、DAGレベル・タスクレベルで、失敗タスクのリトライや先送り、強制キャンセル、強制成功などがUIやCLIでできる。

それはよいのだが、一方で前回のDAGのステータスによらず、後続のDAGインスタンスは実行され続ける。 つまり、DAGが失敗していたり、遅延で次回DAGの実行時間に到達してもまだ実行中などの場合に、次のDAGの実行を自動的に止める方法はない。

一方で(なぜか)タスク単位では抑止の手段がある。

  • depends_on_past = True -> 前回DAGの同じタスクが成功している場合のみ、今回DAGのタスクを実行する
  • wait_for_downstream = True -> 前回DAGの同じタスクの直系の子供のDAGが成功している場合のみ、今回DAGのタスクを実行する

そのため、これらのフラグを使えば、ある程度のDAGの実行制御は可能ではある。 しかし、これでDAGの実行が止まるのは、前回DAGの最初のタスクが失敗した場合のみくらいで、DAGの途中でタスクが失敗した場合はそこまでは実行されてしまう。

前述のDAGのサンプルで、最後のタスクを無理やり失敗させ、wait_for_downstream = True を設定して、しばらくスケジュールを回したものがこちらだ。

t4 = BashOperator(task_id="test2", bash_command="exit 2", dag=dag)

f:id:kencharos:20200507150652p:plain

DAGの最後のタスクが失敗しても、次のDAGは投入され続け、wait_for_downstream = True により、DAGごとに停止するタスクが一つずつ上に上がるようになっていく。
UIから失敗タスクのステータス変更などを行えば、停止しているDAGはまた流れ出す。

現状、DAGの開始を最初から確実に止めたい場合は、 試してはいないが ExternalTaskSensor などが使えそうではある。

python - Is it possible for Airflow scheduler to first finish the previous day's cycle before starting the next? - Stack Overflow

manually trigger

最後に、manually trigger の紹介をしておく。 Airflowでは UI あるいはCLIで DAGをその場ですぐに実行できる仕組みがある。

また、 schedule_interval 設定なしのDAGであれば、未来日に対するトリガーの指定も設定により、可能となっている。 ( https://airflow.apache.org/docs/stable/configurations-ref.html#allow-trigger-in-future )

この機能は、schedule_interval を設定しないDAGで使うものだろうが、 一応 schedule_interval を設定してあるDAGでも実行は可能で、DAGのスケジューリングとは独立して実行される。 (つまり、DAGのスケジューリングを先行して動かすというものではない)

ただし、UI上は統合されて見えるため、不思議な見え方をすることがある。

例として、2分間隔で動くDAGの合間に手動実行を行ってみる。

execution_date 15:34 のDAGが完了している状態で、15:37分に UIから手動実行を行う。

f:id:kencharos:20200507154701p:plain

手動実行のDAGは run_id に manual がつく、external_triggerがtrueになる他、execution_date が 現在時刻となる。

f:id:kencharos:20200507154902p:plain

そして、スケジューラが次のDAGを起動した瞬間、おかしなことが起こる。

f:id:kencharos:20200507155008p:plain

execution_date の設定がスケジューラでは閉じた期間の開始時刻、手動では現在時刻となるため、UI上では execution_date の昇順で並ぶので、順序が実際のDAGの投入順とはずれてしまう。

これは、他のUIでも起きてしまう。
こちらのUIでも手動実行したDAGの前に、後続のスケジューリングのDAGが並んでいる。

f:id:kencharos:20200507155255p:plain

AirflowのUIは実際の処理時間ではなく、DAGの期間順に並ぶので間違ってはいないのかもしれないが、それでも混乱する。 (DAGの実際の開始時間の表示と、開始時間のソートオプションが欲しい。Databaseのフィールドとしては存在している)

基本的には手動実行は、専用のDAGを実行してそちらで実行した方が良いだろう。

まとめ

  • Airflow は期間に対して動作する
  • UIが貧弱でAIrflowのコンテキストに合わせた読み替えが現在は必須
  • なるべくDAGは大きめに作りたくさんタスクを入れた方が、一定間隔で動かす方が間違いは起きづらい気がしている
  • スケジュールのDAGは線で、手動のDAGは点
  • 手動DAG は独立して用意する
  • DAGの抑止機能は作りこみが必要

問題ばっかりあげたが、DSLでかけるDAGや豊富なOperator, Sensor 類は魅力ではある。 競合となる Luigi や DigDag なども気にはなるが、乗り換えには勇気がいる。

これを乗り越えた先に幸せが待っているかは、一年後の自分が知るだろう

書籍「みんなのJava」を共同執筆、最近のフレームワークの紹介を書きました #minjava

「みんなのJava OpenJDKから始まる大変革期!」という本を共同執筆しました。

gihyo.jp

3/13日発売予定で、電子化の予定もあり gihyo.jp から購入するとDRMフリーです。

書籍について

ここ数年、Java に関する書籍はあまり出ておらず、 特にJava 11 以降 や OpenJDK のリリースモデル変更に関する最新情報などは、インターネットや情報雑誌などに頼らざるを得ない状況だったと思います。

Java の動向についてはリリースモデルの変更により、よりオープンになり継続的に改善が行われていくことになったのはとても良いと思います。
現職では AdoptOpenJDK のLTSであるJava11を使用していますが、特に問題は起きていませんし。 (リリースモデル変更時に色々と騒ぎになったのは、オラクル社のアナウンスが十分ではなかったとは今でも思っていますが。。。私もあの頃は調査で忙しかった覚えがあります)

とはいえ、次期LTSである Java 17 にはパターンマッチ、ヒアドキュメント、レコード型など魅力的な機能がてんこ盛りになるでしょうし(もしかしたらLoomも?) とても期待しています。 これはアップデートが2,3年に一度であったJava8 以前であれば考えられたなかったことです。 (もちろん、更新頻度が少なかったという部分に重きを置いていたところもあるだろうとは思いますが、アップデートがなくなる言語はいずれ死に体となる運命でしょうし)

「みんなのJava」は上記の内容のほか、ここ最近の Java を取り巻く変化とこれからについて書かれた久しぶりの新しいJava の書籍だと思います。 ぜひお手に取ってください。 (偶然にも同日にもう一冊、Javaの本が出版されるのですが、、)

執筆担当部分について

私の担当は 第6章の[新世代]軽量フレームワーク入門 で、ここ数年で登場した新しいフレームワーク、 Micronaut, Quarkus, Helidon についての記事を書きました。

2019年の春頃に著者のひとりのきしださんから新しい Java の本を書きたいので、 Helidon, Micronaut, Quarkus あたりの内容を担当してほしいとの DM が突然届き、 どうして私なんだろう? と思いつつも承諾しました。

一応、Micronaut についてはGAから3日後に紹介スライドをあげたり(1)、 GraalVM native image + AWS Lambda を試したり(2)していたので、 他のフレームワークも同じように手を動かしつつ、実際にその感触を確かめて記事にしてみたつもりです。

(1)

www.slideshare.net

(2)

qiita.com

1については、まだGraalVM native image が出る前だったので、軽量とはなったとはいえ Lambdaは厳しいかも、 からの2で、native image でJavaでもいけるかもみたいな変化があったので、 GraalVM の盛り上がりも見逃せないですね。

記事で解説に使用したソースコードは 下記の Github で公開しています。 執筆時のバージョンのものと、現在の最新バージョンに追従したもの、両方ともあります。

github.com

特に後者は、GraalVM 19.3r11 にも対応していて、Qurakusでも Java11 で natvie-image も生成できるものとなっています。

"軽量"フレームワークの軽量とは、今回の記事では起動速度やモジュールのサイズが従来よりも小さいという文脈で使っています。 起動が早いにも関わらず、機能も豊富であり (特に Micronaut, Quarkusは) 実用的なアプリケーションを作るのに十分な機能を備えていると思いました。 その仕組みについても解説しています。

フレームワークの紹介記事は、だいたい Getting Start +α な内容に留まりがちですが、 編集部からはより実用的な内容がほしいと要望があったので、私なりに実用的な内容を考えて、 ヘルスチェック、メトリクス、分散トレーシング、コンフィグレーションなど、アプリケーションの基盤的な機能を各フレームワークで設定するにはという観点で書いてみました。

というのも、ここ1,2年で感じたことですが、クラウドやコンテナの流行によりサーバーはなるべく単純化してアプリケーションを動かす箱となり、 従来であればサーバー側でやっていたような死活監視やメトリクス集計などの非機能要件に関する内容も、 フレームワーク側の機能として提供されているなと思ったからです。

また、フレームワークが提供する機能の変化も、最近の技術トレンドの変遷によるものなので、フレームワークの紹介と一緒に技術トレンドの変遷についても書きました。

というわけで私が担当した章は上記のことを考えて書いてみました。興味があればぜひお手にとってください。

あとがきのようなもの

それにしても、フレームワークのアップデートは速く、どうしても最新情報から遅れてしまいますね。 誌面には書けなかった最近のアップデートについて軽く触れておきます。

執筆期間はだいたい半年ほどでしたが、執筆開始当初は Quarkus がここまで流行になるとは思っていなかったですね。 初稿を出した後に、 Quarkus 1.0.0 が出た時はどうしようと思いました。
1.2.0 から GraalVM19.3r11 にも対応したので、 Java11で開発もnative image 生成もできるようになりました。
Extensionの充実度合いが執筆時から比べても段違いなので、今後注目の的になっていくだろうと思います。

Micronaut も 1.3 から Spring Data のような永続化レイヤーの Micronaut Data や、Kotlinコルーチン対応、GraphQL など、 Spring のように実用的な機能や開発効率の高いものんをどんどん取り込んでいます。

現時点は上記2つに押されがちに見える Helidon ですが、時期メジャーバージョンの Helidon 2.0 は Helidon SE へのDBクライアントや Helidon MP への native-image 対応など、より実用的なフレームワークとなるべく開発が活発に進んでいるようです。 個人的は Helidon SE には注目していて、サイズが小さかったり、プログラマブルにエンドポイントを構成できたりする性質は、高機能なリバースプロキシを作るのに向いているなと思ったりもしています。Helidon SE でサービスメッシュみたいなを作るのも面白そうだなと思います。

consul connect, L7 traffic management, nomad consul connect を試す(2)

前回の続きから。

ここから行なっていく手順も前回の consul connect を構築した状態から再開です。

今回は、 consul 1.6 から追加された L7 traffic management と、 nomad 0.10 の consul connect integration を試すものになる。

consul connect は envoy の他に組み込みプロキシなども使用できるから、この先以降は現時点では envoy 限定の機能となってくる。 実現している機能を考えれば、これは確かに consul 内で実装するのは相当な手間だろうとは思う。

Consul L7 traffic management

概ねこの辺のガイドなどを参考にしている。

www.consul.io

www.consul.io

最初にいろいろ眺めていても全然理解できなかったのだけど、読んだり試したりしていてなんとなくどういうことをするものかがわかってきた。

L7 traffic management は config entry という機能を使って、サイドカーへ適用するする設定を与えることで実現していく。

config entry は Kind と Name 属性と Kind ごとに個別の情報を持つ、JSONまたはHCLファイルで、 Kindが設定の種類、 Nameが設定を適用するサービス名となる(全体の設定を示す、Globalもある)

service-defaults

まずは、Kind=service-defaults で、サービスごと proxy の設定を L4(TCP) から L7(http) に切り替える config を 各サービスごとに設定する。

# service_default.hcl
Kind = "service-defaults"
Name = "service-a"
Protocol = "http"
# service_default2.hcl
Kind = "service-defaults"
Name = "service-b"
Protocol = "http"

このファイルを、 config consul write コマンドで投入する。

consul config write l7/service_default.hcl
consul config write l7/service_default2.hcl

service-router

次が、L7の振り分けを設定する Kind, "service-router" の設定となる。

service-router を設定すると、 今まで単一のサービスにしか接続できなかった upstream のコネクションが、 URLやHTTPヘッダなどの内容に応じて、複数のサービスにルーティングできるようになる。

これの恩恵が受けやすいのが gRPCだろう。

gRPCは、コネクションを サーバーアドレス+ポートで生成して、PRC処理ごとにコネクションを使い回す。 その際に service-router を使うことで、サイドカーへの単一のコネクションだけで複数のサービスに接続可能にできる。 これが、L4レベルプロキシだとサービスごとに異なるポートで異なるコネクションが必要となってしまう。

今回のサンプルはこれを模したルーティングを行なってみる。

(この方法が正しいのかはわからないが) service-a の sidecar の upstream の接続先を自分自身に向けて再設定する。

{
    "ID": "service-a",
    "Name": "service-a",
    "Address": "127.0.0.1",
    "Port": 3000,
    "Meta": {
      "version": "v1"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3000/health",
      "Interval": "10s"
    },
    "Connect": {
        "SidecarService":{
            "Proxy":{
                "upstreams": [
                  {
                    "destination_name": "service-a",
                    "local_bind_port": 9000
                  }
                ]
            }
        }
    }
  }
curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_a_in_l7.json

次に、以下の内容で service-router を作成し config を適用する。

# service_a_router.hcl
kind = "service-router"
name = "service-a"
routes = [
  {
    match {
      http {
        path_prefix = "/hello_b"
      }
    }
    destination {
      service = "service-b"
    }
  },
  # NOTE: a default catch-all will send unmatched traffic to "service-a"
]
consul config write l7/service_a_router.hcl

この内容は、service-a のサイドカーに来た "/hello_b" へのリクエストは、service-b に向け、それ以外はデフォルト(service-a)に向けるという意味になる。

この状態で、service-a にアクセスしてみる。

curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}

無事に、service-a に向かう upstream 出会っても、特定のパスなら service-bに向けれたことが確認できた。 gRPCでも同様に動くだろう。

この時、service-a の envoy は次のように設定されている。 抜粋で示す。

    {
     "version_info": "00000009",
     "listener": {
      "name": "service-a:127.0.0.1:9000",
      "address": {
       "socket_address": {
        "address": "127.0.0.1",
        "port_value": 9000
       }
      },
      "filter_chains": [
       {
        "filters": [
         {
          "name": "envoy.http_connection_manager",
          "config": {
           "tracing": {
            "random_sampling": {},
            "operation_name": "EGRESS"
           },
           "http_filters": [
            {
             "name": "envoy.router"
            }
           ],
           "rds": {
            "route_config_name": "service-a",
            "config_source": {
             "ads": {}
            }
           },
           "stat_prefix": "upstream_service-a_http"
          }
         }
        ]
       }
      ]
     },
     "last_updated": "2019-12-17T05:11:49.438Z"
    },

  {
   "@type": "type.googleapis.com/envoy.admin.v2alpha.RoutesConfigDump",
   "dynamic_route_configs": [
    {
     "version_info": "00000009",
     "route_config": {
      "name": "service-a",
      "virtual_hosts": [
       {
        "name": "service-a",
        "domains": [
         "*"
        ],
        "routes": [
         {
          "match": {
           "prefix": "/hello_b"
          },
          "route": {
           "cluster": "service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul"
          }
         },
         {
          "match": {
           "prefix": "/"
          },
          "route": {
           "cluster": "service-a.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul"
          }
         }
        ]
       }
      ],
      "validate_clusters": true
     },
     "last_updated": "2019-12-17T05:11:49.440Z"
    }
   ]
  }

まずは、リスナーについて HTTPモードでの接続を示す、 http-connection-managerが使われるようになった。 これは proxyの設定を http に変更したためだ(ただし、 service-routerの設定を行うまでは tcpのままだったので、必要となるまではなるべくTCPのままでやろうとするのだろう)

このリスナーには rds(envoyの動的なルーティング設定) が設定されていて、その設定先である route_config には、service-router で設定した通りのルートが設定されているのが見て取れる。

つまりは、 consul config の適用により、Kindに合わせて envoy の設定を xDSで変更していくのが、L7 traffic management の裏側だ。

service-splitter, service-resolver

service-router がリスナー側(入口側)の設定なら、 service-splitter, service-resolver はクラスター側(出口側, upstream側) の振り分け設定だ。 service-b の2つのサービスへの振り分けは均等だったが、これらを使うことで配分を変えることができる。

実は service-b2つのサービスにはそれぞれ、 versionという meta情報を付与していて、それぞれ v1, v2 という内容を設定してある。 (前回の記事の service-bの設定ファイルを見ると書いてある)

meta情報など様々な条件を使って同一のサービスであっても振り分け先ごとにグルーピングできる、これを subset と呼ぶようになっていて、 service-resolver は subset の設定を行う kind だ。

#service_b_resolver.hcl
kind           = "service-resolver"
name           = "service-b"
default_subset = "v1"
subsets = {
  "v1" = {
    filter = "Service.Meta.version == v1"
  }
  "v2" = {
    filter = "Service.Meta.version == v2"
  }
}

これは サービスの meta情報の値によって、v1, v2 という subset を設定している。

https://www.consul.io/docs/agent/config-entries/service-resolver.html を見ると、別サービスへの振り分けなどもできるようだし、色々な使い道がありそう。

service-splitter はサブセットごとに振り分ける量を設定するものになる。

#service_b_splitter.hcl

kind = "service-splitter"
name = "service-b"
splits = [
  {
    weight         = 90
    service_subset = "v1"
  },
  {
    weight         = 10
    service_subset = "v2"
  },
]

これは、90:10 で v1 と v2 の振り分け量を設定していることになる。

これらの config を設定してみる。

consul config write l7/service_b_resolver.hcl
consul config write l7/service_b_splitter.hcl

テストしてみる。

$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(2) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}

明らかに、service-b_2 にはつながりづらくなった。

さて、同じく envoy の設定を覗いてみよう。

     "route_config": {
      "name": "service-b",
      "virtual_hosts": [
       {
        "name": "service-b",
        "domains": [
         "*"
        ],
        "routes": [
         {
          "match": {
           "prefix": "/"
          },
          "route": {
           "weighted_clusters": {
            "clusters": [
             {
              "name": "v1.service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul",
              "weight": 8500
             },
             {
              "name": "v2.service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul",
              "weight": 1500
             }
            ],
            "total_weight": 10000
           }
          }
         }
        ]
       }

service-b のルート設定に、 weighted_clusters という設定が増えていて、service-splitter で設定した重みが設定されていることがわかる。

振り分け先のクラスタも、v1, v2 で分かれている。 subset は、envoy上では単純なクラスタ定義となっていることがわかる。

splitter によりconsul上で1つのサービスも、envoy上では分かれている。これはとても参考になる実装だと思う。

というわけで、L7 traffic management を試した。 envoy の xDS コントロールプレーンを実装した自分にとって、consul の xDS 実装は非常に参考になる部分が多い。

簡易的な isito として使える。

nomad

続いて、 nomadnomad の consul connect 連携を試す。

これを使うと、docker container を1つずつあげて、手動で consul に登録する必要はないし、 サイドカーの立ち上げも不要になる。
さらに、 host ネットワークではなく、 nomad が管理しているコンテナネットワーク内でコンテナ間通信を行うようにもできる。

今までの手順で起動した Docker コンテナは全て落とし、 consul からもサービスを全て削除する。

consul services deregister --id=service-a
consul services deregister --id=service-a-sidecar-proxy
consul services deregister --id=service-b
consul services deregister --id=service-b_2
consul services deregister --id=service-b-sidecar-proxy
consul services deregister --id=service-b_2-sidecar-proxy

docker stop service_a service_b service_b_2 sidecar_a sidecar_b sidecar_b_2

これから行う手順は概ね、次のガイドに従って行ったものになる。

www.nomadproject.io

さらりと、 consul へのパスが通っていることや、 nomad は root 権限で起動することなどハマりポイントが書いてあるので注意が必要だ。

CNI (Container Network Interface, https://github.com/containernetworking/cni ) plugin のインストールが必要となり、 またカーネル設定の一部変更も必要となる。 nomad は CNI経由で iptable を操作して、サイドカーと docker コンテナを localhost で通信可能にしている。

( CNI についてはいまいち理解不足だが、 k8s のネットワーキングでも使っているコンテナ間のネットワーク設定のための仕様であり、 network namespace をいい感じに作って、コンテナにアタッチするみたいなものだろうか)

# CNI plugin
curl -L -o cni-plugins.tgz https://github.com/containernetworking/plugins/releases/download/v0.8.1/cni-plugins-linux-amd64-v0.8.1.tgz
sudo mkdir -p /opt/cni/bin
sudo tar -C /opt/cni/bin -xzf cni-plugins.tgz

以下の設定ファイルを、"/etc/sysctl.d/99-sysctl.conf" などに作って、 sudo sysctl -p

net.bridge.bridge-nf-call-arptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1

この状態で、 nomadルート権限で起動する。

nomad では docker コンテナを、 ジョブ定義ファイルを作って投入する。k8sでいう所の、deploymentSet だ。

今までに作ってきた、service-a, service-b 2つのジョブ定義ファイルを作ってみる。

#service_a.job
job "service_a_job" {
  datacenters = ["dc1"]
  group "service-a" {
    count = 1
    network {
      mode = "bridge"
      # this setting host network 3000 forward to bridge network 3000.
      port "http" {
        static = 3000
        to     = 3000
      }
    }
    service {
      name = "service-a"
      port = "3000"
      connect {
        sidecar_service {
            proxy {
                upstreams {
                    destination_name = "service-a"
                    local_bind_port = 9901
                }
          }
        }
      }
    }
    task "service-a" {
      driver = "docker"
      env {
          #. Note that dashes (-) are converted to underscores (_) in environment variable
        SIDECAR_URL = "http://${NOMAD_UPSTREAM_ADDR_service_a}"
      }
      config {
        image = "<your_registory>/service_a"
      }
      resources {
            memory = 100
      }
    }
  }
}

nomad 0.10 から、最上位のgroup 節 に network 節が書けるようになった。 以前では、nomad にはコンテナネットワークを管理する機能はなく、事前に自分で docker network や vxlan を構成しておく必要があったが、 今回からは nomad 独自のコンテナネットワークが暗黙的にあるようだ。 このネットワークに外部からブリッジするポートとして静的に3000ポートを指定している。

また、service 節に connect 節も書けるようになった。 consul connet と同様の upstream の設定を行なっている。 前述の L7 traffic manager とも併用可能なので、自分自身に upstream を向けている。
また、service節では他に、 network で設定した 3000版ポートを、service-a に流すように port を設定している。

task 節の env でコンテナの環境変数を与えている。 http://${NOMAD_UPSTREAM_ADDR_service_a} のように、nomad が管理している属性値を実行時に与えることができる。ちなみに ${NOMAD_UPSTREAM_ADDR_service_a} は実際には、 localhost:9901 であり、 nomad が CNI を使って独自のコンテナネットワークを行なっていることがわかった。

というわけで、同様に service-b のジョブ定義ファイルも作成する。

# service-b.job
job "service_b_job" {
  datacenters = ["dc1"]
  group "service-b" {
    count = 2
    network {
      mode = "bridge"
    }
    service {
      name = "service-b"
      port = "3001"
      connect {
        sidecar_service {}
      }
    }

    task "service-b" {
      driver = "docker"
      env {
        APP_ID = "${NOMAD_ALLOC_ID}"
      }
      config {
        image = "<your-registory>/service_b"
      }
      resources {
          memory = 100
      }
    }
  }
}

ちなみに、docker イメージは Docker レジストリにpush しておく必要があり、ローカルの docker image は使用できない。 ただし、一度 tar に出力することでtar からロードも可能となっている。 その辺の方法は、github の全量のソースを参照してほしい。

こららのファイルを nomad に適用すると、 noamd は (今回はサーバーは1つしかないが) nomad エージェントを導入している各サーバーから、 docker コンテナを起動するサーバーを探して割り当てる。

nomad job run nomad/service_a.job
nomad job run nomad/service_b.job

consul を見てみると、 nomad アイコンのついた、service-a, service-bとそのサイドカーがサービスとして登録されていることがわかる。 このように、nomad は consul に 自身が起動したコンテナの情報を登録し、ヘルスチェックなどを移譲している。

f:id:kencharos:20191217200537p:plain

nomad を見てみると、サービスを3つ起動していることがわかる。

f:id:kencharos:20191217200932p:plain

service-a ジョブの内容を見てみると、サイドカーコンテナも一緒に起動し、 3000ポートが service-aに割り当ててあることがわかり、

f:id:kencharos:20191217201123p:plain

service-bには ポートが割り当てられてない(外部からアクセスできない) ことがわかる。

f:id:kencharos:20191217201331p:plain

サイドカーのポートは公開されているように見えるが、サイドカーへのアクセスは mTLSのため、クライアント証明書が必要なので、実質的に外部からアクセスできない。

では、疎通できるか試してみる。

$ curl localhost:3000/hello_a
{"message_b":"service_b(a7885ab8-cef6-5aed-7c25-b7c5978bcd3f) up at Tue, 17 Dec 2019 11:42:00 GMT","message_a":"service_a up at Tue, 17 Dec 2019 11:43:11 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(89984200-a56b-5696-2ee3-c5fa19512145) up at Tue, 17 Dec 2019 11:42:00 GMT","message_a":"service_a up at Tue, 17 Dec 2019 11:43:11 GMT"}

service_b のIDには、nomad のコンテナごとにユニークに振られる allocation id を設定していて、ちゃんと分散してアクセスしていることがわかる。

(なお、consul からの続きでこの手順を試してたらうまく動かなかなったので、一度 consulのtmpファイルを破棄してから再起動し、 サイド L7 traffic manager の config を再設定した。 そのため、nomad の手順を実行する際に、サービスだけでなく、L7 traffic manager の設定も全て消したほうがよかったと思われる。)

というわけで、 nomad と consul connect を使うことで、サイドカーの設定を設定だけで追加でき、独自ネットワークでの動作やポート管理からも解放された。

簡易的な k8s, istio として使えそうだ。

ちなみに、 docker ps した結果は次のようになった。

CONTAINER ID        IMAGE                                      COMMAND                  CREATED             STATUS              PORTS                    NAMES
a915b780f123        service_a                                  "docker-entrypoint.s…"   8 minutes ago       Up 8 minutes                                 service-a-c4cd69c1-50ac-981f-2748-2b53a176d106
cf072b8f461c        envoyproxy/envoy:v1.11.2                   "/docker-entrypoint.…"   8 minutes ago       Up 8 minutes                                 connect-proxy-service-a-c4cd69c1-50ac-981f-2748-2b53a176d106
9d51d11c526f        gcr.io/google_containers/pause-amd64:3.0   "/pause"                 8 minutes ago       Up 8 minutes                                 nomad_init_c4cd69c1-50ac-981f-2748-2b53a176d106
662baf7eab4a        service_b                                  "docker-entrypoint.s…"   10 minutes ago      Up 10 minutes                                service-b-89984200-a56b-5696-2ee3-c5fa19512145
265d8defce94        service_b                                  "docker-entrypoint.s…"   10 minutes ago      Up 10 minutes                                service-b-a7885ab8-cef6-5aed-7c25-b7c5978bcd3f
53481e6f6282        envoyproxy/envoy:v1.11.2                   "/docker-entrypoint.…"   10 minutes ago      Up 10 minutes                                connect-proxy-service-b-a7885ab8-cef6-5aed-7c25-b7c5978bcd3f
befc5e30e23d        envoyproxy/envoy:v1.11.2                   "/docker-entrypoint.…"   10 minutes ago      Up 10 minutes                                connect-proxy-service-b-89984200-a56b-5696-2ee3-c5fa19512145
f798256ae7e7        gcr.io/google_containers/pause-amd64:3.0   "/pause"                 10 minutes ago      Up 10 minutes                                nomad_init_89984200-a56b-5696-2ee3-c5fa19512145
565e75715f36        gcr.io/google_containers/pause-amd64:3.0   "/pause"                 10 minutes ago      Up 10 minutes                                nomad_init_a7885ab8-cef6-5aed-7c25-b7c5978bcd3f

自分のイメージと サイドカーenvoy 以外に、謎の pause-amd64 とかいうコンテナも立っていたり、CNI経由のためか コンテナがどのネットワークにも属してないなど、 nomad のシンプルさが若干失われたような気がした。

まとめ

L7 traffic management はいい感じだった。 mesh gateway を使うことで、マルチクラウドやオンプレ、k8sなんかともいい感じに連携できそうだ。 適用したconfigの内容が UI とかで参照できるともっといいんだけどね。

nomad の consul connect は可能性は感じるが今の所まだ荒削りだなと思った。 CNIによるネットワーキングは便利だが、果たしてそれは nomad に求めていたことなのかと思ったり、 ルート権限いるの? とか、envoy をもう少し自由にいじりたいとか、まだ洗練される余地があるような気がしている。

とはいえ、オンプレでも使えるサービスメッシュ、コンテナクラスタとしては魅力的なので、今後もアップデートを見守っていきたいと思う。

consul connect, L7 traffic manager, nomad consul connect を試す(1)

consul には connect という consul 管理下のサービス間のmTLSによる接続を管理する connect という機能がある。 サービス間接続には 組み込み Proxy や envoy を使い、いわゆるサービスメッシュのようなことができる。

www.hashicorp.com

consul connect は L4レベルのプロキシであり、また nomad には対応してなかった。 そのため、connect を使うことを断念し envoy を自前でどうにかするようなことをしていたのだけど、 ちょっと前に consul, nomad それぞれのバージョンアップで consul 1.6 には L7レベルのルールを設定可能な L7 traffic manager が追加され、 nomad 0.10 には CNI (container network interface) を使用した consul connect 統合が追加された。
(consul 1.6 には もう1つ 別ネットワークのconsulへのプロキシとして動く mesh gateway も追加されたが今回は割愛)

勉強がてらこれらの機能を使ってみる。試したソースはこちらにある。

https://github.com/kencharos/consul-connect-nomad

consul, nomad それぞれ 簡易化のために 1ノードでサーバーとエージェント両方を構成する設定が含まれているので、 それを起動してから各種手順を実行していく。
nomad は途中で Linuxでしか動かない機能を使用していくので、 Vagrantfile も用意してある。

consul connect

まずは基本の consul connect から。概ね 以下のチュートリアルに沿った内容となっている。

learn.hashicorp.com

service-a と service-b という2つのWebアプリケーションを用意し、service-a から service-bのエンドポイントを呼び出すというような形をとる。

どちらも単純な node.js, express のアプリケーションで作る。 service-a には環境変数として service-b のURLが渡るような作りになっている。

# service-a
const port = process.env.PORT || 3000;
const sidecarUrl = process.env.SIDECAR_URL || "http://localhost:3001";
const up = new Date().toUTCString()

app.get('/hello_a', (req, res) => {
    console.log(sidecarUrl)
    fetch(sidecarUrl + "/hello_b")
        .then(r => r.json())
        .then(data => res.send(Object.assign(data, {message_a:"service_a up at " + up})))
        .catch(e => {console.log(e); res.sendStatus(500); })
});
#service-b
const port = process.env.PORT || 3001;
const id = process.env.APP_ID || "1";
const up = new Date().toUTCString()

app.get('/hello_b', (req, res) => {
    res.send({"message_b":`service_b(${id}) up at ${up}`})
});

これらをDockerイメージにしておき、hostネットワーク上で起動する。 service-a は1つ、service-bは connect経由で負荷分散させたいので2つ起動する。 service-aに与える接続先URLは後から起動するサイドカープロキシのURLになっている。

docker run --rm --name service_a -e "SIDECAR_URL=http://localhost:9000" --network host -d service_a
docker run --rm --name service_b -d --network host  service_b
docker run --rm --name service_b_2 -e "PORT=3002" -e "APP_ID=2" -d --network host  service_b

これらのプロセスを consul に登録することで、consul上でサービスとして登録でき、ヘルスチェックの設定や、conusl を複数サーバーで動かす場合は各サーバーのconsul で動いているサービスの情報が収集され、全サーバーで稼働しているサービスの状態が把握できるようになる。

例えば service-a は次のようなファイルを作って、consul cli で登録する。

{
    "ID": "service-a",
    "Name": "service-a",
    "Address": "127.0.0.1",
    "Port": 3000,
    "Meta": {
      "version": "v1"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3000/health",
      "Interval": "10s"
    },
    "Connect": {
        "SidecarService":{
            "Proxy":{
                "upstreams": [{
                    "destination_name": "service-b",
                    "local_bind_port": 9000
                }]
            }
        }
    }
  }

"service-a" のホストやポート、ヘルスチェックの他に、 connect という節がある(connect を使わない場合は書かない)。
これは "service-a" のサイドカーproxyには 9000ポートで "service-b" 行きのリスナーを作ってという指示になる。

2コンテナで上げた "service-b" についても同様にコンテナごとにファイルを作る。

{
    "ID": "service-b",
    "Name": "service-b",
    "Address": "127.0.0.1",
    "Port": 3001,
    "Meta": {
      "version": "v1"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3001/health",
      "Interval": "10s"
    },
    "connect": {
        "sidecar_service":{
        }
  }
}
{
    "ID": "service-b_2",
    "Name": "service-b",
    "Address": "127.0.0.1",
    "Port": 3002,
    "Meta": {
      "version": "v2"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3002/health",
      "Interval": "10s"
    },
    "connect": {
        "sidecar_service":{
        }
  }
}

"service-b" は IDは異なるが、Nameは同じサービスという風にしてある。また、connect 節でサイドカーproxyは設定しているが、 外部に出て行く通信がないため、特に他サービスへの設定はない。

これを consul cliREST API で登録する。

curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_a.json
curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_b.json
curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_b2.json

とりあえず、3つのサービスを登録した直後の consul の状態は次のようになる。

f:id:kencharos:20191216235732p:plain
service-a,b の登録後

サービス登録時にヘルスチェックの設定もしたのでサービス1つにつき2つのチェックが設定されるので、 "service-a" のヘルスチェックは2, "service-b" のヘルスチェックはサービスが2つあるので4 となっている。

各サービスに sidecar-service というサービスがあることがわかる。 これがconsul connect で管理されるサイドカーproxyなのだが、自分でやる場合は別途、サイドカーproxyを起動して consul に登録しないといけない(面倒)。

というわけで、今回は envoy を使ってサイドカーを登録する。

envoy を所定の定義ファイルを与えて起動さえできれば、 consul connect コマンドで登録可能ではあるが、 色々と煩雑なのでconsulコマンドと envoy をdocker イメージにまとめる手段が上記のチュートリアルに紹介されている。

次のようなDockerfile を作って、 consul-envoy という名前の docker イメージを作っておく。

# https://learn.hashicorp.com/consul/developer-mesh/connect-envoy
# docker build -t consul-envoy .
# use with --init option
FROM consul:latest
FROM envoyproxy/envoy:v1.11.2
COPY --from=0 /bin/consul /bin/consul
ENTRYPOINT ["consul", "connect", "envoy"]

このイメージをサービス数分起動して行く。起動する際に consul connect envoy コマンド https://www.consul.io/docs/commands/connect/envoy.html の引数でどのサービスのサイドカーなのか、admin port を使うかなどを指定するのがポイントとなる。

先に起動した3つのサービスそれぞれに、envoy サイドカーを起動して割り当てる。

docker run --init --rm -d --network host --name sidecar_a consul-envoy -sidecar-for service-a -admin-bind 0.0.0.0:19000 
docker run --init --rm -d --network host --name sidecar_b consul-envoy -sidecar-for service-b -admin-bind 0.0.0.0:19001 
docker run --init --rm -d --network host --name sidecar_b_2 consul-envoy -sidecar-for service-b_2 -admin-bind 0.0.0.0:19002

これで、サイドカーも無事起動し、consul のヘルスチェックも全て通るようになる。

f:id:kencharos:20191217001145p:plain

service-a サイドカーの情報を見てみると、どのサービスのサイドカーなのかや、upstream(接続先のサービス)のポートなどが記載されている。

f:id:kencharos:20191217001447p:plain

service-a を起動した時にすでにSIDECAR_URL に localhost:9000 を割り当てていたので、これですでに疎通はできるはずなのでやってみよう。

$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Mon, 16 Dec 2019 14:35:49 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

$ curl localhost:3000/hello_a
{"message_b":"service_b(2) up at Mon, 16 Dec 2019 14:42:29 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Mon, 16 Dec 2019 14:35:49 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

$ curl localhost:3000/hello_a
{"message_b":"service_b(2) up at Mon, 16 Dec 2019 14:42:29 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

"service_b" からの応答メッセージには、2つ起動したservice_bどちらからかの応答かがわかるように, 1か2 のIDが振ってある。 9000番ポート経由のservice_b への通信に対して、 service-bと service-b_2 への通信が均等に割り振られているので、 サイドカーenvoyが適切にリクエストを振り分けていることがわかる。

また、次のように consul intension で サービス間通信の許可・拒否を登録可能だ。 次のようにすると、 service-bへの通信は遮断される。

$ consul intention create -deny service-a service-b
Created: service-a => service-b (deny)

$ curl localhost:3000/hello_a
Internal Server Error

これらの制御はどのようになっているかが気になったので、envoyの管理コンソールを見てみる。 管理コンソールはenvoy コンテナを起動する際に 1900x ポートで起動するように設定してある。

envoy の コンフィグダンプの全量は gist https://gist.github.com/kencharos/459aacbf41e5d7cb6fdc45ac29ca73a8 を見てもらうとして、 大事な部分を抜粋する。

まずは service-a のリスナー1

"listener": {
      "name": "public_listener:127.0.0.1:21000",
      "address": {
       "socket_address": {
        "address": "127.0.0.1",
        "port_value": 21000
       }
      },
      "filter_chains": [
       {
        "tls_context": {
         "common_tls_context": {
           "割愛"
         },
         "require_client_certificate": true
        },
        "filters": [
         {
          "name": "envoy.ext_authz",
          "config": {
           "stat_prefix": "connect_authz",
           "grpc_service": {
            "envoy_grpc": {
             "cluster_name": "local_agent"
            },
            "initial_metadata": [
             {
              "key": "x-consul-token"
             }
            ]
           }
          }
         },
         {
          "name": "envoy.tcp_proxy",
          "config": {
           "stat_prefix": "public_listener_tcp",
           "cluster": "local_app"
          }
         }
        ]
       }
      ]
     }

envoy 自体は 21000ポートで起動し、中身は割愛するが クライアント認証ありのTLS接続で受け付けるようになっている。 connect プロキシ間の通信は全て mTLSとなっていて、証明書は consul がルートCAとなって発行するようになっている。

また、ext_authz フィルタを設定し、接続を受け付けたらlocal_agent に転送するようになっている。 local_agent は 8502番ポートで、 consul のgRPC ポートになっていて、ここで intention などのサービス間接続の許可・拒否を行なっていると思われる。 これは、 SPIFFE や OPA と近いアプローチだと思う。

そのあとは、 tcp_proxy フィルタで、 local_app へ転送している。 local_app は 最初に起動した service-a のdocker イメージとなっているので、 このサイドカープロキシは、21000で受け付け -> consul へ認証移譲 -> 本来のサービスへ転送 という処理をしている。

このプロキシにはもう1つリスナがある。

"listener": {
      "name": "service-b:127.0.0.1:9000",
      "address": {
       "socket_address": {
        "address": "127.0.0.1",
        "port_value": 9000
       }
      },
      "filter_chains": [
       {
        "filters": [
         {
          "name": "envoy.tcp_proxy",
          "config": {
           "stat_prefix": "upstream_service-b_tcp",
           "cluster": "service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul"
          }
         }
        ]
       }
      ]
     },

9000番ポートで立っていることからわかる通り、これは service-a を登録する際に設定した service-bへの接続用のリスナとなっている。 このリスナが向けている "service-b...." という cluster も、envoy の admin 機能から実際のアドレスがわかるようになっていて、 次のような内容が設定されている。

service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::cx_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::cx_connect_fail::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::cx_total::5
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_error::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_success::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_timeout::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_total::5
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::hostname::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::health_flags::healthy
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::weight::1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::region::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::sub_zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::canary::false
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::priority::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::success_rate::-1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::local_origin_success_rate::-1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::cx_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::cx_connect_fail::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::cx_total::4
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_error::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_success::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_timeout::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_total::4
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::hostname::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::health_flags::healthy
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::weight::1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::region::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::sub_zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::canary::false
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::priority::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::success_rate::-1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::local_origin_success_rate::-1

このクラスタには2つのエンドポイント、"localhost:21001", "localhost:21002" が設定されている。

2100x ポートは、service-b 向けに起動した2つのサイドカーのリスナーのポートとなっていて、 service-a のものと同様に、これらのリスナーもconsul への認証とservice-b への転送を行うようになっている。

また、どちらも weight::1 が設定されているのでこのクラスタへのアクセスは2つのエンドポイント へ均等に割り振られる。

よって全体のフローは、

service-a から 9000ポートにアクセス -> service-aサイドカーが 21001 or 201002 へ振り分け -> service-b-sidecar(21001, 21002) -> consul へ認証移譲 -> service_b へ転送

ということになる。 壮大ではあるが、consul がクラスタ化して複数サーバーにサービスが分散したとしても、 service-a は とりあえず 9000 ポートに接続すれば、どのサーバーに動いている service-b であっても接続できるようになる。

また、サービスの数や位置が変わったとしても、その内容は即座に変更が各 envoy に伝わる。 これは envoy の xDS という仕組みで実現されていて、 istio などの別のサービスメッシュでも同じように使われている。

というわけで、サーバーやサービスの構成が変更されても柔軟に追従できそうな仕組みが connect であることがわかり、 それを裏で支えているのが envoy と xDS プロトコルであることもわかった。

とはいっても次の点が少々面倒

  • connect は L4レベルなので接続先のサービスが増えると管理しないといけないポートが増える
  • サービスの起動と consul への登録さらにサイドカーの起動と登録も必要

この辺を楽にしていく仕組みが、L7 traffic manager と nomad である。

が、長くなったのでその2つについてはまた後で書きます。

余談だが、 consul から起動する envoy には追加で設定をあたえるオプションもある。 分散トレーシングやログフォーマットなども追加できるようになっているので、頑張ればより高度なサイドカーとしても使えるかもしれない。

https://www.consul.io/docs/connect/proxies/envoy.html#advanced-configuration

サイドカーライブラリ Dapr の分散トレーシングを試す

ちょっと前にこんなニュースがありました。

www.publickey1.jp

公式はこの辺かな?

dapr.io

github.com

MicrosoftOSS で、しかも golang で作ったという異色のライブラリです。

また最近は envoy を使ったサービスメッシュについて色々と調べていたこともあり、似たような問題を解決するものであるといこともあり、興味を持ちました。

サンプルやコンセプトページを見ているとなんとなく雰囲気がつかめてきます。

github.com

github.com

  • Isito のように、各サービスにサイドカーとして起動するDapr インスタンスサイドカーインスタンスを管理するDaprサーバーから構成されている。
    • サイドカー経由でサービスにアクセスすることでアドレス解決を任せたり、プロトコル変換ができる。
      • 例えば、HTTPしかないサービスをgRPC経由で呼び出したりとか、Kafka経由で非同期イベント経由で呼び出したりとか、サービス間の連携を後から設定できる(Bindings)
  • RedisやKafkaなどのストレージと連携して、サービス間でステートをやり取りする機能(WebAPIや言語別のSDK)がある
    • そんな大したものではなく、Daprサーバーに向けてオブジェクトを登録・取得する簡単なAPIがあるという感じ
  • セキュリティ、秘密管理、分散トレーシングなどの非機能要件に関する部分をある程度Dapr側でやってくれる

envoy, Istio などと比較して違いがあるのは最初にあげたBindingsでしょう。 とりあえずWebAPIとして作っておいて、DaprがよしなにgRPCとかイベント連携にしてくれるとか、自前で実行方法に合わせた連携処理を書かなくて済むというのは便利です。

と色々と気になる機能はありますが、今回はの主題は分散トレーシングについてです。

サイドカーで分散トレーシング

どうして分散トレーシングが気になるかと言えば、 envoy などのサイドカーパターンで分散トレーシングを行うには Context Propagation というトレース情報の伝播が必要だからです。

www.envoyproxy.io

サービスを跨ぐ複数の通信をトレーシングで一連の処理だと認識するには、 各通信で一位となるID(トレースIDとか呼ばれる)を受け渡していく必要があります。

分散トレーシングライブラリはサーバー側(通信の入り口)でヘッダなどを見てトレースIDがあるかを調べ、あるならそれを使い、無いなら新しくトレースIDを作ります。
同一処理内で外部API通信などを行うクライアント側(通信の出口)でも通信のヘッダにサーバー側で取得したトレースIDを乗せることで、一連のトレースであることを認識させていきます。

これが伝播と呼ばれるもので、実装するのがとても面倒です。

Zipkin Brave, OpenTelemetry, Spring Cloud Sleuth など様々なライブラリやフレームワークがあり、現状はOpenTelemetryにまとまるかもしれないという希望もありつつ、まだまだ群雄割拠な状態です。

envoy のようなサイドカーの分散トレーシングがやってくれるのは、トレースサーバーへの情報送信だったり、ZipkinやJaeger,StackDriverなど複数トレースサーバーへの差異の吸収だったりします。

トレースIDの伝播は、そもそもサービス内から実行するクライアント実行の通信が一連のトレースかどうかを判断する術をサイドカーが持たないので、サービス側の責務になります。

ということは伝播のための仕組みやライブラリをサービス側から完全に消し、透過的な分散トレーシングを実現することは困難というのが現時点での私の結論でした。

となると、Daprのトレーシングは果たしてどうかというのが気になってしまいます。

Dapr で分散トレーシングに言及しているのはこの辺り。

github.com

まだサンプル集には実装例はありませんでしたが、ドキュメントを見ていると、

  • OpenTelemetry使うから、Dapr側で ZipkinとかJargerとかにトレース情報送る
  • X-Correlation-ID というのがトレースID的なもの
  • X-Correlation-ID がヘッダにあればそのまま使うし、無いなら新しく作るとある

最後の文面が出てきた時点であまり期待はできなくなったのですけど、折角ですし試してみました。

Dapr で分散トレーシング

試したコードはこちらです。

github.com

Daprはk8sでもオンプレミスでも動くようです。(オンプレミスで分散で動かすガイドはまだ見当りませんが)

またローカルなどでテスト用途で動かすための standalone モードがありますので、今回はそちらで試します。

github.com

に従い、Daprを導入すると dapr cli が手に入り、Docker上 で daprサーバーと ステート管理のための redis が動くようになります。

55bf9b9c9e52        daprio/dapr         "./placement"            4 days ago          Up 4 days           0.0.0.0:50005->50005/tcp           dapr_placement
ad75cf783790        redis               "docker-entrypoint.s…"   4 days ago          Up 4 days           0.0.0.0:6379->6379/tcp             dapr_redis

あとは、サービスのコードを作成したら、 dapr run コマンドでサービスとサイドカーを一緒に起動するという感じです。

まずは適当にservice2 という名前でアプリを作ってみます。

// app.js
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());

const port = 3001;

app.post('/apply', (req, res) => {
    console.log(req.headers)
    res.send({message:"hello! " + req.body.name})
});

app.listen(port, () => console.log(`Node App service2 listening on port ${port}!`));

JSONを受け取り、リクエストヘッダをプリントして、返すというだけの簡単なものです。

また準備としてzipkinを起動し、Daprに zipkinにトレースを送るように設定します。

zipkin起動

docker run -d -p 9411:9411 openzipkin/zipkin

dapr サイドカーに trace と zipkin の設定を行います。

設定は CRD形式というk8sでの設定ファイルの書式です。

tracing.yml、これはトレースの送信方法など汎用的な設定です

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: tracing
spec:
  tracing:
    enabled: true
    expandParams: true
    includeBody: true

zipkin.yaml , zipkinにトレースを送るための設定。トレース送信先の実装に応じて変更するものです。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: zipkin
spec:
  type: exporters.zipkin
  metadata:
  - name: enabled
    value: "true"
  - name: exporterAddress
    value: "http://10.200.10.1:9411/api/v2/spans"

これらを サービスごとに components というディレクトリを作って置いておきます。

(components ディレクトリは、 dapr run コマンド実行時に自動で作れらる場合もあり、デフォルトでいくつかのファイルが勝手にできます。サイドカーの設定などを規定する場所となっています)

では、サイドカーと一緒にサービスを dapr run コマンドで起動します。

$ dapr run --app-id service2 --app-port 3001 --port 3501 --config ./components/tracing.yaml  node app.js

ℹ️  Starting Dapr with id service2. HTTP Port: 3501. gRPC Port: 55788
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="starting Dapr Runtime -- version 0.2.0 -- commit c75b111"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="log level set to: info"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="standalone mode configured"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="dapr id: service2"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component statestore (state.redis)"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component zipkin (exporters.zipkin)"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component messagebus (pubsub.redis)"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component tracing ()"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="application protocol: http. waiting on port 3001"
✅  You're up and running! Both Dapr and your app logs will appear here.

== APP == Node App service2 listening on port 3001!
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="application discovered on port 3001"
== DAPR == 2019-11-26 01:41:07.169239 I | redis: connecting to localhost:6379
== DAPR == 2019-11-26 01:41:07.216321 I | redis: connected to localhost:6379 (localAddr: [::1]:55814, remAddr: [::1]:6379)
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actor runtime started. actor idle timeout: 1h0m0s. actor scan interval: 30s"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: starting connection attempt to placement service at localhost:50005"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="http server is running on port 3501"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="gRPC server is running on port 55788"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="local service entry announced"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="dapr initialized. Status: Running. Init Elapsed 651.0255980000001ms"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: established connection to placement service at localhost:50005"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement order received: lock"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement order received: update"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement tables updated"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement order received: unlock"

3501 ポートで dapr インスタンスが起動し、 3001 ポートで起動したservice2 に接続しているようです。

docker 上の dapr インスタンスと、ホスト上の node アプリケーションが合わさり、ログも混ざって出てる(== APP == の行)のが、なかなか面白いですね。

service2 は、 localhost:3001/apply で待ち受けしていますが、 これをdapr サイドカーは、localhost:3501/v1.0/invoke/service2/method/apply と、 //invoke/<app-id>/method/ というURLで待ち受けるようになります。

最初は面食らいますが、URLを全体でユニークにするためなのかな?

では呼び出してみます。

$ curl -X POST http://localhost:3501/v1.0/invoke/service2/method/apply -d '{"name":"test"}'
{"message":"hello! test"}

ログは次のような感じ。

== APP == {
== APP ==   'user-agent': 'curl/7.54.0',
== APP ==   host: '127.0.0.1:3001',
== APP ==   'content-type': 'application/json',
== APP ==   'content-length': '15',
== APP ==   accept: '*/*',
== APP ==   'x-correlation-id': '86d482e659eea6f4;fcc76f86bce3a9614b9a372a36ab242f;1'
== APP == }

x-correlation-id が出ているので、zipkinを確認してみます。
ちなみに、x-correlation-id の ;より前は spanIDで、後ろがトレースIDです。

トレースが出ています。dapr サイドカーがいい感じにzipkinにトレースを送ってくれたようです。

f:id:kencharos:20191126015418p:plain

2サービス間のトレーシング

では本題となる、2サービス間のトレーシングを試してみます。

前述のService2 を呼び出すService1 を別のサービスとして作成し、こちらも dapr サイドカーと一緒に起動します。

ポイントは Service2 のURLです。ソースにもある通り、Service1(自分)のサイドカーのURL(localhost:3500) + /v1.0/invoke/service2 としていて、 Serivce2のサイドカーに直接向けていません。Service2のアドレス解決は、Daprがやってくれます。

// app.js
const express = require('express');
const bodyParser = require('body-parser');
const fetch = require("node-fetch")
const app = express();
app.use(bodyParser.json());

const port = 3000;
// dapr サイドカー経由のURL
const service2URL = "http://localhost:3500/v1.0/invoke/service2/method/apply"

app.get('/hello', (req, res) => {
    console.log(req.headers)
    fetch(service2URL, {
        method: "POST",
        body: JSON.stringify({name:"service1"}),
        headers: {
            "content-type":"application/json"
            // リクエストヘッダのトレースIDをクライアントのリクエストヘッダに伝播する
           // , "x-correlation-id" : req.headers["x-correlation-id"]
            }
    }).then(r => r.json())
      .then(data => res.send(data))
});

app.listen(port, () => console.log(`Node App service1 listening on port ${port}!`));

ある程度答えは見えていたので恣意的ですが、とりあえずは x-correlation-id ヘッダの連携は今はしないようにコメントアウトしています。

dapr サイドカーを3500ポートで起動します。

dapr run --app-id service1 --app-port 3000 --port 3500 --config ./components/tracing.yaml  node app.js

余談ですが、 dapr list で起動している dapr インスタンスも見れます。

$ dapr list
  APP ID    HTTP PORT  GRPC PORT  APP PORT  COMMAND      AGE  CREATED              PID
  service2  3501       56104      3001      node app.js  12m  2019-11-26 01:51.24  53879
  service1  3500       56938      3000      node app.js  11s  2019-11-26 02:03.23  55314

さて、service1 を呼び出してみます。

$ curl http://localhost:3500/v1.0/invoke/service1/method/hello
{"message":"hello! service1"}

service1 のログ

== APP == {
== APP ==   'user-agent': 'curl/7.54.0',
== APP ==   host: '127.0.0.1:3000',
== APP ==   'content-type': 'application/json',
== APP ==   accept: '*/*',
== APP ==   'x-correlation-id': 'e757cdd5120e5471;a8ef06433d0fbf475e9d4e71e6caddd2;1'
== APP == }

service2 のログ

== APP == {
== APP ==   'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)',
== APP ==   host: '127.0.0.1:3001',
== APP ==   'content-type': 'application/json',
== APP ==   'content-length': '19',
== APP ==   accept: '*/*',
== APP ==   'accept-encoding': 'gzip,deflate',
== APP ==   'x-correlation-id': 'ad8782c8425119f8;c49be5e2c42af227a984a36677c246d3;1',
== APP ==   connection: 'close'
== APP == }

見事に x-correlation-id が一致してないです。zipkinもおかしい。

f:id:kencharos:20191126020826p:plain

ぱっと見、service1,service2 のスパンのあるトレースがあるので、成功? とも思ったけど中身がおかしい 。

gRPCの内部の通信のようなもの? を拾っているようだ。

f:id:kencharos:20191126021031p:plain

で、service1 の app.js の コメントアウトしてある x-correlation-id の伝播を設定して、service1 を再起動します。 この場合は次のように、同一のトレースIDになりました。

$ curl http://localhost:3500/v1.0/invoke/service1/method/hello
{"message":"hello! service1"}

service1

== APP == {
== APP ==   'user-agent': 'curl/7.54.0',
== APP ==   host: '127.0.0.1:3000',
== APP ==   'content-type': 'application/json',
== APP ==   accept: '*/*',
== APP ==   'x-correlation-id': '327d36a56fb4f0b7;fe6ad7b82c9e24156d73da4f24646a81;1'
== APP == }

service2

== APP == {
== APP ==   'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)',
== APP ==   host: '127.0.0.1:3001',
== APP ==   'content-type': 'application/json',
== APP ==   'content-length': '19',
== APP ==   'x-correlation-id': '3c614f073b853834;fe6ad7b82c9e24156d73da4f24646a81;1',
== APP ==   accept: '*/*',
== APP ==   'accept-encoding': 'gzip,deflate',
== APP ==   connection: 'close'
== APP == }

zipkin も次の通り。

f:id:kencharos:20191126021934p:plain

前述の分断されたトレースが1つにまとまったように見える。

なんでservice1のspanが4つ(自分のサイドカーに向けた通信だから?) とか、service2 の gRPC 呼び出しは何だ(サイドカー内部の通信はgRPC?)? とか疑問は残りますが、 dapr でも トレース情報の伝播は必須だということがわかりました。

まとめ

Dapr でもサービス内でヘッダ情報の伝播は必須だということがわかりました。

伝播に必須なのは x-correlation-id だけなので、楽といえば楽なんですけど。
実際、伝播や instrumentation のライブラリを作るのもそこまで難しくないでしょうし、OpenTelemetry でも用意されるのかな?

とはいえ、透過的な分散トレーシングは夢ではあるので、もしお前の呼び出し方はおかしいとか、こうすれば行けるとか、こんなAPIがあるよとかご指摘があれば是非ともお願いところです。

分散トレーシング以外でDapr に感じることは、 envoy 直接使うよりも設定は楽だし、dapr 前提でマイクロサービスを設計すると色々楽ができる部分がありそうなので将来性を感じました。

今後も注視していきたいと思います。

JJUG CCC 2019 Spring で登壇してきました

5/18 に行われた JJUG CCC 2019 Spring に「初めてのgRPC」 という内容で発表してきました。

www.java-users.jp

発表資料はこちらです。

speakerdeck.com

全体の資料もこちらでまとまっています。

github.com

今回は、登壇者兼ボランティアスタッフとして申し込みしました。 ボランティアスタッフは2回目だし慣れてるだろうから、部屋の進行と発表同時にやっちゃえということで、 スタッフからの注意事項の説明の後、そのまま発表するというちょっと面白い流れになりました。

会社の人からもいい感じのツッコミをいただきました。

技術イベントの登壇はいつかやってみたいことの一つでした、なので夢が一つ叶いました。

なかなか大変でしたけど、発表したテーマに興味を持って頂けた方が何名かいらっしゃったので、発表してみて良かったです。

発表テーマ gRPC について

gRPC については2,3 年ほど前から採用事例を聞くようになってきました。 また最近だと、国内・海外のマイクロサービスを主導している会社で gRPC + サービスメッシュの採用事例を聞くようになってきていたので gRPC について調べ出しました。

その過程で、4種類ある RPC のプログラムの書き方が結構違ったり、そもそも gRPC Java に関する情報があまりない( Go が多い) ということに気づきました。 ちょうどいいタイミングで CCC の CFP 応募が始まったので、良い機会だということで申し込んでみました。

幸いにも、同じタイミングで 社内でも gRPC を検討するタイミングがあったので、 チーム内勉強会を開くノリで発表資料をまとめられたのは良かったかなと思います。

スライドが70枚と多く、後半は飛ばし気味になりましたが時間内で言いたいことは言えたので良かったです。 わかりづらい点などありましたら、コメントや Twitter で質問いただけると幸いです。

gRPC は個人的に筋の良い技術だと思っていますが、ライブラリやミドルウェアを含めた運用の知見がまだ足りてないです。 さらに今までの Web アプリケーションフレームワークの手法が使えないです。 ( とは言え、Webアプリケーションフレームワークの仕事の大半は、不確実なHTTP ボディの解析とURLのルーティングで、gRPC はそのどちらも割と高いレベルで解消しているのだけど) なので、まだまだ発展の余地はあるし、色々貢献できそうだと思いました。 社内でも草の根的に gRPC やってるチームがあるということもあとで聞きましたので、やっておいて損はないと思います。

またパgRPC についてフォーマンスよりも気に入っているのは、IDL としての Protocol Bufferes の良さです。 DDD の文脈では公開されたインターフェースという、そのサービスが提供する API をサービス自らが提供するパターンがあります。 proto ファイルはまさに公開されたインターフェースのフォーマットとして最適だと思います。

今後チームで gRPC をやってみて、実際どうだったかをお知らせする機会があればやってみようと思います。

いただいた質問について

  • 分散トレースはできるの? Netty 以外も使えるの?

  • Protocol Bufferes の message を ビジネスロジックに持ち込まないなら、DTOを自分で作ってコピーしないといけないのでは?

  • Reactive-gRPC を使うと、RxJava や Spring WebFlux とかと接続できるの?

    • WebFlux との接続は確認しました。そのため gRPC-gateway を使わなくても、Spring WebFlux->Reactive gRPC で REST-gRPC変換はできなくもないです。
    • R2DBC が登場したら、 gRPC とDB をシームレスでリアクティブに扱えるので面白いなと思っています。
    • 色々な Reactive なもの(外部のWebApi とか、別の gRPC の呼び出しとか)を逐次・並列に接続したい場合に、Reactive-gRPC は良い選択肢だと思います。
  • 何か困ったことは?

    • 時刻型と十進数型は ProtocolBufferes には無いので、自作する必要があること。
    • 自作した場合、Javaの LocalDate や BigDecimal とのマッピングコードが必要なので、どうやって実現するかは検討の余地がある。
      • go だとこの場合 インターフェースで後からメソッドが追加できるから楽なんだろうなとか思った。
      • Java だとどうしようもないが、 Kotlinだと拡張メソッドで Protocol Buffersの型とJavaの型それぞれに相互変換のメソッドを追加できるので、Kotlin やるならありかなと思います。
  • Kotlin でできるの?

    • できます。gRPC-Kotlin プラグインもありますが、普通に生成した gRPC Service, Stub のJavaコードもKotlin から使えます。
    • gRPC-Kotlin のコードは コルーチンを使うためか、MDC,分散トーレシングのためにCoroutien Context の上書きが必須だったので、もう少し見守ろうと思いました。(サンプルコードを参照)

所属について

2019年のはじめに転職しました。 今回、試用期間が空けたこともあり、所属について隠すことはやめました。

懇親会 LT でもほぼ同時期に入試された方が、働いてみてどうだったかを語っていました。 私もチームは違いますが、大規模なサービスの開発に携われるというのはプレッシャーもありますが、とても面白いです。 エンジニアリングに集中できる環境があり、チームメンバーみんなが生き生きとしているので働いて楽しいなと感じています。

DevRel チームの方々はフットワークも軽く、知識もあり、今回の発表にあたり色々とアドバイスいただけました。 発表内容については、機密や誹謗中傷がないかをチェックされるだけで、 内容については発表者の意思を尊重いただけたので感謝しかありません。 内容についての拙い点はわかりづらい点は全て私のスキルによるものです。

JJUG CCC について

ボランティアスタッフとして

今回は人員が2倍くらいに増えました。 色々な人が参加していて楽しかったし、一人当たりの負荷も前回に比べて減りました。 また総会には参加できませんでしが、運営の方式も変わりますので CCC はより良いものになっていくと思います。 みんなもやりましょう。

参加者として

自分の発表以外は、割と自由にその辺をうろついていました。 その辺で久しぶりの方々と身の回りの話や、今後の技術動向などについて話をしていることが多かったかな。 セッション聞くのも面白いですけど、ここでしか会えない人たちと話すのも楽しいし、刺激になる。

特にアンカンファレンスは、今回は Java Champion や JDK ソムリエ、他に Java 界隈の強い人ばかりの豪華メンバーでした。

貴重な実体験や知識を聞くことができました。

まとめ

皆さまお疲れ様でした。

こんなに貴重な技術イベントは他にはないと思う。

次も頑張るぞ。