Kubernetes上にAPI Gatewayを立て、経路の暗号化と認証認可を行わせる

# はじめに
皆さん[Kubernetes](https://kubernetes.io/)を使っていますか？
宣言的にコンテナベースのインフラを構成できる[Kubernetes](https://kubernetes.io/)は便利ですが、Internetへ何らかのサービスを公開する場合には必須となる「公開サービスの経路暗号化」や「公開サービスの認証認可」は、残念ながらKubernetes自体には備わっていません（Kubernetes自体には認証認可機構が有りますし、Kubernetes上に起動されるサービスのアレコレはKubernetesの範疇外なので、当たり前といえば当たり前ですが）。

Kubernetesのパッケージマネージャ的な位置づけである[Helm](https://github.com/kubernetes/helm)には、nginxでL7ロードバランサーを構成する[nginx-ingress](https://github.com/kubernetes/ingress-nginx)が公開されています。このnginx-ingressは非常に高機能で、パスベースのルーティングもできますしTLSの終端やBASIC認証等をさせることもできます。上手くハマるならば、nginx-ingressを使うのが良い手でしょう。

しかしより細やかなルールでREST APIのToken認証認可を行わせたい場合など、nginx-ingressではなく[Ambassador](https://www.getambassador.io/)というKubernetes上で動作するAPI Gatewayを用いるほうが、イイカンジにサービスを構成できる場合もありそうです。
今回はこの[Ambassador](https://www.getambassador.io/)を、Azure AKS上で動作させてみようと思います。

# Ambassadorとは
> OPEN SOURCE, KUBERNETES-NATIVE API GATEWAY FOR MICROSERVICES BUILT ON ENVOY
> https://www.getambassador.io/

[Ambassador](https://www.getambassador.io/)は、[Envoy](https://www.envoyproxy.io/)を用いて構成されたKubernetes上で動作するAPI Gatewayです。

そもそもEnvoyとは、マイクロサービスを構成する際に横串で必要となる機能（Service discoveryやHealth Check、Circuit Breaker等）を提供するC++で書かれたプロキシソフトウェアです（Sidecarパターンと呼ばれるものですね）。元々は[Lyft](https://www.lyft.com/)のインフラを構成するコンポーネントとして開発され、[Cloud Native Computing Foundation](https://www.cncf.io/)にApache License 2.0のOSSとして寄贈されました。

[Ambassador](https://www.getambassador.io/)は、Kubernetesのsecretやannotation経由で与えた設定を用いて、そのEnvoyをイイカンジに構成してくれるOSSです。自分のServiceのコードを書き換えること無く、またEnvoyそのものを意識すること無く、自分のServiceに経路の暗号化や認証認可を付け加えることができます。
（位置づけとしては[Istio](https://istio.io/)とよく似ていますが、もう少し小規模な感じですね。）

![Logical diagram of Ambassador deployment on Kubernetes](https://cdn-images-1.medium.com/max/1600/1*JTtnbZPL9OAxP7_Ma_1xOQ.png)
> https://blog.getambassador.io/building-ambassador-an-open-source-api-gateway-on-kubernetes-and-envoy-ed01ed520844

# Ambassadorを使ってみる
ということで、Ambassadorを使ってAPI Gatewayを構成してみます。今回はAzureを使いますが、原理上他のクラウドでも動作すると思います。

## 検証した環境

|Kubernetesクラスタ|バージョン|
|:--|:--|
|AKS|Microsoft AKS 米国中部|
|Kubernetes|1.8.11|

|検証用端末|バージョン|
|:--|:--|
|azure cli|2.0.31|
|kubectl|1.10.1|
|OS|macOS Sierra 10.12.6|

## Azure DNSへDNSゾーンの作成
まずは事前に準備した `nmatsui.work` というドメインをAzure DNSゾーンとして定義し、ドメインレジストラにこのDNSゾーンのnameserverを設定します。
> 注意）ドメインレジストラによっては、nameserverの反映に時間がかかる場合があります。

```bash:create_domain
$ az network dns zone create --resource-group nmatsui_dns --name nmatsui.work
```

## サーバ証明書の準備
次にLet's EncryptからDNS-01 challengeを用いて、 `api.nmatsui.work` というFQDNのサーバ証明書を取得します（Verisign等から発行されたサーバ証明書があるならば、それで良いですが）。

### Dockerコンテナ経由で `certobot` コマンドを起動
certbotコマンドをローカルにインストールするのは面倒なので、dockerコンテナ経由で使います。

1. 証明書とログを保存するディレクトリを先に作っておきます。

    ```bash:create_directories
    $ mkdir secrets
    $ mkdir certbot-logs
    ```
1. LetsEncrypt公式のDockerイメージを用い、DNS-01 challengeで`certbot`コマンドを実行します。
    * 現時点ではグローバルIPを持ったインスタンスはどこにもないので、よく解説されているHTTP-01 challengeではなくDNS-01 Challengeでサーバ証明書を取得します。

    ```bash:start_certbot
    $ docker run -it -v $(PWD)/secrets:/etc/letsencrypt -v $(PWD)/certbot-logs:/var/log/letsencrypt certbot/certbot certonly --manual --domain api.nmatsui.work --email nobuyuki.matsui@gmail.com --agree-tos --manual-public-ip-logging-ok --preferred-challenges dns
    ```
    * 上記のコマンドを実行すると、指定されたvalueを持つ指定されたnameのTXT recordをDNSへ登録するように促されます。

    ```bash:txt_record_msg
    -------------------------------------------------------------------------------
    Please deploy a DNS TXT record under the name
    _acme-challenge.api.nmatsui.work with the following value:
    
    XXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    
    Before continuing, verify the record is deployed.
    -------------------------------------------------------------------------------
    Press Enter to Continue
    ```
    * このDockerコンテナのターミナルはそのままにしておき、別のターミナルを開きます。
    
### Azure DNSへTXT Recordを追加
別のターミナルを開き、指定されたTXT recordをAzure DNSへ追加します。

1. Azure DNSへ指定されたTXT recordを追加します。

    ```bash:create_text_record
    $ az network dns record-set txt add-record --resource-group nmatsui_dns --zone-name nmatsui.work --record-set-name "_acme-challenge.api" --value "XXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    ```
1. TXT recordが追加されていることを確認します。

    ```bash:list_record
    $ az network dns record-set txt show --resource-group nmatsui_dns --zone-name nmatsui.work --name "_acme-challenge.api"
    ```

### 証明書を取得
TXT recordが追加できたら、最初のcertbotコマンドを実行しているターミナルでEnterキーを押し、certbotに処理を継続させます。指定されたTXT recordが正しく登録されていれば、 `./secrets`以下に `api.nmatsui.work` に対するサーバ証明書が取得されます。

```bash:success
-------------------------------------------------------------------------------
Press Enter to Continue
Waiting for verification...
Cleaning up challenges

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/api.nmatsui.work/fullchain.pem
...
```

### Azure DNSからTXT recordを削除
サーバ証明書が取得できたので、登録したTXT recordは削除しておきます。

```bash:delete_txt_record
$ az network dns record-set txt remove-record --resource-group nmatsui_dns --zone-name nmatsui.work --record-set-name "_acme-challenge.api" --value "XXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Kubernetes上へAmbassadorを起動
サーバ証明書が準備できたので、Kubernetes上へAmbassadorを起動します。今回使ったyaml定義ファイルはgithub（ https://github.com/nmatsui/kubernetes-ambassador ）上にありますので、参考にしてください。

### サーバ証明書をKubernetes secretに登録
取得した証明書をKubernetesのsecretへ、 `ambassador-certs` として登録します。

```bash:store_cert
$ kubectl create secret tls ambassador-certs --cert=$(PWD)/secrets/live/api.nmatsui.work/fullchain.pem --key=$(PWD)/secrets/live/api.nmatsui.work/privkey.pem
```

### AmbassadorのServiceとPodを起動
LoadBalancer Serviceとして、Ambassadorを起動します（執筆時点での最新版は `0.31` でした）。とりあえずPODは3つ起動させていますが、KubernetesのNode数や負荷量で調整してください。

```bash:start_ambassador
$ kubectl apply -f ambassador/ambassador.yaml
```
```yaml:ambassador.yaml
apiVersion: v1
kind: Service
metadata:
  name: ambassador
  creationTimestamp: null
  labels:
    service: ambassador
spec:
  type: LoadBalancer
  ports:
  - name: ambassador
    port: 443
    targetPort: 443
  selector:
    service: ambassador
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: ambassador
spec:
  replicas: 3
  template:
    metadata:
      labels:
        service: ambassador
    spec:
      containers:
      - name: ambassador
        image: quay.io/datawire/ambassador:0.31.0
        resources:
          limits:
            cpu: 1
            memory: 400Mi
          requests:
            cpu: 200m
            memory: 100Mi
        env:
        - name: AMBASSADOR_NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        livenessProbe:
          httpGet:
            path: /ambassador/v0/check_alive
            port: 8877
          initialDelaySeconds: 30
          periodSeconds: 3
        readinessProbe:
          httpGet:
            path: /ambassador/v0/check_ready
            port: 8877
          initialDelaySeconds: 30
          periodSeconds: 3
      - name: statsd
        image: quay.io/datawire/statsd:0.31.0
      restartPolicy: Always
```
* ポート8877にアクセスするとAmbassadorの管理コンソールが開けますが、Internetに公開する意味はないのでこのポートはServiceに登録しません。

### LoadBalancerのExternal IPをDNSに登録
起動したAmbassador ServiceのExternal IPを`api.nmatsui.work`で名前解決できるように、Azure DNSにA recordを登録します。

1. Ambassador ServiceのExternal IPを確認します。

    ```bash:check_externalip
    $ kubectl get services -l service=ambassador
    NAME         TYPE           CLUSTER-IP     EXTERNAL-IP    PORT(S)         AGE
    ambassador   LoadBalancer   10.0.149.207   XX.YY.ZZ.WWW   443:32704/TCP   6m
    ```
1. このExternal IPに対して、 `api.nmatsui.work` をA recordとして登録します。

    ```bash:create_a_record
    $ az network dns record-set a add-record --resource-group nmatsui_dns --zone-name nmatsui.work --record-set-name "api" --ipv4-address "XX.YY.ZZ.WWW"
    ```

### AmbassadorのTLS終端を確認
この段階ですでに、AmbassadorがTLSを終端してくれています。 `api.nmatsui.work` にhttpsでアクセスすると、TLSで接続されていることが確認できます。

```text:check_tls_termination
$ curl -v https://api.nmatsui.work
* Rebuilt URL to: https://api.nmatsui.work/
*   Trying XX.YY.ZZ.WWW...
* TCP_NODELAY set
* Connected to api.nmatsui.work (XX.YY.ZZ.WWW) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate: api.nmatsui.work
* Server certificate: Let's Encrypt Authority X3
* Server certificate: DST Root CA X3
> GET / HTTP/1.1
> Host: api.nmatsui.work
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< date: Mon, 23 Apr 2018 04:24:49 GMT
< server: envoy
< content-length: 0
<
* Connection #0 to host api.nmatsui.work left intact
```

### httpをhttpsにリダイレクト
Ambassador Serviceの `annotations` に `tls` Moduleを設定すると、クライアント認証の設定やALPNプロトコルの設定など、AmbassadorのTLS設定をカスタマイズすることができます。たとえば次のように `redirect_cleartext_from`を設定すれば、http(80)アクセスをhttps(443)にリダイレクトするようになります。詳細は[公式ドキュメント](https://www.getambassador.io/reference/modules)を参照してください。

```yaml:ambassador.yaml
apiVersion: v1
kind: Service
metadata:
  name: ambassador
  creationTimestamp: null
  labels:
    service: ambassador
  annotations:
    getambassador.io/config: |
      ---
      apiVersion: ambassador/v0
      kind: Module
      name: tls
      config:
        server:
          enabled: True
          redirect_cleartext_from: 80
spec:
  type: LoadBalancer
  ports:
  - name: ambassador-tls
    port: 443
    targetPort: 443
  - name: ambassador
    port: 80
    targetPort: 80
  selector:
    service: ambassador
---
apiVersion: extensions/v1beta1
kind: Deployment
...
```

```text:check_http_redirect
$ curl -v http://api.nmatsui.work
* Rebuilt URL to: http://api.nmatsui.work/
*   Trying XX.YY.ZZ.WWW...
* TCP_NODELAY set
* Connected to api.nmatsui.work (XX.YY.ZZ.WWW) port 80 (#0)
> GET / HTTP/1.1
> Host: api.nmatsui.work
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< location: https://api.nmatsui.work/
< date: Mon, 23 Apr 2018 04:49:40 GMT
< server: envoy
< content-length: 0
<
```

## AmbassadorによるAPI Gateway
では実際に、Kubernetes上のServiceに対してAmbassadorを適用し、API Gatewayとして動作させてみましょう。

### パスベースのルーティング
検証用にダミーのREST APIサービスを二つ起動し、パスベースのルーティングを検証します。

> このダミーREST APIサービスは、どのようなパスに対しても環境変数として与えられたメッセージを返すシンプルなサービスです。詳細は、DockerHubの[nmatsui/hello-world-api](https://hub.docker.com/r/nmatsui/hello-world-api/)を参照してください。

パスベースルーティングのポイントは、以下二つです。

* 各serviceの`annotations`に`Mapping`定義を追加することで、Ambassadorにパスベースのルーティングを指示することができます（今回の例ですと、 `/api1/` に到達したリクエストは `http://dummy-restapi-1:3000` へ、 `/api2/` に到達したリクエストは `http://dummy-restapi-2:8888` にルーティングされます）。
* Internetとの接続はAmbassadorが全て中継しますので、ダミーREST APIサービスは `ClusterIP` Serviceにし、外部からの直接アクセスを禁止します。

1. ルーティング定義を設定し、ダミーREST APIサービスを起動する。

    ```bash:start_dummyapi
    $ kubectl apply -f dummy-restapi/dummy-restapi.yaml
    ```
    ```yaml:dummy-restapi.yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: dummy-restapi-1
      labels:
        service: dummy-restapi-1
      annotations:
        getambassador.io/config: |
          ---
          apiVersion: ambassador/v0
          kind: Mapping
          name: dummy-restapi-1
          prefix: /api1/
          service: http://dummy-restapi-1:3000
    spec:
      type: ClusterIP
      selector:
        pod: dummy-restapi-1
      ports:
      - name: dummy-restapi-1
        port: 3000
        targetPort: 3000
    ---
    apiVersion: extensions/v1beta1
    kind: Deployment
    metadata:
      name: dummy-restapi-1
    spec:
      replicas: 3
      template:
        metadata:
          labels:
            pod: dummy-restapi-1
        spec:
          containers:
          - name: hello-world-api
            image: nmatsui/hello-world-api:latest
            env:
            - name: PORT
              value: "3000"
            - name: MESSAGE
              value: "dummy restapi 1"
            ports:
              - name: dummy-restapi-1
                containerPort: 3000
    # dummy-restapi-2も同様
    ```
1. パスベースのルーティングを確認する。

    ```bash:dummy-restapi-1
    $ curl -i https://api.nmatsui.work/api1/
    HTTP/1.1 200 OK
    content-type: application/json
    date: Mon, 23 Apr 2018 05:38:43 GMT
    x-envoy-upstream-service-time: 2
    server: envoy
    transfer-encoding: chunked

    {"message":"dummy restapi 1"}
    ```
    ```bash:dummy-restapi-2
    $ curl -i https://api.nmatsui.work/api2/foo/
    HTTP/1.1 200 OK
    content-type: application/json
    date: Mon, 23 Apr 2018 05:38:59 GMT
    x-envoy-upstream-service-time: 1
    server: envoy
    transfer-encoding: chunked

    {"message":"dummy restapi 2"}
    ```
    * Serviceのannotationで指示した設定はリアルタイムにAmbassadorに反映されますので、Ambassadorサービスを再起動することなく、リクエストがダミーREST APIサービスへルーティングされることが確認できます。

### 認証認可
次に、Ambasssadorを用いた認証認可について検証します。Ambassadorは、ターゲットとなるサービスにリクエストをルーティングする前に、認証認可用のサービスを割り込ませ、リクエストをフィルタリングすることができます。

この認証認可用のサービスは、以下の要求を満たすように自分で作る必要があります。DB等を使って自力で認証認可を行っても良いですし、外部のIdPを使って認証認可を行っても良いでしょう。

* Ambassadorは、クライアントからリクエストされたRequest Headerとパスを用いて、認証認可用サービスにPOSTでリクエストを行います。
* 認証認可用サービスは、そのリクエストを受け付けるならば `200 OK` を返します。そうすると、Ambassadorが実際のサービスにリクエストをルーティングします。
* 認証認可用サービスが `200 OK` 以外を返すと、実際のサービスへのルーティングは行われません。クライアントへは、認証認可サービスがAmbassadorへ返したレスポンスが（Ambassadorを経由して）返されます。
* 必要であれば、実際のサービスが返すResponse Headerへ、認証認可サービスが追加のHeaderを付与することもできます。

今回は、検証用に作成した[nmatsui/bearer-auth-api](https://hub.docker.com/r/nmatsui/bearer-auth-api/)というコンテナで認証認可サービスを立ち上げます。
> この認証認可サービスは、環境変数`AUTH_TOKEN`経由で与えられるTOKEN定義を元にして、Bearer認証とパスベースの認可を行うものです。詳細は[nmatsui/bearer-auth-api](https://hub.docker.com/r/nmatsui/bearer-auth-api/)を参照してください。

1. TOKEN定義（`secrets/auth-tokens.json`）を作成する。
    * 二つのTOKENを設定し、最初のTOKENは `/api1/*`も`/api2/*`も使え、二つ目のTOKENは `/api1/*` しか使えないというTOKEN定義です。

    ```json:secrets/auth-token.json
    {
      "Znda7iglaqdoltsp7kDl60TvkkszcEGU": ["^/api1/.*$", "^/api2/.*$"],
      "fANtLRTszYAayjtmLFllSHBrt2zRyoqV": ["^/api1/.*$"]
    }
    ```
1. KubernetesのSecretにTOKENを定義を登録する。

    ```bash:store_tokens
    $ kubectl create secret generic auth-tokens --from-file=./secrets/auth-tokens.json
    ```
1. 認証認可サービスを起動し、Ambassadorへ `authentication` Moduleとして登録する。

    ```bash:start_auth_service
    $ kubectl apply -f bearer-auth/bearer-auth.yaml
    ```
    ```yaml:bearer-auth.yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: bearer-auth
      labels:
        service: bearer-auth
      annotations:
        getambassador.io/config: |
          ---
          apiVersion: ambassador/v0
          kind: Module
          name:  authentication
          config:
            auth_service: "bearer-auth:8080"
    spec:
      type: ClusterIP
      selector:
        pod: bearer-auth
      ports:
      - name: bearer-auth
        port: 8080
        targetPort: 8080
    ---
    apiVersion: extensions/v1beta1
    kind: Deployment
    metadata:
      name: bearer-auth
    spec:
      replicas: 3
      template:
        metadata:
          labels:
            pod: bearer-auth
        spec:
          containers:
          - name: bearer-auth-api
            image: nmatsui/bearer-auth-api:latest
            env:
            - name: LISTEN_PORT
              value: "8080"
            - name: AUTH_TOKENS
              valueFrom:
                secretKeyRef:
                  name: "auth-tokens"
                  key: "auth-tokens.json"
            ports:
              - name: bearer-auth
                containerPort: 8080
    ```
1. 認証認可を確認する。

    ```bash:no_auth
    $ curl -i https://api.nmatsui.work/api1/
    HTTP/1.1 401 Unauthorized
    content-type: application/json; charset=utf-8
    www-authenticate: Bearer realm="token_required"
    date: Mon, 23 Apr 2018 07:03:16 GMT
    content-length: 60
    x-envoy-upstream-service-time: 2
    server: envoy

    {"authorized":false,"error":"missing Header: authorization"}
    ```
    ```bash:valid_auth
    $ curl -i -H "Authorization: bearer fANtLRTszYAayjtmLFllSHBrt2zRyoqV" https://api.nmatsui.work/api1/
    HTTP/1.1 200 OK
    content-type: application/json
    date: Mon, 23 Apr 2018 07:04:37 GMT
    x-envoy-upstream-service-time: 1
    server: envoy
    transfer-encoding: chunked

    {"message":"dummy restapi 1"}
    ```
    ```bash:not_allowd
    $ curl -i -H "Authorization: bearer fANtLRTszYAayjtmLFllSHBrt2zRyoqV" https://api.nmatsui.work/api2/
    HTTP/1.1 403 Forbidden
    content-type: application/json; charset=utf-8
    www-authenticate: Bearer realm="token_required" error="not_allowed"
    date: Mon, 23 Apr 2018 07:06:09 GMT
    content-length: 41
    x-envoy-upstream-service-time: 1
    server: envoy

    {"authorized":false,"error":"not allowd"}
    ```
    * ダミーREST APIサービスには何も手を加えずとも、TOKEN定義に設定されている認証認可情報に従ってリクエストのフィルタリングが行われていることが確認できます。

# さいごに
ということで、Ambassadorを用いることで、複数のサービスに対して横串でTLS終端や認証認可機能を追加できました。Ambassadorには他にも、Rate LimitingやgRPCのサポートなどが含まれています。上手く活用して、幸せなKubernetesライフを過ごしましょう！