🍈

モジュール数 100+ の Go Monorepo の CI を改善した話

2025/02/21に公開

株式会社ナレッジワーク SRE の tapih(@_tapih) です。

本記事では、 Go Monorepo の CI を最適化し、インスタンスコストをピーク時から50%以上削減できた具体的な事例をご紹介します。

本記事で得られる点は以下の通りです。

  • Go Monorepo CI の事例を学ぶこと
  • GitHub Actions の Tips を学ぶこと
  • 施策の進め方・考え方を学ぶこと

背景

弊社ではバックエンドの言語に Go を採用しており、以下の特徴を持つ Monorepo で開発を行っています。

  • モジュール数 100+ のマルチモジュール構成
  • モジュール間のローカルでの依存関係を go.modreplace ディレクティブで解決 (go work の依存解決機能は未使用)
  • Go 以外のコードも同じ Monorepo 内で管理

弊社では、各モジュールごとに CI のワークフロー定義を自動生成し、on.pull_request.paths で必要な CI のみを起動する方式を採用していました。具体的には、 go.modreplace ディレクティブを利用して依存関係を解析し、ローカルで直接依存しているモジュールを on.pull_request.paths に指定していました。 Pull Request(以降、 PR) にコードがプッシュされると GitHub Actions 側で実行するワークフローのチェックが実行され、必要な CI のみが起動するイメージです。

課題

CI インスタンスコストの増加

まず発端となったのは、 GitHub Actions の GitHub Hosted Runner のインスタンスコストが大幅に増加したことでした。 Monorepo では、一つのコミットで実行される CI が多くなり、実行時間が長くなりがちです。さらに、開発者の増加に伴い、CI の実行頻度も上がり、コストは徐々に膨らんでいました。結果、設定した Budget Limit にある時期を境に頻繁に到達するようになり、改善の必要性が一気に高まりました。


コスト増加による悲鳴

GitHub Actions では、ワークフロー実行時にジョブ単位で Runner が割り当てられます。従って、例えば 10 のモジュールに対してテストとリントのジョブをそれぞれ定義している場合、合計 20(= 10 × 2)個の Runner が必要になります。また、 GitHub Hosted Runner は 利用時間を 1 分単位で切り上げて課金されます。

今回の施策に関係するインスタンスコスト増加の原因は以下の 3 点です。

  • CI 環境構築の処理 (例: Go SDK のインストール) がジョブの数と同数実行されていて無駄が大きい
  • Cache API Call の並列数の増加により 429 が頻発 & キャッシュ容量制限 超過によりキャッシュが無効に
  • 直接コードを変更していないモジュールに対してもリントを全部実行していて無駄が大きい

CI 待ち時間の増加

CI 実行対象となるモジュール数が多い PR では多数の Runner のインスタンス確保が同時に走ります。結果、 Runner 確保のキューが混雑してしまい、 CI 開始・完了までの時間がのびる問題が発生していました。

CI 実行状況把握の認知コストの増加

GitHub Actions の YAML がモジュールごとに分かれていてワークフローが別々に実行されており、どの CI が起動しているのか / していないのかを一目で把握しづらいという課題がありました。

  • 同時に起動するワークフローが多いと GitHub Actions の Web UI 上でワークフローが一気に埋まってしまう
  • GitHub Actions の Web UI の左サイドバーに大量の Go CI が表示されてしまい、目的の CI を探すのに何度もスクロールしなければならない

方針

現在、Platform & SRE Group は 3 名体制(うち 1 名はプレイングマネージャ)で運営されており、開発・運用リソースは限られています。まずは Goals / Non Goals を整理した上で施策の大枠を決定しました。

Goals

  • CI の並列数を制御する仕組みをミニマルに実装する
  • CI の実行時間・失敗率(≒SLO)を低運用コストで維持し、開発生産性を保つ

Non Goals

  • 高機能な技術を導入し、コストを増やして解決すること

本施策の完了がスタートではなくゴールに

特に気を付けたのは「本施策の完了がスタートではなくゴールになる」ことでした。大規模 Monorepo の CI では BazelNx を活用する選択肢もありますし、インスタンスコスト削減に特化するなら Self Hosted Runner も有力です。一方で、これらのパワフルな技術の導入は組織内に別の課題を生むリスクも伴います。ミニマルに問題解決をすることで、施策完了後にやることが極力少なくなっている状態を目指しました。

関連して、 Go のレイヤには極力踏み込まないこととしました。 Go のビルド・テストフローにまで手を入れると関連する仕組みの再設計や他チームとの調整が必要となり、スコープが拡大してしまいます。CI に閉じた形で施策を進めることで、自分たちのリソース内で完結させることを意識しました。

いかにコストのバランスをとるか

次に考えたことは、いかにバランスをとるかでした。

  • インスタンスコスト を抑えすぎると、開発者の CI 待ち時間が増加し、結果として 開発コスト が増加する
  • CI 待ち時間を短縮するために CI 改善に時間をかけると、自分たちの 運用コスト が増加する

このバランスをとるため、設計段階から CI の実行時間と失敗率に対して非公式な目標値を設定しました。

概要

以下の構成としました。

  • ワークフロー設定の自動生成を辞めて CI 実行時に CI を流すべきモジュールを解析
  • 変更のあったモジュールを事前に定義した JSON のルールに従ってグループ化
  • テストとリントでそれぞれ Workflow Call を実装 & Matrix でテストとリントをそれぞれグループごとに並列実行
  • 並列実行される 1 つのジョブの中で複数モジュールを直列実行
  • 直接変更がない依存先のモジュールのリントは一部のルールのみを実行


GitHub Actions Workflow Diagram

移行前 移行後
ワークフロー モジュール毎に 1 つ モジュール全体で 1 つ
ジョブ モジュール毎に 2 つ (テストとリント) 複数モジュールでジョブを共有
依存解析 Build time Runtime
テスト 依存する全モジュールで実行 依存する全モジュールで実行
リント 依存する全モジュールで実行 依存するモジュールには一部のルールのみ実行
直接変更のあったモジュールは全ルールを実行

詳細

実装したワークフローは大きく以下の 3 つです。複雑な処理は Composite Actions として以下のワークフローとは別で切り出しています。

  • 依存解析用のワークフロー: .github/workflows/pr--golang.yaml
  • テスト用の Workflow Call: .github/workflows/call--golang-test.yaml
  • リント用の Workflow Call: .github/workflows/call--golang-lint.yaml

.github/workflows/pr--golang.yaml

以下、処理の流れがわかるよう抜粋したコードです。

name: PR / Go

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

# 🦏
concurrency:
  group: ${{ github.event_name == 'push' && github.run_id || github.workflow_ref }}
  cancel-in-progress: true

jobs:
  analyze:
    name: Prepare
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: read
    outputs:
      test-grouped: ${{ steps.analyze.outputs.test-grouped }}
      test-ungrouped: ${{ steps.analyze.outputs.test-ungrouped }}
      lint-grouped: ${{ steps.analyze.outputs.lint-grouped }}
      lint-ungrouped: ${{ steps.analyze.outputs.lint-ungrouped }}
    defaults:
      run:
        shell: bash
    steps:
      # 🐦
      - name: PR commits + 1
        id: commits
        if: github.event_name == 'pull_request'
        env:
          commits: ${{ github.event.pull_request.commits }}
        run: echo "fetch-depth=$(( commits + 1 ))" >> "${GITHUB_OUTPUT}"

      # 🐦
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
          fetch-depth: ${{ github.event_name == 'pull_request' && steps.commits.outputs.fetch-depth || 2 }}

      # 🐦
      - name: Fetch base (origin/${{ github.base_ref }})
        if: ${{ github.event_name == 'pull_request' }}
        run: |
          git fetch --depth 50 --no-tags --progress origin "+refs/heads/${GITHUB_BASE_REF}:refs/heads/${GITHUB_BASE_REF}"

      # 🦍
      - name: Get changed files
        id: changed
        uses: tj-actions/changed-files@dcc7a0cba800f454d79fff4b993e8c3555bcc0a8 # v45.0.7
        with:
          skip_initial_fetch: true
          output_dir: /tmp/changed
          write_output_files: true
          json: true
          files_yaml: |
            go:
              - '**.go'
              - '**/go.mod'
              - '**/go.sum'
              - '**/.tool-versions'

      # 🦍
      - name: Analyze diff
        id: analyze
        uses: ./.github/actions/analyze-golang-diff
        with:
          input-json: /tmp/changed/go_all_modified_files.json

  test:
    name: Test
    needs: [analyze]
    uses: ./.github/workflows/call--golang-test.yaml
    secrets: inherit
    permissions:
      contents: read
      packages: read
    with:
      grouped: ${{ needs.analyze.outputs.test-grouped }}
      restore-cache: ${{ github.event_name == 'pull_request' }}
      save-cache: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
      # 🦖
      fail-fast: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
      junit-artifact-prefix: junit-test
      test-report-xml: test.xml
      lint-report-xml: lint1.xml

  lint:
    name: Lint
    needs: [analyze]
    uses: ./.github/workflows/call--golang-lint.yaml
    secrets: inherit
    permissions:
      contents: read
      packages: read
    with:
      grouped: ${{ needs.analyze.outputs.lint-grouped }}
      restore-cache: ${{ github.event_name == 'pull_request' }}
      save-cache: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
      # 🦖
      fail-fast: ${{ github.event_name == 'push' && github.ref_name == 'master' }}
      junit-artifact-prefix: junit-test
      lint-report-xml: lint2.xml

  postprocess:
    name: Post process
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: read
      pull-requests: write
      actions: write
    needs:
      - analyze
      - test
      - lint
    if: always()
    steps:
      - name: Download JUnit test results
        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
        continue-on-error: true
        if: ${{ !contains(needs.*.result, 'cancelled') }}
        with:
          pattern: junit-*
          merge-multiple: true

      # 🐷
      - name: Comment on PR
        uses: tapihdev/junit-monorepo-go@aa7ec350310ec061b2145f26f98ac8ceaa31dcd8 # v0.5.2
        continue-on-error: true
        if: ${{ !contains(needs.*.result, 'cancelled') }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          config: |
            test:
              title: Test
              type: gotestsum
              fileName: test.xml
              directories: ${{ needs.analyze.outputs.test-ungrouped }}
            lint1:
              title: Lint1
              file: .golangci.dep.toml
              type: golangci-lint
              fileName: lint1.xml
              directories: ${{ needs.analyze.outputs.test-ungrouped }}
            lint2:
              title: Lint2
              file: .golangci.toml
              type: golangci-lint
              fileName: lint2.xml
              directories: ${{ needs.analyze.outputs.lint-ungrouped }}

     - name: Fail if any job failed
        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: exit 1

./.github/workflows/call--golang-test.yaml

以下、同じく処理の流れがわかるよう抜粋したコードです。 ./.github/workflows/call--golang-lint.yaml は大体同じため割愛します。

name: Go / Test

on:
  workflow_call:
    inputs:
      grouped:
        type: string
        required: false
        default: '[]'
        description: |
          JSON string of grouped directories

          Example:
          ```
          [{"name":"group1","directories":"go/app1,go/app2"},{"name":"group2","directories":"go/app3"}]
          ```
      fail-fast:
        type: boolean
        required: false
        default: false
        description: "fail fast if true"
      restore-cache:
        type: boolean
        required: false
        default: true
        description: "restore cache if true"
      save-cache:
        type: boolean
        required: false
        default: false
        description: "save cache if true"
      junit-artifact-prefix:
        type: string
        required: true
        description: "prefix for artifact name of JUnit xml"
      test-report-xml:
        type: string
        required: false
        default: "test.xml"
        description: "test report xml file"
      lint-report-xml:
        type: string
        required: false
        default: "lint.xml"
        description: "test report xml file"

env:
  HOME_DIR: /home/runner
  # 🦁
  GOLANGCI_TOML: .golangci.dep.toml

jobs:
  main:
    # 🐝
    name: ${{ matrix.name }}
    runs-on: ubuntu-latest${{ contains(matrix.name, '[L]') && '-l' || '-m' }}
    if: inputs.grouped != '' && inputs.grouped != '[]'
    timeout-minutes: 15
    permissions:
      contents: read
      packages: read
    strategy:
      fail-fast: ${{ inputs.fail-fast }}
      # 🪸
      matrix:
        include: ${{ fromJson(inputs.grouped) }}
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Install tools
        uses: ./.github/actions/install-tools

      - name: Setup infrastructure
        uses: ./.github/actions/setup-infra

      - name: Read golang-version
        uses: ./.github/actions/get-tool-version
        id: golang-version
        with:
          dir-path: .
          tool-name: golang

      - name: Setup Go ${{ steps.golang-version.outputs.version }}
        uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
        with:
          go-version: ${{ steps.golang-version.outputs.version }}
          cache: false

      - name: Set vars
        id: vars
        env:
          group_name: ${{ matrix.name }}
        run: |
          echo "group-name=$(echo "${group_name}" | tr "/" "_")" >> "${GITHUB_OUTPUT}"

      - name: Fetch GitHub App Token
        uses: actions/create-github-app-token@67e27a7eb7db372a1c61a7f9bdab8699e9ee57f7 # v1.11.3
        id: fetch-token
        with:
          app-id: ${{ vars.GH_APP_ID }}
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

      # 🦄
      - name: Restore mod cache
        id: restore-mod
        if: inputs.restore-cache || inputs.save-cache
        continue-on-error: true
        uses: ./.github/actions/artifact-cache-restore
        with:
          working-directory: ${{ env.HOME_DIR }}
          github-token: ${{ steps.fetch-token.outputs.token }}
          branch: main
          lookup-only: ${{ (!inputs.restore-cache && inputs.save-cache) && 'true' || 'false' }}
          key: |-
            ${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
              runner.os,
              runner.arch,
              'golang',
              steps.golang-version.outputs.version,
              'mod',
              steps.vars.outputs.group-name
            ) }}

      # 🦄
      - name: Restore test cache
        id: restore-test
        if: inputs.restore-cache || inputs.save-cache
        continue-on-error: true
        uses: ./.github/actions/artifact-cache-restore
        with:
          working-directory: ${{ env.HOME_DIR }}
          github-token: ${{ steps.fetch-token.outputs.token }}
          branch: main
          lookup-only: ${{ (!inputs.restore-cache && inputs.save-cache) && 'true' || 'false' }}
          key: |-
            ${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
              runner.os,
              runner.arch,
              'golang',
              steps.golang-version.outputs.version,
              'test',
              steps.vars.outputs.group-name
            ) }}

      # 🦍
      - name: Run tests one by one
        uses: ./.github/actions/go-test-lint
        with:
          test: true
          lint: true
          directories: ${{ matrix.directories }}
          fail-fast: ${{ inputs.fail-fast }}
          test-xml-file: ${{ inputs.test-report-xml }}
          lint-xml-file: ${{ inputs.lint-report-xml }}
          golangci-toml-path: ${{ env.GOLANGCI_TOML }}

      # 🐷
      - name: Upload JUnit test results
        uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
        if: success() || failure()
        timeout-minutes: 1
        with:
          name: ${{ inputs.junit-artifact-prefix }}-${{ steps.vars.outputs.group-name }}
          path: |
            **/${{ inputs.test-report-xml }}
            **/${{ inputs.lint-report-xml }}

      # 🦄
      - name: Save test and lint cache
        uses: ./.github/actions/artifact-cache-save
        if: inputs.save-cache
        continue-on-error: true
        timeout-minutes: 3
        with:
          github-token: ${{ steps.fetch-token.outputs.token }}
          working-directory: ${{ env.HOME_DIR }}
          key: |-
            ${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
              runner.os,
              runner.arch,
              'golang',
              steps.golang-version.outputs.version,
              'test',
              steps.vars.outputs.group-name
            ) }}
          path: |-
            ./.cache/go-build
            ./.cache/golangci-lint
          existing-cache-url: ${{ steps.restore-test.outputs.url }}

      # 🦄
      - name: Save mod cache
        uses: ./.github/actions/artifact-cache-save
        if: inputs.save-cache
        continue-on-error: true
        timeout-minutes: 3
        with:
          github-token: ${{ steps.fetch-token.outputs.token }}
          working-directory: ${{ env.HOME_DIR }}
          key: |-
            ${{ format('{0}-{1}-{2}-{3}-{4}-{5}',
              runner.os,
              runner.arch,
              'golang',
              steps.golang-version.outputs.version,
              'mod',
              steps.vars.outputs.group-name
            ) }}
          path: |-
            ./go/pkg/mod
          existing-cache-url: ${{ steps.restore-mod.outputs.url }}

引き続き、コード内コメントに従って解説していきます (Ctrl+F -> 🦍 のようにすると該当のコードに飛ぶことができます)。

🦍 CI 対象のモジュールをグループ化 -> 直列で CI 実行

前段の tj-actions/changed-files によって Git Diff によるコード差分が得られます。また、 Go モジュールの依存関係マップを go.modreplace ディレクティブで DFS して構築します。このマップにコード差分を適用することで間接的に変更のあったモジュールを取得します。

  1. tj-actions/changed-files により直接変更のあったファイルの一覧を取得
  2. 各ファイルパスを上に辿っていき go.mod が存在するモジュールのルートディレクトリパスの一覧を取得 (例: product/example/main.go -> product/example)
  3. レポジトリ内の go.modreplace を解析し、依存関係のマップを構築
  4. 2 のディレクトリパスを 3 の依存関係のマップに入力し、間接的に変更のあったモジュールを抽出

次に複数モジュールをグルーピングします。事前に JSON に定義した正規表現 match のリストを順に処理し、最初にマッチした group にモジュールを分類します。

以下の例だと product/examplemiddleware/example に属するモジュールは example [L] というグループにまとめられます。また、 CI Skip したいグループには <skip ci> というグループ名をつける、インスタンスサイズを変えたいグループには [L] をグループ名に含む、変更時にどのようなグルーピングがされるかを確認するための簡易なスクリプトも用意する等により、 JSON 側の変更だけである程度の CI の調整を完結できるようにしています。

[
  {"match": "^playground/.+", "group": "<skip ci>"},
  {"match": "^(product/example|middleware/example)/.+$", "group": "example [L]"},
  {"match": "^(product/[^/]+|middleware/[^/]+)/.+$", "group": "$1"},
  {"match": "^(.+)$", "group": "$1"}
]

そして各グループのジョブ内で複数モジュールの処理を直列に実行します。

DFS 部分は自動生成で利用していたコードを流用しました。グルーピング部分のコードは for で正規表現によるマッチを回すだけなので低コストで開発できました。また、 (社内で利用しているロガー関連のパッケージを除き) Go SDK 以外への依存がない形で実装しているため、今後 Go のバージョンアップ対応だけで安定して動き続けてくれることを期待しています。

🦁 依存先モジュールでは一部のリントのみを実行

テストでは依存関係を考慮して CI を実行する必要がありますが、リントの大部分は直接変更があったモジュールでのみ実行しておけば十分です。 golangci-lint の社内で適用されていたルールうち、以下のリントのみを依存先の CI で実行するようにしました。

🦄 Artifact 上にキャッシュを保存

GitHub Actions のキャッシュ容量はレポジトリ毎に 10GB で、 Monorepo においてはこの容量制限は割とタイトです。

そこで、比較的サイズの大きい一部のキャッシュを GitHub Artifact Storage にアップロードするようにしました。 AWS S3 や Google Cloud Storage も候補に上がりましたが、ネットワーク転送コストが高くなることが懸念されたため不採用としました。

アーティファクトはキャッシュと比較して保存・復元が遅い点が悩みどころでしたが、 zstd で事前に圧縮/解凍して高速化することで (依然キャッシュよりは遅いものの) 実用的な速さで処理が完了しています。また、デフォルトブランチのキャッシュを利用すれば十分なため、キャッシュキーの prefix マッチ 相当の機能は実装せずに運用しています。

🐷 JUnit report を PR 上にコメント

新しい CI 実装では一つのインスタンスで複数モジュールの CI が実行される関係で、 CI の実行結果を以前よりも確認しづらくなる懸念がありました。

そこで、 gotestsum や golangci-lint が出力する複数モジュールの JUnit レポートを、一つの PR コメントに集約して表示する仕組みを導入しました。既存の 3rd-party Action では要件を満たせなかったため、 Action を自作しました。

tapihdev/junit-monorepo-go

その他細かい Tips

本稿では割愛しますが、他にもいろいろな Tips があります。以下に一部を記載しているので、興味がある方は DMカジュアル面談で質問いただけると嬉しいです。

  • 🦏 PR では concurrency を利用して重複ワークフローをキャンセルするが、 Merge 時はキャンセルしない
  • 🐦 必要なコミット履歴をミニマルに取得することで fetch にかかる時間を最小化
  • 🪸 Matrix を Workflow Call 側に定義することで GitHub Web UI 上での見やすさを調整
  • 🐝 各 Workflow の Name を調整することで GitHub Web UI 上での見やすさを調整
  • 🦖 PR では fail fast しないが、 Merge 時は fail fast する
  • ARM インスタンスの活用によるインスタンスコスト削減
  • Datadog CI Visibility による低導入コスト but 非常にリッチな可視化
  • Go のバージョン切り替えは公式の手順を利用
  • Workflow Call や Composite Run Steps が変更されたときのテストを整備

移行

既存 CI に大量に変更を入れると、変更に失敗したときに他のメンバーの開発に影響が出てしまう可能性があります。この点を踏まえ、新 CI は旧 CI とは完全に別で実装を進めました。

  • 新 CI の実装完了後に一部モジュールのみを有効にした状態で旧 CI と並行稼働
  • 並行稼働期間中に努力目標値の達成状況を確認 & 利用者からフィードバックを収集
  • 新 CI に切り替えつつ旧 CI はスケジュール実行してしばらくの正常動作を確認

この手順で作業を進めたことで、開発を止めることなくかつ切り替え後も大きな問題が起きることなく移行を完了できました 🎉

結果

以上の施策により当初に想定していた通りの成果が得られました。

コスト

Push 回数の影響もあるため単純な比較は難しいものの、インスタンスコストはピーク時と比べて最低でも 50% は削減できました。主に不要なリントの実行を抑えたことと Arm インスタンスの活用による影響が支配的だったと思います。

また、新しい CI をリリースした後は、安定化のためのパッチを細かく適用している程度で、ほぼ手を加えることなく運用できています。極力仕様が安定したものに依存して CI を構築するようにしているため、 (一部の考慮漏れにより実装が当初の 1.5 倍ほど膨らんだものの) 今後もそこまで手を加えずに運用できる状態が続くことを期待しています。

パフォーマンス

移行後 1 カ月間で集計したところ、以下の結果となりました。

  • Latency (PR to default branch)
    • P50: 4.5 min
    • P95: 7.2 min
  • Error Rate (CI 起因のエラーのみを仕分けて計算した概算値)
    • リリース後の問題対処前: 2.4%
    • リリース後の問題対処後: 0.7%

当初はコスト面からジョブをまとめることを検討したのですが、実際は CI 待ち時間の短縮に寄与したように感じています。

まとめと今後の展望

顕在化した CI に関する問題に対してミニマルな解決策を提示し、対応することができました。
今後はチーム内リソースの拡充とともに、 Go のレイヤーに踏み込んだ施策の実行やビルドツールの導入等で仕組みを進化させていければと考えています。

もし似たような環境で CI コストや運用負荷に悩んでいる方がいれば、ぜひフィードバックやご質問をいただけると嬉しいです!

株式会社ナレッジワーク

Discussion