Secret Manager で Cloud SQL のパスワードを安全に管理する
こんにちは。すらぼです。好きな言葉は「ベストプラクティス」です。
皆さんは Cloud SQL のパスワード、きちんと管理できていますか?
Cloud SQL のパスワードは、 Secret Manager を利用するのがベストプラクティスの1つとされています。
しかしいざ試してみたところ、関数やトリガーなどを1つずつ作成していく必要があったり、躓くポイントもあったので、手順としてまとめてみました。
Secret Manager とは?
Secret Manager とは、Google Cloud 上でパスワードなどのセキュアな情報を安全に管理するためのサービスです。
データベースの認証情報やAPIキーなどは、アプリケーションの .env
ファイルなどにハードコードされることがあったりします。しかし、長期的に同じ認証情報を使用することは、持ち出しや流出時の影響が大きくなるなど様々なリスクが伴います。
Secret Manager でそれらのセキュアな認証情報を管理することで、シークレットへのアクセスコントロールや、操作・参照の履歴をトラッキングしたり、定期的なローテーションにによって「ハードコードができない仕組み」を作ることができるなど、セキュリティ面で様々なメリットがあります。
セキュリティ対策の基本となる Secret Manager ですが、今回の記事では特に「DBアクセス情報のローテーション」について実装方法を紹介します。
前提条件
Cloud Run functions から Cloud SQL にアクセスできる状態を前提とします。
今回の検証記事では、サーバーレス VPC アクセスコネクタを利用して検証を行なっています。
Direct VPC Egress を利用している場合は、Cloud Run functions のコネクタ部分を適宜修正して実行してください。
全体の流れ
今回は大きく2段階の構成で進めます。
- Secret Manager にパスワードを登録し、Cloud Run functions から取得して Cloud SQL にアクセスする
- パスワードローテーション用の Cloud Run functions を実装し、定期的にパスワードをローテーションする
今回作成するもの
今回は、以下のリソースを作っていきます。
- Secret Manager のシークレット
- パスワードローテーション用の Cloud Run functions
- Pub/Sub トピック・サブスクリプション
- Eventarc トリガー
構成としては、以下のようなイメージです。
また、検証のために Cloud SQL へのアクセスを行う Cloud Run functions も作成しています。(上記画像の左下)
やってみる
まず、Secret Manager のパスワードを使って Cloud SQL にアクセスできる仕組みを整えます。
1. Cloud SQL のパスワードを Secret Manager に登録
Cloud SQL インスタンスのパスワードを Secret Manager に登録します。
# シークレットの作成
gcloud secrets create cloudsql-password \
--replication-policy="automatic"
# パスワードをシークレットに追加
echo -n "Password" | gcloud secrets versions add cloudsql-password --data-file=-
※ Password
は実際のデータベースパスワードに置き換えてください。
もし現在のパスワードが不明な場合は、マネージドコンソール上でパスワードを上書きできるので、その値と一致させてください。
2. Cloud Run functions 用のサービスアカウントを作成
Cloud Run functions 用の専用サービスアカウントを作成し、Secret Manager へのアクセス権限を付与します。
# サービスアカウントの作成
gcloud iam service-accounts create connect-db-sa \
--display-name="CloudSQL Connector Service Account"
# Secret Manager への読み取り権限を付与
gcloud secrets add-iam-policy-binding cloudsql-password \
--member="serviceAccount:connect-db-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
# Cloud SQL クライアント権限を付与
gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \
--member="serviceAccount:connect-db-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/cloudsql.client"
3. Cloud Run functions から Secret Manager にアクセスする実装
Cloud Run functions で Secret Manager からパスワードを取得し、Cloud SQL に接続する関数を実装します。
index.js
const functions = require('@google-cloud/functions-framework');
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
const { Pool } = require('pg');
async function accessSecretVersion(projectId, secretId, versionId = 'latest') {
const client = new SecretManagerServiceClient();
const name = `projects/${projectId}/secrets/${secretId}/versions/${versionId}`;
const [version] = await client.accessSecretVersion({ name });
return version.payload.data.toString('utf8');
}
functions.http('connectDb', async (req, res) => {
let client;
try {
// Secret Managerからパスワードを取得
const password = await accessSecretVersion(
process.env.GOOGLE_CLOUD_PROJECT,
'cloudsql-password'
);
// Cloud SQL接続設定
const pool = new Pool({
host: process.env.DB_HOST, // Cloud SQLのプライベートIP
user: process.env.DB_USER,
password: password,
port: 5432,
database: process.env.DB_NAME
});
// データベースに接続
client = await pool.connect();
// クエリの実行例
const result = await client.query('SELECT NOW()');
res.status(200).json({
success: true,
message: 'Cloud SQLへの接続に成功しました',
data: result.rows[0]
});
} catch (error) {
console.error('データベース接続エラー:', error);
res.status(500).json({
success: false,
message: 'Cloud SQLへの接続に失敗しました',
error: error.message
});
} finally {
if (client) {
client.release();
}
}
});
package.json も用意します。
package.json
{
"name": "cloudsql-secret-manager",
"version": "1.0.0",
"dependencies": {
"@google-cloud/functions-framework": "^3.0.0",
"@google-cloud/secret-manager": "^5.0.0",
"pg": "^8.11.0"
}
}
4. Cloud Run functions のデプロイ
実装した関数を、作成したサービスアカウントを指定してデプロイします。
gcloud functions deploy connect-db \
--gen2 \
--runtime=nodejs20 \
--entry-point=connectDb \
--trigger-http \
--no-allow-unauthenticated \
--region=asia-northeast1 \
--vpc-connector=<your-vpc-connector> \
--service-account=connect-db-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--set-env-vars=GOOGLE_CLOUD_PROJECT=$GOOGLE_CLOUD_PROJECT,DB_HOST=<your-db-host>,DB_USER=<your-db-user>,DB_NAME=<your-db-name>
※ <your-vpc-connector>
は実際の VPC コネクタ名に置き換えてください。
※ <your-db-host>
, <your-db-user>
, <your-db-name>
は実際の Cloud SQL の設定値に置き換えてください。
5. 動作確認
デプロイした関数を呼び出して、Cloud SQL に接続できることを確認します。
gcloud functions call connect-db --region=asia-northeast1
正しく接続できれば、現在のタイムスタンプが返ってきます。
これで、Secret Manager を使ってDBにアクセスするところまで確認できました。
パスワードローテーションの実装
では、今回の肝となるパスワードローテーションの部分を作成していきます。
6. パスワードローテーション用の Cloud Run functions を作成
パスワードをローテーションする関数を実装します。
先ほどの接続テスト用の関数 connect-db
とは別のディレクトリに、以下の2つのファイルを作成します。
index.js
const functions = require('@google-cloud/functions-framework');
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
const { Pool } = require('pg');
/**
* ランダムなパスワードを生成(PostgreSQLのポリシーに準拠)
*/
function generatePassword(length = 20) {
const crypto = require('crypto');
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const numbers = '0123456789';
const special = '!@#$%^&*()';
// 各カテゴリから最低1文字ずつ確保
let password = '';
password += lowercase[crypto.randomInt(0, lowercase.length)];
password += uppercase[crypto.randomInt(0, uppercase.length)];
password += numbers[crypto.randomInt(0, numbers.length)];
password += special[crypto.randomInt(0, special.length)];
// 残りの文字をランダムに生成
const allChars = lowercase + uppercase + numbers + special;
for (let i = password.length; i < length; i++) {
password += allChars[crypto.randomInt(0, allChars.length)];
}
// パスワードをシャッフル
return password.split('').sort(() => crypto.randomInt(0, 2) - 1).join('');
}
/**
* Cloud SQL のパスワードを更新
*/
async function updateCloudSQLPassword(host, user, currentPassword, newPassword) {
const pool = new Pool({
host: host,
user: user,
password: currentPassword,
port: 5432,
});
const client = await pool.connect();
try {
await client.query(`ALTER USER ${user} WITH PASSWORD '${newPassword}'`);
} finally {
client.release();
await pool.end();
}
}
/**
* Secret Manager のシークレットを更新
*/
async function updateSecret(projectId, secretId, newPassword) {
const client = new SecretManagerServiceClient();
const parent = `projects/${projectId}/secrets/${secretId}`;
const payload = Buffer.from(newPassword, 'utf8');
const response = await client.addSecretVersion({
parent: parent,
payload: {
data: payload
}
});
return response;
}
/**
* パスワードをローテーション
*/
functions.http('rotatePassword', async (req, res) => {
try {
// Pub/Subメッセージからイベントタイプを取得
const pubsubMessage = req.body.message;
const attributes = pubsubMessage?.attributes || {};
// SECRET_ROTATE イベントのみ処理(SECRET_VERSION_ADD は無視)
if (attributes.eventType !== 'SECRET_ROTATE') {
console.log(`Ignoring event type: ${attributes.eventType}`);
res.status(200).json({
success: true,
message: `Event type ${attributes.eventType} ignored`
});
return;
}
console.log('Processing SECRET_ROTATE event');
const projectId = process.env.GOOGLE_CLOUD_PROJECT;
const dbHost = process.env.DB_HOST;
const dbUser = process.env.DB_USER;
// 現在のパスワードを取得
const secretClient = new SecretManagerServiceClient();
const name = `projects/${projectId}/secrets/cloudsql-password/versions/latest`;
const [version] = await secretClient.accessSecretVersion({ name });
const currentPassword = version.payload.data.toString('utf8');
// 新しいパスワードを生成
const newPassword = generatePassword();
// Cloud SQL のパスワードを更新
await updateCloudSQLPassword(dbHost, dbUser, currentPassword, newPassword);
console.log('Cloud SQL password updated successfully');
// Secret Manager のシークレットを更新
await updateSecret(projectId, 'cloudsql-password', newPassword);
console.log('Password rotation completed successfully');
res.status(200).json({
success: true,
message: 'Password rotation completed successfully'
});
} catch (error) {
console.error('Password rotation error:', error);
res.status(500).json({
success: false,
message: 'Password rotation failed',
error: error.message
});
}
});
package.json も用意します。
package.json
{
"name": "cloudsql-password-rotation",
"version": "1.0.0",
"dependencies": {
"@google-cloud/functions-framework": "^3.0.0",
"@google-cloud/secret-manager": "^5.0.0",
"pg": "^8.11.0"
}
}
7. パスワードローテーション用のサービスアカウントを作成
パスワードローテーション関数用の専用サービスアカウントを作成し、必要な権限を付与します。
# サービスアカウントの作成
gcloud iam service-accounts create password-rotation-sa \
--display-name="Password Rotation Service Account"
# Secret Manager への読み取り・書き込み権限を付与
gcloud secrets add-iam-policy-binding cloudsql-password \
--member="serviceAccount:password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding cloudsql-password \
--member="serviceAccount:password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretVersionAdder"
# Cloud SQL クライアント権限を付与
gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \
--member="serviceAccount:password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/cloudsql.client"
# サービスアカウント自身に関数呼び出し権限を付与(後でデプロイする関数用)
gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \
--member="serviceAccount:password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/cloudfunctions.invoker"
8. パスワードローテーション関数をデプロイ
作成したサービスアカウントを指定して関数をデプロイします。
gcloud functions deploy rotate-password \
--gen2 \
--runtime=nodejs20 \
--entry-point=rotatePassword \
--trigger-http \
--no-allow-unauthenticated \
--region=asia-northeast1 \
--vpc-connector=my-connector \
--service-account=password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--set-env-vars=GOOGLE_CLOUD_PROJECT=$GOOGLE_CLOUD_PROJECT,DB_HOST=<your-db-host>,DB_USER=<your-db-user>,DB_NAME=<your-db-name>
※ <your-db-host>
, <your-db-user>
, <your-db-name>
は実際の Cloud SQL の設定値に置き換えてください。
9. Pub/Sub トピックを作成
Secret Manager の期限切れ通知を受け取るための Pub/Sub トピックを作成します。
また、同時にそれぞれのサービスエージェントにも必要な権限を付与していきます。
# Pub/Sub トピックを作成
gcloud pubsub topics create secret-expiration-topic
# Secret Manager のサービスエージェントを有効化(まだ存在しない場合)
gcloud beta services identity create --service=secretmanager.googleapis.com
# Pub/Sub のサービスエージェントを有効化(まだ存在しない場合)
gcloud beta services identity create --service=pubsub.googleapis.com
# プロジェクト番号を取得
PROJECT_NUMBER=$(gcloud projects describe $GOOGLE_CLOUD_PROJECT --format='value(projectNumber)')
# Secret Manager サービスアカウントに Pub/Sub への publish 権限を付与
gcloud pubsub topics add-iam-policy-binding secret-expiration-topic \
--member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-secretmanager.iam.gserviceaccount.com" \
--role="roles/pubsub.publisher"
# Pub/Sub サービスアカウントに Token Creator 権限を付与(IDトークン作成に必要)
gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \
--member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountTokenCreator"
10. Secret Manager にローテーション期間とトピックを設定
Secret Manager のシークレットに定期的なローテーション期間と通知トピックを設定します。
# シークレットにローテーション設定を追加
gcloud secrets update cloudsql-password \
--add-topics=projects/$GOOGLE_CLOUD_PROJECT/topics/secret-expiration-topic \
--rotation-period=2592000s \
--next-rotation-time=$(date -u -d "+6 minutes" +"%Y-%m-%dT%H:%M:%SZ")
※ --rotation-period=2592000s
は30日ごとにローテーション通知を送信します(2592000秒 = 30日間)。
※ --next-rotation-time
は次回のローテーション日時を指定します(この例では6分後、動作確認用)。
11. Eventarc トリガーを作成
Pub/Sub トピックからのメッセージを受け取り、パスワードローテーション関数を呼び出す Eventarc トリガーを作成します。
# Eventarc トリガーを作成
gcloud eventarc triggers create secret-rotation-trigger \
--location=asia-northeast1 \
--service-account=password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--transport-topic=projects/$GOOGLE_CLOUD_PROJECT/topics/secret-expiration-topic \
--destination-run-service=rotate-password \
--destination-run-region=asia-northeast1 \
--destination-run-path="/" \
--event-filters="type=google.cloud.pubsub.topic.v1.messagePublished"
Eventarc から関数を呼び出すための権限を付与します。
# password-rotation-sa に Cloud Run サービスの呼び出し権限を付与
gcloud run services add-iam-policy-binding rotate-password \
--region=asia-northeast1 \
--member="serviceAccount:password-rotation-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com" \
--role="roles/run.invoker"
ACK Deadline の調整
Eventarc が自動作成する Pub/Sub サブスクリプションのデフォルト ACK Deadline は 10 秒です。
パスワードローテーション処理は DB 接続やシークレット更新を含むため、10 秒を超える可能性があります。
ACK Deadline 内に処理が完了しないと、Pub/Sub が同じメッセージを何度も再配信し、ローテーションが連続して実行されるリスクがあります。
これを防ぐため、サブスクリプションの ACK Deadline を延長します。
# Eventarc が作成したサブスクリプション名を取得
SUBSCRIPTION_NAME=$(gcloud pubsub subscriptions list \
--filter="topic:secret-expiration-topic" \
--format="value(name)")
# ACK Deadline を 600 秒(10 分)に延長
gcloud pubsub subscriptions update $SUBSCRIPTION_NAME \
--ack-deadline=600
設定を確認します。
gcloud pubsub subscriptions describe $SUBSCRIPTION_NAME
ackDeadlineSeconds: 600
と表示されていれば OK です。
Secret Manager のイベント通知と無限ループ対策
Secret Manager に設定した Pub/Sub トピックには、以下のすべてのイベントで通知が送信されます。
SECRET_ROTATE
- ローテーション期限到達時(処理したいイベント)SECRET_VERSION_ADD
- 新しいバージョン追加時- その他のイベント(SECRET_UPDATE, SECRET_DELETE など)
そのため、ローテーション関数が実行されて新しいシークレットバージョンを追加すると、SECRET_VERSION_ADD
イベントが発火し、再び関数が呼ばれてしまい、無限ループが発生します。
この問題を防ぐため、rotate-password の関数内でイベントタイプをチェックし、SECRET_ROTATE
イベントのみを処理するように実装しています。
他にもPub/Subのサブスクリプションフィルターを使う方法もあります。そちらについては、以下の記事で紹介しているので確認してみてください。
12. 動作確認
先ほど --next-rotation-time
で設定した6分後の時間に、適切にローテーションが実行されることを確認します。
まず、コマンドを実行したタイミングの6分後は 22:20 でした。
そして 22:20 になったタイミングで、Secret Manager の画面を確認すると、1度だけローテーションされていることが確認できました。
※検証動作のため、22:13にも1度ローテーションしています。
ログを確認すると SECRET_ROTATE
だけを適切に処理し、それ以外のイベントは無視していることがわかります。
その後、接続確認用の関数を呼び出して、新しいパスワードで接続できることを確認します。
gcloud functions call connect-db --region=asia-northeast1
ローテーション後も、問題なくアクセスできましたね。
まとめ
Secret Manager を使って Cloud SQL のパスワードを安全に管理し、定期的にローテーションする仕組みを構築しました。
パスワードをコードに直接書かず Secret Manager で管理することで、セキュリティを向上させることができます。
また、定期的なローテーションにより、パスワード漏洩のリスクをさらに低減できます。
なかなか手間のかかる実装ではありましたが、この記事がそういった皆さんの参考になれば幸いです。
IAM でのデータベース認証に関しては、また別の記事で紹介したいと思います。