はじめに
自組織のGoogle Cloud環境において、安全な検証環境(サンドボックス環境)が整備されていると新規メンバーが安心して使えていいですよね!
組織としてサンドボックス環境を用意しておくことには下記のようなメリットがあります。
-
本番リソースの安全確保
検証や学習時に本番データや本番システムを誤って操作するリスクを排除できます。幸いGoogle Cloudではプロジェクトとして環境を容易に分離することが可能&一般的であるため安全に分離された環境を用意しやすいです。 -
自由な検証・学習の推進
チームメンバーが新サービスを試す・設定変更を練習する・自動化スクリプトを検証するなど、失敗を恐れずにトライできます。気軽に試せる自分だけの環境があるというのは検証をするハードルを下げてくれますよね。 -
技術力・運用力の向上
お手本となるセキュアな設定やコスト監視の仕組みを実践することで、チーム全体のクラウド運用力が向上します。メタ的な話ですが、サンドボックス環境構築用のterraformコードを読むことで「こんなふうに組織ポリシーで制約を課すことができるんだ」といったことをメンバーが知れることもあるかもしれません。
一方で、サンドボックス環境として自由に使って良いとは言いつつ、下記のようなことは避けたいです。
-
思わぬ高額課金の発生
コスト監視や制限を怠ると、短期間でも高額なクラウド料金が発生してしまう可能性があります。 -
意図しない情報漏えい
リソースの公開設定ミスなどにより、検証用データやサービスがインターネット上に露出してしまうことがあります。 -
不要なリソースやサービスの乱立
サンドボックスであっても使い終わったリソースが放置されると、管理が煩雑になり、セキュリティリスクや余計なコスト増に繋がります。
サンドボックス環境整備の方針
安全にサンドボックス環境を運用するためには、事前のルール決めと技術的なガードレールの整備が重要です。ここでは、Google Cloudでサンドボックス環境を用意する際の、運用・管理の方針について一例としてご紹介したいと思います。
基本方針
- 個人ごとに専用プロジェクトを作成: 各メンバーが自由に試せる環境を確保するため、個別のプロジェクトを割り当てる
コスト管理のルール
-
予算は目安:月額15000円/人程度
- 月額15000円(約100ドル)の予算は、一般的な学習・検証用途なら十分といえるでしょう。サーバーレスなサービスを検証のときだけ作動させるのであればかなり余裕がありますし、常時稼働するVMやストレージのコストに関しても検証用の小さなインスタンスを使用している場合は十分予算内に収まるはずです。
- 一方、大量データの分析や機械学習といった用途や、高スペックなVMやGKEクラスタの利用など、本番並の構成や負荷テストを想定したケースではすぐに100ドルを超えるでしょう。ここは柔軟に変更いただければと思います。
-
予算消化状況の通知と利用制限
-
予算に対して、使用額の50%、90%、100%、150%のタイミングでメール通知を実施、使用額の200%で自動的に課金停止措置(リソース使用不可状態)を導入する
-
これにより、課金の暴走や不注意による高額請求を事前に防ぎます。
-
高額課金・セキュリティリスク操作の制限
- 高額課金を防ぐための組織ポリシー/クォータを設定: 高額になりやすいGPUなどを大量に使用できないよう制限
- セキュリティリスクとなる設定・操作も組織ポリシーで制限: GCSバケットの公開アクセス、サービスアカウントキーの発行、外部IPの利用などを制限
- その他、利用が好ましくないサービス・機能も事前に制限: ドメイン購入機能(Cloud Domains)、確約利用契約(Commitment)なども制限
Terraformによる具体的な設定例
下記にTerraformを用いたサンドボックス環境構築の設定例を紹介します。
組織ポリシー
# サービス アカウント キーの作成の制限
resource "google_org_policy_policy" "iam_managed_disableServiceAccountKeyCreation" {
name = "folders/${var.sandbox_folder_id}/policies/iam.managed.disableServiceAccountKeyCreation"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# デフォルトのサービス アカウントに対する IAM ロールの自動付与の無効化
resource "google_org_policy_policy" "iam_automaticIamGrantsForDefaultServiceAccounts" {
name = "folders/${var.sandbox_folder_id}/policies/iam.automaticIamGrantsForDefaultServiceAccounts"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# 均一なバケットレベルのアクセスの適用
resource "google_org_policy_policy" "storage_uniformBucketLevelAccess" {
name = "folders/${var.sandbox_folder_id}/policies/storage.uniformBucketLevelAccess"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# サービス アカウント キーのアップロードを無効にする
resource "google_org_policy_policy" "iam_managed_disableServiceAccountKeyUpload" {
name = "folders/${var.sandbox_folder_id}/policies/iam.managed.disableServiceAccountKeyUpload"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# デフォルトネットワーク作成の無効化
resource "google_org_policy_policy" "skipDefaultNetworkCreation" {
name = "folders/${var.sandbox_folder_id}/policies/compute.skipDefaultNetworkCreation"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# 公開アクセスの防止を適用する
resource "google_org_policy_policy" "storage_publicAccessPrevention" {
name = "folders/${var.sandbox_folder_id}/policies/storage.publicAccessPrevention"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# 新しい Vertex AI Workbench のノートブックとインスタンスでパブリック IP アクセスを制限する
resource "google_org_policy_policy" "ainotebooks_restrictPublicIp" {
name = "folders/${var.sandbox_folder_id}/policies/ainotebooks.restrictPublicIp"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# デフォルトのサービス アカウントの権限付き基本ロールを防止する
resource "google_org_policy_policy" "iam_managed_preventPrivilegedBasicRolesForDefaultServiceAccounts" {
name = "folders/${var.sandbox_folder_id}/policies/iam.managed.preventPrivilegedBasicRolesForDefaultServiceAccounts"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
# TLS バージョンを制限する
resource "google_org_policy_policy" "gcp_restrictTLSVersion" {
name = "folders/${var.sandbox_folder_id}/policies/gcp.restrictTLSVersion"
parent = "folders/${var.sandbox_folder_id}"
spec {
rules {
values {
denied_values = [
"TLS_VERSION_1",
"TLS_VERSION_1_1"
]
}
}
}
}
サンドボックス用プロジェクト
module "sandbox_project" {
source = "terraform-google-modules/project-factory/google"
version = "~> 14.0"
name = var.project_name
random_project_id = false
billing_account = "000000-111111-222222" # 請求アカウントID
org_id = "123456789012" # 組織ID
folder_id = var.folder_id
default_service_account = "keep"
create_project_sa = false
svpc_host_project_id = "shared-vpc-host-project" # 共有VPCホスト
shared_vpc_subnets = [
"projects/shared-vpc-host-project/regions/asia-northeast1/subnetworks/sandbox-subnet"
]
activate_apis = [
"cloudresourcemanager.googleapis.com",
"billingbudgets.googleapis.com",
"cloudbilling.googleapis.com",
"serviceusage.googleapis.com",
"compute.googleapis.com",
"monitoring.googleapis.com",
"logging.googleapis.com"
# その他利用しそうなAPI
]
# リソース上限をクォータとしてプロジェクト毎に設定
consumer_quotas = [
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/instances")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "3" # インスタンス数の上限
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/cpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "8" # vCPU数の上限
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/commitments")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "0" # 確約利用契約を制限
},
# 以下、各種GPU(A100,K80,L4,P100,P4,T4,V100等)の利用上限を2に制限
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_a100_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_k80_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_l4_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_l4_vws_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_p100_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_p100_vws_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_p4_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_p4_vws_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_t4_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_t4_vws_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
},
{
service = "compute.googleapis.com"
metric = urlencode("compute.googleapis.com/nvidia_v100_gpus")
limit = urlencode("/project/region")
dimensions = { region = "asia-northeast1" }
force = true
value = "2"
}
]
lien = false
}
# 各プロジェクトにオーナーロールのメンバーを割り当て
resource "google_project_iam_member" "iam_owners" {
for_each = toset(var.project_owners)
project = module.sandbox_project.project_id
role = "roles/owner"
member = "user:${each.value}"
}
# モニタリング通知チャネル(メール通知用)を作成
resource "google_monitoring_notification_channel" "email_channel" {
for_each = toset(var.notification_emails)
project = module.sandbox_project.project_id
display_name = each.value
type = "email"
labels = {
email_address = each.value
}
force_delete = false
}
# 予算の設定
module "budgets" {
source = "terraform-google-modules/project-factory/google/modules/budget"
version = "~> 14.0"
billing_account = "000000-111111-222222" # 請求アカウントID
projects = [module.sandbox_project.project_id]
amount = var.budget # 予算上限
display_name = "Budget For ${module.sandbox_project.project_id}"
credit_types_treatment = "INCLUDE_ALL_CREDITS"
alert_spent_percents = [0.5, 0.9, 1.0, 1.5, 2.0] # 50, 90, 100, 150, 200%消化で通知
alert_pubsub_topic = "projects/billing-manage/topics/billing-alert" # Pub/Subトピック
monitoring_notification_channels = values(google_monitoring_notification_channel.email_channel)[*].id
}
上記例では共有VPCを使用していますが、共有VPCを使用しない場合はよしなに(geminiなどに聞いて)修正ください。
上記は請求先アカウントの支払い通貨を円で設定している場合の設定になっていますが、ドルの場合はおそらくドルでの金額を記載する必要があります。
クォータを設定するためには該当するAPIを有効化する必要があります。
後述する課金停止処理のためにPubSubトピックに通知が飛ぶように設定していますが、課金停止まではしなくてよい場合は省略可能です。
参考:
- https://registry.terraform.io/modules/terraform-google-modules/project-factory/google/latest
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/monitoring_notification_channel
- https://registry.terraform.io/modules/terraform-google-modules/project-factory/google/latest/submodules/budget
variable "project_name" {
type = string
}
variable "region" {
type = string
default = "asia-northeast1"
}
variable "project_owners" {
description = "The email addresses of the project owners"
type = list(string)
default = []
}
variable "notification_emails" {
description = "The email addresses for budget notifications"
type = list(string)
default = ["platform_team@example.com"]
}
variable "folder_id" {
description = "The folder ID of the sandbox projects"
type = string
default = "012345678901" # sandboxフォルダのID
}
variable "budget" {
description = "Budget limit for the project in Japanese Yen"
type = number
default = 15000 # 1.5万円をデフォルト
}
locals {
# サンドボックス環境作成時は下記に追加する
sandbox_projects = [
{ project_name="sandbox-user1", project_owners=["user1@example.com"] },
{ project_name="sandbox-user2", project_owners=["user2@example.com"] },
]
}
module "sandbox" {
for_each = { for p in local.sandbox_projects : p.project_name => p }
source = "module/sandbox"
project_name = each.value.project_name
project_owners = each.value.project_owners
notification_emails = concat(["platform_team@example.com"], each.value.project_owners)
region = "asia-northeast1"
folder_id = "123456789012" # sandboxフォルダのID
budget = 15000
}
課金停止処理
Cloud Billingでは予算を設定しアラートを発砲することは可能ですが、予算に達したら課金を停止するといった機能は実装されていません。
そのため、
Cloud Billing(予算消化) → PubSub通知 → Cloud Run Functions発火(課金停止処理)
というフローが必要になります。
import base64
import json
import functions_framework
from cloudevents.http import CloudEvent
from googleapiclient import discovery
@functions_framework.cloud_event
def stop_billing(cloud_event: CloudEvent) -> None:
print(f"cloud_event: {cloud_event}")
pubsub_data = base64.b64decode(cloud_event.data["message"]["data"]).decode("utf-8")
pubsub_json = json.loads(pubsub_data)
print(f"pubsub_json: {pubsub_json}")
cost_amount = pubsub_json["costAmount"]
budget_amount = pubsub_json["budgetAmount"]
limit = budget_amount * 2 # 予算の200%を上限とする
project_id = pubsub_json["budgetDisplayName"].split(' ')[2]
project_name = f"projects/{project_id}"
if cost_amount <= limit:
print(f"No action necessary. (Current cost: {cost_amount})")
return
if project_id is None:
print("No project specified with budget display name")
return
billing = discovery.build(
"cloudbilling",
"v1",
cache_discovery=False,
)
projects = billing.projects()
billing_enabled = __is_billing_enabled(project_name, projects)
if billing_enabled:
__disable_billing_for_project(project_name, projects)
else:
print("Billing already disabled")
def __is_billing_enabled(project_name, projects):
"""
Determine whether billing is enabled for a project
@param {string} project_name Name of project to check if billing is enabled
@return {bool} Whether project has billing enabled or not
"""
try:
res = projects.getBillingInfo(name=project_name).execute()
print(f"res: {res}")
return res["billingEnabled"]
except KeyError:
# If billingEnabled isn't part of the return, billing is not enabled
return False
except Exception as e:
print("Unable to determine if billing is enabled on specified project, assuming billing is enabled")
print(e)
return True
def __disable_billing_for_project(project_name, projects):
"""
Disable billing for a project by removing its billing account
@param {string} project_name Name of project disable billing on
"""
body = {"billingAccountName": ""} # Disable billing
try:
res = projects.updateBillingInfo(name=project_name, body=body).execute()
print(f"Billing disabled: {json.dumps(res)}")
except Exception as e:
print("Failed to disable billing, possibly check permissions")
print(e)
resource "google_service_account" "gcf_sa" {
account_id = "gcf-sa"
display_name = "Function Service Account"
disabled = false
project = var.billing_manage_project
}
resource "google_pubsub_topic" "billing_alert_topic" {
provider = google-beta
project = var.billing_manage_project
name = "billing-alert"
}
resource "google_cloudfunctions2_function" "stop_billing" {
name = "disable-budget-function"
project = var.billing_manage_project
location = var.region
description = "disable billing for project"
build_config {
runtime = "python312"
entry_point = "stop_billing"
source {
storage_source {
bucket = "${var.billing_manage_project}-gcf-sources"
object = "stop-billing-function-source.zip"
}
}
service_account = google_service_account.default.id
}
service_config {
max_instance_count = 1
available_cpu = 1
available_memory = "256M"
timeout_seconds = 240
ingress_settings = "ALLOW_INTERNAL_ONLY"
service_account_email = google_service_account.gcf_sa.email
environment_variables = {
LOG_EXECUTION_ID = "true"
}
}
event_trigger {
trigger_region = var.region
event_type = "google.cloud.pubsub.topic.v1.messagePublished"
pubsub_topic = google_pubsub_topic.billing_alert_topic.id
service_account_email = google_service_account.gcf_sa.email
retry_policy = "RETRY_POLICY_DO_NOT_RETRY"
}
}
参考:
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_service_account
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/pubsub_topic
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket_object
- https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloudfunctions2_function
まとめ
ユーザーにとっての自由度を確保しつつ、適切にガードレールを設定することで安全に安心して利用できるサンドボックス環境を整備するというのは、意外といろいろな要素を考慮する必要がありますね。
今回は組織ポリシーやクォータといった予防的統制をメインで紹介しましたが、各リソースがどのように使用されているかのモニタリングや、不自然な操作がないかの監視などの発見的統制についてももう少し策を講じる余地がありそうですね。
また予算に達した後の課金停止などのアクションについては、なんらか公式の機能として追加されると嬉しいですね。今後の発展に期待しましょう!