Envoy + SPIRE + OPA で Service Mesh を構築する

この記事は [Z Lab Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zlab) の19日目の記事となります。

# TL;DR

Data Plane として Envoy[^1] を Control Plane として SPIRE[^2] と OPA[^3] を使った Service Mesh を Kubernetes 上に構築する方法を紹介します。実際に手を動かして理解を深められるようにデモのコードを [zlabjp/envoy-spire-opa-service-mesh](https://github.com/zlabjp/envoy-spire-opa-service-mesh) で公開しています。

![スクリーンショット 2019-12-18 18.25.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49924/188b1f27-6494-0ad4-65c8-9d3956cb25ad.png)

# はじめに

今年の10月に開催された [SPIFFE Meetup Tokyo #2](https://spiffe-jp.connpass.com/event/142393/) に参加して [Securing the Service Mesh with SPIRE](https://speakerdeck.com/ryysud/securing-the-service-mesh-with-spire) という題で、セキュアな Service Mesh を小さく始めたい方向けに Data Plane として Envoy を Control Plane として SPIRE を使った Service Mesh を Kubernetes 上に構築する方法を紹介しました。

上記の発表の中では SPIFFE ID をベースとした Pod 間の認証しか実現できていなかったので、今回は OPA（Open Policy Agent）を Control Plane として追加して SPIFFE ID ベースの Pod 間の認可を実現する方法を紹介します。

# ポリシー概要

![policy-overview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49924/dab4effc-dbba-ad29-ebed-598c01a710b1.png)
今回 Kubernetes 上に構築する Service Mesh には、ECサイトとニュースサイトのサービスが稼働しており、それぞれが web と backend という Pod を保持していると仮定し、各 Pod 間の通信可否を以下のように設定します。括弧内にそれぞれの Pod に対応する SPIFFE ID も併記していきます。

- ec-web（`spiffe://example.org/ec-web`）
    - HTTPヘッダー X-Opa-Secret に ec-web-secret という値がセットされているリクエストのみを受け付ける
    - エンドポイントの制限は行わない
- ec-backend（`spiffe://example.org/ec-backend`）
    - ec-web からの /data エンドポイントへのリクエストのみを受け付ける
    - 管理者向けの /admin エンドポイントは図に記載している Pod からはリクエストを行うことができない
- news-web（`spiffe://example.org/news-web`）
    - HTTPヘッダー X-Opa-Secret に news-web-secret という値がセットされているリクエストのみを受け付ける
    - エンドポイントの制限は行わない
- news-backend（`spiffe://example.org/news-backend`）
    - news-web からの /data エンドポイントへのリクエストのみを受け付ける
    - 管理者向けの /admin エンドポイントは図に記載している Pod からはリクエストを行うことができない

# アーキテクチャ

具体的なアーキテクチャは以下となります。
![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49924/ab47ba66-e8cf-528b-7031-781c92edc0b9.png)
各 Pod にサイドカーとして Envoy, SPIRE Agent, OPA が起動する状態となります。少しだけ機能を説明すると (ec|news)-web は Nginx となっていて Viewer から受けたリクエストを (ec|news)-backend にプロキシするだけで、(ec|news)-backend は文字列を返す API です。

各ミドルウェアの役割の詳細は後述しますが、この時点で ec-web から ec-backend に対してリクエストを行う際のフローを簡単に紹介します。

1. Envoy が起動時に SPIRE Agent から TLSクライアント証明書 などの mTLS に必要なクレデンシャルを取得する
2. ec-web Pod 内の App が ec-backend にリクエストを行うために自身の Envoy にリクエストを行う
3. リクエストを受けた Envoy は ec-backend の Envoy に対してリクエストを行う
4. ec-web と ec-backend の Envoy 同士で SPIRE Agent から得たクレデンシャルを使って mTLS による相互認証を行う
5. 認証に成功すると ec-backend の Envoy は自身の OPA に許可されたリクエストか問い合わせを行う
6. OPA は予め設定されたポリシーと渡ってきたリクエストを照会して認可処理を行う
7. 認可に成功すると ec-backend の Envoy は自身の App にリクエストをプロキシする
8. ec-backend から ec-web にレスポンスが返る

以上が簡単なフローとなります。それでは各ミドルウェアの役割の詳細を説明していきます。

# 各ミドルウェアの役割

今回使用する各ミドルウェアのバージョンは以下のとおりです。

- Kubernetes 1.17.0
- Envoy 1.12.2
- SPIRE 0.9.0
- OPA 0.15.1

## Envoy

各Pod間の通信での中心人物が Envoy となっていて、今回重要となってくる Envoy の機能が xDS API の1つである [Secret discovery service（SDS）](https://www.envoyproxy.io/docs/envoy/v1.12.2/configuration/security/secret) と、認可の処理を別システムに任せるための [External Authorization](https://www.envoyproxy.io/docs/envoy/v1.12.2/intro/arch_overview/security/ext_authz_filter#arch-overview-ext-authz) となります。

SDS を使うことで Envoy が mTLS 通信に必要な TLS証明書 やシークレットを SDS Server から動的に取得することが可能になります。今回 SDS Server として振る舞うのが SPIRE Agent となっており、SPIRE に TLS証明書 の管理（配布やローテーション）を一任することができます。

次に External Authorization ですが、この機能を使うことで Envoy のネットワークフィルター機能を別システムに一任することが可能になり、リクエストが許可されていないものと判断された場合には通信がクローズされる仕様（HTTP の場合には 403 が返る）となっています。今回 External Authorization Server として振る舞うのが OPA となります。

※ Envoy の概要は冒頭で触れた [スライド](https://speakerdeck.com/ryysud/securing-the-service-mesh-with-spire?slide=17) を参照

## SPIRE

Envoy の部分で触れたとおり、SPIRE の役割は TLS証明書 の管理（配布やローテーション）となります。SPIRE から発行された TLS証明書 には Workload の Identity となる SPIFFE ID が含まれているので、今回紹介する Servicec Mesh では SPIFFE ID ベースの認証認可（Envoy での mTLS による相互認証と OPA での SPIFFE ID ベースでのポリシー定義）が可能となっています。

※ SPIRE の概要は冒頭で触れた [スライド](https://speakerdeck.com/ryysud/securing-the-service-mesh-with-spire?slide=14) を参照

## OPA

Envoy の部分で触れたとおり、OPA の役割は Envoy のネットワークフィルター機能を代理するというものになります。今回は HTTP filter の External Authorization Server として振る舞うように設定を行っており、HTTP リクエストが保持する情報を元にポリシーを定義しています。HTTP リクエストの情報には TLS証明書 ももちろん含まれるので SPIFFE ID を元に制御することも可能です。

※ OPA の概要記事は後日公開予定なので他の記事を参考にしてください

# Envoy と OPA 設定例

ec-backend に適用している Envoy 設定の一部と全Podに適用している OPA 設定を引用して説明します。全設定が気になる方は [zlabjp/envoy-spire-opa-service-mesh](https://github.com/zlabjp/envoy-spire-opa-service-mesh) の各マニフェスト内の ConfigMap で定義してある envoy.yaml と policy.rego を参照ください。

## ec-backend に適用している Envoy 設定

以下が External Authorization Server として OPA を指定している箇所です。
Envoy から 同一 Pod の `127.0.0.1:20000` で稼働している OPA に問い合わせがされる設定です。

```yaml:envoy.yaml
          - name: envoy.ext_authz
            config:
              failure_mode_allow: false
              grpc_service:
                google_grpc:
                  target_uri: 127.0.0.1:20000
                  stat_prefix: ext_authz
                timeout: 0.5s
```

以下が SDS Server として SPIRE Agent を指定している箇所です。
Envoy から 同一 Container で Unix Domain Socket で稼働している SPIRE Agent（`/run/spire/sockets/agent.sock`）からクレデンシャルが取得される設定です。mTLS に関する他の設定は [こちら](https://speakerdeck.com/ryysud/securing-the-service-mesh-with-spire?slide=27) を参照ください。

```yaml:envoy.yaml
      tls_context:
        common_tls_context:
          tls_certificate_sds_secret_configs:
          - name: "spiffe://example.org/ec-backend"
            sds_config:
              api_config_source:
                api_type: GRPC
                grpc_services:
                  envoy_grpc:
                    cluster_name: spire_agent
          combined_validation_context:
            default_validation_context:
              verify_subject_alt_name:
                - "spiffe://example.org/ec-web"
                - "spiffe://example.org/news-web"
            validation_context_sds_secret_config:
              name: "spiffe://example.org"
              sds_config:
                api_config_source:
                  api_type: GRPC
                  grpc_services:
                    envoy_grpc:
                      cluster_name: spire_agent
# ...
  clusters:
  - name: spire_agent
    connect_timeout: 0.25s
    http2_protocol_options: {}
    hosts:
      - pipe:
          path: /run/spire/sockets/agent.sock
```

## 各 Pod に適用している OPA 設定

各 Pod に適用している Rego で書かれた OPA 設定を説明します。

### ec-backend 

ec-web からの /data エンドポイントへのリクエストのみを受け付けて、管理者向けの /admin にはリクエストを行えないポリシーを定義しています。また、リクエスト元の TLSクライアント証明書 の URI SAN が許可された SPIFFE ID となっているかのチェックも行っています。

```python:policy.rego
package envoy.authz

import input.attributes.request.http as http_request
import input.attributes.source.address as source_address

default allow = false

allow {
		http_request.path == "/data"
		http_request.method == "GET"
		svc_spiffe_id == "spiffe://example.org/ec-web"
}

svc_spiffe_id = client_id {
		[_, _, uri_type_san] := split(http_request.headers["x-forwarded-client-cert"], ";")
		[_, client_id] := split(uri_type_san, "=")
}
```

### ec-web 

HTTPヘッダー X-Opa-Secret に ec-web-secret という値がセットされているリクエストのみを受け付けて、エンドポイントの制限は行わないポリシーが定義されています。また、こちらは Kubernetes 外からのリクエストを想定しているため、SPIFFE ID のチェックは行っていません。

```python:policy.rego
package envoy.authz

import input.attributes.request.http as http_request
import input.attributes.source.address as source_address

default allow = false

allow {
		http_request.method == "GET"
		http_request.headers["x-opa-secret"] == "ec-web-secret"
}
```

### news-backend 

news-web からの /data エンドポイントへのリクエストのみを受け付けて、管理者向けの /admin にはリクエストを行えないポリシーを定義しています。また、リクエスト元の TLSクライアント証明書 の URI SAN が許可された SPIFFE ID となっているかのチェックも行っています。

```python:policy.rego
package envoy.authz

import input.attributes.request.http as http_request
import input.attributes.source.address as source_address

default allow = false

allow {
		http_request.path == "/data"
		http_request.method == "GET"
		svc_spiffe_id == "spiffe://example.org/news-web"
}

svc_spiffe_id = client_id {
		[_, _, uri_type_san] := split(http_request.headers["x-forwarded-client-cert"], ";")
		[_, client_id] := split(uri_type_san, "=")
}
```

### news-web 

HTTPヘッダー X-Opa-Secret に news-web-secret という値がセットされているリクエストのみを受け付けて、エンドポイントの制限は行わないポリシーが定義されています。また、こちらは Kubernetes 外からのリクエストを想定しているため、SPIFFE ID のチェックは行っていません。

```python:policy.rego
package envoy.authz

import input.attributes.request.http as http_request
import input.attributes.source.address as source_address

default allow = false

allow {
		http_request.method == "GET"
		http_request.headers["x-opa-secret"] == "news-web-secret"
}
```

# 動作確認

ポリシー概要に記載した想定通りの挙動となるかを確認していきます。

## ✅ Viewer から ec-web と news-web へのリクエスト（必須HTTPヘッダーあり）

ec-web と news-web の Envoy に HTTPヘッダー X-Opa-Secret を付与して Ingress 経由で Kubernetes 外からリクエストを行って、想定通りの `200 OK` なレスポンスを得ることが確認できました。

```bash
$ curl -i -H 'Host: ec-web.example.org' -H 'X-Opa-Secret: ec-web-secret' http://$(minikube ip)/noproxy
HTTP/1.1 200 OK
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:53:49 GMT
Content-Type: application/octet-stream
Content-Length: 24
Connection: keep-alive
x-envoy-upstream-service-time: 0

ec-web no proxy endpoint

$ curl -i -H 'Host: news-web.example.org' -H 'X-Opa-Secret: news-web-secret' http://$(minikube ip)/noproxy
HTTP/1.1 200 OK
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:54:15 GMT
Content-Type: application/octet-stream
Content-Length: 26
Connection: keep-alive
x-envoy-upstream-service-time: 0

news-web no proxy endpoint
```

## ❌ Viewer から ec-web と news-web へのリクエスト（必須HTTPヘッダーなし）

ec-web と news-web の Envoy に HTTPヘッダー X-Opa-Secret を付与せずに Ingress 経由で Kubernetes 外からリクエストを行って、想定通り `403 Forbidden` なレスポンスを得ることが確認できました。

```bash
$ curl -i -H 'Host: ec-web.example.org' http://$(minikube ip)/noproxy
HTTP/1.1 403 Forbidden
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:54:31 GMT
Content-Length: 0
Connection: keep-alive

$ curl -i -H 'Host: news-web.example.org' http://$(minikube ip)/noproxy
HTTP/1.1 403 Forbidden
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:54:49 GMT
Content-Length: 0
Connection: keep-alive
```

## ✅ (ec|news)-web から (ec|news)-backend の /data へのリクエスト

(ec|news)-web は (ec|news)-backend にプロキシしてくれるので、これまでの動作確認と同様に Ingress 経由で Kubernetes 外から (ec|news)-web にリクエストを行って、想定通りの `200 OK` なレスポンスを得ることが確認できました。

```bash
$ curl -i -H 'Host: ec-web.example.org' -H 'X-Opa-Secret: ec-web-secret' http://$(minikube ip)/data
HTTP/1.1 200 OK
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:55:12 GMT
Content-Type: application/octet-stream
Content-Length: 21
Connection: keep-alive
x-envoy-upstream-service-time: 12

Hi ec-backend client!

$ curl -i -H 'Host: news-web.example.org' -H 'X-Opa-Secret: news-web-secret' http://$(minikube ip)/data
HTTP/1.1 200 OK
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:55:23 GMT
Content-Type: application/octet-stream
Content-Length: 23
Connection: keep-alive
x-envoy-upstream-service-time: 11

Hi news-backend client!
```

## ❌ (ec|news)-web から (ec|news)-backend の /admin へのリクエスト

許可されていないエンドポイント /admin にリクエストを行って、想定通りの `403 Forbidden` なレスポンスを得ることが確認できました。

```bash
$ curl -i -H 'Host: ec-web.example.org' -H 'X-Opa-Secret: ec-web-secret' http://$(minikube ip)/admin
HTTP/1.1 403 Forbidden
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:55:37 GMT
Content-Type: application/octet-stream
Content-Length: 46
Connection: keep-alive
x-envoy-upstream-service-time: 4

Forbidden. Not authorized by ec-backend\'s OPA.

$ curl -i -H 'Host: news-web.example.org' -H 'X-Opa-Secret: news-web-secret' http://$(minikube ip)/admin
HTTP/1.1 403 Forbidden
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:55:50 GMT
Content-Type: application/octet-stream
Content-Length: 48
Connection: keep-alive
x-envoy-upstream-service-time: 2

Forbidden. Not authorized by news-backend\'s OPA.
```

## ❌ news-web から ec-backend の /data へのリクエスト

許可されていない news-web から ec-backend にリクエストを行って、想定通りの `403 Forbidden` なレスポンスを得ることが確認できました。

```bash
$ curl -i -H 'Host: news-web.example.org' -H 'X-Opa-Secret: news-web-secret' http://$(minikube ip)/ec-backend-data
HTTP/1.1 403 Forbidden
Server: openresty/1.15.8.2
Date: Wed, 18 Dec 2019 08:56:01 GMT
Content-Length: 0
Connection: keep-alive
x-envoy-upstream-service-time: 5
```

以上で動作確認は完了です。
ポリシー概要に記載した想定通りの挙動となりました。

# 最後に

Data Plane として Envoy を Control Plane として SPIRE と OPA を使った Service Mesh を Kubernetes 上に構築する方法を紹介しました。実際に手元で構築できるようにデモのコードを [zlabjp/envoy-spire-opa-service-mesh](https://github.com/zlabjp/envoy-spire-opa-service-mesh) で公開していますので、興味ある方は参照頂ければと思います。

今回の OPA ポリシーは簡単なものでしたが、OPA に [Build-in されている関数](https://www.openpolicyagent.org/docs/v0.15.1/policy-reference/) は他にも多数存在しますので、導入するシステムの要件に応じてポリシーを組み立てていけば良いと思います。この記事を書いていて OPA に更に興味が出てきたので、空いた時間で更に深堀りして調べていこうと思います。もちろん SPIFFE/SPIRE も引き続きやっていきです！

おしまい。

[^1]: https://github.com/envoyproxy/envoy
[^2]: https://github.com/spiffe/spire
[^3]: https://github.com/open-policy-agent/opa