SREのたっち(@TatchNicolas)です。
JX通信社では、月に一度「WinSession」というリリースした機能や検証したリリースについて開発チーム全体へ発表する機会を設けています。今回は自分が前回社内に紹介した「パパッと便利APIを作って5分でお手軽&セキュアにデプロイする」方法について書きます。
TL; DR;
- Istio/cert-manager/Auth0を使って、任意のコンテナを認証つきで5分でデプロイできる仕組みを作った
- 設定はアプリケーションごとに独立し、中央集権的なリポジトリに依存しない*1
きっかけ
プロダクト間で共通のAPIを認証付きでパパッと作りたいこと、よくありますよね?
でも、アプリケーションに毎回認証のための仕組みを組み込むのは骨が折れます。アプリケーションはあくまで、アプリケーションの関心ごとに集中させたい。すると、サイドカーコンテナを使って責務を分離するのが良さそうです。
そこで、少しでもその手間を小さくするための仕組みを作ってみよう、となりました。ちょうどPyCon JPの配信システムでGKEに慣れた人も増えてきたタイミングだったので、またGKE上に構築しようと考えました。
しかし前回と違って幾つか課題がありました。
課題と対策
- あるアプリケーションの関心ごとがそのリポジトリの中で完結できない
- JX通信社ではモノレポではなくマイクロサービス単位でレポジトリを切っている
- 「気軽さ」を売りにしたいので中央集権的なリポジトリを作りたくない
- Ingressだと多数のサービスへのトラフィックルールが1つのリソースのmanifestに列挙される
- するとこの仕組み自体がよく使われるようになる程、Ingressリソースの記述が長くなってしまう
- 新しいアプリケーションをデプロイするたびに証明書を作る 必要がある
<アプリケーション名のサブドメイン>.<会社のドメイン>
みたいな形で割り振りたい- しかしGCPのマネージド証明書はワイルドカード非対応
- 増えた証明書をIngressのAnnotationに列挙する必要があり、記述がまた大きくなる
- きちんと認証をかけたいけど、アプリケーション本体と認証は分離したい
- 基本的にはエンドユーザー向けというよりも内部で使うものたち
- アプリケーションは自身の機能だけに集中したい
上記の3つの課題に対して、Istio/cert-manager/Auth0 の組み合わせた 「新しくリポジトリを作ったら、その中だけで必要なものが揃う」 ような仕組みで解決を試みました。
- 個々のアプリケーションへのトラフィック制御はIstioのVirtualServiceに任せることで、アプリケーションごとのリポジトリでトラフィックの管理ができる
- cert-managerを使ってワイルドカード証明書を利用する
- アプリケーションが増えるたびに証明書を増やしたり、その設定を更新したりしなくて良い
- IstioのGatewayリソースに三行書き足すだけで簡単
- Auth0を使って手軽に認証をつける
- こちらもIstioのRequestAuthentication/AuthorizationPolicyを使うことで簡単に組み込むことができます。
- アプリケーション側の実装は何も変えなくてOK
今回の環境/前提
- クラスタ:
v1.15.12-gke.2
- Istioおよびcert-managerはインストールされている前提とします
- どちらもOperatorでインストールしました
- GKEのアドオンのIstioは使っていません
- クラスタ外からのアクセスはistio-ingressgatewayのLoadBalancerで受けてます
- アプリケーションのデプロイ先のNamespaceでistio-autoinjectは有効化しておきます
- Auth0のsign upも完了していることとします
実際に作ってみる
(以下、本記事において「Application」「API」はAuth0上の概念、「Service」「Secrets」などはKubernetesリソース、「アプリケーション」は今回デプロイしたい対象としての一般名詞を指しています)
1. 全体で使うリソースの準備
各アプリケーションではなく、GKEクラスタ全体で使うリソースのリポジトリに置く想定のものから解説します。
cert-manager
apiVersion: cert-manager.io/v1alpha2 kind: ClusterIssuer metadata: name: clusterissuer labels: repo: platform spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: 'yourname@yourdomain' privateKeySecretRef: name: cm-secret solvers: - dns01: clouddns: # GCPプロジェクトを指定 project: your-project
CloudDNSを使ったchallengeを行うには、cert-managerのPodがCloudDNSを操作する権限を持つ必要があります。今回は、Workload Identityを使って、cert-managerのServiceAccountに権限を付与する方法を取りました*2 。
apiVersion: cert-manager.io/v1alpha2 kind: Certificate metadata: name: wildcard namespace: istio-system labels: repo: platform spec: # certificateリソースの名前じゃなくて、 # このsecretNameをistioのGatewayで使う # https://istio.io/latest/docs/ops/integrations/certmanager/ secretName: cm-secret issuerRef: name: clusterissuer kind: ClusterIssuer commonName: <your domain> dnsNames: - <your domain> - '*.<your domain>'
CertificateリソースのsecretNameで指定した名前で、cert-managerがKubernetesのSecretリソースを作成してくれます。このSecretリソースはあとでIstioのGatewayリソースから参照されます。
Istio
apiVersion: networking.istio.io/v1alpha3 kind: Gateway metadata: name: gateway-with-tls namespace: istio-system spec: selector: istio: ingressgateway servers: - port: number: 443 name: https protocol: HTTPS tls: mode: SIMPLE credentialName: cm-secret hosts: - "*.<your domain>"
Gatewayリソースに、前述のCertificateリソースによって作られるSecret(cm-secret
)を紐付けます。
apiVersion: security.istio.io/v1beta1 kind: RequestAuthentication metadata: name: require-auth0-jwt spec: selector: matchLabels: require-auth0: enabled jwtRules: - issuer: https://<yourtenant>.auth0.com/ jwksUri: https://<yourtenant>.auth0.com/.well-known/jwks.json
require-auth0: enabled
なラベルを持ったPodへの通信には認証を必要とするような設定をします。但しこれだけではまだ設定不足で、 不正なjwtを弾くことはできても、そもそもAuthorizationヘッダが無いリクエストは素通りしてしまいます。また、そのJWTがどのアプリケーションのために発行されたのかのチェックもできません。その対策は、アプリケーション単位に行います。
2. アプリケーション固有のリソースを作る
続いて、個々のアプリケーションごとに作成するリソースをみてみましょう。新しいアプリケーションを生やしたくなったら、ここ以降の手順を繰り返せばOKです。お好きなコンテナを動かしてください。
apiVersion: apps/v1 kind: Deployment metadata: name: sample labels: app: sample spec: replicas: 1 selector: matchLabels: app: sample require-auth0: enabled template: metadata: labels: app: sample require-auth0: enabled spec: containers: - name: app image: tatchnicolas/envecho:v0.3.2 env: - name: ENVECHO_PORT value: "8765" - name: ENVECHO_MESSAGE value: "Hi! I am running as a `sample` service on GKE!" ports: - containerPort: 8765 --- apiVersion: v1 kind: Service metadata: name: sample labels: app: sample spec: type: ClusterIP selector: app: sample stage: current ports: - name: http protocol: TCP port: 8888 targetPort: 8765
DeploymentとServiceは、require-auth0: enabled
というラベルを指定している以外は特に変わったことはありません。そしてこのラベルが前述のRequestAuthenticationリソースのselectorと対応します。
Serviceが公開するポートは、一つしか無い場合はIstioがよしなにしてくれるので、何でもOKです。
続いて、トラフィック制御と認証を追加しましょう。
apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: sample labels: app: sample spec: gateways: - istio-system/gateway-with-tls hosts: - sample.<your domain> http: - route: - destination: host: sample.default.svc.cluster.local --- apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: sample-auth-policy labels: app: sample spec: selector: matchLabels: app: sample action: ALLOW rules: - when: - key: request.auth.audiences values: # auth0のidentifierと一致させる - sample.<your domain> - from: - source: namespaces: ["prometheus"] to: - operation: methods: ["GET"]
sample.<your domain>
というホスト名で来たリクエストを、前の手順で作ったServiceに紐づいたPodたちに回すようにVirtualService設定をします。
先ほど、RequestAuthenticationはまだ設定が不足していると書きましたが、足りない部分をここで補います。AuthorizationPolicyの
spec.rules[0].when
以下の部分で、jwtのaudienceが正しいことを求めるようになります。また、AuthorizationPolicyを設定するとAuthorizationヘッダが無いリクエストもちゃんと弾いてステータスコード403を返すようになります。(あとで管理しやすいようにドメインと一致させましたが、特にその必要はありません。)
今回のテーマからは少し逸れますが、アプリケーションがPrometheusでinstrumentされていてる場合スクレイピングも弾かれてしまいますので、Prometheusサーバが prometheus
というネームスペースにデプロイされていることを想定して、スクレイピングのリクエストは許可するような設定を参考として追加しました。
Kubernetes側の設定は以上です。
3. Auth0側にAPIを登録する
Auth0のコンソールにログインして、アプリケーションをAPIとして登録します。2.
の手順の中でjwtのaudienceとして文字列が必要になります。
細かな手順は 公式の Register APIs を参照してください。注意すべき点はたった一つ、identifierの値を spec.rules[0].when
以下で指定した値(上記の例ではsample.<your domain>
) とそろえるだけです。
APIを登録すると、対応するTest Applicationが作成されると同時に、API詳細画面のTestというタブからリクエストの方法が各種言語で見られるようになります。
4. 動作確認
今回はcurlを使ってトークンを取得し、実際に立てたサービスへリクエストを試みます。
Applicationsの一覧に<API作成時の名前> (Test Application)
というApplicationが作成されていますので、Client IDとClient Secretを使って次のように実際のアプリケーションへアクセスします。
# 上記の図の(1) $ TOKEN=$(curl --request POST \ --url https://jxpress.auth0.com/oauth/token \ --header 'content-type: application/json' \ --data '{"client_id":"xxxxxxxx","client_secret":"abcdefghijklmnopqrstuvwxyz","audience":"sample.<your domain>","grant_type":"client_credentials"}' | jq -rc '.access_token') # 上記の図の(3) $ curl -i -H "Authorization: Bearer $TOKEN" https://sample.<your domain> HTTP/2 200 date: Thu, 27 Aug 2020 03:18:21 GMT content-length: 46 content-type: text/plain; charset=utf-8 x-envoy-upstream-service-time: 3 server: istio-envoy Hi! I am running as a `sample` service on GKE!
TOKENが不正だったり、AuthorizationPolicyで指定したaudienceがJWTのaudience(=Auth0 APIのidentifier)が一致しない場合には403 Forbiddenが返ってきます。
Auth0の使い方について
今回は自動で作成されるTest Applicationを利用したので分かりにくいのですが、実際の運用では
- デプロイするAPIのConsumerごとにApplicationを作成
- そのApplicationに対してAPIの利用を許可する
という手順を踏むことになると思います。つまり、あるApplicationは複数のAPIに対する認可を得ることができ、またAPIも複数のアプリケーションに認可を与えることができます。
前述の例の audience
の部分が使いたいAPIで設定したidentifierになり、ApplicationがAPIに対する権限を持っていなければAuth0からJWTが入手できないので、「どのApplicationがどのAPIを利用できるか」をAuth0で一元管理できるというわけですね。
たとえば、画像のアップロードを受け付ける foo
というApplicationが、いい感じにリサイズや圧縮をしてくれる便利API hoge
や画像に何が写っているかを判定する fuga
というAPIを利用する場合、Application(foo
) がAPI (hoge
fuga
) を叩けるようにAuth0のコンソール上でAuthorizeしてやる 必要があります。
詰まったところ/改善点
Istioがプロトコルを認識してくれない
今回の仕組みを作る途中、RequestAuthorization/AuthenticationPolicyを有効化すると upstream connect error or disconnect/reset before headers
なエラーが出る現象にハマってしまいました。
本来IstioはデフォルトでHTTPおよびHTTP/2のプロトコルは自動で検出可能で、VirtualServiceは、spec.http.route[].destination
の指しているServiceが公開しているポートが1つだけの場合、自動でそのポートへリクエストを回してくれます。*3 実際、認証をかけない状態ではきちんとリクエストは通っていました。
なので特に明示的に書かなくても大丈夫と思っていたのですが、Service側で spec.ports[].name
に http
を指定してやる必要がありました。(そういう仕様なのか意図せぬ挙動なのかまでは調べきれてません...)
VirtualServiceのトラフィック制御条件が散らばってしまう
これは当初の目標だった「アプリケーションごとのリポジトリでトラフィックの管理ができる」ことの裏返しなのですが、トラフィック制御のルールが複数のリポジトリに散らばってしまいます。
公式Docにもある通りVirtualServiceは一つのKubernetesリソースとして登録することも、複数のリソース分割して登録することもできます。今回はその機能を前向きに利用したのですが、そのデメリットとして、例えばホスト名をベースにトラフィック制御していると、たまたま条件がかぶっていたり矛盾していると期待した振る舞いをしてくれない可能性があります。
it will only have predictable behavior if there are no conflicting rules or order dependency between rules across fragments
以前書いた記事のように、Admission Webhookなどを使って、自前で条件被りのチェックを実装しても良いかもしれませんが、条件のパターンは多岐に渡りますし、「何を意図せぬ衝突とし、何を意図した衝突とするか」の判断も難しいので一筋縄では実装できなさそうです。
さいごに
本記事の元になった社内発表でも、ライブコーディング的にデモを行って5分でサンプルアプリをデプロイすることができました。少しでも皆さんの参考になれば幸いです。
*1:もちろん、Istioやcert-manager自体の管理をするリポジトリは用意しますが、「アプリケーションを追加するときに、基盤の事情は知らなくていい」がポイントです
*2:https://github.com/jetstack/cert-manager/issues/3009
*3:https://istio.io/latest/docs/ops/configuration/traffic-management/protocol-selection/#automatic-protocol-selection