ROXX開発者ブログ

Go 言語における Functional Option Pattern をラーメン屋で理解する

この記事は個人ブログと同じ内容です

www.ritolab.com


Functional Option Pattern は、Go 言語でよく使用される設計パターンの 1 つです。このパターンを使用することで、柔軟で拡張性の高い API 設計が可能になります。この記事では、Functional Option Pattern の基本的な考え方と実装方法について解説します。

Functional Option Pattern

Functional Option パターンは、関数を使ってオプションの設定を定義し、適用するデザインパターンです。

長いパラメータリスト(その多くがオプション)をコンストラクタや関数に渡す代わりに、設定用の関数(オプション)をリストとして渡します。これにより、デフォルトの設定や挙動を柔軟に変更できます。

Functional Option Pattern の主な利点

  1. オプショナルな設定を柔軟に扱える
    • 必要なオプションのみを指定でき、不要な設定は省略できる
    • オプションの順序も自由に変更できる
    • オプション間の依存関係も明確に表現できる(例:特定の設定が有効な場合のみ必要となる追加設定など)
  2. デフォルト値を自然に設定できる
    • 新しい設定項目を追加する際に、デフォルト値を設定できる
    • デフォルト値の管理が一箇所(コンストラクタ)に集中するため、保守が容易
    • デフォルト値の変更も、既存コードに影響を与えずに行える
  3. コードの可読性が高い
    • パラメータリストが長くなることを防ぎ、コードの見通しを良くする
    • 各オプションの目的が関数名から明確に分かる
    • IDE補完が効きやすく、どのようなオプションが利用可能か分かりやすい
  4. 後方互換性を保ちやすい
    • 新しいオプションを追加しても既存のコードに影響を与えない
    • 既存のオプションの動作を変更する際も、新しいオプション関数を追加することで対応可能
    • 非推奨になったオプションも、段階的に廃止することが容易

簡単な実装例

例えば、サーバーの設定には色々な設定項目がありますが、これを Functional Option Pattern で作成してみます。

まずはサーバーの構造体

package main

import "fmt"

// サーバーの設定を保持する構造体
type Server struct {
    Host string
    Port int
    TLS  bool
}

次に、それぞれのオプション設定を関数として定義します。それぞれのオプション関数は、シンプルにサーバー構造体が保持している値を設定しているだけです。

// ホスト名を設定するオプション
func WithHost(host string) func(*Server) {
    return func(s *Server) {
        s.Host = host
    }
}

// ポート番号を設定するオプション
func WithPort(port int) func(*Server) {
    return func(s *Server) {
        s.Port = port
    }
}

// TLS を有効化するオプション
func WithTLS() func(*Server) {
    return func(s *Server) {
        s.TLS = true
    }
}

最後に、サーバー構造体のコンストラクタ関数を定義します。この中で、オプションを適用するようにしていきます。(必ずしもコンストラクタ関数で適用しないといけないということではありません)

func NewServer(options ...func(*Server)) *Server { // [1] 引数でオプション関数のスライスが渡ってくる
    server := &Server{
        Host: "localhost",
        Port: 8080,
        TLS:  false,
    }
    
    // [2] オプション関数のスライスをループで回して、オプションを適用
    for _, optionFunc := range options {
        optionFunc(server)
    }
    
    return server
}

[2] の部分が Functional Option Pattern のポイントです。オプション関数のスライスをループで回して、サーバー構造体に対してオプション関数を実行し、値を設定しています。

オプションがあるだけ関数を実行し設定しますし、オプションがなければ関数は実行されないのでオプションのままです。この設計が柔軟性を生み出しています。

また、各オプションを関数で定義するため、それぞれの意図もわかりやすく、可読性も高いです。

実際に適用している箇所も、ループで回して設定しているだけですから、記述量も最小限ですし、オプションが増えても既存のコードに影響を与えずに済みます。後方互換性も保てるというわけです。

さて、これでオプション適用の実装ができたので、実際にこれを使用してみます。

func main() {
    // デフォルト設定でサーバーを作成
    defaultServer := NewServer()
    fmt.Printf("Default Server: %+v\n", defaultServer)

    // カスタム設定でサーバーを作成
    customServer := NewServer(
        WithHost("example.com"), // 1 つ目のオプション
        WithPort(9090),          // 2 つ目のオプション
        WithTLS(),               // 3 つ目のオプション
    )
    fmt.Printf("Custom Server: %+v\n", customServer)
}

上記のコードを実行すると、以下のような出力が得られます

Default Server: {Host:localhost Port:8080 TLS:false}
Custom Server: {Host:example.com Port:9090 TLS:true}

Functional Option パターンは上記のようにして実装できます。

ラーメン屋さんで理解する Functional Option Pattern

Web サーバーなんて滅多に立てるものでもないですから、もっと具体的な例も見てみましょう。

私たちは日頃、ラーメン屋さんにとてもお世話になっているわけですが、行きつけの「ジローラ系モラーメン」は、あの独特なオプションで有名です。

私たちの注文をスタッフさんが厨房に届けるまでを Functional Option パターンで実装してみます。

ソースコードの全容は以下です。前述したこと以上のものはありませんので、読んでみてください。

package main

import (
  "fmt"
  "strings"
)

// RamenOrder ラーメンの注文を表す構造体
type RamenOrder struct {
  garlic     Amount // にんにく
  vegetables Amount // 野菜
  oilLevel   Amount // 油の量
}

// RamenOption ラーメンのオプションを設定する関数の型
type RamenOption func(*RamenOrder)

// デフォルトのラーメン設定
func defaultRamenSetting() *RamenOrder {
  return &RamenOrder{
    garlic:     Regular,
    vegetables: Regular,
    oilLevel:   Regular,
  }
}

// 量に関する Enum を表現(ジローラモ系は特殊なため)
type Amount int

const (
  Light Amount = iota
  Regular
  Extra
  Ultimate
)

// String メソッドを実装して、各値を文字列として表現できるようにする
func (a Amount) String() string {
  switch a {
  case Light:
    return "少なめ"
  case Regular:
    return "普通"
  case Extra:
    return "マシ"
  case Ultimate:
    return "マシマシ"
  default:
    return "多分マシマシ" // スタッフの気分による
  }
}

// -- ここから Functional Option Pattern -- //

// WithGarlic にんにく量を指定するオプション
func WithGarlic(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.garlic = a
  }
}

// WithVegetables 野菜の量を指定するオプション
func WithVegetables(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.vegetables = a
  }
}

// WithOilLevel 油の量を設定するオプション
func WithOilLevel(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.oilLevel = a
  }
}

// NewRamenOrder 新しいラーメンの注文を作成する
func NewRamenOrder(opts ...RamenOption) *RamenOrder {
  r := defaultRamenSetting()
  
  // ここでオプションを適用
  for _, opt := range opts {
    opt(r)
  }
  
  return r
}

// Call Ramenのオプション情報を文字列として返す(スタッフさんが厨房にオーダーを通す)
func (r *RamenOrder) Call() string {
  options := []string{}

  if r.garlic != Regular {
    options = append(options, fmt.Sprintf("にんにく%s", r.garlic))
  }

  if r.vegetables != Regular {
    options = append(options, fmt.Sprintf("野菜%s", r.vegetables))
  }

  if r.oilLevel != Regular {
    options = append(options, fmt.Sprintf("油%s", r.oilLevel))
  }

  optionStr := ""
  if len(options) > 0 {
    optionStr = fmt.Sprintf("、%sで!!", strings.Join(options, "、"))
  }

  return fmt.Sprintf("注文入りました!ラーメン一丁%s", optionStr)
}

func main() {
  // はじめて
  ramenOrder1 := NewRamenOrder()
  fmt.Println(ramenOrder1.Call())

  // 健康志向
  ramenOrder2 := NewRamenOrder(
    WithVegetables(Ultimate),
  )
  fmt.Println(ramenOrder2.Call())

  // 深夜の背徳感
  ramenOrder3 := NewRamenOrder(
    WithGarlic(Ultimate),
    WithOilLevel(Extra),
  )
  fmt.Println(ramenOrder3.Call())

  // フードファイター
  ramenOrder4 := NewRamenOrder(
    WithGarlic(Ultimate),
    WithVegetables(Ultimate),
    WithOilLevel(Ultimate),
  )
  fmt.Println(ramenOrder4.Call())
}

上記実装の実行結果は以下になります。

注文入りました!ラーメン一丁
注文入りました!ラーメン一丁、野菜マシマシで!!
注文入りました!ラーメン一丁、にんにくマシマシ、油マシで!!
注文入りました!ラーメン一丁、にんにくマシマシ、野菜マシマシ、油マシマシで!!

ちなみに、全てマシマシのときは本当は「全マシマシ」とかいうらしいです。

まとめ

Functional Option パターンは、コードの柔軟性と可読性を向上させるだけでなく、拡張性にも優れています。特に、構造体や関数に対して多くのオプションを扱う必要がある場合に、このパターンを採用すると効果的です。

  • オプション設定が直感的に理解できる。
  • デフォルト値の管理が簡単になる。
  • 新しい設定項目を追加する際のコード変更が最小限で済む。

このパターンを活用して、よりメンテナンス性の高い Go コードを目指しましょう。

XP入門3

この記事は個人ブログと同じ内容です


前回までの振り返り

第1章~第16章は実践的な内容で、XP (エクストリームプログラミング)における開発手法や考え方について学びました。

今回の内容

今回は17~25章を読み進め、XP の哲学や歴史的な背景について学びました。 その中でも印象的だった章についてまとめていきます。

エクストリームプログラミング / ケント・ベック

テイラー主義とソフトウェア

  • 世界初の生産技術者である、フレドリック・テイラーという人物について。
    • 工場の生産性を飛躍的に向上させる、科学的管理法を提唱した。
    • これが「テイラー主義」と呼ばれており、技術的、社会的、経済的に影響を与えた。
  • テイラー主義には好ましい効果もあるが、いくつか深刻な欠点もある。これらの欠点は、以下の3つの単純化された仮定によるもの。
    • 通常、物事は計画通りに進む。
    • 局所最適化は、全体最適化につながる。
    • 人はほぼ代替可能であり、何をすべきかを指示する必要がある。
  • テイラー主義によるソーシャルエンジニアリングの手順。
    • その1
      • 計画立案と実行作業の分離。
      • 作業方法や作業期間を決定するのは、教育を受けたエンジニアである。
      • 作業者は、与えられた仕事を、与えられた方法で、与えられた時間内に、忠実に実行しなければいけない。
      • 権限のある人が他人の作業の見積もりを作成したり、変更したりする。
    • その2
      • 独立した品質管理部門の設置。
      • テイラーは品質管理部門を設置することで、作業者に適度なペースかつ規定の方法で作業させるようにして、適切な品質レベルを保った。
      • 多くのソフトウェア開発組織は、品質部門を別に設置しているという意味で、まぎれもなくテイラー主義
      • 品質部門を別にすれば、エンジニアリングにおける品質の重要性が、マーケティングや営業における品質と同等だというメッセージを送ることになる。
      • エンジニアリングで品質に責任を持つ人がいなくなる。
  • テイラー主義は生産性を向上させる一方で、ソフトウェア開発のような複雑で創造性を必要とする分野には適していない。
    • ソフトウェア開発においては、個々のエンジニアの能力を最大限に引き出し、チーム間のコミュニケーションを促進するような柔軟な開発体制が求められる。

トヨタ生産方式

  • トヨタは最も利益性の高い自動車メーカーのひとつ。
    • その理由は無理をしているからではない。
    • 車を製造するプロセスのすべての工程で、無駄な労力を削減しているから。
    • 無駄を十分に排除すれば、単に速く進もうとするよりも、ずっと速く進むことができる。
  • 従来と異なる仕事の社会構造が、トヨタの成功には絶対不可欠。
    • 全ての作業者が、生産ライン全体に責任を持つ。
    • 欠陥を見つけたら、紐を引っ張ってラインを止める。
    • そしてラインの全員で問題の根本原因を発見し、それを修正する。
    • 「ラインの最後」で品質に責任を持つ人がいる大量生産ラインとは異なり、後工程の品質保証が必要ないくらいにラインの品質を保つのが TPS (Toyota Production System)。
  • TPS では、作業者個人が作業のやり方や改善について多くの意見を述べる。
    • 無駄は改善イベントで削減していく。
    • まずは作業者が無駄(品質問題や非効率)の源泉を特定する。
    • そして率先して問題を分析して、実験して、その結果を標準化する。
  • TPS では、テイラー主義の工場で見られた厳格な社会的成層が排除されている。
    • 日常的なメンテナンスはエリート階級の技術者ではなく、普通の作業者が行っている。
    • 独立した品質部門は存在しない。組織全体が品質部門。
  • 最大の無駄は「つくりすぎの無駄」
    • 何かを作り、それが売れないとなると、作った労力の行き場がない。
    • 生産ラインで何かを作り、それをすぐに使わないとなると、情報のバリューが消えてしまう。保管コストもかかってしまう。
  • ソフトウェア開発には「つくりすぎの無駄」が満ち溢れている。
    • すぐに陳腐化する分厚い要求文書。
    • 全く使われない精巧なアーキテクチャ
    • 数ヶ月も放置され、インテグレーション、テスト、本番環境での実行が全くされないコード。
    • 不適切で誤解を招くようになるまで誰にも読まれないドキュメント。
  • 無駄を排除するためには、アウトプットをすぐに利用する必要がある。
    • 要件収集を改善するには、要件収集のプロセスを念入りに行うのではなく、詳細な要件の作成とソフトウェアのデプロイの間隔を短縮すればいい。
    • すぐに利用するのであれば、要件収集は静的なドキュメントを作成するフェーズではなく、開発で必要になった詳細な情報を作り出す機会になる。
  • TPS の考え方は、ソフトウェア開発にも多くの共通点がある。
    • 特に「無駄の排除」という点で深い繋がりがある

時を超えたプログラミングの道

  • クリストファー・アレグザンダーという建築家について。
    • アレグザンダーは、建築家の自己中心的な関心事は、施主の関心事と一致していないと指摘した。
    • 建築家は仕事をすぐに終わらせて、賞を獲得したいと思っているが、重要な情報を見逃している。
    • それは、施主がどのような生活をしたいかという情報。
    • ソフトウェア開発においても、エンジニアの利益や技術的な興味事項ばかりに注目し、ユーザーのニーズを見落としてしまうことがある。
  • 筆者が育ったシリコンバレーではエンジニアリングが王様だった。
    • 「あなたに必要なものを与えよう。必要かどうかは知らなくても構わない」がモットーだった。
    • このようにして作られたソフトウェアは、技術的には優れていたが、役に立たないものが多かった。
  • 経験を積んでいくと、正反対の不均衡を目にするようになった。
    • ビジネスの関心ごとが開発を支配する世界。
    • ビジネス上の理由だけで期日やスコープを設定すると、チームの誠実性を維持できない。
  • 高みに到達することが目的であれば、ソフトウェア開発は「プログラマーとその他大勢」で成立するものではない。
    • 関係者全員の関心事のバランスがとれていなければ、開発に貢献できない人が出てくる。
  • XP の成功は、信頼できるソフトウェアのすばやい見積もり、実装、デプロイができる優秀なプログラマーの増加にかかっている。
    • このようなプログラマーがいれば、チームのビジネス担当者に意思決定を任せることができるはず。
  • ツールや技法は何度も変化するが、大きく変化するわけではない。
    • 一方、人はゆっくりとだが、深く変化する。
    • XP の課題は、このような深い変化を促し、個人の価値と相互の人間関係を新しいものにして、ソフトウェア業界に次の50年間の居場所を用意すること。

コミュニティーと XP

  • サポートしてくれるコミュニティーの存在は、ソフトウェア開発の偉大な資産である。
    • 自分に共感してくれる人を見つけたり、誰かの声に自分の耳を傾けたりする絶好の場。
    • 人と人との関係は、安全に実験ができる安定した場を提供してくれる。
  • コミュニティーでは、自分から話すよりも耳を傾けるスキルの方が重要。
  • コミュニティーは一緒に勉強する場にもなる。
    • XP には、練習が必要なスキルも数多く含まれている。
  • コミュニティーは説明責任の場でもある。
    • コミュニティーでは、自分の発言に責任を持たなければいけない。
    • 説明責任を果たすためにも、コミュニティーには安全性が必要である。
    • 相手の秘密を大切にしたり、求められたときにだけアドバイスしたり、よく考えてから判断したりすることは、すべてが安全性につながっている。
  • コミュニティーは質問や疑問を投げかける場でもある。
    • コミュニティーには、個人の意見が重要である。
    • 衝突や意見の不一致は、共に学習するための下地となる。
    • 衝突を抑え込んでしまうのは、コミュニティーが弱い証拠。
    • 本当に重要なアイデアならば、そのようなことで重要性が失われることはない。
    • 常に意見を一致させる必要はない。お互いをリスペクトしながら、意見の不一致に対応すればいい。

今回のまとめ

  • テイラー主義
    • 専門化することで作業時間を短縮し、生産性を向上させ、品質の安定化を図る
    • 管理者と労働者を明確に分離する
  • トヨタ生産方式
    • 過剰生産や加工の「無駄」を排除することで、生産性を向上させる
    • すべての作業者が品質管理に責任を持つ
  • XP
    • 顧客と密なコミュニケーションをとることで、期待とのズレを生じにくくし、生産性を向上させる
    • ペアプログラミングを行いコードの品質向上や知識共有を行う
    • 信頼関係を重要視し、チーム全体で協力して問題を解決する

例えば XP のペアプログラミングは、一人当たりの労働時間を短縮し、生産性を向上させようとするテイラー主義では考えられない手法だと思います。 チーム全体で取り組む点と、管理者と労働者を明確に分離する点も対照的です。

また XP とトヨタ生産方式を比較すると、どちらもチームワークを重要視し、個人の能力を超えた成果を目指している点は共通しているように思います。

まとめると、工場生産が中心だった産業革命時代から、コンピューターの発明とインターネットの普及により、ソフトウェア開発が登場した時代に合わせて、生産性を向上させるための手法が変化してきたと言えそうです。 そしてその手法は徐々に、人間性やコミュニケーションを重要視するようになってきており、XP はその流れの中で生まれたものだと感じました。 どの手法も無駄を排除することで生産性を向上させようという点では共通しているように思いますが、歴史的背景を知ることで、何を重視して現在のプラクティスが生まれたのか理解し、より効果的な取り組みを行えそうです。

本書を読み終えて

今回で「エクストリームプログラミング」を読み終えました。 ソフトウェア開発は、単なる技術的な作業ではなく、人々が協力して行う創造的な活動であるということを改めて感じました。 本書でも触れられていたのですが、XP は問題解決に取り組むための新しい文脈であり、それ自体では問題を解決するものではありません。 アジャイルが健全に回るように、これからも継続的な学習・改善を行っていきたいと思います。

BigQuery パイプ構文を試す - 直感的で読みやすい新しいSQLクエリの書き方

この記事は個人ブログと同じ内容です

www.ritolab.com


2024 年 10 月 9 日の Google Cloud Blog にて、BigQuery と Cloud Logging における「パイプ構文」導入の記事がアップされました。

Introducing pipe syntax in BigQuery and Cloud Logging | Google Cloud Blog

SQL のクエリといえば、長らく同じ記法で歴史を刻んできた古に伝わる呪文ですが、今回の「パイプ構文」は、それに一石を投じる新しい書き方になっています。まさに黒船。ペリー来航です。(といいつつ、今回紹介するパイプ構文はあくまでも GoogleSQL としての機能です。MySQLPostgreSQL などでは現状パイプ構文をサポートしていません)

そんなパイプ構文を、BigQuery で試してみたいと思います。

パイプ構文

BigQuery のパイプ構文は、SQL クエリをより直感的で読みやすくするための記法です。パイプ演算子 (|>) を使って処理を順番にチェーンすることで、クエリの各操作をステップごとに記述できるのが特徴です。 これにより、クエリの記述量の削減、可読性の向上が期待できます。

従来の SQL のようにすべての操作を 1 つのブロックでまとめるのではなく、フィルタリング、選択、集約などの操作をそれぞれの行で分けて記述します。詳細な記法は公式ドキュメントをご確認ください。

パイプ構文を体験

では、実際にデータを用意してクエリを書いてみましょう。

今回用意したのは、e-sports のゲームをプレイした履歴のデータです。

esports_play_sessions テーブル(n=30,000)

player_id esports_genre session_date match_result
31 sports_games 2024/01/01 win
133 moba 2024/01/01 lose
32 sports_games 2024/01/01 win

どのプレイヤーが、いつ、どのジャンルのゲームをプレイして、勝敗がどうだったか。を収録したデータです。

players テーブル(n=163)

id name
1 Caspian
2 Kamryn
3 Charmaine

こちらはプレイヤーを収録したテーブルです。

さて、こんなオーダーが来ました。

「sports_games と、card_games をプレイしたプレイヤーの、最終プレイ日を抽出したい」

このとき、どんなクエリを書くでしょうか。通常の SQL なら、以下のようなクエリになると思います。

SELECT
    p.id as player_id,
    p.name,
    s.esports_genre,
    MAX(s.session_date) AS session_date
FROM `sample.players` p
INNER JOIN `sample.esports_play_sessions` s ON p.id=s.player_id
WHERE s.esports_genre IN ('sports_games', 'card_games')
GROUP BY p.id, p.name, s.esports_genre
ORDER BY p.id, MAX(s.session_date)

ジャンルを絞り込み、それに対して集計。ユーザーごとの sports_games, card_games をプレイした最新の日時を抽出します。

これをパイプ構文を使用してクエリを組み立てると、以下になります。

FROM `sample.players` AS p
|> JOIN `sample.esports_play_sessions` AS s ON p.id = s.player_id
|> WHERE s.esports_genre IN ('sports_games', 'card_games')
|> AGGREGATE MAX(s.session_date) AS session_date GROUP BY p.id, p.name, s.esports_genre
|> SELECT id as player_id, name, esports_genre, session_date
|> ORDER BY player_id, session_date;
  1. [|> JOIN] players テーブルに esports_play_sessions テーブルを結合
  2. [|> WHERE] ジャンルを絞り込む
  3. [|> AGGREGATE] 集計し最新日を算出
  4. [|> SELECT] 出力するカラムを指定
  5. [|> ORDER BY] 並び替え

上から順番に読めるので、可読性は確かに向上するかもしれません。一方で、記述量はあまり変わってはいない印象を受けます。

もう少し複雑なクエリで試してみます。 各ジャンルにおける、一年間のプレイ数トップ 3 を出してみます。まずは通常の SQL です。

WITH ranked_sessions AS (
    SELECT
        esports_genre, player_id, COUNT(*) AS session_count,
        RANK() OVER (PARTITION BY esports_genre ORDER BY COUNT(*) DESC) AS session_rank
    FROM `sample.esports_play_sessions`
    WHERE session_date BETWEEN '2024-01-01' AND '2024-12-31'
    GROUP BY esports_genre, player_id
)
SELECT
    s.esports_genre, s.session_rank, p.id, p.name, s.session_count
FROM `sample.players` p
INNER JOIN ranked_sessions s ON p.id=s.player_id
WHERE s.session_rank <= 3
ORDER BY s.esports_genre, s.session_rank;

ランキングで絞りトップ 3 としたいため、一度 CTE を定義し session_rank を付与、それから絞り込んでいます。

対して、このクエリをパイプ構文で記述すると以下になります。

FROM `sample.esports_play_sessions` AS s
|> WHERE session_date BETWEEN '2024-01-01' AND '2024-12-31'
|> AGGREGATE COUNT(*) AS session_count GROUP BY esports_genre, player_id
|> WINDOW RANK() OVER (PARTITION BY esports_genre ORDER BY session_count DESC) AS session_rank
|> WHERE session_rank <= 3
|> JOIN `sample.players` AS p ON player_id=p.id
|> SELECT esports_genre, session_rank, p.id AS player_id, p.name, session_count
|> ORDER BY esports_genre, session_rank;
  1. [|> WHERE] 日付を絞り込む
  2. [|> AGGREGATE] 集計し各ジャンルのプレイ数を算出
  3. [|> WINDOW RANK()] プレイ数によってジャンルごとにユーザーをランク付け
  4. [|> WHERE] トップ 3 に絞り込み
  5. [|> JOIN] esports_play_sessions テーブルに players テーブルを結合
  6. [|> SELECT] 出力するカラムを指定
  7. [|> ORDER BY] 並び替え

抽出の順は確かに追いやすいですね。ただ、記述量は減ったか?と言われればそうでもない気がします。

最後に、もう少し複雑なクエリで試してみます。各ジャンルにおいて、勝率が最も高いユーザートップ 3 を抽出してみます。

まずは、通常の SQL です。

WITH player_stats AS ( -- 各プレイヤーの各ジャンルにおける総試合数と勝利数を算出
    SELECT
        e.player_id,
        p.name as player_name,
        e.esports_genre,
        COUNT(*) AS total_matches,
        SUM(CASE WHEN e.match_result = 'win' THEN 1 ELSE 0 END) AS wins
    FROM `sample.esports_play_sessions` e
    JOIN `sample.players` p ON e.player_id = p.id
    GROUP BY e.player_id, p.name, e.esports_genre
),
ranked_players AS ( -- 勝率を算出し、各ジャンルごとに勝率の高い順にランク付け
    SELECT
        esports_genre,
        player_id,
        player_name,
        ROUND(SAFE_DIVIDE(wins, total_matches), 2) AS win_rate,
        ROW_NUMBER() OVER (PARTITION BY esports_genre ORDER BY SAFE_DIVIDE(wins, total_matches) DESC) AS rank
    FROM player_stats
)
SELECT
    esports_genre,
    rank,
    player_id,
    player_name,
    win_rate
FROM ranked_players
WHERE rank <= 3
ORDER BY esports_genre, rank;

これをパイプ構文で記述すると以下になります。

FROM `sample.esports_play_sessions` AS s
|> JOIN `sample.players` AS p ON s.player_id = p.id
|> AGGREGATE COUNT(*) AS total_matches, SUM(CASE WHEN s.match_result = 'win' THEN 1 ELSE 0 END) AS wins GROUP BY s.player_id, p.name, s.esports_genre
|> EXTEND ROUND(SAFE_DIVIDE(wins, total_matches) * 100, 2) AS win_rate
|> WINDOW ROW_NUMBER() OVER (PARTITION BY esports_genre ORDER BY win_rate DESC) AS rank
|> RENAME name as player_name
|> WHERE rank <= 3
|> SELECT esports_genre, rank, player_id, player_name, win_rate
|> ORDER BY esports_genre, rank;
  1. [|> JOIN] esports_play_sessions テーブルに players テーブルを結合
  2. [|> AGGREGATE] 各プレイヤーの各ジャンルにおける総試合数と勝利数を算出
  3. [|> EXTEND] 勝率を算出
  4. [|> WINDOW] 勝率によってランク付け
  5. [|> RENAME] カラム名 name を player_name に変更
  6. [|> WHERE] トップ 3 に絞り込み
  7. [|> SELECT] 出力するカラムを指定
  8. [|> ORDER BY] 並び替え

追いやすさはこれまで通りポジティブですが、今回は記述量が大分減りました。CTE で切り出し、ないしはサブクエリの記述が無くなった分、SELECT 文分の記述量が主に削減されています。パイプ構文では、SELECT や集計でカラムが絞られない限りはそのまま次の行に持ち越されるため、追加したい新たなカラムだけを記述すればよい点が削減に寄与しています。

もう一点、通常の SQL では、ranked_players 定義時に win_rate と rank を算出していますが、その両方で SAFE_DIVIDE(wins, total_matches) を行っています。対してパイプ構文だと同じ計算は 1 回済んでおり、冗長さが解消されています。こういった点は地味にうれしいポイントです。

直感的で追いやすくクエリ冗長に成り難し

2024 年 10 月 16 日現在、パイプ構文のリリースステージは「プレビュー」であり、一般提供はされていません。

BigQuery でパイプ構文を使ってみたい場合は、BigQuery パイプ構文登録フォームに記入し、パイプ構文プレビューにプロジェクトを登録する必要があります。

また、今回紹介したパイプ構文は、GoogleSQL としての機能です。MySQLPostgreSQL などでは現状パイプ構文をサポートしていません。

さて、パイプ構文を使ってみましたが、読みやすさの向上、記述量の削減など、複雑なクエリほどパイプ構文の恩恵を受けやすくて良い機能でした。パイプ構文で記述したクエリも CTE として切り出せました。

「直感的で追いやすくクエリ冗長に成り難し」

BigQuery のパイプ構文、是非使ってみてください。

271_sample_data - Google スプレッドシート

(今回使用したサンプルデータはスプレッドシートで公開しています。CSV ダウンロードしてご自身の BigQuery にインポートするなど、ご自由にお使いください。)

XP入門2

この記事は個人ブログと同じ内容です


前回の振り返り

簡単に前回を振り返ると、XP (エクストリームプログラミング) は、アジャイル開発の手法の一つです。 XP において「価値」「原則」「プラクティス」は重要な概念であり、それぞれの要素が連携してソフトウェア開発を効率化します。

前回の記事はこちら

今回の内容

今回は9~16章を読み進めました。 その中で印象的だった、導出プラクティス、チーム全体、制約理論と時間の重要性についてまとめていきます。

エクストリームプログラミング / ケント・ベック

導出プラクティス

導出プラクティスは主要プラクティスを補強し、より効果的な開発を可能にするプラクティスです。 そのため主要プラクティスを実践していて、ある程度チームが XP に成熟していることが推奨されます。

本物の顧客参加

  • 自分たちのシステムによって生活やビジネスに影響を受ける人をチームの一員にすること。
  • 顧客参加のポイントは、ニーズを持つ人とそれを満たす人が直接やりとりをして、ムダな労力を減らすこと。
  • 信頼できる行動をとり、何も隠さなければ、生産性は高まる。(隠すことや取り繕うことに時間を費やす必要がないため)

チームの継続

  • 優秀なチームは継続させること。
  • 大きな組織は、ヒトをモノに抽象化する傾向がある。互換性のあるプログラミングユニットだと考えている。
  • ソフトウェアのバリューは、みんなが知っていることや行なっていることだけでなく、人間関係やみんなで一緒に成し遂げることによっても生み出される。
  • 要員計画の問題を単純化するためだけに、人間関係や信頼の大切さを無視するのは経済的ではない。

チームの縮小

  • チームの能力が高まったら、仕事量を維持しながら少しずつチームの規模を縮小すること。
  • チームを離れた人は、また別のチームを作ることができる。
  • より多くの仕事量をこなすために、チームの規模を拡大するような戦略もあるが、それではうまくいかない。他の方法を考えるべき。
  • チームメンバーの誰かの手が空くまで、開発を改善していくこと。そうすれば、規模を縮小しながらチームを継続できるはず。

コードとテスト

  • コードとテストだけを永続的な作成物として保守すること。
  • その他のドキュメントについては、コードとテストから生成すること。
  • プロジェクトの重要な履歴の維持については、社会的な仕組みに任せること。
  • 顧客は、システムの今日の挙動と、チームが開発する明日のシステムの挙動に対してお金を支払っている。
    • この2つのバリューの源泉に貢献する作成物は、それ自体にバリューがある。

利用都度課金

  • 利用都度課金システムがあれば、システムが利用されるたびにお金を請求することができる。
  • お金は究極のフィードバック。
    • お金には実体があり、これから自分で使うこともできる。
    • お金の流れをソフトウェア開発に直接接続すれば、改善を推進するための正確でタイムリーな情報が得られるはず。
  • 利用都度課金にできなくても、サブスクリプションモデルに移行することはできるかもしれない。
    • チームは自分たちの行動の状況を把握する情報源として、少なくとも定着率(サブスクライブを継続する顧客数)を見ることができる。
  • ライセンス収益のフィードバックだけを頼りにしているチームよりも、利用都度課金の情報を使っているチームの方が効果的な仕事ができるはず。

XP チーム全体

XP における「チーム全体」は、単なる開発チームではなく、プロジェクトの成功のために協力し合う、より広義のチームを指します。 さまざまな人の視点を注ぎ込み、チーム全体が一体となって取り組むことで、より良いソフトウェアを開発し、ユーザーの満足度を高めることができます。

この章では次の比喩を用いて、チーム全体の重要性が説明されていました。

  • 異なる視点を持つ人たちがロープに結ばれて氷河の上を歩いているときに、誰が先頭になるかは重要ではない。
  • 本当に重要なのは、全員がロープに結ばれているという感覚をチーム全体が共有していること。
  • 誰かが先頭になって他の人を追従させるよりも、全員で足並みをそろえて歩いたほうが、ずっと先まで進める。

また XP においてそれぞれの役割は固定化されるものではなく、チーム全体の成功のために柔軟に変化することが重要です。 チームの状況や目標に合わせて変化し、チーム全体の成功に貢献することが求められます。

プロジェクトマネージャー

  • XP チームのプロジェクトマネージャーは、チーム内のコミュニケーションを円滑にしたり、顧客、サプライヤー、その他のチーム外の組織とのコミュニケーションを調整したりする。
  • チームの歴史学者となり、チームに進捗状況を思い出させる。
  • プロジェクトの情報をまとめて、経営幹部や同僚にプレゼンするために、クリエイティブでなければいけない。
  • 正確性を保つために、プロジェクトの情報を頻繁に変更することになる。
    • したがってプロジェクトマネージャーには変更をうまく伝える能力が求められる。
  • チーム内のコミュニケーションを円滑にして、一体感や信頼関係を築くようにしなければいけない。
    • そのためには、重要な情報の管理者になるよりも、効果的なファシリテーターになるほうが、得られる力は大きい。

プロダクトマネージャー

  • XP のプロダクトマネージャーは、ストーリーを書いたり、四半期サイクルのテーマやストーリーを選択したり、週次サイクルのストーリーを選択したり、実装によって明らかになったストーリーのあいまいな部分の質問に答えたりする。
  • チームがオーバーコミットしていたら、想定していた要件と現実の違いを分析して、チームが優先順位をつけられるように支援する。
  • プロダクトマネージャーは、今実際に起きていることにストーリーやテーマを適応させる。
  • ストーリーの順番は、技術ではなくビジネスの理由で決めるべき。
  • プロダクトマネージャーは、顧客とプログラマーのコミュニケーションを促進する。
    • 顧客の最も重要な課題がチームに伝わり、きちんと対処されるようにしなければいけない。
    • チームが本物の顧客参加を実践していれば、ストーリーを選択した顧客やマーケット全体のニーズを満たせるように、システムの成長を促さなければいけない。

経営幹部

  • 経営幹部は、XP チームに勇気、自信、説明責任を提供する。
  • 共通の目標に向かって一緒に進んでいく XP チームの強みは、弱みにもなり得る。
    • チームのゴールが会社のゴールと合っていなかったらどうなるだろう?
    • 成功のプレッシャーと興奮によって、ゴールを見失ってしまったらどうなるだろう?
    • 大きなゴールの明確化と維持は、XP チームのスポンサーや監督を務める経営幹部の仕事。
  • もうひとつの仕事は、改善の監視、促進、円滑化。
    • 経営幹部はチームが作り出す優れたソフトウェアだけではなく、継続的な改善についても目を配らなければいけない。
  • 経営幹部は、XP プロジェクトのあらゆる側面について自由に説明を求めることができる。
    • 説明は筋が通ったものでなければいけない。
    • 筋が通っていなかったなら、経営幹部はチームに対して考察と明確な説明の提供を求めるべき。
  • XP チームの評価を決める人たちは、優秀なチームがどのようなものかを理解するべき。
    • XP チームは会話しながら仕事をする。
    • にぎやかな話し声は健全である証拠。
    • 静寂はリスクがたまっている音色。
    • 経営幹部が XP チームを理解し、自身の経験や視点をうまく適用するには、新しい経験則を学ぶ必要がある。

テクニカルライター

  • XP チームにおけるテクニカルライターの役割は、フィーチャーのフィードバックを早期に提供したり、ユーザーとの密接な関係を築いたりすることである。
    • フィーチャーのフィードバックを早期に提供。
      • 文章と図を使ったシステムの説明は、チームにフィードバックをもたらす要素のひとつである。
    • ユーザーとの密接な関係を築くこと。
      • ユーザーがプロダクトを学習できるように支援したり、ユーザーからのフィードバックを受け取ったり、ユーザーが混乱しないように発表資料や新しいストーリーを追加したりする。
  • XP チームは実際の利用状況からフィードバックを得るべき。
    • マニュアルサイトを掲載しているなら、利用状況を監視できる。
    • ユーザーがドキュメントを見ていなかったら、その部分を書くのはやめる。
    • そして、空いた時間をもっとうまく活用する。

ユーザー

  • XP チームのユーザーは、開発中のストーリーの記述や選択の支援をしたり、専門領域の意思決定をしたりする。
    • 構築中のシステムと類似したシステムに関する幅広い知識や経験を持っていたり、システムを実際に利用するユーザーコミュニティとの強い関係性を持っていたりすれば、そのユーザーは非常に大切な存在だ。
    • ユーザーはコミュニティの代表者であることを忘れないようにしなければいけない。

プログラマー

  • XP チームのプログラマーがやること。
    • ストーリーやタスクを見積もる。
    • ストーリーをタスクに分解する。
    • テストを書く。
    • フィーチャーを実装するコードを書く。
    • 退屈なプロセスを自動化する。
    • システムの設計を少しずつ改善したりする。
    • 技術的に密接に協力しながら一緒に働く。
      • つまりプログラマーは社交性や人間関係のスキルを身に付ける必要がある。

人事

  • チームが XP を適用し始めるとき、人事評価と雇用という2つの課題が発生する。
    • 人事評価の課題
      • XP はチームのパフォーマンスに集中しているのに、実際の人事評価や昇給は個人の目標や達成度に対して行われているから。
      • XP 適用前の評価の仕方を大きく変える必要はない。
      • 以下は、XP における重要性の高い従業員
        • リスペクトを持って行動できる。
        • 他人とうまくやれる。
        • イニシアチブをとれる。
        • 約束したものをデリバリーできる。
      • 2つの方法で解決できる。
        • このまま個人ベースの目標、評価、昇給を続ける。
        • もしくはチームベースのインセンティブや昇給に移行するか。
    • 雇用の課題

制約理論

制約理論とは、システムのボトルネック(制約)を特定し、それを改善することで、システム全体の効率を最大化する理論です。 導入するには組織全体の意識改革が求められます。個人の生産性よりも、システム全体の効率を重視し、ボトルネック解消に協力する体制作りが重要です。

  • 先ずはどの問題が開発の問題かを見極めるところから、ソフトウェア開発の改善の機会を発見すること。
  • 洗濯を例にした理論の説明
    • 洗濯機が衣類を洗濯するのに45分かかり、乾燥機が衣類を乾燥するのに90分かかり、衣類を畳むのに15分かかるとする。
    • このシステムのボトルネックは乾燥。洗濯機が2台あっても、洗濯がすべて完了した衣類が増えるわけではない。
    • 洗濯だけが終わった衣類は一時的に増えるかもしれないが、濡れたままの衣類が至るところに山積みになり、その対応をしなければいけなくなる。
    • より多くの衣類の洗濯をすべて完了させたければ、乾燥をどうにかする以外に選択肢はない。
  • システム全体のスループットを改善するには、最初に制約を見つけなければいけない。
    • 次に、その制約が最大限に稼働していることを確認する。
    • そして、制約のキャパシティーを増やすか、制約以外の負荷を下げるか、制約を完全に排除するかのいずれかの方法を探す。
  • システムの制約をどのように発見するか。
    • 仕掛品が山積みになっているところが制約。
    • 洗濯の例では、これから畳まなければいけない乾燥した衣類は山積みになっておらず、これから乾燥する濡れた衣類が山積みになっている。
    • ER 図が多くのフィーチャーを網羅しているにもかかわらず、やることが多すぎて実装から外されている場合は、実装プロセスが制約になっているかもしれない。
    • 多くのフィーチャーの実装が終わっているにもかかわらず、インテグレーションやデプロイが待機中になっている場合は、インテグレーションプロセスが制約になっているかもしれない。

設計 : 時間の重要性

ソフトウェア開発における設計は、一度で完璧なものを作り上げるのではなく、段階的に改善していくことが重要です。 柔軟性、シンプルさ、チームとの連携を意識することで、より良いソフトウェアを開発することができます。

  • インクリメンタルな設計は、機能を早期に届ける方法であり、プロジェクトの全期間にわたって毎週継続して機能を届ける方法。
  • 設計が日常の業務の一環になれば、プロジェクトがもっとスムーズに進む。
  • ソフトウェアはレバレッジゲーム
    • ひとつの優れたアイデアが何百万ドルものコストを削減したり、何百万ドルもの収益を生み出したりする。
  • 残念ながらソフトウェアの設計は、物理的な設計活動のメタファーにとらわれている。
    • たとえば、50階建てのビルを所有しており、すべての空間をすでに貸し出しているからといって、そこに別の50階を付け足すことはできない。
    • ソフトウェア開発における実践的でリスクの低い方法。
      • 犬小屋から開始して、少しずつ部品を置き換えながら、基本的な構造はそのままにして、最終的に超高層ビルにする。
  • ソフトウェアの世界でインクリメンタルな設計が重要なのは、アプリケーションをはじめて書く機会が多いから。
  • 設計には大きな影響力があり、設計のアイデアは経験によって改善される。
    • しかがって、ソフトウェア設計者が持つべき最も重要なスキルのひとつは忍耐。
    • フィードバックが十分に得られる分だけ設計を行い、そこから得られたフィードバックを使って、次回のフィードバックが十分に得られる分だけ設計を改善する。そうした技能が求められる。
  • 前もった設計は必要だが、最初の実装ができるだけで十分。
    • それ以上の設計は実装後に設計の本当の制約が明らかになってから行えばいい。
    • XP の戦略は「何も設計しない」ではなく「常に設計する」。
  • ソフトウェア設計のおもしろいところ。
    • 設計の品質は成功を約束するものではないが、設計の失敗は確実に失敗につながる。
  • 最も強力な設計方法は、「Once and Only Once (一度、ただ一度)」
    • データ、構造、ロジックなどは、システムのひとつの場所に存在するべき。
    • 重複を見つけたら設計を改善する必要がある。
    • 重複した表現をひとつにまとめる方法を思いつくまで、設計の改善に取り組んでいく。
  • ソフトウェアの設計はそれ自体では完結しない。
    • 設計とは、技術側の人間とビジネス側の人間の信頼関係を築くためのもの。
    • 要求された機能を毎週デリバリーすることが、信頼関係の構築に欠かせない。
    • チームの中でバリューを生み出す多様な関係性を維持することに比べたら、設計者の利便性は優先順位が低い。
  • XP チームはできるだけシンプルな解決策を好む。
    • 設計のシンプリシティを評価する4つの基準
      1. 対象者に適している
        • 設計がいかに見事で洗練されているかは重要ではない。その設計を使うべき人たちが理解できなければ、それはシンプルではない。
      2. 情報が伝わりやすい
        • 伝えるべきすべてのアイデアがシステムに表現されている。
        • システムの要素は、用語の単語と同じように、未来の読者に情報を伝えるものである。
      3. うまく分割されている
        • ロジックや構造の重複は、コードの理解や修正を困難にする。
      4. 最小限である
        • 上記の3つの制約を守った上で、システムの要素はできるだけ少なくする。
        • 要素が少なければ、その分だけ必要なテスト、ドキュメント、コミュニケーションが少なくなる。

まとめ

今回読んだ範囲では、より実践的な側面に焦点を当てた内容が多かったと感じました。 特に印象的だったのは「チーム全体」で、経営幹部や人事など、チーム外の人たちがどのような役割を果たすべきかも学べたので、メンバーをサポートするためにより大きな視点を得ることができました。 XP は単なる開発手法ではなく、組織全体の文化を変えるための取り組みとも言えそうです。 メンバーがチームに最善を尽くせるように、これからも学びを深めていきたいと思います。

Workload Identity 連携で GithubActions から GCP リソースをデプロイする

この記事は個人ブログと同じ内容です

www.ritolab.com


GCP 外のアプリケーションから GCP リソースを操作する場合に、サービスアカウントキーを用いずに安全にリソースへアクセスできる Workload Identity 連携を用いて、Github Actions から GCP リソースのデプロイを行います。

Workload Identity 連携:簡単で安全な Google Cloud アクセス

Workload Identity 連携は、Google Cloud外のアプリケーションが安全かつ簡単にGoogle Cloudリソースを利用できるようにする機能です。

従来、外部アプリケーションはサービスアカウントキーを使ってGoogle Cloudにアクセスしていました。しかし、このキーは、漏洩リスクやローテーションなど管理が難しく、セキュリティリスクも高いものです。

Workload Identity 連携はこの問題を解決します。キーの代わりに、外部の ID システム(例:AWS IAMなど)と Google Cloud の IAM を連携させます。これにより、以下の利点があります。

  1. セキュリティが向上します:キーの漏洩リスクがなくなります。
  2. 管理が楽になります:キーの作成や更新、削除の手間が不要になります。
  3. きめ細かな制御が可能になります:外部 ID に直接権限を付与したり、一時的に権限を貸し出したりできます。

例えば、AWSで動いているアプリケーションが Google Cloud Storage のデータを読み取りたい場合、Workload Identity 連携を使えば、AWS の IAMロールに Google Cloud の読み取り権限を付与できます。これにより、安全かつ簡単にクラウド間でのデータアクセスが実現します。

Workload Identity 連携は、こういったマルチクラウド環境や、クラウドとオンプレミス環境を跨ぐシステムで特に力を発揮します。

GithubActions から GCP リソースのデプロイ

今回は、GithubGCP を連携させて動作させてみます。

例えば、CloudFunctions の関数を Git 管理しているとして、それらのデプロイを Workload Identity 連携で実施するようなイメージです。本来ならば、デプロイヤーとして作成したサービスアカウントとして GithubActions からデプロイを実行する場合、サービスアカウントキーを用いた認証が必要ですが、それが不要になります。

まずは、GCP リソースをデプロイするサービスアカウントを作成します。Terraform で定義していきます。

resource "google_service_account" "github_action_deploy" {
  account_id   = "github-action-deploy"
  display_name = "github_action_deploy"
  project      = var.project_id
}

resource "google_project_iam_member" "github_action_deploy_roles" {
  for_each = toset([
    .
    .
    (GCPリソース操作に必要な権限)
    .
    .
  ])
  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.github_action_deploy.email}"
}

Workload Identity 連携における pool と provider

ここから Workload Identity 連携を構築していきますが、プールとプロパイダという概念があります。

  • Workload Identity Pool (プール):
    • 外部のIDシステム(例:AWS、Azure, Github)からのアイデンティティを一つにまとめて管理する仕組みです。これにより、複数の外部システムのユーザーやアプリケーションを一元的に扱うことができます。プールは、これらの外部 ID を GoogleCloud のリソースにアクセスできるよう橋渡しする役割を果たします。
    • 一般には、外部アプリの環境(開発環境、ステージング環境、本番環境など)ごとにプールは分けるのが望ましいとされています。
  • Workload Identity Pool Provider (プロバイダー):
    • プール内に存在し、特定の外部 ID プロバイダー(例:AWS IAM、Azure AD)と GoogleCloud を接続する設定(認証方法)を管理します。プロバイダーは、外部システムの認証情報を GoogleCloud が理解できる形式に変換(属性マッピング)し、適切な権限を割り当てる手助けをします。
    • 各プールには、複数のプロバイダーを設定できます。

例えると、プール (Pool) は「空港」のようなものです。様々な航空会社(プロバイダー)からの乗客(認証要求)を受け入れます。

プロバイダー (Provider) は「特定の航空会社」のようなものです。その航空会社特有のチケット(認証情報)を持つ乗客を確認し、適切に処理します。

この構造により、複数の外部IDソースを柔軟に管理し、それぞれに対して細かな設定を行うことが可能になります。

では、プールとプロパイダを定義します。

resource "google_iam_workload_identity_pool" "github_pool" {
  provider                  = google-beta
  project                   = var.project_id
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Pool"
}

resource "google_iam_workload_identity_pool_provider" "github_provider" {
  provider                           = google-beta
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub Provider"
  attribute_condition                = "assertion.repository_owner_id == 'xxxxxxxxx'"
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.actor"      = "assertion.actor"
    "attribute.aud"        = "assertion.aud"
    "attribute.repository" = "assertion.repository"
  }
  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

プールとプロバイダを作成し、プールにプロバイダを紐づけています。

プロバイダでは、属性のマッピングと、認証方法として OIDC を指定しています。

また、attribute_condition を設定し、特定の組織でのみ利用可能に制限しています。
(repository_owner_id の値は https://api.github.com/users/<user_or_org_name> の id を指定)

参考: GitHub または他のマルチテナント ID プロバイダと連携する場合に属性条件を使用する - Google Cloud

そして最後に、デプロイ用のサービスアカウントとこれらを紐づけます。

resource "google_service_account_iam_member" "github_action_deploy_workload_identity" {
  service_account_id = google_service_account.github_action_deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_pool.name}/attribute.repository/${var.github_org}/${var.github_repo}"
}

デプロイ用のサービスアカウントは、先ほど作成した Workload Identity Pool と Provider を介して、GitHub Actions からの認証を受け付けるように設定されます。

この設定により、GitHub Actions のワークフローが Google Cloud のリソースにアクセスする際、このサービスアカウントの権限を使用することができます。

具体的には、google_service_account_iam_member リソースを使用して、以下の設定を行っています:

  1. service_account_id: デプロイ用に作成したサービスアカウントを指定します。
  2. role: "roles/iam.workloadIdentityUser" を指定することで、外部IDシステム(この場合はGitHub)からこのサービスアカウントを使用する権限を付与します。
  3. member: Workload Identity Pool と、GitHubリポジトリを指定します。これにより、特定の GitHub リポジトリからの認証要求のみを受け付けるよう制限します。

この設定が完了すると、指定された GitHub リポジトリの Actions ワークフローから、Google Cloud のリソースに安全にアクセスできるようになります。ワークフロー内では、OpenID Connect (OIDC) トークンを使用して認証を行い、このサービスアカウントの権限で Google Cloud API を呼び出すことが可能になります。

GithubActions ワークフロー

最後のステップとして、GitHub Actions のワークフローを定義します。ワークフロー内では、公式から提供されている google-github-actions/auth アクションを使用して認証を行い、その後 Google Cloud CLI や他の関連アクションを使用してデプロイやその他の操作を実行できます。

- name: Authenticate to Google Cloud
  uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
    service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}

ここで渡している workload_identity_provider は、プロジェクト番号、プール名、プロバイダー名を含む、ワークロード ID プロバイダーの完全な識別子です。コンソール画面から取得するか、以下の gcloud コマンドでも取得できます。

gcloud iam workload-identity-pools providers describe <プロパイダ名 今回の例では github-provider> \
--workload-identity-pool="<プール名 今回の例では github-pool>" \
--location="global" \
--format="value(name)"

# -> projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider

https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/providers/describe

service_account は、デプロイ用に作成したサービスアカウント(email)です。

このように、Workload Identity 連携を使用することで、サービスアカウントのキーファイルを管理する必要がなくなります。GithubActions でデプロイを実施する際に、よりセキュアな CI/CD パイプラインを構築できます。

まとめ

Workload Identity 連携は、GCP 外のアプリケーションから GCP リソースを安全に操作する革新的な方法です。本記事では、GithubActions から GCP リソースをデプロイする過程を通じて、その実装方法を解説しました。

この技術を活用することで、サービスアカウントキーの管理が不要となり、セキュリティが向上し、運用負荷が大幅に軽減されます。より安全で効率的な CI/CD パイプラインや外部アプリケーション連携を構築できますので、是非試してみてください。

AWS セッションマネージャーでEC2に接続できない時の対処法

この記事は個人ブログと同じ内容です

はじめに

プライベートサブネットにあるEC2インスタンスに、VPCエンドポイント経由でAWS Systems Manager(SSM)を使って接続する際、かなり苦戦したので、その対処法についてこの記事でまとめます。

SSMで接続できないときのエラーメッセージ

SSMでEC2に接続できない場合、接続画面に以下のようなエラーメッセージが表示されることがあります。

SSM Agent がオンラインになっていません

SSM エージェントは Systems Manager エンドポイントに接続して自身をサービスに登録できませんでした。

私もAWS初心者で、最初は何が問題なのか全くわかりませんでした。原因は複数あるかもしれないので、以下の項目を一つずつ確認していきましょう。

確認すべき項目

  • AmazonSSMManagedInstanceCoreポリシーがアタッチされたIAMロールを持つEC2インスタンス
  • SSM エージェントがインストールされていること
  • 以下の4つのインターフェース型のVPCエンドポイントが設定されていること
    • com.amazonaws.region.ec2
    • com.amazonaws.region.ec2messages
    • com.amazonaws.region.ssm
    • com.amazonaws.region.ssmmessages
  • VPCエンドポイントへのTCP 443ポートの通信が許可されていること

特にネットワーク周りの設定がわかりにくいので、ここを確認するための具体的な方法を紹介します。

対処法1: VPC Reachability Analyzerを使う

VPC Reachability Analyzerは、VPC内のリソース間で接続テストを行うことができるツールです。 これを使うことで、EC2とVPCエンドポイントが通信できているか確認できます。また、通信が失敗している場合、どこで失敗しているかがわかるので非常に便利です。

送信元にEC2インスタンスを指定し、宛先にVPCエンドポイントのENI(Elastic Network Interface)を指定してテストを実施します。失敗した場合、どこで失敗したのかが一目でわかります。

Reachability Analyzerの画面

ただし、返りの通信(EC2からVPCエンドポイントへの応答)は確認できない点と、1回の分析に0.1ドルかかるため、無駄な分析をしないように注意してください。

対処法2: VPCフローログを見る

VPCフローログは、VPCのENI間のIPトラフィックに関する情報をキャプチャする機能です。 これを使うことで、EC2とVPCエンドポイントが通信できているかを確認できます。 ただし、VPC Reachability Analyzerとは異なり、セキュリティグループ(SG)やネットワークACL(NACL)のどちらが原因なのかは判別できないため、少し面倒です。 また、ログの内容はわかりにくいことが多いです。

まず、VPCの設定画面でフローログを作成します。すると、CloudWatchにロググループが作成されるので、その中からEC2とVPCエンドポイントのENIに関連するログを確認します。

フローログの画面

フローログの見方

AWS公式ドキュメントのフローログの例が参考になります。 https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/flow-logs-records-examples.html.

例えば、以下のログでは、IPアドレス 172.31.16.139 からプライベートIPアドレス 172.31.16.21 へのSSHトラフィック(宛先ポート22、TCPプロトコル)が許可されていることがわかります。

2 123456789010 eni-1235b8ca123456789 172.31.16.139 172.31.16.21 20641 22 6 20 4249 1418530010 1418530070 ACCEPT OK

実際にどんなログがあればいいのか

EC2のENIのフローログでは、EC2のIPアドレスからVPCエンドポイントのIPアドレスへのTCP 443通信が行われていることを確認します。

2 123456789010 {EC2またはVPCエンドポイントのENI} {EC2のIP} {VPCエンドポイントのIP} 20641 {443} 6 20 4249 1418530010 1418530070 {ACCEPT OK}

また、VPCエンドポイントのIPアドレスからEC2のIPアドレスへの443通信の返りも確認できれば問題ありません。

2 123456789010 {EC2またはVPCエンドポイントのENI} {VPCエンドポイントのIP} {EC2のIP} {443} 12345 6 20 4249 1418530010 1418530070 {ACCEPT OK}

対処法3: 他の方法でEC2に接続する

EC2に直接アクセスすることで、SSMエージェントがオンラインであるかどうかや、VPCエンドポイントとの疎通状況を確認できます。 他の方法で接続する場合、Instance Connectを利用するのがおすすめです。EC2のサブネットに作成すれば、セキュリティグループの設定し、VPCエンドポイントを作り、IAMポリシーを付与するだけで、接続できるため、手間が省けます。

SSMエージェントがオンラインであるか確認

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ssm-agent-status-and-restart.html

SSMエージェントがオンラインかどうかは、以下のコマンドで確認できます(Amazon Linux 2023の場合)。

sudo systemctl status amazon-ssm-agent

成功すると次のような表示がされます。失敗している場合は、SSMエージェントのインストールや起動を行いましょう。

SSMエージェントのステータス確認

VPCエンドポイントとの疎通確認

以下のコマンドでVPCエンドポイントとの疎通を確認できます。 ただし、どこで失敗したかの詳細まではわかりません。

curl -v https://VPCエンドポイントのDNS

レスポンスに以下のようなメッセージが含まれていれば、成功です。

* Connected to {VPCエンドポイントのDNS} (IPアドレス) port 443

対処法4: NACLとSGを全開放する

これは最終手段です。すべてのNACLとSGで0.0.0.0/0からのアクセスを許可します。 これで接続が成功すれば、ネットワーク設定に問題があったと考えられます。失敗する場合は、IAMロールやSSMエージェントのインストール状態など、ネットワーク以外の原因と考えられ、原因の切り分けができます。

接続が成功した場合は、NACLやSGのルールを一つずつセキュアな設定に戻していきましょう。

最後に

セッションマネージャーでEC2に接続できないときの対処法を説明しました。VPC Reachability Analyzerは、原因を迅速に特定できるため非常に便利です。VPCフローログも使えますが、内容がわかりにくく初心者には少し難しいかもしれません。少しずつ慣れていきましょう。

AWSのプライベートサブネットにあるEC2にSession Managerを使ってセキュアにアクセスする

この記事は個人ブログと同じ内容です

Session Managerについて

Session ManagerはAWS Systems Managerの一部であり、安全にEC2インスタンスなどに接続するためのツールです。Session Managerを使用すれば、SSHキーやパブリックIPを割り当てることなく、AWSのプライベートサブネットに配置されたEC2インスタンスに接続することができます。

VPCエンドポイントについて

VPCエンドポイントは、VPC内のリソース(例:EC2 インスタンス)がインターネットを経由せずに、他のAWSサービスに接続できるようにするための仮想デバイスです。

VPCエンドポイントを使ったSession Managerでのアクセスに必要なもの

・IAMポリシーのAmazonSSMManagedInstanceCoreを持ったIAMロールを持つEC2
・SSM エージェントがインストールされているEC2
・以下の4つのインターフェース型のVPCエンドポイント
 ・com.amazonaws.region.ec2.
 ・com.amazonaws.region.ec2messages.
 ・com.amazonaws.region.ssm.
 ・com.amazonaws.region.ssmmessages.
VPCエンドポイントへの443通信の許可.

今回作るもの

今回はプライベートサブネットにあるEC2にVPCエンドポイント経由でSession Managerを使ってセキュアにアクセスできるようにします。

作るのは、以下の通りです。
VPCとサブネット.
・EC2インスタンス.
・IAMロール.
VPCエンドポイント.

図にするとこんな感じです。

実際に作っていく

ステップ1 VPCとサブネットを作る

VPCとプライベートサブネットをEC2用とVPCエンドポイント用で2つ作っていきます。

VPCとサブネットのcidr_blockと設計は以下の通りです。
10.0.0.0/16 は全体のアドレス範囲、
10.0.0.0/18 はリージョン、AZ用(4個)、
10.0.0.0/20 はサブネット用(4個)、
アドレス部は12ビット(=4096-5個)
になるような設計をしました。

name cidr_block
VPC 10.0.0.0/16
EC2のサブネット 10.0.16.0/20
VPCエンドポイントのサブネット 10.0.48.0/20
locals {
  aws_region = "ap-northeast-1"
  name = "sample"
  cidr_blocks = {
    vpc       = "10.0.0.0/16"
    private  = "10.0.16.0/20"
    endpoint = "10.0.48.0/20"
  }
}

# VPC
# 10.0.0.0/16 は全体のアドレス範囲
# 10.0.0.0/18 はリージョン、AZ用(4個) 
# 10.0.0.0/20 はサブネット用(4個)
# アドレス部は12ビット(=4096-5個)

resource "aws_vpc" "main" {
  cidr_block           = local.cidr_blocks.vpc
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = {
    Name = local.name
  }
}
# EC2用のプライベートサブネット
resource "aws_subnet" "private" {
  availability_zone = "ap-northeast-1a"
  cidr_block        = local.cidr_blocks.private
  vpc_id            = aws_vpc.main.id
}

resource "aws_network_acl" "private" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.main.id]
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table_association" "private" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}
# VPCエンドポイント用のプライベートサブネット
resource "aws_subnet" "endpoint" {
  availability_zone = "ap-northeast-1a"
  cidr_block        = local.cidr_blocks.endpoint
  vpc_id            = aws_vpc.main.id
}

resource "aws_network_acl" "endpoint" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.main.id]
}

resource "aws_route_table" "endpoint" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table_association" "endpoint" {
  subnet_id      = aws_subnet.endpoint.id
  route_table_id = aws_route_table.endpoint.id
}

ステップ2 IAMロールの作成

IAMポリシーのAmazonSSMManagedInstanceCoreを持ったIAMロールを作ります。

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}
resource "aws_iam_role" "main" {
  name               = "ssm-iam-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy" "systems_manager" {
  arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_role_policy_attachment" "main" {
  role       = aws_iam_role.main.name
  policy_arn = data.aws_iam_policy.systems_manager.arn
}

resource "aws_iam_instance_profile" "systems_manager" {
  name = "ssm-instance-profile"
  role = aws_iam_role.main.name
}

ステップ3 EC2インスタンスの作成

EC2用のプライベートサブネットに、先ほど作ったIAMロールを付与したEC2インスタンスを作っていきます。 AMIは、SSM エージェントがすでにインストールされているAmazon Linux2023を使います。

resource "aws_instance" "main" {
  ami                         = "ami-05779067e4eff0b9d"
  instance_type               = "t4g.nano"
  subnet_id                   = local.cidr_blocks.private
  associate_public_ip_address = false
  vpc_security_group_ids      = [aws_security_group.ec2.id]
  availability_zone           = "ap-northeast-1a"
  iam_instance_profile        = aws_iam_instance_profile.systems_manager.name
}

resource "aws_security_group" "ec2" {
  name        = "ec2-sg"
  vpc_id      = aws_vpc.main.id
  description = "ec2 security group"
}

ステップ4 VPCエンドポイントの作成

以下の4つのインターフェースエンドポイントを作成します。

・com.amazonaws.region.ec2.
・com.amazonaws.region.ec2messages.
・com.amazonaws.region.ssm.
・com.amazonaws.region.ssmmessages.

resource "aws_vpc_endpoint" "main" {
  for_each = {
    ec2         = "com.amazonaws.ap-northeast-1.ec2"
    ec2messages = "com.amazonaws.ap-northeast-1.ec2messages"
    ssm         = "com.amazonaws.ap-northeast-1.ssm"
    ssmmessages = "com.amazonaws.ap-northeast-1.ssmmessages"
  }

  service_name       = each.value
  vpc_endpoint_type  = "Interface"
  vpc_id             = var.vpc_id
  security_group_ids = [aws_security_group.vpc_endpoint.id]
  subnet_ids         = [aws_subnet.private.id]
}

resource "aws_security_group" "vpc_endpoint" {
  description = "vpc-endpoint"
  name        = "vpc-endpoint"
  vpc_id      = aws_vpc.main.id
}

ステップ5 セキュリティグループとNACLの調整

先ほど作成したエンドポイントに対して、EC2からポート443のアクセスができるようにセキュリティグループとNACLを調整します。
注意ポイント:
・行きの通信だけではなく、帰りの通信も通れるようにしよう
・NACLは帰りの通信も記載する必要がありますが、セキュリティグループはステートレスなので、行きの通信のみで良いです

NACLの調整

# ステップ1で作成した、EC2用のプライベートサブネットのファイルの続き

resource "aws_network_acl_rule" "subnet_private1_ingress" {
  for_each = {
    ephemeral         = { action = "allow", cidr_block = local.cidr_blocks.endpoint, from_port = 1024, to_port = 65535, protocol = "tcp", rule_no = 100 }, #ssmの443通信の帰り
  }

  network_acl_id = aws_network_acl.private.id
  rule_action    = each.value.action
  cidr_block     = each.value.cidr_block
  from_port      = each.value.from_port
  to_port        = each.value.to_port
  protocol       = each.value.protocol
  rule_number    = each.value.rule_no
  egress         = false
}

resource "aws_network_acl_rule" "subnet_private1_egress" {
  for_each = {
    https                = { action = "allow", cidr_block = local.cidr_blocks.endpoint, from_port = 443, to_port = 443, protocol = "tcp", rule_no = 100 },                  #ec2からインターネット通信するため
  }

  network_acl_id = aws_network_acl.private.id
  rule_action    = each.value.action
  cidr_block     = each.value.cidr_block
  from_port      = each.value.from_port
  to_port        = each.value.to_port
  protocol       = each.value.protocol
  rule_number    = each.value.rule_no
  egress         = true
}
# ステップ1で作成した、VPCエンドポイント用のプライベートサブネットのファイルの続き

resource "aws_network_acl_rule" "subnet_endpoint1_ingress" {
  for_each = {
    https_from_private = { action = "allow", cidr_block = local.cidr_blocks.private, from_port = 443, to_port = 443, protocol = "tcp", rule_no = 100 },
  }

  network_acl_id = aws_network_acl.endpoint.id
  rule_action    = each.value.action
  cidr_block     = each.value.cidr_block
  from_port      = each.value.from_port
  to_port        = each.value.to_port
  protocol       = each.value.protocol
  rule_number    = each.value.rule_no
  egress         = false
}

resource "aws_network_acl_rule" "subnet_endpoint1_egress" {
  for_each = {
    ephemeral_to_private1 = { action = "allow", cidr_block = local.cidr_blocks.private, from_port = 1024, to_port = 65535, protocol = "tcp", rule_no = 100 },
  }

  network_acl_id = aws_network_acl.endpoint.id
  rule_action    = each.value.action
  cidr_block     = each.value.cidr_block
  from_port      = each.value.from_port
  to_port        = each.value.to_port
  protocol       = each.value.protocol
  rule_number    = each.value.rule_no
  egress         = true
}

セキュリティグループの調整

# ステップ3で作ったEC2のファイルの続き

resource "aws_vpc_security_group_egress_rule" "ec2_egress" {
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  security_group_id = aws_security_group.ec2.id
  cidr_ipv4         = local.cidr_blocks.private
}
#ステップ4で作ったVPCエンドポイントのファイルの続き

resource "aws_vpc_security_group_ingress_rule" "vpc_endpoint_ingress" {
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  security_group_id = aws_security_group.vpc_endpoint.id
  cidr_ipv4         = local.cidr_blocks.private
}

ステップ6 Session Managerでアクセスする

  1. AWSにアクセスして、EC2のインスタンス選択して、接続ボタンを押す

  2. 画像のようになっていれば成功です!

まとめ

今回SSMでの接続をしましたが、個人的にネットワーク周りにかなり苦戦したので、うまくいかない時は入念に見直しましょう。
あと、公式ドキュメント読みにくくて、個人で書いたブログに行きがちですが、公式は間違いがないので最優先で読みましょう。
不明点や問題点などありましたらコメントお願いします。

参考記事.
https://zenn.dev/kazutech/articles/3559db9605d198
https://dev.classmethod.jp/articles/terraform-session-manager-linux-ec2-vpcendpoint/