Enjoy Architecting

Twitter: @taisho6339

CloudEndpointsを使って手軽に安全にGKE上のAPIを公開する

導入

GKEにElasticsearchなどのクラスタを載せて運用するとき、 大抵はファイアウォールなどで特定のノードやネットワークからしかアクセスできないようにすると思う。 だがそれだけでは特定のAPIはアクセスを禁止し、特定のAPIだけ公開したいといったニーズには答えられない。 そこでCloudEndpointsを利用して秒速で安全なAPIを公開する方法を紹介する。

前提

  • 構成はCloud CloudEndpoints + GKE(ESクラスタ)を想定
  • 参照系はアクセスできるユーザ全員に、更新系はさらに特定の権限を持ったユーザにのみ公開するようにしたい
  • 下記リンクのように、クラスタ内のclientノード(Pod)の前段にプロキシを配置し、プロキシがアクセスの制御を行う。 CloudEndpointsについて

ESクラスタの構築

最近stableに昇格したElasticsearchのhelmのチャートを使って構築する。

構築スクリプト

#!/bin/sh

cd $(dirname $0)

set -eux

CLUSTER_NAME=cloud-endpoints-sample-es

## Enable APIs
gcloud services enable deploymentmanager.googleapis.com

## Create GKE Cluster
gcloud --quiet deployment-manager deployments create \
  --automatic-rollback-on-error \
  --template ./deployment-manager/environment.jinja \
  cloud-endpoints-sample

gcloud container clusters get-credentials ${CLUSTER_NAME} --zone asia-northeast1-a

# Create Admin Cluster Role Binding To Current User
kubectl create clusterrolebinding cluster-admin-binding \
  --clusterrole=cluster-admin \
  --user=$(gcloud config get-value account 2>/dev/null)
kubectl create -f resource-helm-service-account.yaml

# Initialize helm
helm init --service-account helm --history-max 20
echo "wait for helm init task"
sleep 120

# Install ElasticSearch
helm install -f values.yaml --name ${CLUSTER_NAME} stable/elasticsearch

こんな感じにクラスタが出来上がる。

NAME                                                              READY     STATUS    RESTARTS   AGE
cloud-endpoints-sample-es-elasticsearch-es-client-67fbf5657ss5t   1/1       Running   0          32m
cloud-endpoints-sample-es-elasticsearch-es-client-67fbf565dnfhm   1/1       Running   0          32m
cloud-endpoints-sample-es-elasticsearch-es-data-0                 1/1       Running   0          32m
cloud-endpoints-sample-es-elasticsearch-es-data-1                 1/1       Running   0          29m
cloud-endpoints-sample-es-elasticsearch-es-master-0               1/1       Running   0          32m
cloud-endpoints-sample-es-elasticsearch-es-master-1               1/1       Running   0          30m
cloud-endpoints-sample-es-elasticsearch-es-master-2               1/1       Running   0          29m

インデックス定義

このように簡単な記事データを持つインデックスを想定する。

{
  "settings": {
    "index": {
      "number_of_shards": 5,
      "number_of_replicas": 1
    },
    "analysis": {
      "analyzer": {
        "ngram_analyzer": {
          "tokenizer": "ngram_tokenizer"
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 2
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic": false,
      "properties": {
        "post_id": {
          "type": "keyword"
        },
        "title": {
          "type": "text",
          "analyzer": "ngram_analyzer"
        },
        "content": {
          "type": "text",
          "analyzer": "ngram_analyzer"
        }
      }
    }
  }
}

本当はインデックスを作るJobリソースをデプロイするとかしたほうが良いだろうが、今回は雑にpodに入ってcurlしてインデックスを作成。

kubectl exec -it  cloud-endpoints-sample-es-elasticsearch-es-client-67fbf565dnfhm bash

[root@cloud-endpoints-sample-es-elasticsearch-es-client-67fbf565dnfhm elasticsearch]# curl -X PUT "localhost:9200/posts" -H 'Content-Type: application/json' -d'
{
  "settings": {
    "index": {
      "number_of_shards": 5,
      "number_of_replicas": 1
    },
    "analysis": {
      "analyzer": {
        "ngram_analyzer": {
          "tokenizer": "ngram_tokenizer"
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 2
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic": false,
      "properties": {
        "post_id": {
          "type": "keyword"
        },
        "title": {
          "type": "text",
          "analyzer": "ngram_analyzer"
        },
        "content": {
          "type": "text",
          "analyzer": "ngram_analyzer"
        }
      }
    }
  }
}
'

Cloud CloudEndpointsのデプロイ

エンドポイント定義ファイルをデプロイする

CloudEndpointsはswaggerの定義ファイルをデプロイすることで定義したAPIへのアクセスを許容することができる。 逆に定義していないエンドポイントについてはアクセスすることができない。 まずは雑に下記のような設定でデプロイする。

swagger: "2.0"
info:
  title: "Auth Proxy"
  description: "Auth Proxy For Elasticsearch Cluster"
  version: "1.0.0"
host: "cloud-endpoints-sample-es.endpoints.[PROJECT_ID].cloud.goog"
x-google-endpoints:
  - name: "cloud-endpoints-sample-es.endpoints.[PROJECT_ID].cloud.goog"
    target: ""
consumes:
  - "application/json"
produces:
  - "application/json"
schemes:
  - "http"

paths:
  /_cluster/health:
    get:
      description: "Returns the es clusters' health."
      operationId: "get_cluster_health"
      produces:
        - "application/json"
      responses:
        200:
          description: get cluster's health.

この設定だと、クラスタのヘルスチェックAPIのみ許容しリクエストを通すことになる。

gcloud --quiet endpoints services deploy -f cloud-endpoints.yaml

次にプロキシのServiceをGKE内にデプロイし、プロキシとCloudEndpointsの設定を紐づける。 これによって、実際にプロキシがきめられたルールに沿ってアクセス制御を行うことができる。 下記設定のうち、backendがGKEクラスタ内のclientノードを指し、serviceがendpointsのサービス名を指している。

apiVersion: v1
kind: Service
metadata:
  name: esp-cloud-endpoints-sample-es
spec:
  ports:
  - port: 9200
    targetPort: 9201
    protocol: TCP
    name: http
  selector:
    app: esp-cloud-endpoints-sample-es
  type: LoadBalancer
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: esp-cloud-endpoints-sample-es
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: esp-cloud-endpoints-sample-es
    spec:
      containers:
      # [START esp]
      - name: esp
        image: gcr.io/endpoints-release/endpoints-runtime:1
        args: [
          "--http_port=9201",
          "--backend=cloud-endpoints-sample-es-elasticsearch-es-client.default.svc.cluster.local:9200",
          "--service=cloud-endpoints-sample-es.endpoints.[PROJECT_ID].cloud.goog",
          "--rollout_strategy=managed",
        ]
      # [END esp]
        ports:
          - containerPort: 9201
kubectl apply -f resource-esp.yaml

esp-* があらたにデプロイされたプロキシのサービスのPodになる

NAME                                                              READY     STATUS    RESTARTS   AGE
...
esp-cloud-endpoints-sample-es-64f86b6df8-9xwpr                    1/1       Running   0          24s
esp-cloud-endpoints-sample-es-64f86b6df8-jlvjl                    1/1       Running   0          24s

ドメインネームをつける

CloudEndpointsはIPと[クラスタ名].endpoints.[PROJECT_ID].cloud.googドメインを紐付けしてルーティングする、DNS機能を提供している。 先程デプロイしたプロキシのExternalIPを、CloudEndpoints定義ファイルの下記の場所に記載し再デプロイすることでドメインでのアクセスが可能になる。

swagger: "2.0"
info:
  title: "Auth Proxy For Elasticsearch Cluster"
  description: "Auth Proxy For Elasticsearch Cluster."
  version: "1.0.0"
host: "cloud-endpoints-sample-es.endpoints.[PROJECT_ID].cloud.goog"
x-google-endpoints:
  - name: "cloud-endpoints-sample-es.endpoints.[PROJECT_ID].cloud.goog"
    target: "ここにIPを記載"

これによって下記のようにとりあえず外部からのアクセスができるようになる。

くコ:彡 curl "cloud-endpoints-sample-es.endpoints.cloud-endpoints-sample-225706.cloud.goog:9200/_cluster/health?pretty"                                            
{
  "cluster_name" : "cloud-endpoints-sample-es",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 7,
  "number_of_data_nodes" : 2,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

また、CloudEndpointsの定義ファイルに指定していないエンドポイントを叩くとこうなる。

くコ:彡 curl "cloud-endpoints-sample-es.endpoints.cloud-endpoints-sample-225706.cloud.goog:9200/_cat/indices?v"                                                   
{
 "code": 5,
 "message": "Method does not exist.",
 "details": [
  {
   "@type": "type.googleapis.com/google.rpc.DebugInfo",
   "stackEntries": [],
   "detail": "service_control"
  }
 ]
}

これによって意図していないAPIへのアクセスを防ぐことができる。

JWT認証による制御

エンドポイントによって認証をつけたいケースがあったとする。 ここではJWT認証を想定し、特定のサービスアカウントによって署名されたJWTつきのリクエストのみ許可したいといったケースを想定すると、 下記のようにCloudEndpointsのswaggerファイルに記載しデプロイすることになる。

...

paths:
  /_cluster/health:
    get:
      description: "Returns the es clusters' health."
      operationId: "get_cluster_health"
      produces:
        - "application/json"
      responses:
        200:
          description: Get
      security:
        - google_jwt: []

securityDefinitions:
  google_jwt:
    authorizationUrl: ""
    flow: "implicit"
    type: "oauth2"
    x-google-issuer: "[PROJECT_ID].appspot.com"
    x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/jwk/cloud-endpoints-sample-225706@appspot.gserviceaccount.com"
    x-google-audiences: "[PROJECT_ID].appspot.com"

この例だとGAEのデフォルトサービスアカウントによって署名されたJWT付きのリクエストのみ通すようになる。 リクエストするとこのように弾かれる。

くコ:彡 curl "cloud-endpoints-sample-es.endpoints.cloud-endpoints-sample-225706.cloud.goog:9200/_cluster/health?pretty"                                            
{
 "code": 16,
 "message": "JWT validation failed: Missing or invalid credentials",
 "details": [
  {
   "@type": "type.googleapis.com/google.rpc.DebugInfo",
   "stackEntries": [],
   "detail": "auth"
  }
 ]
}

認証のオーバーヘッドも試しにStackdriverのトレースで見てみると、

https://miro.medium.com/max/1400/1*zoq3t6SW-HHyIviuXpAjSw.png

0.004ms(東京リージョン)程度だったので十分実用範囲内だと思う。

APIKeyによる制御

JWTではなく特定のAPIKeyによって制御したい場合は下記のようになる。

securityDefinitions:
  api_key:
    type: "apiKey"
    name: "key"
    in: "query"

この場合はGCPプロジェクトで発行したAPIのキーが付与されていることをチェックする(適当な文字列では通らない)

まとめ

GKEと併用してCloudEndpointsを活用することで簡単にAPIへのアクセス制御を実現できた。(実際の運用時にはファイアウォールも併用すると思うけど) これによって、限られたクライアントのみインデックスのドキュメント削除やインデックスの作成などができるといった制御が可能になる。 Elasticsearchだけでなく普通のAPIでも簡単に認証を入れられるはずなので活用していきたい。

参考

Kubernetes Engineでの Endpoints のスタートガイド