TECHSTEP

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

【メモ】CircleCIとAmazon ECR / EKSを利用したCI/CDパイプラインの例

はじめに

今回はCircleCIとAmazon ECRを利用したCI/CDパイプラインを用意し、コンテナイメージのビルドからKubernetesクラスタへのデプロイまで実行するサンプルを作成しました。

構成

今回は以下のようなCI/CDパイプラインを作成しました。

構成図は以下のようになります。CI/CDパイプラインはCircleCI、コンテナレジストリAmazon ECR、Kubernets環境はAmazon EKSを選択しています。

f:id:FY0323:20210515172615j:plain

CircleCI

今回利用したリポジトリディレクトリ構造はこちら。.circleci/config.yml 以外は前回のGitHub Actionsの例と同じファイルを使用しています。

.
├── .circleci
│   └── config.yml
├── README.md
├── app
│   ├── dockerfile
│   ├── main.go
│   └── main_test.go
└── manifest
    ├── deployment.yaml
    └── test.sh

CI/CDパイプラインは以下のようなファイルで構成しています。CircleCIを利用するには .circleci/config.yml ファイル上にワークフローを定義する必要があります。

今回はAmazon ECRへコンテナイメージをPushするために circleci/aws-ecrというOrbを利用しています。このOrbは、サンプルなどを見る限りWorkflow上で定義することが多いのですが、今回処理結果をSlackに通知したかったためecr-pushというJob上で定義しています。本当はJobの実行基盤をDockerにしたかったのですが上手くいかなかったため、ecr-push JobはVM上で実行しています。

./circleci/config.yml

version: 2.1
 
orbs:
  slack: circleci/slack@4.4.2
  aws-ecr: circleci/aws-ecr@7.0.0
  aws-eks: circleci/aws-eks@1.1.0
  kubernetes: circleci/kubernetes@0.12.0
 
commands:
  slack_fail:
    steps:
      - slack/notify:
          event: fail
          template: basic_fail_1
  slack_success:
    steps:
      - slack/notify:
          event: pass
          template: basic_success_1
 
jobs:
  go-test:
    docker:
      - image: circleci/golang:1.15.6
    working_directory: ~/repo
    steps:
      - checkout
      - run:
          name: Run tests
          command: go test -v ./app
      - slack_fail
  ecr-push:
    machine:
      image: ubuntu-2004:202010-01
    steps:
      - aws-ecr/build-and-push-image:
          account-url: AWS_ECR_ACCOUNT_URL
          aws-access-key-id: AWS_ACCESS_KEY_ID
          aws-secret-access-key: AWS_SECRET_ACCESS_KEY
          dockerfile: dockerfile
          path: ./app
          region: AWS_REGION
          repo: <repository-name>
          tag: latest
      - slack_fail
      - slack_success
 
  k8s-validate:
    docker:
      - image: circleci/golang:1.15.6
    working_directory: ~/repo
    steps:
      - checkout
      - run:
          name: install
          command: |
            wget https://github.com/instrumenta/kubeval/releases/latest/download/kubeval-linux-amd64.tar.gz
            tar xf kubeval-linux-amd64.tar.gz
            sudo cp kubeval /usr/local/bin
      - run:
          name: validation
          command: kubeval ./manifest/*.yaml
      - slack_fail
  k8s-deployment:
    executor: aws-eks/python3
    parameters:
      cluster-name:
        default: <cluster-name>
        type: string
      aws-region:
        default: <aws-region>
        type: string
    steps:
      - checkout
      - aws-eks/update-kubeconfig-with-authenticator:
          cluster-name: << parameters.cluster-name >>
          aws-region: << parameters.aws-region >>
          install-kubectl: true
      - kubernetes/create-or-update-resource:
          resource-file-path: manifest/deployment.yaml
      - run:
          name: check resource
          command: kubectl get pods
      - run:
          name: test apps
          command: |
            chmod +x ./manifest/test.sh
            sh ./manifest/test.sh
      - slack_fail
      - slack_success
 
 
workflows:
  docker-build-and-deploy:
    jobs:
      - go-test
      - k8s-validate
      - ecr-push:
          requires:
            - go-test
          filters:
            branches:
              only:
                - main
      - k8s-deployment:
          requires:
            - k8s-validate
            - ecr-push
          filters:
            branches:
              only:
                - main


利用したOrbsはこちら。

参考ドキュメントはこちら。


利用時の前提

パイプラインを利用するうえで必要な準備なども記載します。

CircleCIの登録

CircleCIに登録していない場合は登録と利用するリポジトリの選択を済ませておきます。

※参考ドキュメント:

Slackへの通知用設定

Jobの実行結果をSlackに通知するため、Slackの設定を行います。Slack AppにてTokenの取得と投稿用チャンネルの作成をしておきます。Slackでの設定はこちらのドキュメントに書かれた通り実行すれば完了するかと思います。

CircleCIでの変数の設定

CircleCIの Environment Variables には以下の情報を登録します。

  • AWS_ACCESS_KEY_ID: AWSへのアクセスに利用するアカウントID
  • AWS_SECRET_ACCESS_KEY: AWSへのアクセスに利用するシークレットアクセスキー
  • AWS_ECR_ACCOUNT_URL: Amazon ECRへのアクセスに利用するURL
  • AWS_REGION: AWSで利用するリージョン
  • SLACK_ACCESS_TOKEN: Slack Appで取得したアクセスToken
  • SLACK_DEFAULT_CHANNEL: Slack通知に利用するチャンネル名

その他

CircleCIでは、リポジトリ上のディレクトリやファイル毎にJobを起動することができない、と認識しています。そのため、以前GitHub Actionsでつくったパイプラインと異なり、今回はひとつのワークフローの中に、イメージのビルドとクラスターへのデプロイのJobを入れています。

CIとCDのプロセスを分けようと思った場合、いくつか方法はあると考えられます。

アプリ・インフラそれぞれ専用のリポジトリを用意する

リポジトリごとに.circleci/config.ymlを作成し、ワークフローを設定すれば処理を分けることが可能になり、CIとCDを分離することができます。アプリとインフラのコードを別のリポジトリに管理する場合、こちらの方法を採用することができるでしょう。

シェルスクリプト上で処理する

こちらの記事などで紹介されていますが、シェルスクリプト上でCircleCIを実行するタイミングをコントロールする、という方法もあるようです。プロジェクトで利用するコードをmonorepoで管理する場合、こちらの方法を採用することになるでしょう。

【メモ】GitHub ActionsとGitHub Container Registryを利用したCI/CDパイプラインの例

はじめに

今回はタイトルの通り、GItHub ActionsとGitHub Container Registryを利用したCI/CDを用意し、コンテナイメージのビルドからKubernetesクラスターへのデプロイまでを実行するサンプルを作成しました。

構成

CI/CDのパイプラインのパターンはいくつか考えられますが、今回メインで用意したのは、以下のような特徴を備えたものです。

今回の構成は以下のようになります。CI/CDパイプラインはGitHub Actionsで、コンテナイメージのレジストリGitHub Contianer Registryを利用しています。KubernetesクラスターはAmazon EKSを利用していますが、kubectl でアクセスできる環境であれば何でも良いと思います。

f:id:FY0323:20210504141133j:plain

f:id:FY0323:20210504141150j:plain

GitHub Actions

今回のサンプルはこちらのリポジトリに配置しています。

ディレクトリ構造は以下の通り。

.
├── .github
│   └── workflows
│       ├── docker-build-and-push.yaml
│       ├── k8s-deploy.yaml
│       ├── k8s-validate.yaml
│       └── test-and-build-code.yaml
├── README.md
├── app
│   ├── dockerfile
│   ├── main.go
│   └── main_test.go
└── manifest
    ├── deployment.yaml
    └── test.sh

CIは以下のようなアクションを用意しました。コンテナイメージビルド時に ghcr.io を指定して、GitHub Container Registryを利用しています。また secrets.USERNAME という値を指定していますが、この理由については後述します。

test-and-build-code.yaml

name: CI test Go code
on: 
  pull_request:
    branches:
    - main
    paths:
    - 'app/**'

jobs:
  test-and-build:
    runs-on: ubuntu-latest
    steps:
    - name: setup
      uses: actions/setup-go@v1
      with:
        go-version: 1.15
    - name: checkout
      uses: actions/checkout@v2
    - name: go test
      run: |
        go test -v ./app
    - name: notification
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      if: always() 

docker-build-and-push.yaml

name: CI docker build and push
on: 
  pull_request:
    branches:
    - main
    paths: 
    - 'app/**'
    types: [closed]

jobs:
  docker-build-and-push:
    runs-on: ubuntu-latest
    steps:
    - name: checkout
      uses: actions/checkout@v2
    - name: Set up Docker Builder
      uses: docker/setup-buildx-action@v1
    - name: Log into GitHub Container Registry
      uses: docker/login-action@v1
      with:
        registry: ghcr.io
        username: ${{ github.repository_owner }}
        password: ${{ secrets.CR_PAT }}
    - name: build container image
      uses: docker/build-push-action@v2
      with:
        context: ./app
        file: dockerfile
        push: true
        tags: ghcr.io/${{ secrets.USERNAME }}/sample-app:latest
    - name: notification
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      if: always() 


使用したActionsはこちら。

参考にしたドキュメントはこちら。


CDは以下のようなアクションを用意しました。

k8s-validate.yaml

name: CD validate Kubernetes manifests
on: 
  pull_request:
    branches:
    - main
    paths:
    - 'manifest/**'
    - 'app/**'

jobs:
  validation:
    name: validate k8s manifest
    runs-on: ubuntu-latest
    steps:
    - name: checkout
      uses: actions/checkout@v2
    - name: validation
      uses: instrumenta/kubeval-action@master
      with:
        files: ./manifest/
        version: 1.18
    - name: notification
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      if: always() 

k8s-deploy.yaml

name: CD deploy container to Kubernetes
on: 
  pull_request:
    branches:
    - main
    paths:
    - 'manifest/**'
    - 'app/**'
    types: [closed]

jobs:
  deploy-to-EKS:
    name: deploy manifests to k8s
    runs-on: ubuntu-latest
    steps:
    - name: checkout
      uses: actions/checkout@v2
    - name: login to AWS
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1
    - name: set kubectl
      uses: azure/setup-kubectl@v1
    - name: deploy manifest
      env:
        KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
      run: |
        echo "$KUBE_CONFIG" > /tmp/kubeconfig
        export KUBECONFIG=/tmp/kubeconfig
        kubectl apply -f manifest/
        chmod +x manifest/test.sh
        sh ./manifest/test.sh
    - name: notification
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      if: always() 

Kubernetes Podデプロイ後に実行しているテストスクリプト中で kubectl exec ~ を実行しています。GitHub ActionsではTTYを使用することができないため、 kubectl オプションには -i のみを指定しています。


使用したActionsはこちら。

参考にしたドキュメントはこちら。


利用時の前提条件

上記アクションを利用するうえで必要な設定も書いておきます。

GitHub Container Registryを利用可能にする

GitHub Container Registryは現在パブリックベータで提供されるため、GitHubでFeature Previewで有効にする必要があります。

f:id:FY0323:20210504130938j:plain

またGitHub Container Registryにアクセスするために、GitHubのPersonal Access Tokenを用意する必要があります。

f:id:FY0323:20210504131014j:plain

GitHub Secretsに必要な値を設定する

GitHub Secretsには以下の値を設定しています。

  • AWS_ACCESS_KEY_ID : AWSへのアクセスに利用するアカウントID
  • AWS_SECRET_ACCESS_KEY : AWSへのアクセスに利用するシークレットアクセスキー
  • CR_PAT : GitHub Container Registry用のPersonal Access Token。
  • KUBE_CONFIG : Kubernetesクラスターへアクセスするのに利用する kubeconfig
  • SLACK_WEBHOOK_URL : SlackチャンネルにJobの実行結果を送信するためのIncoming Webhook URL
  • USERNAME : コンテナレジストリへのイメージ格納時に使用

AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY は、利用するクラウドプロバイダーに合わせて変更します。KUBE_CONFIGクラスター作成後の ~/.kube/config ファイルの内容を設定します。

USERNAME については少し補足すると、通常は repository_owner を指定すればGitHubアカウント名を指定することができるのですが、私の作成したアカウント名に大文字が含まれており、このまま利用すると repository name must be lowercase. というエラーが発生します。そのためここでは小文字に直したアカウント名を指定しています。

KubernetesからイメージをPullできるようSecretを設定する

GitHub Container Registryはデフォルトでプライベートレジストリになります。KubernetesクラスターがプライベートレジストリからイメージをPullするには、コンテナレジストリにアクセスするためのSecretリソースを作成し、イメージを利用するPodマニフェスト中でImagePullSecret を設定する必要があります。

私はWindows端末でWSL2を利用しているため、以下のようなコマンドを実行してSecretリソースを作成しました。

# secretリソースの作成
$ kubectl create secret docker-registry regcred \
--docker-server=https://ghcr.io \
--docker-username=fy0323 \
--docker-password=<GitHub_Personal_Access_Token>

secret/regcred created


# 作成後の確認
$ kubectl get secret regcred -oyaml
apiVersion: v1
data:
  .dockerconfigjson: <base64_decoded_data>
kind: Secret
metadata:
  creationTimestamp: "2021-05-02T08:11:01Z"
  managedFields:
  - apiVersion: v1
    fieldsType: FieldsV1
    fieldsV1:
      f:data:
        .: {}
        f:.dockerconfigjson: {}
      f:type: {}
    manager: kubectl-create
    operation: Update
    time: "2021-05-02T08:11:01Z"
  name: regcred
  namespace: default
  resourceVersion: "75368"
  selfLink: /api/v1/namespaces/default/secrets/regcred
  uid: ee113b17-4d53-4e19-b475-887f3acc32a2
type: kubernetes.io/dockerconfigjson

Podマニフェストでは、以下のように spec.imagePullSecrets を指定します。

(中略)
    spec:
      containers:
      - name: app
        image: ghcr.io/fy0323/sample-app:latest
        ports:
        - containerPort: 8080
      imagePullSecrets:
      - name: regcred

その他

今回はCIとCDを別のタイミングで実行するよう設定していますが、CIでコンテナイメージをビルドしたタイミングで、テスト環境などのクラスターに対してデプロイを実行したい場合もあります。その場合は、今回のアクションをベースに、アクションの実行パスやタイミングを変更することで実現できます。

また、それとは別に、本番環境へのデプロイは、指定のレビューアの許可を得ることで実行したい、という場合もあります。GitHub Actionsの Environment の機能を利用することで、これを実現することが可能です。ただしこの機能はパブリックリポジトリでのみ利用可能なので、実際のプロジェクトで利用するには制限が発生しそうです。

f:id:FY0323:20210504133843j:plain

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のワークフロー定義ファイル中で、それらを個別に指定する必要があります。これはテストを用意するうえで手間となりますし、ワークフローの定義ファイルをコピーして別のマニフェストファイルに対するテストを用意するときも面倒です。

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

参考ドキュメント