Terraformプロジェクトの構造を考える

こんにちは! ジモティーにてインフラ開発・運用を担当している斎藤です。

ジモティーでは主にAWSを活用してシステムを作っているのですが、分析基盤としてBigQueryを利用するなど、一部の用途でGoogle Cloudも活用してきました。

今回、BigQueryにリアルタイムでログを蓄積していく必要があり、APIサーバなどのコンピューティングリソースもGoogle Cloudに構築する必要性が出てきました。

Google Cloudでインフラを管理するなら将来的な拡張も見据えてやはりTerraformか、、、? という考えに至り、実際にTerraformプロジェクトを1から立ち上げてみましたので、そのときに考えたことを共有したいと思います。

Terraformってなに?

パブリッククラウドのリソース(サーバ、ネットワーク、ストレージ等々)をコードで管理するためのIaCツールです。

Hashicorp社製で、HCLという独自言語によってインフラを宣言することができます。

例えば以下のような記述をすると、Google Cloud上にCloud Storageバケットをひとつ定義できます。

resource "google_storage_bucket" "static_contents_bucket" {
  name                        = "jmty-static-contents"
  location                    = "ap-northeast1"
  uniform_bucket_level_access = true
}

IaCツールにおいてはAWSだとCloud Formationが有名ですね。

最近はCloud Formationをより抽象化してより開発者がコーディングに集中できるようなAWS CDK(Cloud Development Kit)というものもあります。

TerraformがAWSのそれらと違うのは、プロバイダというライブラリを入手することで多種多様なインフラをコード化対象にできる点です。

AWS、Azure、Google Cloudなどの主要パブリッククラウドだけでなく、GitHubやHerokuなどのSaaS、PaaSに対してもプロバイダが提供されています。

提供されているプロバイダはこちらから一覧することが可能です。

プロジェクト構造

さて、Terraformでインフラを管理することを決めたのはいいのですが、まず何をすればいいのか。。。

Terraformプロジェクトの構成の仕方は基本的に自由です。拡張子を .tf にさえしておけば、どこでどんなリソースを定義することもできます。

ただしプラクティスは既に無数に存在します。

なんならGoogle自身がTerraformでインフラを管理する際のプラクティスも提供してくれています。

重要な点は一つのディレクトリが「モジュール」として扱われる点です。

基本的にデプロイはモジュール単位で行われ、モジュールの中で別のモジュールを呼び出すこともできます。

このような特徴から、Terraformプロジェクトでは以下のようなプロジェクト構造を採用しました。

結果、プロジェクトは大きくわけて modules ディレクトリと environments ディレクトリを持つ形に落ち着きました。

.
├── README.md
├── environments → ★各環境ごとに設定値を格納したファイル群
│   ├── global → ●環境間共有リソース
│   │   ├── common
|   |   ...
│   ├── prod → ●本番環境
│   │   ├── api_server
|   |   ...
│   └── stg → ●検証環境
│       ├── api_server
|       ...
└── modules → ★各システムごとのリソース定義ファイル群(モジュール)
    ├── api_server
    │   ├── enable_apis.tf
    |   ...
   ...

以降で modules ディレクトリと environments ディレクトリについて説明します。

modulesディレクトリ

modules ディレクトリには各環境を構成するシステム単位毎に、Google Cloudリソース群がモジュールとして定義されています。

例えば api_server モジュールにはAPIサーバとそれが動くために必要なインフラ、 management_console モジュールには管理画面のインフラを定義するためのリソース群が定義されています。

木構造で表すと、以下のような構造になっています。

modules/
├── api_server
│   ├── enable_apis.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── provider.tf
│   └── variables.tf
├── management_console
│   ├── enable_apis.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── provider.tf
│   └── variables.tf
...
└── networking
    ├── enable_apis.tf
    ├── main.tf
    ├── outputs.tf
    ├── provider.tf
    └── variables.tf

各モジュールは以下のようなファイル群で構成するようにしました。(各モジュールによっては、利用しないファイルも存在します)

  • enable_apis.tf
    • モジュールの実行に必要なGoogle CloudのAPIの有効化を行う
  • main.tf
    • モジュールの実行によって作成したいGoogle Cloudリソースを定義する
  • outputs.tf
    • モジュールの出力を定義する。モジュール実行の戻り値(出力インタフェース)として機能する
  • provider.tf
    • モジュールで利用するTerraformプロバイダを宣言する
  • variables.tf
    • モジュールで利用する変数を定義する。モジュール実行の引数(入力インタフェース)として機能する

environmentsディレクトリ

modules ディレクトリでは環境固有の設定値を引数にしたモジュールを定義していました。environments ディレクトリでは modules で定義したモジュールに対して、各環境の設定値を入力として与えモジュールをインスタンス化します。

結果としてGoogle Cloudにリソースをデプロイすることができます。

stg 環境、つまり検証環境を例にとると以下のような構成になっており、各モジュール名のディレクトリで実際にモジュールがインスタンス化されるようになっています。

environments/stg/
├── api_server
│   ├── backend.tf
│   ├── data.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── terraform.tfvars
│   └── variables.tf
├── common
│   ├── backend.tf
│   ├── outputs.tf
│   ├── terraform.tfvars
│   └── variables.tf
├── management_console
│   ├── backend.tf
│   ├── data.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── terraform.tfvars
│   └── variables.tf
...
└── networking
    ├── backend.tf
    ├── data.tf
    ├── main.tf
    └── outputs.tf

各モジュールは以下のような構成を取るようにしました。

  • backend.tf
    • Terraformステートを格納するTerraformバックエンドを宣言する
  • data.tf
    • 依存する別モジュールを宣言する。例えばサーバをデプロイするためにnetwork名が必要になるため、 networking モジュールに依存するなど。ここで定義したモジュールの出力を使って main.tf でさらにインフラを定義する
  • main.tf
    • モジュールをインスタンス化する宣言をする
  • outputs.tf
    • モジュールの出力を all というキーに全て詰め込む宣言をする。これにより外部モジュール側の data.tf で利用宣言すると、もれなくこのモジュールの出力を利用させることができる
  • terraform.tfvars
    • variables.tf に実際に渡す値を書く
  • variables.tf
    • モジュールで利用されている変数を定義しておく
      • モジュール側と環境側で二重管理になってしまっているが、モジュール側の variables.tf はあくまで環境に依存せず必須にしたい変数を必須宣言しておき、環境によっては任意にしたい変数にはデフォルト値を付与して任意宣言としてある。これにより、モジュール側は必須パラメータだけを最低限定義すればよく、環境によるチューニングは任意パラメータで実施することが可能になる

commonモジュール

各環境には modules ディレクトリにはないモジュールである common モジュールが定義されています。

これは特殊なモジュールで main.tf を持たないモジュールです。

つまり common モジュールをインスタンス化することによってGoogle Cloud上にリソースがデプロイされることはなく、Terraformステートだけがデプロイされることになります。

commonモジュールの役割は、各システム間で値を共有することです。

commonモジュールの出力にあらかじめ環境内の各システム間で共有したい値(環境名など)をあたえておきます。

これにより各モジュールは common モジュールに依存すればそれらにアクセスすることができるようになり、先の役割を全うするようになっています。

global環境

environments ディレクトリには global 環境が定義されています。

これは各環境に直接属さない、環境間共有のリソースを定義する環境として利用されます。

例えばIAMを管理する project_members モジュールなどがこの環境でインスタンス化されます。

構成自体は他の環境と全く同様です。

最終的なデプロイ方法

結果として、以下のように modulesenvironments が宣言され、Google Cloudリソースが生成されることになります。

実際に terraform apply を実行するのはデプロイしたい環境のシステム単位ごとに行うようにします。

まとめ

この記事ではTerraformを使ってプロジェクトを構成する際に弊社で考えたことを書きました。

以下が肝だったのではないかと考えていますが、この考え方はデプロイ先がGoogle Cloudに限らず、適用できる考え方かと思います。

これからTerraformプロジェクトを立ち上げようとする方の参考になれば幸いです。

  • モジュールには環境固有の設定値を入れないことで、環境の複製を容易に、かつ環境差異が生じにくいようにした
  • モジュールの入力(variable) と出力(output) を意識しモジュールに対するインタフェースと捉えることで依存関係を明確にし、かつ一方向にのみ依存するように整理した

今回の共有は以上です。今後もテックブログを定期的に更新し、ジモティーの開発事例やプラクティスを共有していきたいと思います。

次回もまたお楽しみに!

Androidアプリのマルチモジュール化

はじめに

初めまして、ジモティーで Android アプリを担当している谷です。

最近マイクラにハマってしまい、やることが無限すぎて困っています。

今回は弊社の Android アプリをマルチモジュール化したのでそのお話をしたいと思います。

導入の背景

まずは弊社の Android アプリの現状の構成について説明します。

弊社の Android アプリは MVVM + CleanArchitecture を取り入れ、3層のレイヤードアーキテクチャで構成しています。

  • Presentation 層
  • Domain 層
  • Data 層 (Infra層)

上記を1つのモジュール内で管理しており、各層はパッケージで切り分けているため、参照の方向に強制力はありませんでした。

なので、実装担当者が参照の方向に気をつけながら実装する必要があり、本来の目的以外のところに注意を払う必要があり、厳しいさを感じてました。

そこで、マルチモジュールを取り入れることで、アーキテクチャをモジュールで表現することができるようになると考えて、上記課題を解決できるのではないかと思い導入を決めました。

また、各モジュールの依存関係を整理していくことで、ビルド時間の短縮の短縮も見込める可能性があるため、その点でも開発生産性の向上に期待できそうというのも決め手の一つとなりました。

分割方針

長い歴史のあるアプリで、コードベースも巨大なため、1回で理想的な形に変更できないと判断しました。

そこで、以下の点を考慮して方針を決めました。

  • 学習コストを低くする
  • 今後より改善しやすい構造であること

特に 学習コストを低くする という点を意識するために、現状のアーキテクチャ構造に近くなるようにモジュールを分割する形としました。

イメージ図

パッケージ構成

最終的なパッケージ構成は以下です。

モジュールには * をつけています。

(data パッケージ内の entity は CleanArchitecture 上の entity ではなく、 Gson によるレスポンスのマッピングを行うクラスを格納するモジュールとしての命名となっています。命名ミスっているので本当は変更したいところですが。。。)

├── app*
├── data
│   ├── repositoryimpl*
│   ├── data*
│   ├── entity*
│   ├── mapper*
│   ├── network*
│   └── database*
├── domain
│   ├── repository*
│   ├── model*
│   └── usecase*
└── utility*

解説

全てを説明するのは難しいため、ポイント部分の解説です。

  • 基本構成は app / domain / data としました。

  • app モジュールには Application クラスが入っており、全てのモジュールを参照する形になっています。

    • 本来、Application クラスを分離するべきですが、Application クラスを一時的なデータの保存場所として使っており、修正するには時間がかかりそうだったためこの形にしました。
  • utility モジュールは全モジュールが共通で使う、以下のようなクラスを格納しています。

    • util
    • helper
    • constant (定数などを定義しているクラス)

マルチモジュール化の結果

  • ⭕️アーキテクチャを仕組みで表現できるようになった

    • 適切な依存関係を設定することができるようになったため、アーキテクチャの強制力を働かせて開発を進めることができるようになり、実装者が意識しないといけない部分が減ったため、開発生産性が向上した。
  • ❌一方で、ビルド時間の短縮に対しては効果が薄かった。

    • フルビルド時のビルド時間が若干増加してしまいました。

工夫した点

  • マルチモジュール化によりどのくらい改善が図れたかを調べるため、gradle-profilerを使用して計測を行うようにしました。

    • マルチモジュール修正頻度よりも多く計測を行う必要はなさそうと判断したため、計測頻度は1週間に1回行うようにしました。
  • マルチモジュールの移行率を計測することでモチベーションを上げるようにしました。

    • 最初はちょっとずつそれぞれのモジュールへ移行していたのですが、数が膨大だったため、少しずつでも移行されている実感を得られないと続かないだろうと考えて計測を行うようにしました。 (チームの方にも手伝っていただき、移行が加速しました!)
  • ConventionPlugin導入

    • モジュールを作成する中で同じプラグインやライブラリを記述していく必要があり、それぞれのライブラリの管理に関してのオーバーヘッドが発生することが想像されたので、共通で使える形にしたいと考えて導入しました。
  • モジュールの作成手順の作成

    • 今回のマルチモジュール化では、学習コストを低くするというのも大事な要素だったので、モジュールの手順書を作成して、チームに展開後、作成した手順書を使ってハンズオンを行いました。

残課題

  • Application クラスを application モジュールとして切り出すことができなかったので分離を進める。
    • 画面上で生成したインスタンスの一時保存クラスとして Application クラスを使用してしまっているため、修正工数がかなりかかってしまうのと、安全にマルチモジュール化が行えないと判断して、app モジュールはそのままにするという形にしました。

最後に

今回はジモティーの Android アプリのマルチモジュール化について紹介させていただきました!

移行したばかりでまだまだこれからという状況ですが、日々改善していければと思います!

【iOS】広告の事前読み込みチャレンジ

はじめに

どうも。

ジモティーでiOSアプリ開発チームのマネージャーをやらせてもらっている、ていです。

前回の記事で弊社のiOSアプリにアーキテクチャを導入した過程の計画編をお届けしたので順番的には導入編をお届けするのが筋かと思いますが、気分が乗らないので少し前に広告の取得表示改善に取り組んだ話をしようと思います。

広告の課題

弊社の広告は所謂アドネットワークから配信される広告をSDKを通じてアプリに表示させるよくある手法を取ってるのですが、課題はたくさんあります。

私自身ジモティーに入社して7年目を迎えておりますが、この7年は広告(SDK)との戦いの歴史だと言っても過言ではないと自分では思ってます。

たくさんある課題の一つに広告の表示が遅く、ユーザさんが広告を目にする前にスクロールやページ遷移で広告表示画面から離れてしまうというものがありました。

そもそも見栄えが悪いのもありますし、弊社としても売上の機会損失につながっている状況です。

事前読み込み方式のチャレンジ

広告はリクエストを送った後、Biddingと呼ばれる入札が行われてその時点で収益効果の高い広告を返す仕組みになっている関係上リクエストからレスポンスまで一定の時間がかかってしまい、さらに(Admobの場合)カスタムイベントをたくさん設定しているとその分さらに時間がかかるということがあるので、どうしてもページ表示から広告表示までタイムラグが発生してしまいます。

弊社のアプリは特にそのタイムラグが大きく、レスポンスが返ってくるまでは広告表示枠が白板状態でぽっかりと空いてしまい非常に見栄えが悪い状態になっています。

そこで以下のような考え方で改修を行なってみることになりました。

1. 現在の広告取得後の表示タイミングで次に表示する予定の広告を取得しておいて、端末内に保存する

2. 次の広告表示タイミングでまず事前取得した広告を表示し、さらにその次に表示する予定の広告を取得しておいて端末内に保存する

3. こうしておけば初回リリース以降は(広告取得失敗がない限り)常にほぼ待ち時間0で広告を表示することが可能になる。

実際のソース(一部抜粋)

キャッシュクラス

class AdCacheHelper {
    private static var preloadAd: PreloadAd?

    static func getAdView() -> GADBannerView? {
        Self.checkAdGetTime()
        let localData = Self.preloadAd
        Self.removeAdView()
        return localData?.adView
    }

    static func setAdView(_ adView: GADBannerView) {
        Self.preloadAd = PreloadAd(createdAt: Date(), adView: adView)
    }

    // 取得から1時間経過した事前読み込み広告は破棄
    private static func checkAdGetTime() {
        if let preloadAd = self.preloadAd {
            let adLimitDate = Calendar.current.date(byAdding: .hour, value: 1, to: preloadAd.createdAt)!
            if adLimitDate < Date() {
                Self.removeAdView()
            }
        }
    }

    private static func removeAdView() {
        Self.preloadAd = nil
    }
}

struct PreloadAd {
    let createdAt: Date
    let adView: GADBannerView
}

呼び出しがわ

    public func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
        if let adCacheableViewController = self.rootViewController as? AdLoadable {
            switch self.loadStatus {
            // 事前取得表示済みの場合と再取得後はキャッシュ保存してリフレッシュ待機状態にする
            case .preLoaded, .reRequested:
                AdCacheHelper.setAdView(bannerView)
                self.loadStatus = .refreshing
            // 初回起動時 or 破棄されてる場合 or 異常として広告がない場合は取得した広告を表示して事前読み込み用の広告を再取得
            case .firstLoading:
                adCacheableViewController.loadAd(bannerView: bannerView, admobUnitId: self.admobUnitId)
                self.loadStatus = .reRequested
                self.requestAds()
            // リフレッシュはそのまま表示するのみ
            case .refreshing:
                adCacheableViewController.loadAd(bannerView: bannerView, admobUnitId: self.admobUnitId)
            }
        }
    }

※1 実装に関する指摘コメント歓迎です!

※2 ただしあまりに厳しい言葉だと実装者のメンタルに響きますのでお手柔らかにお願いします。

※3 実装に関してzaimさんのブログを勝手に参考にさせていただきました。

結果

結果としては見栄えの部分ではある程度改善されたのですが、数字上の効果はあまり改善が見られませんでした。

仮説としてはそもそも広告の取得が遅すぎて、事前読み込み完了前にページ遷移などで広告リクエストが破棄されてしまいいつまで経っても事前広告がキャッシュされないパターンが多いのでは?というものが有力そうです。

最後に

この経験を踏まえ今後は事前読み込み方式を一度に複数読み込んでおくとか、そもそもBiddingの速度を上げられないか?など多岐にわたる改善方法を検討して進めていく予定です。

広告を扱われていて速度改善で良い成果を挙げられた方などいらっしゃいましたら、是非お知恵をお貸しいただけると幸いです。

またはジョインしていただき一緒にジモティープロダクトを改善していただけるとよりハッピーなので、よろしくお願いします。

CodeBuild始めました

ジモティーでインフラとバックエンドを担当している鈴木です。最近は貝出汁ラーメンをよく食べてます。美味しい。

ジモティーにCodeBuildを導入しましたので、背景や工夫した点などを紹介します。

CodeBuildとは

AWS CodeBuildは、AWSが提供するフルマネージド型のビルドサービスです。このサービスを利用することで、ソースコードのビルド、テストの実行など、ビルドプロセスを自動化することができます。 サーバーレスなサービスであり、ビルド実行の度に新たな環境が自動的に作成されるため、ユーザーは事前にビルドサーバーを準備しておく必要がありません。

背景

ジモティーでは、Rails、Next.jsといったアプリケーションサーバーがECS Fargate上で稼働しており、FargateにデプロイするDockerイメージは、元々はJenkinsを使用してビルドしていました。

しかし、Jenkins上で同時に複数のビルドを行うと、メモリ不足に陥るケースがありました。

さらに、新たな問題として、RailsやNext.jsのコンテナをARMアーキテクチャに置き換えることを計画していました。これにはARM向けのイメージをビルドする必要がありますが、イメージのビルドは一般的にホスト環境のCPUアーキテクチャに依存します(※)。弊社のJenkinsサーバーはx86アーキテクチャのインスタンスで構築されているため、ARM向けのイメージをビルドすることができませんでした。

こういった一連の課題を解決するため、ジモティーではCodeBuildの導入を決定しました。これにより、ホスト環境に依存せずに、各アーキテクチャのイメージをビルドできるようになります。また、CodeBuildはビルドごとに独立した環境で実行されるため、ビルドの同時実行によるメモリ不足といったリソースの問題も解決できます。

※ Docker Buildxプラグインを使うことでマルチアーキテクチャでのビルドが可能ですが、今回はリソース問題も解決できるCodeBuildの導入を選択しました。

工夫した点

Jenkins環境でDockerイメージをビルドしていた際には、Dockerビルドの結果がキャッシュとしてローカルに保存され、次に同じイメージをビルドする際にビルド時間を短縮することができていました。

一方、AWS CodeBuildはビルドごとに新しい環境を作成し、ビルドが完了するとその環境は破棄されます。そのため、ビルド間でイメージキャッシュを共有することができません。

キャッシュによるビルド時間の短縮はデプロイプロセスの効率において重要だったため、ECRに保存している前回のイメージを --cache-from オプションで指定することでキャッシュを有効にしました。

# 前回のイメージをECRからpull
- docker pull XXXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/${REPOSITORY_NAME}:latest

# 前回のイメージを --cache-from オプションで指定
- docker build -t ${REPOSITORY_NAME} . --cache-from XXXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/${REPOSITORY_NAME}:latest 

この方法では、前回のイメージをECRからpullするため、通信のオーバーヘッドは発生しますが、このユースケースではビルド時間の短縮幅に比べるとはるかに小さいものだっため許容しました。

まとめ

  • ジモティーのアプリケーションサーバーはFargateで構築されており、デプロイするDockerイメージをJenkins上でビルドしていた
  • 以下の課題をCodeBuild導入により解決した
    • ビルドの同時実行によるリソースの枯渇
    • ビルドがホスト環境のCPUアーキテクチャに依存するためARM用のイメージビルドが難しい
  • CodeBuildではローカルのイメージキャッシュを使えないというデメリットがあるがECRからpullしたイメージをキャッシュ指定することで解決できた(pullの通信によるオーバーヘッドは発生する)

この記事では、ジモティーにおけるCodeBuild導入の背景やそのメリット、そして工夫した点について解説しました。CodeBuildを活用することで、様々なアーキテクチャに対応したビルド環境を効率的に構築することができます。今後もテックブログでジモティーの開発事例や取り組みをご紹介していきますので、どうぞお楽しみに!

openapi.ymlのコンフリクト解消術

ジモティーでサーバサイドとインフラを担当している熊谷です。

今回はエンジニアグループ内で問題視されていた、openapi.ymlファイル競合(コンフリクト)問題を解決した事例についてご紹介します。

ジモティーのAPI開発

ジモティーのAPI開発では、仕様の共通化を目的としてOpenAPI を導入しています。

OpenAPI は公式ドキュメントREST APIのためのAPI記述形式として説明されており、定義ファイルをYAMLやJSONで記述することができるものです。

定義ファイル編集時はOpenAPI の GUI 定義エディタとして提供されているStoplight Studio ( バージョン2.10.0 )を使用することで、仕様の把握にかかる時間を短縮し、直感的な記述を行えるようにしています。

課題

OpenAPIの定義はyamlを1ファイル用意し、gitで管理していました。

しかし同時に編集する開発メンバーが増えたことの影響で、ファイルのコンフリクトが頻繁に発生するようになってきました。

# 開発メンバーの声
Aさん
OpenAPI のファイルはコンフリクトしやすいのが課題ですね。。。

Bさん
openapiのymlのコンフリクト解消したら関係ないpathの定義が壊れてるの腹立つ、、

このような課題感から解決策検討を行いました。

問題解消のために試してだめだった方法

ウェブでopenapi.ymlファイルの分割方法を検索すると、ファイル分割によるコンフリクト解決案がいくつかヒットします。

解決案の1つとして、以下のようなフォルダ構成を用意し分割を試しました。

Path とSchemaを分割する

root
│  openapi.yaml
│  
├─build
│      openapi.yaml
├─paths
│      users.yaml
│      user_settings.yaml
│      
└─schemas
        User.yaml
        Post.yaml
        Error.yaml

OpenAPIの用語としてpaths とschemasは以下のように説明されています。参考

paths
パスはAPIが公開する/usersや/reports/summary/などのエンドポイントのことで、これらのパスを操作するためのHTTPメソッドと一緒に記述します。

schemas
API で使用されるデータ構造を記述します。

各ファイルが分割された状態だとStoplight Studioで仕様の確認が行えないため、swagger-cli を使うことで、分割したファイルを結合します。

※ 結合ファイルは、build/openapi.yaml を指定し生成しています。

分割されたopenapi.yml を直接編集することでAPIの仕様を記載する場合は、こちらの方法でコンフリクト問題を解決できます。

しかし前述の通りジモティーのAPI開発では、openapi.ymlの編集にStoplight Studioを使用しています。

Stoplight Studioはopenapi.ymlが分割された状態では一部正しく編集を行えないためファイルは結合する必要があり、結合したファイルはStoplight Studioで編集後にコンフリクトを防ぐために分割する必要があります。

ファイルを結合するツールはswagger-cli が用意されていますが、結合されたファイルを分割するツールは見つからず、準備に時間がかかる可能性もあったため他の方法も模索することにしました。

Path 順、Schema順にぞれぞれソートする

そもそもopenapi.ymlファイルがコンフリクトする原因はPathやSchemaの最終行付近で編集が集中するからであり、PathやSchemaをそれぞれソートすることができればコンフリクトを防げる可能性は高いです。

これを踏まえ、pre-commit を導入し、commit 直前にPathとSchemaをそれぞれ確認し、ソートする方法を試しました。

pre-commit はコミットごとにスクリプトを実行し、コード上の問題を自動で指摘できるようにするツールでgit commit 時にPath とSchemaをソートするために使用します。

大まかな処理の流れ

1. 開発者がopenapi.ymlファイルの内容を追加・編集する
2. 変更内容を git commit する
3. pre-commit により.git/pre-commit ファイルに記載の処理が実行される
4. .git/pre-commit の中でソート用の 処理ファイルを実行する ( ジモティーでは ruby ファイルでソートしている)
5. 処理ファイルの中でopenapi.ymlファイルに記載されている内容をPathごと、Schemaごとに格納し、ruby のsort メソッドで処理を行う 

この方法であればopenapi.ymlファイルを分割しないため、Stoplight Studioを使用しつつコンフリクトを抑えることができます。

似た名前のPath が同時に作成された場合にまたコンフリクトが発生する可能性もあるとは思いますが、コンフリクトは発生しなくなりました。

まとめ

今回はOpenAPIのymlファイル コンフリクト防止案検討についてご紹介しました。

openapi.ymlファイルのコンフリクト問題の解決策の一つとして、この記事が少しでも参考になれば幸いです。

Google Search Consoleでsitemap.xmlの検出URL件数が0件になる問題を解決した話

こんにちは、ジモティーエンジニアチームの山口です。

主にフロントエンド面を担当しております。

現在はジモティーWeb版のフロントをNext.jsに移行する開発を日々進めています。

今回は、フロントエンドとは直接関係ない話ですが、SEO対策の一環として行なっていた作業の中で遭遇した問題についてご紹介します。

問題の概要

ジモティーではGoogle Search Consoleから、Railsで生成したsitemap.xmlファイルとそのindexファイルをいくつか登録しています。

sitemap_index.xml
├── sitemap_1.xml
├── sitemap_2.xml
└── sitemap_3.xml

しかし、ある時から下記画像のように、読み込みには正常に成功するものの検出URL件数が0件となってしまうファイルが発生するようになりました。

Search Consoleから再読み込みをかけても変わらず0件のままであり続けたため、新規ページを作成してもサイトマップからインデックスさせられず、施策が行いにくい状況となってしまいました。

問題解消のために試してダメだったこと

このサイトマップが0件になる問題自体はネット上でも同様の症状が報告されており、その回避方法の情報が散見されました。

情報を元に試してみて改善に至らなかったことを4つ紹介します。

path名を短くしてみる

一定文字数以上(20文字以上?)のファイルやpathになると、この現象が発生する可能性があるらしいとのことで、試してみました。

弊社のsitemap.xmlは種類が多いため、50~100文字程度のpath名が設定されているものがほとんどでした。

これらをシンプルなよくあるsitemap.xmlなどの命名に短縮し、20文字以下ぐらいのpath名に収まるようにしました。

path名に特定の文字を使わない

Search Consoleに登録しているsitemap.xmlのpath内に"-"(ハイフン)を使うと、この問題が発生することがあるとの記事を見かけたため、シンプルにアルファベット以外の文字列が一切入らないpath名にしてみました。

生成していたxmlの形式が適切であるかを確認

sitemap.xmlのファイルの形式が間違っている可能性も考え、sitemap.xmlの形式をチェックできるツールにも通してみました。

残念ながらこれも問題なしという結果でした。

生成したxml内の不要な改行を削除

タグとタグの間など一部に改行が入っている場合にもこのバグを引き起こす可能性があるとの記事をみて試してみました。

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
...

<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
...

などにしてなるべく改行が入らないようにしてみましたがこれも効果がありませんでした。

問題解消に至った方法

ネットの情報で改善に至らず困っていたのですが、同じjmty.jpドメインで登録しているsitemap.xmlのうち、RailsからではなくWordpressから登録していたものだけは正常に読み込みできていることに気づき、そのファイルとの違いを比較することで原因に当たりをつけることができました。

弊社のsitemap.xmlはAWSのS3にアップロードして運用を行なっていたため、Google Search Consoleへの登録自体はhttps://jmty.jp/sitemap.xmlhttps://jmty.jp/sitemap_index.xml のURLでしていたものの、このURLにアクセスした際にはCloudFrontドメインのURL https://〇〇.cloudfront.net/sitemap.xmlhttps://〇〇.cloudfront.net/sitemap_index.xml などのURLへリダイレクトされるように設定されていました。

この設定自体はこれまで正常に機能していたため問題を引き起こす原因とは考えていませんでしたが、試しにこのリダイレクト設定を外して直接S3上のファイルを Google Search Consoleへ登録していたURLhttps://jmty.jp/sitemap.xmlhttps://jmty.jp/sitemap_index.xmlのURLでアクセスできるように設定したところ、検出URL件数が正常にカウントされるようになりました。

まとめ

今回は、SEO対策の一環として行なっていた作業の中で遭遇した問題とその解決策についてご紹介しました。

この問題は以前sitemapxmlの調整を担当していた前任者の方の時から発生しており半年ほど解決できずに匙を投げかけていた問題でしたが今回ようやく改善に至れて一安心しました。

ファイル自体に問題があるかのように見えて、実際は環境起因の問題だったということで原因が分からずに手詰まりになったときは、視野を広げる意識が大事だと理解させられる一件となりました。

この記事が同じ問題を抱えている方の手助けになれば幸いです。

Danger のコマンドインジェクション問題を発見して修正した

ジモティーでサーバサイドとインフラを担当している吉田です。

この前、初めて献血に行ってきました。直前の検査で手汗に気づいたスタッフから「初めてで緊張されていますか?今ならまだ止められますよ。」と声をかけられたのが、今年一番緊張した出来事です。2023年はいろんな新しい事にチャレンジする年にしたいと思います。

さて、今回は Danger の問題を発見し、報告および修正した事例を紹介します。Danger には JavaScript 版Swift 版もあるのですが、ここでは Ruby 版について記載します。また、Danger のメンテナである Orta Therox 氏には、公開の許可を頂いています。

概要

バージョン 8.6.0 より前の Danger では、細工された名前のブランチで実行されると、任意のコマンドを実行できる問題がありました。すでに 8.6.0 以降では修正されています。

Danger は主にリモートの CI 環境で実行されるので RCE (Remote Code Execution) もしくはコマンドインジェクションの脆弱性に該当する可能性があると考えますが、Security Advisory の作成や CVE の申請はありませんでした。

Danger とは?

Danger は CI 上で、特定のルールにしたがったコードレビューを自動化するためのソフトウェアです。

例えば、プラグインと連携することで RubocopBreakman といった静的解析ツールによる指摘事項を GitHub Pull Request にコメントする事が可能です。一部の制限はあるものの、GitHub だけでなく GitLab や Bitbucket などの SCM (Source Control Management) でも動作しますし、JavaScript や Swift など他のプログラミング言語にも対応しています。

問題

環境

ここでは次の環境を想定しています。

  • Danger のバージョン
    • 8.5.0
  • SCM
    • GitHub(非 Enterprise)
  • CI 環境
    • GitHub Actions (GitHub-hosted runner)
    • Runner Image は ubuntu-22.04

準備

適当なリポジトリを準備し、名前を ;PATH=$PWD;evil.sh; としてブランチを作成します。

$ git switch -c ';PATH=$PWD;evil.sh;'

次に、任意のコマンドを実行するシェルスクリプト evil.sh を作成します。

#!/bin/bash

export PATH=$PATH:/sbin:/bin:/usr/bin

# 任意のコマンドの例
echo "evil" > evil.txt

GitHub Actions のワークフローファイルに対して、Pull Request 作成時に次のコマンドを実行する指定を行います。その他の設定については説明を省略します。

bundle exec danger
cat evil.txt

最後に evil.sh およびワークフローファイルをコミットして、Pull Request を作成します。

実行と確認

GitHub Actions で Danger の実行が完了すると、ログに evil が出力されます。これにより evil.sh で実行したコマンドの成功が確認できます。

GitHub Actions ワークフローのログ

影響

前提として、公開設定がプライベートになっているリポジトリは外部のユーザがアクセスできないため、影響を受ける可能性は非常に低いと思います。

そうでない場合、具体な影響の例として次が考えられます。

  • Danger を実行する環境の破壊
  • リポジトリのコードの追加、変更、削除
    • Danger の実行ユーザに権限があった場合
  • シークレットを外部に送信
  • Danger の実行環境からアクセス可能な外部環境へのアクセス
    • 例えば、CI からテストデータ参照のため社内 AWS 環境へアクセスを許可しているケース

実際は外部のユーザがそのまま CI を実行できないようにブロックされているケースが多いです。Danger 自体の GitHub Actions ワークフローも、承認が行われてから実行される運用になっていました。

しかし、たとえ承認フローが設定されていても、レビューで細工されたコードを見落とすと実行が成功してしまいます。今回の例ではシェルスクリプトでしたが、バイナリファイルだったり、別の Pull Request で入った(入る)コードを実行する処理が難読化されていると、見落としてしまう可能性は残ります。

原因

修正前の 8.5.0 を例に、原因となったコードについて解説します。

まず、Danger の実行の過程で Danger::GitRepo クラスの git_fetch_branch_to_depth メソッドが呼び出されます。

def git_fetch_branch_to_depth(branch, depth)
  exec("fetch --depth=#{depth} --prune origin +refs/heads/#{branch}:refs/remotes/origin/#{branch}")
end

このメソッドでは、第一引数の branch と第二引数の depth から作成される文字列が同じクラスの exec メソッドに string として渡されます。

def exec(string)
  require "open3"
  Dir.chdir(self.folder || ".") do
    Open3.popen2(default_env, "git #{string}") do |_stdin, stdout, _wait_thr|

  # (省略)

上記コードから、string は git コマンドのオプションとして期待された文字列になる事が分かります。

git_fetch_branch_to_depth メソッドの branch はその名の通りブランチ名です。ブランチ名には git で許可された任意の文字列を指定できます。参考までに、ブランチ名に関する制限は Git - git-check-ref-format Documentation から確認できます。

もし depth20 だと仮定すると、branch の値は ;PATH=$PWD;evil.sh; なので 、次の文字列

"fetch --depth=#{depth} --prune origin +refs/heads/#{branch}:refs/remotes/origin/#{branch}"

を文字列展開すると次になります。

fetch --depth=20 --prune origin +refs/heads/;PATH=$PWD;evil.sh;:refs/remotes/origin/;PATH=$PWD;evil.sh;

これは Danger::GitRepo#exec 内の Open3.popen2 によって、次のコマンドが連続して実行される事になります。

  1. git fetch --depth=20 --prune origin +refs/heads/
  2. PATH=$PWD
  3. evil.sh
  4. :refs/remotes/origin/
  5. PATH=$PWD
  6. evil.sh

問題となるのは3と6のコマンドです。

PATH=$PWD は環境変数の PATH にカレントディレクトリを指定しています。ここには evil.sh が存在するため、evil.sh に指定した任意のコマンドが実行できてしまいます

また、別のファイルを実行しなくても、例えばブランチ名を ;echo${IFS}"hello"; とすると結果的に echo hello を実行する事が出来ます。これは次のドキュメントを読んで知りました。

ブランチ名のみにインジェクションする方法は、文字やその長さに制限がある一方で、レビュー時に見落とされる可能性が比較的上がると思います。

報告と修正

Danger のソースコードを読んでいて、偶然この問題に気づきました。発見した当初は具体的に問題となる方法が思いつかず、違和感がありつつもそのままにしていました。しばらくして、今となっては何がきっかけになったか忘れたのですが、その方法が思いついた次第です。

Danger の GitHub リポジトリにはセキュリティレポートの窓口がなかったため、メンテナの Orta Therox 氏に Twitter の DM で報告しました。

その結果、「Issue に該当する」と返答をもらいましたが、数ヶ月経過しても特に動きがありませんでした。念の為再度連絡して Issue の作成により問題が公開される事を説明し、その許可をもらったため、GitHub のリポジトリ上に Issue を作成しました。

また、あわせて修正する PR を作成しました。

すぐにマージされ、8.6.0 としてリリースされました。

最後に

Danger に存在していた問題について解説し、報告および修正した事例を紹介しました。

CI 環境は、プロダクトコードと同様に常にセキュアに保つ必要があります。次は GitHub Actions に関する資料ですが、攻撃の例や、もし被害を受けた時の影響を軽減するベストプラクティスなどが記載されており、他の CI 環境でも非常に参考になると思いますので紹介しておきます。

また、ジモティーではエンジニアを積極的に採用しています。

ご興味のある方はこちらをご覧ください。

https://jmty.co.jp/recruit_top/