📚

【CloudNative Entry】入社課題で学んだことTips

2024/11/01に公開

はじめに

10月から 3-shake に入社した melanmeg です。
入社時課題が始まって、 やったこと・わかったこと をここに整理してみました!

前職ではAWS・Azureを触っていたため、今回Google Cloudで課題を進めることにしました。
今まで触ってこなかったのでクラウドごとの特徴を知れる良い学びになりました。

早速、整理したことを紹介していきます。

課題

一言でいうと、『クラウドネイティブのエントリーレベルのスキルを身に着けるを目標の元、k8sクラスタ構築からwordpressを載せて、CI/CDまで作る』になります。

ざっと主にやったこと一覧

  1. Terraform
  2. GKE
    2.1. 夜間停止てきな機能
  3. External Secrets Operator
  4. Cloud SQL
  5. ArgoCD
  6. CI/CD

設計思想

ある程度設計思想を持って取り組みました。
※予め決めておくことで一貫した作りにできたかなと思います。

設計思想

  • 本番ではなく、開発・検証用として作る
    • 再構築しやすくするために削除ポリシーは有効化する
    • コストはできるだけ抑える
  • なるべくセキュアに設計する
  • なるべくIaCで管理して、手動構築を最低限にする
  • 勉強のため興味のある機能も盛り込んでみる

環境

  • Gitlab
  • Google Cloud
  • Ubuntu24.04 (端末)

全体像

1.Terraform

  • Google Cloudのリソース作成は基本的にすべてTerraformで作成する方針としました。

tfstateをGCSで管理する

Terraformには、現在の状態を記録するtfstateファイルが実行時に作成されます。
Terraformによって作成されたリソースの詳細情報を読み取って、クラウド上に作成されている状態とコードに定義された状態との比較を取っています。

このファイルをクラウドで管理することで共同開発ができ、クラウドにバックアップも取れるというメリットがあります。

TerraformバックエンドにGCSを設定する方法

  • GCSバケットの作成
$ gsutil mb -l asia-northeast1 gs://BUCKET_NAME 
  • Terraformの設定
provider.tf
terraform {
  backend "gcs" {
    bucket  = "BUCKET_NAME"
  }
}

この時のポイントとして、共同開発時に同時にtfstateを参照すると競合が起こりうるため、これを回避するためにロックをかける設定を入れるのが良いです。

ただしGCSの場合、特に気にする必要がなくロックはサポートされています。

バックエンドでサポートされている場合、Terraform は状態を書き込む可能性のあるすべての操作に対して状態をロックします。
https://developer.hashicorp.com/terraform/language/state/locking

ロックがかかった時のエラー例
$ terraform plan
Acquiring state lock. This may take a few moments...
╷
│ Error: Error acquiring the state lock
│ 
│ Error message: writing "gs://tfstate-bucket-melanmeg/default.tflock" failed: googleapi: Error 412: At least one of the pre-conditions you specified did
│ not hold., conditionNotMet
│ Lock Info:
│   ID:        1728368306539182
│   Path:      gs://tfstate-bucket-melanmeg/default.tflock
│   Operation: OperationTypeApply
│   Who:       melanmeg@wsl
│   Version:   1.9.2
│   Created:   2024-10-08 06:18:26.271664754 +0000 UTC
│   Info:      
│ 
│ 
│ Terraform acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and try
│ again. For most commands, you can disable locking with the "-lock=false"
│ flag, but this is not recommended.

Terraformバックエンドの状態ロックのサポート状況について

先に述べたようにGCSではロックがサポートされていますが、3大クラウドすべてが純粋にサポートされているわけではありませんでした。

  • Azureの場合
    Azure Blobでサポートされてます。

https://developer.hashicorp.com/terraform/language/backend/azurerm

  • AWSの場合
    DynamoDBを介したS3でのロックがサポートされてます。

https://developer.hashicorp.com/terraform/language/backend/s3

AWSでは、DynamoDBのテーブル作成が必要です。
理由については、以下記事でまとめられている方がいましたので参考に。
https://zenn.dev/ishii1648/articles/02044d9ee78942

Terraformバージョン表記について

Terraformモジュールのバージョンは、セマンティックバージョニングというバージョン番号付けスキーマが推奨されています。そしてこの表記は、dockerコンテナのイメージなどでもよく利用されています。何だかカッコいい名称です。

https://developer.hashicorp.com/terraform/plugin/best-practices/versioning

ドキュメントによると、バージョン表記のMAJOR.MINOR.PATCH番号付けには以下の意味があるようです。

MAJOR version when you make incompatible API changes
MINOR version when you add functionality in a backward compatible manner
PATCH version when you make backward compatible bug fixes

またTerraformで『version = "~> x.y"』の表記をよく見かけるのですが、これは上記でいうPATCHバージョンだけ最新化を許可するの意味になります。

module "XXX" {
  source  = "XXX"
  version = "~> x.y.x"
}

2.GKE

GKEのIP制限について

GKE Autopilotでアクセス制限を実現するには、限定公開クラスタで構築する方法があります。

その名の通り、外部からのアクセス制御が必要なワークロードに適しており、公式ドキュメントによるとGKEへのアクセス制御をするには、3つオプションが挙げられています。

/ 外部エンドポイント アクセス 承認済みネットワーク
パターン1 無効 -
パターン2 有効 有効
パターン3 有効 無効
  1. パターン1は、最も安全なオプションで、すべてのインターネットアクセスが阻止される
  2. パターン2は、承認済みネットワークがコントロールプレーンの外部エンドポイントに適用される
  3. パターン3は、最も制限の少ないオプション

今回の課題要件としては、指定の外部IPからのみアクセス可能にしたかったのでパターン2を採用しました。

GKE Autopilot 作成

モジュール選定

クラスタ作成には、terraform-google-kubernetes-engineモジュールを利用し、中でも限定公開クラスタ作成用のbeta-autopilot-private-clusterを選びました。

betaと名が付いていますが、READMEを読んだところではモジュールがベータ版というより 様々なGKEベータ機能を使用できるという意味合いのようです。

クラスタ作成

  • 実装例
GKE Autopilot (Terraform)
locals.tf
locals {
  # common
  project_id = "PROJECT_ID"
  region     = "asia-northeast1"
  zones      = ["asia-northeast1-a"]

  # subnet
  subnet = {
    name          = "my-subnet"
    ip_cidr_range = "192.168.0.0/24"
    stack_type    = "IPV4_ONLY"
    secondary_pod_ip_range = {
      range_name    = "pod-ranges"
      ip_cidr_range = "10.128.0.0/16"
    }
    secondary_service_ip_range = {
      range_name    = "services-range"
      ip_cidr_range = "10.96.0.0/16"
    }
  }
}
network.tf
# network
resource "google_compute_network" "default" {
  name = "network"
  auto_create_subnetworks  = false
}

# subnet
resource "google_compute_subnetwork" "default" {
  name             = local.subnet.name
  ip_cidr_range    = local.subnet.ip_cidr_range
  region           = local.region
  stack_type       = local.subnet.stack_type
  network          = google_compute_network.default.id

  secondary_ip_range {
    range_name    = local.subnet.secondary_service_ip_range.range_name
    ip_cidr_range = local.subnet.secondary_service_ip_range.ip_cidr_range
  }

  secondary_ip_range {
    range_name    = local.subnet.secondary_pod_ip_range.range_name
    ip_cidr_range = local.subnet.secondary_pod_ip_range.ip_cidr_range
  }
}

# nat
resource "google_compute_router" "default" {
  name    = "nat-router"
  project = local.project_id
  region  = local.region
  network = google_compute_network.default.self_link
}

resource "google_compute_address" "default" {
  name    = "nat-address"
  project = local.project_id
  region  = local.region
}

resource "google_compute_router_nat" "default" {
  name                               = "nat"
  project                            = local.project_id
  region                             = local.region
  router                             = google_compute_router.default.name
  nat_ip_allocate_option             = "AUTO_ONLY"

  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
}
gke.tf
module "gke" {
  source                                   = "terraform-google-modules/kubernetes-engine/google//modules/beta-autopilot-private-cluster"
  version                                  = "33.0.4"
  project_id                               = local.project_id
  name                                     = "gke"
  region                                   = local.region
  zones                                    = local.zones
  network                                  = google_compute_network.default.name
  subnetwork                               = google_compute_subnetwork.default.name
  ip_range_pods                            = local.subnet.sub1.secondary_pod_ip_range.range_name
  ip_range_services                        = local.subnet.sub1.secondary_service_ip_range.range_name
  stack_type                               = "IPV4"
  enable_l4_ilb_subsetting                 = true # L4 内部ロードバランサー (ILB)を有効化。明示しないとterraform実行ごとに再作成要求されるため注意
  enable_vertical_pod_autoscaling          = true
  horizontal_pod_autoscaling               = true
  enable_private_endpoint                  = false # 外部エンドポイントアクセスを有効化
  enable_private_nodes                     = true
  network_tags                             = ["allow-hoge"]
  master_ipv4_cidr_block                   = "172.16.0.0/28"
  dns_cache                                = false
  deletion_protection                      = false
  enable_binary_authorization              = true # Enable BinAuthZ Admission controller
  enable_cost_allocation                   = true # Enables Cost Allocation Feature and the cluster name and namespace of your GKE workloads appear in the labels field of the billing export to BigQuery

  master_authorized_networks = [
    {
      cidr_block   = "X.X.X.X/XX" # 承認済みネットワークのアドレス範囲設定
      display_name = "VPC"
    }
  ]
}

アクセス許可したいネットワークについては、gke.tf内にあるmaster_authorized_networkscidr_blockを指定することで可能です。

所感では、GKE Standard や AWS EKS に比べ、Autopilotはノードの管理が不要な分、実装も簡素でありそうです。

1つ不思議な点がありました。enable_l4_ilb_subsettingの値を明示しなかった場合に再度terraform実行すると差分がある判定になったことです!値を明示して置けば問題はないですが、現象の起こる理由まではちょっと見つかりませんでした💦恐らくは、モジュールを利用する場合にはこのような現象も起こるんだなぁと勉強になりました。

現場の方にも話を聞くと、モジュールを使うのは再利用性やコードの簡素化といったメリットもあるが、モジュールだと最新機能がすぐサポートされないことがあったり、モジュールのメンテナンスする側に委ねられる部分も大きいというデメリットもあるようですね🤔

クラスタ接続

  • Google Cloud CLI 認証
$ gcloud auth login --no-launch-browser
$ gcloud config set project PROJECT_ID
$ gcloud config set account EMAIL
$ gcloud auth application-default login --no-launch-browser
  • クラスタ接続
$ gcloud components install gke-gcloud-auth-plugin # 一度だけ実行
$ gcloud container clusters get-credentials my-gke-cluster --region=asia-northeast1
$ kubectl get ns

2.1.夜間停止てきな機能

GKEクラスタを定期削除

GKE Autopilotはノード管理はマネージドであるため、クラスタを削除するか、Podを0にして費用を抑えるかの2択があると思います。

実際の現場では、Pod数などを0にする方法を取ることが多いようですが、今回クラスタ定期削除をやってみました。

構成としては、以下のようになってます。

  • 実装例
GKEクラスタを定期削除 (Terraform)
constant_delete_gke_cluster.tf
resource "google_pubsub_topic" "default" {
  name   = "my-gke-scheduler-topic"
  labels = local.labels
}

resource "google_storage_bucket" "default" {
  name     = "my-gke-function-bucket"
  location = "ASIA"
  labels   = local.labels
}

resource "google_storage_bucket_object" "default" {
  name   = "function_source.zip"
  bucket = google_storage_bucket.default.name
  source = "${path.module}/function/function_source.zip"
}

resource "google_cloudfunctions_function" "default" {
  name                  = "my-gke-control-function"
  runtime               = "python310"
  entry_point           = "gke_control"
  source_archive_bucket = google_storage_bucket.default.name
  source_archive_object = google_storage_bucket_object.default.name
  event_trigger {
    event_type = "google.pubsub.topic.publish"
    resource   = google_pubsub_topic.default.id
  }

  environment_variables = {
    CLUSTER_ID = module.gke.cluster_id
  }

  labels = local.labels
}

resource "google_cloud_scheduler_job" "default" {
  name        = "my-gke_delete_job"
  description = "Delete GKE Cluster at night"
  schedule    = "0 22 * * *" # 毎日22時に削除
  pubsub_target {
    topic_name = google_pubsub_topic.default.id
    data = base64encode(jsonencode({ # 必ずdata設定が必要。
      action = "delete"              # データをBase64形式にエンコードする。
    }))
  }
}
GKEクラスタを定期削除 (実装)
main.py
import base64
import json
import os

import google.auth
from googleapiclient.discovery import build


def gke_control(data, context):  # 引数2つ必要
    request_json = base64.b64decode(data["data"]).decode("utf-8")
    request_json = json.loads(request_json)
    action = request_json.get("action")

    cluster_id = os.environ.get("CLUSTER_ID")

    credentials, _ = google.auth.default()
    service = build("container", "v1", credentials=credentials)

    if action == "delete":
        # GKEクラスタを削除する
        print("In delete start")
        # 完全なリソース名を指定
        cluster_name = f"projects/{project_id}/locations/{location}/clusters/{cluster_id}"
        response = service.projects().locations().clusters().delete(name=cluster_name).execute()
        print(f"Delete response: {response}")
        print("In delete end")

    print("GKE Cluster delete process completed")
    return "GKE Cluster deleted"
requirements.txt
google-api-python-client==2.149.0

Terraform実行前には以下でzipファイルにまとめておく

$ zip function_source.zip main.py requirements.txt

作成してから思ったのですが、クラスタ削除してしまうと規模によっては再作成に時間がかかるので、Pod数などを0にする方が実用的かもと感じました。ただ、環境消し忘れ対策など何か役立つことはあるかもしれません。

3.External Secrets Operator

シークレット管理について

まず何の情報を管理するかと言うと、今回の課題であるwordpressのパスワード情報(wordpressログインユーザーDBユーザーのパスワード)を管理します。

このパスワード情報をコードに直書きしてリポジトリへプッシュするのはセキュリティ上、良くないためSecret Manager[1]などの外部でシークレット管理をするのが1つの手段になります。

今回、External Secrets Operator[2]を使い、Secret Managerからシークレットを取得する実装にしました。

外部シークレット 作成

最低限の事前作業として、パスワード情報をSecret Managerに手動で登録しておきます。

  • 事前作業
$ echo -n 'testpassword' > user-password.secret  # wordpressログインユーザーのパスワード
$ echo -n 'db-password' > db-password.secret  # DBのパスワード ※もし改行が入ると、wordpressでは設定が上手く反映されなくなるため注意
$ gcloud secrets create wordpress-user-password-sm --replication-policy=automatic --data-file=user-password.secret
$ gcloud secrets create wordpress-db-password-sm --replication-policy=automatic --data-file=db-password.secret

次に、TerraformでExternal Secrets Operatorを構築します。

ここで重要だったのがWorkload Identity連携[3]についてです。

GSA(Google Service Account)を作成してCloud SQLへ接続する事例はいくつか見られましたが、GKE Autopilotを利用している場合、Workload Identity連携が常に有効になっており、KSA(kubernetes Service Account)に許可ポリシーを与えるだけで済みます。

Autopilot では、GKE 用 Workload Identity 連携が常に有効になっています。GKE 用 Workload Identity 連携を使用するようアプリケーションを構成するセクションに進みます。
https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity?hl=ja#enable_on_clusters_and_node_pools

  • 実装例
External Secrets Operator (Terraform)
external_secrets.tf
resource "google_project_iam_member" "external_secrets_access" {
  project = local.project_id
  role    = "roles/secretmanager.secretAccessor"
  member  = "principal://iam.googleapis.com/projects/${local.project_number}/locations/global/workloadIdentityPools/${local.project_id}.svc.id.goog/subject/ns/external-secrets/sa/external-secrets-sa"
}
External Secrets Operator (実装)
cluster-secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: secretstore-gcp-sm
  namespace: external-secrets
spec:
  provider:
    gcpsm:
      projectID: PROJECT_ID
      auth:
        workloadIdentity:
          clusterLocation: asia-northeast1
          clusterName: my-gke-cluster
          clusterProjectID: PROJECT_ID
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets
ksa.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-secrets-sa
  namespace: external-secrets
external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: wordpress-externalsecret
  namespace: wordpress
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: secretstore-gcp-sm
  target:
    name: wordpress-secret
    creationPolicy: Owner
  data:
    - secretKey: wordpress-password
      remoteRef:
        key: wordpress-user-password-sm
        version: latest
    - secretKey: mariadb-password
      remoteRef:
        key: wordpress-db-password-sm
        version: latest

GSAなしでこれほど簡素に実装ができました!Autopilotすごい👍

外部シークレットからパスワードを取得

以下コマンドで、パスワード取得確認。

$ kubectl get secret wordpress-secret -n wordpress -o jsonpath='{.data.wordpress-password}' | base64 --decode
$ kubectl get secret wordpress-secret -n wordpress -o jsonpath='{.data.mariadb-password}' | base64 --decode

4.Cloud SQL

wordpressのDB用にCloud SQLを利用します。

GKEからCloud SQLへの接続について

Cloud SQLへの接続方法には、PSA(Private Service Access)を使いました。

他にもPSC(Private Service Connect)Cloud SQL Auth Proxyを利用する方法もあるようですが、内部から安全にアクセスする手段としてPSAがシンプルで適していると判断しました。

https://cloud.google.com/sql/docs/mysql/configure-private-services-access?hl=ja

Cloud SQL 作成

最低限の事前作業として、パスワード情報をterraform.tfvarsへ手動で作成します。

  • 事前準備
$ cat <<EOF > terraform.tfvars
db_user = "db-user"
db_password = "db-password"
EOF
  • 実装例
Cloud SQL (Terraform)
sql.tf
resource "google_compute_global_address" "cloud-sql" {
  name          = "my-private-ip-address"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 16
  network       = google_compute_network.default.id
}

resource "google_service_networking_connection" "default" {
  network                 = google_compute_network.default.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.cloud-sql.name]
  deletion_policy         = "ABANDON" # CloudSQL インスタンスを破棄するときに terraform destroy が正常に実行されるようにする
}

resource "google_sql_database_instance" "default" {
  name                = "my-sql-instance"
  database_version    = "MYSQL_8_0"
  region              = local.region
  deletion_protection = false

  depends_on = [google_service_networking_connection.default]

  settings {
    tier = "db-f1-micro"

    backup_configuration {
      enabled = false # 自動バックアップ無効化
    }

    ip_configuration {
      ipv4_enabled                                  = false
      private_network                               = google_compute_network.default.self_link
      enable_private_path_for_google_cloud_services = true
    }
  }
}

resource "google_sql_database" "default" {
  name     = "wordpress-database"
  instance = google_sql_database_instance.default.name
}

resource "google_sql_user" "default" {
  name     = var.db_user
  instance = google_sql_database_instance.default.name
  password = var.db_password
  host            = "%"
  deletion_policy = "ABANDON" # SQL ロールが付与されているユーザーを API から削除できない Postgres で役立つ
}
dns.tf
resource "google_dns_managed_zone" "default" {
  name        = "my-zone"
  description = "For me"
  dns_name    = "my-dns."
  visibility  = "private"
  private_visibility_config {
    networks {
      network_url = google_compute_network.default.id
    }
  }

  labels = local.labels
}

resource "google_dns_record_set" "default" {
  name         = "my-cloud-sql.my-dns."
  managed_zone = google_dns_managed_zone.default.name
  type         = "A"
  ttl          = 300

  rrdatas = [google_sql_database_instance.default.private_ip_address]
}

my-cloud-sql.my-dnsというホスト名でアクセスができます。

5.ArgoCD

ID/PASSでのログインは廃止し、GitLabでのSSOログインにする

argocdのSSO連携は簡素化されていると感じました。
Gitlabで発行したclientIDclientSecretをvalues.yamlに書くだけである。

argocdのhelmChartはこちら

  • argocdのvalues.yaml の SSO連携設定
values.yaml
configs:
  cm:
    # Disable userid/password login and remove from login page
    admin.enabled: false
    dex.config: |
      connectors:
        - type: gitlab
          id: gitlab
          name: Gitlab
          config:
            clientID: xxxx
            clientSecret: xxxx
  rbac:
    policy.csv: |
      g, XXXX/YYYY, role:admin
    policy.default: role:readonly

シークレット情報を秘匿化

プライベートリポジトリへのアクセス

  • argocdからプライベートリポジトリへアクセスするには、以下のようにシークレットを作成します。
repo-secret.yaml
# ref: https://argo-cd.readthedocs.io/en/stable/operator-manual/argocd-repositories-yaml/
apiVersion: v1
kind: Secret
metadata:
    name: private-ssh-repo
    namespace: argocd
    labels:
        argocd.argoproj.io/secret-type: repository
stringData:
    url: git@gitlab.com:REPOSITORY_URL
    sshPrivateKey: |
        -----BEGIN OPENSSH PRIVATE KEY-----
        ...
        -----END OPENSSH PRIVATE KEY-----
    insecure: "true"
    enableLfs: "true"

sopsで暗号化する

暗号化に使用する鍵を安全に管理することは重要です。

今回は、Google KMSというKMSサービスに鍵管理し、鍵を参照することで利用します。

最低限の事前作業として、repo-secret.yamlファイルを暗号化します。

  • 事前作業
# Google Cloud KMS作成
$ gcloud kms keyrings create sops --location global
$ gcloud kms keys create sops-key --location global --keyring sops --purpose encryption
$ gcloud kms keys list --location global --keyring sops
.sops.yaml
creation_rules:
  - gcp_kms: projects/PROJECT_ID/locations/global/keyRings/sops/cryptoKeys/sops-key
# 暗号化する
$ sops -e -i repo-secret.yaml
# 復号化する
$ sops -d repo-secret.yaml | kubectl apply -f -

外部公開のIP制限

デフォルトでは、外部IPを公開するとどこからでもアクセスが可能です。
特定のIPからのみアクセス可能なように制限するには、Cloud ArmorとIngressを組み合わせることで実現できます。

今回、証明書はIngress用に自己署名証明書を作成しました。
以下コマンドで事前にシークレットを作成しておきます。

$ openssl genrsa -out tls.key 2048
$ openssl req -new -x509 -key tls.key -out tls.crt -days 365 -subj "/CN=argocd.example.com"
$ kubectl create secret tls argocd-testsecret -n argocd --cert=tls.crt --key=tls.key
Cloud Armor (Terraform)
locals.tf
locals {
  rules = [
    # デフォルトルール
    {
      action   = "deny(403)"  # すべてのアクセスをデフォルトで拒否する
      priority = "2147483647"
      match = {
        versioned_expr = "SRC_IPS_V1"
        config = {
          src_ip_ranges = ["*"]
        }
      }
      description = "Deny access to All IPs"
    },
    {
      action   = "allow"
      priority = "1000"
      match = {
        versioned_expr = "SRC_IPS_V1"
        config = {
          src_ip_ranges = ["X.X.X.X/XX"]  # 特定のネットワークのみを許可
        }
      }
      description = "Allow access to VPC"
    }
  ]
}
backends.tf
resource "google_compute_security_policy" "policy" {
  name = "my-policy"

  dynamic "rule" {
    for_each = local.rules
    content {
      action   = rule.value.action
      priority = rule.value.priority
      match {
        versioned_expr = rule.value.match.versioned_expr
        config {
          src_ip_ranges = rule.value.match.config.src_ip_ranges
        }
      }
      description = rule.value.description
    }
  }
}
Argocd (実装)
argocd-values.yaml
# valuesに以下設定を追記
server:
  service:
    type: NodePort
    annotations:
      cloud.google.com/backend-config: '{"ports": {"http":"argocd-backend-config"}}'
      cloud.google.com/neg: '{"ingress": true}'
configs:
  params:
    server.insecure: true  # httpからhttpsにリダイレクトするのを許可するため
argocd.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-ingress
  namespace: argocd
  annotations:
    kubernetes.io/ingress.class: "gce"
    kubernetes.io/ingress.global-static-ip-name: argocd-ssl-public-ip
spec:
  tls:
    - secretName: argocd-testsecret  # 作成した自己署名証明書を指定
      hosts:
        - argocd.example.com  # 指定のドメインを指定
  defaultBackend:
    service:
      name: argocd-server
      port:
        number: 80
---
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
  name: argocd-backend-config
  namespace: argocd
spec:
  healthCheck:
    checkIntervalSec: 30
    timeoutSec: 5
    healthyThreshold: 1
    unhealthyThreshold: 2
    type: HTTP
    requestPath: /healthz
    port: 8080
  securityPolicy:
    name: my-policy
  • 許可していないネットワークからアクセスしたときの表示
    先の設定のCloud Armorのセキュリティポリシーで"deny(403)"と設定していたため、 403が返ってきていることが確認できました。

Argocd デプロイ

まず、プライベートリポジトリへアクセス用シークレットを復号化と同時にデプロイしておきます。

$ kubectl create ns argocd
$ sops -d repo-secret.yaml | kubectl apply -f -

argocdをデプロイします。valuesファイルもsopsで暗号化している場合、helm secretsで自動的に復号してインストールすることができます。

$ helm repo add argo https://argoproj.github.io/argo-helm
$ helm secrets install argocd argo/argo-cd --version 7.6.12 -n argocd -f argocd-values.yaml
$ helm install argocd argo/argocd-apps --version 2.0.2 -f argocd-apps-values.yaml

ログイン確認

6.CI/CD

CIに組み込んだ項目

  • サンプルアプリ(Go)のtest
  • dockerイメージのビルド、プッシュ
  • trivyによるスキャン

Workload Identity Federationについて

CI/CDの実装を進める上で、特に役立った資料がこちらになります。
https://sreake.com/blog/gitlab-runner-cicd/

ここでのポイントは、Workload Identity Federationで認証していることです。
先の資料の引用です。

GARにプッシュするために、事前にアクセス認証をする必要があります。認証方法はいくつかありますが、今回はJWTとWorkload Identity Federationを利用した方法を利用します。この方法だと鍵などを保存せずともGARに対してイメージをプッシュすることが可能になります。

認証情報をGit側に渡せないような場面では活きてくるかと思います。

参考資料では、ArgoCD Image Updaterの例も挙げられてますが、今回こちらは取り入れない方針としました。

また、イメージタグのベストプラクティスについても触れてありました、参考になります。

CI/CDの実装

  • 事前作業(再作成時のみ)
# 再作成時は、Workload Identity プール/プロバイダ が30日間は完全削除されないため、以下コマンドでundelete,importしておくことによりTerraformのエラーを回避。
$ gcloud iam workload-identity-pools undelete my-gitlab-pool --location global --project PROJECT_ID
$ gcloud iam workload-identity-pools providers undelete my-gitlab-pool-provider --workload-identity-pool gitlab --location global --project PROJECT_ID
$ terraform import -var-file="db.tfvars" google_iam_workload_identity_pool.gitlab_pool projects/PROJECT_ID/locations/global/workloadIdentityPools/my-gitlab-pool
$ terraform import -var-file="db.tfvars" google_iam_workload_identity_pool_provider.gitlab_provider_jwt projects/PROJECT_ID/locations/global/workloadIdentityPools/my-gitlab-pool/providers/my-gitlab-pool-provider
  • 実装例
Workload Identity (Terraform)
workload_identity.tf
# Workload Identity Pool の作成
resource "google_iam_workload_identity_pool" "gitlab_pool" {
  workload_identity_pool_id = "my-gitlab-pool"
  project                   = local.project_id
}

# Workload Identity Pool Provider の作成
resource "google_iam_workload_identity_pool_provider" "gitlab_provider_jwt" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.gitlab_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "my-gitlab-pool-provider"
  project                            = local.project_id
  attribute_mapping = {
    "google.subject"         = "assertion.sub",
    "attribute.aud"          = "assertion.aud",
    "attribute.project_path" = "assertion.project_path",
    "attribute.project_id"   = "assertion.project_id",
    "attribute.group_id"     = "assertion.project_path.startsWith('group_a/') ? 'group_a' : ''"
    "attribute.ref"          = "assertion.ref",
  }
  # GitLab上の特定のプロジェクトからのみ利用可能なように絞っている
  attribute_condition = "assertion.project_id == '${local.gitlab_project_id}'"
  oidc {
    issuer_uri        = local.gitlab_url
    allowed_audiences = [local.gitlab_url]
  }
}

# Gitlab CI用サービスアカウントの作成
resource "google_service_account" "gitlab_service_account" {
  account_id   = "my-gitlab-sa"
  display_name = "GitLab Service Account"
  project      = local.project_id
}

# Gitlab CI用サービスアカウントへの Workload Identityユーザロールの付与
resource "google_service_account_iam_member" "gitlab_runner_oidc" {
  service_account_id = google_service_account.gitlab_service_account.id
  role               = "roles/iam.workloadIdentityUser"
  # GitLab上の特定のプロジェクトからのみ利用可能なように絞っている
  member     = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.gitlab_pool.name}/attribute.project_id/${local.gitlab_project_id}"
  depends_on = [google_service_account.gitlab_service_account]
}

# GARへの読み込みアクセス権限を付与
resource "google_project_iam_member" "artifact_registry_reader" {
  project = local.project_id
  role    = "roles/artifactregistry.reader"
  member  = google_service_account.gitlab_service_account.member
}

# GARへの書き込みアクセス権限を付与
resource "google_project_iam_member" "artifact_registry_writer" {
  project = local.project_id
  role    = "roles/artifactregistry.writer"
  member  = google_service_account.gitlab_service_account.member
}
CI/CD (実装)
gitlab-ci.yaml
variables:
  GO_VERSION: 1.23.2
  GOVULNCHECK_VERSION: 1.1.3
  APP_ROOT: ${CI_PROJECT_DIR}/app
  IMAGE: welcom-study-app
  WORKLOAD_IDENTITY_PROVIDER: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/my-gitlab-pool/providers/my-gitlab-pool-provider
  SERVICE_ACCOUNT_EMAIL: my-gitlab-sa@PROJECT_ID.iam.gserviceaccount.com
  REGISTRY_HOSTNAME: asia-northeast1-docker.pkg.dev
  REGISTRY_URL: ${REGISTRY_HOSTNAME}/PROJECT_ID/my-repository

default:
  image: golang:${GO_VERSION}
  before_script:
    - cd ${APP_ROOT}

stages:
  - test
  - build
  - scan
  - push

go test:
  stage: test
  before_script:
    - cd ${APP_ROOT}/golang
  script:
    - go test -v ./...

go vulncheck:
  stage: test
  before_script:
    - cd ${APP_ROOT}/golang
  script:
    - go install golang.org/x/vuln/cmd/govulncheck@v${GOVULNCHECK_VERSION}
    - govulncheck ./...

build:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - test -d image || mkdir image
    - /kaniko/executor
      --context .
      --dockerfile ./Dockerfile
      --destination "${IMAGE}:${CI_COMMIT_SHORT_SHA}"
      --tarPath image/${CI_COMMIT_SHA}.tar
      --no-push
  artifacts:
    paths:
      - ${APP_ROOT}/image

scan image:
  stage: scan
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    # failed to download vulnerability DB エラー対策として、キャッシュディレクトリを指定
    - trivy image --download-db-only --cache-dir /cache
    # オフラインでスキャンを実行
    - trivy image --no-progress --exit-code 1 --input image/${CI_COMMIT_SHA}.tar --severity HIGH,CRITICAL --offline-scan --cache-dir /cache

push-job:
  stage: push
  image: docker:27.3.1-alpine3.20
  services:
    - docker:27.3.1-dind-alpine3.20
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  before_script:
    - apk update && apk add curl jq
    - |
      PAYLOAD="$(cat <<EOF
      {
        "audience": "//iam.googleapis.com/${WORKLOAD_IDENTITY_PROVIDER}",
        "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
        "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
        "scope": "https://www.googleapis.com/auth/cloud-platform",
        "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
        "subjectToken": "${GITLAB_OIDC_TOKEN}"
      }
      EOF
      )"
    - |
      FEDERATED_TOKEN="$(curl --fail "https://sts.googleapis.com/v1/token" \
        --header "Accept: application/json" \
        --header "Content-Type: application/json" \
        --data "${PAYLOAD}" \
        | jq -r '.access_token'
      )"
    - |
      ACCESS_TOKEN="$(curl --fail "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SERVICE_ACCOUNT_EMAIL}:generateAccessToken" \
        --header "Accept: application/json" \
        --header "Content-Type: application/json" \
        --header "Authorization: Bearer ${FEDERATED_TOKEN}" \
        --data '{"scope": ["https://www.googleapis.com/auth/cloud-platform"]}' \
        | jq -r '.accessToken'
      )"
    - echo ${ACCESS_TOKEN} | docker login -u oauth2accesstoken --password-stdin https://${REGISTRY_HOSTNAME}
  script:
    - docker load -i ${APP_ROOT}/image/${CI_COMMIT_SHA}.tar
    - docker tag ${IMAGE}:${CI_COMMIT_SHORT_SHA} ${REGISTRY_URL}/${IMAGE}:latest
    - docker tag ${IMAGE}:${CI_COMMIT_SHORT_SHA} ${REGISTRY_URL}/${IMAGE}:${CI_COMMIT_SHORT_SHA}
    - docker push ${REGISTRY_URL}/${IMAGE}:latest
    - docker push ${REGISTRY_URL}/${IMAGE}:${CI_COMMIT_SHORT_SHA}
welcom-studyアプリのデプロイ(Terrform)
backends.tf
# GKEからArtifact Registryへアクセスする権限の付与
resource "google_project_iam_member" "allow_image_pull" {
  project = var.project_id
  role    = "roles/artifactregistry.reader"
  member  = "serviceAccount:${module.gke.service_account}"
}
welcom-studyアプリのデプロイ(実装)
ksa.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: welcome-study-sa
  namespace: welcome-study
welcome-study.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: welcome-study-deployment
  namespace: welcome-study
  labels:
    app: welcome-study
spec:
  replicas: 1
  selector:
    matchLabels:
      app: welcome-study
  template:
    metadata:
      labels:
        app: welcome-study
    spec:
      serviceAccountName: welcome-study-sa
      containers:
      - name: welcome-study
        image: asia-northeast1-docker.pkg.dev/PROJECT_ID/my-repository/welcome-study-app:latest
        ports:
        - containerPort: 8080
        resources:
          limits:
            memory: "256Mi"
            cpu: "500m"
          requests:
            memory: "256Mi"
            cpu: "250m"

※ここで出てきたwelcolme-studyアプリとは、まあ単にサンプルアプリのことです。

アクセス制御については、特定のGitlabプロジェクトIDからのみアクセス許可する設定にしてあります。

イメージのビルドにはkanikoを使いました。DinDを使いたくない方にはおすすめで、Gitlab CIとも相性は良さそうなためこちらで実装してます。

まとめ

学びとして、

  • Google Cloudの勉強になった。
  • 開発基盤を自動化することを、実際に手を動かして実感できた。

今回argocdの実装周りは詳しく紹介していなかったが、もっと良い方法や機能もありそうで、今後キャッチアップしていきたいです。

また、後々にGithub用に作り直して全体のソースコードも公開したいと思ってます💭

ご一読ありがとうございました。

以上

参考

脚注
  1. Google Cloudのサービスで、API キー、パスワード、証明書、その他の機密データを保存する ↩︎

  2. External Secrets Operatorは、 AWS Secrets Manager、HashiCorp Vault、Google Secrets Manager、Azure Key Vault、IBM Cloud Secrets Manager、Cyber​​Ark Conjurなどの外部シークレット管理システムを統合する Kubernetes オペレーターです。このオペレーターは外部 API から情報を読み取り、その値をKubernetes Secretに自動的に挿入します。 ↩︎

  3. Google Cloud の外部で実行されているアプリケーションは、サービス アカウント キーを使用して Google Cloud リソースにアクセスできます。ただし、サービス アカウント キーは強力な認証情報であり、正しく管理しなければセキュリティ上のリスクとなります。Workload Identity 連携により、サービス アカウント キーに関連するメンテナンスとセキュリティの負担が軽減されます。 ↩︎

Discussion