はじめに
通常、Google Cloud Platform (GCP) から Amazon Web Services (AWS) S3 へファイルをアップロードする際、S3へのアクセスに必要なIAMアカウントのアクセスキーとシークレットをGCP側に共有し設定する必要があります。しかし、この方法はセキュリティ上のリスクが非常に高いため、避けるべきです。
この記事では、機密性の高い認証情報(キー)をGCP側に共有することなく、安全にデータ転送を可能にするAssumeRoleを用いた方法を詳しく解説します。
事前準備:AWSとGCPの設定
セキュアな連携を実現するために、まずはAWS側とGCP側で以下の準備を行います。
GCP側での準備
- サービスアカウントの準備: AWSへ連携させるGCPプロジェクト内のサービスアカウントを準備します。このサービスアカウントが、AWSのIAMロールを引き受ける(AssumeRoleする)主体となります。
- GKEで実行する場合、以前の記事でサービスアカウントをキーレスで使用する方法を解説しています。この方法を用いる事で、GCP→GKE→AWS連携が完全にキーレス運用可能です。
AWS側での準備
- IAMロールの作成: GCPのサービスアカウントを認証するためのIAMロールを作成します。
- 信頼関係の設定: このIAMロールの信頼ポリシーに、GCPのサービスアカウントからのAssumeRoleを許可する設定を追加します。
- 注意: “accounts.google.com:aud”に設定する値がサービスアカウントのIDだけでは権限が不足するため、サービスアカウントのGUI画面で確認できるOAuth2クライアントIDと共に配列の形で記述してください。
まずはIAMロールを作成します。IAM > ロール で新規作成を行い、信頼されたエンティティタイプをカスタム信頼ポリシーに設定して、直接JSONを編集します。
サービスアカウントの一意のID(GCPではOAuth2クライアントIDと同値)とサービスアカウントのメールアドレスを配列で入力します。
入力したら次の画面に移行します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "accounts.google.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"accounts.google.com:aud": [
"サービスアカウントの一意のID",
"サービスアカウントのメールアドレス"
]
}
}
}
]
}
次に、このロールに対してAWS内の権限を付与しますが、既存のポリシーの場合には権限が大雑把であるため、ここでは何も付与せずに次の画面に移行し、この時点では権限を持たないロールを作成します。
一覧から作成したロールを選択し、許可ポリシー > 許可を追加 > インラインポリシーを作成をクリックして、直接権限を付与します。
設定画面では、JSONモードに変更して直接JSONを貼り付けると楽に設定可能です。
ポリシー名を付けたらポリシーを作成すればAWS側の準備は完了です。
※実行する操作に基づき、権限は付与してください。
- アップロード・上書きのみ → s3:PutObjectだけでOK
- ダウンロードも必要 → s3:GetObjectを追加
- 削除も必要 → s3:DeleteObjectを追加
- 一覧取得も必要 → s3:ListBucketを追加
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::バケット名",
"arn:aws:s3:::バケット名/*"
]
}
]
}
※IAMロールを作成したら、ARNをコピーしておきます。データ転送に時間がかかる場合には、ロールの編集でセッションの維持時間を延長可能です。
実装:Node.jsを用いたSTSクライアントでのアクセス
GCPのサービスアカウントがAWSのIAMロールを引き受けるために、AWS Security Token Service (STS) を利用します。ここでは、Node.js環境でAWS SDKを用いて一時的な認証情報を取得し、S3へアクセスする実装例を示します。
以下のコードは、GCP環境で実行されることを前提としており、GCPのサービスアカウントを用いて一時的なIDが自動的に生成され、転送に使用されます。
Node.jsコード例を抜粋
// S3バケット名を指定
const S3_BUCKET_NAME = process.env['S3_BUCKET_NAME'] || '';
if (!S3_BUCKET_NAME) throw new Error('S3バケット名が指定されていません。')
// aws系のライブラリ
import { S3Client, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { STSClient, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
// その他ライブラリ
import fs from 'fs';
import crypto from 'crypto';
/**
* hash値を返す関数
*/
const hash = (string: string): string => {
return crypto.createHash('md5').update(string).digest('hex');
};
/**
* S3とのファイルのやり取りするための認証情報を取得する関数
*/
const authS3 = async (destination: string) => {
// awsの基本設定
const roleArn = 'arn:aws:iam::XXXXXXXXXXXX:role/role-test'; // コピーしたIAMロールARNを入力
const region = 'ap-northeast-1'; // この例では東京リージョンを設定しています
// 任意のセッション名 重複したセッション名を同時に使用すると不具合が発生する可能性があるためファイル名のハッシュ値を付与
const destinationHash = hash(destination);
const roleSessionName = 'data-transfer-' + destinationHash;
// Google Cloud の認証情報を取得
const auth = new GoogleAuth();
// STSクライアントのセットアップ
const clientArgs = 'https://sts.amazonaws.com/';
const stsClient = new STSClient({ region });
const idTokenClient = await auth.getIdTokenClient(clientArgs);
const idToken = await idTokenClient.idTokenProvider.fetchIdToken(clientArgs);
if (!idToken) {
throw new Error('GCPサービスアカウントのIDトークンが取得できませんでした');
}
// AssumeRoleWithWebIdentityCommandの実行で一時的な認証情報を取得
const assumeRoleCommand = new AssumeRoleWithWebIdentityCommand({
RoleArn: roleArn,
RoleSessionName: roleSessionName,
WebIdentityToken: idToken,
// WebIdentityToken: auth.jsonContent?.client_id,
});
const assumeRoleResponse = await stsClient.send(assumeRoleCommand);
if (!assumeRoleResponse.Credentials) {
throw new Error('一時的な認証情報が取得できませんでした');
}
const { AccessKeyId, SecretAccessKey, SessionToken } = assumeRoleResponse.Credentials;
if (!AccessKeyId || !SecretAccessKey || !SessionToken) {
throw new Error('一時的な認証情報が取得できませんでした');
}
return { AccessKeyId, SecretAccessKey, SessionToken, region };
};
/**
* S3にファイルをアップロードする関数
*/
export const uploadToS3 = async (filePath: string, destination: string) => {
// ここで一時的な認証情報を取得します
const { AccessKeyId, SecretAccessKey, SessionToken, region } = await authS3(destination);
// 一時的な認証情報を使用してS3クライアントを作成
const s3Client = new S3Client({
region,
credentials: {
accessKeyId: AccessKeyId,
secretAccessKey: SecretAccessKey,
sessionToken: SessionToken,
},
});
const fileStream = fs.createReadStream(filePath);
const upload = new Upload({
client: s3Client,
params: {
Bucket: S3_BUCKET_NAME,
Key: destination,
Body: fileStream,
},
leavePartsOnError: false, // エラー発生時にパーツを削除
});
let lastLoggedTime = 0;
const logInterval = 20000; // ログを記録する間隔(ミリ秒)
upload.on('httpUploadProgress', (progress) => {
const currentTime = Date.now();
if (currentTime - lastLoggedTime >= logInterval) {
logger.info('S3にアップロード中... ' + progress.loaded + '/' + progress.total);
lastLoggedTime = currentTime;
}
});
try {
await upload.done();
const command = new ListObjectsV2Command({
Bucket: S3_BUCKET_NAME,
Prefix: destination.split('/')[0],
});
const response = await s3Client.send(command);
const result = response.Contents ? response.Contents.map((item) => item.Key) : [];
logger.info(result);
logger.info(filePath + 'をs3://' + S3_BUCKET_NAME + '/' + destination + 'にアップロードしました');
} catch (error) {
// エラー処理を記載
throw error;
}
};
終わりに
この記事では、AssumeRoleを利用することで、機密性の高いAWSのアクセスキーをGCP環境に一切置くことなく、キーレスでS3への安全なデータ転送を実現する方法を解説しました。
クラウド間の連携においてセキュリティを大幅に向上させるこの方法が、皆様のセキュアなクラウド運用の一助となれば幸いです。