GitHub Actionsで実現する、APIキー不要でGitOps-likeなインフラCI/CD

※ 今はGitHub ActionsでOIDCが使えるので、本記事の内容は少し古いです。*1 現場のルール等で「インフラを触るワークロードはオンプレでしか動かしてはならない」みたいなルールがある場合には多少参考になるかと思います。

SREのたっち(@TatchNicolas)です。

JX通信社では「インフラチーム」のようなものは存在せず、開発したチームが運用までやるFull-cycleなスタイルを取っています。AWS・GCPリソースの管理も特定のメンバーが担当するのではなく、必要とする人が必要な時に作成・修正等を行います。すると、terraformなどIaCのツールを利用する場合に「今リポジトリにあるコードは実態を正しく反映しているのか」「誰かが矛盾する変更を加えていないか」という問題が発生します。

CIツール上でterraformを実行することで、問題の一部は回避できるかもしれませんが、CIにSaaSを利用している場合、クラウド上のリソースを操作する強い権限を持ったAPIキーを外部に預けることになってしまいます。

GCPのCloudBuildを使った解決策 *2、AWSのCodeBuildを使った例*3はそれぞれあるのですが、せっかくterraformをつかうならできるだけ同じ仕組みに載せたいところです。

そこで、「CIの仕組みとしてはSaaS(今回はGitHub Actions)を利用しつつも、実行場所はクラウド内にとどめることでGitOpsの考え方を一部取り入れたterraform運用のしくみ」を作ってみました。

TL;DR;

f:id:TatchNicolas:20201005152126p:plain
GCPの場合

f:id:TatchNicolas:20201005152147p:plain
AWSの場合

  • Self-hosted runnerを使えば、GitHubにクラウドの認証情報を渡さなくともGitHub Actionsでterraformを実行できる
  • Pull Request作成でplan、masterへのマージでapplyして、また定期的にmaster最新コミットでplanして乖離をチェック することでGitOps-likeに運用できる *4
  • 発想としては単純で「GCP Service AccountまたはAWS IAM Roleを使ってCIのRunnerにterraform実行に必要な権限を与える」なので、GitLab.comでも同じようにできるはず *5

やってみる

GKE(GCP)・EKS(AWS)にて、実際にやってみます。 AWS・GCPの権限を引き受けることができれば良いので、k8sではなくEC2・GCEでも実現可能なのですが、できるだけクラウドベンダごとの違いがでないようにk8s上のPodとして動かしました。

  • GKE・EKSクラスタは構築済みの前提
  • RunnerにGCP・AWSの強い権限を持ったAPIキーを渡さない
    • GKE・EKSの機能を使ってAPIキー自体を発行しないで権限をRunnerに付与する
  • 定期的にplanを実行して、masterの最新commitと食い違いを検知する

1. Runnerをつくる

今回は下記の記事の中で紹介されている gh-runners/gke at master · bharathkkb/gh-runners · GitHub を参考にしました

github.blog

気を付けるポイントとしては、

  • あとで hachicorp/setup-terraform を使うために、unzipをDockerイメージにインストールする必要がある
  • 権限付与の動作確認用にgcloudやaws cliなども追加しておくと便利かもしれません

Podが削除されるときにGitHub上のからRunnerを除名する処理をシェルスクリプトで書いて、preStop のlifecycleで実行するようにしても良いでしょう。

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: github-runner
  name: github-runner
  labels:
    app: github-runner
spec:
  replicas: 1
  selector:
    matchLabels:
      app: github-runner
  template:
    metadata:
      labels:
        app: github-runner
    spec:
      serviceAccount: github-runner
      containers:
      - name: runner
        image: gcr.io/your-project-name/github-runner
        env:
        - name: REPO_OWNER
          value: "your_github_name"
        - name: REPO_NAME
          value: "your_repo_name"
        - name: GITHUB_TOKEN
          valueFrom:
            secretKeyRef:
              name: name-of-secret
              key: GITHUB_TOKEN
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "'./remove.sh'"]

ちなみにPodが削除されたまま放っておいても、GitHub側で30日経てば自動で取り除いてくれるようです。しかしお片付けはきちんとしたほうが気持ちが良いでしょう。

A self-hosted runner is automatically removed from GitHub if it has not connected to GitHub Actions for more than 30 days.

About self-hosted runners - GitHub Docs

また、今回は使いませんでしたがRunnerの登録/削除などを良い感じに管理してくれる素敵なControllerもありました。

github.com

2. Actionを定義する

HashiCorpのチュートリアルにGitHub Actionsを使った自動化の例 があるので、そちらを参考にすれば環境やチームのリポジトリ構成に合わせてすんなり書けると思います。

learn.hashicorp.com

また、追加で定期的にplanを実行して、tfファイルと実態の乖離を検知するworkflowを定義してみます。

検知の方法は色々あるとおもいますが、planをファイルに書き出して、jsonとしてterraform showしたものをjqでパースして判断してみます。今回はついでに例として、乖離があったらIssueを作成してみます。

下記の例の通りやると乖離が解決されない限り重複するIssueを作成しつづけてしまうので、cronの頻度を落としたり通知先をGitHub IssueではなくSlackにする、復帰のためのapplyまで実行してしまうなど、チームの方針に合わせて調整してみてください。

name: check-drift

on:
  push:
  schedule:
    - cron:  '*/10 * * * *'

jobs:
  check-drift:
    runs-on:
      - self-hosted
    steps:
      - uses: actions/checkout@v2

      - uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.13.2

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: terraform plan -out tfplan

      - name: Print the result of terraform plan as json
        id: print_json
        run:  terraform show -json tfplan

      - name: Check configuration drift
        id: check
        env:
          PLAN_JSON: ${{ steps.print_json.outputs.stdout }}
        run: |
          DIFF=$(echo "$PLAN_JSON"| jq -c '.resource_changes[] | select(.change.actions!=["no-op"])')
          if [ -z "$DIFF" ]; then
            echo 'No configuration drift detected'
            echo '::set-output name=STATUS::synced'
          else
            echo 'Configuration drift detected!'
            echo '::set-output name=STATUS::drifted'
            terraform show tfplan -no-color
          fi

      - uses: actions/github-script@0.9.0
        id: show_plan
        if: steps.check.outputs.STATUS == 'drifted'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `\`\`\`${process.env.PLAN}\`\`\``;

            github.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: "Configuration drift detected",
              body: output
            });

3. Runnerに権限を渡す

GCPのservice accountまたはAWSのIAM roleと、k8s service account(KSA)の紐付けをするterraformのサンプルコードを示しておきます。どちらも github-runner というk8s namespaceにrunnerをデプロイすることを前提としています。 *6

GCPの場合

GKE上のPodにGCPリソースの操作権限を渡すには、Workload Identityを使います。

resource "google_service_account" "github_runner" {
  account_id   = "github-runner"
  display_name = "github-runner"
}

resource "google_project_iam_member" "github_runner_owner" {
  role   = "roles/owner"
  member = "serviceAccount:${google_service_account.github_runner.email}"
}

resource "google_service_account_iam_member" "github_runner_wi" {
  service_account_id = google_service_account.github_runner.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:${var.project_id}.svc.id.goog[github-runner/github-runner]"
}

紐付けるKSA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: github-runner
  namespace: github-runner
  labels:
    app: github-runner
  annotations:
    iam.gke.io/gcp-service-account: github-runner@your-project-id.iam.gserviceaccount.com

AWSの場合

EKS上のPodにAWSリソースの操作権限を渡すには、こちらのドキュメントを参考にします。

resource "aws_iam_role" "github_runner" {
  name               = "GitHubRunnerRole"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "${aws_iam_openid_connect_provider.this.arn}"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "${replace(aws_iam_openid_connect_provider.this.url, "https://", "")}:sub": "system:serviceaccount:github-runner:github-runner"
        }
      }
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "github_runner" {
  role       = aws_iam_role.github_runner.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"

}

紐付けるKSA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: github-runner
  namespace: github-runner
  labels:
    app: github-runner
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/GitHubRunnerRole

動かしてみる

実際にPull Requestを作成してみると、下記のようなコメントが自動でつきます。 show plan のところをクリックすれば terraform plan した内容が確認できます。 確認できたらマージすると、applyが実行されます。

f:id:TatchNicolas:20201005065742p:plain
自動でplanの結果がコメントされる

また、定期的にplanして定義と実態の乖離を検知すると、Issueが作成されます。

f:id:TatchNicolas:20201005075351p:plain
自動で作成されたIsuue

まとめ

得られたメリット

  • GCP・AWSの強い権限をもったAPIキーをGitHubに預けなくてよくなった
  • AWSでもGCPでも、仕組みの作り方がほぼ共通で利用・メンテナンスがしやすい
    • クラウドベンダごとの違いはterraformとk8sで吸収
  • terraformを運用者の手元で実行する必要がなくなった
    • 「これって反映済み?どれが実態?」「planが食い違ってしまった、今誰か作業してる?」が発生しなくなる
    • tfenvに頼らなくてもterraformのバージョンを指定して実行できる
  • terraformの定義と実態の乖離を検知できるようになった

注意点・残った課題

  • GitHubのself-hosted runnerはリポジトリ単位か組織単位でしか設定できない
    • 組織でterraform用runnerを共有すると、組織以下のすべてのリポジトリがCIでAWS・GCPの強い権限を利用できてしまう
    • GitLabにはgroup単位のrunnerを設定できるようですが、GitHubにはそのあたりを細かく設定できなさそうなのでもうひと工夫する必要がありそう
  • masterへpushできる人は(アプリケーションでもそうですが)しっかり管理する必要がある
    • masterへpushできる人は(与えた権限の範囲で)Runnerを通じて何でも出来てしまうため
    • 同じ理由でrunnerを動かしているk8sクラスタ上でのPodへの権限も、きちんと管理する必要がある
  • HashiCorpのチュートリアルにあったworkflow定義では、Pull Request作成時にplanした分はファイルへ書き出さず、マージ時に改めてapplyしているので、Pull Requestコメントに書き出したplanと実際に行われるapplyの内容が異なる可能性がある
    • planした結果を何処かへ保存して、その内容を参照するようにworkflowを改善できるかも

チームの体制やポリシーによって、持たせる権限やRunnerの運用方法には工夫が必要ですが、「credentialを外に出さない」「GitリポジトリをSingle Source of Truthにする」「乖離を検知する」というGitOpsの特徴を持ったterraform運用が実現できました。少しでも参考になれば幸いです。

*1:https://github.blog/changelog/2021-10-27-github-actions-secure-cloud-deployments-with-openid-connect/

*2:https://cloud.google.com/solutions/managing-infrastructure-as-code

*3:https://medium.com/mixi-developers/terraform-on-aws-codebuild-44dda951fead

*4:GitOpsの定義は https://www.weave.works/technologies/gitops/https://www.gitlab.jp/blog/2020/09/03/is-gitops-the-next-big-thing-in-automation/ など揺らぎがあるので、今回はその一部である「権限を外に出さない」「GitリポジトリをSingle Source of Truthとする」を取り入れつつもsyncはCI上で行っていることからGitOps-"like"としました

*5:調べてみると、事例がありました 絶対に止められない超重要サービスをGitOpsで安全に開発できるようにしている話

*6:本記事ではroles/ownerやAdministratorAccessといった強い権限を渡していますが、実際にはチームの方針にあわせて調整してください。

リモート下でチームのコミュニケーションを円滑にするための試み

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。

新型コロナの影響で、オフィスに出社して働くというスタイルに大きな変化が生じてきています。 弊社でも原則リモートでの勤務が推奨となっており、従来通りのコミュニケーションを続けることが困難になってます。 今回はコミュニケーションを活性化するために、チームでどのような工夫をしているかを紹介したいと思います。

リモートワークだとなにができないか

最初にリモートワークへ移行して感じた課題を挙げてみます。

気軽な話しかけができない

f:id:nsmr_jx:20200821210659p:plain:w160

一番違いを感じるのはこの点でした。 仕様の相談だったり、困ったときにちょっと質問するみたいなことは、物理でオフィスに集まっているときは気軽に出来ていました。相談しやすいようにチームごとに座席が近くなるよう席替えしたりして、できるだけコミュニケーションの障壁となるものを廃していました。

しかし、リモートワークでお互いが別々の場所にいるとなると、話しかけるという行動が難しくなります。ボイスチャットをするために話し相手と通話部屋を都度用意し、お互い通話可能なタイミングを調整するというのは結構コストのかかる行為です。 忙しくなさそうなタイミングを見計らって隣の人に声をかけるのと比べるとどうしてもためらいがちになります。

空気感が伝わらない

f:id:nsmr_jx:20200821210319p:plain:w160

音声でのコミュニケーションのハードルが上がった結果、Slack などの文字ベースでのコミュニケーションが主流になってきます。文字として記録が残るのでメリットもあるのですが、話し手の意図が伝わりづらいというデメリットもあります。 対面レビューと比べて指摘の語気が強くとられてしまったり、やってもらったことに対しての感謝が伝えづらかったりします。

また 音声 VS 文字 だけでなく、実際の会話 VS 通話越し であっても空気感の伝わりづらさというのは感じています。 物理的に空間を共有して会話するというのはやはり強いですね。 みんなでOKR決めようとかブレストしようみたいな場合、やはりビデオ通話より会議室に集まってやった方が楽しいです。

顔を見ないとメンバーの調子が分からないという面もありますね。 出社していた頃は多少冗長でも朝会で集まって各自タスク共有をしてたりしました。

ランチ行けない

f:id:nsmr_jx:20200821210455p:plain:w160

チームの人を誘ってランチ行くことが多かったのですが、リモートだとこれが出来なくなりました。 業務に関係ないゲームの話とかをするのは昼休みの時間がうってつけだったのですが、リモート化が進むにつれこういった話がしにくくなりました。

シャッフルランチというランダムなメンバーでご飯いくという制度もあったのですが、こちらもコロナの影響で実施できていない状況です。

あと単純に神保町のおいしいランチが食べられないのは期待損失が大きいです。

そのほか

  • 同時に複数人が話すと聞き取れない
  • 通話環境に左右されやすい
  • MTG がスムーズに開始できない
  • 打ち上げができない
  • 業務後ゲーム(ボードゲーム、スマブラ)ができない

などなどリモートならではの問題が生じてきています。

チームで取り組んでること

上記の問題すべてを解決することは出来ないですが、少しでもコミュニケーションを円滑にするためチームで取り組んでることを紹介していきます。

朝会

f:id:nsmr_jx:20200905145210p:plain:w160

出社時にやっていた朝会はリモートでも毎日継続してやるようにしました。 最初は音声のみでやっていたのですが、新しいメンバーが入ってきて「顔見ながらやりたい」という意見があったため、朝会はカメラありで実施するようにしています。やはりメンバーの顔色が分かった方が安心感がありますね。

ツールとしては Zoom をつかってビデオ通話しています。Zoom の場合バーチャル背景の機能が組み込まれているため、周囲の状況を気にせずビデオ通話ができます。ビデオ通話するのに部屋を片付けたり洗濯物をどけたりする必要がなくて便利です。

朝会の内容としては当日やるタスクの確認と、相談・周知したいことの共有をしています。

常時通話

f:id:nsmr_jx:20200905145758p:plain:w160

気軽に話しかけるのが難しいことの対策として、常時通話状態にしておくというのをやってみました。 都度音声通話に来てもらうのではなくて、話せばチームにすぐ声をかけられる仕組みですね。 ツールとしては Discord というツールを使ってます。

Zoom とちがって気軽に音声通話の部屋を移動できるので、相談したいからこっちの部屋で、とか集中モード( or 休憩中)だから話しかけないでみたいなことがサッとできます。画面共有もできるので後述するペアプロとかも Discord をつかってやっています。 通話のハードルが下がったことで気軽な相談がしやすくなったように思います。1対1のつもりで話す内容でもグループ通話で聞いてる人が増えることで思わぬ助け舟が降ってくることもあります。 ただ他のチームから何をやっているかが見えづらいという意見ももらったので、文字に残すべきもの(仕様や決定事項)はちゃんと文字に残こすよう注意すべきだなと思いました。

ペアプロ・モブプロ

f:id:nsmr_jx:20200905144529p:plain:w160

ちょうど1年前くらいからチームでペアプロ、モブプロを導入し始めました。 PCを操作してコードを書く人(ドライバー)と、画面をみて指示を出す人(ナビゲーター)に分かれて一緒にコーディングを進めていく手法です。 個別に実装を行ってあとからレビューする形式と比べて出戻りが少なく、メンバーの知見を共有しつつ開発を進められるので大きな機能を実装するときによく活用しています。

出社してモブプロするときは、1台のモニタ使ってみんなで見ながらナビゲートする形で進めていました。 今にして思うとなかなか密な作業環境ですね。 リモートになってからは、Discord の画面共有機能を主につかってモブプロを実施しています。メンバーが Discord で待機していることもあり、モブプロで進めようとなったときはスムーズに開始することができます。 ただやはりオフラインでやっていた頃と比べると、ワイワイと実装を進めている感覚が乏しくなったように思います。3人以上のモブプロだと、モニタ越しにドライバーが実装していくのを見守るだけということになりやすく、複数人が参加するシナジーが得られにくいです。そのため最近はペアで作業することが多くなってます。

ティータイム

f:id:nsmr_jx:20200905145545p:plain:w160

最近導入してよかったのがこのティータイム制度です。 リモート下で「雑談が少ない」という振り返りから、意図的に雑談する時間を業務内に用意することにしてみました。

毎日16:00から15分間、Discord に集まって雑談するというだけなのですが、これが結構チームの健全性に貢献しているように思います。ランチや飲み会といった業務外の話ができる場がなくなってしまったので、こういったブレイクタイムを意図的にもたせることは大事だなと感じました。特にインターンで来てくれている方ともお話できるのが大きいですね。やってもらっているタスクによってはメンターとメンティの1対1のやりとりで完結してしまうことがあるのですが、こういった雑談タイムに参加してもらうことで、お互いを知る機会を作れています。

ちなみに最初は「おやつタイム」という名称だったのですが、16時に間食すると夕飯に支障がでるという理由からティータイムへと変更になりました。

物理出社

f:id:nsmr_jx:20200905145621p:plain:w160

リモートが続いてしんどくなってきたときの最終手段です。 リモート推奨下でもオフィスは空いているので、一人での作業が堪えてきたら出社することもできます。 自分のチームでもだいたい週0.5〜1くらいの頻度でバラバラと出社している印象があります。 出社するとチーム外の人とも会話できるのでいい気分転換になります。 やはり人と実際に話せる場というのは大切なのかもしれませんね。

KPT

f:id:nsmr_jx:20200907105305p:plain:w160

KPT という振り返りのミーティングを毎週実施しています。 Keep(継続すべきよかったこと)とProblem(課題に感じていること)を共有して、Try(改善策)を続けていくやりかたですが、こちらもリモート前から継続してやっています。 形式としてはおのおの事前に GoogleDoc へKとPを書いておき、MTGの際に「前回のTはどうだったか」「K、Pの共有」「今回のTの決定」を話しています。

「朝会でカメラオンにしよう」「ティータイムつくろう」みたいな試みはこのKPTの中で生まれました。 継続的な振り返りを通して試行錯誤を続けていたことで、フルリモートへの移行のような大きな変化でも柔軟に適応できたように思います。

おわりに

以上がチームでやっているリモートのコミュニケーション施策でした。 これからもより良いリモート開発体制を求めて試行錯誤していきたいと思ってます。

IstioとAuth0でJWT認証付きAPIを5分でデプロイする

SREのたっち(@TatchNicolas)です。

JX通信社では、月に一度「WinSession」というリリースした機能や検証したリリースについて開発チーム全体へ発表する機会を設けています。今回は自分が前回社内に紹介した「パパッと便利APIを作って5分でお手軽&セキュアにデプロイする」方法について書きます。

TL; DR;

  • Istio/cert-manager/Auth0を使って、任意のコンテナを認証つきで5分でデプロイできる仕組みを作った
  • 設定はアプリケーションごとに独立し、中央集権的なリポジトリに依存しない*1

きっかけ

プロダクト間で共通のAPIを認証付きでパパッと作りたいこと、よくありますよね?

でも、アプリケーションに毎回認証のための仕組みを組み込むのは骨が折れます。アプリケーションはあくまで、アプリケーションの関心ごとに集中させたい。すると、サイドカーコンテナを使って責務を分離するのが良さそうです。

そこで、少しでもその手間を小さくするための仕組みを作ってみよう、となりました。ちょうどPyCon JPの配信システムでGKEに慣れた人も増えてきたタイミングだったので、またGKE上に構築しようと考えました。

tech.jxpress.net

しかし前回と違って幾つか課題がありました。

課題と対策

  1. あるアプリケーションの関心ごとがそのリポジトリの中で完結できない
    • JX通信社ではモノレポではなくマイクロサービス単位でレポジトリを切っている
    • 「気軽さ」を売りにしたいので中央集権的なリポジトリを作りたくない
    • Ingressだと多数のサービスへのトラフィックルールが1つのリソースのmanifestに列挙される
    • するとこの仕組み自体がよく使われるようになる程、Ingressリソースの記述が長くなってしまう
  2. 新しいアプリケーションをデプロイするたびに証明書を作る 必要がある
    • <アプリケーション名のサブドメイン>.<会社のドメイン> みたいな形で割り振りたい
    • しかしGCPのマネージド証明書はワイルドカード非対応
    • 増えた証明書をIngressのAnnotationに列挙する必要があり、記述がまた大きくなる
  3. きちんと認証をかけたいけど、アプリケーション本体と認証は分離したい
    • 基本的にはエンドユーザー向けというよりも内部で使うものたち
    • アプリケーションは自身の機能だけに集中したい

上記の3つの課題に対して、Istio/cert-manager/Auth0 の組み合わせた 「新しくリポジトリを作ったら、その中だけで必要なものが揃う」 ような仕組みで解決を試みました。

  1. 個々のアプリケーションへのトラフィック制御はIstioのVirtualServiceに任せることで、アプリケーションごとのリポジトリでトラフィックの管理ができる
  2. cert-managerを使ってワイルドカード証明書を利用する
    • アプリケーションが増えるたびに証明書を増やしたり、その設定を更新したりしなくて良い
    • IstioのGatewayリソースに三行書き足すだけで簡単
  3. 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. 動作確認

f:id:TatchNicolas:20200827151837p:plain
Client、Auth0、istio-proxy付きPodの関係図

今回は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[].namehttp を指定してやる必要がありました。(そういう仕様なのか意図せぬ挙動なのかまでは調べきれてません...)

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などを使って、自前で条件被りのチェックを実装しても良いかもしれませんが、条件のパターンは多岐に渡りますし、「何を意図せぬ衝突とし、何を意図した衝突とするか」の判断も難しいので一筋縄では実装できなさそうです。

tech.jxpress.net

さいごに

本記事の元になった社内発表でも、ライブコーディング的にデモを行って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

PyCon JP 2020のTwitter実況システムをGKE上に作った話

SREのたっち(@TatchNicolas)です。

今年のPyCon JPはオンライン開催でした。JX通信社はSilverスポンサーとして協賛したほか、イベントをより盛り上げるために、参加者の反応をリアルタイムに配信に反映するシステムを開発・提供しました。

jxpress.net

アプリケーションはPythonで作られており、基盤としてGKEを採用しました。データ分析基盤や昨年の開発合宿等で社内向けのプロジェクトにKubernetesの採用した事例は過去にもあったのですが、今回はじめて社外向けのシステムに採用したので、その裏側について書いてみたいと思います。

できたもの

構成図

構成としては比較的シンプルだと思います。

基本的な処理はGKE上で行い、データの永続化はFirestoreを使っています。一部、ブラウザで動く運営向けフィード画面上の操作(いいね・リツイート)について、Firebaseを使った書き込みをトリガーとしてCloud Functionsを使って反映させています。

f:id:TatchNicolas:20200827091516p:plain
PyCon JP 2020 SNSリアルタイム配信システム 構成図

(一部リソースは省略しています)

設計のポイント

前提としては、

  • 継続的に提供するサービスではなく、PyCon JP 2020が開催されている二日間のみ稼働すればよい
  • Kubernetes採用の理由は「普通に作っても面白くない、今までと違うことにチャレンジしよう
  • 一方で、短期間 + 開発チームは普段の業務と並行してすすめるため、あまり欲張りはできない

以上から、 「今までよりちょっと便利」を感じてもらいつつも「今までと違いすぎない」 を目指して構成を考えました。

やってみて

Kubernetes投入のハードルは思った以上に低かった

もともとJX通信社ではECSを使い倒しているので、「これはECSでいうとXXみたいな何か」という説明で、Kubernetesに馴染みのないメンバーにもすんなり理解してもらえたと思います。毎週社内の有志でKubernetes勉強会を開催していたので予備知識としても全くのゼロからのスタートではなく、実践の場として良い機会を作れたなと思います。

(最近は本を読むより、それぞれが作りたいものを作ってもくもくする時間になりつつあります。)

永続化や認証はFirebaseを利用したので、複雑になりがちな部分をうまくマネージドに逃がせたことも導入の容易さに繋がったと思います。

port forward のおかげでローカル開発が捗った

今回のプロジェクトでは単にツイートを取得してくるだけでなく、不適切な書き込みをできるだけ除去するために幾つかの機械学習モデルをWeb API化して利用しています。

実際には構成図のcrawlerから利用されていて、収集したツイートをスコアリングしています。ローカルでのcrawler動作確認時にもツイートの内容を判定するためにモデルを叩きにいきたいのですが、そのためにモデルをダウンロードしたりdocker-composeなど用意して推論APIをローカルに立てるのも面倒です。さらに、手元のマシンのリソースも余分に消費してしまいます。

そこで kubectl port-forward <推論APIのService> <ローカルのポート>:<推論APIのポート> とlocalhostで使えるようにすることで、別途APIを立てることなくスムーズに開発ができるようになりました。

APIのURLは環境変数としてcrawlerに渡すようにしているので、切り替えも簡単に行えます。

これは普段のECSの開発では無かった体験なので、開発メンバーからも「こりゃ便利だ」と言ってもらえました。

Secretsのおかげで開発用のAPIキーを簡単に共有できた

Slack連携やTwitter APIの取得のために、いくつか秘匿情報としてPodに渡したいAPIキーがありました。コーディング時は前述の推論APIのURLと同様、実際にSlack/TwitterのAPIを叩きたいのですが、メンバーが自分でキーを用意するのはやはり面倒です。

こちらも検証および本番環境ではSecretsリソースを使って環境変数としてアプリケーションへ渡しているので、ローカルで使いたい場合は検証環境から各種キーを取得して環境変数へセットするワンライナーを用意することで、各メンバーがそれぞれキーを発行することなく利用でき、開発に集中することができました

export SLACK_CHANNEL=$(kubectl get configmap crawler --template="{{.data.SLACK_CHANNEL}}")
export SLACK_TOKEN=$(kubectl get secret crawler --template="{{.data.SLACK_TOKEN}}"|base64 -D)

上記をそのままdirenvの .envrc に書いておけば、下準備はほとんど必要なく、pullしてきてすぐに開発を始められます。

まとめ

PyCon JPに限らず、様々なテックカンファレンスがオンラインで開催されている中で、JX通信社としてイベントを少しでも盛り上げるために貢献できたことはとても嬉しいです。

今回のPyCon向け配信システムに限らず、チーム全体での開発のスピードを上げるために、Kubernetesを使って社内のマイクロサービス基盤を改善していく取り組みも行っています。次回はもうすこしKubernetesのエコシステムを生かした仕組みづくりについて書いてみたいと思います。

アプリを利用しながらアプリの更新ができるin-app updates

Androidエンジニアの@sakebookです。 今まではストアに飛ばしたり、自前で用意したロジックやAPIで更新があるかを確認していました。しかしそんな時代はもう終わりました。Play Core Libraryを使えばアプリ内でアップデートが可能になります。

in-app updates

文字通りアプリ内でアプリのアップデートを行える機能です。アプリのアップデートといえば、知らない間に自動更新されていたり、ストアへ行って更新ボタンを押すなどがありましたが、それらのトリガーをアプリ内から任意のタイミングで引き起こすことが可能になったイメージです。

in-app updatesでは大きく分けて2つの方法がサポートされています。

フレキシブル(Flexible)

知らない間に自動更新

に相当するものです。ユーザにアプリを利用させつつ更新版アプリをDLし、DL完了したタイミングで再起動するメソッドを呼び出すことでアプリを再起動させられます。

アプリにとって必須とは言えないアップデート等の場合に有効です。

f:id:sakebook:20200826004435p:plain

即時(Immediate)

ストアへ行って更新ボタンを押す

に相当するものです。更新版アプリをDLしている間はユーザにアプリのUIを触らせない形になります。DL完了後、画面に従いアプリは自動で再起動します。

アプリにとって必須なアップデート等の場合に有効です。

f:id:sakebook:20200826004507p:plain

実装

詳細はドキュメントに任せます。

ざっくりいうと、アプデが可能かどうか確認し、可能であればFlexible or Immediateとしてアプデリクエストを送る流れです。

Flexibleの場合は、自動ではアプデ完了しないので、DLの進捗をモニタリングし、完了したら再起動を促すUIを表示するのが推奨されています。再起動自体はアプリをDL後、AppUpdateManager#completeUpdate()を呼び出すことで可能です。アプリがBGの状態で呼び出すとアップデートがサイレントインストールされます。

Immediateの場合は、全画面表示になりユーザのアプリの利用を阻害しますが、ユーザはキャンセルすることもできます。そのため、DLがキャンセルされた場合に途中から再開するべきか判定する処理があることが望ましいです。

どちらの場合でも、ユーザはキャンセル可能で、アップデートを強制させるものではないことに注意してください。

テスト

ロジックのテスト用にFakeAppUpdateManagerというクラスが用意されています。AppUpdateManagerと同様のinterfaceを持っています。状態を操作できることと、実際にDLやUIは表示させないこと以外は同じです。

初めての場合は実際にアプリがどんな挙動を取るのか確認したくなると思います。

そんなとき、Google Playの内部アプリ共有機能を使えば実際にin-app updatesを確認することも出来ます。

内部アプリ共有(internal app-sharing)

通常、ストアにあげるアプリにはVersionCodeをincrementしたりする必要がありますが、internal app-sharingを使えばincrementの制約を無視したり、許可リストによる配布が可能になります。

アプリをアップロードしてリンクを発行することで、認定テスターやリンクを知っている人のみDL可能にします。認定テスターとはメーリング リストに追加したテスターのことです。

注意点として、認定テスターはGooglePlayアプリの設定からバージョンを7回タップしてデベロッパー設定をしておく必要があります。設定をしていないと、発行したリンクを踏んでもDLできません。また、既存のアプリとは同じPackage Nameなので端末内で同居はできません。

認定テスターは発行したリンクを踏むことでストア内のinternal app-sharingのアプリページに遷移し、DLできるようになります。

in-app updatesは、GooglePlayアプリ上で更新があるかどうかでアプデ可能かを判定しています。internal app-sharingを使うと、既にストアに上がっているアプリとは別のアプリとして扱われます。

別のアプリとして扱われるというのは、更新判定が別という意味です。

例えばストアのアプリのVersion Codeが2で、internal app-sharingのアプリのVersion Codeを1にして、internal app-sharingのVersion Code1のアプリをインストールしていても更新判定は行われません。

f:id:sakebook:20200826004923p:plain

internal app-sharingのアプリで、Version Codeが2以上ものを用意し、そのリンクを踏んで、GooglePlayアプリ上で更新があることを認識させてあげることで、アプデ可能判定フラグを建てることが出来ます。

  • internal app-sharingのVersion Code 1のリンクを踏んでアプリをインストール
  • internal app-sharingのVersion Code 2のリンクを踏んで「更新」ボタンが確認できたら更新をせずストアから離れる
  • アプデ可能判定フラグが立つ
  • internal app-sharingのVersion Code 1のアプリでin-app updatesの実装をしている画面を表示
  • アプデ可能判定になりin-app updatesのフローに入る

f:id:sakebook:20200826004951p:plain

つまり、既存のストアアプリとは別で、バージョン違いの内部アプリ共有のアプリを2つ用意することで既存のストアアプリに影響を与えることなく実際にin-app updatesの確認ができます。

f:id:sakebook:20200826005325p:plain

任意の識別しやすい名前をつけることが出来ます。ニュースダイジェストではまだAndroid App Bundle(AAB)に対応していませんが、もちろんAABでも可能です。

ニュースダイジェストでは、Immediateの方式で実装しました。

ニュースダイジェストでは、ユーザ一人ひとりに対して返事を行うことは現状していません。しかしユーザからのフィードバックには目を通しています。

フィードバックの中には、アプリを最新版にしてもらえれば直っているものとか対応している機能があったりするのにと思うケースもちらほらあります。

そこで今回は、フィードバックを送る画面でin-app updatesを組み込むことにしました。そうすることで、現状送ろうとしていたフィードバックはアプデによって解決されるかもしれないことを示せるようにしました。

なので、FlexibleではなくてImmediateを選択しました。

もちろん内容によっては解決されないものもありますし、そもそもin-app updatesの仕組みを導入したバージョン以降でしか機能しないです。

f:id:sakebook:20200826005132p:plain

今回のような例の実装であれば、1日あればできるので、気になった方は仕組みとしては入れておくと良いと思います。

きめ細やかな制御

今回は利用しませんでしたが、ストアに更新可能なアプリが配布されてから何日経過しているかという情報や、Google Play Developer APIと組み合わせることでアプリのバージョンごとにアップデートのpriorityの設定が可能です。

更新が頻繁であったり、FlexibleとImmediateを組み合わせたい場合などに有効だと思います。

まとめ

開発者としてはなるべく最新のアプリを使ってもらいたいです。そうすることがユーザにとってもメリットです。

適切に更新導線を組み込むことで、ユーザにストレスなく最新のアプリを提供できるようになります。

in-app updatesはその選択肢の一つになるでしょう。

参考

アプリ内アップデートをサポートする  |  Android デベロッパー  |  Android Developers

Support in-app updates  |  Android Developers

Internal app sharing | Google Play Console

Exploring in-app updates on Android | by Joe Birch | Google Developers Experts | Medium

Support In-App-Updates Implementation Example | by Rajan Maurya | Medium

AppUpdateInfo  |  Android デベロッパー  |  Android Developers