Terraform、k8s、Helm のハンズオンまとめ


## はじめに
社内研修でTerraform、k8s、Helmを使用したGoogle Cloud環境の構築を行ったので、その学習内容のまとめです。

## ハンズオンで構築したもの
- Google CloudでGKE Autopilotクラスタを構築
- Wordpress(バックエンドにCloud SQL)、ArgoCDをGKE上にデプロイ
- 秘匿情報はExternal Secret Operatorを使用する
- Cloud SQLの起動停止ジョブ


## Terraformを使用してGKEクラスタを作成する

Google Cloud上のリソースを一つずつ把握しながら作成していきたかったので、あえてモジュールを使用せずにリソースで実装しました。

<details><summary>実装例</summary>

```terraform:variables.tf
variable "project_id" {
  description = "The project ID to host the cluster in"
  default     = "test-xxxxx-xxxxx"
}

variable "gke_name" {
  default     = "test"
}

variable "region" {
  description = "The region the cluster in"
  default     = "asia-northeast1"
}
```

```terraform:provider.tf
provider "google" {
  project = var.project_id  
}
```

```terraform:cluster.tf
resource "google_compute_network" "default" {
  name = "${var.gke_name}-network"
  project = var.project_id

  auto_create_subnetworks  = false
  enable_ula_internal_ipv6 = true
}

resource "google_compute_subnetwork" "default" {
  name = "${var.gke_name}-subnetwork"
  project = var.project_id

  ip_cidr_range = "10.0.0.0/16"
  region        = var.region

  stack_type       = "IPV4_IPV6"
  ipv6_access_type = "EXTERNAL" # Change to "EXTERNAL" if creating an external loadbalancer

  network = google_compute_network.default.id
  secondary_ip_range {
    range_name    = "services-range"
    ip_cidr_range = "192.168.0.0/24"
  }

  secondary_ip_range {
    range_name    = "pod-ranges"
    ip_cidr_range = "192.168.1.0/24"
  }
}

resource "google_container_cluster" "default" {
  name = "${var.gke_name}-autopilot-cluster"
  project = var.project_id

  location                 = var.region
  enable_autopilot         = true
  enable_l4_ilb_subsetting = true

  network    = google_compute_network.default.id
  subnetwork = google_compute_subnetwork.default.id

  resource_labels = {
    team = "research"
  }

  ip_allocation_policy {
    stack_type                    = "IPV4_IPV6"
    services_secondary_range_name = google_compute_subnetwork.default.secondary_ip_range[0].range_name
    cluster_secondary_range_name  = google_compute_subnetwork.default.secondary_ip_range[1].range_name
  }  

  # Set `deletion_protection` to `true` will ensure that one cannot
  # accidentally delete this instance by use of Terraform.
  deletion_protection = false
}
```
</details>

### 内部VPNのみ接続可能な設定とする
公開クラスタでクラスタを作成し、Cloud Armorで全部のアクセスを拒否するデフォルトルールと内部VPNのみ許可するセキュリティポリシーを追加し、IngressとBackendConfigによる制御を実装しました。

<details><summary>実装例</summary>

```yaml:backendconfig.yaml
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
  name: hello-backend-config
  namespace: default
spec:
  securityPolicy:
    name: hello-sec-policy  # 作成したセキュリティポリシーの名前
```

```yaml:ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress
  namespace: default
  annotations:
    kubernetes.io/ingress.class: "gce"  # 外部 HTTP(S) ロードバランサを指定
spec:
  defaultBackend:
    service:
      name: hello-app
      port:
        number: 8080
```

```yaml:hello-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-app
  namespace: default
spec:
  selector:
    matchLabels:
      run: hello-app
  template:
    metadata:
      labels:
        run: hello-app
    spec:
      containers:
      - image: us-docker.pkg.dev/google-samples/containers/gke/hello-app:1.0
        imagePullPolicy: IfNotPresent
        name: hello-app
        ports:
        - containerPort: 8080
          protocol: TCP
```

```security.tf
resource "google_compute_security_policy" "policy" {
  name = "hello-sec-policy"
  project = var.project_id

  rule {
    action   = "deny(403)"
    priority = "2147483647"
    match {
      versioned_expr = "SRC_IPS_V1"
      config {
        src_ip_ranges = ["*"]
      }
    }
    description = "default rule"
  }

  rule {
    action   = "allow"
    priority = "1000"
    match {
      versioned_expr = "SRC_IPS_V1"
      config {
        src_ip_ranges = ["XXX.XX.XX.XX/32"]
      }
    }
  }
}
```
</details>

### tfstateファイルの実装

今回はGoogle Cloud Storageを使用して、tfstateファイルを管理するためバージョン管理と暗号化、ロック機能はCloud Storageの設定でできました。
AWSの場合は、DynamoDBを使用して排他ロックをかける必要があるそうです。

<details><summary>tfstateファイルの実装</summary>

```terminal:main.tf
resource "google_storage_bucket" "default" {
  name     = "terraform-remote-backend"
  location = "ASIA-NORTHEAST1"
  project = var.project_id

  force_destroy               = false
  public_access_prevention    = "enforced"
  uniform_bucket_level_access = true

  versioning {
    enabled = true
  }
}
```

```terminal:backend.tf
terraform {
  backend "gcs" {
    bucket = "terraform-remote-backend"
  }
}
```
</details>


### Google Cloudのクラスタについて
GKEにはAutopilot モードと Standard モードが選択でき、今回はフルマネージドサービスであるAutopilotを採用しました。セキュリティはデフォルトで有効となっていて、自動スケーリングの様子も確認することができました。また、Workload Identityもデフォルトで有効化されています。

Autopilotでは、デプロイしたリソースを削除すると自動的にノードがゼロまで削減されます。また、request/limitを指定しないとデフォルトのCPU、memory、エフェメラルストレージが設定されます。
デフォルトはCPUが0.5vCPU、memoryが2GiB、エフェメラルストレージが1GiBとなっていました。今回作成した、Wordpress、External Secrets Operator、ArgoCD、Ingress、BackEndConfigをリリースしただけで
4〜5ノードが起動してコストがかかるため、それぞれに最低限のリソースを使用するように設定しておいた方がよさそうでした。
最小設定は、CPUが50m メモリは52MiBとなっています。

## Helm Chartを使用してGKE上にWordpressを構築する

### Helm Chartについて
下記のHelm Chartを使用して、values.yamlで設定可能な値を変更しました。
https://artifacthub.io/packages/helm/bitnami/wordpress

変更点は下記です。
- エフェメラルストレージが不足しているというエラーが出たので、エフェメラルストレージの指定を変更しました
- WordpressにIP制限をかけるため、アノテーションを追加しました
- 機密情報（DBのパスワード、Wordpressのパスワード）はExternal Secret Operatorを使用したので、ExternalSecretで指定したk8s Secret名に変更しました
- DBはMariaDBからCloud SQL（MySQL）に変更しました

<details><summary>values.yaml</summary>

```yaml:wordpress/values.yaml
existingSecret: "wordpress-credentials"
resources:
  requests:
    ephemeral-storage: 300Mi
    cpu: 100m
    memory: 128Mi
  limits:
    ephemeral-storage: 300Mi
    cpu: 200m
    memory: 256Mi
service:
  type: NodePort
  annotations: {
    cloud.google.com/backend-config: '{"ports": {"80":"hello-backend-config"}}' 
    ,cloud.google.com/neg: '{"ingress": true}'
  }
mariadb:
  enabled: false
externalDatabase:
  host: "xxxxxxxxxxxx.xxxxxxxxxxxx.asia-northeast1.sql.goog."
  user: "wordpress-admin-user"
  database: "wordpress-db"
  existingSecret: "wordpress-credentials"
```
</details>



### WordpressのDBをMariaDBからCloud SQL（MySQL）に変更する
Terrafromを使用してCloud SQLを作成しました。DBユーザーの作成は機密情報のため、コンソールから行いました。
インスタンス接続はPrivate Service Connectを使用しました。

<details><summary>Cloud SQL</summary>

```terraform:db.tf
# Enable APIs
locals {
  services = toset([
    "sqladmin.googleapis.com",
    "servicenetworking.googleapis.com",
    "networkmanagement.googleapis.com"
  ])
}

resource "google_project_service" "db" {
  for_each = local.services
  service  = each.value
  disable_on_destroy = false
}

# Cloud SQLインスタンスの作成
resource "google_sql_database_instance" "default" {
  name             = "mysql-instance"
  region           = var.region
  database_version = "MYSQL_8_0"
  settings {
    tier              = "db-f1-micro"
    availability_type = "ZONAL" # 高可用性構成 (REGIONAL) or single zone (ZONAL)
    disk_autoresize = false     # ストレージの自動増量

    backup_configuration {
      enabled            = false # 自動バックアップ
      binary_log_enabled = false
    }
    ip_configuration {
      psc_config {
        psc_enabled               = true
        allowed_consumer_projects = [var.project_id]
      }
      ipv4_enabled = false
    }
  }
  deletion_protection = false # Set to "true" to prevent destruction of the resource
}

# プライベート IP アドレスを予約
resource "google_compute_address" "default" {
  name         = "psc-compute-address-${google_sql_database_instance.default.name}"
  region       = var.region
  address_type = "INTERNAL"
  subnetwork   = google_compute_subnetwork.default.id
  address      = "10.0.0.10"
}

data "google_sql_database_instance" "default" {
  name = resource.google_sql_database_instance.default.name
}

# Private Service Connect エンドポイントを作成する
resource "google_compute_forwarding_rule" "default" {
  name                  = "psc-forwarding-rule-${google_sql_database_instance.default.name}"
  region                = var.region
  network               = google_compute_network.default.id #VPC名
  ip_address            = google_compute_address.default.self_link 
  load_balancing_scheme = ""
  target                = data.google_sql_database_instance.default.psc_service_attachment_link
}

# databaseの作成
resource "google_sql_database" "database" {
  name     = "wordpress-db"
  instance = google_sql_database_instance.default.name
}

# DBユーザーの作成の作成はコンソールから行う

# Cloud SQL Auth Proxyを使用して接続する
# 限定公開 DNS ゾーンを作成する
resource "google_dns_managed_zone" "default" {
  name     = "cloud-sql-dns"
  description = "Cloud SQL インスタンスの DNS ゾーン"
  dns_name = data.google_sql_database_instance.default.dns_name
  visibility = "private"
  cloud_logging_config {
    enable_logging = false
  }
  private_visibility_config {
    networks {
      network_url = google_compute_network.default.id
    }
  }
}

# ゾーンに DNS レコードを作成する
resource "google_dns_record_set" "a" {
  name         = google_dns_managed_zone.default.dns_name
  managed_zone = google_dns_managed_zone.default.name
  type         = "A"
  ttl          = 0

  rrdatas = [google_compute_address.default.address]
}
```

</details>



### External Secrets Operatorの導入

秘匿情報の取り扱いのためGoogle Cloud Secret ManagerとExternal Secrets Operatorを使用しました。Google Cloud Secret Managerで秘匿情報を管理し、それをExternal Secrets Operatorを使用してGKE上のKubernetes Secretsとして同期しました。

HelmでExternal Secrets Operatorをインストールした後、ClusterSecretStoreとExternalSecretをapplyする必要があります。

```sh
helm repo add external-secrets https://charts.external-secrets.io

helm install external-secrets \
   external-secrets/external-secrets \
    -n external-secrets \
    --create-namespace \
```

<details><summary>External Secrets Operatorの実装</summary>

```yaml:cluster-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: gcp-store
  namespace: external-secrets
spec:
  provider:
    gcpsm:
      projectID: test-xxxxx-xxxxx
      auth:
        workloadIdentity:
          # name of the cluster Location, region or zone
          clusterLocation: asia-northeast1
          # name of the GKE cluster
          clusterName: test-autopilot-cluster
          # projectID of the cluster (if omitted defaults to spec.provider.gcpsm.projectID)
          clusterProjectID: test-xxxxx-xxxxx
          # reference the sa from above
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets
```


```yaml:external-secret.yaml

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: wordpress-credentials
spec:
  refreshInterval: 24h             # rate SecretManager pulls GCPSM
  secretStoreRef:
    kind: ClusterSecretStore
    name: gcp-store               # name of the SecretStore (or kind specified)
  target:
    name: wordpress-credentials    # name of the k8s Secret to be created
    creationPolicy: Owner
  data:
  - secretKey: wordpress-password
    remoteRef:
      key: wordpress-pass      # name of the GCPSM secret key
  - secretKey: mariadb-password
    remoteRef:
      key: db-pass      # name of the GCPSM secret key
```

</details>



## 今後の展望
CloudSQLの夜間インスタンス停止の実装とArgoCDの実装について続きを書ければと思います。