newmo 技術ブログ

技術で地域をカラフルに

MonorepoでのTerraform運用を楽にする!tfactionを使ったGitHub Actions Workflowの構築

はじめに

newmoではGoogle Cloud等のリソース管理にTerraformを使っています。また、newmoではMonorepoを使って開発しています。 Monorepoについてここでは詳しく説明しませんが、バックエンドのGoのコードもフロントエンドのTypeScriptのコードもTerraformのコードもすべて同じGitHubのレポジトリで管理し開発を行っています。

TerraformのコードをMonorepoで管理することで、以下の要素を統一的に制御できるようになりました

  • CICDパイプライン
  • TerraformとProviderのバージョン
  • セキュリティポリシー
  • Lintルール
  • クラウドリソースの構成
  • パフォーマンスとコストの最適化

リソースをTerraformのコードで管理する場合に用意するGitHubでのWorkflowは一般的には以下のようなものになると思います。

  1. Terraformのコードを書いてPull Requestを作成する
  2. 自動的にTerraformのPlanが実行される
  3. TerraformのコードとPlanの結果をレビューして承認する
  4. Mainブランチにマージすると自動的にTerraform Applyが実行される

私もこれまで何度かこのようなWorkflowを作成して利用してきたことがあります。単純にPlanとApplyを実行するだけなら難しいことはあまりないのですが、Monorepoで今後広く長く使われるWorkflowということでもう少し要件が出てきました。

  • 複数のTerraform stateに関連するPull Requestは並列に実行したい
  • Terraform のPlan, Apply結果をPull Requestのコメントで通知したい
  • Applyに失敗したときに、リトライしたい
    • 一時的なエラーによりApplyが失敗するケースが多いため、リトライ機能は重要です
  • Terraform stateが進んでいる場合にはApplyをさせない
    • 他のPull Requestが先にマージされた場合にはstateが変わっているので、Plan結果とは違う変更が行われてしまうのを防ぎたい
    • GitHub の Require branches to be up to date before mergingで防ぐことができるが、Monorepoで有効にするとマージ待ちが多く発生してしまうため、有効にしたくない
  • terraform fmt, terraform validateに加えてTFLintやTrivyでコードのチェックをしたい

以上のような要件をすべて自分で実装するのは結構大変なので利用可能なツールを探していたところ、 tfaction を知りました。

tfactionとは

tfaction - GitHub Actions で良い感じの Terraform Workflow を構築 に概要が書かれていますが、自分が上で挙げていた要件を満たすようなGitHub ActionsのWorkflowを簡単に構築できるActionのセットです。

tfactionの主な機能と特徴

ここでは実際に私たちが利用している機能だけ紹介します。

1:高度なPlan/Apply管理

2: PlanとApplyの並列実行

GitHub Actions build matrixを利用したWorkflow

3: セキュリティとコード品質

4: local moduleのサポート

これらの機能をGitHub ActionsのWorkflowを書くだけで利用することができます。 AtlantisやHCP Terraformなどとちゃんと比較したわけではないのですが、tfactionでやりたいことが実現できています。

newmoでの利用

Monorepoにおけるディレクトリ構成

newmoではMonorepoの lib/terraform 以下にTerraform関連の設定、たとえばTerraform Moduleやtfactionの設定、TFLintのルールなどを置いていて、Terraformの実際のコードはいくつかのディレクトリに分かれています。 Terraform stateのサイズを管理可能な範囲に保つため、サービス単位とProvider単位でディレクトリを分割しています。

.
├── lib
│   └── terraform
│       ├── tfaction-root.yaml
│       ├── tflint.hcl
│       ├── modules
│       │   └── # 共通で使用するTerraformモジュール
│       └── templates
│           └── # 共通で使用するTerraformテンプレート
└── server
    ├── a
    │   └── terraform
    │       ├── dev
    │       │   ├── googlecloud
    │       │   └── cloudflare
    │       └── prod
    │           ├── googlecloud
    │           └── cloudflare
    └── b
        └── terraform
            ├── dev
            │   └── googlecloud
            └── prod
                └── googlecloud

tfactionを使ったTerraform Workflowの設定

GitHub ActionsのWorkflowの設定の例を以下に載せておきます。 かなりコードを省略しているのでこのままでは動きませんが、設定の雰囲気は伝わるかと思います。 バージョンも省略していますがこの設定の時点ではtfaction v1.7.0を利用していました。

name: CI Terraform Plan
on:
  pull_request:
    paths:
      - lib/terraform/**
      - server/**/terraform/**

concurrency:
  group: ${{ github.workflow }}--${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  AQUA_CONFIG: "${{ github.workspace }}/lib/terraform/aqua.yaml"
  TFACTION_CONFIG: "${{ github.workspace }}/lib/terraform/tfaction-root.yaml"

jobs:
  setup:
    timeout-minutes: 10
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}

    steps:
      - uses: actions/checkout
      - uses: aquaproj/aqua-installer
      - uses: suzuki-shunsuke/tfaction/list-targets
        id: list-targets

  plan:
    name: "terraform plan (${{ matrix.target.target }})"
    timeout-minutes: 30
    needs: setup

    # skip if targets is empty
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''

    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}

    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFLINT_CONFIG_FILE: "${{ github.workspace }}/lib/terraform/tflint.hcl"
      TRIVY_SEVERITY: HIGH,CRITICAL

    steps:
      - uses: actions/checkout
        with:
          sparse-checkout: |
            .github/actions
            lib/terraform
            ${{ matrix.target.working_directory }}

      - uses: aquaproj/aqua-installer
      - uses: suzuki-shunsuke/tfaction/get-target-config
      - run: github-comment exec -- github-comment hide

      - uses: suzuki-shunsuke/tfaction/setup

      - uses: suzuki-shunsuke/tfaction/test

      - uses: suzuki-shunsuke/tfaction/plan

      - name: Playbook
        if: failure()
        uses: ./.github/actions/playbook
        with:
          message: |
            # ${{ github.job }} が失敗しました

            ## 影響
            - このままでは Terraform applyができません

            ## 調査方法
            - Jobの落ちてるステップのエラーログを確認してください

            ## 修正方法
            - Jobをretryすることで解決する場合もあります
            - 分からない場合は Platform Teamメンバー に聞いてください

最後のstepにあるPlaybookについては GitHub ActionsのJobが落ちたときに何をするべきかを記述するPlaybookの仕組みを作って運用している話 - newmo 技術ブログ の記事で詳しく紹介しています。

tfactionについてはほとんどデフォルトの設定のまま使っています。

tfaction-root.yamlの設定の一部

# 高速化のためparallelism を50に変える (defaultは10)
env:
  TF_CLI_ARGS_plan: "-parallelism=50"
  TF_CLI_ARGS_apply: "-parallelism=50"

# lintの有効化(デフォルトで有効)
tflint:
  enabled: true
trivy:
  enabled: true

TFLintの設定は、まだ特別なものはないです。

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "google" {
    enabled = true
    version = "0.28.0"
    source  = "github.com/terraform-linters/tflint-ruleset-google"
}

rule "terraform_naming_convention" {
  enabled = true
}

今後の改善

newmoのTerraform Workflowにおける今後の改善点として考えているものには以下のようなものがあります。

TerraformやTerraform providerのバージョンを統一する

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する - newmo 技術ブログ で紹介したように、newmoではできる限りMonorepo内のTerraformやTerraform Providerのversionを統一したいと考えています。Terraformとしてterraform.tfに記述するバージョンを外から指定する機能は自分が知る限りないので、Terragrunt などのツールを試してみるかもしれません。

セルフサービス化

現在はPlatform teamが一元的にコードレビューを行っていますが、開発チームの安全で自律的な運用に向けて以下の取り組みを検討しています

  • Terraform Moduleより高度な抽象化
  • さまざまなセキュリティポリシーの自動チェック
  • TerraformやProviderの自動アップデート

Workflowの高速化

サービスやPlatformの成長とともに、Workflowのstepが増えたりTerraform stateが大きくなったりしてTerraform Workflowにかかる時間も増加していくことが予想されます。 今はまだ問題になることはないですが、Terraform Workflowに要する時間を計測して継続的に改善していきたいと考えています。

OpenTofu?

移行することは考えていないですが、気になっています。

ちなみにtfactionはOpenTofuもTerraguntもサポートしているようなのでその点も安心です。

まとめ

以上のようにnewmoではMonorepoにおいてtfactionを利用してTerraform Workflowを構築しています。 特に以下の点で効果を実感しています:

  • 運用しやすいシンプルなWorkflowの設定
  • Workflowの自動化による運用効率の向上
  • セキュリティとコード品質の確保
  • インフラ変更の安全性向上

GitHub ActionsでのTerraform Workflow構築を検討されている組織にとって、tfactionは優れた選択肢となるでしょう。 以前はいくつかnewmoのレポジトリにフィットしない部分もあったのですが、作者の suzuki-shunsuke さんに相談してv1.6.0に入れてもらった改善によって以前より快適に使えるようになりました。

書いた人: tjun

OpenTelemetry Collectorを使ったCloud Run to Datadogの実装パターン

newmoでは現在アプリケーションサーバーをCloud Runで動かし、Datadogを利用してサービスの監視をすることを考えています。 複数のCloud Runサービスからメトリクス、トレース、そしてログをDatadogへ送信する方法としていくつかのパターンが考えられます。 Datadogへメトリクスやトレース、ログを送る方法としてDatadog Agentを使う方法が一般的ですが将来のための柔軟性や拡張性を考えてOpenTelemetry Collectorを利用することを検討しました。この記事では、検討した構成案を紹介します。

(2025/05/21 に、現在の情報で更新しました)

はじめに

Datadog公式ドキュメントのOpenTelemetryのページでは、OpenTelemetryのテレメトリデータをDatadogへ送信する方法を紹介しています。 大きく分けてDatadog Agentを使う方法と、OpenTelemetry Collectorを使う方法があります。

docs.datadoghq.com

実装パターン

Cloud RunからDatadogへテレメトリデータを送信する方法として、以下のような実装パターンがあります。

1. アプリケーションから直接送信する

Datadog のライブラリを使って、直接送信することができます。 この方式はインフラ観点で管理するリソースが少ないというメリットがあります。しかし、アプリケーションの実装と密結合になってしまう、Envoyなど自分たちが実装していないサービスを動かす際に利用できない、などの課題があります。また、将来的には OpenTelemetry Protocol(OTLP)でも直接送信することができるようになると思いますが、まだ完全にはサポートされていません。

2. 同じコンテナでDatadog Agentを動かす

Datadogが公式にサポートしている方式です。

Datadogのserverless-initがアプリケーションのプロセスをwrapすることでメトリクスやトレース、ログを自動的に収集するものです。 すべてのCloud Runサービスのコンテナに設定が必要となります。

3. サイドカーとして動かす

Cloud Runでサイドカーが使えるようになったので、サイドカーで収集してDatadogへ送信することができます。サイドカーにもいくつかやり方があります。

  1. Datadog Agentをサイドカーとして動かす
  2. OpenTelemetry Collectorをサイドカーとして動かす
  3. Cloud RunのためのDatadog サイドカーを使う(2024年10月7日時点ではPrivate Beta)

アプリケーションコンテナからサイドカーに送り、サイドカーからDatadogに送信するという方式です。アプリケーションと分けてCollectorやAgenetを管理することができます。 各Cloud Runサービスごとにサイドカーの設定をする必要があります。 ログについては、サイドカーとの共有Volumeを作ってそこに書くか、OTLP(HTTP)でサイドカーへ送信することでOpenTelemetry Collectorが受け取ることができます。

最後のDatadog サイドカーは少し前に発表されていましたが、まだ公開されていないので今回の比較には入れていません。参考: Automatically instrument your Google Cloud Run services with the Datadog sidecar

4. Cloud RunサービスとしてOpenTelemetry Collectorを動かす

OpenTelemetry Collectorを独立したCloud Runサービスとして動かすこともできます。 Cloud Runサービスが一つ増えることにはなりますが、各Cloud Runサービスで追加のリソースはなくOpenTelemetry CollectorへOTLPで送信するだけになります。

newmoでは、Cloud Runサービスごとに設定を持たず1箇所に集約してPlatformチームで管理できる点と、OpenTelemetry Collectorの拡張性などの利点を考慮して、上記4のOpenTelemetry CollectorをCloud Runサービスとして動かす方式を採用してみることにしました。

OpenTelemetry Collectorの設定

各Cloud RunサービスからOpenTelemetry CollectorへOTLP(gRPC)でメトリクスやトレースを送信します。 ログについては、サービスがOTLPでログを送信する以前にエラーを出して停止してしまう場合もログを拾いたい、またCloud Runのリクエストログも合わせて取得したいということで、少し工夫して Cloud RunがCloud Loggingに出すログをLog RouterでPub/Subへ送り、OpenTelemetry Collectorの Pub/Sub receiverを使って取得する、という方式にしました。

Logの収集

設定は以下を参考にしました。

実際の設定例

receivers:
  otlp:
    protocols:
      grpc:
  googlecloudpubsub:
    subscription: projects/${env:GOOGLE_CLOUD_PROJECT}/subscriptions/${env:LOGGING_SUBSCRIPTION}
    encoding: googlecloudlogentry_encoding

processors:
  batch:
    # Datadog APM Intake limit is 3.2MB. Let's make sure the batches do not
    # go over that.
    send_batch_max_size: 1000
    send_batch_size: 100
    timeout: 10s
  memory_limiter:
    # drop metrics if memory usage gets too high
    check_interval: 1s
    limit_percentage: 65
    spike_limit_percentage: 20

connectors:
  datadog/connector: # DatadogのAPM Trace metricsのための設定

exporters:
  datadog/exporter:
    api:
      key: ${env:DD_API_KEY}
      site: api.datadoghq.com

extensions:
  googlecloudlogentry_encoding:
    handle_json_payload_as: "json"
    handle_proto_payload_as: "json"

service:
  extensions: [googlecloudlogentry_encoding]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [datadog/connector, datadog/exporter]
    metrics:
      receivers: [datadog/connector, otlp]
      processors: [batch, memory_limiter]
      exporters: [datadog/exporter]
    logs:
      receivers: [googlecloudpubsub]
      processors: [batch]
      exporters: [datadog/exporter]

こんな感じで設定して、メトリクス、トレース、ログを収集してDatadogへ送信することができていました。 長くなるので省略しますがtrace_idのフィールドがRequestログとApplicationログでズレていたりするのを揃えたり、など細かい調整も入れています。

OpenTelemetry Collectorのビルド

OpenTelemetry Collector は otel/opentelemetry-collector-contrib という全部入りのImageを使うこともできるのですが、必要なコンポーネントだけ入れたものを OpenTelemetry Collector Builder (ocb) を使ってビルドすることが推奨されています。 現在 googlecloudpubsub receiverで encoding: cloud_logging でログを読むとときどきクラッシュする問題があるため、とりあえず修正を手元で当ててビルドすることで回避しています。 この問題はversion v0.126.0では解消しています。

ocbに渡す設定例

dist:
  name: otelcol
  description: Custom OpenTelemetry Collector
  output_path: /app

receivers:
  - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.126.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/googlecloudpubsubreceiver v0.126.0
processors:
  - gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.126.0
  - gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.126.0
exporters:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter v0.126.0
connectors:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/datadogconnector v0.126.0
extensions:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/encoding/googlecloudlogentryencodingextension v0.126.0

これで必要なものを入れたImageを作成できます。

ハマったところ

ログ収集まわりのデバッグでログのフィールドの確認のためにOpenTelemetry Collectorのデバッグログを数行を出したところ、そのログをOpenTelemetry Collectorが拾って1行のログに対して数行ログが出るループができてログが爆発しました。ログ周りのデバッグ時は、OpenTelemetry Collector自身のログをExcludeする設定をしておいたほうが安全かもしれません。

まとめ

newmoでは、今後のOpenTelemetry Collectorの拡張性への期待から、OpenTelemetry CollectorをCloud Runサービスとして動かしてDatadogへテレメトリデータを送信しています。 まだ使い始めたばかりで送っているデータも多くないためパフォーマンスやコストは問題となっていませんが、今後もっと利用が増えていくと見直しが必要になる部分もあるかもしれません。 OpenTelemetry Collectorを使うのは初めてなので、改善できる部分や他の事例などがありましたら気軽に教えてもらえると助かります。

書いた人: tjun

newmoインターンがgqlparserにプルリクエストを投げた話

こんにちは。8月からnewmoでインターンをしている堀之内(@horinouchi09)と申します。

nemwoではバックエンドエンジニアとして、ビジネスドメインのAPIの開発やプラットフォームエンジニアリングのタスクなど多岐にわたってプロダクト開発に携わっています。

Go言語での開発は未経験からのスタートでしたが、バックエンドエンジニアのitoさんをはじめ多くの方々にサポートしていただき、楽しく開発ができています!

さて、今回は私がnewmoでのインターンを通して人生初のOSS Contributeをした話をします。

newmoの開発スタイル

newmoではクライアントからサーバーのAPIを呼び出すスタイルとしてGraphQLを採用しています(詳しくはこちらをご覧ください)。

GraphQLのディレクティブ

GraphQLに関する詳しい説明は省略しますが、GraphQLでは以下のようなスキーマを定義することでAPIを明示することができます。

# スキーマ定義
type Query {
  passenger: Passenger!
}

type Passenger {
  passengerId: String!
  name: String!
  nickname: String! @deprecated(reason: "old")
}

先ほどのスキーマ定義の例にあった@deprecated はディレクティブと呼ばれるものです。

GraphQLでは、ディレクティブをスキーマ定義のフィールドなどに付与することで情報を追加することができます。

例えば、先ほどの例の@deprecated は、Passengerのnicknameフィールドが非推奨であることを意味するGraphQLの標準仕様で定義されているディレクティブです。

さらに、@deprecatedのような標準で用意されているディレクティブに加えて、newmoでは独自のディレクティブを定義しています。

@validateStringというディレクティブがその一例です。

# 例
input DriverInformationInput {
  driverId: String! @validateString(format: UUID)
}

ここでの@validateString はクラアントから送られてくるdriverIdのバリデーションルールを明示できるディレクティブです。

ディレクティブとして定義することでdriverIdがUUIDの形式であることがクライアントからも明白になり仕様が明確になります。さらにnewmoではgqlgenというGo用のGraphQLコード生成ツールにプラグインを追加することでバリデーションのコードも自動で生成されるようにしています。

コード生成の実装としては、gqlgenが提供しているGenerateCodeというhookを利用してプラグインを書き、バリデーションの実装も含まれたresolverのコードを自動で生成されるようにしています。 

import "github.com/99designs/gqlgen/api"

// gqlgenによるコード生成部分
if err := api.Generate(
    cfg,
    api.AddPlugin(plugin.New()), // pluginの追加
); err != nil {
    return fmt.Errorf("failed to generate files: %w", err)
}
// pluginに実装しているGenerateCodeの例
func (m *Plugin) GenerateCode(data *codegen.Data) error {
    if !data.Config.Resolver.IsDefined() {
        return nil
    }
 
    // 具体的なロジック
    // 内部では"github.com/99designs/gqlgen/codegen/templates"のRenderでGoのファイルを生成しています。
    return m.generateSingleFile(data)
}

認可用のディレクティブの導入

APIにはそれぞれの認可用のトークンが必要です。しかし、現状のスキーマからはどのAPIがどのような認可情報が必要かわかりません。また、認可チェックを手書きで実装する必要があり漏れが発生しうる、などの問題がありました。

そこで認可用のディレクティブ@authorizationを定義することで表現力を高めつつ、そのようなディレクティブを元に認可チェックのコードも自動生成をしようと考えました。

@authorizationの仕様

type Query {
    Driver: Driver! @authorization(userType: hoge)
}

@authorizationは上記の例のようにQueryやMutationのフィールドに付与することで認可情報を表現するようにしたいと考えました。

しかし、このままでは全てのQueryやMutationのフィールドに対して@authorizationを付与していかなければなりません。また、newmoでは各スキーマの大半のAPIが同一の認可情報を必要としていたので、スキーマのデフォルトの認可情報を定義できた方が合理的です。

そこで以下のようにschemaに対して@authorizationを付与することにしました。

extend schema @authorization(userType: hoge)

type Query {
    Driver: Driver!
}

このように定義して、@authorizationのないDriver もuserTypeがhogeであることが必要になるものとして実装を進めます。

gqlgenによるコード生成

ディレクティブの仕様が決まれば、次は@authorizationに沿ったコードを生成するgqlgenのプラグインの実装です。

新たなファイルを生成するには、以下のCodeGeneratorというインターフェイスを満たすプラグインを作り、GenrateCode内でロジックを書くことになります。

type CodeGenerator interface {
    GenerateCode(cfg *codegen.Data) error
}

ここでのcodegen.Dataは以下のような構造体です。

この中でschemaに付与したディレクティブが入ってそうなSchema *ast.Schema は以下のような構造体でした。

type Schema struct {
    Query            *Definition
    Mutation         *Definition
    Subscription     *Definition

    Types      map[string]*Definition
    Directives map[string]*DirectiveDefinition

    PossibleTypes map[string][]*Definition
    Implements    map[string][]*Definition

    Description string

    Comment *CommentGroup
}

しかし、このSchemaという構造体の中にはschemaに付与されたディレクティブが入っていませんでした(この中のDirectives はスキーマ全体で定義されているディレクティブを指していて、schemaに付与されたディレクティブを格納している訳ではありません)。

gqlgenの利用するDataにschema上のディレクティブが入っていないのでは、プラグインとして先ほどの仕様を満たすコード生成を実装することができません。

gqlparserの修正

gqlgenはgqlparserというGraphQLスキーマをパースする別リポジトリのOSSに依存しています。

特に前述したSchema *ast.Schemaには、gqlparserのLoadSchemaという関数を用いて定義されたスキーマが落としこまれています。gqlparserのLoadSchemaの中で使われているvalidatorのLoadSchemaは以下のような関数です。

func LoadSchema(inputs ...*Source) (*Schema, error) {
    sd, err := parser.ParseSchemas(inputs...)
    if err != nil {
        return nil, gqlerror.WrapIfUnwrapped(err)
    }
    return ValidateSchemaDocument(sd)
}

よくよくgqlparserを読み込んでいくと、ParserSchemaではschemaに付与したディレクティブをきちんとParseしていましたが、ValidateSchemaDocument 内で捨てられていることがわかりました。

そこで、以下のようにSchemaDirectivesというフィールドを追加しValidateSchemaDocument 内で付け足すように変更するプルリクエストを投げることにしました。

type Schema struct {
    Query            *Definition
    Mutation         *Definition
    Subscription     *Definition
    SchemaDirectives DirectiveList // このフィールドを追加!

    Types      map[string]*Definition
    Directives map[string]*DirectiveDefinition

    PossibleTypes map[string][]*Definition
    Implements    map[string][]*Definition

    Description string

    Comment *CommentGroup
}

実際のプルリクエストがこちらです。

https://github.com/vektah/gqlparser/pull/318/files

こちらのプルリクエストがマージされ、実際にnewmoのgqlgenはSchemaに付与したディレクティブを元にコード生成ができるようになりました。

終わりに

このような背景で人生初のOSS Contributeに成功することができました!

newmoでは周りのエンジニアの方々が当たり前のようにOSSにプルリクエストを投げているので、自分自身の意識も変わってきている感覚があります。

newmoはビジネスドメインの開発にスピード感がある一方で、生産性を高めるためのツールの整備や将来の負債にならないような設計などにも力を入れており、インターンとして勉強できることが大変多いです。

今後もnewmoのプロダクト開発に少しでも力になり、「移動で地域をカラフルに」を実現できるように邁進します!


newmoではエンジニアを積極的に採用中です!キャリアサイトはこちら↓ careers.newmo.me

YAPC::Hakodate 2024 に参加&学生支援ランチでLTしました! #yapcjapan

YAPC::Hakodate 2024に社員4名が参加&学生向けにLTを実施しました

こんにちは。newmoのソフトウェアエンジニアの @tenntenn です。

2024年10月5日に開催されたYAPC::Hakodate 2024にて、今年はnewmoからエンジニア4名が参加。さらに、学生支援スポンサーとして学生向けにLTをさせていただきました。

このブログでは、LTの登壇資料や補足情報、参加した社員の感想などをシェアします。

 

学生支援スポンサーランチLT

 

参加された学生のみなさんに向けて、tenntennが入社した理由についてお話しました。自分の身の回りの課題を自分で解決したいという思いを語りました。

参加したnewmoエンジニアたち

当日はnewmoからこの4名が参加しました!左から、 @kamipo, genkey6@shugenshugen, @tenntenn, @yaotti

公式バッグに封入したチラシの一面

東京からクリアファイルとステッカーを持ち込み、お話した方に直接お渡ししたりも。

参加した人の感想

@kamipo

今回のYAPC期間中にフォロワーが70人増えました!たくさんお話してくれたひとたちありがとうございます!また次のYAPCでたくさんお話しましょう!

 

genkey6(@shugenshugen)

YAPC初参加勢でしたが、前夜祭から2日間参加させていただき、皆さんの熱量に圧倒されました!当日は主にセッション巡りをしていたのですが、どの発表も濃密でとても勉強になりました。

来年は登壇含めて盛り上げていけるようにチャレンジしたいです!

 

@yaotti

交流させてくださった皆さん、運営スタッフや登壇された方々、ありがとうございました!YAPC::* は10年ぶりの参加でしたが、面白く楽しい時間を過ごすことができました。

newmoとしてもスポンサーに加えて登壇などでコミュニティ貢献していけるように頑張りますー!

 

おわりに

私も今回が初めてのYAPC参加でした。普段参加しているGoコミュニティとはまた違った雰囲気で楽しかったです。

(苫小牧から函館は遠いですが)住んでいる北海道での開催だったので嬉しかったです!はこだて未来大学のキャンパスも素晴らしく、こういう雰囲気で学べる学生の皆さんは羨ましいなと感じました。

次回は地元(宮崎)のある九州(福岡)で計画中ということなので、そちらも楽しみです!

 

newmoではエンジニアを積極的に採用中です!キャリアサイトはこちら↓

careers.newmo.me

 

newmoでは新卒採用も行っています!興味ある方は長期インターン(ソフトウェアエンジニア)にぜひご応募ください。詳しくは↓のページをご覧ください。

careers.newmo.me

 

2024年10月28日(月)に「地域交通の未来を創る 新卒採用説明会」というイベントを開催します。興味のある方はぜひお越しください。

 

地域交通の未来を創る 新卒採用説明会

イベントの詳細確認や参加登録は以下のフォームから行えます。

https://forms.gle/g1jK6BVxi4Ev8pW19

Google Cloud PAMを使った権限昇格の仕組みと、Terraformでloopをネストする方法

PAM(Privileged Access Manager)とは

Google CloudのPrivileged Access Manager(PAM)という機能をご存知でしょうか。

詳しくは 新しい Privileged Access Manager を使用して常時オンの特権からオンデマンド アクセスに移行 | Google Cloud 公式ブログ に書かれています。

簡単な方法で、必要なときにのみ、必要な期間だけ、必要なアクセス権を正確に取得できるようにすることで、最小権限の原則を実現するのに役立ちます。PAM は、常時オンの常設特権から、ジャストインタイム(JIT)、時間制限付き、承認ベースのアクセス昇格を備えたオンデマンドの特権アクセスに移行できるようにすることで、リスクを軽減します。

常に強い権限を持つ運用にしていると、アカウントが乗っ取られた場合のリスクや開発環境だと思って作業したら本番環境だった、みたいなオペレーションミスのリスクがあります。普段は必要最低限の権限にしておいてPAMを使って権限昇格する運用にすると、権限を取得するためには他の人からの承認が必要となり、また時間経過とともに自動的に権限が元に戻るので、リスクを減らすことができます。 PAMを使わずTerraformなどで権限昇格の設定を書いて承認をもらって権限を取得する、という運用もできますが、時間制限で自動的に権限が消えること、また申請して権限昇格するまでのリードタイムを考えるとPAMのような仕組みがあると便利です。

つまり、普段は必要最低限の権限だけ持っておいて必要なときだけPAMを使って権限昇格して一時的に必要な権限を取得するようにすることで、セキュリティと利便性のバランスを取ることができます。

PAM利用の流れ

PAMでは、Entitlementという事前に作成した権限に対して申請を行い、指定した承認者が承認・却下を行います。 newmoではSlackにPAMの申請の通知を流し、Platform teamが承認を行う運用にしています。

PAMの利用

PAMを使えるように設定をする

PAMを利用するためには、事前にEntitlementを作っておく必要があります。 Entitlementでは以下のような項目を指定します。

  • 申請できるアカウント(user, groupなど)
  • 承認できるアカウント(user, groupなど)
  • 申請する権限
  • emailの通知先
  • 権限付与できる最大の時間(3時間など)

申請可能な権限の数だけEntitlementを各Google Cloudのprojectで作成しておく必要があります。そこでPAMのためのTerraform moduleを作って各Projectで設定することにしました。しかし一つ課題がありました。

Terraformのloopをネストする

PAMのためのEntitlementを必要となる権限に対して用意するにあたって、複数の権限をセットにして扱いたいことがありました。 たとえば、 roles/secretmanager.secretVersionAdder の権限だけを付与してもGoogle Cloud コンソールから作業するためには roles/secretmanager.viewer も必要となる、みたいなことがあるため、それぞれの権限を別で申請しなくても良いように権限セットを1つにまとめたEntitlementを作成しておきたいです。 そのためにはTerraformにおいて 権限セットごとにEntitlementを作成する というloopと 権限セットに複数の権限を入れる というloopが必要になるのですが、Terraformでは for_eachはネストできないという課題があります。

そこで今回はmoduleの中でmoduleを利用することで対応することにしました。以下のように書くことができます。

pam-module では、role setの数だけpam-internalを作ります

module "pam-internal" {
  source     = "./pam-internal"
  project_id = var.project_id

  for_each    = merge(local.default_role_sets, var.role_sets)
  name        = each.key
  roles       = each.value
  slack_email = data.google_secret_manager_secret_version_access.pam_slack_email.secret_data
}

pam-internal moduleでは、一つのentitlementにrole set内のroleの数だけblockを作ります

resource "google_privileged_access_manager_entitlement" "entitlements" {
  entitlement_id       = var.name
  max_request_duration = "18000s" # 5 hours
  parent               = "projects/${var.project_id}"

  privileged_access { 
    gcp_iam_access {
      dynamic "role_bindings" {
        for_each = toset(var.roles)  # roleの数だけ作成
        content {
          role = role_bindings.value
        }
      }
      resource      = "//cloudresourcemanager.googleapis.com/projects/${var.project_id}"
      resource_type = "cloudresourcemanager.googleapis.com/Project"
    }
  }

  eligible_users {
    principals = [
      "group:abc@example.com" # 申請できるgroup
    ]
  }

  approval_workflow {
    manual_approvals {
      steps {
        approvers {
          principals = [
            "group:platform@example.com".  # platform teamが承認
          ]
        }
      }
    }
  }
}

実際に利用するところでは以下のような感じで指定できます。

module "pam" {
  source = "..../modules/pam"

  project_id  = "newmo-example"

  role_sets = {
    "bigquery-data-editor" = [
      "roles/bigquery.user",
      "roles/bigquery.dataEditor",
    ]
    "bigquery-data-viewer" = [
      "roles/bigquery.user",
      "roles/bigquery.dataViewer",
    ]
    "secret-manager-secret-version-adder" = [
      "roles/secretmanager.viewer",
      "roles/secretmanager.secretVersionAdder",
    ]
    "storage-object-viewer" = [
      "roles/storage.objectViewer",
    ]
  }  
}

(ここで挙げたコードは多くの部分を省略しているので、そのままでは動きません)

これを各projectで設定することで、Entitlementの準備ができました。 あとはPAMを利用するように必要な権限を付与しておけば、ドキュメントのとおりにGoogle Cloudコンソールから申請や承認が可能です。

使ってみた感想

以前に似たような仕組みを作ったことがあったのですが、Google Cloudの標準の機能として提供されたのはうれしいです。 課題としては、EmailをSlackに飛ばしている場合かなり見にくく、申請なのか承認された通知なのかExpireされた通知なのか、よく見ないと分からない点があります。公式にSlack通知をサポートしてもらえるとうれしいです。

参考

書いた人: tjun

まずはイテレータ(range over func)の仕様を学ぼう - Goのイテレータ深堀りNight

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 本稿では、2024年9月24日(火)にファインディ株式会社主催の「Goのイテレータ深堀りNight」というイベントで登壇してきましたので、その報告と内容について紹介します。

findy.connpass.com

「Goのイテレータ深堀りNight」は、2024年8月にリリースされたGo1.23の機能の1であるrange over func(通称イテレータ)について、6人の登壇者がさまざまな角度で10分のライトニングトーク(LT)を行うイベントです。筆者は、トップバッターということで「まずはイテレータ(range over func)の仕様を学ぼう 」という発表を行いました。

登壇に用いた資料は次のリンクから閲覧ができます。

docs.google.com

イテレータの導入経緯

イテレータの導入経緯

イテレータは単独で導入を検討されていた機能ではありません。Go1.18でジェネリクス(型パラメータ)が導入される際の議論からうまれた機能です。図のように、Go1.18ではジェネリクス(型パラメータ)について言語仕様の追加や宣言済み識別子(anyとcomparable)の追加は行われたものの、標準ライブラリはgoパッケージなどを除いて公開されているAPIはほとんど更新されませんでした。

Goコミュニティにおいて、ジェネリクスを使ったコードの経験がある程度貯まるまでは、標準ライブラリへに追加しないという理由で、slicesパッケージやmapsパッケージはx/expパッケージ以下で実験的に公開されていました。実際にslicesパッケージとmapsパッケージの一部の機能は変更され、constraintsパッケージは公開されず、一部の機能がcmpパッケージとして標準ライブラリに追加されました。

そして、Go1.21でついにslicesパッケージとmapsパッケージは標準ライブラリに追加されました。しかし、maps.Keys関数やmaps.Values関数など、スライスを返す関数は、x/exp/mapsパッケージでは提供されていたものの、標準ライブラリとしては追加されませんでした。これらの関数はスライスで返すのではなく、新たに議論が始まっていたイテレータ型として返すべきだという議論になったからです。

次のリンクから当時の議論を追うことができるでしょう。

github.com

github.com

有志が作成しているGoの歴史をまとめているサイトgolang.design/historyによれば、イテレータに関連する提案はかなり前からされていたようです。

golang.design

議論が活発になったのは、ジェネリクスがGo1.18でリリースされ、次のGitHub Discussionが作成されてからでしょう。

github.com

このDiscussionでは、次のようなインタフェースを満たす型の値がfor range文でNextメソッドを呼び出されていくことでイテレーションができるというものでした。

// Iter supports iterating over a sequence of values of type `E`.
type Iter[E any] interface {
    // Next returns the next value in the iteration if there is one,
    // and reports whether the returned value is valid.
    // Once Next returns ok==false, the iteration is over,
    // and all subsequent calls will return ok==false.
    Next() (elem E, ok bool)
}

この議論は後方互換性の観点からインタフェースでイテレータの仕組みを提供するのは難しいという結論になり、次に示したDiscussionにあるように関数を用いた方法に議論が移っていきました。

github.com

そして議論の末、ついにGo1.23でイテレータ(range over func)がリリースされました。しかし、型パラメータの時と同じように標準ライブラリの変更は最低限になっており、slicesパッケージやmapsパッケージにイテレータを返す関数が少し追加されただけです。

それでもGopher(Goのユーザ)たち念願のmaps.Keys関数が導入されました。なお、maps.Keys関数はfor range文でマップ型の値をイテレーションしてキーを取得した場合と同様にキーの並び順がランダムになるため、次のようにslices.Sorted関数かslices.SortedFunc関数を使用すると良いでしょう。

m := map[string]int{"dog": 1, "cat": 99}
fmt.Println(slices.Sorted(maps.Keys(m))

型パラメータのおさらい

型パラメータのおさらい

このようにイテレータは型パラメータ(ジェネリクス)と密接に関係した機能です。型パラメータを知らずにイテレータを理解することは難しいため、本登壇ではイテレータの説明の前に型パラメータについて、図のように簡単におさらいをしました。

パラメータという言葉は、ここでは「外から変更できるもの」という意味で使用しています。日頃からよく使う関数のパラメータ(引数)と型パラメータを対比させて解説しました。

関数パラメータ(引数)を使用しない場合、関数内で使用する値は関数内で宣言した定数や変数、リテラル表記した値を使用する必要があり、関数の外から変更できません。そこで関数に引数を設けることで外から値を指定できるようになります。また、次のように関数宣言に記述する引数のことを特に仮引数(parameter)と呼び、F(10)のように実際に指定した引数のことを実引数(argument)と呼びます。

func F(n int) {
    fmt.Println(n)
}

関数の引数を値のパラメータだとすると、外部から変更できるものを型にしたものが型パラメータになります。 たとえば、次のような関数において、引数の型を外部から指定できるようにしたい場合を考えます。

func G(v int) {
   fmt.Println(v)
}

次のように記述すると、引数の型を外部から指定できます。この時、Tは型パラメータと呼ばれ、anyは型制約(type constraint)となります。 型制約は、値に対する型のようなもので型パラメータTとして受け入れる型の条件でインタフェースを指定します。 なお、anyはどんな型でも受け付けることを表しています。 比喩的に表現すると、値に対する型や型パラメータに対する型制約は、求人における募集要項のようなものだと考えると良いでしょう。 条件に示したこと以上の働きはできないので、適切な条件を指定する必要がある点が似ています。

func G[T any](v T) {
   fmt.Println(v)
}

また、G[int](10)のように実際に指定した型のことを型引数(type argument)と呼びます。 関数における値の引数の仮引数・実引数は、型パラメータにおける型パラメータと型引数に対応しています。

さらにジェネリクス(型パラメータ)について学びたい場合は、公式チュートリアルや筆者のスライドを参考にすると良いでしょう。

docs.google.com

言語仕様のアップデート

イテレータ(range over func)

図のように、Go1.23ではfor range文のrange expressionに関数が使えるようになりました。どんな関数でも良いわけではなく、引数にyield関数と呼ばれる関数を取るような3種類の関数です。それぞれの違いは引数にとるyield関数の引数の個数になり、0個、1個、2個の3種類になります。yield関数はfor range文のボディに処理を移す役割があるため、これらの数および型はfor range文の左辺の識別子(または式)に一致しています。

range over funcと他のrange over

図のようにGo1.22までは、range expressionで扱える式の型は、配列、配列へのポインタ、スライス、文字列、チャネル(双方向、受信専用)、整数(Go1.22で導入)でした。これらの値に対するイテレーションは、型ごとに決められています。たとえば、スライスや配列であれば先頭から順にアクセスし、マップであればランダムな順番でアクセスします。そして、range over func(イテレータ)によって、任意のデータ構造を任意のアルゴリズムでシーケンシャルにアクセスできるようにするになりました。

重要な3つの仕様

図のようにイテレータには3つの重要な仕様があります。1つめは、for range文が実行される際に、range expressionに指定した関数が1回だけ実行される点です。イテレーションのアルゴリズムは、この関数に移譲されると考えると良いでしょう。2つめは、yield関数を呼ぶとfor range文のボディに処理が移る点です。for range文のボディが1回だけ実行され、yield関数はリターンされます。3つめは、breakなどでfor range文のイテレーションが途中で終了した場合、yield関数はfalseを返す点です。

簡単なイテレータの例を次に示します。Alphabetはアルファベット列を生成するようなイテレータです。 イテレータとは関係ありませんが、rune型は整数なのでfor c := 'A'; c <= 'Z'; c++で、c'A'から'Z'まで変化していきます。

func Alphabet(yield func(rune) bool) {
    for c := 'A'; c <= 'Z'; c++ { // 'A', 'B', 'C', …
        if !yield(c) { return }
    }
}

func usage() {
    // ABC
    for c := range Alphabet {
        fmt.Printf("%c", c)
        if c == 'C' { break }
    }
}

標準ライブラリの変更点

標準ライブラリの変更点

Go1.23では言語仕様の変更の他に、iterパッケージが導入されました。iterパッケージでは、図のようにSeq[V]型とSeq2[K, V]型が提供されています。 この2つの型のおかげで、次のようにイテレータをわかりやすく表記できるようになっています。

func Map[X, Y any](seq iter.Seq[X], f func(X) Y) iter.Seq[Y] {
    return func(yield func(Y) bool) {
        for x := range seq { if !yield(f(x)) { break } }
    }
}

func usage() {
    seq := Map(slices.Values([]int{10, 20}), func(x int) string {
        return fmt.Sprintf("0x%x", x)
    })

    // 0xa
    // 0x14
    for v := range seq { fmt.Println(v) }
}

Map関数は、あるiter.Seq[X]型のイテレータをiter.Seq[Y]型に変換する関数です。各要素は第2引数の関数fによって変換されます。 なお、slices.Values関数は、Go1.23で導入されたスライスの値からなるイテレータを生成する関数です。

注意点

注意点

図のように、yieldが宣言済み識別子(組み込み型)や予約語ではありません。ちなみに宣言済み識別子が増えることはありますが、予約語が増えたことはありません。 予約語は識別子にできない語なので、予約語を追加する場合は後方互換に気を使う必要があるからです。 Go1.21で互換性に関する機能の拡張が行われたため、予約語を追加しても後方互換性がなくなる可能性は低いですが、気軽に増やせるものではないでしょう。

また、yield関数がfalseを返した場合、さらに yield 関数を呼ぶとパニックが発生します。そのため、戻り値を if 文などでハンドリングしておく必要があります。

for range 文のrange expressionに値が nil の関数を指定した場合、パニックが発生します。これは通常の関数呼び出しの場合と同じ挙動になっています。標準ライブラリでは、引数でイテレータを受け取る場合でも nil かどうかのチェックは特別行っていないようです。一方で戻り値でイテレータを返す場合には、 nil を返さないように作られています。

おわりに

本稿では、Go1.23で導入されたイテレータについて、その仕様に紹介しました。標準ライブラリで本格的に活用されるのはGo1.24になるようですが、それまでにぜひ触ってみましょう。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me

go testの時だけ時刻を固定する

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 newmoには2024年8月に入社しました。この記事を書いているのは2024年9月なので、入社してだいたい1ヶ月ちょっとが経過したところです。 なお、筆者が入社した経緯などは次の記事を読んでください。

note.com

入社した当初、newmoのバックエンドコードのコードを眺めていると、次のように宣言された関数を見つけました。

func Now(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

単にtime.Now関数を呼び出して、LocationUTCに設定しているだけです。 しかも、引数はブランク識別子になっているので使用していません。

しかし、筆者はこれを見て、これは後々のことを考えているなと感心しました。 ちなみに変数と違って関数内で使用してなくてもコンパイルエラーにはならないためブランク識別子(_)にする必要はありません。 それにも関わらず、あえてブランク識別子にしているのは、今は使ってないけど将来は使いますよと気持ちを暗示しているように見えました。 (意図は社内で確認してないので、考えすぎかもしれません。)

ちなみに、Goの言語仕様では次のようにブランク識別子すら使用せず、型名だけでも問題ありません。 ただし、この形を取る場合は、すべての引数の識別子を省略する必要があります。

func Now(context.Context) time.Time {
    return time.Now().In(time.UTC)
}

さて、この引数のコンテキストは何のためにあるのでしょうか? 実はこのコンテキストはテストの際に時刻をコントロールするためにあります。 特に時刻を特定の時間で固定するために使用することを想定しています。

その後、プロダクト開発していく中で時刻を固定してテストがしたくなり、筆者がコンテキストを使って時刻を固定できるように変更しました。 また、本稿を書くにあたって、ライブラリとして切り出しOSSとして公開してあります。

そこで、本稿ではコンテキストを使って時刻を固定する方法とその方法で実装されたライブラリについて紹介します。

コンテキストを使って時刻を固定する

テストのために時刻を固定する方法はいくつか存在します。 たとえば、筆者が書いた記事には次の方法が挙げられていました。

  • 引数に現在時刻を渡す
  • パッケージ変数やフィールドなどに現在時刻を返す関数やインタフェースを設定する
  • context.WithValue関数でコンテキストに現在時刻を設ける

スレッドセーフなテスト用の時間を固定するライブラリを作った - tenntenn.dev

この記事ではさらに筆者が開発したtesttimeというライブラリが紹介されていました。 testtimeは便利ですが、linknameや-overlayフラグなど、若干"やんちゃ"な機能を使用していました(詳細はtestimeの記事をご覧ください)。

テストのときだけとは言えども、できるなら"通常の"やり方で解決したいところです。 やはり、安全に時刻を固定するには、プロジェクトの初期からテストで時刻を固定したくなるときが来ることを考慮しておく方が良いでしょう。

冒頭に紹介したNow関数は、コンテキストを引数に取っているため、後から如何様にも実装を拡充できるようになっています。 たとえば、次のようなWithFixedNow関数を作成し、コンテキストに特定の時刻を紐づけることもできます。

func Now(ctx context.Context) time.Time {
    if testing.Testing() {
        return nowForTest(ctx)
    }
    return defaultNow(ctx)
}

func defaultNow(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

func nowForTest(ctx context.Context) time.Time {
    now, ok := nowFromContext(ctx)
    if ok {
        return now
    }
    return defaultNow(ctx)
}

type ctxkey struct{}

func WithFixedNow(t *testing.T, ctx context.Context, tm time.Time) context.Context {
    t.Helper()
    return context.WithValue(ctx, ctxkey{}, tm)
}

func nowFromContext(ctx context.Context) (time.Time, bool) {
    tm, ok := ctx.Value(ctxkey{}).(time.Time)
    return tm, ok
}

WithFixedNowの第1引数に*testing.T型を指定する必要があるのは、テスト以外で呼ばれることを防ぐためです。 また、Now関数はtesting.Testing関数がtrueを返す場合のみ、つまりテストの時だけコンテキストから時刻を取得するようにしています。

テストの時だけ挙動を変える

前述の方法では、テストのときにだけ時刻を固定することができますが、Now関数を宣言したパッケージがtestingパッケージをインポートする必要があります。 そこで、次にテスト以外ではtestingパッケージをインポートせずに、テストの時だけ挙動を変える方法を考えてみましょう。

次のように、Now関数を宣言しているパッケージをctxtimeと名付け、テストで使用するWithFixedNow関数などは、サブパッケージのctxtimetestパッケージに移動させます。 また、ctxtimeパッケージがtestingパッケージに依存しないように、internalパッケージを用意します。

ctxtime                                                                                                            
├── ctxtime.go                                                                                                     
├── ctxtimetest                                                                                                                                                                                      
│   └── ctxtimetest.go                                                                                        
└── internal                                                                                                       
    └── now.go  

Goにおいて、internalパッケージは特別なパッケージです。 internalという名前のディレクトリ以下に配置したソースコードやパッケージは、そのinternalディレクトリが存在するディレクトリ以下でしか参照できません。 詳細は筆者が前職時代に書いた記事を参照してください。

qiita.com

それでは、配置し直した各ファイルの中身を見ていきましょう。 ctxtimeパッケージには、次のようにNow関数だけを配置します。

// ctxtime/ctxtime.go
package ctxtime

import (
    "context"
    "time"

    "github.com/newmo-oss/ctxtime/internal"
)

func Now(ctx context.Context) time.Time {
    return internal.Now(ctx)
}

大部分の処理はinternalパッケージに移動させます。 そして、internalパッケージでは次のように宣言しておきます。

// ctxtime/internal/now.go
package internal

import (
    "context"
    "time"
)

var Now = DefaultNow

func DefaultNow(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

ctxtime.Now関数の挙動を変更できるように、internal.Nowは変数として宣言します。 デフォルト値としてinternal.DefaultNow関数が設定されています。

そして、次のようにctxtimetestパッケージのinit関数でinternal.Now変数の値を変更しています。

// ctxtime/ctxtimetest/ctxtimetest.go
package ctxtimetest

import (
    "context"
    "sync"
    "testing"
    "time"

    "github.com/newmo-oss/ctxtime/internal"
)

func init() {
    if testing.Testing() {
        internal.Now = nowForTest
    }
}

func nowForTest(ctx context.Context) time.Time {
    now, ok := nowFromContext(ctx)
    if ok {
        return now
    }
    return internal.DefaultNow(ctx)
}

type ctxkey struct{}

func WithFixedNow(t *testing.T, ctx context.Context, tm time.Time) context.Context {
    t.Helper()
    return context.WithValue(ctx, ctxkey{}, tm)
}

func nowFromContext(ctx context.Context) (time.Time, bool) {
    tm, ok := ctx.Value(ctxkey{}).(time.Time)
    return tm, ok
}

このようにすることでctxtimeパッケージは、testingパッケージに直接依存することなくテストの時だけ時刻を固定できるようになります。

テストごとのID

newmoでは、テストごとにtest idと呼ばれるID(通常はUUID)を採番しています。 この他にテストを一意に識別するには、*testing.T型のNameメソッドを用いる方法もあります。 しかし、テスト(サブテストも含む)でIDを複数用いたくなることを想定して、テスト名とは別にIDを付与することにしています。 また、テスト時にtest idをHTTPのヘッダやgRPCのメタデータに付与することで、どのテストからリクエストが来たか分かるようにしています。

testidパッケージは、以下のリポジトリでOSSで公開されています。

github.com

newmoで開発・使用しているctxtimeパッケージも前述したコンテキストとキーを使った方法ではなく、次のようにこのtest idを使用する方法を使用しています。 WithFixedNow関数の代わりにSetFixedNow関数が用意され、UnsetFixedNow関数で設定した時刻を削除できるようにしています。

なお、UnsetFixedNow関数を呼ばなくてもテスト終了時に(*testing.T).Cleanup関数で自動で削除されるように作られています。

// ctxtime/ctxtimetest/ctxtimetest.go
package ctxtimetest

import (
    "context"
    "sync"
    "testing"
    "time"

    "github.com/newmo-oss/ctxtime/internal"
    "github.com/newmo-oss/testid"
)

var fixedNows sync.Map

func init() {
    if testing.Testing() {
        internal.Now = nowForTest
    }
}

// SetFixedNow fixes the return value of ctxtime.Now.
// The fixed current time is set each test id which get from [testid.FromContext].
// If any test id cannot obtain from the context, the test will be fail with t.Fatal.
// The fixed current time will be remove by t.Cleanup.
func SetFixedNow(t testing.TB, ctx context.Context, tm time.Time) {
    t.Helper()

    tid, ok := testid.FromContext(ctx)
    if !ok {
        t.Fatal("failed to get test ID from the context")
    }

    t.Cleanup(func() {
        fixedNows.Delete(tid)
    })

    fixedNows.Store(tid, tm)
}

// UnsetFixedNow removes the fixed current time which was set by [SetFixedNow].
// If any test id cannot obtain from the context, the test will be fail with t.Fatal.
func UnsetFixedNow(t testing.TB, ctx context.Context) {
    t.Helper()

    tid, ok := testid.FromContext(ctx)
    if !ok {
        t.Fatal("failed to get test ID from the context")
    }

    fixedNows.Delete(tid)
}

func loadFixedTime(ctx context.Context) (time.Time, bool) {
    tid, ok := testid.FromContext(ctx)
    if !ok {
        return time.Time{}, false
    }

    v, ok := fixedNows.Load(tid)
    if !ok {
        return time.Time{}, false
    }

    tm, ok := v.(time.Time)
    if !ok {
        return time.Time{}, false
    }

    return tm, true
}

func nowForTest(ctx context.Context) time.Time {
    tm, ok := loadFixedTime(ctx)
    if !ok {
        return internal.DefaultNow(ctx)
    }
    return tm
}

Linterを使ったtime.Now関数の呼び出しの検出

ctxtime.Now関数を効果的に使うためには、プロジェクト全体でtime.Now関数ではなくて、ctxtime.Now関数を使用するというルールを設ける必要があります。 ルールを守っているかレビューでチェックするようにしていると、時間が経つにつれ形骸化しがちです。

そこでtime.Nowを使っている箇所を検出するctxtimechekというLinterも合わせて作成しました。 なお、次のようにgolang.org/x/tools/go/analysisパッケージ(以下、go/analysisパッケージ)を用いて作成しています。

// ctxtime/ctxtimecheck/ctxtimecheck.go
package ctxtimecheck

import (
    "go/types"

    "github.com/gostaticanalysis/analysisutil"
    "github.com/gostaticanalysis/ssainspect"
    "golang.org/x/tools/go/analysis"
)

const doc = "ctxtimecheck finds calling time.Now instead of ctxtime.Now"

// Analyzer finds calling time.Now instead of ctxtime.Now.
var Analyzer = &analysis.Analyzer{
    Name: "ctxtimecheck",
    Doc:  doc,
    Run:  run,
    Requires: []*analysis.Analyzer{
        ssainspect.Analyzer,
    },
}

func run(pass *analysis.Pass) (any, error) {
    in := pass.ResultOf[ssainspect.Analyzer].(*ssainspect.Inspector)

    timenow, _ := analysisutil.ObjectOf(pass, "time", "Now").(*types.Func)
    if timenow == nil {
        // skip
        return nil, nil
    }

    for in.Next() {
        c := in.Cursor()
        if analysisutil.Called(c.Instr, nil, timenow) {
            pass.Reportf(c.Instr.Pos(), "do not use %s, use ctxtime.Now", timenow.FullName())
        }
    }

    return nil, nil
}

Linterなどの静的解析ツールやgo/analysisパッケージについては、300ページ超えでちょっとだけ長いですが次のスライドが参考になります。

docs.google.com

time.Now関数はもちろん、time.Now関数をローカル変数に代入して呼び出している箇所も検出できます。 現在の実装ではパッケージ変数に代入した場合は検出できませんが、今後のバージョンアップで対応予定です。

ctxtimechekは、次のようにgo installを用いてインストールができます。

$ go install github.com/newmo-oss/ctxtime/ctxtimecheck/cmd/ctxtimecheck@latest

インストールした実行可能ファイルは、go vetコマンドの-vettoolフラグに絶対パスを指定することで利用できます。

$ go vet -vettool=$(which ctxtimecheck) ./...

なお、現在のctxtimecheckは筆者が開発しているcalledという静的解析ツールでも同様の動作をします。 calledを導入している方は、次のように指定することで同様の結果が得られるでしょう。

$ go vet -vettool=$(which called) -called.funcs="time.Now" ./...

github.com

おわりに

本稿では、newmoで活用しているgo test時に時刻を固定する方法とOSS化しているctxtimeパッケージとtestidパッケージの紹介をしました。 time.Now関数だけではなく、time.Ticker型を使ったコードなどもテストしにくいので、今後のアップデートで対応できたらと考えています。

newmoではスピード感を保ちながら新しいプロダクトを開発しつつ、技術へのチャレンジを惜しまず日々の開発を行っています。 これからもプロダクト開発の中で生まれたライブラリや知見は、惜しまず技術コミュニティにお返しできればと考えています。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me