電気ひつじ牧場

技術メモ

Ubieにおけるプラットフォームエンジニアリングの取り組み2023

Ubie Engineering Advent Calendar 2023 の22日目の記事では、Ubieのプラットフォームで生じていた課題と、それを解決するためにサービステンプレーティングツールを開発・導入した取り組みについて紹介します。

はじめに

アドカレ初日の記事で紹介があったように、Ubieではモジュラモノリスとマイクロサービスアーキテクチャの両方を採用しており、独立して動くサービス数は現在70近くに及んでいます。それらのインフラの大部分はGoogle Cloud上のGKEかCloudRunで動いており、その管理と運用が私の所属する基盤チームの責務になります。

新たなマイクロサービスを立ち上げる時は、基本的にコードを書くアプリケーションエンジニアがサービスの立ち上げに必要なTerraformやk8sマニフェストを書き、基盤チームのレビューを通して実環境にデプロイします。インフラにアプリケーション都合で変更を加えたくなった時も同様です。このようなコードを書いた人が(基盤やインフラチームの支援を受けつつ)運用まで行うというアイデアはDevOpsの原則とされ、近年のソフトウェアエンジニアリングでは良いプラクティスとして広く浸透しています。

一方で、そのようなサービスのインフラを扱うためには把握しないといけないことが多く、時間と気力を消費するといった課題がありました。具体的には次のようなものです。

  • 扱いたいサービスが存在するGCPプロジェクトやGKEクラスタが分からない
  • k8sをはじめとするクラウドネイティブ系のツールやプラットフォームの使い方を把握するのが大変
  • 何か不具合があった時に見るCIの履歴やログの場所を発見するのに時間がかかる

UbieではGCPのプロジェクトやGKEのクラスタはセキュリティ上の理由などからいくつかの単位に分割されています。このような状態の中で、アプリケーションエンジニアが適切なプロジェクトやクラスタを選択し、その上にベストプラクティスに沿った形でサービスを起動するのはなかなか骨の折れる作業です。また起動時だけでなく運用中であっても、ツールの使い方やログの見方がわからないといった問題が生じていました。

ここではいわゆる認知負荷(Cognitive Load)の増大が起こっており、それが素早いサービスの立ち上げと変更を妨げているという状態が起こっていたものと考えられます。さらに現在、各種サービスをドメインに沿って適切なクラスタへ配置し直すといった移行プロジェクトが走っており、今後ますますインフラの複雑度が増していく見込みがありました。

そこで、近年急速に注目を浴びているプラットフォームエンジニアリングのアプローチからこれらの問題解決に取り組みました。

プラットフォームエンジニアリングとは

ガートナーのブログでは次のように定義されています。

Platform engineering improves developer experience and productivity by providing self-service capabilities with automated infrastructure operations. It is trending because of its promise to optimize the developer experience and accelerate product teams’ delivery of customer value.

www.gartner.com

つまり、インフラオペレーションのセルフサービス機能を提供することで、開発者体験と生産性を向上させ、顧客に素早く価値提供できるようにするものがプラットフォームエンジニアリングです。特にセルフサービス機能を提供するというのがポイントです。これにより、アプリケーションエンジニアがインフラ部分を全てインフラ担当者へ依頼してやってもらう(DevOps以前に逆戻り)のではなく、また複雑さを増すクラウドネイティブ時代のツールやプラットフォームの扱いに疲弊するのでもなく、各開発組織の規模や事情に合わせて作られたツールやサービスを使い、自身でインフラの管理をすることができるようになるとされています。

日本語だと下記のスライドが参考になります。

speakerdeck.com

具体的な課題

「はじめに」で述べたとおり、Ubieでは大部分のサービスがGKEかCloudRun上で起動しています。これらのマニフェストはモノレポで管理され、全てのサービスの設定が1つのリポジトリに含まれています。

新しいサービスを立ち上げる際、ほとんどのアプリケーションエンジニアは既存のサービスを参考にしてTerraformやk8sマニフェストなどを作成していました。1からyamlを書くのは大変なのでそうするのが普通だと思いますが、これには次のような課題がありました。

  • k8sマニフェストをベストプラクティスに沿った形で書くのが難しい
    • 他サービスからコピペで作ると古いサービスのAPI versionが残ったり、不要なリソースが作られたりする
  • マニフェストの書き手によってスタイルや質がまばらになり、管理が難しくなる
    • PodDisruptionBudgetがあったりなかったり
    • メインとなるコンテナの名前がappだったりサービス名だったり
  • 基盤チームがレビューする際に確認すべきファイルや箇所が多く、抜けや漏れが生じる

解決法

サービステンプレーティングツールを開発し、それにマニフェストやCI/CDの設定を生成させることで解決を試みました。サービステンプレートというのは、サービスを起動するのに必要な設定一式を含むパッケージのことです。これにより最小限のパラメータから一貫性のある設定ファイルを素早く生成することが可能になります。サービステンプレートはセルフサービス機能を実現するものとして、プラットフォームエンジニアリングの一要素とされています。

このツールは社内でubieformと呼ばれているので、この記事でも以降そのように呼びます。

具体例

ubieformを使って新たなサービスを起動する具体例を示します。

  1. 以下のような設定を書きます。
name: "sample"
env: ["qa", "stg", "prd"]
segment: "sample-segment"
service_config: {
  github_actions: {
    pipelines: [
      {
        envs: ["qa"]
      },
      {
        envs: ["stg", "prd"]
      },
    ]
  }
  manifest: {
    app: {
      main_container: {
        image_path: "asia-northeast1-docker.pkg.dev/example/{{.Env}}/sample"
        image_tag: {{ if or (eq .Env "stg") (eq .Env "prd") }}"v1.0.0"{{ else }}"aabbccddee"{{ end }}
        args: ["serve"]
        ports: [
          {
            name: "http-web"
            container_port: 8080
          },
        ]
        readiness_probe: {
          path: "/health/readiness"
          port: "http-web"
        }
        config_names: ["sample-env"]
      }
      sidecars: [_alloydb_proxy]
      gcp_service_account: "jp-sample-app-{{.Env}}@example-{{.Env}}.iam.gserviceaccount.com{{- end}}"
      export_service: true
    }
    configs: [
      {
        name: "sample-env",
        values: [
          {
            key: "sample_DB_HOST"
            value: "127.0.0.1"
          },
          {
            key: "sample_DB_NAME",
            value: "sample"
          },
        ]
      }
    ]
  }
  backstage: [
    {
      description: "awesome service"
    }
  ]
  cloud_deploy: {
    pipelines: [
      {
        content: {
          envs: ["qa"]
        }
      },
      {
        content: {
          envs: ["stg", "prd"]
        }
      }
    ]
  }
}

_alloydb_proxy: {}

2.書いたコンフィグをubieformリポジトリ内の指定のディレクトリにコミットしてプッシュし、プルリクを作成します。

3.CIによって作成されるコメントから、作成されるマニフェストを確認します。

4.レビューが済みマージされると、マニフェストやCI/CDの設定がまとまってるモノレポへ以下のような新規生成ファイルを含むプルリクが作成されます。

sample
    ├── backstage/ENV.yaml    # ENVはqa, stg, prdに置換されそれぞれ生成される
    ├── clouddeploy/ENV.yaml
    ├── skaffold.yaml
    ├── app
    │   └── ENV
    │       ├── deployment.yaml
    │       ├── destinationrule.yaml
    │       ├── service.yaml
    │       ├── serviceaccount.yaml
    │       └── virtualservice.yaml
    └── config
        └── ENV
            └── configmaps.yaml
── .github/workflows/sample_ENV.yaml
── run/flow/production/config.yaml (update only)

5.モノレポに作成されたプルリクをマージすると、GitHub ActionsによってCloud Deployのパイプラインがキックされ、サービスが起動します。

このような手順を取ることで簡単にサービスを立ち上げることができます。上記の例はかなりシンプルなサービスの例で、他にもDBのマイグレーションジョブや、公開サービスに付随するadmin系のサービス、シークレット、HPA、IstioのAuthorizationPolicyなど様々なリソースを生成することができます。

実際にはGKE Workload Identityの設定をしたり、オブジェクトストレージやDBの作成が必要になることが多いため、Terraformを別途書く必要があります。Terraformのコードはubieformがプルリクを出すモノレポとは別のモノレポになっており、そこへのコード生成はまだサポートできていません。

さらに、インターネットから作成したサービスにトラフィックを流すにはIstioのGatewayに紐づくVirtualServiceを編集する必要があります。公開のタイミングはサービス生成のタイミングより後になるのが普通なので、ここは手作業で編集しています。

実装

ubieformを作るにあたって考えたポイントは次の通りです。

  • 設定記述の労力を、直接マニフェストを書く場合と比較して可能な限り減らす
  • 新たな認知負荷を最小限にとどめる
  • なるべく実際にデプロイする前にエラー要因を排除する

1つ目はubieformの存在意義なので明らかです。2つ目に関して。新たなツールを導入するわけなので、そこでまた別の認知負荷が生じるのは仕方のないことかもしれません。ただし認知負荷を下げるためのツールが認知負荷を大きく産んでしまうと元も子もないので、極力シンプルで簡単に扱えるツールにしなくてはなりません。3つ目はlintの機能ですが、k8sマニフェストとして正しいか、という以外にもUbieのサービスとして許容するかという組織ポリシーの観点も含めて扱える必要がありました。

CUE

前項のような要件を満たすため、ubieformに与えるコンフィグのフォーマットとして選んだのがオープンソースのデータ検証言語であるCUEです。CUEはJSONのスーパセットであるため見た目はJSONと似ていますが、型システムと制約を利用してデータ構造を定義することができます。CUEを選んだ理由は主に次の通りです。

  • スキーマが定義できる
  • 強力な型システムとバリデーションの仕組みを持つ
  • (コンフィグを書くだけなら)シンプルですぐに扱える

CUEではスキーマの定義と値に対するバリデーションを記述することで、与えられたコンフィグを評価することができます。さらに、スキーマの書き方を工夫すれば、ubieformユーザが与えるコンフィグはシンプルになり、CUEの知識がほとんどなくてもコンフィグが書けるようになります。

簡単に文法の説明をしておきます。以下のschema.cueは有効なCUEの記述の例であり、スキーマとして使うことができます。

config: #Config

#Config: {                                  // Definition
  name: =~ "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" // 正規表現で制約をつける
  env: [#Env, ...#Env]                      // Definitionを使う
  segment: *"shared" | string               // *はデフォルト値。
  container?: {                             // ?のあるフィールドは省略可能
    name: "app"                             // 定数決め打ち
    label: *[name] | [...string]            // 他のフィールド参照
  }
  #Env: "dev" | "qa" | "stg" | "prd"        // Definitionの入れ子
}

次のconfig.cueは先ほどのスキーマに対するコンフィグ*1です。スキーマの記述にあったような?*などの記号は登場しません。

config: {
  name: "ubieform"
  env: ["stg", "prd"]
}

これら2つのCUEから生成される値は次のようになります。

$ cue export schema.cue config.cue
{
    "config": {
        "name": "ubieform",
        "env": [
            "stg",
            "prd"
        ],
        "segment": "shared"
    }
}

先ほどのスキーマに対して次のようなコンフィグの書き方をすると全てエラーになります。

  • nameを省略する(?かデフォルト値が無いフィールドは全て必須)
  • env: ["staging", "production"]と書く
  • segnent: "shared"とフィールド名をtypoする

このようなCUEが持つ強力なバリデーションの仕組みによって、正確なコンフィグの記述が可能になります。

さて、これらを踏まえた上で以下にubieformのスキーマの一部を示します。

config: #Config

#Config: {
  name: =~ "^[a-z][a-z0-9-]{0,61}[a-z0-9]$"
  env: [#Env, ...#Env]
  segment: #Segment
  // the path to the directory where the manifests and configs are generated
  destination_dir: *"services/v3/\(name)" | string 
  service_config: close({
    platform: *"gke" | "run"
    // k8s manifest or cloudrun config settings
    manifest: close({
      if platform == "gke" {
        // main application config
        app?: #DeploymentResource
        // ...
        // migration job config
        migration?: close({
          main_container: #MainContainer
          sidecars?: [#Sidecar]
          // GSA email that associated with the KSA.
          gcp_service_account?: string & =~ "^[a-zA-Z0-9-]+@[a-zA-Z0-9-]+\\.iam\\.gserviceaccount\\.com$"
        })
        configs?: [#Config, ...#Config]
        secrets?: [#GKESecret, ...#GKESecret]
      }
      if platform == "run" {
        // app is required for run
        app: close({
          main_container: #MainContainer
          // ...
          cloudrun_cpu_throttoling: *false | bool
          cloudrun_startup_cpu_boost: *true | bool
        })
        // ...
      }
    })
  })
  
  #Env: "dev" | "qa" | "stg" | "prd"

  #Segment: "shared" | ... | ... | "other"
  
  #DeploymentResource: {
    init_containers?: [...#BaseContainer]
    main_container: #MainContainer
    sidecars?: [...#Sidecar]
    pdb: close({
      min_available: *"30%" | string
    })
    ...
  }
  ...
}

大部分を省略していますが、雰囲気は掴めると思います。例えば#DeploymentResourcek8sのDeploymentと対応しており、コンテナやポートの設定ができます。またubieformのスキーマk8sマニフェストに記述できる全てのフィールドを受け入れるわけではなく、サービスごとに固有の値を取りうる重要なフィールドだけを受け入れます。さらにそれらの設定可能なフィールドの中でも、一部の必須フィールドを除いて大部分がデフォルト値が設定してあるか省略可能であるため、多くのサービスで100~200行ほどのCUEファイルを書くだけで済みます。そこから1000~3000行ほどのマニフェストとCI/CDの設定が吐き出されるため、かなりの記述量を圧縮していると言えます。

CI/CD

ubieformはk8sマニフェストだけではなく、そのCI/CDの設定も生成することができます。ubieではpush型のリリース方式を採用しており、これまではGitHub Actionsから直接クラスタにapplyしていました。

一方、ubieformで生成したサービスではGitHub Actionsに加えてGCPのマネージドサービスであるCloud Deployを利用します。Cloud Deploy採用の経緯やメリットなどは同じチームの@sakajunqualityさんが記事を書いているのでそちらを参照ください。

medium.com

Cloud Deployを利用するには、DeliveryPipeline, Targetの設定に加えてSkaffoldの設定を与える必要があるため手で書くと少々大変です。しかしubieformがあればこのようなコンフィグでパイプラインを簡単に生成できます。

name: "sample"
env: ["qa", "stg", "prd"]
segment: "sample-segment"
service_config: {
  // ...
  cloud_deploy: {
    pipelines: [
      {
        content: {
          envs: ["qa"]
        }
      },
      {
        content: {
          envs: ["stg", "prd"]
        }
      }
    ]
  }
}

この設定ではqaのパイプラインと、stgとprdを繋げたパイプラインが生成されます。デプロイターゲットとなるGCPプロジェクトやクラスタの設定を書くこともできますが、ほとんどのケースでsegmentenvに対応して自動で決まるようになっているため、アプリケーションエンジニアがそれを気にする必要はありません。

Cloud Deployのコンソールに表示されるstg-prdのパイプライン

画像のようなprdの前にstgにデプロイが成功していることを保証するようなパイプラインを簡単に作ることができます。さらに、prdのデプロイの前にはIAMで管理されたApprovalが挟まれているため、特定の人以外がprdに対してリリースできないような仕組みになっています。

マニフェストの更新

k8sエコシステムは活発に開発が続けられており、アップデートに伴い便利な機能が使えるようになったり、リソースのapiVersionが上がったりといったことがよくあります。こういう時は複数のサービスのマニフェストを横断的に更新したくなるため、検索置換などを駆使してなんとかする必要がありますが、それでもフィールドの追加などはうまくいかない可能性があります。そこでubieformはこのような一括更新のユースケースもサポートするようにしました。

ubieform自体は内部にマニフェストのテンプレートを持っており、これをレンダリングすることでファイルを生成します。例えば先ほど紹介したCloud Deployを使う際に必要となるSkaffoldのテンプレートは次のようになっています。

apiVersion: skaffold/v4beta6
kind: Config
metadata: 
  name: {{.Name}}
{{- if .CustomActions}}
customActions:
  {{- range $action := .CustomActions}}
- name: {{$action.Name}}
  containers:
  - name: {{$action.Name}}
    image: {{$action.Image}}
    args:
    {{- range $arg := $action.Args}}
      - {{$arg}}
    {{- end}}
  {{- end }}
{{- end }}
profiles:
{{- range $profile := .Profiles}}
- name: {{$profile.Name}}
  deploy:
    statusCheckDeadlineSeconds: 600
    tolerateFailuresUntilDeadline: true
    {{- if $.IsCloudRun }}
    cloudrun: {}
    {{- end}}
  manifests:
    rawYaml:
     {{- range $path := $profile.Paths}}
      - {{$path}}
     {{- end}}
{{- end }}

このようなテンプレートからマニフェストなどを生成することで、フィールドに追加や更新をかける際はテンプレートに手を加えるだけで済みます。具体例の項で見せたように、全サービスのコンフィグを1箇所(ubieformのリポジトリ内)で管理しているのも、このような一括更新の時にまとめて実行しやすくするためです。

サービスカタログ

ubieformは、オープンソースのDeveloper PortalであるBackStageのサービスカタログ設定を生成することもできます。BackStage自体についての詳細な説明や導入の経緯については他の記事に譲りますが、これはサービスに関する様々な情報をまとめて確認できる開発者のための社内ポータルサイトであり、増え続ける認知負荷を軽減するために導入しています。

社内のBackStage
ubieformでサービスを作成すると、作成されたサービスカタログ設定が自動でBackStageにロードされ、ポータルから確認できるようになります。ポータルには各種Cloud Loggingへのリンクや、Cloud Deployのコンソールへのリンクなどがデフォルトで掲載されるので、GCPのプロジェクトを切り替えて目当てのページを探す手間が軽減されます。

サービスごとのリンク集

BackStage自体にはプラグイン機構があり、Kubernetesプラグインを導入することでPodのステータスが確認できるなど便利に利用できるようになります。この辺りのポータル機能の拡充は今後進めていく予定です。

ubieformの現在と今後

「はじめに」で述べたように、現在各種サービスをドメインに沿って適切なクラスタへ配置し直すといった移行プロジェクトが進行中です。その移行作業の1ステップとして、ubieformを使うことで既存のサービスを作り直すということをしています。こういったテンプレートツールは既存のサービスの載せ替えをやるのかどうかという問題が付き纏いがちですが、ubieformはこの用途が最初から決まっていたためそこの懸念はありませんでした。全社に提供する前に基盤チームでドッグフーディングできるという点にも大きなメリットを感じています。

ubieformもサービスカタログもまだ社内にベータ版としてひっそりと公開した段階で、まだ正式な周知もしていない段階です。新規サービスを立ち上げたいと問い合わせをしてきたエンジニアに使ってもらっているため、これから実際にどの程度開発効率に効果があったのかという定量・定性的な評価をしていければと思っています。

おわりに

本記事ではUbieにおけるプラットフォームエンジニアリングの取り組みとしてサービステンプレーティングツールであるubieformについて紹介しました。Ubieでは今後も引き続きDeveloper Experienceに投資を続けていきます。

Ubieでは各種ソフトウェアエンジニアを募集しています。

recruit.ubie.life

おまけ

人力100%で書いたのでAI無添加のステッカーを貼っておきます*2

*1:便宜上このような書き方をしていますが、CUEではこの2つは明確に区別されません。https://cuelang.org/docs/tutorials/tour/intro/cue/

*2:ChatGPT組み込みのDALL-Eで生成