ついに最強のCI/CDが完成した 〜巨大リポジトリで各チームが独立して・安全に・高速にリリースする〜

OGP

こんにちは。SRE部の巣立(@ksudate)です。

我々のチームでは、AWS上で多数のマイクロサービスを構築・運用しています。マイクロサービスが増えるにつれて、CI/CDの長期化やリリース手法の分散など様々な課題に直面しました。

本記事では、それらの課題をどのように解決したのかを紹介します。

目次

はじめに

我々のチームが管理するCI/CDでは、ZOZOTOWNマイクロサービス基盤の全てのインフラリソースを対象にリリースまで行います。

インフラリソースごとに管理ツールが異なっており、全てGitHubにコードとして管理されています。

CI/CDで実行される処理は以下のようになっています。

インフラリソース 管理ツール CI CD
AWS CloudFormation aws cloudformation create-change-set aws cloudformation execute-change-set
Kubernetes Fluxcd kubectl diff kubectl apply
or
flux push artifact
Datadog
Sentry
PagerDuty
Terraform terraform plan terraform apply

CI/CDのこれまで

これまでに2つのリリース手法を利用していました。

  • Release PRによるリリース
  • ドメイン単位の並行リリース

Release PRによるリリース

マイクロサービス基盤の構築当初は、releaseブランチを利用してリリースしていました。

このリリース手法ではGitHubのPull Request(以下、PR)の作成・更新によってCIパイプラインが動作し、マージによってCDパイプラインが動作します。

開発用ブランチ(ここでは、featureブランチとする)からmasterブランチ宛のPRを作成すると、DEVELOP・STAGING環境のCIパイプラインが動作します。PRをmergeするとCDパイプラインによってDEVELOP環境・STAGING環境へデプロイされます。

masterブランチからreleaseブランチ宛のPRを作成すると、PRODUCTION環境のCIパイプラインが動作します。PRをmergeするとCDパイプラインによってPRODCUTION環境へデプロイされます。

PR CI/CDパイプラインの対象環境
feature -> master DEVELOP・STAGING
master -> release PRODUCTION

masterブランチからreleaseブランチへのPR(以下、Release PR)は自動で作成されます。既にRelease PRが存在する場合は、そのPRの変更内容に追加されます。

master-release

この手法のメリットは、リリース前の動作確認が可能でリリース手順も簡単という点です。

しかし、マイクロサービスが増えると以下の課題が生まれました。

  • CI/CD実行時間の長期化
  • マイクロサービスごとのリリースが難しい
  • リリーサーの制限ができない

CI/CD実行時間の長期化

CI/CDパイプラインでは、マイクロサービスの数だけ直列に処理を実行していました。

そのため、マイクロサービスが増えるにつれて、実行時間も増加していきました。

マイクロサービスごとのリリースが難しい

前述の通りreleaseブランチやRelease PRを全てのマイクロサービスで共有するため、マイクロサービスごとにリリースするのが難しいという問題がありました。

ロールバックする可能性のある変更は、他の変更と一緒にリリースしたくないことがあります。それを実現するには次の手順が必要です。

  • Release PRが存在するか確認。存在する場合、mergeする。
    • Release PRが存在するとその変更内容と同時にリリースされます。
  • 他メンバーにPRのmergeを停止するように連絡する。
    • Release PRをmergeするまでに他メンバーがmasterブランチ宛に別のPRをmergeするとその変更も含まれてしまいます。

この調整が大きな負担で、リリースサイクルの低下を引き起こしていました。

リリーサーの制限ができない

Release PRをmergeすることでCI/CDパイプラインによってPRODUCTION環境へリリースされます。

Release PRをmergeするには、1名以上のSREのレビューが必要です。

そのため、リリース可能なメンバー(以下、リリーサー)もSREに制限するのが理想です。

しかし、レビュー済みであればリポジトリへアクセスできる人は誰でもmergeできてしまいます。

上記の課題から新たなリリース手法を導入しました。

ドメイン単位の並行リリース

この手法ではマイクロサービスを大まかな機能や担当チームごとにグループ(以下、ドメイン)に分けます。そしてそのドメインごとにmainブランチとreleaseブランチを使ってリリースします。

以下は検索ドメインの例を示しています。

検索ドメインでは、Search APIとSuggest APIの2つのマイクロサービスを持ちます。また、利用するブランチはzozo-search-mainzozo-search-releaseとします。

parallel-release

この手法のメリットは、ドメイン単位で並行にリリースできることです。

以前は複数チーム間でリリースの調整が必要でしたが、ドメイン内のマイクロサービスは1つのチームが管理しているので、調整なしでリリースできるようになりました。

また、CI/CDパイプラインではドメイン内のマイクロサービスに対してのみ処理が実行されます。そのため、以前の方法に比べて大幅な高速化を実現しています。

しかし、この手法でもいくつかの問題を抱えていました。

  • リリース手法が分散する
  • ブランチ間の同期が必要
  • パイプラインの増加
  • CI/CD実行時間の長期化
  • リリーサーを制限できない

リリース手法が分散する

この手法ではマイクロサービスで利用するリソースのみをリリースの対象としました。

その他のリソース(ex. Cluster Autoscaler)は従来通りreleaseブランチを利用していました。

そのため、リリース手法が2つ存在することになり新規利用者を困惑させる原因となっていました。

また、変更内容によってはブランチを切り替えながら作業する必要がありました。

ブランチ間の同期が必要

複数のブランチから参照されるリソース、例えば作業用のスクリプトなどは、これまで通りmasterブランチやreleaseブランチで管理していました。

各ブランチで最新の内容を参照するために定期的に各ブランチを同期していました。この作業は毎週SREが実施していました。

しかし、各ブランチを同期するには複雑な手順が必要になります。この作業手順を間違えるとブランチの変更内容が消えたり、最新の状態と異なるものをリリースする可能性があります。

そのため、SREの大きなトイルとなっていました。

パイプラインの増加

この手法では専用のブランチごとにパイプラインが実行されます。そのため、ブランチごとにパイプラインを作成する必要があります。

パイプラインの大部分は同じ内容になっています。しかし、全てのパイプラインで利用しているカスタムアクションのアップグレードにも複数のファイルを修正する必要がありました。

またパイプラインが増加したことでCI/CDの渋滞が発生するようになりました。一度に大量のパイプラインが起動すると新しいパイプラインは他のパイプラインが一定数に落ち着くまで待機状態となります。その結果、CI/CD完了までの時間も大幅に増加していました。

CI/CD実行時間の長期化

Release PRによるリリースに比べて実行時間の高速化は達成しました。

しかし、ドメイン内のマイクロサービスが増えるにつれ実行時間が増加するため根本的な問題解決には至っていませんでした。

また、releaseブランチで稼働するCI/CDパイプラインは高速化できていませんでした。

リリーサーを制限できない

Release PRによるリリース同様にこの問題は解決していません。

CI/CDの刷新

これらの問題を全て解決するために、CI/CD基盤を新たにデザインすることにしました。

高速かつシンプルなCIパイプライン

まずは、CIパイプラインについて説明します。

ci

変更差分を利用したCIパイプラインの実行

新しいCIパイプラインでは、PRに変更のあるインフラリソースに対してのみ処理が実行されます。

directory-changes Jobでは、変更差分のあるインフラリソースのディレクトリ名を取得します。

変更差分の検知には、changed-filesを利用しました。このGitHubアクションを使うとPull Requestに変更のあったファイルやディレクトリを取得できます。

以下は、cloudformationディレクトリ配下に変更があった場合にそのディレクトリ名を返します。

  cfn-directory-changes:
    outputs:
      cfn_changed_files: ${{ steps.directory_changes.outputs.cfn_all_changed_files }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: List modified directories
        id: directory_changes
        uses: tj-actions/changed-files@v40
        with:
          json: true
          dir_names: true
          escape_json: false
          files_yaml: |
            cfn:
              - cloudformation/**
          dir_names_max_depth: 2
          base_sha: ${{ github.event.pull_request.base.sha }}
      - name: Echo modified directories
        run: |
          echo "${{ steps.directory_changes.outputs.cfn_all_changed_files }}"

ここで取得したディレクトリを後続のJobへ渡します。結果、Pull Requestに変更のあるインフラリソースに対してのみ処理が実行されます。

directory-changes Jobの導入によってマイクロサービスがどれだけ増えようとも実行時間が長期化することはなくなりました。

承認機能付きのCDパイプライン

続いて、CDパイプラインです。

cd

CDパイプラインでもCIパイプライン同様にdirectory-changesを活用しています。

また、新たにrelease-approvalconfirm-management-teamの2つのJobを追加しました。

2つのJobによる新しい機能について説明します。

GitHub Environmentsによるリリース制御

これまで利用していたreleasezozo-search-releaseなどのブランチを廃止しました。新しいリリース手法では、masterブランチのみを利用します。

しかし、リリース用のブランチが無くなるとリリース前の動作確認やリリースタイミングの制御ができません。

そこで、GitHub Environmentsを使うことにしました。

GitHub EnvironmentsはGitHub ActionsのJobに設定できます。

release-approval:
    runs-on: ubuntu-latest
    environment:
        name: <ENVIRONMENT NAME>

GitHub EnvironmentsにはいくつかのProtection Ruleが存在します。

今回は、Required reviewersを付与しました。Required reviewersを設定すると指定のレビュアーからレビューがあるまでJobは実行されません。

以下の例では、sre Environmentsの必須レビュアーにksudateが設定されています。

env

上記のsre Environmentsを使用した例がこちらです。

ksudateから承認があるまで、release-approvalのJobは実行されません。また、needsにrelease-approvalを指定しているk8s-applyも実行されません。

name: Release Gate
on:
  push:
    branches:
      - main

jobs:
  release-approval:
    runs-on: ubuntu-latest
    environment:
      name: sre
    steps:
      - run: |
          echo "release approved"

  # CI STEP
  k8s-diff:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "kubectl diff -f xxx"

  # CD STEP
  k8s-apply:
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == true
    needs: release-approval
    steps:
      - run: |
          echo "kubectl apply -f xxx"

gha

この機能によってPRODUCTION環境のリリース前にレビューを追加できました。

その結果、リリース前の動作確認が可能になり、リリースタイミングを指定のレビュアーが制御できます。

しかし、この方法はGitOpsを実現するKubernetesクラスタで問題があります。

マイクロサービス基盤のKubernetesクラスタはFluxcdでGitHubを参照してアプリケーションのデプロイを行なっています。

techblog.zozo.com

そのため、masterへmergeしたタイミングでクラスタへ同期されます。

この対策として、FluxcdのOCIRepositoryを利用しました。OCIRepositoryを利用することで、FluxcdはGitHubではなく任意のOCI Repositoryからマニフェストを取得します。

OCI RepositoryにはAmazon ECRを利用しています。このAmazon ECRにマニフェストが格納されるとFluxcdはその情報を元に同期を行います。

そこで、release-approvalの実行後にAmazon ECRへマニフェストを格納することでリリースのタイミングを制御できました。

他にもOCI Repositoryを利用するメリットはあります。

詳しくは「Kubernetes Meetup Tokyo #58」で発表した資料をご覧ください。

speakerdeck.com

release-approvalによって、以下の課題を解決しました。

  • マイクロサービスごとのリリースが難しい
    • release-approvalによってPRごとにリリースすることが可能になりました。
  • リリース手法が分散する
    • 全てのインフラリソースをmasterブランチを使ってリリース可能になりました。
  • ブランチ間の同期が必要
    • releaseブランチは廃止されました。ドメインごとにreleaseブランチ、mainブランチを管理する必要もありません。
  • パイプラインの増加
    • パイプラインのトリガーはmasterブランチのみで今後増えることもありません。

GitHub Environmentsによるリリーサーの制限

残る課題は、リリーサーの制限についてです。

リリーサーはどのファイルを変更したかによって変わります。例えば、Search API変更時のリリーサーはSearch Teamになります。Cart API変更時のリリーサーはCart Teamになります。

そこで、ファイルごとに管理するチームを設定しました。

これには、paths-filterを利用しました。

paths-filterを利用すると、事前に定義されたファイルの変更があったかどうかを知ることができます。

以下に例を示します。

confirm-management-team Jobはfiltersに定義された情報を元に変更を検知します。例えば、cloudformation/search-apiに変更があれば、${{ steps.filter.outputs.search-team }}がtrueを返します。

この情報をrelease-approval Jobに渡すことで特定のチームのリリースを必須にできます。

今回の例では、release-approvalはチームごとに作成しています。こうすることで、Search Teamが管理するファイルに変更があった場合にsearch-team-release-approvalを起動して、Search Teamのレビューを必須にできます。

name: Release Gate
on:
  push:
    branches:
      - main

jobs:
  confirm-management-team:
    runs-on: ubuntu-latest
    outputs:
      search-team: ${{ steps.filter.outputs.search-team }}
      cart-team: ${{ steps.filter.outputs.cart-team }}
    steps:
      - uses: actions/checkout@v3
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            search-team:
             - 'cloudformation/search-api'
              - 'kubernetes/search-api'
              - 'terraform/datadog/search-api'
            cart-team:
              - 'cloudformation/cart-api'
              - 'kubernetes/cart-api'
              - 'terraform/datadog/cart-api'  

  search-team-release-approval:
    runs-on: ubuntu-latest
    if: ${{ needs.confirm-management-team.outputs.search-team == 'true' }}
    needs: confirm-management-team
    environment:
      name: search-team
    steps:
      - run: |
          echo "search-team release approved"

  cart-team-release-approval:
    runs-on: ubuntu-latest
    if: ${{ needs.confirm-management-team.outputs.cart-team == 'true' }}
    needs: confirm-management-team
    environment:
      name: cart-team
    steps:
      - run: |
          echo "cart-team release approved"

  # CI STEP
  k8s-diff:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "kubectl diff -f xxx"

  # CD STEP
  k8s-apply:
    runs-on: ubuntu-latest
    if: ${{ ! ( failure() || cancelled() ) }}
    needs: 
      - search-team-release-approval
      - cart-team-release-approval
    steps:
      - run: |
          echo "kubectl apply -f xxx"

結果

実行時間は、導入前に比べると大幅な削減を実現しました。

既存のワークフローから段階的に移行しているため、正確な比較は難しいです。しかし、導入前後で1か月間の平均などを見ると以下のようになっていました。

Before (min) After (min)
Avg 9.45 2.00
Max 23.5 6.38
Min 0.37 0.28
Sum 1146 126

1か月のトータル実行時間は1/10程度に減少しており、平均も7分近く削減できました。

加えて、これまで抱えていた課題も全て解決できました。

さいごに

この記事では、拡大し続けるマイクロサービス基盤で直面したCI/CDの課題をどのように改善したのかを説明しました。

現在、新しいモノレポCI/CDは問題なく稼働しています。引き続き、より良い開発体験を提供できるよう改善を進めていきます。

ZOZOTOWNでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com

カテゴリー