TECHSTEP

ITインフラ関連の記事を公開してます。

NATSに入門する

今回はメッセージングシステムの一つであるNATSを試してみました。

NATSとは

分散システムにおけるアプリケーションとサービス間のコミュニケーションは複雑で理解が難しいものとなります。現代のメッセージングシステムは複数のコミュニケーションパターンをサポートする必要があり、それに加えてセキュリティ・マルチテナンシー・拡張性など多様な要素も求められています。

NATSはこれらの要求を満たすために開発されたソフトウェアで、以下のような特徴を備えています。なおNATSはNeural Automatic Transport Systemの略称です。

複数のメッセージングパターンのサポート

NATSはSubjectをベースとしたメッセージングシステムで、PublisherがSubjectに対してメッセージを送信し、Subjectと紐づくSubscriberへメッセージを転送します。Subjectは、PublisherとSubscriberが互いを見つけるために利用する名前を形成する「文字列」です。Subjectは階層構造やワイルドカードなどを利用して表現でき、複数のSubscriberへの送信や、特定のパターンに合致したもののみにメッセージを送信するといったコントロールも可能になります。

ワイルドカードを利用したSubject(画像は公式ドキュメントより)

※特定のSubscriberにのみ送信されるパターン(画像は公式ドキュメントより)

NATSはPublish-Subscribe / Request-Reply / Queueという3つのメッセージングパターンに対応しています。

Publish-Subscribeの場合、NATSは「1対多」のコミュニケーションとして扱い、Publisherから送られてきたメッセージはSubjectが合致したアクティブなSubscriberへと送られます。

※画像:公式ドキュメントより

Request-Replyの場合、クライアントがReply Subject付きのRequestを送信すると、Responderがそれを捉えてReply Subject宛にResponseを返します。また複数のResponderを設定することも可能で、それらを使って動的なQueue Groupを形成し、スケールアップすることもできます。

※画像:公式ドキュメントより

Queueの場合、NATSは分散Queueと呼ばれる負荷分散機能を提供します。同一のQueue Nameを与えられたSubscriberはQueue Groupのメンバーとなり、Publisherから送られるメッセージがグループ内のメンバーに対して均一に送信されます。この設定はPublisher/Subscriber側で行い、Serverでは行いません。

※画像:公式ドキュメントより

At Most Once / At Least Onceのサポート

NATSはCore NATS (NATS Server、NATSとも呼ばれる)とNATS Streamingとで構成されます。Core NATSはクライアント間のメッセージをルーティングします。クライアントとの接続は、クライアントライブラリによって確立されるTCP接続を介して行われます。

※画像:GitHubより

nats-server

NATS StreamingはCore NATSのクライアントとして位置しています。NATS Streamingを利用するにはCore NATSが必要で、クライアントからの通信はCore NATSを介してNATS Streamingへ送られます。

※画像:GitHubより

nats-streaming

Core NATSはAT Most Onceと呼ばれるデリバリー戦略を採用しており、これは最高でも1回しか配信を行わず、再送は行わないことを意味します。これはメッセージの欠損する可能性を含んでおり、アプリケーションによってはこの戦略は適さない場合があります。

その一方でNATS StreamingではAt Least Once、つまり最低でも1回は配信されるよう再送を行います。こちらを採用することでSubscriberへの配信が保証できる一方、メッセージの重複が発生する可能性もあります。

NATS StreamingはメッセージをディスクやSQLデータベースに書き込むことでAt Least Onceを実現しており、Kubernetesへデプロイする際はPersistentVolume PersistentVolumeClaimを利用しています。


※参考ドキュメント:


高可用性・拡張性

NATSは可用性と拡張性を実現するため、Server Clusteringをサポートしています。NATS ServerはClusterを形成するすべてのNATS Serverと接続し、フルメッシュを形成します。NATSが利用するクラスタリングプロトコルでは、NATS Serverにクラスターメンバーを伝え、全てのNATS Serverはクラスター内の他のメンバーをDiscoverすることができます。またクライアントがNATS Serverに接続すると、クラスターに関する情報が通知されます。これにより、クラスターの構成が動的に変更したり、自己修復することを可能にします。

※画像:GitHubより

cluster

またクライアントライブラリでは、接続・Subscriptionを削除する際にDrainを行う機能が用意されています。これにより、実行中、あるいはキャッシュされたメッセージを処理してから、接続・Subscriptionの停止をすることが可能です。この機能を利用することで、メッセージが失われることなくScale Dwonを実行することも可能になります。


※参考ドキュメント:


その他


※参考ドキュメント:


NATSを使ってみる

ここから実際にNATSを動かしてみます。今回はKubernetesへNATS Serverをデプロイした後、Tutorialとして紹介されているこちらのページの内容をなぞっていきます。

検証環境

NATSのインストール

まずはNATSをインストールします。NATSはKubernetesへのインストールで利用するためのリポジトリを用意しており、こちらに置いてあるマニフェストファイルを利用します。

NATS、NATS Streaming用のマニフェストファイルは、それぞれ以下の通りです。今回はNATS、NATS Streamingを一つずつ作成する最小構成のパターンで行いました。

single-server-nats.yaml

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: nats-config
data:
  nats.conf: |
    pid_file: "/var/run/nats/nats.pid"
    http: 8222
---
apiVersion: v1
kind: Service
metadata:
  name: nats
  labels:
    app: nats
spec:
  selector:
    app: nats
  clusterIP: None
  ports:
  - name: client
    port: 4222
  - name: cluster
    port: 6222
  - name: monitor
    port: 8222
  - name: metrics
    port: 7777
  - name: leafnodes
    port: 7422
  - name: gateways
    port: 7522
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nats
  labels:
    app: nats
spec:
  selector:
    matchLabels:
      app: nats
  replicas: 1
  serviceName: "nats"
  template:
    metadata:
      labels:
        app: nats
    spec:
      # Common volumes for the containers
      volumes:
      - name: config-volume
        configMap:
          name: nats-config
      - name: pid
        emptyDir: {}

      # Required to be able to HUP signal and apply config reload
      # to the server without restarting the pod.
      shareProcessNamespace: true

      #################
      #               #
      #  NATS Server  #
      #               #
      #################
      terminationGracePeriodSeconds: 60
      containers:
      - name: nats
        image: nats:2.1.7-alpine3.11
        ports:
        - containerPort: 4222
          name: client
          hostPort: 4222
        - containerPort: 7422
          name: leafnodes
          hostPort: 7422
        - containerPort: 6222
          name: cluster
        - containerPort: 8222
          name: monitor
        - containerPort: 7777
          name: metrics
        command:
         - "nats-server"
         - "--config"
         - "/etc/nats-config/nats.conf"

        # Required to be able to define an environment variable
        # that refers to other environment variables.  This env var
        # is later used as part of the configuration file.
        env:
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: POD_NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        - name: CLUSTER_ADVERTISE
          value: $(POD_NAME).nats.$(POD_NAMESPACE).svc
        volumeMounts:
          - name: config-volume
            mountPath: /etc/nats-config
          - name: pid
            mountPath: /var/run/nats

        # Liveness/Readiness probes against the monitoring
        #
        livenessProbe:
          httpGet:
            path: /
            port: 8222
          initialDelaySeconds: 10
          timeoutSeconds: 5
        readinessProbe:
          httpGet:
            path: /
            port: 8222
          initialDelaySeconds: 10
          timeoutSeconds: 5

        # Gracefully stop NATS Server on pod deletion or image upgrade.
        #
        lifecycle:
          preStop:
            exec:
              # Using the alpine based NATS image, we add an extra sleep that is
              # the same amount as the terminationGracePeriodSeconds to allow
              # the NATS Server to gracefully terminate the client connections.
              #
              command: ["/bin/sh", "-c", "/nats-server -sl=ldm=/var/run/nats/nats.pid && /bin/sleep 60"]

single-server-stan.yaml

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: stan-config
data:
  stan.conf: |
    port: 4222
    http: 8222
    streaming {
     ns: "nats://nats:4222"
     id: stan
     store: file
     dir: /data/stan/store
    }
---
apiVersion: v1
kind: Service
metadata:
  name: stan
  labels:
    app: stan
spec:
  selector:
    app: stan
  clusterIP: None
  ports:
  - name: metrics
    port: 7777
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: stan
  labels:
    app: stan
spec:
  selector:
    matchLabels:
      app: stan
  serviceName: stan
  replicas: 1
  volumeClaimTemplates:
  - metadata:
      name: stan-sts-vol
    spec:
      accessModes:
      - ReadWriteOnce
      volumeMode: "Filesystem"
      resources:
        requests:
          storage: 1Gi
  template:
    metadata:
      labels:
        app: stan
    spec:
      # Prevent NATS Streaming pods running in same host.
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - topologyKey: "kubernetes.io/hostname"
            labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - stan
      # STAN Server
      containers:
      - name: stan
        image: nats-streaming:0.16.2
        ports:
        - containerPort: 8222
          name: monitor
        - containerPort: 7777
          name: metrics
        args:
         - "-sc"
         - "/etc/stan-config/stan.conf"

        # Required to be able to define an environment variable
        # that refers to other environment variables.  This env var
        # is later used as part of the configuration file.
        env:
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: POD_NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        volumeMounts:
          - name: config-volume
            mountPath: /etc/stan-config
          - name: stan-sts-vol
            mountPath: /data/stan

        # Disable CPU limits.
        resources:
          requests:
            cpu: 0

        livenessProbe:
          httpGet:
            path: /
            port: 8222
          initialDelaySeconds: 10
          timeoutSeconds: 5
      volumes:
      - name: config-volume
        configMap:
          name: stan-config

上記マニフェストファイルを利用してインストールを行います。

# GitHubリポジトリのクローン
$ git clone https://github.com/nats-io/k8s
$ cd k8s/


# NATSのインストール
$ kubectl apply -f nats-server/single-server-nats.yml 
configmap/nats-config created
service/nats created
statefulset.apps/nats created

# NATS Streamingのインストール
$ kubectl apply -f nats-streaming-server/single-server-stan.yml 
configmap/stan-config created
service/stan created
statefulset.apps/stan created


# インストール後の確認
$ kubectl get cm
NAME          DATA   AGE
nats-config   1      57s
stan-config   1      26s


$ kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                                                 AGE
kubernetes   ClusterIP   10.100.0.1   <none>        443/TCP                                                 2d
nats         ClusterIP   None         <none>        4222/TCP,6222/TCP,8222/TCP,7777/TCP,7422/TCP,7522/TCP   74s
stan         ClusterIP   None         <none>        7777/TCP                                                43s


# NATS Streamingが利用するPV/PVC
$ kubectl get pvc
NAME                  STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
stan-sts-vol-stan-0   Bound    pvc-37cf09c9-b32b-4573-bb3b-9a32d6bfff7d   1Gi        RWO            gp2            20h


$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                         STORAGECLASS   REASON   AGE
pvc-37cf09c9-b32b-4573-bb3b-9a32d6bfff7d   1Gi        RWO            Delete           Bound    default/stan-sts-vol-stan-0   gp2                     20h


$ kubectl get sc
NAME            PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
gp2 (default)   kubernetes.io/aws-ebs   Delete          WaitForFirstConsumer   false                  2d22h


$ kubectl get statefulsets
NAME   READY   AGE
nats   1/1     94s
stan   1/1     64s


$ kubectl get pods
NAME     READY   STATUS    RESTARTS   AGE
nats-0   1/1     Running   0          101s
stan-0   1/1     Running   0          71s


# NATSのログは以下の通り
$ kubectl logs nats-0
[7] 2020/12/28 04:50:09.349020 [INF] Starting nats-server version 2.1.7
[7] 2020/12/28 04:50:09.349061 [INF] Git commit [bf0930e]
[7] 2020/12/28 04:50:09.349569 [INF] Starting http monitor on 0.0.0.0:8222
[7] 2020/12/28 04:50:09.349616 [INF] Listening for client connections on 0.0.0.0:4222
[7] 2020/12/28 04:50:09.349625 [INF] Server id is NBBQ76INWDZXMFBGF5V4MBXCV3PBEQ5EWNFPPKS3FSTNPF3VFCR3RY2K
[7] 2020/12/28 04:50:09.349629 [INF] Server is ready


# NATS Streamingのログは以下の通り
$ kubectl logs stan-0
[1] 2020/12/28 04:51:02.605219 [INF] STREAM: Starting nats-streaming-server[stan] version 0.16.2
[1] 2020/12/28 04:51:02.605250 [INF] STREAM: ServerID: TgyiHElDc171yiUZ30g5pL
[1] 2020/12/28 04:51:02.605254 [INF] STREAM: Go version: go1.11.13
[1] 2020/12/28 04:51:02.605258 [INF] STREAM: Git commit: [910d6e1]
[1] 2020/12/28 04:51:02.631773 [INF] STREAM: Recovering the state...
[1] 2020/12/28 04:51:02.631932 [INF] STREAM: No recovered state
[1] 2020/12/28 04:51:02.883723 [INF] STREAM: Message store is FILE
[1] 2020/12/28 04:51:02.883738 [INF] STREAM: Store location: /data/stan/store
[1] 2020/12/28 04:51:02.883770 [INF] STREAM: ---------- Store Limits ----------
[1] 2020/12/28 04:51:02.883774 [INF] STREAM: Channels:                  100 *
[1] 2020/12/28 04:51:02.883778 [INF] STREAM: --------- Channels Limits --------
[1] 2020/12/28 04:51:02.883781 [INF] STREAM:   Subscriptions:          1000 *
[1] 2020/12/28 04:51:02.883785 [INF] STREAM:   Messages     :       1000000 *
[1] 2020/12/28 04:51:02.883788 [INF] STREAM:   Bytes        :     976.56 MB *
[1] 2020/12/28 04:51:02.883791 [INF] STREAM:   Age          :     unlimited *
[1] 2020/12/28 04:51:02.883795 [INF] STREAM:   Inactivity   :     unlimited *
[1] 2020/12/28 04:51:02.883798 [INF] STREAM: ----------------------------------
[1] 2020/12/28 04:51:02.883802 [INF] STREAM: Streaming Server is ready

インストールが完了したので、簡単な動作確認を行います。

NATSはnats-boxというコンテナイメージを用意しており、これを利用することでNATSの各コマンドを実行することができます。

# nats-boxコンテナの起動
$ kubectl run -i --rm --tty nats-box --image=synadia/nats-box --restart=Never
If you dont see a command prompt, try pressing enter.
nats-box:~#


# バックグラウンドでSubscriberを起動
nats-box:~# nats-sub -s nats hello &
nats-box:~# Listening on [hello]


# Publisherからメッセージを送信
nats-box:~# nats-pub -s nats hello world
[#1] Received on [hello]: 'world'


# PublisherからNATS Streamingへメッセージ送信
nats-box:~# stan-pub -s nats -c stan hello world
Published [hello] : 'world'


# NATS Streamingからも送られたメッセージが確認できる
nats-box:~# stan-sub -s nats -c stan hello
Connected to nats clusterID: [stan] clientID: [stan-sub]
Listening on [hello], clientID=[stan-sub], qgroup=[] durable=[]
[#1] Received: sequence:1 subject:"hello" data:"world" timestamp:1609131378713322197


# NATS Streaming Podでは以下のようなログが確認できる
$ kubectl logs stan-0 -f

[1] 2020/12/28 04:56:18.713310 [INF] STREAM: Channel "hello" has been created

NATSはその他にも複数のツールを提供しており、例えばnats-topコマンドを実行するとNATS Serverのモニタリングを行うことができます。

nats-box:~# nats-top -s nats

NATS server version 2.1.7 (uptime: 8m24s)
Server:
  Load: CPU:  0.0%  Memory: 9.9M  Slow Consumers: 0
  In:   Msgs: 26  Bytes: 1022  Msgs/Sec: 0.0  Bytes/Sec: 0
  Out:  Msgs: 25  Bytes: 985  Msgs/Sec: 0.0  Bytes/Sec: 0

Connections Polled: 3
  HOST                  CID    NAME                SUBS    PENDING     MSGS_TO     MSGS_FROM   BYTES_TO    BYTES_FROM  LANG     VERSION  UPTIME   LAST ACTIVITY
  192.168.1.45:56406    1      _NSS-stan-send      0       0           0           8           0           159         go       1.8.1    7m57s    2020-12-28 08:45:11.674703256 +0000 U  192.168.1.45:56408    2      _NSS-stan-general   8       0           8           5           361         461         go       1.8.1    7m57s    2020-12-28 08:45:11.677642417 +0000 U  192.168.1.45:56410    3      _NSS-stan-acks      0       0           4           0           36          0           go       1.8.1    7m57s    2020-12-28 08:45:11.673086331 +0000 U

またNATSはモニタリング機能を提供しており、以下のようにPort: 8222からアクセスすることで、各種設定やメッセージ受信数などを確認することができます。

$ kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                                                 AGE
kubernetes   ClusterIP   10.100.0.1   <none>        443/TCP                                                 2d20h
nats         ClusterIP   None         <none>        4222/TCP,6222/TCP,8222/TCP,7777/TCP,7422/TCP,7522/TCP   44s
stan         ClusterIP   None         <none>        7777/TCP                                                27s

# ポートフォワーディング
$ kubectl port-forward svc/nats 8222:8222 &
[1] 10071

Forwarding from [::1]:8222 -> 8222

f:id:FY0323:20201229135232j:plain

例えばlocalhost:8222/subszにアクセスすると、Subscriptionの数などが表示されます。

f:id:FY0323:20201229135246j:plain

NATSを利用する

NATS/NATS Streamingの起動確認ができたので、クライアントライブラリからNATS Serverを利用してみます。今回はnats.goというGoのライブラリに含まれているサンプルプログラムを実行していきます。

まずはnat.goパッケージを入手します。Goのバージョンが古いと取得できないことがあるので、必要があればバージョンアップを実施します。

$ go version
go version go1.15.6 linux/amd64

$ go get github.com/nats-io/nats.go/

また以降の操作ではlocalhostからNATS Serverにアクセスするため、kubectl port-forwardコマンドによりNATSへアクセスできるようにします。

$ kubectl port-forward svc/nats 4222:4222 &
[2] 10088
$ Forwarding from 127.0.0.1:4222 -> 4222
Forwarding from [::1]:4222 -> 4222
NATS Pub/Sub

まずはPub/Subパターンを実行します。ここではPublisher1つ、Subscriber 3つを起動し、それぞれにメッセージが配信される様子を確認します。

まずは1つ目のSubscriberを起動します。ここではmsg.testというSubjectを指定します。

# Subscriber-1
$ cd $GOPATH/src/src/github.com/nats-io/nats.go/examples
$ go run nats-sub/main.go msg.test
Listening on [msg.test]

次に別のターミナルを用意し、Publisherを起動、メッセージを送信します。

# Publisher
# “hello”メッセージを送信
$ cd $GOPATH/src/src/github.com/nats-io/nats.go/examples
$ go run nats-pub/main.go msg.test hello

# “NATS MESSAGE”メッセージを送信
$ go run nats-pub/main.go msg.test "NATS MESSAGE"

Publisherからメッセージを送信すると、Publisher側では送信したことを示すメッセージが表示され、Subscriber側ではメッセージの受け取りが確認できます。

# Publisher
Published [msg.test] : 'hello'
Published [msg.test] : 'NATS MESSAGE'


# Subscriber-1
[#1] Received on [msg.test]: 'hello'
[#2] Received on [msg.test]: 'NATS MESSAGE'

次に新しいターミナルを開き、2つ目のSubscrierを起動します。ここでもmsg.testSubjectを指定します。

# Subscriber-2
$ cd $GOPATH/src/src/github.com/nats-io/nats.go/examples
$ go run nats-sub/main.go msg.test
Listening on [msg.test]

この状態でPublisherからメッセージを送信すると、2つのSubscriberへメッセージが送信されることが確認できます。

# Publisher
$ go run nats-pub/main.go msg.test "NATS MESSAGE 2"
Published [msg.test] : 'NATS MESSAGE 2'

# Subscriber-1
[#3] Received on [msg.test]: 'NATS MESSAGE 2'

# Subscriber-2
[#1] Received on [msg.test]: 'NATS MESSAGE 2'

ここでmsg.test.newという別のSubjectを指定したSubscriberを起動します。

# Subscriber-3
$ cd $GOPATH/src/src/github.com/nats-io/nats.go/examples
$ go run nats-sub/main.go msg.test.new
Listening on [msg.test.new]

ここで再びPublisherからメッセージを送信すると、今回はSubscriber-3はメッセージを受け取らない様子が確認できます。これはSubscriberで指定したmsg.test.newというSubjectが、Publisherがメッセージ送信時に指定したmsg.testというSubjectと一致しなかったためです。

# Publisher
$ go run nats-pub/main.go msg.test "NATS MESSAGE 3"
Published [msg.test] : 'NATS MESSAGE 3'

# Subscriber-1
[#4] Received on [msg.test]: 'NATS MESSAGE 3'

# Subscriber-2
[#2] Received on [msg.test]: 'NATS MESSAGE 3'

# Subscriber-3

(no message)

ここでSubscriber-3を一度停止し、起動時に指定するSubjectをmsg.*に変更します。今度はSubjectにワイルドカードを含み、先ほどPublisherが指定したmsg.testが条件に一致するため、メッセージを受け取る様子が確認できます。

# Subscriber-3を停止、再起動
$ go run nats-sub/main.go msg.test.new
Listening on [msg.test.new]

^Csignal: interrupt

$ go run nats-sub/main.go msg.*
Listening on [msg.*]


# Publisher
$ go run nats-pub/main.go msg.test "NATS MESSAGE 4"
Published [msg.test] : 'NATS MESSAGE 4'

# Subscriber-1
[#5] Received on [msg.test]: 'NATS MESSAGE 4'

# Subscriber-2
[#3] Received on [msg.test]: 'NATS MESSAGE 4'

# Subscriber-3
[#1] Received on [msg.test]: 'NATS MESSAGE 4'

なお、NATSを起動する際に特定のオプションを指定することで、NATS Server側でログを出力することができます。例えば-Vオプションを追加すると、Subscriber・Publisher間でやり取りが発生した際、以下のようにログが出力されます。

single-server-nats.yaml

        command:
         - "nats-server"
         - "-V"  # 追加
         - "--config"
         - "/etc/nats-config/nats.conf"
$ kubectl logs nats-0 -f
# NATS Server起動時のログ
[7] 2020/12/29 04:42:11.784867 [INF] Starting nats-server version 2.1.7
[7] 2020/12/29 04:42:11.784895 [INF] Git commit [bf0930e]
[7] 2020/12/29 04:42:11.785860 [INF] Starting http monitor on 0.0.0.0:8222
[7] 2020/12/29 04:42:11.785910 [INF] Listening for client connections on 0.0.0.0:4222
[7] 2020/12/29 04:42:11.785919 [INF] Server id is NDK7LIGLOBR5MC5DOJKNVHHTUOBZWIMJRVXBRNNDFNHM5UKUZKJPZ6ST
[7] 2020/12/29 04:42:11.785923 [INF] Server is ready



# Subscriber-1起動時
[7] 2020/12/29 04:43:35.461255 [TRC] 127.0.0.1:38544 - cid:1 - <<- [CONNECT {"verbose":false,"pedantic":false,"tls_required":false,"name":"NATS Sample Subscriber","lang":"go","version":"1.11.0","protocol":1,"echo":true,"headers":false,"no_responders":false}]
[7] 2020/12/29 04:43:35.461364 [TRC] 127.0.0.1:38544 - cid:1 - <<- [PING]
[7] 2020/12/29 04:43:35.461372 [TRC] 127.0.0.1:38544 - cid:1 - ->> [PONG]
[7] 2020/12/29 04:43:35.471896 [TRC] 127.0.0.1:38544 - cid:1 - <<- [SUB msg.test  1]
[7] 2020/12/29 04:43:35.471916 [TRC] 127.0.0.1:38544 - cid:1 - <<- [PING]
[7] 2020/12/29 04:43:35.471921 [TRC] 127.0.0.1:38544 - cid:1 - ->> [PONG]
[7] 2020/12/29 04:43:37.787668 [TRC] 127.0.0.1:38544 - cid:1 - ->> [PING]
[7] 2020/12/29 04:43:37.798709 [TRC] 127.0.0.1:38544 - cid:1 - <<- [PONG]



# Publisher起動・メッセージ送信時
[7] 2020/12/29 04:44:02.334750 [TRC] 127.0.0.1:38702 - cid:2 - <<- [CONNECT {"verbose":false,"pedantic":false,"tls_required":false,"name":"NATS Sample Subscriber","lang":"go","version":"1.11.0","protocol":1,"echo":true,"headers":false,"no_responders":false}]
[7] 2020/12/29 04:44:02.334822 [TRC] 127.0.0.1:38702 - cid:2 - <<- [PING]
[7] 2020/12/29 04:44:02.334829 [TRC] 127.0.0.1:38702 - cid:2 - ->> [PONG]
[7] 2020/12/29 04:44:02.345670 [TRC] 127.0.0.1:38702 - cid:2 - <<- [SUB msg.test  1]
[7] 2020/12/29 04:44:02.345694 [TRC] 127.0.0.1:38702 - cid:2 - <<- [PING]
[7] 2020/12/29 04:44:02.345700 [TRC] 127.0.0.1:38702 - cid:2 - ->> [PONG]
[7] 2020/12/29 04:44:04.424048 [TRC] 127.0.0.1:38702 - cid:2 - ->> [PING]
[7] 2020/12/29 04:44:04.437254 [TRC] 127.0.0.1:38702 - cid:2 - <<- [PONG]
NATS Request/Reply

次にRequest/Replyパターンを実行します。ここではPublisher/Subscriberを一つずつ起動し、Publisherからメッセージを送信するとResponseが返ってくる様子を確認します。

まずはSubscriberを起動します。ここではhelp.pleaseというSubjectを設定し、またResponseメッセージも設定します。

# Subscriber
$ go run nats-rply/main.go help.please "OK, I CAN HELP!"
Listening on [help.please]

次にPublisherを起動し、同一のSubjectとRequestメッセージを指定します。

# Publisher
$ go run nats-req/main.go help.please "I need help!"

Publisherがメッセージを送信すると、Subscriberがメッセージを受け取る様子に加え、Publisher側でResponseメッセージが表示されることが確認できます。

# Subscriber
[#1] Received on [help.please]: 'I need help!'


# Publisher
Published [help.please] : 'I need help!'
Received  [_INBOX.g672l3mO17Q5MuaBrdZalB.roViAMQe] : 'OK, I CAN HELP!'  # Response Message
NATS Queueing

最後にQueueingパターンを実行します。ここでは複数のSubscriberを起動してQueue Groupを構成し、Publisherからのメッセージが各Subscriberに分散されて送られる様子を確認します。

まずは2つのSubscriberをQueue Groupを与えて起動します。

# Subscriber-1 (Queue Group)
$ go run nats-qsub/main.go foo my-queue
Listening on [foo], queue group [my-queue]

# Subscriber-2 (Queue Group)
$ go run nats-qsub/main.go foo my-queue
Listening on [foo], queue group [my-queue]

次にQueue Groupを指定せず、先ほどと同じSubjectを指定したSubscriberを起動します。

# Subscriber-3 (No Queue Group)
go run nats-sub/main.go foo
Listening on [foo]

この状態でPublisherからメッセージが送られると、Queue Groupに属するSubscriberはメッセージが分散されて送られますが、Queue Groupに属さないSubscriberは全てのメッセージが配信されます。

# Publisher
$ go run nats-pub/main.go foo "Hello NATS-1"

# 同様のメッセージを計5回送信する
$ go run nats-pub/main.go foo "Hello NATS-5"


# Subscriber-1 (Queue Group)
[#1] Received on [foo] Queue[my-queue] Pid[11326]: 'Hello NATS-3'
[#2] Received on [foo] Queue[my-queue] Pid[11326]: 'Hello NATS-4'


# Subscriber-2 (Queue Group)
[#1] Received on [foo] Queue[my-queue] Pid[11392]: 'Hello NATS-1'
[#2] Received on [foo] Queue[my-queue] Pid[11392]: 'Hello NATS-2'
[#3] Received on [foo] Queue[my-queue] Pid[11392]: 'Hello NATS-5'


# Subscriber-3 (No Queue Group)
[#1] Received on [foo]: 'Hello NATS-1'
[#2] Received on [foo]: 'Hello NATS-2'
[#3] Received on [foo]: 'Hello NATS-3'
[#4] Received on [foo]: 'Hello NATS-4'
[#5] Received on [foo]: 'Hello NATS-5'

参考ドキュメント

Falcoに入門する

今回はCloud-Nativeなランタイムセキュリティ機能を提供するFalcoを試しました。

Falcoとは

FalcoSysdig社が開発元であるランタイムセキュリティツールです。CNCFがホストとなっているOpen Sourceであり、2020年12月時点でプロジェクトの成熟度はIncubatingとされています。

Falcoの前に、Container Runtime Securityについて少し触れておきます。本番環境で動作するコンテナは、インターネットからの外部通信や、サービス・システム内部のマイクロサービスからの通信を受け取り、常に攻撃者からの標的となります。またコンテナを利用したシステムはコンテナ単体では動作せず、サーバ・ネットワークなどのインフラレイヤー、ユーザーに対して実際のサービスの機能を提供するアプリケーションレイヤーなど、様々な要素が連携して成り立っています。さらにコンテナを利用するシステムはコンテナの起動する数やコンテナを実行するホストの構成など、システム全体が動的に変化します。このような、構成が複雑かつ動的に変動する環境では、従来のような脆弱性診断やID管理などだけでは、セキュリティ対策として不十分になります。

コンテナ実行基盤として代表的なKubernetes上でセキュリティを強化するうえで、従来の観点に加えて追加するべき対策として、CNCF Webinar (動画)では以下のようなものが挙げられています。

  • Prevention/Enforcement: Kubernetes上で「誰が」「何を」行うことを許可するかをコントロールする。
  • Detection/Audit: Kubernetes上で何が起こっているかを検知する。
  • Blocking: 攻撃者からの攻撃をブロックする。システムの入り口にFirewallを設置するなどの対策を行う。
  • Incident response + Forensics: セキュリティの問題が発生した際、何が起こったかを把握し、素早く対応・修理できる必要がある。

一つ目のステップであるPreventionについては、Kubernetesがネイティブに備えるいくつかの機能(RBAC / Network Policy / Pod Security Policy)を利用することができます。それに加え、Admission Controlerを利用したOPAによる制御も有効です(OPAについては前回の投稿をご覧ください)。

しかし一つ目の対策で防げない場合、二つ目の対策であるDetectionが重要となります。これは事前の対応が難しいような問題が発生した際、後の調査や対策を検討することも含め、実際に何が起こったかを追跡し、原因を特定できるようにする必要があるため、セキュリティにおける最終ラインであるとも言えます。

Detectionを行うために検討できる方法としてはいくつかの選択肢が考えられましたが、Sysdig社のエンジニアはKernelベースでシステムのイベントをモニタリングする方式を採用し、それをFalcoに利用しました。

falco-image

FalcoはLinux System Callを利用したモニタリングを行います。FalcoはLinuxのSystem Callをパースして、あらかじめ設定されたRuleベースにそれを評価し、違反する場合はそれを検知してログ等に出力します。また検出時はSlack等へ通知を送ることも可能です。

Falcoはモニタリングする内容をRuleというYAMLファイルで定義します。Falco ver 0.8.0からは、デフォルトで利用できるRuleファイル(falco_rules.yaml)が用意され、インストールした直後からFalcoの機能を利用することができます。利用者がカスタマイズする場合はfalco_rules.local.yamlというローカルファイルを編集することで、Ruleの追加や上書きが可能になります。

Falcoは大きく3つのコンポーネントから構成されています。

  • Userspace Program (falco CLI): Falcoと接続するのに利用する。シグナルをコントロールし、Falco Driverからの情報(syscall)をパースし、必要ならアラートを送る。

  • Configuration: Falcoに関する設定。Falco自体の設定に加え、利用するRuleやアラートの送信方法などを定義する。

  • Driver: FalcoのDriver仕様に準拠したソフトウェアで、syscall情報を送信する。DriverをインストールしなければFalcoを起動することはできない。現在は3種類のDriverをサポートしている。

    • libscap libsinspというライブラリ上に構築されたKernelモジュール
    • 同モジュール上に構成されたeBPF probe
    • Userspaceで実行されるUserspace Instrumentation

Falcoの構成は以下のような図で表されます。System CallがKernelモジュールやeBPFからlibscap libsinspを経由してFalcoへ送られ、Ruleに基づいて疑わしいイベントを検知し、アラートを送信します。またKubernetesAudit Logから情報を受け取ったり、アラートの送信先としてfalco-exporterを経由してPrometheusを利用することも可能です。

※図:いずれもCNCF Webinar資料より一部抜粋

f:id:FY0323:20201227232812j:plain

f:id:FY0323:20201227232821j:plain

Falcoを使ってみる

ここから実際にFalcoを操作してみます。

検証環境

Falcoのインストール

FalcoをKubernetes上にインストールする場合はHelmを利用します。こちらのGitHubリポジトリにはHelmチャートが用意されており、これを利用すればインストールを簡単に実行できます。今回はhelm templateコマンドを利用してマニフェストファイルを生成し、それを利用してインストールを行いました。

# Chartの取得
$ git clone https://github.com/falcosecurity/charts.git
$ cd charts/


# Templateの生成
$ mkdir testdir
$ helm template --output-dir ./testdir ./falco/
wrote ./testdir/falco/templates/serviceaccount.yaml
wrote ./testdir/falco/templates/configmap.yaml
wrote ./testdir/falco/templates/clusterrole.yaml
wrote ./testdir/falco/templates/clusterrolebinding.yaml
wrote ./testdir/falco/templates/daemonset.yaml

マニフェストファイルは以下の通りです。ファイル中のRELEASE-NAME-という箇所は、適宜修正を行ってからデプロイします。またconfigmap.yamlは、設定するConfigの情報が膨大なため、ここでは省略しています(設定ファイルへのリンクのみ記載)。

serviceaccount.yaml

---
# Source: falco/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: RELEASE-NAME-falco
  namespace: default
  labels:
    app: RELEASE-NAME-falco
    chart: "falco-1.5.7"
    release: "RELEASE-NAME"
    heritage: "Helm"

clusterrole.yaml

---
# Source: falco/templates/clusterrole.yaml
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: RELEASE-NAME-falco
  labels:
    app: RELEASE-NAME-falco
    chart: "falco-1.5.7"
    release: "RELEASE-NAME"
    heritage: "Helm"
rules:
  - apiGroups:
      - extensions
      - ""
    resources:
      - nodes
      - namespaces
      - pods
      - replicationcontrollers
      - replicasets
      - services
      - daemonsets
      - deployments
      - events
      - configmaps
    verbs:
      - get
      - list
      - watch
  - apiGroups:
      - apps
    resources:
      - daemonsets
      - deployments
      - replicasets
      - statefulsets
    verbs:
      - get
      - list
      - watch
  - nonResourceURLs:
      - /healthz
      - /healthz/*
    verbs:
      - get

clusterrolebinding.yaml

---
# Source: falco/templates/clusterrolebinding.yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: RELEASE-NAME-falco
  labels:
    app: RELEASE-NAME-falco
    chart: "falco-1.5.7"
    release: "RELEASE-NAME"
    heritage: "Helm"
subjects:
  - kind: ServiceAccount
    name: RELEASE-NAME-falco
    namespace: default
roleRef:
  kind: ClusterRole
  name: RELEASE-NAME-falco
  apiGroup: rbac.authorization.k8s.io

configmap.yaml

---
# Source: falco/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: RELEASE-NAME-falco
  namespace: default
  labels:
    app: RELEASE-NAME-falco
    chart: "falco-1.5.7"
    release: "RELEASE-NAME"
    heritage: "Helm"
data:
  falco.yaml: |-
(中略)
  application_rules.yaml: |
(中略)
  falco_rules.local.yaml: |
(中略)
  falco_rules.yaml:
(中略)
  k8s_audit_rules.yaml: 
(中略)

daemonset.yaml

---
# Source: falco/templates/daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: RELEASE-NAME-falco
  namespace: default
  labels:
    app: RELEASE-NAME-falco
    chart: "falco-1.5.7"
    release: "RELEASE-NAME"
    heritage: "Helm"
spec:
  selector:
    matchLabels:
      app: RELEASE-NAME-falco
      role: security
  template:
    metadata:
      name: RELEASE-NAME-falco
      labels:
        app: RELEASE-NAME-falco
        role: security
      annotations:
        checksum/config: 87dc39d25a867a7fb6f376d0f62aae0ef2cd2476a5b6ab25af75ee23b3c55f95
        checksum/rules: 01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
        checksum/certs: 01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
    spec:
      serviceAccountName: RELEASE-NAME-falco
      tolerations:
        - effect: NoSchedule
          key: node-role.kubernetes.io/master
      containers:
        - name: falco
          image: docker.io/falcosecurity/falco:0.26.2
          imagePullPolicy: IfNotPresent
          resources:
            limits:
              cpu: 200m
              memory: 1024Mi
            requests:
              cpu: 100m
              memory: 512Mi
          securityContext:
            privileged: true
          args:
            - /usr/bin/falco
            - --cri
            - /run/containerd/containerd.sock
            - -K
            - /var/run/secrets/kubernetes.io/serviceaccount/token
            - -k
            - "https://$(KUBERNETES_SERVICE_HOST)"
            - -pk
          env:
          volumeMounts:
            - mountPath: /host/var/run/docker.sock
              name: docker-socket
            - mountPath: /host/run/containerd/containerd.sock
              name: containerd-socket
            - mountPath: /host/dev
              name: dev-fs
              readOnly: true
            - mountPath: /host/proc
              name: proc-fs
              readOnly: true
            - mountPath: /host/boot
              name: boot-fs
              readOnly: true
            - mountPath: /host/lib/modules
              name: lib-modules
            - mountPath: /host/usr
              name: usr-fs
              readOnly: true
            - mountPath: /dev/shm
              name: dshm
            - mountPath: /host/etc
              name: etc-fs
              readOnly: true
            - mountPath: /etc/falco
              name: config-volume
      volumes:
        - name: dshm
          emptyDir:
            medium: Memory
        - name: docker-socket
          hostPath:
            path: /var/run/docker.sock
        - name: containerd-socket
          hostPath:
            path: /run/containerd/containerd.sock
        - name: dev-fs
          hostPath:
            path: /dev
        - name: proc-fs
          hostPath:
            path: /proc
        - name: boot-fs
          hostPath:
            path: /boot
        - name: lib-modules
          hostPath:
            path: /lib/modules
        - name: usr-fs
          hostPath:
            path: /usr
        - name: etc-fs
          hostPath:
            path: /etc
        - name: config-volume
          configMap:
            name: RELEASE-NAME-falco
            items:
              - key: falco.yaml
                path: falco.yaml
              - key: falco_rules.yaml
                path: falco_rules.yaml
              - key: falco_rules.local.yaml
                path: falco_rules.local.yaml
              - key: application_rules.yaml
                path: rules.available/application_rules.yaml
  updateStrategy:
    type: RollingUpdate

configmap.yamlの設定ファイル:


上記マニフェストファイルを修正後、デプロイします。FalcoはDaemonSetとして定義されているので、各Node上にPodが配置されます。各Node上に配置されたFalco Podは、それぞれのNode上のイベントを監視し、デフォルトではFalco Podのログとしてアラートを出力します。

# 今回は2Node
$ kubectl get nodes
NAME                                               STATUS   ROLES    AGE   VERSION
ip-192-168-1-144.ap-northeast-1.compute.internal   Ready    <none>   33h   v1.18.9-eks-d1db3c
ip-192-168-2-241.ap-northeast-1.compute.internal   Ready    <none>   33h   v1.18.9-eks-d1db3c


# Falcoのインストール
$ kubectl apply -f testdir/falco/templates/
clusterrole.rbac.authorization.k8s.io/falco created
clusterrolebinding.rbac.authorization.k8s.io/falco created
configmap/falco created
daemonset.apps/falco created
serviceaccount/falco created


# インストール後の確認
$ kubectl get cm
NAME    DATA   AGE
falco   5      9s


$ kubectl get ds
NAME    DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
falco   2         2         0       2            0           <none>          17s


$ kubectl get pods
NAME          READY   STATUS    RESTARTS   AGE
falco-hgkqh   1/1     Running   0          32s
falco-w5ld8   1/1     Running   0          32s

Falcoの動作確認

Falcoのインストールが完了したので、Falcoがどのように動作するかを見ていきます。今回はDefault Ruleに定義されたモニタリング項目をいくつか取り上げ、Falcoのログとしてアラートが生成される様子を確認しました。

コンテナへのログイン

まずはkubectl execコマンドを利用してFalcoコンテナへのログイン・ログアウトを実行しました。コンテナ上でShellを実行することは、いくつかの設定管理ソフトウェアで例があるものの、基本的にはあまり実行することは想定されていません。そのため、コンテナ上でShellが実行されたイベントが発生すると、Falcoはそれをアラートとして出力します。またそれと関連して(?)shellスクリプトからexitした際も、Falcoはそのイベントをアラート対象と設定しています。

falco_rules.yamlでの該当するRuleはこちらこちらの箇所になります。

falco_rules.yaml

- rule: Terminal shell in container
  desc: A shell was used as the entrypoint/exec point into a container with an attached terminal.
  condition: >
    spawned_process and container
    and shell_procs and proc.tty != 0
    and container_entrypoint
    and not user_expected_terminal_shell_in_container_conditions
  output: >
    A shell was spawned in a container with an attached terminal (user=%user.name user_loginuid=%user.loginuid %container.info
    shell=%proc.name parent=%proc.pname cmdline=%proc.cmdline terminal=%proc.tty container_id=%container.id image=%container.image.repository)
  priority: NOTICE
  tags: [container, shell, mitre_execution]

(中略)

- rule: Delete or rename shell history
  desc: Detect shell history deletion
  condition: >
    (modify_shell_history or truncate_shell_history) and
       not var_lib_docker_filepath and
       not proc.name in (docker_binaries)
  output: >
    Shell history had been deleted or renamed (user=%user.name user_loginuid=%user.loginuid type=%evt.type command=%proc.cmdline fd.name=%fd.name name=%evt.arg.name path=%evt.arg.path oldpath=%evt.arg.oldpath %container.info)
  priority:
    WARNING
  tags: [process, mitre_defense_evasion]
# 別ターミナルでFalco Logsを流しておく
$ kubectl logs falco-hgkqh -f


# Falcoコンテナへログイン
$ kubectl exec -it falco-hgkqh bash
kubectl exec [POD] [COMMAND] is DEPRECATED and will be removed in a future version. Use kubectl kubectl exec [POD] -- [COMMAND] instead.
root@falco-hgkqh:/# 


# ログアウト
root@falco-hgkqh:/# exit
exit
$


# 以下のようなログが流れる

03:40:38.696280739: Notice A shell was spawned in a container with an attached terminal (user=root user_loginuid=-1 k8s.ns=default k8s.pod=falco-hgkqh container=2b1d9a24cdc0 shell=bash parent=runc cmdline=bash terminal=34816 container_id=2b1d9a24cdc0 image=falcosecurity/falco) k8s.ns=default k8s.pod=falco-hgkqh container=2b1d9a24cdc0

03:40:49.213841028: Warning Shell history had been deleted or renamed (user=root user_loginuid=-1 type=openat command=bash fd.name=/root/.bash_history name=/root/.bash_history path=<NA> oldpath=<NA> k8s.ns=default k8s.pod=falco-hgkqh container=2b1d9a24cdc0) k8s.ns=default k8s.pod=falco-hgkqh container=2b1d9a24cdc0
コンテナ上でパッケージインストール

次にログインしたコンテナ上でパッケージインストールを実行しました。コンテナは本来immutableな状態であるべきで、パッケージのインストールはコンテナイメージのBuild時になされるべきです。Falcoはコンテナ上でパッケージインストールを行うというイベントをモニタリング対象としており、イベント発生を契機にアラートを生成します。ここで該当するRuleはこちらの箇所になります。

falco-rules.yaml

# Container is supposed to be immutable. Package management should be done in building the image.
- rule: Launch Package Management Process in Container
  desc: Package management process ran inside container
  condition: >
    spawned_process
    and container
    and user.name != "_apt"
    and package_mgmt_procs
    and not package_mgmt_ancestor_procs
    and not user_known_package_manager_in_container
  output: >
    Package management process launched in container (user=%user.name user_loginuid=%user.loginuid
    command=%proc.cmdline container_id=%container.id container_name=%container.name image=%container.image.repository:%container.image.tag)
  priority: ERROR
  tags: [process, mitre_persistence]
# テスト用コンテナの起動
$ kubectl run nginx --image nginx:latest
pod/nginx created


$ kubectl get pods -o wide
NAME          READY   STATUS    RESTARTS   AGE    IP              NODE                                               NOMINATED NODE   READINESS GATES
falco-hgkqh   1/1     Running   0          15m    192.168.2.126   ip-192-168-2-241.ap-northeast-1.compute.internal   <none>           <none>
falco-w5ld8   1/1     Running   0          15m    192.168.1.117   ip-192-168-1-144.ap-northeast-1.compute.internal   <none>           <none>
nginx         1/1     Running   0          112s   192.168.1.45    ip-192-168-1-144.ap-northeast-1.compute.internal   <none>           <none>


# Nginx Podが起動したNodeに存在するFalcoのログを流しておく
$ kubectl logs falco-w5ld8 -f


# パッケージ更新
$ kubectl exec -it nginx -- apt-get update -y


# 以下のようなログが確認できる

05:16:58.737843387: Error Package management process launched in container (user=root user_loginuid=-1 command=apt-get update -y container_id=5f7008d770cd container_name=k8s_nginx_nginx_default_f7a69089-ac2a-4006-94af-78ad15ded39d_0 image=nginx:latest) k8s.ns=default k8s.pod=nginx container=5f7008d770cd k8s.ns=default k8s.pod=nginx container=5f7008d770cd
Networkツールの実行

コンテナ上でnmap digのような「疑わしい」Networkツールを実行した際も、Falcoはそれをアラートとして検知します(ここではnc ncat nmap dig tcpdump tshark ngrep telnet mitmproxy socat zmapが対象として設定されています)。Default Ruleではこちらに定義されています。

falco-rules.yaml

- rule: Launch Suspicious Network Tool in Container
  desc: Detect network tools launched inside container
  condition: >
    spawned_process and container and network_tool_procs and not user_known_network_tool_activities
  output: >
    Network tool launched in container (user=%user.name user_loginuid=%user.loginuid command=%proc.cmdline parent_process=%proc.pname
    container_id=%container.id container_name=%container.name image=%container.image.repository:%container.image.tag)
  priority: NOTICE
  tags: [network, process, mitre_discovery, mitre_exfiltration]

ここではdigコマンドを実行してみます。実際にDNSサーバへの問い合わせを行わなくとも、ログとして出力される様子がわかります。

# Falcoログのモニタリング
$ kubectl logs falco-w5ld8 -f


# Nginx Podへのdigコマンドインストール
$ kubectl exec -it nginx -- apt-get install dnsutils -y


# digコマンドの実行
$ kubectl exec -it nginx -- dig -h
Usage:  dig [@global-server] [domain] [q-type] [q-class] {q-opt}
            {global-d-opt} host [@local-server] {local-d-opt}
            [ host [@local-server] {local-d-opt} [...]]
(中略)


# 以下のようなログが確認できる

09:17:51.427951077: Notice Network tool launched in container (user=root user_loginuid=-1 command=dig -h parent_process=runc container_id=5f7008d770cd container_name=k8s_nginx_nginx_default_f7a69089-ac2a-4006-94af-78ad15ded39d_0 image=nginx:latest) k8s.ns=default k8s.pod=nginx container=5f7008d770cd k8s.ns=default k8s.pod=nginx container=5f7008d770cd
ホストディレクトリのマウント

FalcoはPodの起動するNode上のディレクトリのうち/etc /procなど重要なファイルが配置されているディレクトリを対象に、これらがコンテナからマウントされた場合にアラートを発行します。Default Rulesではこちらの箇所が該当します。

falco_rules.yaml

- rule: Launch Sensitive Mount Container
  desc: >
    Detect the initial process started by a container that has a mount from a sensitive host directory
    (i.e. /proc). Exceptions are made for known trusted images.
  condition: >
    container_started and container
    and sensitive_mount
    and not falco_sensitive_mount_containers
    and not user_sensitive_mount_containers
  output: Container with sensitive mount started (user=%user.name user_loginuid=%user.loginuid command=%proc.cmdline %container.info image=%container.image.repository:%container.image.tag mounts=%container.mounts)
  priority: INFO
  tags: [container, cis, mitre_lateral_movement]

ここではhostpathを利用してコンテナから/etcディレクトリをマウントします。

# Falcoログのモニタリング
$ kubectl logs falco-w5ld8 -f
$ kubectl logs falco-hgkqh -f


# hostpath-nginxの作成
 kubectl apply -f hostpath-nginx.yml 
pod/hostpath-nginx created

$ kubectl get pods
NAME             READY   STATUS    RESTARTS   AGE
falco-hgkqh      1/1     Running   0          101m
falco-w5ld8      1/1     Running   0          101m
hostpath-nginx   1/1     Running   0          5s
nginx            1/1     Running   0          87m


# ログの確認

05:13:24.185024277: Notice Container with sensitive mount started (user=root user_loginuid=0 command=container:3a70a57615fd k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd image=nginx:latest mounts=/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/etc-hosts:/etc/hosts::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/containers/sec-ctx-nginx/ea0ec6e1:/dev/termination-log::true:rprivate,/etc:/test-pd::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/volumes/kubernetes.io~secret/default-token-s4dhx:/var/run/secrets/kubernetes.io/serviceaccount:ro:false:rprivate) k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd

05:13:24.202081499: Notice Container with sensitive mount started (user=root user_loginuid=-1 command=docker-entrypoi /docker-entrypoint.sh nginx -g daemon off; k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd image=nginx:latest mounts=/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/etc-hosts:/etc/hosts::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/containers/sec-ctx-nginx/ea0ec6e1:/dev/termination-log::true:rprivate,/etc:/test-pd::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/volumes/kubernetes.io~secret/default-token-s4dhx:/var/run/secrets/kubernetes.io/serviceaccount:ro:false:rprivate) k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd

05:13:24.202099569: Notice Container with sensitive mount started (user=root user_loginuid=-1 command=docker-entrypoi /docker-entrypoint.sh nginx -g daemon off; k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd image=nginx:latest mounts=/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/etc-hosts:/etc/hosts::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/containers/sec-ctx-nginx/ea0ec6e1:/dev/termination-log::true:rprivate,/etc:/test-pd::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/volumes/kubernetes.io~secret/default-token-s4dhx:/var/run/secrets/kubernetes.io/serviceaccount:ro:false:rprivate) k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd

05:13:24.202205924: Notice Container with sensitive mount started (user=root user_loginuid=-1 command=nginx -g daemon off; k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd image=nginx:latest mounts=/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/etc-hosts:/etc/hosts::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/containers/sec-ctx-nginx/ea0ec6e1:/dev/termination-log::true:rprivate,/etc:/test-pd::true:rprivate,/var/lib/kubelet/pods/64ea869e-3778-4edc-9b14-8fe36d1cb439/volumes/kubernetes.io~secret/default-token-s4dhx:/var/run/secrets/kubernetes.io/serviceaccount:ro:false:rprivate) k8s.ns=<NA> k8s.pod=<NA> container=3a70a57615fd

ファイルの書き換え

Falcoは/bin /sbin /usr/bin /usr/sbinというBinaryディレクトリ上でのファイルの書き込みが発生した場合、それをアラートとして検出します。Default Rulesではこちらの箇所が該当します。

falco_rules.yaml

- rule: Write below binary dir
  desc: an attempt to write to any file below a set of binary directories
  condition: >
    bin_dir and evt.dir = < and open_write
    and not package_mgmt_procs
    and not exe_running_docker_save
    and not python_running_get_pip
    and not python_running_ms_oms
    and not user_known_write_below_binary_dir_activities
  output: >
    File below a known binary directory opened for writing (user=%user.name user_loginuid=%user.loginuid
    command=%proc.cmdline file=%fd.name parent=%proc.pname pcmdline=%proc.pcmdline gparent=%proc.aname[2] container_id=%container.id image=%container.image.repository)
  priority: ERROR
  tags: [filesystem, mitre_persistence]
# Falcoログのモニタリング
$ kubectl logs falco-w5ld8 -f


# Ngins Pod上で新しいファイルを作成
$ kubectl exec -it nginx -- touch /usr/bin/test.txt


# Falcoログの確認

05:35:59.018418997: Error File below a known binary directory opened for writing (user=root user_loginuid=-1 command=touch /usr/bin/test.txt file=/usr/bin/test.txt parent=<NA> pcmdline=<NA> gparent=<NA> container_id=5f7008d770cd image=nginx) k8s.ns=default k8s.pod=nginx container=5f7008d770cd k8s.ns=default k8s.pod=nginx container=5f7008d770cd
その他

Falcoは上記5つのほかにも多くのイベントをデフォルトのモニタリング対象として設定しています。

  • Privilegedコンテナによる権限昇格
  • setns等によるNamespaceの変更
  • Symlinkの作成
  • ssh scpなどSSHバイナリの実行
  • Linux coreutilsパッケージに含まれる実行ファイルの変更
  • パスワード管理に関連する実行ファイル(shadow-utilsパッケージ、passwdなど)の変更

参考ドキュメント

Open Policy Agentに入門する

今回は汎用的な環境に対してポリシーエンジンを提供するOpen Policy Agent(OPA)について調査しました。

Policy as Codeとは

OPAを操作する前に、Policy as Codeというコンセプトについて少し触れます。Policy as CodeはHashiCorp社がSentinelというツールを発表した際に用いたコンセプト(Twitterで検索したところ、用語自体はこれが初出ではなさそう)で、名前の通りアクセスポリシーなどをコードとして記述・管理可能とすることを意味します。ポリシーをコードで表現することで、バージョン管理や自動テストなどのベストプラクティスをポリシー定義に対して適用できるようになります。

TerraformやAnsibleなど様々なプロダクトが登場し、インフラをコードで管理するInfrastructure as Code (IaC)が進むことで、インフラの構築・運用の自動化やバージョン管理などが普及しています。その一方で、各リソースを定義するコードに記述ミスが含まれれば、それはインフラの障害につながります。IaCが普及することで、そのような記述ミスが及ぼす影響範囲が拡大し、これを事前に防ぐための機構が求められました。

Sentinelのドキュメントには、Policy as Codeを導入することで得られるメリットを、以下のように紹介しています。

  • Sandboxing: 自動化されたシステムに対する枠組み(ポリシー)を提供する。自動化された領域が増えるほど、危険な振る舞いを行うリスクが高まるが、それらを手動でチェックするのでなく、ポリシーをコードとして定義することで検証を可能にする。

  • Codification: ポリシーをコードで定義することで、どのようなポリシーが設定されているかをコードから知ることが可能になる。また必要な場合はコメントを追加し、コードの理解をサポートすることもできる。

  • Version Control: コードとして管理することでGitによるバージョン管理を可能にし、履歴や差分、Pull Requestなどの機能を利用することができる。

  • Testing: アプリ開発のCIサイクルにポリシーテストを組み込むことができる。GitHubなどと組み合わせることで、Pull Requestをトリガーとした自動ポリシーテストなどを行い、Mergeする前に検証を行うことができる。

  • Automation: Testingとも重なるが、コードで定義することで、各種自動化ツールに取り込むことが容易になる。

Sentinelが登場してから数年が経過した現在、多くの企業はアプリケーションのコンテナ化を進め、コンテナ実行基盤としてKubernetes業界標準となっています。KubernetesではYAML形式で記述されたマニフェストファイルに、実行するコンテナの種類や設定などを定義し、宣言的にリソースの作成・管理を行います。Kubernetes上でコンテナを管理することで、アプリケーションの可用性や拡張性などを獲得し、大規模環境においても安定したシステムパフォーマンスを実現することが可能になります。

一方、各アプリケーションやその他リソースを定義するマニフェストファイルは、システムが拡大するほど増加し、その構成は複雑になります。それらはWall of YAMLとも表現されるほど増加する場合があり、大量のYAMLファイルを管理するためにHelmKustomizeなどのマニフェストファイル管理ツールも登場しました。


※参考ドキュメント:


また、Kubernetesを利用する組織やプロジェクトによっては、独自のポリシーを指定している場合もあります。マニフェストファイルが増加するほど、それらが組織のポリシーに従っているかをチェックするのは困難になり、ポリシーを違反する可能性も高くなります。

これを防ぐアプローチとしては、①マニフェストファイルをデプロイする前にファイルを検証する、②マニフェスト適用後のValidationチェック機能で検知する、の2つに大きく分かれるかと思います。デプロイ前のチェックツールは以前の記事中にて簡単に紹介させていただきました。今回はデプロイ実行後にポリシー違反を検出するプロダクトの一つとしてOpen Policy Agentを取り上げました。

Open Policy Agentとは

Open Policy Agentは複数の環境で利用できる汎用的なポリシーエンジンを提供するOSSです。環境全体で共通のポリシーエンジンとして利用することが可能なため、ポリシーの定義をOPAにてすべて管理することが可能になります。

OPAには以下のような特徴が備わっています。

  • Policy Decoupling: あるソフトウェアがポリシーの判断を必要とする場合、ソフトウェアはJsonなどの構造化データをOPAに提供し、OPAはその入力データを評価します。評価結果は任意の構造化データとして返し、ポリシーに違反した場合は何らかの処理を実行することができます。OPAではポリシーによる検証をソフトウェア外部に分離することで、ポリシーの設定や更新を、ソフトウェアへの影響なしに実行することができます。

  • Regoによる記述: OPAはRegoという言語を用いてポリシーを定義し、先ほどのPolicy as Codeを実現します。RegoはDataLogというクエリ言語にインスパイアされており、DataLogがJSONのような構造化された形式をサポートするよう拡張されています。Regoを採用する理由として、Nestedなデータを参照するためのサポートを提供すること、クエリが正確で曖昧さを排除したものであること、宣言型のクエリ言語であり、命令型の言語と比べてシンプルに記述できること、クエリの最適化によるパフォーマンス向上ができること、などを挙げています。

  • 複数の環境で利用可能: OPAは汎用的なポリシーエンジンであり、Kubernetesだけでなく、Docker・Linux・Terraform・Envoy・Kafkaなど様々な環境・プロダクトに対して適用することが可能です。またOPAはServerとして起動するほかにも、Go Libraryとしてアプリケーションコードに組み込んだり、対話形式(REPL(Read-Eval-Print Loop))でポリシーのテストを実行することも可能です。

  • CNCF傘下のプロジェクト: OPAはCNCFがホストとして管理するプロダクトで、Security & Complianceのカテゴリに分類されています。同じカテゴリに含まれるプロダクトとしてはFalcoNotaryなどがあり、2020年12月の時点でOPAのプロジェクトの成熟度は”Incubating”となっています。

OPAは以下のような構成となっています。上述の通り、クライアントがポリシーの検証を行う場合はJsonなどのデータをクエリとしてOPAに渡し、OPAの持つPolicyを元にテスト結果を返します。OPAで利用するJsonなどの階層型データはDocumentと呼ばれ、クライアントから投げられるDocumentBase Document、OPAがポリシーを元に生成したDocumentVirtual Documentと呼びます。

※画像:OPA公式ドキュメントより

architecture


※参考ドキュメント:


Kubernetes上でOPAを利用する場合

Kubernetes上でOPAを利用する場合、Admission Controllerを利用する方法とGatekeeperを利用する方法とに分かれます。今回はAdmission Controllerのほうを試してみました。

Admission ControllerはKubernetes API Serverにリクエスト制御を追加する機能で、APIリクエストに対する検証や変更、拒否などを実行することが可能になります。Admission Controlのプロセスは大きくMutating Admission Validating Admissionの2つに分かれ、MutatingAdmissionWebhookValidatingAdmissionWebhookという2つのリソースを利用することで、それぞれのフェーズにおけるカスタムロジックを実装することができます。

※画像: Kubernetes公式ブログより

admission-controller

Admission ControllerはKubernetesに対してポリシーを施行する上でのベースとなります。OPAをAdmission Controllerとしてデプロイすることで、各Objectに対するポリシーのチェック(例:Labelの有無、特定のコンテナレジストリを指定しているか否か)、Objectの修正(例:Sidecarコンテナの追加)などを実行することができます。

Kubernetes APIServerは、各Objectが作成・変更・削除されるたび、OPAへクエリを流すように設定されます。API ServerはWebhookリクエストにObjectの情報を付与してOPAへ送信し、OPAはAdmissionReviewRequestによって入力されたDocumentを評価し、AdmissionReviewResponseを生成・API Serverへ返信します。

Admission Controllerを利用する場合、kube-mgmtというソフトウェアをSidecarコンテナとして利用します。kube-mgmtはOPAにPolicyを読み込ませること・Kubernetes ObjectをOPAへ送ることを責務とし、API Serverからの通信はkube-mgmtを経由してOPAへと渡されます。kube-mgmtConfigMapとして受け取ったPolicyをOPAへ渡し、動的にPolicyを更新することができます。

※画像:OPA公式ドキュメントより

kube-mgmt

OPAを使ってみる

ここからは実際にOPAを操作してみます。今回はOPAの公式ドキュメントで紹介されている2つの例(1つ目の例2つ目の例)をなぞっていきます。

検証環境

例1: Admission Controllerを利用しない場合

一つ目はAdmission Controllerを利用せず、OPAとPolicyだけを設定する場合です(ドキュメントはこちら)。この場合OPAはKubernetes Objectが作られるたびにそれらを評価しますが、Objectの作成や変更に対して許可・拒否するような動きはできません。

まずはOPAに設定するPolicyを用意します。ここではPodのホスト名を取得し、それを含めたメッセージを返す、というポリシーを指定します。

example.rego

package example

greeting = msg {
    info := opa.runtime()
    hostname := info.env["HOSTNAME"] # Kubernetes sets the HOSTNAME environment variable.
    msg := sprintf("hello from pod %q!", [hostname])
}

上記Regoファイルを指定してConfigMapを作成します。

# ConfigMap作成
$ kubectl create configmap example-policy --from-file example.rego
configmap/example-policy created


# 作成後の確認
$ kubectl get cm
NAME             DATA   AGE
example-policy   1      35s


$ kubectl describe cm example-policy
Name:         example-policy
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
example.rego:
----
package example

greeting = msg {
    info := opa.runtime()
    hostname := info.env["HOSTNAME"] # Kubernetes sets the HOSTNAME environment variable.
    msg := sprintf("hello from pod %q!", [hostname])
}
Events:  <none>

次にPodとして起動するOPAの定義ファイルを用意します。マニフェストファイルには、先ほど作成したConfigMapを指定し、マウントするよう設定しています。

deployment-opa.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: opa
  labels:
    app: opa
spec:
  replicas: 1
  selector:
    matchLabels:
      app: opa
  template:
    metadata:
      labels:
        app: opa
      name: opa
    spec:
      containers:
      - name: opa
        image: openpolicyagent/opa:latest
        ports:
        - name: http
          containerPort: 8181
        args:
        - "run"
        - "--ignore=.*"  # exclude hidden dirs created by Kubernetes
        - "--server"
        - "/policies"
        volumeMounts:
        - readOnly: true
          mountPath: /policies
          name: example-policy
      volumes:
      - name: example-policy
        configMap:
          name: example-policy
# Deployment作成
$ kubectl apply -f deployment-opa.yml
deployment.apps/opa created


# 作成後の確認
$ kubectl get pods
NAME                   READY   STATUS    RESTARTS   AGE
opa-754686d87c-d2rgs   1/1     Running   0          15s

OPAが無事に起動しました。次にOPAのAPIへアクセスできるよう、Serviceリソースを作成します。

service-opa.yaml

kind: Service
apiVersion: v1
metadata:
  name: opa
  labels:
    app: opa
spec:
  type: NodePort
  selector:
    app: opa
  ports:
    - name: http
      protocol: TCP
      port: 8181
      targetPort: 8181
$ kubectl apply -f service-opa.yml
service/opa created


$ kubectl get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes   ClusterIP   172.20.0.1      <none>        443/TCP          6d3h
opa          NodePort    172.20.193.87   <none>        8181:30625/TCP   3s

Serviceが作成されましたが、今回はこれを一度編集しLoadBalancerへと変更しました。

# Serviceの編集 (type: LoadBalancerへ変更)
$ kubectl edit svc opa
service/opa edited


# 修正後の確認
$ kubectl get svc
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP                                                                   PORT(S)          AGE
kubernetes   ClusterIP      172.20.0.1      <none>                                                                        443/TCP          6d3h
opa          LoadBalancer   172.20.193.87   a1b4926b227974c33a0f51c37c2a82a0-647219299.ap-northeast-1.elb.amazonaws.com   8181:30625/TCP   71s

これでOPAにアクセスできるので、以下のようなコマンドを実行し、OPAのAPIへアクセスします。

$ curl a1b4926b227974c33a0f51c37c2a82a0-647219299.ap-northeast-1.elb.amazonaws.com:8181/v1/data
{"result":{"example":{"greeting":"hello from pod \"opa-754686d87c-d2rgs\"!"}}}

無事にアクセスし、Podのホスト名がメッセージに含まれることも確認できました。

例2: Admission Controllerを利用する場合

次にAdmission Controllerを利用する場合を見てみます(ドキュメントはこちら)。今回はValidatingAdmissionWebhookを利用し、特定の条件を満たす・満たさないObjectの作成を拒否するような例です。

なお、こちらは公式ドキュメントをなぞるだけだとうまくいかないため(ValidatingAdmissionWebhookのバージョンが古いため?)、以下のドキュメントも参照しながら進めました。


※参考ドキュメント:


Namespace作成とContext切り替え

まずはOPA専用のNamespaceを用意し、kubectl configコマンドを利用してContextを作成・変更します。

# Namespaceの作成
$ kubectl create namespace opa
namespace/opa created


$ kubectl get ns
NAME              STATUS   AGE
default           Active   29m
kube-node-lease   Active   29m
kube-public       Active   29m
kube-system       Active   29m
opa               Active   2s

# kubectl configの確認
$ kubectl config view

(中略)

contexts:
- context:
    cluster: eks-cluster.ap-northeast-1.eksctl.io
    user: testuser@eks-cluster.ap-northeast-1.eksctl.io
  name: testuser@eks-cluster.ap-northeast-1.eksctl.io
current-context: testuser@eks-cluster.ap-northeast-1.eksctl.io

(中略)

# Contextの作成
$ kubectl config set-context opa-tutorial --user testuser@eks-cluster.ap-northeast-1.eksctl.io --cluster eks-cluster.ap-northeast-1.eksctl.io --namespace opa
Context "opa-tutorial" created.


# Contextの切り替え
$ kubectl config use-context opa-tutorial
Switched to context "opa-tutorial".


# 切り替え後の確認
$ kubectl config view

(中略)

contexts:
- context:
    cluster: eks-cluster.ap-northeast-1.eksctl.io
    namespace: opa
    user: testuser@eks-cluster.ap-northeast-1.eksctl.io
  name: opa-tutorial
- context:
    cluster: eks-cluster.ap-northeast-1.eksctl.io
    user: testuser@eks-cluster.ap-northeast-1.eksctl.io
  name: testuser@eks-cluster.ap-northeast-1.eksctl.io
current-context: opa-tutorial

(中略)
TLS Certificateの用意

次にAdmission ControllerとOPAとを通信させる準備を行います。Admission WebhookはHTTPSでアクセスしなければならないため、各種証明書を用意します。今回はopensslコマンドを利用します。

# 作業フォルダの用意
$ mkdir opa
$ cd opa


# 秘密鍵・CSRの作成
$ openssl genrsa -out ca.key 2048
Generating RSA private key, 2048 bit long modulus (2 primes)
...............................+++++
......+++++
e is 65537 (0x010001)

$ openssl req -x509 -new -nodes -key ca.key -days 100000 -out ca.crt -subj "/CN=admission_ca"
Cant load /home/testuser/.rnd into RNG
140456210534848:error:2406F079:random number generator:RAND_load_file:Cannot open file:../crypto/rand/randfile.c:88:Filename=/home/testuser/.rnd


# TLS key / 証明書の作成
$ cat server.conf 
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth


$ openssl genrsa -out server.key 2048
Generating RSA private key, 2048 bit long modulus (2 primes)
..................+++++
......................+++++
e is 65537 (0x010001)

$ openssl req -new -key server.key -out server.csr -subj "/CN=opa.opa.svc" -config server.conf

$ openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 100000 -extensions v3_req -extfile server.conf
Signature ok
subject=CN = opa.opa.svc
Getting CA Private Key

上記作業で作成したファイルを利用してSecretリソースを作成し、TLS CredentialsをOPAに渡せるようにします。

$ kubectl create secret tls opa-server --cert=server.crt --key=server.key
secret/opa-server created

$ kubectl get secret
NAME                  TYPE                                  DATA   AGE
default-token-kprlq   kubernetes.io/service-account-token   3      9m20s
opa-server            kubernetes.io/tls                     2      8s
OPAのデプロイ

次にAdmission ControllerとしてOPAをデプロイするため、以下のマニフェストファイルを用意します。こちらはOPAのドキュメントから一部記載を変更しています。

OPAコンテナは、先ほどの1つ目の例と異なり、HTTPS通信を行うための設定が追加されています。また、こちらの例ではkube-mgmtがコンテナとして追加されています。--replicate-cluster --replicateオプションでOPAへReplicateするObjectを限定しており、それぞれNamespace Ingressを指定しています。

ConfigMapではデフォルトのPolicyを設定しており、ここではAdmissionの結果がdenyとなった場合はその理由を、それ以外は”allowed: trueのメッセージを返す、という設定となっています。

admission-controller.yaml

# Grant OPA/kube-mgmt read-only access to resources. This lets kube-mgmt
# replicate resources into OPA so they can be used in policies.
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: opa-viewer
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: Group
  name: system:serviceaccounts:opa
  apiGroup: rbac.authorization.k8s.io
---
# Define role for OPA/kube-mgmt to update configmaps with policy status.
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: opa
  name: configmap-modifier
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["update", "patch"]
---
# Grant OPA/kube-mgmt role defined above.
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: opa
  name: opa-configmap-modifier
roleRef:
  kind: Role
  name: configmap-modifier
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: Group
  name: system:serviceaccounts:opa
  apiGroup: rbac.authorization.k8s.io
---
kind: Service
apiVersion: v1
metadata:
  name: opa
  namespace: opa
spec:
  selector:
    app: opa
  ports:
  - name: https
    protocol: TCP
    port: 443
    targetPort: 443
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: opa
  namespace: opa
  name: opa
spec:
  replicas: 1
  selector:
    matchLabels:
      app: opa
  template:
    metadata:
      labels:
        app: opa
      name: opa
    spec:
      containers:
        # WARNING: OPA is NOT running with an authorization policy configured. This
        # means that clients can read and write policies in OPA. If you are
        # deploying OPA in an insecure environment, be sure to configure
        # authentication and authorization on the daemon. See the Security page for
        # details: https://www.openpolicyagent.org/docs/security.html.
        - name: opa
          image: openpolicyagent/opa:latest
          args:
            - "run"
            - "--server"
            - "--tls-cert-file=/certs/tls.crt"
            - "--tls-private-key-file=/certs/tls.key"
            - "--addr=0.0.0.0:443"
            - "--addr=http://127.0.0.1:8181"
            - "--log-format=json-pretty"
            - "--set=decision_logs.console=true"
          volumeMounts:
            - readOnly: true
              mountPath: /certs
              name: opa-server
          readinessProbe:
            httpGet:
              path: /health?plugins&bundle
              scheme: HTTPS
              port: 443
            initialDelaySeconds: 3
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health
              scheme: HTTPS
              port: 443
            initialDelaySeconds: 3
            periodSeconds: 5
        - name: kube-mgmt
          image: openpolicyagent/kube-mgmt:0.11
          args:
            - "--replicate-cluster=v1/namespaces"
            - "--replicate=extensions/v1beta1/ingresses"
      volumes:
        - name: opa-server
          secret:
            secretName: opa-server
---
kind: ConfigMap
apiVersion: v1
metadata:
  name: opa-default-system-main
  namespace: opa
# apiVersion: v1beta1 to v1
data:
  main: |
    package system

    import data.kubernetes.admission

    main = {
      "apiVersion": "admission.k8s.io/v1",  # v1beta1から変更
      "kind": "AdmissionReview",
      "response": response,
    }

    default uid = ""

    uid = input.request.uid

    response = {
        "allowed": false,
        "uid": uid,
        "status": {
            "reason": reason,
        },
    } {
        reason = concat(", ", admission.deny)
        reason != ""
    }
    else = {"allowed": true, "uid": uid}

なお、Regoをテストする環境としてRego Playgroundというものが、OPAの開発元であるStyra社から提供されており、こちらで簡単にテストを行うことができます。

上記マニフェストファイルで組み込んでいるPolicyをテストすると、以下のように動作を確認することができます。

※画像:Rego Playgroundでの実行結果(リンクはこちら

f:id:FY0323:20201227102705j:plain

上記マニフェストファイルをデプロイします。

# OPAデプロイ
$ kubectl apply -f admission-controller.yaml 
clusterrolebinding.rbac.authorization.k8s.io/opa-viewer created
role.rbac.authorization.k8s.io/configmap-modifier created
rolebinding.rbac.authorization.k8s.io/opa-configmap-modifier created
service/opa created
deployment.apps/opa created
configmap/opa-default-system-main created



# デプロイ後の確認
$ kubectl get pods
NAME                   READY   STATUS    RESTARTS   AGE
opa-6cfd4b6797-ttwnv   2/2     Running   0          23s



$ kubectl describe pod opa-6cfd4b6797-ttwnv
Name:         opa-6cfd4b6797-ttwnv
Namespace:    opa
Priority:     0
Node:         ip-192-168-2-241.ap-northeast-1.compute.internal/192.168.2.241
Start Time:   Sat, 26 Dec 2020 14:32:03 +0900
Labels:       app=opa
              pod-template-hash=6cfd4b6797
Annotations:  kubernetes.io/psp: eks.privileged
Status:       Running
IP:           192.168.2.126
IPs:
  IP:           192.168.2.126
Controlled By:  ReplicaSet/opa-6cfd4b6797
Containers:
  opa:
    Container ID:  docker://1b0aff5054362e51eaee4e4a7768488d29bcb737648d7e08487e6ebba9419593
    Image:         openpolicyagent/opa:latest
    Image ID:      docker-pullable://openpolicyagent/opa@sha256:d5b2a82662ce18c3456645b73519592835c96e5493c0ebf35e7dcbc5474c730b
    Port:          <none>
    Host Port:     <none>
    Args:
      run
      --server
      --tls-cert-file=/certs/tls.crt
      --tls-private-key-file=/certs/tls.key
      --addr=0.0.0.0:443
      --addr=http://127.0.0.1:8181
      --log-format=json-pretty
      --set=decision_logs.console=true
    State:          Running
      Started:      Sat, 26 Dec 2020 14:32:09 +0900
    Ready:          True
    Restart Count:  0
    Liveness:       http-get https://:443/health delay=3s timeout=1s period=5s #success=1 #failure=3
    Readiness:      http-get https://:443/health%3Fplugins&bundle delay=3s timeout=1s period=5s #success=1 #failure=3
    Environment:    <none>
    Mounts:
      /certs from opa-server (ro)
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-kprlq (ro)
  kube-mgmt:
    Container ID:  docker://1e2afda1101a01d9a4c7b536289e6bafbbcefe8ab156f0f6f09251854dd695ec
    Image:         openpolicyagent/kube-mgmt:0.11
    Image ID:      docker-pullable://openpolicyagent/kube-mgmt@sha256:6ac4d979fda61e15123b43e8e6d2cd6163f3ae0f6dcf1785a8507012d827f8a3
    Port:          <none>
    Host Port:     <none>
    Args:
      --replicate-cluster=v1/namespaces
      --replicate=extensions/v1beta1/ingresses
    State:          Running
      Started:      Sat, 26 Dec 2020 14:32:16 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-kprlq (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  opa-server:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  opa-server
    Optional:    false
  default-token-kprlq:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-kprlq
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age   From                                                       Message
  ----    ------     ----  ----                                                       -------
  Normal  Scheduled  34s   default-scheduler                                          Successfully assigned opa/opa-6cfd4b6797-ttwnv to ip-192-168-2-241.ap-northeast-1.compute.internal
  Normal  Pulling    33s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Pulling image "openpolicyagent/opa:latest"
  Normal  Pulled     29s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Successfully pulled image "openpolicyagent/opa:latest"
  Normal  Created    28s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Created container opa
  Normal  Started    28s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Started container opa
  Normal  Pulling    28s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Pulling image "openpolicyagent/kube-mgmt:0.11"
  Normal  Pulled     22s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Successfully pulled image "openpolicyagent/kube-mgmt:0.11"
  Normal  Created    21s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Created container kube-mgmt
  Normal  Started    21s   kubelet, ip-192-168-2-241.ap-northeast-1.compute.internal  Started container kube-mgmt

次にOPAをAdmission Controllerとして機能させるために必要なValidatingWebhookConfigurationを用意します。こちらはOPAの公式ドキュメントではapiVersion: admissionregistration.k8s.io/v1beta1となっていますが、今回はadmissionregistration.k8s.io/v1に変更しています。またそれに合わせ、webhooks.admissionReviewVersions webhooks.sideEffectsという2つの必須フィールドを追加しています。

以下の設定では、特定のラベル(openpolicyagent.org/webhook=ignore)が付与されたNamespaceを除き、Create Updateの操作を行う全てのリソースに対してPolicyを適用します。

またwebhooks.clientConfig.caBundleではca.crtBase64エンコードした値を設定します。

webhook-configuration.yaml

kind: ValidatingWebhookConfiguration
apiVersion: admissionregistration.k8s.io/v1
metadata:
  name: opa-validating-webhook
webhooks:
  - name: validating-webhook.openpolicyagent.org
    namespaceSelector:
      matchExpressions:
      - key: openpolicyagent.org/webhook
        operator: NotIn
        values:
        - ignore
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: ["*"]
        apiVersions: ["*"]
        resources: ["*"]
    clientConfig:
      caBundle: "<base64-encoded ca.crt>"
      service:
        namespace: opa
        name: opa
    admissionReviewVersions: ["v1", "v1beta1"]
    sideEffects: None

まずは上記ルールが該当すると動作に影響の出てしまうkube-system opaの2つのNamespaceに対してラベルを付与します。

$ kubectl label ns kube-system openpolicyagent.org/webhook=ignore
namespace/kube-system labeled

$ kubectl label ns opa openpolicyagent.org/webhook=ignore
namespace/opa labeled

$ kubectl get ns --show-labels
NAME              STATUS   AGE   LABELS
default           Active   54m   <none>
kube-node-lease   Active   54m   <none>
kube-public       Active   54m   <none>
kube-system       Active   54m   openpolicyagent.org/webhook=ignore
opa               Active   25m   openpolicyagent.org/webhook=ignore

上記マニフェストファイルをデプロイします。

$ kubectl apply -f webhook-configuration.yaml 
validatingwebhookconfiguration.admissionregistration.k8s.io/opa-validating-webhook created

デプロイ後にOPAのログを確認してみます。

$ kubectl get pods
NAME                   READY   STATUS    RESTARTS   AGE
opa-6cfd4b6797-ttwnv   2/2     Running   0          8m20s


$ kubectl logs -l app=opa -c opa -f
  "level": "info",
  "msg": "Sent response.",
  "req_id": 227,
  "req_method": "POST",
  "req_path": "/",
  "resp_bytes": 134,
  "resp_duration": 0.814134,
  "resp_status": 200,
  "time": "2020-12-26T05:40:43Z"
}
{
  "client_addr": "192.168.2.241:46690",
  "level": "info",
  "msg": "Received request.",
  "req_id": 228,
  "req_method": "GET",
  "req_path": "/health",
  "time": "2020-12-26T05:40:43Z"
}
{
  "client_addr": "192.168.2.241:46690",
  "level": "info",
  "msg": "Sent response.",
  "req_id": 228,
  "req_method": "GET",
  "req_path": "/health",
  "resp_bytes": 2,
  "resp_duration": 0.373116,
  "resp_status": 200,
  "time": "2020-12-26T05:40:43Z"
}
{
  "client_addr": "192.168.2.241:46704",
  "level": "info",
  "msg": "Received request.",
  "req_id": 229,
  "req_method": "GET",
  "req_path": "/health",
  "time": "2020-12-26T05:40:47Z"
}
{
  "client_addr": "192.168.2.241:46704",
  "level": "info",
  "msg": "Sent response.",
  "req_id": 229,
  "req_method": "GET",
  "req_path": "/health",
  "resp_bytes": 2,
  "resp_duration": 0.347135,
  "resp_status": 200,
  "time": "2020-12-26T05:40:47Z"
}
{
  "client_addr": "192.168.2.241:46708",
  "level": "info",
  "msg": "Received request.",
  "req_id": 230,
  "req_method": "GET",
  "req_path": "/health",
  "time": "2020-12-26T05:40:48Z"
}
{
  "client_addr": "192.168.2.241:46708",
  "level": "info",
  "msg": "Sent response.",
  "req_id": 230,
  "req_method": "GET",
  "req_path": "/health",
  "resp_bytes": 2,
  "resp_duration": 0.643688,
  "resp_status": 200,
  "time": "2020-12-26T05:40:48Z"
}
^C
Admission Controlのテスト

ここから設定したAdmission Controlが機能するかをテストしてみます。今回はingressで利用できるホスト名を制限するようなPolicyを作成します。

ingress-whitelist.rego

package kubernetes.admission

import data.kubernetes.namespaces

operations = {"CREATE", "UPDATE"}

deny[msg] {
    input.request.kind.kind == "Ingress"
    operations[input.request.operation]
    host := input.request.object.spec.rules[_].host
    not fqdn_matches_any(host, valid_ingress_hosts)
    msg := sprintf("invalid ingress host %q", [host])
}

valid_ingress_hosts = {host |
    whitelist := namespaces[input.request.namespace].metadata.annotations["ingress-whitelist"]
    hosts := split(whitelist, ",")
    host := hosts[_]
}

fqdn_matches_any(str, patterns) {
    fqdn_matches(str, patterns[_])
}

fqdn_matches(str, pattern) {
    pattern_parts := split(pattern, ".")
    pattern_parts[0] == "*"
    str_parts := split(str, ".")
    n_pattern_parts := count(pattern_parts)
    n_str_parts := count(str_parts)
    suffix := trim(pattern, "*.")
    endswith(str, suffix)
}

fqdn_matches(str, pattern) {
    not contains(pattern, "*")
    str == pattern
}

上記Regoファイルを利用し、ConfigMapを作成します。ConfigMap作成後、リソースの状態を確認すると、Policyとして登録されたかどうかがAnnotation(openpolicyagent.org/policy-status)に付与されます。

# ConfigMap作成
$ kubectl create configmap ingress-whitelist --from-file=ingress-whitelist.rego
configmap/ingress-whitelist created


# 作成後の確認
$ kubectl get cm
NAME                      DATA   AGE
ingress-whitelist         1      6s
opa-default-system-main   1      20m


$ kubectl get configmap ingress-whitelist -o yaml
apiVersion: v1
data:
  ingress-whitelist.rego: "package kubernetes.admission\r\n\r\nimport data.kubernetes.namespaces\r\n\r\noperations
    = {\"CREATE\", \"UPDATE\"}\r\n\r\ndeny[msg] {\r\n\tinput.request.kind.kind ==
    \"Ingress\"\r\n\toperations[input.request.operation]\r\n\thost := input.request.object.spec.rules[_].host\r\n\tnot
    fqdn_matches_any(host, valid_ingress_hosts)\r\n\tmsg := sprintf(\"invalid ingress
    host %q\", [host])\r\n}\r\n\r\nvalid_ingress_hosts = {host |\r\n\twhitelist :=
    namespaces[input.request.namespace].metadata.annotations[\"ingress-whitelist\"]\r\n\thosts
    := split(whitelist, \",\")\r\n\thost := hosts[_]\r\n}\r\n\r\nfqdn_matches_any(str,
    patterns) {\r\n\tfqdn_matches(str, patterns[_])\r\n}\r\n\r\nfqdn_matches(str,
    pattern) {\r\n\tpattern_parts := split(pattern, \".\")\r\n\tpattern_parts[0] ==
    \"*\"\r\n\tstr_parts := split(str, \".\")\r\n\tn_pattern_parts := count(pattern_parts)\r\n\tn_str_parts
    := count(str_parts)\r\n\tsuffix := trim(pattern, \"*.\")\r\n\tendswith(str, suffix)\r\n}\r\n\r\nfqdn_matches(str,
    pattern) {\r\n    not contains(pattern, \"*\")\r\n    str == pattern\r\n}"
kind: ConfigMap
metadata:
  annotations:
    openpolicyagent.org/policy-status: '{"status":"ok"}'  # Policyの追加が成功している
  creationTimestamp: "2020-12-26T05:52:00Z"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .: {}
          f:openpolicyagent.org/policy-status: {}
    manager: kube-mgmt
    operation: Update
    time: "2020-12-26T05:52:00Z"
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:data:
        .: {}
        f:ingress-whitelist.rego: {}
    manager: kubectl
    operation: Update
    time: "2020-12-26T05:52:00Z"
  name: ingress-whitelist
  namespace: opa
  resourceVersion: "11734"
  selfLink: /api/v1/namespaces/opa/configmaps/ingress-whitelist
  uid: 6810a1e8-d817-4293-95d0-08644bdff5da

Policyを設定したのでKubernetes Objectを作成します。今回はingressリソースを異なるNamespaceに作成し、それぞれのNamespaceのAnnotationで指定したホスト名と一致しないものをingressが利用した場合は、作成を却下します。

以下のマニフェストファイルを用意します。

qa-namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  annotations:
    ingress-whitelist: "*.qa.acmecorp.com,*.internal.acmecorp.com"
  name: qa

production-namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  annotations:
    ingress-whitelist: "*.acmecorp.com"
  name: production

ingress-ok.yaml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress-ok
spec:
  rules:
  - host: signin.acmecorp.com
    http:
      paths:
      - backend:
          serviceName: nginx
          servicePort: 80

ingress-bad.yaml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress-bad
spec:
  rules:
  - host: acmecorp.com
    http:
      paths:
      - backend:
          serviceName: nginx
          servicePort: 80

上記マニフェストファイルをデプロイします。まずはNamespaceを作成します。

# Namespaceの作成
$ kubectl apply -f qa-namespace.yaml 
namespace/qa created

$ kubectl apply -f production-namespace.yaml 
namespace/production created

$ kubectl get ns
NAME              STATUS   AGE
default           Active   69m
kube-node-lease   Active   69m
kube-public       Active   69m
kube-system       Active   69m
opa               Active   40m
production        Active   4s
qa                Active   14s

次にingressを作成します。するとingress-ok.yamlは無事に作成できる一方、ingress-bad.yamlのほうはエラーメッセージが表示されます。メッセージを見るとAdmission Webhookがリクエストを拒否したと表示されており、OPA(+Admission Controller)が正しく機能していることが分かります。

# ingressの作成(成功)
$ kubectl create -f ingress-ok.yaml -n production
ingress.extensions/ingress-ok created

$ kubectl get ingress -A
NAMESPACE    NAME         CLASS    HOSTS                 ADDRESS   PORTS   AGE
production   ingress-ok   <none>   signin.acmecorp.com             80      11s


# ingressの作成(失敗)
$ kubectl create -f ingress-bad.yaml -n qa
Error from server (invalid ingress host "acmecorp.com"): error when creating "ingress-bad.yaml": admission webhook "validating-webhook.openpolicyagent.org" denied the request: invalid ingress host "acmecorp.com"
Policyの修正とテスト

OPAは他のアプリケーションに影響を与えることなくPolicyを更新することができます。ここではingressが異なるNamespaceで同じホスト名を所有する場合、その作成を却下するようなPolicyを設定します。

ingress-conflict.rego

package kubernetes.admission

import data.kubernetes.ingresses

deny[msg] {
    some other_ns, other_ingress
    input.request.kind.kind == "Ingress"
    input.request.operation == "CREATE"
    host := input.request.object.spec.rules[_].host
    ingress := ingresses[other_ns][other_ingress]
    other_ns != input.request.namespace
    ingress.spec.rules[_].host == host
    msg := sprintf("invalid ingress host %q (conflicts with %v/%v)", [host, other_ns, other_ingress])
}

上記regoファイルを利用してConfigMapを作成します。

# ConfigMapの作成
$ kubectl create configmap ingress-conflicts --from-file=ingress-conflicts.rego
configmap/ingress-conflicts created


# 作成後の確認
$ kubectl get cm
NAME                      DATA   AGE
ingress-conflicts         1      3s
ingress-whitelist         1      8m10s
opa-default-system-main   1      28m


$ kubectl get configmap ingress-conflicts -o yaml
apiVersion: v1
data:
  ingress-conflicts.rego: "package kubernetes.admission\r\n\r\nimport data.kubernetes.ingresses\r\n\r\ndeny[msg]
    {\r\n    some other_ns, other_ingress\r\n    input.request.kind.kind == \"Ingress\"\r\n
    \   input.request.operation == \"CREATE\"\r\n    host := input.request.object.spec.rules[_].host\r\n
    \   ingress := ingresses[other_ns][other_ingress]\r\n    other_ns != input.request.namespace\r\n
    \   ingress.spec.rules[_].host == host\r\n    msg := sprintf(\"invalid ingress
    host %q (conflicts with %v/%v)\", [host, other_ns, other_ingress])\r\n}"
kind: ConfigMap
metadata:
  annotations:
    openpolicyagent.org/policy-status: '{"status":"ok"}'
  creationTimestamp: "2020-12-26T06:00:07Z"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .: {}
          f:openpolicyagent.org/policy-status: {}
    manager: kube-mgmt
    operation: Update
    time: "2020-12-26T06:00:07Z"
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:data:
        .: {}
        f:ingress-conflicts.rego: {}
    manager: kubectl
    operation: Update
    time: "2020-12-26T06:00:07Z"
  name: ingress-conflicts
  namespace: opa
  resourceVersion: "13099"
  selfLink: /api/v1/namespaces/opa/configmaps/ingress-conflicts
  uid: 1dc3f286-5b49-4db1-9510-cbcb74d8bffa

ConfigMapの作成とPolicyの読み込みが成功したので、実際の動作をテストします。以下のようなマニフェストファイルを用いて新しいNamespaceを作成し、先ほど利用したingress-ok.yamlを、作成したNamespaceへデプロイします。すると、すでに同一のホスト名が使用されているため、エラーが返されます。

staging-namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  annotations:
    ingress-whitelist: "*.acmecorp.com"
  name: staging
# Namespaceの作成
$ kubectl apply -f staging-namespace.yaml 
namespace/staging created

$ kubectl get ns
NAME              STATUS   AGE
default           Active   78m
kube-node-lease   Active   78m
kube-public       Active   78m
kube-system       Active   78m
opa               Active   49m
production        Active   9m2s
qa                Active   9m12s
staging           Active   5s


# ingressの作成(失敗)
$ kubectl create -f ingress-ok.yaml -n staging
Error from server (invalid ingress host "signin.acmecorp.com" (conflicts with production/ingress-ok)): error when creating "ingress-ok.yaml": admission webhook "validating-webhook.openpolicyagent.org" denied the request: invalid ingress host "signin.acmecorp.com" (conflicts with production/ingress-ok)

上記の通り、更新したPolicyも想定通り動作することが確認できました。

参考ドキュメント

Istioに入門する

今回はService Meshについて概要を調べ、Service Meshを提供するプロダクトの一つであるIstioに触れてみました。

Service Meshとは

マイクロサービスの課題

Service Meshを考えるうえでまず必要になるのが、マイクロサービスアーキテクチャの抱えるいくつかの課題です。

マイクロサービスを導入・構築するうえでの課題として、ネットワークに関連する事項が挙げられます。マイクロサービスはお互いネットワークを通じて連携するため、ネットワークに関する機能(Load Balancing、Traffic Routingなど)を実装する必要があります。また、アプリケーションを構成するマイクロサービスの数が多くなるほど、マイクロサービス間の接続数は増加し、通信断の発生する確率やパフォーマンス低下など、ネットワーク関連の問題が発生する可能性も増加します。

これまで複数のグローバルな大企業では、信頼のできないネットワークに関連した課題を解決するため、様々な取り組みを行いました。当初はマイクロサービスの内部に埋め込むフレームワークでの解決を目指すことが多かったようですが(例:RibbonHystrixなど)、それらは開発言語やプラットフォームに依存しており、既存のアプリケーションに組み込むことも難しい場面がありました。またフレームワークがアップデートされた場合、それを利用するアプリケーション側もアップデートをして追従する必要があり、さらにフレームワークが各言語に対応していても、各言語間で一貫した機能を利用するには大きな労力が必要となっていました。

一方で、アプリケーションの実行基盤としてコンテナやKubernetesが進化・発展してきました。異なる開発言語で書かれたマイクロサービスは、コンテナという箱の中で起動し、それらはKubernetesなどのコンテナプラットフォーム上へデプロイすることで、共通基盤上で動作し、複数のマイクロサービスが連携するアプリケーションを提供することができます。しかし、基盤上の複数のマイクロサービスが、どのように通信を行うか、連携するか、といった機能は、Service Meshが登場するまでは提供されませんでした。

Service Meshの特徴

Service Meshの特徴としては以下のようなものが挙げられます。Service Meshを導入することで、これらの機能を「アプリケーションコードに改変を加えることなく」利用することができます。アプリケーションと独立して導入できることで、開発者は自分たちのマイクロサービスが担う機能の開発に集中することができます。

  • Traffic Control: 特定のサービス間でのルーティングに加え、ネットワーク全体のトラフィックフローを変更(traffic shaping)したり、トラフィックを別のロケーションに移行(traffic shifting)する機能も提供する。

  • Service Discovery: 各サービスの存在や送信先を動的に検出・更新し、サービス間の通信を実現する。KubernetesはService Discoveryの機能を備えているため、こちらを利用する場合もある。

  • Load Balancing: Kubernetesではkube-proxyによりデフォルトでRound-Robinな負荷分散が提供されるが、Service Meshにより複数のアルゴリズム(Random / Weighted least connectなど)を利用可能となる。

  • Resilience: マイクロサービス間の通信の信頼性を向上する機能を提供する。ヘルスチェック、タイムアウト、リトライなどに加え、サービス間障害の影響が拡大しないよう、サービス間を切り離す仕組み(Circuit Breaker)などがある。

  • Observability: マイクロサービスでは大量のコンポーネントが動的に変化するため各サービスの状況や通信状況などを可視化することが必須になります。Service Meshにより、メトリクスの取得や分散トレーシング、ロギングなどを利用できます。

  • Security: サービス間の通信をセキュアにするため、相互TLS(mTLS)、ポリシーベースのアクセスコントロールACL)などの機能を提供する。

Service Meshのアーキテクチャは、大きくData PlaneとControl Planeに分けられます。Data Planeは各マイクロサービスのProxyとして実装されることが多く、サービスのエンドポイントを通過する全てのトラフィックを対象に、パケットの変換や転送、モニタリングなどを行います。特にKubernetesなどコンテナ基盤ではSidecar Proxyコンテナとして、マイクロサービスの外部Proxyとして実現されます。

Control Planeは各Data Planeの情報を集約した可視化、各Data Planeのポリシーや設定の管理などを行い、複数のData Planeを分散システムとして構成することを実現します。

※画像:Envoy Blogより

Data Plane

data-plane

Control Plane

control-plane

Service Meshを実現するプロダクト

Service Meshを実現するプロダクトには、現在複数の選択肢が用意されています。

  • Istio: Service Meshプロダクトの中でもっとも有名なもの。EnvoyをSidecar Proxyとして利用し、豊富な機能と拡張性を備えている。

  • Linkerd: Service Meshプロダクトとして最初に登場したもの。独自のData Plane Proxyを利用し、軽量・高性能を実現。

  • Kuma: 2019年にKong社が発表した、比較的新しいプロダクト。一つのControl Planeから複数のメッシュを管理することができる。またVMなどKubernetes以外のプラットフォームでも利用できる。

  • Consul Connect: Consulの提供するService Discovery機能を利用し、SIdecar Proxyを追加することで、サービス間の認証・mTLSによる通信を実現する。また各サービスのトラフィックを集約し、Prometheus等のモニタリングツールへ送信することも可能。

  • Traefik Mesh (Maesh): Cloud-NativeなReverse Proxy / Load Balancingを提供するTraefikの配下でService Meshの機能を提供する、軽量・シンプルなプロダクト。

  • AWS App Mesh: AWSの提供するマネージド型のService Mesh。Control PlaneはAWSが管理し、Fargate / ECS / EKS / EC2等にsidecar proxyを注入することで利用することが可能。

  • Open Service Mesh: Microsoftがリリース、Service Mesh Interface(SMI)に準拠したもの。Envoyをベースに利用し、軽量・拡張性を備えている。

※各Service Meshプロダクトの比較はこちらのページなどをご確認ください。

今回はこの中からもっとも有名なIstioを触ってみました

Istioとは

Istioの公式ページでは、以下のような特徴を持つと紹介されています。

  • HTTP / gRPC / WebSocket / TCPといった複数のプロトコルに対応した自動ロードバランス
  • Routingルール・Retry・Failover・Fault Injectionといった機能による詳細なトラフィックコントロール
  • Pluggableなポリシーレイヤと、Access Control / Rate Limit・QuotaをサポートするConfiguration API
  • Ingress / Egress方向を含む、クラスター内の全トラフィックのメトリクス・ログ・トレースを自動的に取得する
  • 強力なIDベースの認証認可によるセキュアなサービス間通信の実現

またIstioは拡張性と多様なデプロイ要求に合わせて設計されており、それらはメッシュトラフィックに対するInjectionとConfigurationにより実現しています。

※Istioアーキテクチャ(画像はIstio公式ドキュメントより)

istio-architecture

ここからは実際にIstioを触ってみます。今回は公式ドキュメントにあるGetting Startedのページにある内容をなぞってみました。

検証環境

Istioのダウンロード

まずはIstioctl CLIをインストールします。Istioを利用するには、istioctlを用いる、Operatorを利用する、CLIによりマニフェストファイルを生成する、Helmを利用する、などの方法がありますが、ここではistioctlによるインストールを採用しています。

※各インストール方法のPros/Consはこちらに記載されています

istioctlをインストールするため、以下の通りコマンドを実行します。

# ダウンロード
$ curl -L https://istio.io/downloadIstio | sh -
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   102  100   102    0     0    290      0 --:--:-- --:--:-- --:--:--   292
100  4579  100  4579    0     0   7631      0 --:--:-- --:--:-- --:--:--  154k

Downloading istio-1.8.1 from https://github.com/istio/istio/releases/download/1.8.1/istio-1.8.1-linux-amd64.tar.gz ...

Istio 1.8.1 Download Complete!

Istio has been successfully downloaded into the istio-1.8.1 folder on your system.

Next Steps:
See https://istio.io/latest/docs/setup/install/ to add Istio to your Kubernetes cluster.

To configure the istioctl client tool for your workstation,
add the /mnt/c/Users/user/Desktop/istio-1.8.1/bin directory to your environment path variable with:
         export PATH="$PATH:/mnt/c/Users/user/Desktop/istio-1.8.1/bin"

Begin the Istio pre-installation check by running:
         istioctl x precheck

Need more information? Visit https://istio.io/latest/docs/setup/install/

ここでダウンロードしたファイルにはistioctl以外にもマニフェストファイルなどが含まれています。istioctlをパスが通っているフォルダにコピーしてコマンドを実行できるようにします。

$ cd istio-1.8.1/
$ ll
total 24
drwxrwxrwx 1 testuser testuser   512 Dec  8 06:58 ./
drwxrwxrwx 1 testuser testuser   512 Dec 21 11:12 ../
-rwxrwxrwx 1 testuser testuser 11348 Dec  8 06:58 LICENSE*
-rwxrwxrwx 1 testuser testuser  5866 Dec  8 06:58 README.md*
drwxrwxrwx 1 testuser testuser   512 Dec  8 06:58 bin/
-rwxrwxrwx 1 testuser testuser   853 Dec  8 06:58 manifest.yaml*
drwxrwxrwx 1 testuser testuser   512 Dec  8 06:58 manifests/
drwxrwxrwx 1 testuser testuser   512 Dec  8 06:58 samples/
drwxrwxrwx 1 testuser testuser   512 Dec  8 06:58 tools/

$ sudo cp -p bin/istioctl /usr/local/bin


# コマンド実行確認
$ istioctl version
no running Istio pods in "istio-system"
1.8.1

Istioのインストール

次にクラスターにIstioをインストールします。ここではデモ用のConfiguration Profileを利用します。Configuration Profileはistioctlダウンロード直後に利用できる、ビルトインのインストールオプションのことで、このうちdemoには、リソースが少ない環境下でもIstioの機能を見ることのできるような設定が含まれています。

demo.yaml

apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
spec:
  meshConfig:
    accessLogFile: /dev/stdout
  components:
    egressGateways:
    - name: istio-egressgateway
      enabled: true
      k8s:
        resources:
          requests:
            cpu: 10m
            memory: 40Mi

    ingressGateways:
    - name: istio-ingressgateway
      enabled: true
      k8s:
        resources:
          requests:
            cpu: 10m
            memory: 40Mi
        service:
          ports:
            ## You can add custom gateway ports in user values overrides, but it must include those ports since helm replaces.
            # Note that AWS ELB will by default perform health checks on the first port
            # on this list. Setting this to the health check port will ensure that health
            # checks always work. https://github.com/istio/istio/issues/12503
            - port: 15021
              targetPort: 15021
              name: status-port
            - port: 80
              targetPort: 8080
              name: http2
            - port: 443
              targetPort: 8443
              name: https
            - port: 31400
              targetPort: 31400
              name: tcp
              # This is the port where sni routing happens
            - port: 15443
              targetPort: 15443
              name: tls

    pilot:
      k8s:
        env:
          - name: PILOT_TRACE_SAMPLING
            value: "100"
        resources:
          requests:
            cpu: 10m
            memory: 100Mi

  values:
    global:
      proxy:
        resources:
          requests:
            cpu: 10m
            memory: 40Mi

    pilot:
      autoscaleEnabled: false

    gateways:
      istio-egressgateway:
        autoscaleEnabled: false
      istio-ingressgateway:
        autoscaleEnabled: false
# Profileの配置場所
$ ll manifests/profiles/
total 24
drwxrwxrwx 1 testuser testuser  512 Dec  8 06:58 ./
drwxrwxrwx 1 testuser testuser  512 Dec  8 06:58 ../
-rwxrwxrwx 1 testuser testuser  862 Dec  8 06:58 PROFILES.md*
-rwxrwxrwx 1 testuser testuser 8255 Dec  8 06:58 default.yaml*
-rwxrwxrwx 1 testuser testuser 1836 Dec  8 06:58 demo.yaml*
-rwxrwxrwx 1 testuser testuser  317 Dec  8 06:58 empty.yaml*
-rwxrwxrwx 1 testuser testuser  216 Dec  8 06:58 minimal.yaml*
-rwxrwxrwx 1 testuser testuser  481 Dec  8 06:58 openshift.yaml*
-rwxrwxrwx 1 testuser testuser  664 Dec  8 06:58 preview.yaml*
-rwxrwxrwx 1 testuser testuser   67 Dec  8 06:58 remote.yaml*


# Install
$ istioctl install --set profile=demo -y
✔ Istio core installed
✔ Istiod installed
✔ Egress gateways installed
✔ Ingress gateways installed
✔ Installation complete


# Install後の確認
$ kubectl get ns
NAME              STATUS   AGE
default           Active   5d17h
istio-system      Active   72s
kube-node-lease   Active   5d17h
kube-public       Active   5d17h
kube-system       Active   5d17h

$ kubectl get all -n istio-system
NAME                                       READY   STATUS    RESTARTS   AGE
pod/istio-egressgateway-69fc79d576-zldvs   1/1     Running   0          64s
pod/istio-ingressgateway-bc9b55659-9bldh   1/1     Running   0          63s
pod/istiod-67f5756967-7b7rf                1/1     Running   0          80s

NAME                           TYPE           CLUSTER-IP       EXTERNAL-IP                                                                    PORT(S)
                                     AGE
service/istio-egressgateway    ClusterIP      172.20.239.202   <none>                                                                         80/TCP,443/TCP,15443/TCP
                                     63s
service/istio-ingressgateway   LoadBalancer   172.20.42.254    a2c0d8b0138ac4c0283e0bab1f35d904-1021565130.ap-northeast-1.elb.amazonaws.com   15021:30711/TCP,80:32657/TCP,443:30422/TCP,31400:30596/TCP,15443:32315/TCP   63s
service/istiod                 ClusterIP      172.20.58.162    <none>                                                                         15010/TCP,15012/TCP,443/TCP,15014/TCP
                                     80s

NAME                                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/istio-egressgateway    1/1     1            1           64s
deployment.apps/istio-ingressgateway   1/1     1            1           64s
deployment.apps/istiod                 1/1     1            1           80s

NAME                                             DESIRED   CURRENT   READY   AGE
replicaset.apps/istio-egressgateway-69fc79d576   1         1         1       64s
replicaset.apps/istio-ingressgateway-bc9b55659   1         1         1       64s
replicaset.apps/istiod-67f5756967                1         1         1       80s

クラスター上にIstioをインストールすることができたので、次にテスト用アプリケーションをデプロイするNamespaceにistio-injection=enabledというラベルを付与します。このラベルを付与することで、指定のNamespaceにアプリケーションPodがデプロイされると、Istioがそれを検知し、Envoyを含むSidecar Proxyを自動的に追加します。

$ kubectl label namespace default istio-injection=enabled
namespace/default labeled

$ kubectl get ns --show-labels
NAME              STATUS   AGE     LABELS
default           Active   5d17h   istio-injection=enabled
istio-system      Active   4m49s   istio-injection=disabled
kube-node-lease   Active   5d17h   <none>
kube-public       Active   5d17h   <none>
kube-system       Active   5d17h   <none>

サンプルアプリケーションのデプロイ

次にサンプル用アプリケーションをデプロイします。今回はBookInfoというアプリケーションを利用します。Bookinfoはランタイムの異なる4つのマイクロサービスからなるアプリケーションです。

Bookinfo構成図(画像はIstioドキュメントより)

bookinfo

デプロイに利用するbookinfo.yamlというマニフェストファイルを見ると、ここにはSidecar Proxyの設定はされていないことが確認できます。

boolinfo.yaml

# Copyright Istio Authors
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.

##################################################################################################
# This file defines the services, service accounts, and deployments for the Bookinfo sample.
#
# To apply all 4 Bookinfo services, their corresponding service accounts, and deployments:
#
#   kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml
#
# Alternatively, you can deploy any resource separately:
#
#   kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml -l service=reviews # reviews Service
#   kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml -l account=reviews # reviews ServiceAccount
#   kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml -l app=reviews,version=v3 # reviews-v3 Deployment
##################################################################################################

##################################################################################################
# Details service
##################################################################################################
apiVersion: v1
kind: Service
metadata:
  name: details
  labels:
    app: details
    service: details
spec:
  ports:
  - port: 9080
    name: http
  selector:
    app: details
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookinfo-details
  labels:
    account: details
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: details-v1
  labels:
    app: details
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: details
      version: v1
  template:
    metadata:
      labels:
        app: details
        version: v1
    spec:
      serviceAccountName: bookinfo-details
      containers:
      - name: details
        image: docker.io/istio/examples-bookinfo-details-v1:1.16.2
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 9080
---
##################################################################################################
# Ratings service
##################################################################################################
apiVersion: v1
kind: Service
metadata:
  name: ratings
  labels:
    app: ratings
    service: ratings
spec:
  ports:
  - port: 9080
    name: http
  selector:
    app: ratings
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookinfo-ratings
  labels:
    account: ratings
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ratings-v1
  labels:
    app: ratings
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ratings
      version: v1
  template:
    metadata:
      labels:
        app: ratings
        version: v1
    spec:
      serviceAccountName: bookinfo-ratings
      containers:
      - name: ratings
        image: docker.io/istio/examples-bookinfo-ratings-v1:1.16.2
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 9080
---
##################################################################################################
# Reviews service
##################################################################################################
apiVersion: v1
kind: Service
metadata:
  name: reviews
  labels:
    app: reviews
    service: reviews
spec:
  ports:
  - port: 9080
    name: http
  selector:
    app: reviews
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookinfo-reviews
  labels:
    account: reviews
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reviews-v1
  labels:
    app: reviews
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: reviews
      version: v1
  template:
    metadata:
      labels:
        app: reviews
        version: v1
    spec:
      serviceAccountName: bookinfo-reviews
      containers:
      - name: reviews
        image: docker.io/istio/examples-bookinfo-reviews-v1:1.16.2
        imagePullPolicy: IfNotPresent
        env:
        - name: LOG_DIR
          value: "/tmp/logs"
        ports:
        - containerPort: 9080
        volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: wlp-output
          mountPath: /opt/ibm/wlp/output
      volumes:
      - name: wlp-output
        emptyDir: {}
      - name: tmp
        emptyDir: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reviews-v2
  labels:
    app: reviews
    version: v2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: reviews
      version: v2
  template:
    metadata:
      labels:
        app: reviews
        version: v2
    spec:
      serviceAccountName: bookinfo-reviews
      containers:
      - name: reviews
        image: docker.io/istio/examples-bookinfo-reviews-v2:1.16.2
        imagePullPolicy: IfNotPresent
        env:
        - name: LOG_DIR
          value: "/tmp/logs"
        ports:
        - containerPort: 9080
        volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: wlp-output
          mountPath: /opt/ibm/wlp/output
      volumes:
      - name: wlp-output
        emptyDir: {}
      - name: tmp
        emptyDir: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reviews-v3
  labels:
    app: reviews
    version: v3
spec:
  replicas: 1
  selector:
    matchLabels:
      app: reviews
      version: v3
  template:
    metadata:
      labels:
        app: reviews
        version: v3
    spec:
      serviceAccountName: bookinfo-reviews
      containers:
      - name: reviews
        image: docker.io/istio/examples-bookinfo-reviews-v3:1.16.2
        imagePullPolicy: IfNotPresent
        env:
        - name: LOG_DIR
          value: "/tmp/logs"
        ports:
        - containerPort: 9080
        volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: wlp-output
          mountPath: /opt/ibm/wlp/output
      volumes:
      - name: wlp-output
        emptyDir: {}
      - name: tmp
        emptyDir: {}
---
##################################################################################################
# Productpage services
##################################################################################################
apiVersion: v1
kind: Service
metadata:
  name: productpage
  labels:
    app: productpage
    service: productpage
spec:
  ports:
  - port: 9080
    name: http
  selector:
    app: productpage
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookinfo-productpage
  labels:
    account: productpage
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: productpage-v1
  labels:
    app: productpage
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: productpage
      version: v1
  template:
    metadata:
      labels:
        app: productpage
        version: v1
    spec:
      serviceAccountName: bookinfo-productpage
      containers:
      - name: productpage
        image: docker.io/istio/examples-bookinfo-productpage-v1:1.16.2
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 9080
        volumeMounts:
        - name: tmp
          mountPath: /tmp
      volumes:
      - name: tmp
        emptyDir: {}
---

# Bookinfoデプロイ
$ kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml
service/details created
serviceaccount/bookinfo-details created
deployment.apps/details-v1 created
service/ratings created
serviceaccount/bookinfo-ratings created
deployment.apps/ratings-v1 created
service/reviews created
serviceaccount/bookinfo-reviews created
deployment.apps/reviews-v1 created
deployment.apps/reviews-v2 created
deployment.apps/reviews-v3 created
service/productpage created
serviceaccount/bookinfo-productpage created
deployment.apps/productpage-v1 created


# デプロイ後の確認

$ kubectl get svc
NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
details       ClusterIP   172.20.77.57    <none>        9080/TCP   33s
kubernetes    ClusterIP   172.20.0.1      <none>        443/TCP    5d17h
productpage   ClusterIP   172.20.186.50   <none>        9080/TCP   32s
ratings       ClusterIP   172.20.1.42     <none>        9080/TCP   33s
reviews       ClusterIP   172.20.31.107   <none>        9080/TCP   33s

$ kubectl get pods
NAME                              READY   STATUS    RESTARTS   AGE
details-v1-558b8b4b76-vsg5d       2/2     Running   0          82s
productpage-v1-6987489c74-lkkp7   2/2     Running   0          81s
ratings-v1-7dc98c7588-kn2nd       2/2     Running   0          82s
reviews-v1-7f99cc4496-tv2xw       2/2     Running   0          81s
reviews-v2-7d79d5bd5d-7vtb6       2/2     Running   0          81s
reviews-v3-7dbcdcbc56-npl2r       2/2     Running   0          81s

# 以下のコマンドにより、全てのマイクロサービスが正常に起動していることを確認

$ kubectl exec "$(kubectl get pod -l app=ratings -o jsonpath='{.items[0].metadata.name}')" -c ratings -- curl -s productpage:9080/productpage | grep -o "<title>.*</title>"
<title>Simple Bookstore App</title>

またこの時点で各PodにSidecar Proxyが含まれていることが確認できます。

kubectl describe pod実行結果

$ kubectl describe pod details-v1-558b8b4b76-vsg5d
Name:         details-v1-558b8b4b76-vsg5d
Namespace:    default
Priority:     0
Node:         ip-10-0-3-254.ap-northeast-1.compute.internal/10.0.3.254
Start Time:   Mon, 21 Dec 2020 11:25:46 +0900
Labels:       app=details
              istio.io/rev=default
              pod-template-hash=558b8b4b76
              security.istio.io/tlsMode=istio
              service.istio.io/canonical-name=details
              service.istio.io/canonical-revision=v1
              version=v1
Annotations:  kubernetes.io/psp: eks.privileged
              prometheus.io/path: /stats/prometheus
              prometheus.io/port: 15020
              prometheus.io/scrape: true
              sidecar.istio.io/status:
                {"version":"e2cb9d4837cda9584fd272bfa1f348525bcaacfadb7e9b9efbd21a3bb44ad7a1","initContainers":["istio-init"],"containers":["istio-proxy"]...
Status:       Running
IP:           10.0.3.200
IPs:
  IP:           10.0.3.200
Controlled By:  ReplicaSet/details-v1-558b8b4b76
Init Containers:
  istio-init:
    Container ID:  docker://671332eb17f770507ea2c03abc0b7e63adaef1cfe4cd71194d1a35bee84ab7b8
    Image:         docker.io/istio/proxyv2:1.8.1
    Image ID:      docker-pullable://istio/proxyv2@sha256:0a407ecee363d8d31957162b82738ae3dd09690668a0168d660044ac8fc728f0
    Port:          <none>
    Host Port:     <none>
    Args:
      istio-iptables
      -p
      15001
      -z
      15006
      -u
      1337
      -m
      REDIRECT
      -i
      *
      -x

      -b
      *
      -d
      15090,15021,15020
    State:          Terminated
      Reason:       Completed
      Exit Code:    0
      Started:      Mon, 21 Dec 2020 11:26:00 +0900
      Finished:     Mon, 21 Dec 2020 11:26:00 +0900
    Ready:          True
    Restart Count:  0
    Limits:
      cpu:     2
      memory:  1Gi
    Requests:
      cpu:     10m
      memory:  40Mi
    Environment:
      DNS_AGENT:
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from bookinfo-details-token-6qxvx (ro)
Containers:
  details:
    Container ID:   docker://d61b82d5a1383486be70492be5ed1dd63271cbe7935db7942b74edd21077b117
    Image:          docker.io/istio/examples-bookinfo-details-v1:1.16.2
    Image ID:       docker-pullable://istio/examples-bookinfo-details-v1@sha256:18e54f81689035019e1ac78f6d2e6483fcf1d94072d047315ab193cb2ab89ae5
    Port:           9080/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Mon, 21 Dec 2020 11:26:44 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from bookinfo-details-token-6qxvx (ro)
  istio-proxy:
    Container ID:  docker://19613b8fee7c77c2a2436824e6a33832e7758752c425add6b762da851faaccd7
    Image:         docker.io/istio/proxyv2:1.8.1
    Image ID:      docker-pullable://istio/proxyv2@sha256:0a407ecee363d8d31957162b82738ae3dd09690668a0168d660044ac8fc728f0
    Port:          15090/TCP
    Host Port:     0/TCP
    Args:
      proxy
      sidecar
      --domain
      $(POD_NAMESPACE).svc.cluster.local
      --serviceCluster
      details.$(POD_NAMESPACE)
      --proxyLogLevel=warning
      --proxyComponentLogLevel=misc:error
      --concurrency
      2
    State:          Running
      Started:      Mon, 21 Dec 2020 11:26:48 +0900
    Ready:          True
    Restart Count:  0
    Limits:
      cpu:     2
      memory:  1Gi
    Requests:
      cpu:      10m
      memory:   40Mi
    Readiness:  http-get http://:15021/healthz/ready delay=1s timeout=3s period=2s #success=1 #failure=30
    Environment:
      JWT_POLICY:                    third-party-jwt
      PILOT_CERT_PROVIDER:           istiod
      CA_ADDR:                       istiod.istio-system.svc:15012
      POD_NAME:                      details-v1-558b8b4b76-vsg5d (v1:metadata.name)
      POD_NAMESPACE:                 default (v1:metadata.namespace)
      INSTANCE_IP:                    (v1:status.podIP)
      SERVICE_ACCOUNT:                (v1:spec.serviceAccountName)
      HOST_IP:                        (v1:status.hostIP)
      CANONICAL_SERVICE:              (v1:metadata.labels['service.istio.io/canonical-name'])
      CANONICAL_REVISION:             (v1:metadata.labels['service.istio.io/canonical-revision'])
      PROXY_CONFIG:                  {"proxyMetadata":{"DNS_AGENT":""}}

      ISTIO_META_POD_PORTS:          [
                                         {"containerPort":9080,"protocol":"TCP"}
                                     ]
      ISTIO_META_APP_CONTAINERS:     details
      ISTIO_META_CLUSTER_ID:         Kubernetes
      ISTIO_META_INTERCEPTION_MODE:  REDIRECT
      ISTIO_METAJSON_ANNOTATIONS:    {"kubernetes.io/psp":"eks.privileged"}

      ISTIO_META_WORKLOAD_NAME:      details-v1
      ISTIO_META_OWNER:              kubernetes://apis/apps/v1/namespaces/default/deployments/details-v1
      ISTIO_META_MESH_ID:            cluster.local
      TRUST_DOMAIN:                  cluster.local
      DNS_AGENT:
    Mounts:
      /etc/istio/pod from istio-podinfo (rw)
      /etc/istio/proxy from istio-envoy (rw)
      /var/lib/istio/data from istio-data (rw)
      /var/run/secrets/istio from istiod-ca-cert (rw)
      /var/run/secrets/kubernetes.io/serviceaccount from bookinfo-details-token-6qxvx (ro)
      /var/run/secrets/tokens from istio-token (rw)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  bookinfo-details-token-6qxvx:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  bookinfo-details-token-6qxvx
    Optional:    false
  istio-envoy:
    Type:       EmptyDir (a temporary directory that shares a pod's lifetime)
    Medium:     Memory
    SizeLimit:  <unset>
  istio-data:
    Type:       EmptyDir (a temporary directory that shares a pod's lifetime)
    Medium:
    SizeLimit:  <unset>
  istio-podinfo:
    Type:  DownwardAPI (a volume populated by information about the pod)
    Items:
      metadata.labels -> labels
      metadata.annotations -> annotations
  istio-token:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  43200
  istiod-ca-cert:
    Type:        ConfigMap (a volume populated by a ConfigMap)
    Name:        istio-ca-root-cert
    Optional:    false
QoS Class:       Burstable
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age    From                                                    Message
  ----    ------     ----   ----                                                    -------
  Normal  Scheduled  5m5s   default-scheduler                                       Successfully assigned default/details-v1-558b8b4b76-vsg5d to ip-10-0-3-254.ap-northeast-1.compute.internal
  Normal  Pulling    5m4s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Pulling image "docker.io/istio/proxyv2:1.8.1"
  Normal  Pulled     4m55s  kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Successfully pulled image "docker.io/istio/proxyv2:1.8.1"
  Normal  Created    4m51s  kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Created container istio-init
  Normal  Started    4m51s  kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Started container istio-init
  Normal  Pulling    4m50s  kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Pulling image "docker.io/istio/examples-bookinfo-details-v1:1.16.2"
  Normal  Pulled     4m10s  kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Successfully pulled image "docker.io/istio/examples-bookinfo-details-v1:1.16.2"
  Normal  Created    4m7s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Created container details
  Normal  Started    4m7s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Started container details
  Normal  Pulling    4m7s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Pulling image "docker.io/istio/proxyv2:1.8.1"
  Normal  Pulled     4m3s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Successfully pulled image "docker.io/istio/proxyv2:1.8.1"
  Normal  Created    4m3s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Created container istio-proxy
  Normal  Started    4m3s   kubelet, ip-10-0-3-254.ap-northeast-1.compute.internal  Started container istio-proxy

アプリケーションを外部に公開

次にBookinfoを外部からアクセスできるようにします。Istioの機能で外部からアプリケーションへアクセスできるようにするには、Istio Ingress Gatewayを作成し、アプリケーションへのルーティングを追加する必要があります。ここではIstioのCRDであるGatewayVirtualServiceをデプロイすることで、外部からのアクセスを実現します。

IstioのCRDの一つであるGatewayを作成すると、Gatewayに含まれる設定は、メッシュのエッジ部分で起動するEnvoy Proxyへと反映されます。Istio gatewayは、外部に公開するポートやTLSの設定など、Layer 4-6のLoad Balancingに関する機能を設定することができます。それに加え、IstioのVirtual ServiceというCRDを利用することも可能です。Virtual ServiceトラフィックのRoutingの機能を提供し、HTTPやgRPCといったプロトコルのルーティング設定を定義することができます。これをGateway組み合わせることで、Layer 7のルーティングをGatewayに追加することができます。

bookinfo-gateway.yaml

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: bookinfo-gateway
spec:
  selector:
    istio: ingressgateway # use istio default controller
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: bookinfo
spec:
  hosts:
  - "*"
  gateways:
  - bookinfo-gateway
  http:
  - match:
    - uri:
        exact: /productpage
    - uri:
        prefix: /static
    - uri:
        exact: /login
    - uri:
        exact: /logout
    - uri:
        prefix: /api/v1/products
    route:
    - destination:
        host: productpage
        port:
          number: 9080
# gateway / virtual serviceのデプロイ
$ kubectl apply -f samples/bookinfo/networking/bookinfo-gateway.yaml
gateway.networking.istio.io/bookinfo-gateway created
virtualservice.networking.istio.io/bookinfo created


# デプロイ後の確認
$ kubectl get gateway
NAME               AGE
bookinfo-gateway   20s

$ kubectl get virtualservice
NAME       GATEWAYS             HOSTS   AGE
bookinfo   [bookinfo-gateway]   [*]     30s

またistioctlコマンドには、Istioクラスターの設定を確認するためのistioctl analyzeというオプションが存在します。

$ istioctl analyze

✔ No validation issues found when analyzing namespace: default.

外部からのアクセス

次にクラスター外からのアクセスが可能かを確認します。

まずは以下のコマンドによりアクセス先のIPアドレスとポート番号を確認します。

# Serviceの確認
$ kubectl get svc -n istio-system
NAME                   TYPE           CLUSTER-IP       EXTERNAL-IP                                                                    PORT(S)                                                                      AGE
istio-egressgateway    ClusterIP      172.20.239.202   <none>                                                                         80/TCP,443/TCP,15443/TCP                                                     31m
istio-ingressgateway   LoadBalancer   172.20.42.254    a2c0d8b0138ac4c0283e0bab1f35d904-1021565130.ap-northeast-1.elb.amazonaws.com   15021:30711/TCP,80:32657/TCP,443:30422/TCP,31400:30596/TCP,15443:32315/TCP   31m
istiod                 ClusterIP      172.20.58.162    <none>                                                                         15010/TCP,15012/TCP,443/TCP,15014/TCP                                        31m

# istio-ingressgatewayのIPアドレス(ここではhostname)を確認

$ kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].hostname}'
a2c0d8b0138ac4c0283e0bab1f35d904-1021565130.ap-northeast-1.elb.amazonaws.com


# istio-ingressgatewayのポートを確認
$ kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].port}'
80

アクセス先が判明したので、以下の通りコマンドを実行し、クラスター外からアクセスできることを確認します。

$ curl -s a2c0d8b0138ac4c0283e0bab1f35d904-1021565130.ap-northeast-1.elb.amazonaws.com/productpage | grep -o "<title>.*</title>"
<title>Simple Bookstore App</title>

また、Webブラウザから上記URLにアクセスすると、以下のようなページへアクセスできることが確認できます。

f:id:FY0323:20201226105523j:plain

Dashboardからアプリケーションの状況を確認する

IstioはGrafanaKialiなど様々なプロダクトのDashboardと統合されています。Dashboardから見ることで、Service Meshの構造を視覚的に見ることが出来たり、メッシュのヘルスチェックを行うこともできます。ここではKialiのDashboardからIstioのメッシュの状況を確認します。

# Kiali(とその他アドオン)のインストール
$ ll samples/addons/
total 300
drwxrwxrwx 1 testuser testuser    512 Dec  8 06:58 ./
drwxrwxrwx 1 testuser testuser    512 Dec  8 06:58 ../
-rwxrwxrwx 1 testuser testuser   5186 Dec  8 06:58 README.md*
drwxrwxrwx 1 testuser testuser    512 Dec  8 06:58 extras/
-rwxrwxrwx 1 testuser testuser 240054 Dec  8 06:58 grafana.yaml*
-rwxrwxrwx 1 testuser testuser   2317 Dec  8 06:58 jaeger.yaml*
-rwxrwxrwx 1 testuser testuser  35080 Dec  8 06:58 kiali.yaml*
-rwxrwxrwx 1 testuser testuser  13250 Dec  8 06:58 prometheus.yaml*


# インストールに失敗することがある
$ kubectl apply -f samples/addons
serviceaccount/grafana created
configmap/grafana created
service/grafana created
deployment.apps/grafana created
configmap/istio-grafana-dashboards created
configmap/istio-services-grafana-dashboards created
deployment.apps/jaeger created
service/tracing created
service/zipkin created
service/jaeger-collector created
customresourcedefinition.apiextensions.k8s.io/monitoringdashboards.monitoring.kiali.io created
serviceaccount/kiali created
configmap/kiali created
clusterrole.rbac.authorization.k8s.io/kiali-viewer created
clusterrole.rbac.authorization.k8s.io/kiali created
clusterrolebinding.rbac.authorization.k8s.io/kiali created
service/kiali created
deployment.apps/kiali created
serviceaccount/prometheus created
configmap/prometheus created
clusterrole.rbac.authorization.k8s.io/prometheus created
clusterrolebinding.rbac.authorization.k8s.io/prometheus created
service/prometheus created
deployment.apps/prometheus created
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"
unable to recognize "samples/addons/kiali.yaml": no matches for kind "MonitoringDashboard" in version "monitoring.kiali.io/v1alpha1"


# 失敗した場合、もう一度同じコマンドを実行する
$ kubectl apply -f samples/addons
serviceaccount/grafana unchanged
configmap/grafana unchanged
service/grafana unchanged
deployment.apps/grafana configured
configmap/istio-grafana-dashboards configured
configmap/istio-services-grafana-dashboards configured
deployment.apps/jaeger unchanged
service/tracing unchanged
service/zipkin unchanged
service/jaeger-collector unchanged
customresourcedefinition.apiextensions.k8s.io/monitoringdashboards.monitoring.kiali.io unchanged
serviceaccount/kiali unchanged
configmap/kiali unchanged
clusterrole.rbac.authorization.k8s.io/kiali-viewer unchanged
clusterrole.rbac.authorization.k8s.io/kiali unchanged
clusterrolebinding.rbac.authorization.k8s.io/kiali unchanged
service/kiali unchanged
deployment.apps/kiali unchanged
monitoringdashboard.monitoring.kiali.io/envoy created
monitoringdashboard.monitoring.kiali.io/go created
monitoringdashboard.monitoring.kiali.io/kiali created
monitoringdashboard.monitoring.kiali.io/micrometer-1.0.6-jvm-pool created
monitoringdashboard.monitoring.kiali.io/micrometer-1.0.6-jvm created
monitoringdashboard.monitoring.kiali.io/micrometer-1.1-jvm created
monitoringdashboard.monitoring.kiali.io/microprofile-1.1 created
monitoringdashboard.monitoring.kiali.io/microprofile-x.y created
monitoringdashboard.monitoring.kiali.io/nodejs created
monitoringdashboard.monitoring.kiali.io/quarkus created
monitoringdashboard.monitoring.kiali.io/springboot-jvm-pool created
monitoringdashboard.monitoring.kiali.io/springboot-jvm created
monitoringdashboard.monitoring.kiali.io/springboot-tomcat created
monitoringdashboard.monitoring.kiali.io/thorntail created
monitoringdashboard.monitoring.kiali.io/tomcat created
monitoringdashboard.monitoring.kiali.io/vertx-client created
monitoringdashboard.monitoring.kiali.io/vertx-eventbus created
monitoringdashboard.monitoring.kiali.io/vertx-jvm created
monitoringdashboard.monitoring.kiali.io/vertx-pool created
monitoringdashboard.monitoring.kiali.io/vertx-server created
serviceaccount/prometheus unchanged
configmap/prometheus unchanged
clusterrole.rbac.authorization.k8s.io/prometheus unchanged
clusterrolebinding.rbac.authorization.k8s.io/prometheus unchanged
service/prometheus unchanged
deployment.apps/prometheus configured


# インストール後の確認

$ kubectl get pods -n istio-system
NAME                                   READY   STATUS    RESTARTS   AGE
grafana-94f5bf75b-ffqqc                1/1     Running   0          119s
istio-egressgateway-69fc79d576-zldvs   1/1     Running   0          67m
istio-ingressgateway-bc9b55659-9bldh   1/1     Running   0          67m
istiod-67f5756967-7b7rf                1/1     Running   0          67m
jaeger-5c7675974-vpzh2                 1/1     Running   0          119s
kiali-667b888c56-2tztk                 1/1     Running   0          118s
prometheus-7d76687994-rf49q            2/2     Running   0          118s


$ kubectl get monitoringdashboard
NAME                        AGE
envoy                       116s
go                          116s
kiali                       116s
micrometer-1.0.6-jvm        116s
micrometer-1.0.6-jvm-pool   116s
micrometer-1.1-jvm          116s
microprofile-1.1            116s
microprofile-x.y            116s
nodejs                      116s
quarkus                     116s
springboot-jvm              116s
springboot-jvm-pool         116s
springboot-tomcat           116s
thorntail                   116s
tomcat                      116s
vertx-client                116s
vertx-eventbus              116s
vertx-jvm                   116s
vertx-pool                  115s
vertx-server                115s

インストールが完了したので、KialiのDashboardにアクセスします。istioctlコマンドにはistioctl dashboard kialiというオプションがあり、これを実行することでkubectl port-forwardを実行したときのように、KialiのDashboardにアクセスできるようになります。

$ istioctl dashboard kiali
http://localhost:20001/kiali
Failed to open browser; open http://localhost:20001/kiali in your browser.

コマンド実行後にhttp://localhost:20001/kialiにアクセスすると、以下のようにDashboardにアクセスできることが確認できます。

f:id:FY0323:20201226105630j:plain

上記画面には、各Namespace毎のアプリケーションPodの個数、Istio Configの設定有無などの状況が表示されています。

ここではdefault NamespaceからGraphを選択し、Pod間の接続状況を示すGraphを表示します。

f:id:FY0323:20201226105748j:plain

上記画面のGraphをクリックすると、以下のようにアプリケーションの状況が図示されます。

f:id:FY0323:20201226105905j:plain

上記画面中の三角形はService、丸四角形はappを表しています。また画面中にはistio-ingressgatewayがいることも確認できます。

Kialiでは、実際のアクセス量やサービス間の通信発生個所をリアルタイムで表示します。ここでWebブラウザからBookinfoアプリケーションへアクセスをしてみると、以下の画像のように、マイクロサービス間での通信の様子が、緑色の線で表示されることがわかります。

f:id:FY0323:20201226110014j:plain

また、以下のコマンドでクラスター内部からアクセスをすると、istio-ingressgatewayからはアクセスしておらずproductpageから矢印が伸びている様子も確認できます。

$ kubectl exec "$(kubectl get pod -l app=ratings -o jsonpath='{.items[0].metadata.name}')" -c ratings -- curl -s productpage:9080/productpage | grep -o "<title>.*</title>"
<title>Simple Bookstore App</title>

f:id:FY0323:20201226110218j:plain

Sidecar Proxyログを眺めてみる

ここまでIstioの機能に触れてみましたが、実際にSidecar Proxyにトラフィックが流れているかを確認してみます。Istioでログを出力するには、IstioOperator以下のような設定を追加する必要があります。

spec:
  meshConfig:
    accessLogFile: /dev/stdout

今回利用するdemoConfiguration Profileは上記設定が最初からされているため、この操作は不要です。

上記設定を行った後、各マイクロサービスのistio-proxySidecar Podのログを見ると、実際に転送するログが確認できます。

# productpageのSidecar Pod log

$ kubectl logs productpage-v1-6987489c74-lkkp7 -c istio-proxy -f

(中略)

[2020-12-21T03:41:23.900Z] "GET /details/0 HTTP/1.1" 200 - "-" 0 178 5 4 "-" "curl/7.52.1" "3d44d9aa-1744-993a-9162-9bd800f1c872" "details:9080" "10.0.3.200:9080" outbound|9080||details.default.svc.cluster.local 10.0.4.151:41226 172.20.77.57:9080 10.0.4.151:38742 - default
[2020-12-21T03:41:23.910Z] "GET /reviews/0 HTTP/1.1" 200 - "-" 0 379 31 31 "-" "curl/7.52.1" "3d44d9aa-1744-993a-9162-9bd800f1c872" "reviews:9080" "10.0.4.134:9080" outbound|9080||reviews.default.svc.cluster.local 10.0.4.151:39398 172.20.31.107:9080 10.0.4.151:35580 - default
[2020-12-21T03:41:23.893Z] "GET /productpage HTTP/1.1" 200 - "-" 0 5183 60 60 "-" "curl/7.52.1" "3d44d9aa-1744-993a-9162-9bd800f1c872" "productpage:9080" "127.0.0.1:9080" inbound|9080|| 127.0.0.1:51420 10.0.4.151:9080 10.0.4.170:44506 outbound_.9080_._.productpage.default.svc.cluster.local default

ここで別のターミナルから以下のコマンドを実行すると、新しくログが表示されることも確認できます。

# 別ターミナルから実行
$ kubectl exec "$(kubectl get pod -l app=ratings -o jsonpath='{.items[0].metadata.name}')" -c ratings -- curl -s productpage:9080/productpage | grep -o "<title>.*</title>"
<title>Simple Bookstore App</title>

# productpage sidecar logs

[2020-12-21T03:54:34.947Z] "GET /details/0 HTTP/1.1" 200 - "-" 0 178 5 4 "-" "curl/7.52.1" "0953f7fd-82aa-9de4-ac25-e1b6cc24c39c" "details:9080" "10.0.3.200:9080" outbound|9080||details.default.svc.cluster.local 10.0.4.151:59200 172.20.77.57:9080 10.0.4.151:46496 - default
[2020-12-21T03:54:34.959Z] "GET /reviews/0 HTTP/1.1" 200 - "-" 0 379 34 34 "-" "curl/7.52.1" "0953f7fd-82aa-9de4-ac25-e1b6cc24c39c" "reviews:9080" "10.0.4.134:9080" outbound|9080||reviews.default.svc.cluster.local 10.0.4.151:48918 172.20.31.107:9080 10.0.4.151:43334 - default
[2020-12-21T03:54:34.940Z] "GET /productpage HTTP/1.1" 200 - "-" 0 5183 66 66 "-" "curl/7.52.1" "0953f7fd-82aa-9de4-ac25-e1b6cc24c39c" "productpage:9080" "127.0.0.1:9080" inbound|9080|| 127.0.0.1:59174 10.0.4.151:9080 10.0.4.170:44506 outbound_.9080_._.productpage.default.svc.cluster.local default

Istioのアンインストール

一通り検証が終わったので、Istio、及びbookinfoアプリケーションを削除します。

# “bookinfo”アプリケーションの削除
$ samples/bookinfo/platform/kube/cleanup.sh
namespace ? [default]  # “default” Namespaceを選択
using NAMESPACE=default

(中略)

Application cleanup successful


# Kiali / Istioのアンインストール
$ kubectl delete -f samples/addons
$ istioctl manifest generate --set profile=demo | kubectl delete --ignore-not-found=true -f -

$ kubectl get all -n istio-system                          
No resources found in istio-system namespace.


# “istio-system” Namespaceの削除
$ kubectl delete namespace istio-system
namespace "istio-system" deleted


# Namespaceに付与していたラベルの削除
$ kubectl label namespace default istio-injection-
namespace/default labeled

参考ドキュメント

Custom Resourceに対するValidationをGitHub Actions + k3sで試してみる

はじめに

今回はCustom Resourceに対してのValidationを何とかできないかと考え、GitHub Actions + k3sを組み合わせてみた結果を残しておきます。

経緯

Kubernetesマニフェストファイルに対するValidation

Kubernetesにリソースを作成する場合、マニフェストファイルに各リソースを定義し、kubectlコマンド等でデプロイを行ったり、Argo CDFluxを利用してGitOpsによる自動デプロイを行うことが多いかと思います。

Kubernetesは各リソースをYAML/JSON形式のマニフェストファイルで定義するため、ファイルの書き方(構文)に問題がある場合は、リソースの作成に失敗します。そのため、作成したマニフェストファイルをデプロイする前に、何かしらの方法でファイルの構文をチェックする必要があります。

またプロジェクトによっては、特定のセキュリティポリシーへの準拠やラベルの付与などをルールとして定めており、それに従っているかをチェックする必要もあります。

これらを全て人の目でチェックするのは、大規模環境はもちろん、中・小規模の環境でも相当な労力となってしまいます。そのため、マニフェストファイルに対するValidationを行う各種ツールが開発されています。

  • kubeval: Kubernetes YAML/JSONファイルのValidationを行う。Kubernetes OpenAPIを利用して生成されるスキーマを利用するため、Kubernetesの複数のバージョンをまたいで利用できる。

  • kube-score: Kubernetesマニフェストファイルに対する静的コード解析を行うツール。信頼性・セキュリティを中心とした項目に対しての解析を行う。

  • conftest: 構造化された設定ファイルに対してのテストを利用者が定義し、ポリシーを満たすかをテストするツール。Kubernetesマニフェストファイルだけでなく、TerraformやTekton Pipelineなどの定義ファイルに対しても利用できる。

これらのツールを利用することで、クラスターへのデプロイを行う前に、マニフェストファイルの構文やポリシーのチェックを行うことができます。

Custom Resourceに対するValidation

一方で、KubernetesはCustom Resource (CR)を利用することでKubernetes APIを拡張し、独自のリソースをKubernetes上に展開することができます。Kubernetesはアプリケーションやプラットフォームを実現するための基盤であり、運用の自動化・効率化やユーザーへ提供する機能を実現するため、どのような形でKubernetesを利用するかに関わらず、Custom Resourceを利用するケースはかなり多いと思われます。

Custom Resourceを利用する場合もマニフェストファイル上に定義をするため、こちらも構文やポリシーをチェックする必要が出てきます。

※参考:

ここで、Custom Resourceに対するValidationをするうえで、それに対応したツールというのが現状あまり見当たらない、という課題が出てきました。正確にはツールが全くない、というわけではなく、例えば前述のconftestを利用することで、各OSSのCustom Resourceに対するポリシーを定義すれば、テストすることは可能です。しかし、これにはいくつかの課題があると考えました。

1. プロダクトごとに異なるポリシーを用意しなければならない: あるプロダクトのために用意したポリシーを、別のプロダクトに使いまわすことは難しい。

2. プロダクトのアップデートに合わせてメンテナンスが必要になる: プロダクトのバージョンアップにより、マニフェストファイルのスキーマが変更された場合、利用するプロダクトのバージョンに追従するだけでなく、テストのほうも修正をする必要がある。

このため、すべてのテストをconftestのようなツールで行う場合、ポリシーの実装とメンテナンスのコストがかなり高くなるのではないか、と考えました。

そこで、Custom Resourceに対するValidationを、より簡単に、より汎用的に実現できないかと考え、表題のようにGitHub Actions + k3sでのテストを試してみました。

Custom Resourceを利用するにはCustom Resource Definitionを事前に用意する必要があります。Custom Resource DefinitionにはOpenAPI schemaを利用したValidation機能が備わっており、Custom Resourceデプロイ時に問題があればエラーを返します。今回はこの機能を利用し、k3sへテスト対象のリソースを実際にデプロイすることで、Validationを行いました。

※下記画像はこちらの記事中の画像を一部編集したもの

f:id:FY0323:20201221140127j:plain

※参考:

GitHub Actions + k3sの実行方法

今回試したのは以下のような構成です。なお、マニフェストファイルはGitHubリポジトリ上で管理することを前提としております。

f:id:FY0323:20201221143607j:plain

まずGitHubリポジトリマニフェストファイルをPushすることでGitHub Actionsが起動します。GitHub Actionsでは最初にk3sをワークフロー用インスタンス上にインストールし、そこへ対象のOSSをインストールします。最後に、テスト対象のマニフェストファイルをkubectlコマンドでデプロイし、エラーが発生しないかを確認します。

今回は検証するOSSとして、Argo CDRookを選択しました。2つのOSSを利用する理由としては、複数のOSSのCustom Resourceを対象にすることで、この方法が共通利用できるかを確認したかったためです。

リポジトリ中のフォルダ構成は以下のようにしています。

/testrepo
|
|-- .github
|    |
|    `-- workflows
|        |
|        |-- argocd-validate.yaml
|        `-- rook-validate.yaml
|
|-- app
|    |
|    `-- sample-app-argocd.yaml
|
`-- storage
     |
     `-- sample-block-storage.yaml

各Workflowの定義ファイルは以下の通りです。今回は各OSS毎に個別にWorkflowファイルを用意し、GitHub Actionsで並列に実行するようにしました。

OSSのインストール中、必要に応じてsleepコマンドを行っていますが、それぞれの処理毎に必要な待機時間については測定していないため、適宜変更する必要があります。

また、k3sの利用に際し、こちらのActionを利用しました。

argocd-validate.yaml

name: ArgoCD

on:
 push:
   branches: [ main ]
 workflow_dispatch:

jobs:
 argocd-validation:
   runs-on: ubuntu-latest
   steps:
     - uses: actions/checkout@v2
     - name: k3s-install
       uses: debianmaster/actions-k3s@master
       id: k3s
       with:
         version: 'v1.17.4-k3s1'
     - name: k3s-install-check
       run: |
         kubectl get nodes
     - name: argocd-install
       run: |
         kubectl create namespace argocd
         sleep 3
         kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
     - name: argocd-app-deploy-test
       run: |
         kubectl apply -f ./app/

rook-validate.yaml

name: Rook
on:
 push:
   branches: [ main ]
 workflow_dispatch:
jobs:
 rook-validation:
   runs-on: ubuntu-latest
   steps:
     - uses: actions/checkout@v2
     - name: k3s-install
       uses: debianmaster/actions-k3s@master
       id: k3s
       with:
         version: 'v1.17.4-k3s1'
     - name: k3s-install-check
       run: |
         kubectl get nodes
     - name: rook-install
       run: |
         git clone https://github.com/rook/rook.git
         sleep 5
         kubectl create -f rook/cluster/examples/kubernetes/ceph/crds.yaml
         sleep 5
         kubectl create -f rook/cluster/examples/kubernetes/ceph/common.yaml
         sleep 5
         kubectl create -f rook/cluster/examples/kubernetes/ceph/operator.yaml
         sleep 60
         kubectl create -f rook/cluster/examples/kubernetes/ceph/cluster-test.yaml
     - name: rook-storage-deploy-test
       run: |
         kubectl apply -f ./storage/

今回テスト対象としたのは、以下の2ファイルです。

sample-app-argocd-yaml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
 name: sampleapp
 namespace: argocd
spec:
 project: default
 source:
   repoURL: https://github.com/argoproj/argocd-example-apps.git
   targetRevision: HEAD
   path: guestbook
 destination:
   server: https://kubernetes.default.svc
   namespace: argocd
 syncPolicy:
   automated:
     prune: false
     selfHeal: false

sample-block-storage.yaml

apiVersion: ceph.rook.io/v1
kind: CephBlockPool
metadata:
 name: replicapool
 namespace: rook-ceph
spec:
 failureDomain: host
 replicated:
   size: 1
   requireSafeReplicaSize: false
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rook-ceph-block
provisioner: rook-ceph.rbd.csi.ceph.com
parameters:
    clusterID: rook-ceph
    pool: replicapool
    imageFormat: "2"
    imageFeature: layering
    csi.storage.k8s.io/provisioner-secret-name: rook-csi-rbd-provisioner
    csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
    csi.storage.k8s.io/controller-expand-secret-name: rook-csi-rbd-provisioner
    csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph     
    csi.storage.k8s.io/node-stage-secret-name: rook-csi-rbd-node
    csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
    csi.storage.k8s.io/fstype: ext4
allowVolumeExpansion: true
reclaimPolicy: Delete

上記ファイルを用意できたら、対象のGitHubリポジトリへPushし、GitHub Actionsを起動します。

Validation成功時

まずは定義ファイルに問題がない場合です。デプロイに成功した場合は、以下の画像のように、エラーも発生せず正常終了します(一部ワークフロー中のコマンドやファイル名が異なりますが、結果には影響ありません)。

f:id:FY0323:20201221152810j:plain

f:id:FY0323:20201221144044j:plain

Validation失敗時

次に定義ファイルに問題がある場合です。今回は以下のように、必須項目の部分を一部修正し、エラーを発生させるようにしました。

sample-app-argocd-yaml

(中略)

spec:
 project: default
 source:
   repoURL: https://github.com/argoproj/argocd-example-apps.git
   targetRevision: HEAD
   path: guestbook
 destinations: # “spec.destination”は必須項目のためエラーとなる
   server: https://kubernetes.default.svc

(中略)

sample-storage-block.yaml

apiVersion: ceph.rook.io/v1
kind: CephBLockPool # スペルミス
metadata:

(中略)

これらをリポジトリへPushした結果は以下の通りです。それぞれエラーが発生し、Jobが失敗している様子が確認できます。

f:id:FY0323:20201221144223j:plain

f:id:FY0323:20201221144412j:plain

ここまでで、ひとまず簡単なValidationを行えることは確認できました。

追加検証

ここから、個人的に気になっていたポイントについて、もう少し試してみました。

同時に複数ファイルをテストするとどうなるか

Custom Resourceを1つテストする分には特に問題なく動作しましたが、複数のリソースを同時にデプロイするとどうなるかを見てみました。

今回はArgo CDで利用するApplicationリソースを複数用意し、それらを同時にデプロイしました。

# 100個のコピーを作成し、"metadata.name"を変更
$ for i in `seq 100`; do cp -p app/sample-app-argocd.yaml app/sample-app-argocd-$i.yaml; done
$ ll app/ | grep sample-app | wc -l
101
$ for i in `seq 100`; do sed -i -e "s/name: sampleapp/name: sampleapp-$i/g" app/sample-app-argocd-$i.yaml; done

上記操作後、一部Applicationリソースのみエラーを起こすよう修正し、リポジトリへPushしました。その結果、以下の画像の通り、エラーを含むリソースが表示され、Jobの結果もエラーとなって終了しました。

f:id:FY0323:20201221144633j:plain

余分な項目が追加されている場合は検出できるか

前項では、設定が必須の項目や指定された値以外を設定した場合を見ましたが、余分なデータ、例えば本来設定する必要のないデータが含まれる場合はどうなるか見てみました。

ここでは、以下のようなデータを追加して、リポジトリへPushしてみます。

(中略)

spec:
 project: default
 source:
   repoURL: https://github.com/argoproj/argocd-example-apps.git
   targetRevision: HEAD
   path: guestbook
   errorData: true  # 追加
 destination:
   server: https://kubernetes.default.svc
   namespace: argocd
   errorData: true  # 追加

(中略)
(中略)

spec:
 failureDomain: host
 replicated:
   size: 1
   requireSafeReplicaSize: false
   errorData: true  # 追加

(中略)

すると、エラーは発生せず、リソースの作成が正常に完了してしまいました。

f:id:FY0323:20201221144800j:plain

f:id:FY0323:20201221144850j:plain

これはkubevalを利用する場合とも共通した問題となりそうですが、意味のない余分なデータの検出は難しいと思われます。

気になること

現時点で気になることは色々とあるのですが、ポジティブな点とネガティブな点について書いておきます。

ポジティブな点

今回GitHub Actions中にk3sへCustom Resourceをデプロイすることで、Custom Resourceを定義するマニフェストファイル中の構文エラーを検出することができました。また、2つのOSSのCustom Resourceに対してチェックを行い、どちらも構文の間違いを検出できたため、Custom Resourceを利用するプロダクトに対しては、ある程度汎用的に使えるのではないか、と思っています。

また、今回は各OSSをk3sへインストールする際に、最新版をデプロイするように指定をしております。これにより、バージョン更新によるマニフェストスキーマ変更に気づけるのではないか、とも考えています。OSSはバージョン更新の際、マニフェストファイルのスキーマが変更される場合があり、古いバージョンでのマニフェストの書き方と一致しなくなる場合があります。CIパイプライン等に今回の仕組みを導入しておくことで、旧バージョン仕様のCustom Resourceのデプロイが失敗することを契機として、スキーマ変更に気づけるかもしれません。

ネガティブな点

GitHub Actions + k3sで簡単にValidationができそうだと思った一方、いくつかの課題もありそうです。

まずはシンプルに実行結果が見にくいと感じました。テストの成功・失敗はGitHub Actions上のステータスからわかるので良いですが、失敗した場合の理由や内容が少し見にくいと感じました。

また、もっと大きな問題として、一度に全てのエラー箇所を表示してくれない、というものがあります。例えばkindなどの指定が誤っている場合、エラー内容としては以下の画像のように、該当するリソースが見つからない、と表記されます。

f:id:FY0323:20201221145048j:plain

しかし、ここでテストを行ったマニフェストファイルを見てみます。マニフェストファイルを見てみると、kindの他にも複数のエラーが含まれており、これらはこの時点では検出されません。

apiVersion: argoproj.io/v1alpha1
kind: Applications  # エラー
metadata:
 name: sampleapp
 namespace: argocd
spec:
 project: default
 source:
   repoURL: https://github.com/argoproj/argocd-example-apps.git
   targetRevision: HEAD
   path: guestbook
 destinations:  # エラー
   server: https://kubernetes.default.svc
   namespace: argocd
 syncPolicy:
   automate:  # エラー
     prune: false
     selfHeal: false

kindの箇所を修正して再びPushすると、今度はspec.destinationが見つからない、というエラーメッセージが表示されますが、その下にあるspec.syncPolicy.automateのエラーは表示されません。

f:id:FY0323:20201221145211j:plain

このように、一度に全てのエラーが表示されないため、何度も同じような修正作業を行う必要が出てしまいます。

最後に、これもkubevalでも同様の問題がありそうですが、テスト対象のファイルを含むフォルダの構成は検討したほうがよさそうだとも感じました。テスト対象のマニフェストファイルが複数のフォルダに分散して配置されている場合、GitHub Actionsのワークフロー定義ファイル中で、それらを個別に指定する必要があります。これはテストを用意するうえで手間となりますし、ワークフローの定義ファイルをコピーして別のマニフェストファイルに対するテストを用意するときも面倒です。

そのため、可能であれば、テスト対象のファイルは、まとめて一つのフォルダに格納したほうがよさそうです。

参考ドキュメント

【メモ】CronJob経由で定期的にKubernetesリソースをデプロイする

はじめに

今回はKubernetesCronJobリソースでkubectlコマンドを実行し、定期的にリソースを更新する方法について、メモを残しておきます。

やりたいことは以下の通りです。

今回はgit kubectlそれぞれのコマンドを実行することのできるコンテナイメージを利用し、2つのコンテナ間でEmptyDir Volumeを共有することで、マニフェストファイルのデプロイを実行しました。

CronJobの利用方法

CronJobからkubectlコマンドを実行するには、CronJobkubectlコマンドを実行できるよう権限を付与する必要があります。そのため、まずはService AccountとRole/Rolebindingを作成します。今回はDeploymentリソースの作成を目的としていたため、以下のようなRoleを付与しました。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: internal-kubectl
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: modify-deployments
rules:
  - apiGroups: ["apps"]
    resources:
      - deployments
    verbs:
      - get
      - create
      - update
      - patch
      - list
      - delete
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: modify-pods-to-sa
subjects:
  - kind: ServiceAccount
    name: internal-kubectl
roleRef:
  kind: Role
  name: modify-deployments
  apiGroup: rbac.authorization.k8s.io

また、利用するGitHubリポジトリがプライベートリポジトリの場合は、以下のようなSecretリソースをあらかじめ作成しておきます。

apiVersion: v1
kind: Secret
metadata:
  name: forgithubaccess
type: Opaque
data:
  username: <base64-GItHub account>
  password: <base64-GitHub access token>

これでCronJobを利用する準備ができたので、次にCronJobを作成します。今回は、以下のような定義ファイルを利用しました。

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: cronjob-git-kubectl
spec:
  schedule: "*/1 * * * *"  # 毎分実行する
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: internal-kubectl  # 事前に作成したService Accountを指定
          containers:
          - name: git
            image: alpine/git:latest
            env:
              - name: SECRET_USERNAME
                valueFrom:
                  secretKeyRef:
                    name: forgithubaccess
                    key: username
              - name: SECRET_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: forgithubaccess
                    key: password
            command: ["git", "clone"]
            args: ["https://$(SECRET_USERNAME):$(SECRET_PASSWORD)@github.com/<Account>/<Repository>.git", "/cache"]
            volumeMounts:
            - mountPath: /cache
              name: git-volume
          - name: kubectl
            image: bitnami/kubectl
            command: ["/bin/sh", "-c"]
            args: 
            - |
              sleep 10;  # 上のgit cloneが終了するまで待機
              kubectl apply -f /cache/sample-app.yaml  # GitHubリポジトリのディレクトリ・ファイル構成に応じて変更
            volumeMounts:
            - mountPath: /cache
              name: git-volume
          volumes:
          - name: git-volume
            emptyDir: {}
          restartPolicy: Never  
      backoffLimit: 4

実行内容としては、①対象の定義ファイルを格納したGitリポジトリからファイルを取得する、②取得したファイルを指定してkubectl applyコマンドを実行する、という流れになります。①の処理が完了する前に②を実行すると、「指定したファイルが存在しない」というエラーが発生するため、sleepで一定時間待機してからkubectlコマンドを実行するようにしています。

CronJob中で2つのコンテナを実行し、取得したリポジトリのデータを共有できるようemptyDirリソースを利用しています。

CronJobのスケジューリングはひとまず1分ごとに実行するよう設定し、1分ごとに①②の操作を繰り返すことになります。

これを利用してCronJobを作成します。

$ kubectl apply -f cronjob-git-kubectl.yml
cronjob.batch/cronjob-git-kubectl created


$ kubectl get cronjob
NAME                  SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cronjob-git-kubectl   */1 * * * *   False     0        <none>          6s

作成後にPodの状態を確認すると、1分ごとにJobが起動し、動作している様子が確認できます。

# CronJobの様子
# “sample-app”がkubectlコマンドで作成したPod

$ kubectl get pods -w
NAME                                          READY   STATUS    RESTARTS   AGE
cronjob-git-kubectl-1607865300-ff4q6          0/2     Pending   0          0s
cronjob-git-kubectl-1607865300-ff4q6          0/2     Pending   0          0s
cronjob-git-kubectl-1607865300-ff4q6          0/2     ContainerCreating   0          0s

# (1回目のJob実行)

cronjob-git-kubectl-1607865300-ff4q6          1/2     Running             0          13s
sample-app-75ff8897f5-rgmgt                   0/1     Pending             0          0s
sample-app-75ff8897f5-rgmgt                   0/1     Pending             0          0s
sample-app-75ff8897f5-rgmgt                   0/1     ContainerCreating   0          0s
cronjob-git-kubectl-1607865300-ff4q6          0/2     Completed           0          23s
sample-app-75ff8897f5-rgmgt                   1/1     Running             0          9s


# (2回目以降のJob実行)

cronjob-git-kubectl-1607865360-9h7ds          0/2     Pending             0          0s
cronjob-git-kubectl-1607865360-9h7ds          0/2     Pending             0          0s
cronjob-git-kubectl-1607865360-9h7ds          0/2     ContainerCreating   0          0s
cronjob-git-kubectl-1607865360-9h7ds          1/2     Running             0          7s
cronjob-git-kubectl-1607865360-9h7ds          0/2     Completed           0          17s
cronjob-git-kubectl-1607865420-6ncqj          0/2     Pending             0          0s
cronjob-git-kubectl-1607865420-6ncqj          0/2     Pending             0          0s
cronjob-git-kubectl-1607865420-6ncqj          0/2     ContainerCreating   0          1s
cronjob-git-kubectl-1607865420-6ncqj          1/2     Running             0          8s
cronjob-git-kubectl-1607865420-6ncqj          0/2     Completed           0          18s


# 実行結果

$  kubectl get pods
NAME                                          READY   STATUS      RESTARTS   AGE
cronjob-git-kubectl-1607865300-ff4q6          0/2     Completed   0          2m26s
cronjob-git-kubectl-1607865360-9h7ds          0/2     Completed   0          86s
cronjob-git-kubectl-1607865420-6ncqj          0/2     Completed   0          26s
sample-app-75ff8897f5-rgmgt                   1/1     Running     0          2m4s


# 1回目のJobのログ
$ kubectl logs cronjob-git-kubectl-1607865300-ff4q6 -c git
Cloning into '/cache'...

$ kubectl logs cronjob-git-kubectl-1607865300-ff4q6 -c kubectl
deployment.apps/sample-app created


# 2回目以降のJobのログ
$ kubectl logs cronjob-git-kubectl-1607865420-6ncqj -c git
Cloning into '/cache'...

$ kubectl logs cronjob-git-kubectl-1607865420-6ncqj -c kubectl
deployment.apps/sample-app unchanged

参考ドキュメント

External Secretsを試してみる

今回はKubernetes上で扱う秘匿情報を管理するExternal Secretsを試してみました。

注意事項:本記事はExternal SecretsのリポジトリGoDaddyからexternal-secretsに移管する前に検証をしていたものです。そのため、一部マニフェストファイルにはgodaddy/kubernetes-external-secretsと記載されております。

リポジトリ移管の経緯はGitHub Issueに記載されております。

Kubernetesで利用するSecretの課題と関連プロダクト

Kubernetesはログインパスワードなどの秘匿情報をSecretリソースで管理することが一般的な方法です。Secretリソースはbase64で暗号化した値を設定する必要があり、これはSecretリソースにアクセスできてしまえば、簡単に復号化できてしまう情報になります。

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  username: YWRtaW4=  # admin
  password: YWRtaW5wYXNzd29yZAo=  # adminpassword
$ echo YWRtaW4= | base64 --decode
admin
$ echo YWRtaW5wYXNzd29yZAo= | base64 --decode
adminpassword

一方でKubernetesマニフェストファイルをGitHubで管理してGitOpsを行う場合、Secretを含むマニフェストファイルをGitHubリポジトリに保管するため、リポジトリにアクセスできるユーザーは誰でも秘匿情報を知ることができてしまいます。

このようなケースに対応するため、KubernetesのSecret情報を管理するプロダクトは複数開発されております。代表的なものとして、以下のものが挙げられます。

  • kubesec: gpg / AWS KMS / Google Cloud KMSなどの鍵情報を利用してSecretリソースを暗号化する。kubesecコマンドを実行して新規・既存のSecretリソースを作成することができる。
  • Sealed Secret: SealedSecret Operatorとkubesealコマンド、デプロイ時にクラスター上に作成される公開鍵・秘密鍵を組み合わせる。kubesealコマンドによってSecretリソースを暗号化したテンプレートが用意され、そのテンプレートを利用してSealedSecretリソースを作成することでSecretリソースが作成される。
  • External Secrets: ExternalSecrets Controllerを利用して、外部のサービスに保管された秘匿情報からSecretリソースを作成する。
  • Berglas: Google Cloud Storage / Google Cloud KMS等を利用して秘匿情報を管理するツール。Kubernetes Secretリソースに対してはMutatingWebhookConfigurationを利用して、Google Cloud Secret Managerに保管した秘匿情報をDeploymentなどのリソースに渡す。

上記ツールの比較については、以下の記事を参照してください。

※参考ドキュメント:

External Secretsとは

このうちExternal Secretsは、以下のような特徴を備えたツールです。

外部の秘匿情報管理サービスから情報を取得

External Secretsは、クラウド上のサービスや、秘匿情報管理ツールなどから情報を取得し、それを用いてKubernetes上でSecretリソースを作成します。秘匿情報の保管はKubernetes以外の外部サービスを利用することで、Kubernetes向けのマニフェストファイル内に直接データを設定する必要がなくなります。

またExternal Secretsはデフォルトで10sごとに外部サービスを確認し、外部サービスの設定が変更された場合、その変更をSecretに反映します。そのため、Kubernetes側での設定変更は不要となります。

※任意のタイミングでSecretを更新したい場合は、以下のリンク先を参照ください。

ExternalSecret リソースの利用

External SecretsではExternalSecretという独自リソースをCRDで定義し利用します。ExternalSecretを作成すると、External Secretsの起動したPodから、指定した外部サービスにアクセスし、データを取得します。取得後はそのデータを用いてSecretリソースを作成することで、Kubernetes上のリソースから秘匿情報を利用することが可能になります。

複数のクラウドプロバイダーに対応

External Secretsは現在以下のサービス・製品に対応しています。複数のクラウドプロバイダーに対応しているため、様々な環境に対して共通利用できることが期待できます。

※External Secrets概要図(GitHubより)

external-secret-summary

External Secretsを使ってみる

ここからは実際にExternal Secretsを利用する方法を紹介します。利用自体はとてもシンプルなので、公式のリポジトリにある手順に従って操作します。

検証環境

今回の検証は以下の環境で行いました。

Secrets Managerへのアクセス権限を付与

External Secretsは、Controllerが起動したPodから、外部サービスにアクセスしてデータを取得します。そのため、Podが対象のサービスにアクセスするためのアクセス権限を、クラスターまたはPodに付与する必要があります。ここではEKSクラスターのData Planeに対して、Secrets ManagerへのReadWrite権限を付与します。

eksctlコマンドを使用してEKSクラスターを作成すると、EKSを利用するために必要なIAMロールがデフォルトで付与されるので、AWSコンソール等から対象のロールに対してポリシーを追加することができます。

またEKSクラスター作成時にIAMポリシーを指定することも可能です。今回は以下のようなマニフェストファイルを指定してクラスターを作成しました。

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: eks-cluster
  region: ap-northeast-1
vpc:
  id: "vpc-xxxxxxxxxxxxxxxx"
  subnets:
    public:
      ap-northeast-1a: { id: subnet-xxxxxxxxxxxxxxxx }
      ap-northeast-1c: { id: subnet-xxxxxxxxxxxxxxxx }
      ap-northeast-1d: { id: subnet-xxxxxxxxxxxxxxxx }


nodeGroups:
- name: eks-nodegroup-1
  instanceType: t2.medium
  desiredCapacity: 2
  minSize: 2
  maxSize: 5
  securityGroups:
    attachIDs: [sg-xxxxxxxxxxxxxxxx]
  targetGroupARNs: 
    - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy
    - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
    - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy
    - arn:aws:iam::aws:policy/SecretsManagerReadWrite  # 新規追加

External Secretsのデプロイ

続いてExternal Secretsをデプロイします。デプロイはKubernetesのパッケージ管理ツールであるHelmを利用します。今回はhelm templateを利用して必要なマニフェストファイルを生成し、それらを利用することでデプロイを行いました。

$ git clone https://github.com/external-secrets/kubernetes-external-secrets.git
$ cd kubernetes-external-secrets
$ helm template --output-dir ./testdir ./charts/kubernetes-external-secrets/
wrote ./testdir/kubernetes-external-secrets/templates/serviceaccount.yaml
wrote ./testdir/kubernetes-external-secrets/templates/rbac.yaml
wrote ./testdir/kubernetes-external-secrets/templates/rbac.yaml
wrote ./testdir/kubernetes-external-secrets/templates/rbac.yaml
wrote ./testdir/kubernetes-external-secrets/templates/service.yaml
wrote ./testdir/kubernetes-external-secrets/templates/deployment.yaml

生成されたマニフェストファイルは以下の通りです。Helmチャートのvalues.yamlで指定する必要のあるパラメータがRELEASE-NOTE-と表記されており、これを適宜修正しないとデプロイに失敗します。

$ kubectl apply -f ./testdir/kubernetes-external-secrets/templates/deployment.yaml

The Deployment "RELEASE-NAME-kubernetes-external-secrets" is invalid:
* metadata.name: Invalid value: "RELEASE-NAME-kubernetes-external-secrets": a DNS-1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')
* spec.template.spec.serviceAccountName: Invalid value: "RELEASE-NAME-kubernetes-external-secrets": a DNS-1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')

修正は手動のほか、オプションを指定することで生成時に指定することも可能です。 ただ私が試した限り--generate-nameオプションを追加してもRELEASE-NOTE-が変更されなかったため、そちらは手動で修正しました(こちらのIssueと関連する?)

serviceaccount.yaml

---
# Source: kubernetes-external-secrets/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
  namespace: "default"
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    app.kubernetes.io/managed-by: Helm

rbac.yaml

---
# Source: kubernetes-external-secrets/templates/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    app.kubernetes.io/managed-by: Helm
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["create", "update"]
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["get", "watch", "list"]
  - apiGroups: ["apiextensions.k8s.io"]
    resources: ["customresourcedefinitions"]
    resourceNames: ["externalsecrets.kubernetes-client.io"]
    verbs: ["get", "update"]
  - apiGroups: ["kubernetes-client.io"]
    resources: ["externalsecrets"]
    verbs: ["get", "watch", "list"]
  - apiGroups: ["kubernetes-client.io"]
    resources: ["externalsecrets/status"]
    verbs: ["get", "update"]
  - apiGroups: ["apiextensions.k8s.io"]
    resources: ["customresourcedefinitions"]
    verbs: ["create"]
---
# Source: kubernetes-external-secrets/templates/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    app.kubernetes.io/managed-by: Helm
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
subjects:
  - name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
    namespace: "default"
    kind: ServiceAccount
---
# Source: kubernetes-external-secrets/templates/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: RELEASE-NAME-kubernetes-external-secrets-auth  # 要修正
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    app.kubernetes.io/managed-by: Helm
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
- name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
  namespace: "default"
  kind: ServiceAccount

service.yaml

---
# Source: kubernetes-external-secrets/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
  namespace: "default"
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    app.kubernetes.io/managed-by: Helm
spec:
  selector:
    app.kubernetes.io/name: kubernetes-external-secrets
  ports:
    - protocol: TCP
      port: 3001
      name: prometheus
      targetPort: prometheus

deployment.yaml

---
# Source: kubernetes-external-secrets/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: RELEASE-NAME-kubernetes-external-secrets  # 要修正
  namespace: "default"
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: kubernetes-external-secrets
      app.kubernetes.io/instance: RELEASE-NAME  # 要修正
  template:
    metadata:
      labels:
        app.kubernetes.io/name: kubernetes-external-secrets
        app.kubernetes.io/instance: RELEASE-NAME  # 要修正
    spec:
      serviceAccountName: RELEASE-NAME-kubernetes-external-secrets  # 要修正
      containers:
        - name: kubernetes-external-secrets
          image: "godaddy/kubernetes-external-secrets:6.0.0"
          ports:
          - name: prometheus
            containerPort: 3001
          imagePullPolicy: IfNotPresent
          resources:
            {}
          env:
          - name: "AWS_DEFAULT_REGION"
            value: "us-west-2"
          - name: "AWS_REGION"
            value: "us-west-2"
          - name: "LOG_LEVEL"
            value: "info"
          - name: "LOG_MESSAGE_KEY"
            value: "msg"
          - name: "METRICS_PORT"
            value: "3001"
          - name: "POLLER_INTERVAL_MILLISECONDS"
            value: "10000"
          - name: "VAULT_ADDR"
            value: "http://127.0.0.1:8200"
          # Params for env vars populated from k8s secrets
      securityContext:
        runAsNonRoot: true
# オプションの指定例
$ helm template --output-dir ./testdir ./charts/kubernetes-external-secrets/ --set env.AWS_REGION="ap-northeast-1"
wrote ./testdir/kubernetes-external-secrets/templates/serviceaccount.yaml
wrote ./testdir/kubernetes-external-secrets/templates/rbac.yaml
wrote ./testdir/kubernetes-external-secrets/templates/rbac.yaml
wrote ./testdir/kubernetes-external-secrets/templates/rbac.yaml
wrote ./testdir/kubernetes-external-secrets/templates/service.yaml
wrote ./testdir/kubernetes-external-secrets/templates/deployment.yaml

$ cat testdir/kubernetes-external-secrets/templates/deployment.yaml | grep -3 AWS_REGION
          env:
          - name: "AWS_DEFAULT_REGION"
            value: "us-west-2"
          - name: "AWS_REGION"
            value: "ap-northeast-1"
          - name: "LOG_LEVEL"
            value: "info"

※参考ドキュメント:

デプロイ後は以下のようにリソースが作成されます。

# External Secretsデプロイ
$ kubectl apply -f ./output_dir/kubernetes-external-secrets/templates/
deployment.apps/kubernetes-external-secrets created
clusterrole.rbac.authorization.k8s.io/kubernetes-external-secrets created
clusterrolebinding.rbac.authorization.k8s.io/kubernetes-external-secrets created
clusterrolebinding.rbac.authorization.k8s.io/kubernetes-external-secrets-auth created
service/kubernetes-external-secrets created
serviceaccount/kubernetes-external-secrets created

# デプロイ後の確認
$ kubectl get pods
NAME                                           READY   STATUS    RESTARTS   AGE
kubernetes-external-secrets-59b8db67c5-9scxm   1/1     Running   0          44s

$ kubectl get service
NAME                          TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
kubernetes                    ClusterIP   172.20.0.1       <none>        443/TCP    35h
kubernetes-external-secrets   ClusterIP   172.20.177.142   <none>        3001/TCP   76s

AWS Secrets Managerへの登録

続いてAWS Secrets Managerのほうにテストデータを登録します。ここではAWS CLIからデータを登録します。

# 秘匿データの設定
$ aws secretsmanager create-secret --name hello-service/password --secret-string "5678"

# 作成後の確認
$ aws secretsmanager get-secret-value --secret-id hello-service/password
{
    "ARN": "arn:aws:secretsmanager:ap-northeast-1:111111111111:secret:hello-service/password-YmCbrt",
    "Name": "hello-service/password",
    "VersionId": "a298c3ea-1e27-4e05-b379-a13856e6f8c8",
    "SecretString": "5678",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": "2020-11-17T13:53:59.130000+09:00"
}

ExternalSecretリソースの作成

ここまででExternal Secretsを利用する準備が整ったので、ExternalSecretリソースをデプロイします。今回は以下のマニフェストファイルを利用しました。

hello-service-es.yaml

apiVersion: kubernetes-client.io/v1
kind: ExternalSecret
metadata:
  name: hello-service
spec:
  backendType: secretsManager
  region: ap-northeast-1
  data:
    - key: hello-service/password
      name: password
  # optional: specify a template with any additional markup you would like added to the downstream Secret resource.
  # This template will be deep merged without mutating any existing fields. For example: you cannot override metadata.name.
  template:
    metadata:
      annotations:
        cat: cheese
      labels:
        dog: farfel

上記ファイルを用いてリソースを作成します。以下の通り、ExternalSecretリソースを作成することで(外部データを参照したのちに)Secretリソースが作成されます。

# ExternalSecretリソースの作成
$ kubectl apply -f hello-service-es.yaml
externalsecret.kubernetes-client.io/hello-service created


# 作成後の確認
$ kubectl get externalsecret
NAME            LAST SYNC   STATUS    AGE
hello-service   7s          SUCCESS   7s

$ kubectl describe externalsecret hello-service
Name:         hello-service
Namespace:    default
Labels:       <none>
Annotations:  kubectl.kubernetes.io/last-applied-configuration:
                {"apiVersion":"kubernetes-client.io/v1","kind":"ExternalSecret","metadata":{"annotations":{},"name":"hello-service","namespace":"default"}...
API Version:  kubernetes-client.io/v1
Kind:         ExternalSecret
Metadata:
  Creation Timestamp:  2020-11-20T11:25:20Z
  Generation:          1
  Resource Version:    410381
  Self Link:           /apis/kubernetes-client.io/v1/namespaces/default/externalsecrets/hello-service
  UID:                 181d2db7-8162-4758-bb60-3b08c3fc4219
Spec:
  Backend Type:  secretsManager
  Data:
    Key:   hello-service/password
    Name:  password
  Region:  ap-northeast-1
Status:
  Last Sync:            2020-11-20T11:27:01.865Z
  Observed Generation:  1
  Status:               SUCCESS
Events:                 <none>



# Secretリソースが作成される
$ kubectl get secret
NAME                                      TYPE                                  DATA   AGE
default-token-88vp5                       kubernetes.io/service-account-token   3      35h
hello-service                             Opaque                                1      15s
kubernetes-external-secrets-token-v7prp   kubernetes.io/service-account-token   3      14m


$ kubectl get secret hello-service -o yaml
apiVersion: v1
data:
  password: NTY3OA==  # “5678”
kind: Secret
metadata:
  creationTimestamp: "2020-11-20T11:25:20Z"
  name: hello-service
  namespace: default
  ownerReferences:
  - apiVersion: kubernetes-client.io/v1
    controller: true
    kind: ExternalSecret
    name: hello-service
    uid: 181d2db7-8162-4758-bb60-3b08c3fc4219
  resourceVersion: "410070"
  selfLink: /api/v1/namespaces/default/secrets/hello-service
  uid: 4975494d-a2ce-4395-a4bb-cc79c7af8d04
type: Opaque

IAM Role for Service Account (IRSA)を利用する場合に必要な設定

ここまでの手順では、EKSクラスターのData Planeに対して、AWS Secrets ManagerへのReadWrite権限を付与していました。この設定ではExternalSecret Controller以外のPodもSecrets Managerにアクセスすることができてしまうため、セキュリティ的に問題となる場合があります。

AWSではIAM Role for Service Account (IRSA)という機能があります。これはKubernetes上で利用するアカウントであるService Accountに対してIAMロールを付与し、Pod起動時に特定のService Accountを指定することで、Pod単位での権限付与を実現する機能です。

irsa-diagram

AWS IAMは外部IDプロバイダーと連携することで、外部IDに対してAWSリソースへのアクセス権を付与することができます。IRSAでは、EKS上でのOpenID Connect(OIDC)プロバイダーを作成・有効化し、これを利用します。

一方KubernetesではVolumeリソースのprojectedプラグインを利用して、Service Account Tokenを利用することができます。projected VolumeはKubernetes Volumeリソースで利用できるプラグインの種類の一つで、Secret / ConfigMap / downwardAPI / Service Accoutn Tokenの4種類のリソースを1か所のディレクトリに集約してマウントする機能を提供します。このトークンにAWSへのアクセス権を付与することで、特定のService AccountがAWSリソースへアクセスすることを許可します。

Amazon EKS上でExternal Secretsを利用する際も、このIRSAを利用することで、必要最小限の権限付与に抑え、セキュリティを向上することが期待できます。

External Secretsを利用する場合、ExternalSecrets Controller Podを作成すると、Amazon EKS Pod Identity Webhookを介して、Podに必要な環境変数を追加します。この環境変数にはIAMロールのARNとトークンの情報が含まれ、Podに対してprojectedプラグインのVolumeを設定します。

次にExternalSecretリソースを作成すると、PodがAWSリソースにアクセスしようとし、OIDCプロバイダーに対するトークンの検証をSTS経由で行います。認証に成功すればアクセス権を付与(ここではAWS Secrets Managerへのアクセス)し、AWS Secrets Managerに保管された秘匿データを取得、Secretリソースの作成を行うことができます。

※External SecretsをIRSAで利用する場合の簡略図。図中の数字は処理のステップ順を表す。

f:id:FY0323:20201218232106j:plain

IRSAを利用するには、以下のような操作が必要となります。なお、以降の作業は、AWS Secrets Managerへのアクセスを許可するIAMロールが無い状況を想定していますので、必要があればEKSクラスターから指定のロールの削除を実行します。

OpenID Connectプロバイダーの有効化

IRSAを利用するには、まずクラスター上でOpenID Connect (OIDC) IDプロバイダーを有効にする必要があります。IDプロバイダーを有効にするにはeksctl utils associate-iam-oidc-providerコマンドを実行します。

$ eksctl utils associate-iam-oidc-provider --name eks-cluster --approve
Flag --name has been deprecated, use --cluster
[]  eksctl version 0.29.2
[]  using region ap-northeast-1
[]  will create IAM Open ID Connect provider for cluster "eks-cluster" in "ap-northeast-1"
[]  created IAM Open ID Connect provider for cluster "eks-cluster" in "ap-northeast-1"

# 有効化後の確認
$ aws iam list-open-id-connect-providers
{
    "OpenIDConnectProviderList": [
        {
            "Arn": "arn:aws:iam::111111111111:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111"
        }
    ]
}

IAMロールの作成

次にService Accountに付与するIAMロールを用意します。今回はAWS Secrets Managerへアクセスするために、以下のようなCloudFormationテンプレートを利用してIAMロールを作成します。

AWSTemplateFormatVersion: 2010-09-09
Resources:
  eksirsa:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Federated: 
              - arn:aws:iam::111111111111:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111
            Action:
              - 'sts:AssumeRoleWithWebIdentity'
            Condition:
              StringEquals:         
                oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111:sub: system:serviceaccount:default:kubernetes-external-secrets
                oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111:aud: sts.amazonaws.com
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/SecretsManagerReadWrite
      RoleName: eks-irsa
      Tags:
        - Key: alpha.eksctl.io/cluster-name
          Value: eks-cluster
        - Key: eksctl.cluster.k8s.io/v1alpha1/cluster-name
          Value: eks-cluster
        - Key: alpha.eksctl.io/iamserviceaccount-name
          Value: default/kubernetes-external-secrets
        - Key: alpha.eksctl.io/eksctl-version
          Value: 0.29.2
# エラーが発生する場合は”--capabilities CAPABILITY_NAMED_IAM”を追加
$ aws cloudformation deploy --stack-name eks-irsa --template aws-iam-irsa.yaml --capabilities CAPABILITY_NAMED_IAM

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - eks-irsa

# 作成後の確認
$ aws iam get-role --role-name eks-irsa
{
    "Role": {
        "Path": "/",
        "RoleName": "eks-irsa",
        "RoleId": "AROATYRF5FWUEKGK4AIKJ",
        "Arn": "arn:aws:iam::111111111111:role/eks-irsa",
        "CreateDate": "2020-12-12T23:05:21+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Federated": "arn:aws:iam::111111111111:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111"
                    },
                    "Action": "sts:AssumeRoleWithWebIdentity",
                    "Condition": {
                        "StringEquals": {
                            "oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111:aud": "sts.amazonaws.com",
                            "oidc.eks.ap-northeast-1.amazonaws.com/id/11111111111111111111111111111111:sub": "system:serviceaccount:default:kubernetes-external-secrets"
                        }
                    }
                }
            ]
        },
        "Description": "",
        "MaxSessionDuration": 3600,
        "Tags": [
            {
                "Key": "alpha.eksctl.io/cluster-name",
                "Value": "eks-cluster"
            },
            {
                "Key": "eksctl.cluster.k8s.io/v1alpha1/cluster-name",
                "Value": "eks-cluster"
            },
            {
                "Key": "alpha.eksctl.io/iamserviceaccount-name",
                "Value": "default/kubernetes-external-secrets"
            },
            {
                "Key": "alpha.eksctl.io/eksctl-version",
                "Value": "0.29.2"
            }
        ],
        "RoleLastUsed": {}
    }
}

Service Accountに設定を追加

次に、作成したIAMロールをService Accountで利用するよう、設定を変更します。

---
# Source: kubernetes-external-secrets/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: kubernetes-external-secrets
  namespace: "default"
  labels:
    app.kubernetes.io/name: kubernetes-external-secrets
    helm.sh/chart: kubernetes-external-secrets-6.0.0
    app.kubernetes.io/instance: 6.0.0
    app.kubernetes.io/managed-by: Helm
  # 以降の記述を追加
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::111111111111:role/eks-irsa
# Service Account更新
$ kubectl apply -f output_dir/kubernetes-external-secrets/templates/serviceaccount.yaml
serviceaccount/kubernetes-external-secrets configured


# 更新後はAnnotationsが付与されている
$ kubectl get sa
NAME                          SECRETS   AGE
default                       1         6d22h
kubernetes-external-secrets   1         6d21h

$ kubectl describe sa kubernetes-external-secrets
Name:                kubernetes-external-secrets
Namespace:           default
Labels:              app.kubernetes.io/instance=6.0.0
                     app.kubernetes.io/managed-by=Helm
                     app.kubernetes.io/name=kubernetes-external-secrets
                     helm.sh/chart=kubernetes-external-secrets-6.0.0
Annotations:         eks.amazonaws.com/role-arn: arn:aws:iam::111111111111:role/eks-irsa
                     kubectl.kubernetes.io/last-applied-configuration:
                       {"apiVersion":"v1","kind":"ServiceAccount","metadata":{"annotations":{"eks.amazonaws.com/role-arn":"arn:aws:iam::111111111111:role/eks-irs...
Image pull secrets:  <none>
Mountable secrets:   kubernetes-external-secrets-token-wggsc
Tokens:              kubernetes-external-secrets-token-wggsc
Events:              <none>

Deploymentリソースの設定変更

これでIAMロールを付与したService Accountを利用できるようになったので、Deploymentなどのリソースを作成します。

Deploymentリソース作成後に内容を確認すると、aws-iam-tokenという名称のProjected Volumeが付与された様子が確認できます。

# Deployment再作成
$ kubectl apply -f output_dir/kubernetes-external-secrets/templates/deployment.yaml
deployment.apps/kubernetes-external-secrets configured


# 作成後の確認
$ kubectl get pods
NAME                                           READY   STATUS        RESTARTS   AGE
kubernetes-external-secrets-59859579b6-xqkg4   1/1     Running       0          7s
kubernetes-external-secrets-59b8db67c5-r74k8   0/1     Terminating   0          6d21h


$ kubectl describe pod kubernetes-external-secrets-59859579b6-xqkg4
Name:         kubernetes-external-secrets-59859579b6-xqkg4
Namespace:    default
Priority:     0
Node:         ip-10-0-3-64.ap-northeast-1.compute.internal/10.0.3.64
Start Time:   Sun, 13 Dec 2020 08:10:52 +0900
Labels:       app.kubernetes.io/instance=6.0.0
              app.kubernetes.io/name=kubernetes-external-secrets
              pod-template-hash=59859579b6
Annotations:  kubernetes.io/psp: eks.privileged
Status:       Running
IP:           10.0.3.247
IPs:
  IP:           10.0.3.247
Controlled By:  ReplicaSet/kubernetes-external-secrets-59859579b6
Containers:
  kubernetes-external-secrets:
    Container ID:   docker://f6813c88c87e065be47fa29b1de1eb2434928eeb30f866137034212832d8bdf2
    Image:          godaddy/kubernetes-external-secrets:6.0.0
    Image ID:       docker-pullable://godaddy/kubernetes-external-secrets@sha256:e827f7bc272117a97a8562ac70e37810266467f07d2fce798c8abc360b892c08
    Port:           3001/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Sun, 13 Dec 2020 08:10:53 +0900
    Ready:          True
    Restart Count:  0
    Environment:
      AWS_DEFAULT_REGION:            ap-northeast-1
      AWS_REGION:                    ap-northeast-1
      LOG_LEVEL:                     info
      LOG_MESSAGE_KEY:               msg
      METRICS_PORT:                  3001
      POLLER_INTERVAL_MILLISECONDS:  10000
      VAULT_ADDR:                    http://127.0.0.1:8200
      AWS_ROLE_ARN:                  arn:aws:iam::111111111111:role/eks-irsa
      AWS_WEB_IDENTITY_TOKEN_FILE:   /var/run/secrets/eks.amazonaws.com/serviceaccount/token
    Mounts:
      /var/run/secrets/eks.amazonaws.com/serviceaccount from aws-iam-token (ro)
      /var/run/secrets/kubernetes.io/serviceaccount from kubernetes-external-secrets-token-wggsc (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  aws-iam-token:  # 追加されている
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  86400
  kubernetes-external-secrets-token-wggsc:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  kubernetes-external-secrets-token-wggsc
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age   From                                                   Message
  ----    ------     ----  ----                                                   -------
  Normal  Scheduled  3s    default-scheduler                                      Successfully assigned default/kubernetes-external-secrets-59859579b6-xqkg4 to ip-10-0-3-64.ap-northeast-1.compute.internal
  Normal  Pulled     2s    kubelet, ip-10-0-3-64.ap-northeast-1.compute.internal  Container image "godaddy/kubernetes-external-secrets:6.0.0" already present on machine
  Normal  Created    2s    kubelet, ip-10-0-3-64.ap-northeast-1.compute.internal  Created container kubernetes-external-secrets
  Normal  Started    2s    kubelet, ip-10-0-3-64.ap-northeast-1.compute.internal  Started container kubernetes-external-secrets

これでExternalSecretリソースが作成できるかと思いましたが、この状態でリソースを作成しようとすると、以下のようなエラーが発生し、Secrets Managerからデータを取得することができませんでした。

$ kubectl apply -f hello-service-es.yaml
externalsecret.kubernetes-client.io/hello-service created

$ kubectl get externalsecret
NAME            LAST SYNC   STATUS
                                                                                                                                                 AGE
hello-service   5s          ERROR, User: arn:aws:sts::111111111111:assumed-role/eksctl-eks-cluster-nodegroup-eks-NodeInstanceRole-1R5YXUU3GJMP5/i-09defe966bee6cb58 is not authorized
to perform: secretsmanager:GetSecretValue on resource: arn:aws:secretsmanager:ap-northeast-1:111111111111:secret:hello-service/password-YmCbrt   6s

エラーメッセージを見る限り、EKSクラスターのNodeGroupに対して付与されるIAMロールを利用しようとしているように見えます。ExternalSecretリソースの定義では、利用するARNを指定することもできるため、こちらを変更してみます。

apiVersion: kubernetes-client.io/v1
kind: ExternalSecret
metadata:
  name: hello-service
spec:
  backendType: secretsManager
  # optional: specify role to assume when retrieving the data
  roleArn: arn:aws:iam::111111111111:role/eks-irsa
  region: ap-northeast-1
  data:
    - key: hello-service/password
      name: password
  # optional: specify a template with any additional markup you would like added to the downstream Secret resource.
  # This template will be deep merged without mutating any existing fields. For example: you cannot override metadata.name.
  template:
    metadata:
      annotations:
        cat: cheese
      labels:
        dog: farfel

上記ファイルを用いてリソースを作成すると、メッセージは変更されたものの、やはりエラーメッセージが確認できます。

# 作成済みのリソースを一度削除する
$ kubectl delete -f hello-service-es.yaml
externalsecret.kubernetes-client.io "hello-service" deleted


# 再作成
$ kubectl apply -f hello-service-es.yaml
externalsecret.kubernetes-client.io/hello-service created


# 作成後の確認
$ kubectl get externalsecret
NAME            LAST SYNC   STATUS                                 AGE
hello-service   4s          ERROR, Missing credentials in config   4s

いろいろと調べてみたところ、External SecretsでIRSAを利用するには、DeploymentのSecurity Cntextの設定を変更し、fsGroupを以下のように変更する必要があるようです。

(中略)

securityContext:
  runAsNonRoot: true
  fsGroup: 65534

こちらの情報は、現在一緒にお仕事をさせて頂いている方から教えて頂きました。この場を借りて感謝申し上げます。

※参考リンク:

上記設定を追加したところ、無事にデータを取得し、Secretリソースが作成されることを確認できました。

# 設定変更後、Controllerを再作成

$ kubectl apply -f output_dir/kubernetes-external-secrets/templates/deployment.yaml
deployment.apps/kubernetes-external-secrets created


# 作成後の確認
$ kubectl get deployment kubernetes-external-secrets -o yaml

(中略)

      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext:
        fsGroup: 65534  ★
        runAsNonRoot: true
      serviceAccount: kubernetes-external-secrets
      serviceAccountName: kubernetes-external-secrets
      terminationGracePeriodSeconds: 30

(中略)



# ExternalSecretリソースの作成
$ kubectl apply -f hello-service-es.yaml
externalsecret.kubernetes-client.io/hello-service created


# 作成後の確認
$ kubectl get externalsecret
NAME            LAST SYNC   STATUS    AGE
hello-service   5s          SUCCESS   8s

$ kubectl get secret
NAME                                      TYPE                                  DATA   AGE
default-token-875qc                       kubernetes.io/service-account-token   3      6d22h
hello-service                             Opaque                                1      14s
kubernetes-external-secrets-token-wggsc   kubernetes.io/service-account-token   3      6d21h

$ kubectl get secret hello-service -o yaml
apiVersion: v1
data:
  password: NTY3OA==  # “5678”
kind: Secret
metadata:
  creationTimestamp: "2020-12-12T23:27:03Z"
  name: hello-service
  namespace: default
  ownerReferences:
  - apiVersion: kubernetes-client.io/v1
    controller: true
    kind: ExternalSecret
    name: hello-service
    uid: 44e6b1f7-3c91-4a7c-b316-a2cec040a114
  resourceVersion: "1762644"
  selfLink: /api/v1/namespaces/default/secrets/hello-service
  uid: 34b88216-3814-4d32-873c-f10103504d75
type: Opaque

参考ドキュメント