日曜日, 9月 14, 2025
日曜日, 9月 14, 2025
- Advertisment -
ホームニューステックニュースtfaction を段階的に導入した

tfaction を段階的に導入した


こんにちは、tsub です。2023 年 11 月から育休を取得していて今年の 5 月に復職しました。今回が Social PLUS Tech Blog への初投稿となります 😄

この数ヶ月間、徐々に tfaction の導入を進めていました。元々 Terraform の CI/CD は GitHub Actions で自前のパイプラインを組んでいましたが、通常の開発タスクを優先しつつ隙間時間で CI/CD の改善を行っていたため、段階的に移行する形となりました。

この記事では tfaction を導入した背景や、どのように導入を進めていったかについて説明したいと思います。

tfaction とは

tfaction とは、簡単に説明すると Terraform (あるいは Terragrunt, OpenTofu) 向けの GitHub Actions ベースの CI/CD ツールです。

https://suzuki-shunsuke.github.io/tfaction/docs/

公式ドキュメントでは以下のように説明されています。

tfaction is a framework for a Monorepo to build high-level Terraform workflows using GitHub Actions. You don’t have to run terraform apply in your laptop, and don’t have to reinvent the wheel for Terraform Workflows anymore.

便宜的に CI/CD ツールと書きましたが、実際には GitHub Actions のカスタムアクションの集合体で、terraform plan や terraform apply を実行するカスタムアクションは勿論、terraform validate や tflint などを実行するカスタムアクションなど、Terraform の開発フローで便利な CI/CD のための機能を多く提供しています。

なぜ tfaction を導入したのか

弊社では素の Terraform ではなく Terragrunt を介して使っているのですが、これまで gruntwork-io/terragrunt-action を使いつつ、ワークフローの大半を自前実装していました。

自前実装で 1 つ課題となっていたのが、ローカルモジュール変更時にモジュールの呼び出し元の plan を実行するような仕組みが実現できていませんでした。

ローカルモジュールのみを変更する機会は割とあるため、CI で plan/apply を実行するために、呼び出し元の方で不要な変更を入れるなどの運用でカバーしている状態でしたが、さすがにこの運用を長く続けるのは大変でした。

当時、ローカルモジュール変更を検知して plan/apply を実行する方法を調べていたところ、tfaction が提供している list-targets Action を使うことで解決できそうなことが分かったため、部分的に list-targets Action を導入しました。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/local-path-module

ここで tfaction の利点に気がつきましたが、tfaction はカスタムアクションの集合体であるため、必要な機能のみを段階的に導入することができます。

また、tfaction は PR をマージしたら apply するというフローで設計されており、我々の自前ワークフローも同様のフローで設計していたため、その点でも親和性が高く、全体的に tfaction を導入していこうという方針となりました。

ここで、list-targets Action 以外の部分は自前実装のままでも問題なかったのですが、ローカルモジュールの変更検知以外にもいくつか課題感があったため、Terraform の CI/CD を全体的に改善していくために tfaction の導入を進めていくことを決めました。

段階的な導入の進め方

以下で説明する各ステップはそれぞれ master にマージした変更で、単体で動作するようになっています。

段階的な導入ということなので、それぞれのステップ間で期間が開いているものもありますし、その間も通常のインフラの開発タスクは継続しているため、単体で実行可能な状態を維持しています。

なお、本記事では導入当時の tfaction v1.16.1 を前提としています。記事執筆時点で v1.19.2 が最新バージョンです。

また、tfaction とは関係ないですが、本記事で紹介しているコードではカスタムアクションの呼び出しにコミットハッシュを使わずにタグで指定しているため、安全に利用するためにコミットハッシュへの置き換えも行うことを推奨します 🙏 (参考)

0. tfaction 導入前のワークフロー

まず前提として弊社のインフラリポジトリの構成は Monorepo 環境となっていて 1 つのリポジトリ内に複数のサービスのルートモジュールがあります。

tfaction の導入を始める前は以下のようなワークフローが定義されていました。
(本記事と直接的に関係のない細かい部分は省いています)

.github/workflows/terragrunt_plan_xxxx.yml

name: Terrgrunt Plan xxxx State

on:
  pull_request:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/xxxx/**"

jobs:
  terragrunt-plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-[email protected]
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    - name: terragrunt plan
      uses: gruntwork-io/terragrunt-action@v2
      with:
        tf_version: 1.9.8 
        tg_version: 0.69.0 
        tg_dir: terragrunt/xxxx
        tg_command: 'plan -lock=false'
        tg_comment: 1
      env:
        TERRAGRUNT_FORWARD_TF_STDOUT: "true"
        GITHUB_TOKEN: ${{ github.token }}

このワークフローがルートモジュールごとに定義されており、弊社のルートモジュールは全部で 14 個あるため、計 14 ワークフローとなります。

ワークフローが分かれていた理由は、変更したディレクトリごとに実行する CI を最小限に抑えたいため、on.pull_request.paths の部分で terragrunt/xxxx/** のようにルートモジュールごとのディレクトリ変更を検知していました。

1. list-targets Action の導入

まずは前述のローカルモジュールの変更を検知するために list-targets Action を導入しました。
また、14 個のワークフローの保守性も課題となっていたため、list-targets Action の導入に伴い、matrix ジョブの導入も同時に行なうことで 14 個のワークフローを 1 つに統合しました。

.github/workflows/terragrunt_plan.yml

name: Terragrunt Plan

on:
  pull_request:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/**"
  
  push:
    branches:
    - master
    paths:
    - "terragrunt/**"

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}
    permissions:
      contents: read
      pull-requests: write 
    steps:
    - uses: actions/checkout@v4
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.69.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.9.8" 
        terraform_wrapper: false 
    
    - uses: suzuki-shunsuke/tfaction/list-[email protected]
      id: list-targets
      with:
        github_token: ${{ github.token }}

  terragrunt-plan:
    name: "terragrunt-plan for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-[email protected]
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    - name: terragrunt plan
      uses: gruntwork-io/terragrunt-action@v2
      with:
        tf_version: 1.9.8 
        tg_version: 0.69.0 
        tg_dir: terragrunt/${{ matrix.target.target }}
        tg_command: 'plan -lock=false'
        tg_comment: 1
      env:
        TERRAGRUNT_FORWARD_TF_STDOUT: "true"
        GITHUB_TOKEN: ${{ github.token }} 

また、tfaction の利用に必要な設定ファイルの追加も合わせて実施しています。

tfaction-root.yaml



plan_workflow_name: Terragrunt Plan



update_local_path_module_caller:
  enabled: true

target_groups:
- working_directory: terragrunt/xxxx
  target: xxxx

terragrunt/xxxx/tfaction.yaml

{}

ワークフローの実装がガラッと変わったようにも見えますが、本質的には terragrunt plan を実行する前準備として、実行対象を検出する仕組みが増えただけです。
list-targets Action によって変更したローカルモジュールやルートモジュールを検出し、PR 上で変更したディレクトリのみ terragrunt plan を実行するような仕組みに変わりました。

terragrunt plan を実行する部分には変わらず gruntwork-io/terragrunt-action Action を使っています。

また、今回は list-targets Action のローカルモジュール変更検知が必要なだけでしたので、tfaction-root.yaml の設定も必要最小限に抑えていますし、tfaction が前提としている aqua パッケージマネージャの導入も行いませんでした。
tfaction 内部で使っている aqua のために CI 上では aqua CLI をインストールしていますが、ローカル環境などには aqua を導入していませんし、aqua.yaml も追加していません。

2. plan Action の導入

次のステップとして、tfaction の plan Action を導入しました。

plan Action の導入理由は gruntwork-io/terragrunt-action Action の PR コメント機能にサマリ表示がなく、1 つ 1 つ詳細を見るのが大変だったため、tfaction に組み込まれている tfcmt による PR コメントを使いたかったからです。
(ちなみに元々 tfcmt は使っていましたが Terragrunt 導入時に公式 Action に寄せるために一度廃止されていました)


No changes であることを確認するのにも都度トグルを開いて中身を確認する必要があった

https://suzuki-shunsuke.github.io/tfaction/docs/feature/tfcmt

.github/workflows/terragrunt_plan.yml

@@ -43,7 +43,7 @@
       uses: jaxxstorm/action-install-gh-[email protected]
       with:
         repo: gruntwork-io/terragrunt
-        tag: v0.69.0 
+        tag: v0.81.0 
         cache: enable
         rename-to: terragrunt
         chmod: 0755
@@ -51,7 +51,7 @@
     
     - uses: hashicorp/setup-[email protected]
       with:
-        terraform_version: "1.9.8" 
+        terraform_version: "1.12.2" 
         terraform_wrapper: false 
     
     - uses: suzuki-shunsuke/tfaction/list-[email protected]
@@ -64,15 +64,19 @@
     runs-on: ubuntu-latest
     needs: setup
     
-    if: join(fromJSON(needs.setup.outputs.targets), '') != ''
+    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.targets), '') != ''
     strategy:
       fail-fast: false
       matrix:
         target: ${{ fromJSON(needs.setup.outputs.targets) }}
     permissions:
       id-token: write
-      contents: read
+      contents: write
       pull-requests: write
+      issues: write
+    env:
+      TFACTION_TARGET: ${{ matrix.target.target }}
+      TFACTION_JOB_TYPE: terraform
     steps:
     - uses: actions/checkout@v4
     - name: Configure AWS credentials
@@ -80,14 +84,36 @@
       with:
         role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
         aws-region: ${{ vars.AWS_REGION }}
-    - name: terragrunt plan
-      uses: gruntwork-io/terragrunt-action@v2
+    
+    - uses: actions/cache@v4
+      env:
+        tfaction_version: v1.16.1 
       with:
-        tf_version: 1.9.8 
-        tg_version: 0.69.0 
-        tg_dir: terragrunt/${{ matrix.target.target }}
-        tg_command: 'plan -lock=false'
-        tg_comment: 1
+        path: ~/.local/share/aquaproj-aqua
+        
+        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
+        restore-keys: |
+          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
+    
+    - uses: aquaproj/aqua-[email protected]
+      with:
+        aqua_version: v2.51.1 
+    - name: Install terragrunt
+      uses: jaxxstorm/action-install-gh-[email protected]
+      with:
+        repo: gruntwork-io/terragrunt
+        tag: v0.81.0 
+        cache: enable
+        rename-to: terragrunt
+        chmod: 0755
+        extension-matching: disable
+    - uses: hashicorp/setup-[email protected]
+      with:
+        terraform_version: "1.12.2" 
+        terraform_wrapper: false 
+    - uses: suzuki-shunsuke/tfaction/[email protected]
       env:
-        TERRAGRUNT_FORWARD_TF_STDOUT: "true"
-        GITHUB_TOKEN: ${{ github.token }} 
+        TG_TF_FORWARD_STDOUT: "true"
+    - uses: suzuki-shunsuke/tfaction/[email protected]
+      env:
+        TG_TF_FORWARD_STDOUT: "true"

tfaction-root.yaml

@@ -7,6 +7,8 @@
 update_local_path_module_caller:
   enabled: true
 
+terraform_command: terragrunt
+
 target_groups:
 - working_directory: terragrunt/xxxx
   target: xxxx
ソースコード全体

.github/workflows/terragrunt_plan.yml

name: Terragrunt Plan

on:
  pull_request:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/**"
  
  push:
    branches:
    - master
    paths:
    - "terragrunt/**"

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}
    permissions:
      contents: read
      pull-requests: write 
    steps:
    - uses: actions/checkout@v4
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    
    - uses: suzuki-shunsuke/tfaction/list-[email protected]
      id: list-targets
      with:
        github_token: ${{ github.token }}

  terragrunt-plan:
    name: "terragrunt-plan for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: write
      pull-requests: write
      issues: write
    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-[email protected]
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    - uses: suzuki-shunsuke/tfaction/[email protected]
      env:
        TG_TF_FORWARD_STDOUT: "true"
    - uses: suzuki-shunsuke/tfaction/[email protected]
      env:
        TG_TF_FORWARD_STDOUT: "true"

tfaction-root.yaml



plan_workflow_name: Terragrunt Plan



update_local_path_module_caller:
  enabled: true

terraform_command: terragrunt

target_groups:
- working_directory: terragrunt/xxxx
  target: xxxx

弊社のインフラリポジトリでは Argo CD の CI も実行しているため、tfcmt の PR コメントを一部変更しています。また、ラベルの自動付与は現時点では不要なため無効化しています。

tfcmt.yaml



terraform:
  plan:
    disable_label: true
templates:
  
  plan_title: "## {{if eq .ExitCode 1}}:x: {{end}}terragrunt plan result{{if .Vars.target}} ({{.Vars.target}}){{end}}"

tfcmt による PR コメントで変更内容のサマリが表示されるようになり、非常に見やすくなりました。

また、合わせて PR ラベルとして変更があったルートモジュールが記載されるようになり、Monorepo 開発において変更箇所が分かりやすくなりました。

plan Action と同時に setup Action も導入したことにより、.terraform.lock.hcl の自動更新も行なってくれるようになりました。

3. Terragrunt のディレクトリ構成変更

次のステップは tfaction と直接的に関係ないのですが、既存の Terragrunt のディレクトリ構成が tfaction の利用に適しておらず、複数の環境の plan を実行できない状態だったため、Terragrunt のディレクトリ構成を変更しました。

元々 Terragrunt 導入時に以下のようなディレクトリ構成となっていました。

- terragrunt
    |- xxxx (サービス/ルートモジュール ごとのディレクトリ)
        |- terragrunt.hcl
        |- *.tf (Terraform ソースコード)

環境ごとにディレクトリが分かれていないため、staging/production の切り替えをどうやっているかというと、Terragrunt の run_cmd function を使ってシェルスクリプトを実行し、環境変数やハードコードされた値などで実行対象の環境を決めていました。

そのため、tfaction の設定でも target_groups は環境ごとのディレクトリを記述せずに以下のようになっていました。

tfaction-root.yaml



target_groups:
- working_directory: terragrunt/xxxx
  target: xxxx

そして、CI 上では PREFIX という環境変数を用いて PR のベースブランチに応じて環境の切り替えを行なっていました。

.github/workflows/terragrunt_plan.yml


    - uses: suzuki-shunsuke/tfaction/[email protected]
      env:
        PREFIX: ${{ github.base_ref == 'master' && 'production' || github.base_ref == 'staging' && 'staging' }}

この設計でも tfaction の動作は問題ないのですが、1 つの PR 上で複数の環境の plan を実行しようとすると、tfaction の target_groups が同一のままだと plan Action でのアーティファクトアップロード部分 (plan 結果をファイルとして出力している) で名前の競合がおき、以下のようなエラーが出てしまいました。

Error: Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run

そのため、Terragrunt のディレクトリ構成を以下のように変更しました。こちらのディレクトリ構成の方が Terraform/Terragrunt において割と一般的ではないかと思います。

- terragrunt
    |- envs
    |   |- production
    |       |- xxxx (サービス/ルートモジュール ごとのディレクトリ)
    |           |- terragrunt.hcl (Terragrunt ワーキングディレクトリ)
    |   |- staging
    |       |- xxxx
    |           |- terragrunt.hcl (Terragrunt ワーキングディレクトリ)
    |   |- review
    |       |- xxxx
    |           |- terragrunt.hcl (Terragrunt ワーキングディレクトリ)
    |- xxxx (サービス/ルートモジュール ごとのディレクトリ)
        |- terragrunt.hcl (互換性維持のため一旦残す)
        |- *.tf (Terraform ソースコード)

一度にディレクトリ構成を完全に切り替えると開発チーム内で混乱が起きそうだと思い、一応従来のディレクトリ構成でもローカルから plan などが実行できるように terragrunt.hcl を残してあります。
ただし CI 上では新しいディレクトリ構成を前提として動作するようにしました。

tfaction の設定も環境ごとに target_groups の定義を分けるようにしました。

tfaction-root.yaml



target_groups:
- working_directory: terragrunt/envs/production/xxxx
  target: production/xxxx
- working_directory: terragrunt/envs/staging/xxxx
  target: staging/xxxx
- working_directory: terragrunt/envs/review/xxxx
  target: review/xxx

なお、GitHub Actions ワークフローの実装例は本筋から外れるため省略します。

4. test, test-module Action の導入

次のステップとして、tfaction の test, test-module Action を導入しました。

tfaction の test Aciton や test-module Action では以下のことが行えます。

  • terraform validate によるエラー検出 (test Action のみ)
  • terraform fmt によるコードの自動修正
  • tflint –fix によるコードの自動修正
  • terraform-docs によるモジュールのドキュメント生成
  • その他、tfsec や trivy の実行 (今回は使っていない)

これまで、terraform fmt や tflint は自前のワークフローや reviewdog などを使って実装していましたが、tfaction に統合することで、管理するワークフローの数を減らせる点や、CI の実行時間などでいくつかメリットがありました。

test Acton の導入は基本的に plan のワークフローに以下を追加するだけです。弊社の場合は前述の通り aqua を使っていないため、tflint のインストールも行なっています。

.github/workflows/terragrunt_plan.yml

@@ -119,6 +120,10 @@ jobs:
+     - uses: terraform-linters/setup-[email protected]
+       with:
+         tflint_version: v0.50.3 
@@ -152,6 +157,41 @@ jobs:
+     
+     - uses: suzuki-shunsuke/tfaction/[email protected]
+       env:
+         TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"
+         TG_TF_FORWARD_STDOUT: "true"

また、tfaction の設定にも追加が必要です。

tfaction-root.yaml

+ tflint:
+   enabled: true
+   fix: true
+
+ 
+ trivy:
+   enabled: false

test-module Action の導入は plan のワークフローに別のジョブを追加しています。

.github/workflows/terragrunt_plan.yml


  
  test-module:
    name: "test-module for ${{ matrix.module }}"
    runs-on: ubuntu-latest
    needs: setup
    
    
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.modules), '') != ''
    strategy:
      fail-fast: false
      matrix:
        module: ${{ fromJSON(needs.setup.outputs.modules) }}
    permissions:
      contents: write
      pull-requests: write
      issues: write
    env:
      TFACTION_TARGET: ${{ matrix.module }}
      TFACTION_JOB_TYPE: terraform
    steps:
    - uses: actions/checkout@v4
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    - uses: terraform-linters/setup-[email protected]
      with:
        tflint_version: v0.50.3 
    - uses: suzuki-shunsuke/tfaction/[email protected]
    - uses: ./.github/actions/test-module
      env:
        TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"

test-module Action でテストする対象は Terraform のルートモジュール以外ということになりますが、弊社の場合は terragrunt/modules/ ディレクトリ配下にモジュールを定義しており、これらのローカルモジュールは素の Terraform で書かれているため terragrunt.hcl を配置しておらず、test-module Action では terraform コマンドを実行する必要があります。

tfaction の設定でトップレベルに terraform_command: terragrunt を指定していると、test-module Action で terragrunt コマンドが使われるようになってしまい、terragrunt.hcl が存在しないため実行エラーとなってしまいます。

そのため、terraform_command: terragrunttarget_groups 配下のみ適用するように変更を加える必要がありました。

tfaction-root.yaml

- terraform_command: terragrunt
+ 
+ 
+ 
+ 

  target_groups:
  - working_directory: terragrunt/envs/production/xxxx
    target: production/xxxx
+   terraform_command: terragrunt
  - working_directory: terragrunt/envs/staging/xxxx
    target: staging/xxxx
+   terraform_command: terragrunt
  - working_directory: terragrunt/envs/review/xxxx
    target: review/xxx
+   terraform_command: terragrunt
# ...

また、test-module Action 内では terraform-docs を使用してドキュメントの自動生成・更新を行なってくれるのですが、弊社では元々 terraform-docs を使っていなかったため、この時点で terraform-docs の導入まで一緒にやってしまうと認知負荷が増えてしまうため、一時的に terraform-docs は実行しないようにしています。

tfaction 側には terraform-docs を無効化するオプションは現在提供されていません。

https://github.com/suzuki-shunsuke/tfaction/issues/2757

そのため、test-module Action をローカルの Composite Action としてコピーし、terraform-docs 実行部分だけ一部コメントアウトするような形を取っています。

.github/actions/test-module/action.yml

+ MIT License
+ 
+ Copyright (c) 2022 Shunsuke Suzuki
+ 
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+ 
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+ 
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+
+ 
+ 
+ 
+ 
+ 
+ 
+
  name: Test Module
  description: Test Module
  inputs:
@@ -56,10 +63,11 @@ runs:
        shell: bash
        working-directory: ${{ env.TFACTION_TARGET }}

-     - uses: suzuki-shunsuke/tfaction/terraform-[email protected]
-       with:
-         github_token: ${{ inputs.github_token }}
-         working_directory: ${{ env.TFACTION_TARGET }}
+     
+     
+     
+     
+     

terraform-docs が実行されること自体は問題ないため、今後この Composite Action 廃止して通常通り test-module Action を使う予定です。

5. GitHub Actions トークンを GitHub App トークンへ移行

次のステップとして、GitHub Actions の GITHUB_TOKEN (ここでは便宜上 GitHub Actions トークンと表現します) から GitHub App トークンへ移行しました。
移行した主な理由としては、Renovate の Terraform Provider アップデートの PR でオートマージを行うためです。

tfaction には Renovate の PR 上で terraform plan の差分があると CI を失敗扱いにしてくれる機能があり、これを使うことで Terraform Provider などのアップデートを安全にオートマージすることができます。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/renovate

この機能を使って Renovate の Terraform Provider アップデートの PR をオートマージにしたいのですが、ここで問題になるのが、GitHub Actions トークンと GitHub Action の仕様です。

GitHub Actions トークンを用いてコミットした場合は、そのコミットで次のワークフローがトリガーされないため、GitHub App トークンを使うことでワークフローがトリガーされるようにしました。

GitHub App トークンの生成には actions/create-github-app-token Action を使っています。一般的な使い方だと思いますので、詳しい実装は割愛します。

6. apply Action の導入

最後のステップとして tfaction の apply Action を導入しました。
ここまで来れば tfaction を導入したと言って良いでしょう 😁

tfaction では plan 時に terraform plan の -out=tfplan.binary オプションを使って plan 結果をファイル出力し、それを apply 時に参照してくれます。(異なるワークフロー間ですので GitHub Actions のアーティファクトを経由します)

これによって、plan の結果として表示された差分だけを適用することができ、安全に terraform apply の CI/CD を組むことができます。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/plan-file

また、tfplan.binary を使って apply を行うと、古い tfplan.binary で apply した時に以下のエラーが出ます。

  ╷
  │ Error: Saved plan is stale
  │ 
  │ The given plan file can no longer be applied because the state was changed
  │ by another operation after the plan was created.
  ╵

このようなケースを考慮して tfaction では自動的に同じルートモジュールに変更がある PR に対してデフォルトブランチを取り込んでくれるような機能があります。

https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs

自前でワークフローを実装する場合はこういった考慮があって難しいため、tfaction を使うことで安全なワークフローを比較的組みやすいと思います。

さて、apply Action の導入ですが、基本的には plan と同様の実装で、違う点としては env.TFACTION_IS_APPLY: "true" が必要になるのと、gruntwork-io/terragrunt-action Action を suzuki-shunsuke/tfaction/apply Action に置き換えるくらいでした。
あとは GITHUB_TOKEN に actions: read の権限が必要になります。


    env:
      TFACTION_IS_APPLY: "true"
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    
    - uses: actions/create-github-app-[email protected]
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-actions: read 
        permission-issues: write 
        permission-pull-requests: write 
        permission-contents: write 

    - name: terragrunt apply
      id: apply
      uses: suzuki-shunsuke/tfaction/[email protected]
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
        TG_LOG_LEVEL: warn 

ただ、弊社では apply 実行後に Slack へ通知していたため、その運用を維持するために以下の改修も行なっています。もともと terragrunt apply の標準出力で No changes かどうかを判断して通知メッセージの内容を書き分けていましたが、tfaction の apply Action では標準出力が outputs 経由で取れないため、tfplan.binary の中から .applyable を参照して No changes かどうかを判断するようにしています。

.github/workflows/terragrunt_plan.yml


    - name: create success message
      id: slack_success_message
      working-directory: ${{ matrix.target.working_directory }}
      env:
        TFPLAN_FILE_PATH: tfplan.binary
      run: |
        ls -al ./ # to debug
        TERRAGRUNT_CHANGES=$(terragrunt show -json "$TFPLAN_FILE_PATH" | jq '.applyable')

        if [ "$TERRAGRUNT_CHANGES" = 'false' ]; then
          message=$(cat *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply による差分はありませんでした
        EOS
          )
        else
          message=$(cat *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply が実行されました。
        正常に完了しました
        ://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow URL>
        EOS
          )
        fi

        {
          echo 'message} >> "$GITHUB_OUTPUT"

最終的な plan, apply ワークフローの実装を載せておきます。

ソースコード全体

.github/workflows/terragrunt_plan.yml

name: Terragrunt Plan

on:
  pull_request:
    branches:
    - master
    - production
    paths:
    - "terragrunt/**"
  
  push:
    branches:
    - master
    paths:
    - "terragrunt/**"

jobs:
  setup:
    uses: ./.github/workflows/terragrunt_setup.yml
    secrets: inherit
    with:
      
      target_envs: |-
        ${{
          github.base_ref == 'master' && 'production staging review' ||
          github.base_ref == 'production' && 'production' ||
        }}

  terragrunt-plan:
    name: "terragrunt-plan for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    
    
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: read
    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    
    - uses: actions/create-github-app-[email protected]
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-issues: write 
        permission-pull-requests: write 
        permission-contents: write 
    - id: export-target
      name: Export target service and env from matrix.target.target
      env:
        target: ${{ matrix.target.target }}
      run: |
        # $target: production/account
        # $env: production
        # $service: account
        env=$(echo "$target" | cut -d "https://zenn.dev/" -f 1)
        service=$(echo "$target" | cut -d "https://zenn.dev/" -f 2)

        echo "env: $env"
        echo "service: $service"

        echo "env=$env" >> "$GITHUB_OUTPUT"
        echo "service=$service" >> "$GITHUB_OUTPUT"
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-[email protected]
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    - uses: terraform-linters/setup-[email protected]
      with:
        tflint_version: v0.50.3 
    - uses: shmokmt/actions-setup-github-[email protected]
      with:
        version: v6.3.2 
    - name: Hide old PR comments
      run: github-comment exec -k hide -- github-comment hide -k tfcmt
      env:
        GITHUB_TOKEN: ${{ steps.tfaction-app-token.outputs.token }}
        GH_COMMENT_VAR_tfaction_target: ${{ env.TFACTION_TARGET }}
    
    - uses: suzuki-shunsuke/tfaction/[email protected]
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
    
    - uses: suzuki-shunsuke/tfaction/[email protected]
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        GITHUB_TOKEN: ${{ steps.tfaction-app-token.outputs.token }} 
        TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"
        TG_TF_FORWARD_STDOUT: "true"
    - uses: suzuki-shunsuke/tfaction/[email protected]
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        GITHUB_TOKEN: ${{ steps.tfaction-app-token.outputs.token }} 
        TG_TF_FORWARD_STDOUT: "true"

  
  test-module:
    name: "test-module for ${{ matrix.module }}"
    runs-on: ubuntu-latest
    needs: setup
    
    
    if: github.event_name == 'pull_request' && join(fromJSON(needs.setup.outputs.modules), '') != ''
    strategy:
      fail-fast: false
      matrix:
        module: ${{ fromJSON(needs.setup.outputs.modules) }}
    permissions:
      id-token: write
      contents: read
    env:
      TFACTION_TARGET: ${{ matrix.module }}
      TFACTION_JOB_TYPE: terraform
    steps:
    
    - uses: actions/create-github-app-[email protected]
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-pull-requests: write 
        permission-contents: write 
    - uses: actions/checkout@v4
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    - uses: terraform-linters/setup-[email protected]
      with:
        tflint_version: v0.50.3 
    
    - uses: suzuki-shunsuke/tfaction/[email protected]
    - uses: ./.github/actions/test-module
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TFLINT_CONFIG_FILE: "${{ github.workspace }}/.tflint.hcl"

  
  
  terragrunt-plan-status-check:
    runs-on: ubuntu-latest
    needs: [terragrunt-plan, test-module]
    if: failure()
    steps:
    - run: exit 1

.github/workflows/terragrunt_apply.yml

name: Terragrunt Apply

on:
  push:
    branches:
    - master
    - staging
    paths:
    - "terragrunt/**"

jobs:
  setup:
    uses: ./.github/workflows/terragrunt_setup.yml
    secrets: inherit
    with:
      target_envs: |-
        ${{
          github.ref_name == 'master' && 'production' ||
          github.ref_name == 'staging' && 'staging'
        }}

  terragrunt-apply:
    name: "terragrunt-apply for ${{ matrix.target.target }}"
    runs-on: ubuntu-latest
    needs: setup
    
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}
    permissions:
      id-token: write
      contents: read
    env:
      TFACTION_IS_APPLY: "true"
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFACTION_JOB_TYPE: terraform
    steps:
    
    - uses: actions/create-github-app-[email protected]
      id: tfaction-app-token
      with:
        app-id: ${{ vars.INFRA_TFACTION_GITHUB_APP_ID }}
        private-key: ${{ secrets.INFRA_TFACTION_GITHUB_APP_PRIVATE_KEY }}
        permission-actions: read 
        permission-issues: write 
        permission-pull-requests: write 
        permission-contents: write 
    - id: export-target
      name: Export target service and env from matrix.target.target
      env:
        target: ${{ matrix.target.target }}
      run: |
        # $target: production/account
        # $env: production
        # $service: account
        env=$(echo "$target" | cut -d "https://zenn.dev/" -f 1)
        service=$(echo "$target" | cut -d "https://zenn.dev/" -f 2)

        echo "env: $env"
        echo "service: $service"

        echo "env=$env" >> "$GITHUB_OUTPUT"
        echo "service=$service" >> "$GITHUB_OUTPUT"
    - uses: actions/checkout@v4
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-[email protected]
      with:
        role-to-assume: ${{ secrets.TERRAGRUNT_AWS_IAM_ROLE_ARN }}
        aws-region: ${{ vars.AWS_REGION }}
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    
    - uses: suzuki-shunsuke/tfaction/[email protected]
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
    - name: terragrunt apply
      id: apply
      uses: suzuki-shunsuke/tfaction/[email protected]
      with:
        github_token: ${{ steps.tfaction-app-token.outputs.token }}
      env:
        TG_TF_FORWARD_STDOUT: "true"
        TG_LOG_LEVEL: warn 
    - name: create failure message
      if: failure()
      id: slack_failure_message
      run: |
        message=$(cat 
        EOS
        )

        {
          echo 'message} >> "$GITHUB_OUTPUT"
    - name: create success message
      id: slack_success_message
      working-directory: ${{ matrix.target.working_directory }}
      env:
        TFPLAN_FILE_PATH: tfplan.binary
      run: |
        ls -al ./ # to debug
        TERRAGRUNT_CHANGES=$(terragrunt show -json "$TFPLAN_FILE_PATH" | jq '.applyable')

        if [ "$TERRAGRUNT_CHANGES" = 'false' ]; then
          message=$(cat *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply による差分はありませんでした
        EOS
          )
        else
          message=$(cat *${{ github.actor }}* による *${{ github.ref_name }}* Mergeによって *${{ matrix.target.target }}* への terragrunt apply が実行されました。
        正常に完了しました
        ://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow URL>
        EOS
          )
        fi

        {
          echo 'message} >> "$GITHUB_OUTPUT"
    - name: Send Slack notification
      if: always()
      uses: ./.github/actions/slack_custom_notification
      with:
        
        message: ${{ steps.slack_success_message.outputs.message }}${{ steps.slack_failure_message.outputs.message }}
        slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL_DEV_TEAM_INFRA }}

.github/workflows/terragrunt_setup.yml

name: Terragrunt Setup

on:
  workflow_call:
    inputs:
      target_envs:
        required: true
        type: string
        description: 'plan/apply 対象の env。スペース区切りで複数指定可能'
    outputs:
      targets:
        description: '変更があった Terragrunt ルートモジュール'
        value: ${{ jobs.setup.outputs.targets }}
      modules:
        description: '変更があった Terragrunt 共有モジュール'
        value: ${{ jobs.setup.outputs.modules }}

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.filtered-targets.outputs.targets }}
      modules: ${{ steps.list-targets.outputs.modules }}
    permissions:
      contents: read
      pull-requests: write 
    steps:
    - uses: actions/checkout@v4
    
    - uses: actions/cache@v4
      env:
        tfaction_version: v1.16.1 
      with:
        path: ~/.local/share/aquaproj-aqua
        
        key: v2-aqua-installer-${{runner.os}}-${{runner.arch}}-for-tfaction-${{env.tfaction_version}}
        restore-keys: |
          v2-aqua-installer-${{runner.os}}-${{runner.arch}}-
    
    - uses: aquaproj/aqua-[email protected]
      with:
        aqua_version: v2.51.1 
    
    - name: Install terragrunt
      uses: jaxxstorm/action-install-gh-[email protected]
      with:
        repo: gruntwork-io/terragrunt
        tag: v0.81.0 
        cache: enable
        rename-to: terragrunt
        chmod: 0755
        extension-matching: disable
    
    - uses: hashicorp/setup-[email protected]
      with:
        terraform_version: "1.12.2" 
        terraform_wrapper: false 
    
    - uses: suzuki-shunsuke/tfaction/list-[email protected]
      id: list-targets
      with:
        github_token: ${{ github.token }}
    
    - name: Exclude all but the target env
      id: filtered-targets
      env:
        targets: ${{ steps.list-targets.outputs.targets }}
        target_envs: ${{ inputs.target_envs }}
      run: |
        outputs="[]"

        for target_env in ${target_envs}; do
          
          
          filtered_targets=$(echo "$targets" | jq -c "map(select(
            (.working_directory | startswith(\"terragrunt/envs/$target_env\"))
          ))")

          echo "filtered_targets: $filtered_targets"

          
          outputs=$(jq -n -c --argjson a "$outputs" --argjson b "$filtered_targets" '$a + $b')
        done

        echo "outputs: $outputs"
        echo "targets=$outputs" >> "$GITHUB_OUTPUT"

tfaction-root.yaml




plan_workflow_name: Terragrunt Plan



update_local_path_module_caller:
  enabled: true






tflint:
  enabled: true
  fix: true


trivy:
  enabled: false

providers_lock_opts: -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_arm64

target_groups:
target_groups:
- working_directory: terragrunt/envs/production/xxxx
  target: production/xxxx
  terraform_command: terragrunt
- working_directory: terragrunt/envs/staging/xxxx
  target: staging/xxxx
  terraform_command: terragrunt
- working_directory: terragrunt/envs/review/xxxx
  target: review/xxx
  terraform_command: terragrunt

今後の方針

今回導入した Action 以外にも、tfaction には様々な機能が提供されています。

  • ドリフト検出
  • apply 失敗時のフォローアップ PR の自動作成
  • ワーキングディレクトリ、モジュールなどの scaffold
  • Conftest サポート
  • tfsec, trivy の実行
  • terraform-docs の実行

今回紹介したように、tfaction は段階的・部分的な導入ができるため、これらすべての機能を使う必要はありませんが、社内の需要に応じて導入を進めていければと思います。

特にドリフト検出は Terraform の CI/CD を組む上でのリスクを減らせるため、積極的に導入したいと考えています。

まとめ

今回社内の課題を解決するのに tfaction がピッタリハマりました。

Terraform の CI/CD でお困りの方は部分的にでも tfaction を導入してみると、課題に対して必要最小限の実装・労力で済むため、非常におすすめです。

tfaction の良いところは小さく導入できるところだと思っています。他の Terraform CI/CD ツールだと割と組み込むのが大掛かりになったり、既存の開発フローを大きく変える必要があると思います。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -