【CloudNative Entry】入社課題で学んだことTips
はじめに
10月から 3-shake に入社した melanmeg です。
入社時課題が始まって、 やったこと・わかったこと をここに整理してみました!
前職ではAWS・Azureを触っていたため、今回Google Cloudで課題を進めることにしました。
今まで触ってこなかったのでクラウドごとの特徴を知れる良い学びになりました。
早速、整理したことを紹介していきます。
課題
一言でいうと、『クラウドネイティブのエントリーレベルのスキルを身に着けるを目標の元、k8sクラスタ構築からwordpressを載せて、CI/CDまで作る』になります。
ざっと主にやったこと一覧
- Terraform
- GKE
2.1. 夜間停止てきな機能 - External Secrets Operator
- Cloud SQL
- ArgoCD
- 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の設定
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のテーブル作成が必要です。
理由については、以下記事でまとめられている方がいましたので参考に。
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は、最も安全なオプションで、すべてのインターネットアクセスが阻止される
- パターン2は、承認済みネットワークがコントロールプレーンの外部エンドポイントに適用される
- パターン3は、最も制限の少ないオプション
今回の課題要件としては、指定の外部IPからのみアクセス可能にしたかったのでパターン2を採用しました。
GKE Autopilot 作成
モジュール選定
クラスタ作成には、terraform-google-kubernetes-engineモジュールを利用し、中でも限定公開クラスタ作成用のbeta-autopilot-private-clusterを選びました。
betaと名が付いていますが、READMEを読んだところではモジュールがベータ版というより 様々なGKEベータ機能を使用できるという意味合いのようです。
クラスタ作成
- 実装例
GKE Autopilot (Terraform)
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
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"
}
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_networks
のcidr_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)
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クラスタを定期削除 (実装)
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"
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)
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 (実装)
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
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-secrets-sa
namespace: external-secrets
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がシンプルで適していると判断しました。
Cloud SQL 作成
最低限の事前作業として、パスワード情報をterraform.tfvarsへ手動で作成します。
- 事前準備
$ cat <<EOF > terraform.tfvars
db_user = "db-user"
db_password = "db-password"
EOF
- 実装例
Cloud SQL (Terraform)
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 で役立つ
}
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で発行したclientID
、clientSecret
をvalues.yamlに書くだけである。
argocdのhelmChartはこちら。
- argocdのvalues.yaml の SSO連携設定
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からプライベートリポジトリへアクセスするには、以下のようにシークレットを作成します。
# 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
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 {
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"
}
]
}
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 (実装)
# 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にリダイレクトするのを許可するため
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の実装を進める上で、特に役立った資料がこちらになります。
ここでのポイントは、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 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 (実装)
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)
# 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アプリのデプロイ(実装)
apiVersion: v1
kind: ServiceAccount
metadata:
name: welcome-study-sa
namespace: welcome-study
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用に作り直して全体のソースコードも公開したいと思ってます💭
ご一読ありがとうございました。
以上
参考
- https://cloud.google.com/kubernetes-engine/docs/concepts/private-cluster-concept?hl=ja
- https://github.com/terraform-google-modules
- https://cloud.google.com/secret-manager?hl=ja
- https://external-secrets.io/latest/
- https://cloud.google.com/iam/docs/workload-identity-federation?hl=ja
- https://cloud.google.com/iam/docs/manage-workload-identity-pools-providers?hl=ja#iam-workload-pools-undelete-gcloud
-
Google Cloudのサービスで、API キー、パスワード、証明書、その他の機密データを保存する ↩︎
-
External Secrets Operatorは、 AWS Secrets Manager、HashiCorp Vault、Google Secrets Manager、Azure Key Vault、IBM Cloud Secrets Manager、CyberArk Conjurなどの外部シークレット管理システムを統合する Kubernetes オペレーターです。このオペレーターは外部 API から情報を読み取り、その値をKubernetes Secretに自動的に挿入します。 ↩︎
-
Google Cloud の外部で実行されているアプリケーションは、サービス アカウント キーを使用して Google Cloud リソースにアクセスできます。ただし、サービス アカウント キーは強力な認証情報であり、正しく管理しなければセキュリティ上のリスクとなります。Workload Identity 連携により、サービス アカウント キーに関連するメンテナンスとセキュリティの負担が軽減されます。 ↩︎
Discussion