はじめに
どうも、機械学習ソリューションアーキテクトの呉 (@kazuneet) です。
前回の記事 では Chococones as Code と称し、IaC を用いてたけのこの里を分析する環境として Amazon SageMaker Studio をデプロイしました。IaC を使うことのメリットとしてユーザー (分析環境を利用する人) とその権限 (ユーザーが何をしていいのか) 、そして分析環境をコードで管理することを実現し、人の出入りや権限の管理が楽になることを紹介してきました。
基本的には上記で運用すればよいのですが、お客様によっては厳しいセキュリティ要件を満たさないといけない場合があり、前回の記事だと不足する、というケースも散見されます。そこで、今まで我々ソリューションアーキテクトが直面してきたお客様のセキュリティ要件の中で最も遭遇頻度が高い、インターネットに出ないアーキテクチャーの最大公約数的なアーキテクチャーとその実装をこの記事では紹介します。
※ 前回の記事 (case01) よりセキュリティを強固にしていますが、あくまで「インターネットに出られないようにする」に主眼を置いているので、皆様のセキュリティ要件を満たしているかどうかはまた別のお話です。お客様の個々の要件ごとにアーキテクティングが必要なことにご留意ください。特に SageMaker Studio の User Profile に AmazonSageMakerFullAccess がついているので、Studio の中から他人が発行した Training Job を止めたり、他人の User Profile を削除できたりします。もちろんそういった操作が必要な場合もあるので、業務を鑑みてどんな操作の権限が必要なのかを検討する必要があります。
ご注意
本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。
このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »
1. ・・・の前に最近の Amazon SageMaker Studio のアップデート
本題に行く前に、 2022 年末に Amazon SageMaker のアップデートが多数ありました。その中で記事に関連するアップデートを 2 つ最初に紹介いたします。
SageMaker Studio の UI がリニューアル
1 つ目は SageMaker Studio の UI が新しくなりました。
黒を基調としたところに vivid なカラーが映えていて私は好きです。

SageMaker Studio のドメインをリージョン内で複数取り扱い可能に
2 つ目は、SageMaker Studio のドメインをリージョン内で複数扱えるようになりました。
今までは 1 つのリージョンに対してドメインが 1 つしか作れなかったため、部署ごとにドメインを用意して協力作業をしていく、といったことができず、すべてのユーザーがリージョン内の 1 つのドメインに紐付いている必要がありました。今回のアップデートで部署ごとにドメインを作成などができるようになりました。これは IaC を使うことで尚更管理が簡単になるので、この記事がさらに有用になるアップデートで嬉しい限りです。
さて、話を今回のアーキテクチャーに戻します。

2. よく遭遇するセキュリティ要件とアーキテクチャー
ここからは前回コードを書いてくれた、そして今回もコードを書いてくれた、工藤さんと津郷さんで会話形式 (フィクション) でよくあるセキュリティ要件と我々の思いを語りながらアーキテクチャーを固めていきます。
今回のアーキテクチャー
津郷「各自の SageMaker Studio の S3 や AWS CodeCommit へのアクセス権限については前回と一緒なので割愛して、図に起こすとこんな感じです。」
今回のアーキテクチャーの説明
津郷「ユーザーは、マネジメントコンソールに自分の IAM User でログインして、Fleet Manager からリモートデスクトップで EC2 にアクセスします。EC2 で SageMaker Studio の署名付き URL を発行して、EC2 のブラウザから SageMaker Studio にアクセスします。」
呉「先程の会話を踏まえて、といいながら踏まえていないところがありますね ? 」
津郷「はい、マネジメントコンソールを使っているところですね。本来、ここはお客様の環境 (ここでは津郷と工藤が接続している場所) と AWS を AWS Direct Connect(DX) か AWS VPN でつなぐことでインターネットを介さずにリモートデスクトップで EC2 までアクセスします。」
本来のアーキテクチャー
本来のアーキテクチャーの説明
津郷「しかし、すでに DX や VPN を引いているお客様はさておき、個人がお試しで引くのは大変で、DX や VPN を前提とした IaC を紹介したところで再現できる人は少ないので、このようなアーキテクチャーにしています。DX や VPN を引いている場合はマネジメントコンソールの部分を置き換えてください。同様に IAM ユーザーに対する IP アドレスの制限もかけていません。」
呉「もちろん、EC2 は専用で、そこから発行できる SageMaker Studio の 署名付き URL も自分の User Profile しか使えないんですよね ? 」
津郷「もちろんです。津郷の EC2 に工藤さんの IAM User ではアクセスできないですし、津郷の EC2 から工藤さんの SageMaker Studio の User Profile を用いた署名付き URL は発行させません。」
3. デプロイと各ユーザーによる利用イメージ
実装は長いので実装の説明に行く前に、どんな感じで SageMaker Studio を使えるのか、デプロイから利用までの流れを紹介します。
3-1. デプロイ
今回も同様にAWS Chococones Development Kit ・・・じゃなかった、 AWS Cloud Development Kit(CDK) は SageMaker Studio Lab から、Terraform は手元の Mac から実行します。ここではデプロイに必要なコマンドだけ記載しますので、詳細は前回の記事の2-3. IaC 実行環境準備を参照してください。
前回から IaC が入っているリポジトリが更新されています。CDK も Terraform もまずは以下のコマンドを打ち込んでリポジトリを更新しましょう。
リポジトリが残っている場合
# 前回クローンしたリポジトリに cd する
cd aws-ml-jp/
# リポジトリの最新化
git pull origin main
リポジトリが残っていない場合
# リポジトリをクローン
git clone https://github.com/aws-samples/aws-ml-jp
cd aws-ml-jp/
設定が残っていない場合
※設定などが残っていない場合は前回の記事の2-3. IaC 実行環境準備を参照して事前準備してください。
CDK TypeScript の場合
スクリプト
$ cd sagemaker/sagemaker-studio/IaC/case02/cdk-typescript
$ # 依存ライブラリインストール
$ npm install
$ # cdk の設定(前回実行している場合は不要)
$ # cdk bootstrap
$ # デプロイ
$ cdk deploy
(前略)
✅ ChococonesStack
✨ Deployment time: 446.38s
Outputs:
ChococonesStack.KudoEC2PasswordXXXXXXXXX = KudoEC2SecretXXXXXXXX-XXXXXXXXXXXX
ChococonesStack.KudoPasswordXXXXXXXXX = KudoSecretXXXXXXXX-XXXXXXXXXXXX
ChococonesStack.TsugoEC2PasswordXXXXXXXX = TsugoEC2SecretXXXXXXXX-XXXXXXXXXXXX
ChococonesStack.TsugoPasswordXXXXXXXXX = TsugoSecretXXXXXXXX-XXXXXXXXXXXXStack ARN:
arn:aws:cloudformation:{REGION}}:{ACCOUNT}:stack/ChococonesStack/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
✨ Total time: 450.95s
CDK Python の場合
スクリプト
$ cd sagemaker/sagemaker-studio/IaC/case02/cdk-python
$ # 依存ライブラリインストール
$ mkdir lib
$ pip install -r requirements.txt -t /lib
$ # インストールディレクトリにパスを通す
$ export PYTHONPATH=$PWD/lib
$ # cdk の設定(前回実行している場合は不要)
$ # cdk bootstrap
$ # デプロイ
$ cdk deploy
(前略)
✅ ChococonesStack
✨ Deployment time: 448.68s
Outputs:
ChococonesStack.KudoEC2PasswordXXXXXXXXX = KudoEC2SecretXXXXXXXX-XXXXXXXXXXXX
ChococonesStack.KudoPasswordXXXXXXXXX = KudoSecretXXXXXXXX-XXXXXXXXXXXX
ChococonesStack.TsugoEC2PasswordXXXXXXXX = TsugoEC2SecretXXXXXXXX-XXXXXXXXXXXX
ChococonesStack.TsugoPasswordXXXXXXXXX = TsugoSecretXXXXXXXX-XXXXXXXXXXXXStack ARN:
arn:aws:cloudformation:{REGION}}:{ACCOUNT}:stack/ChococonesStack/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
✨ Total time: 453.82s
Terraform の場合
スクリプト
$ cd sagemaker/sagemaker-studio/IaC/case02/terraform
$ # 鍵保管用のディレクトリ作成
$ mkdir ./cert
$ # 鍵生成
$ gpg --gen-key
(名前を問われるので terraform,メールアドレスは任意のアドレスを入力する)
$ # 鍵のエクスポート
$ gpg -o ./cert/terraform.public.gpg --export terraform
$ gpg -o ./cert/terraform.private.gpg --export-secret-key terraform
$
$ # Lambda 関数のアセットを zip 圧縮
$ cd examples
$ zip rotate.py.zip rotate.py
$
$ # Terraform 実行準備
$ terraform init
$ # Terraform 実行
$ # project name は世界でユニークなものを設定してください。筆者が使っているのでおそらく使えません。
$ terraform apply -var='project_name=chococones' -auto-approve
(前略)
Plan: 114 to add, 0 to change, 0 to destroy.
(後略)
Secrets Manager を開く
さて、これで各種リソースが出来上がりました。管理者は以下の情報を各ユーザーに配ります。
Secrets Manager の画面に行きます。
{ユーザー名}{8桁の数字}-{ランダムな12文字} が マネジメントコンソールにログインするためのパスワード、{ユーザー名}EC2{8桁の数字}-{ランダムな12文字} の命名規則がそれぞれの人がアクセスする EC2 にログインするための OS のパスワードです。
シークレットの名前のリンクをクリックし、

シークレットの値を取得
「シークレットの値を取得する」、をクリックして表示される文字列を各ユーザーに配ってください。

3-2. ユーザーの利用イメージ
ここからはユーザー (閉域で SageMaker Studio を使う人) の気持ちでどう使うのかを紹介します
マネジメントコンソールにログイン
まずはマネジメントコンソールにログインします。管理者から送られてきたパスワードを元にログインします。

パスワード変更
初回ログイン時はパスワード変更を求められるので変更します。

AWS Systems Manager を開く
AWS Systems Manager の画面に移動します。

フリートマネージャーを開く
「フリートマネージャー」の画面に行きます。

リモートデスクトップ(RDP)と接続
自身の EC2 を選択して、「リモートデスクトップ(RDP)との接続」を選択します。

接続
ユーザー名に user-1 を入力し (実装で変更可能) 、管理者からもらった EC2 用のパスワードを入力し、「Connect」をクリックします。

Explorer を開く
Windows の画面が出るので、Explorer を開きます。

Powershell を起動
C:\ 直下に GetPresignedUrl というファイルがあるので、右クリックして、「Run with PowerShell」をクリックします。

実行ポリシーを変更
初めて実行した時は Do you want to change the execution policy? と聞かれるので、[A] Yes to All である、 A を入力します。

初回起動時の選択
ブラウザが開きます。初回起動時のみ聞かれる質問がいくつか出ます。「Start without your data」を選択するのが楽でいいでしょう。

ブラウジングを始める
チェックを外して 「Confirm and start browsing」をクリックします。

SageMaker Studio の起動
すると、SageMaker Studio の起動画面に遷移するので待ちます。

SageMaker Studio の起動
起動しました。

成功
これで 前回同様きのこの山とたけのこの里の検出モデルを作成 できます。Enjoy !

ターミナルを起動
念の為インターネットに出られないことと、CodeArtifact 経由で pip install できることを確認します。「File」 → 「New」 → 「Terminal」をクリックし、ターミナルを起動します。

https://amazon.co.jp にアクセスを試す
試しに curl で https://amazon.co.jp にアクセスしてもなにも起きません。

Mac の場合
ちなみに手元の Mac だと ステータスコード 301 を返します。

numpy のインストールを試す
numpy をインストールしてみてもインターネットに出られないのでインストールできません。

CodeArtifact の認証を通して試す
しかし、以下のコマンドで CodeArtifact の認証を通したあとに pip install numpy -U をすると numpy がインストールできていることがわかります。
# 認証コマンドaws codeartifact login --tool pip --repository chococones --domain chocones --domain-owner {ACCOUND_ID} --region {region}
これらがインターネットに接続せずに、ライブラリを追加できる分析環境の使い方です。

4. 実装
使い方がわかったところでどのように実現したのか実装を紹介します。
前回との差分をメインに紹介していきます。diff を取りながら見てみてください。
4-1. 閉域を実現するために
今回はインターネットに出ない閉域で SageMaker Studio を始めとしたリソースを構築していきます。AWS で閉域といえば、VPC と Subnet ですね !
前回の記事であまり詳しく解説しませんでしたが、VPC 作成部分のコード差を見てみましょう。
前回 (閉域でない) の CDK TypeScirpt の場合
lib/chococones-stack.ts より抜粋
// Domain 用の VPC
const vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2 })
今回 (閉域) の CDK TypeScript の場合
lib/chococones-stack.ts より抜粋
// Domain 用の VPC
const vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 2,
natGateways: 0,
subnetConfiguration: [
{
name: 'isolated-subnet',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
],
})
前回(閉域でない)の CDK Python の場合
chococones/chococones.py より抜粋
# Domain 用の VPC
vpc = ec2.Vpc(self, "vpc", max_azs=2)
今回 (閉域) の CDK Python の場合
chococones/chococones.py より抜粋
# Domain 用の VPC
vpc = ec2.Vpc(
self,
"vpc",
max_azs=2,
nat_gateways=0,
subnet_configuration=[
{
"name": "isolated-subnet",
"subnetType": ec2.SubnetType.PRIVATE_ISOLATED,
"cidrMask": 24,
},
],
)
前回(閉域でない)の Terrafrom の場合
スクリプト
# VPC
module "vpc_for_sagemaker" {
source = "terraform-aws-modules/vpc/aws"
name = "${var.project_name}-${var.environment}-vpc-sagemaker"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
single_nat_gateway = false
one_nat_gateway_per_az = false
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "vpc_for_sagemaker"
}
}
今回 (閉域)の Terraform の場合
スクリプト
# VPC
module "vpc_for_sagemaker" {
source = "terraform-aws-modules/vpc/aws"
name = "${var.project_name}-${var.environment}-vpc"
cidr = "10.0.0.0/16"
azs = ["${var.aws_region}a", "${var.aws_region}c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
enable_nat_gateway = false
single_nat_gateway = false
one_nat_gateway_per_az = false
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
}
}
重要な点
前回は CDK の場合1行書いただけなのに、今回はいろいろ増えました。重要なのは、ec2.SubnetType.PRIVATE_ISOLATED と書かれた部分です。
PRIVATE_ISOLATED と記載することで、インターネットにルーティングしない Subnet ができあがります。この Subnet に SageMaker Studio のドメインだったり、EC2 だったりを配置することで、中のリソースからインターネットに出られないようにします。
一方 Terraform はもともと事細かに設定していたのでほとんど変更はないです。大きな違いは一箇所だけ、enable_nat_gateway が False になっています。これはインターネットに出ていく必要がないためです。private_subnets に記載した Subnet に各リソースを作成します。
4-2. VPC Endpoint
いくらインターネットに出さないからといって、AWS の各リソースにアクセスできないと AWS を使っている意味があまりありません。AWS の各リソースにアクセスするときはデフォルトでインターネット経由でアクセスしようとしてしまうため、閉域からアクセスしようとすると到達できません(なのでそもそも SageMaker Studio にすらアクセスできません)。閉域から AWS のリソースにアクセスするためには、使用するリソースごとに VPC Endpoint を立てます。VPC Endpoint を立てると、インターネットを経由せずに AWS の各リソースにアクセスできます。VPC Endpoint のリソースを立ち上げる際にどこに立てるのかという Subnet を指定するだけで OK です。
CDK TypeScript の場合
lib/chococones-stack.ts より抜粋
// エンドポイントを設定
new ec2.InterfaceVpcEndpoint(this, `SsmVpcEndpoint`, {
service: ec2.InterfaceVpcEndpointAwsService.SSM,
privateDnsEnabled: true,
vpc: vpc,
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
})
…
(中略)
…
new ec2.InterfaceVpcEndpoint(this, 'SecretsManagerApiVpcEndpoint', {
service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
privateDnsEnabled: true,
vpc: vpc,
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
})
vpc.addGatewayEndpoint('S3GatewayEndpoint', {
service: ec2.GatewayVpcEndpointAwsService.S3,
subnets: [{ subnetType: ec2.SubnetType.PRIVATE_ISOLATED }],
})
CDK Python の場合
chococones/chococones.py より抜粋
# エンドポイントを設定
ec2.InterfaceVpcEndpoint(
self,
"SsmVpcEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.SSM,
private_dns_enabled=True,
vpc=vpc,
subnets={"subnet_type": ec2.SubnetType.PRIVATE_ISOLATED},
)
…
(中略)
…
ec2.InterfaceVpcEndpoint(
self,
"SecretsManagerApiVpcEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
private_dns_enabled=True,
vpc=vpc,
subnets={"subnet_type": ec2.SubnetType.PRIVATE_ISOLATED},
)
vpc.add_gateway_endpoint(
"S3GatewayEndpoint",
service=ec2.GatewayVpcEndpointAwsService.S3,
subnets=[
{"subnet_type": ec2.SubnetType.PRIVATE_ISOLATED},
],
)
Terraform の場合
examples/main.tf より抜粋
# Security Group for VPC endpoint
resource "aws_security_group" "allow_local_https" {
name = "allow_local_https"
description = "Allow HTTPS inbound traffic"
vpc_id = module.vpc_for_sagemaker.vpc_id
ingress {
description = "TLS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [module.vpc_for_sagemaker.vpc_cidr_block]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "allow_local_https"
}
}
resource "aws_vpc_endpoint" "s3_endpoint" {
vpc_id = module.vpc_for_sagemaker.vpc_id
service_name = "com.amazonaws.${data.aws_region.current.name}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = module.vpc_for_sagemaker.private_route_table_ids
policy = <<POLICY
{
"Statement": [
{
"Action": "*",
"Effect": "Allow",
"Resource": "*",
"Principal": "*"
}
]
}
POLICY
}
# VPCエンドポイント
module "endpoints" {
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
vpc_id = module.vpc_for_sagemaker.vpc_id
security_group_ids = [aws_security_group.allow_local_https.id]
endpoints = {
ssm = {
service = "ssm"
private_dns_enabled = true
subnet_ids = module.vpc_for_sagemaker.private_subnets
security_group_ids = [aws_security_group.allow_local_https.id]
},
…
(中略)
…
secretsmanager = {
service = "secretsmanager"
private_dns_enabled = true
subnet_ids = module.vpc_for_sagemaker.private_subnets
} }
}
作成完了
長過ぎるので、中略してしまいましたが、Inteface VPC Endpoint で、今回使用する AWS のサービスである、EC2, S3, CodeCommit, CodeArtifact などの VPC Endpoint を作ります。S3 のみ Gateway VPC Endpoint を作成しています。
Terraform もほとんど同じで、VPC エンドポイント用のセキュリティグループを設定している以外は、粛々と VPC エンドポイントを設定しています。
これでこの Subnet からインターネットを介さず、使用する AWS の各リソースにアクセスできるようになりました。
4-3. EC2 周りの設定
今回は EC2 を踏み台にして、 SageMaker Studio にアクセスしますので、EC2 周りの設定を見ていきましょう。
4-3-1. EC2 と 署名付き URL の発行
ともかく、EC2 を作成する必要があります。EC2 の作成は以下の通りです。
CDK TypeScript の場合
lib/constructs/user.ts より抜粋
// EC2 用のパスワードを Secrets Manager で生成
const ec2Secret = new secretsmanager.Secret(this, 'Ec2Secret')
// EC2 インスタンス
const instance = new ec2.Instance(this, 'Instance', {
vpc: props.vpc,
vpcSubnets: props.vpc.selectSubnets({
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
}),
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.LARGE
),
machineImage: new ec2.WindowsImage(
ec2.WindowsVersion.WINDOWS_SERVER_2022_ENGLISH_FULL_BASE
),
role: instanceRole,
userData: ec2.UserData.custom(
`
<powershell>
$password = (Get-SECSecretValue -SecretId ${ec2Secret.secretArn} -Region ${region}).SecretString
New-LocalUser -Name user-1 -Password (ConvertTo-SecureString $password -AsPlainText -Force)
Add-LocalGroupMember -Group "Remote Desktop Users" -Member user-1
Add-LocalGroupMember -Group Administrators -Member user-1
Write-Output '$url = New-SMPresignedDomainUrl -DomainId ${props.domainId} -UserProfileName ${this.userProfile.userProfileName} -Region ${region}' | Set-Content -Encoding Default 'C:\\GetPresignedUrl.ps1'
Write-Output 'start microsoft-edge:$url' | Add-Content -Encoding Default 'C:\\GetPresignedUrl.ps1'
</powershell>
<persist>true</persist>
`
),
})
CDK Python の場合
chococones/constructs/user.py より抜粋
# EC2 へのログイン用パスワードを Secrets Manager で生成
ec2_secret = secretsmanager.Secret(self, "EC2Secret")
(中略)
# EC2 インスタンス
instance = ec2.Instance(
self,
"Instance",
vpc=vpc,
vpc_subnets=ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
),
instance_type=ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.LARGE,
),
machine_image=ec2.WindowsImage(
ec2.WindowsVersion.WINDOWS_SERVER_2022_ENGLISH_FULL_BASE
),
role=instance_role,
user_data=ec2.UserData.custom(
f"""
<powershell>
$password = (Get-SECSecretValue -SecretId {self.ec2_secret.secret_arn} -Region {region}).SecretString
New-LocalUser -Name user-1 -Password (ConvertTo-SecureString $password -AsPlainText -Force)
Add-LocalGroupMember -Group "Remote Desktop Users" -Member user-1
Add-LocalGroupMember -Group Administrators -Member user-1
Write-Output '$url = New-SMPresignedDomainUrl -DomainId {domain.attr_domain_id} -UserProfileName {self.user_profile.user_profile_name} -Region {region}' | Set-Content -Encoding Default 'C:\\GetPresignedUrl.ps1'
Write-Output 'start microsoft-edge:$url' | Add-Content -Encoding Default 'C:\\GetPresignedUrl.ps1'
</powershell>
<persist>true</persist>
"""
),
)
Terraform の場合
examples/main.tf より抜粋
# Secret
resource "aws_secretsmanager_secret" "ec2_login_password" {
for_each = toset(var.user_list)
name = "${var.project_name}-ec2-login-password-${each.value}"
}
resource "aws_secretsmanager_secret_version" "secret_version" {
for_each = toset(var.user_list)
secret_id = "${var.project_name}-ec2-login-password-${each.value}"
secret_string = jsonencode("dummypassword")
lifecycle {
ignore_changes = [
secret_string
]
}
depends_on = [
aws_secretsmanager_secret.ec2_login_password
]
}
# EC2
resource "aws_instance" "ec2-indivisual" {
for_each = toset(var.user_list)
ami = data.aws_ssm_parameter.windows_latest_ami.value
instance_type = "m5.xlarge"
iam_instance_profile = "ec2-instanceprofile-${each.value}"
subnet_id = module.vpc_for_sagemaker.private_subnets[0]
vpc_security_group_ids = [aws_security_group.private_ec2.id]
user_data = <<EOF
<powershell>
$password = (Get-SECSecretValue -SecretId ${var.project_name}-ec2-login-password-${each.value}).SecretString
New-LocalUser -Name user-1 -Password (ConvertTo-SecureString $password -AsPlainText -Force)
Add-LocalGroupMember -Group "Remote Desktop Users" -Member user-1
Add-LocalGroupMember -Group Administrators -Member user-1
Write-Output '$url = New-SMPresignedDomainUrl -DomainId ${aws_sagemaker_domain.domain.id} -UserProfileName sagemaker-user-${each.value} -Region us-east-1' | Set-Content -Encoding Default 'C:\GetPresignedUrl.ps1'
Write-Output 'start microsoft-edge:$url' | Add-Content -Encoding Default 'C:\GetPresignedUrl.ps1'
</powershell>
<persist>true</persist>
EOF
tags = {
Name = "ec2-indivisual-${each.value}"
}
depends_on = [
aws_secretsmanager_secret.ec2_login_password,
aws_secretsmanager_secret_rotation.ec2_login_password_secret_rotation,
aws_iam_policy_attachment.ec2_get_secret,
aws_secretsmanager_secret_rotation.ec2_login_password_secret_rotation
]
}
解説
最初に Secrets Manager で OS にログインするためのパスワードを生成しています。このパスワードを用いて OS のユーザーを作成します。
EC2 から SageMaker Studio にアクセスすることもあり、OS は慣れている方が多い Windows を利用しています。
引数で、作成した VPC / Private Isolated Subnet を指定しています。role は後述の別途作成したロールをアタッチしています。
また、長め(?)の User Data を設定しています。User Data は EC2 初回起動時のみ (※) 動くスクリプトを設定できます。ここは今回の肝の 1 つなのですが、以下のことを行っています。
※例外はありますが今回は割愛します。
Windows OS 内のユーザー作成
工藤さんや津郷さんが IAM User を用いてマネジメントコンソールにログイン後、 Fleet Manager から Windows OS にリモートデスクトップでログインする際に利用するユーザーを作成します。また、ユーザーのパスワードは秘匿情報なので、Secrets Manager から設定するパスワードを取得します。
パスワードの取得方法は、AWS Tools for Powershell の Get-SECSecretValue を用いています。(EC2 の Windows AMI では、AWS Tools for Powershell が最初から使えるため)署名付き URL を発行して、ブラウザでその URL にアクセスするためのスクリプトを設置
津郷さんや工藤さんが EC2 から SageMaker Studio にアクセスするためには、署名付き URL を発行する必要があります。署名付き URL は今後何度も発行するので、New-SMPresignedDomainUrl を用いて署名付き URL を発行する ps スクリプトを配置しています。また、署名付き URL を発行する、ということはそのままブラウザでアクセスする、ということなので、自動で Edge を起動して取得した URL にアクセスするようにしています。ユーザー (津郷さんや工藤さん) はこの ps スクリプトを実行して SageMaker Studio にアクセスします。
また、Terraform では、Secrets Manager にパスワードを登録する際、何も考慮しないと tfstate にパスワードが平文で記載されてしまうため、Secret Manager でダミーのパスワードを登録すると同時にパスワードローテーション用の Lambda を実装することでパスワードが tfstate に記載されないような実装を行なっています。
4-3-2. EC2 にアタッチするロールと必要なポリシー
CDK TypeScript の場合
lib/constructs/user.ts より抜粋
// インスタンス用 Role
const instanceRole = new iam.Role(this, `InstanceRole`, {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
'AmazonSSMManagedInstanceCore'
),
],
})
const instancePolicy = new iam.ManagedPolicy(this, 'InstancePolicy')
instancePolicy.addStatements(
new iam.PolicyStatement(
{
effect: iam.Effect.ALLOW,
actions: ["sagemaker:CreatePresignedDomainUrl"],
resources: [
userProfile.attrUserProfileArn,
],
}
)
)
instancePolicy.addStatements(
new iam.PolicyStatement(
{
effect: iam.Effect.ALLOW,
actions: ['secretsmanager:GetSecretValue'],
resources: [
ec2Secret.secretArn
]
}
)
)
instanceRole.addManagedPolicy(instancePolicy)
CDK Python の場合
chococones/constructs/user.py より抜粋
# インスタンス用 Role
instance_role = iam.Role(
self,
"InstanceRole",
assumed_by=iam.ServicePrincipal("ec2.amazonaws.com"),
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name(
"AmazonSSMManagedInstanceCore"
)
],
)
instance_policy = iam.ManagedPolicy(self, "InstancePolicy")
instance_policy.add_statements(
iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=["sagemaker:CreatePresignedDomainUrl"],
resources=[
user_profile.attr_user_profile_arn,
],
)
)
instance_policy.add_statements(
iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=['secretsmanager:GetSecretValue'],
resources=[
ec2_secret.secret_arn
]
)
)
instance_role.add_managed_policy(instance_policy)
Terraform の場合
examples/main.tf より抜粋
# IAM role - 個人EC2用
resource "aws_iam_role" "ec2_role_indivisual" {
for_each = toset(var.user_list)
name = "ec2-role-${each.value}"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
# IAM policy - SecretsManagerの値を取得
resource "aws_iam_policy" "ec2_get_secret" {
for_each = toset(var.user_list)
name = "allow-get-${var.project_name}-ec2-login-password-${each.value}"
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"secretsmanager:GetSecretValue"
],
"Effect": "Allow",
"Resource": ["arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${var.project_name}-ec2-login-password-${each.value}-??????"]
}
]
}
EOF
}
resource "aws_iam_instance_profile" "ec2_isntanceprofile_indivisual" {
for_each = toset(var.user_list)
name = "ec2-instanceprofile-${each.value}"
role = "ec2-role-${each.value}"
}
完了
4-4. EC2 にアクセスするための IAM User 周りの設定
CDK TypeScript の場合
lib/constructs/user.ts より抜粋
// IAM ユーザー
this.user = new iam.User(this, 'User', {
userName: props.name,
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('IAMUserChangePassword'),
props.stdioCommonPolicy,
],
password: secret.secretValue,
passwordResetRequired: true,
})
// IAM ユーザーにアタッチする Fleet Manager を使うためのポリシー
const startSessionPolicyJson = {
Version: '2012-10-17',
Statement: [
{
Sid: 'EC2',
Effect: 'Allow',
Action: ['ec2:DescribeInstances', 'ec2:GetPasswordData'],
Resource: '*',
},
{
Sid: 'SSM',
Effect: 'Allow',
Action: [
'ssm:DescribeInstanceProperties',
'ssm:GetCommandInvocation',
'ssm:GetInventorySchema',
],
Resource: '*',
},
{
Sid: 'SSMStartSession',
Effect: 'Allow',
Action: ['ssm:StartSession'],
Resource: ['arn:aws:ssm:*::document/AWS-StartPortForwardingSession'],
Condition: {
BoolIfExists: {
'ssm:SessionDocumentAccessCheck': 'true',
},
},
},
{
Sid: 'AccessTaggedInstances',
Effect: 'Allow',
Action: ['ssm:StartSession'],
Resource: [
`arn:aws:ec2:*:${accountId}:instance/*`,
`arn:aws:ssm:*:${accountId}:managed-instance/*`,
],
Condition: {
StringLike: {
'ssm:resourceTag/Name': [nameTagValue],
},
},
},
{
Sid: 'GuiConnect',
Effect: 'Allow',
Action: [
'ssm-guiconnect:CancelConnection',
'ssm-guiconnect:GetConnection',
'ssm-guiconnect:StartConnection',
],
Resource: '*',
},
],
}
const startSessionPolicyDocument = iam.PolicyDocument.fromJson(
startSessionPolicyJson
)
const startSessionManagedPolicy = new iam.ManagedPolicy(
this,
'StartSessionManagedPolicy',
{
document: startSessionPolicyDocument,
}
)
startSessionManagedPolicy.attachToUser(this.user)
CDK Python の場合
chococones/constructs/user.py より抜粋
# IAM ユーザー
self.user = iam.User(
self,
"User",
user_name=name,
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name("IAMUserChangePassword"),
],
password=secret.secret_value,
password_reset_required=True,
)
# IAM ユーザーにアタッチする Fleet Manager を使うためのポリシー
START_SESSION_POLICY_JSON = {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EC2",
"Effect": "Allow",
"Action": ["ec2:DescribeInstances", "ec2:GetPasswordData"],
"Resource": "*",
},
{
"Sid": "SSM",
"Effect": "Allow",
"Action": [
"ssm:DescribeInstanceProperties",
"ssm:GetCommandInvocation",
"ssm:GetInventorySchema",
],
"Resource": "*",
},
{
"Sid": "SSMStartSession",
"Effect": "Allow",
"Action": ["ssm:StartSession"],
"Resource": [
"arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
],
"Condition": {
"BoolIfExists": {
"ssm:SessionDocumentAccessCheck": "true",
}
},
},
{
"Sid": "AccessTaggedInstances",
"Effect": "Allow",
"Action": ["ssm:StartSession"],
"Resource": [
f"arn:aws:ec2:*:{account_id}:instance/*",
f"arn:aws:ssm:*:{account_id}:managed-instance/*",
],
"Condition": {
"StringLike": {
"ssm:resourceTag/Name": [NAME_TAG_VALUE],
}
},
},
{
"Sid": "GuiConnect",
"Effect": "Allow",
"Action": [
"ssm-guiconnect:CancelConnection",
"ssm-guiconnect:GetConnection",
"ssm-guiconnect:StartConnection",
],
"Resource": "*",
},
],
}
start_session_policy_document = iam.PolicyDocument.from_json(
START_SESSION_POLICY_JSON
)
start_session_managed_policy = iam.ManagedPolicy(
self,
"StartSessionManagedPolicy",
document=start_session_policy_document,
)
start_session_managed_policy.attach_to_user(self.user)
Terraform の場合
完了
これで、IAM User のパスワード変更 (初回ログイン時にパスワード変更を強制するため) と、フリートマネージャー経由で EC2 にリモートデスクトップするのに必要なポリシーだけをアタッチできました。実装は少し長くなってしまっていますが、ほとんどはフリートマネージャーで自分用の EC2 にアクセスするためのポリシー設定です。
大事なことなので 2 回書きますが、DX や VPN を引いている場合はこれらのリソースは不要です。
4-5. AWS CodeArtifact 周りの設定
今回のアーキテクチャーではインターネットから pip install することを禁じており、ライブラリを追加したい場合は CodeArtifact 経由でインストールします。
4-5-1. CodeArtifact のドメインとリポジトリの作成
まずは CodeArtifact のリソースを作成します。
CDK TypeScript の場合
lib/chococones-stack.ts より抜粋
const codeartifactDomain = new codeartifact.CfnDomain(
this,
'codeartifactDomain',
{
domainName: codeartifactRepositoryName,
}
)
const codeartifactRepository = new codeartifact.CfnRepository(
this,
'codeartifactRepository',
{
domainName: codeartifactDomain.domainName,
repositoryName: codeartifactRepositoryName,
externalConnections: ['public:pypi'],
}
)
codeartifactRepository.addDependency(codeartifactDomain)
CDK Python の場合
chococones/chococones.py より抜粋
code_artifact_domain = codeartifact.CfnDomain(
self,
"codeartifactDomain",
domain_name=CODE_ARTIFACT_DOMAIN_NAME,
)
code_artifact_repository = codeartifact.CfnRepository(
self,
"codeartifactRepository",
domain_name=code_artifact_domain.domain_name,
repository_name=CODE_ARTIFACT_REPOSITORY_NAME,
external_connections=["public:pypi"],
)
code_artifact_repository.add_dependency(code_artifact_domain)
Terraform の場合
examples/main.tf より抜粋
# CodeArtifact
resource "aws_codeartifact_domain" "codeartifact_domain" {
domain = var.project_name
}
resource "aws_codeartifact_repository" "codeartifact_repository" {
repository = var.project_name
domain = aws_codeartifact_domain.codeartifact_domain.domain
external_connections {
external_connection_name = "public:pypi"
}
}
4-5-2. SageMaker Studio から CodeArtifact にアクセスするためのポリシー
CDK TypeScript の場合
lib/chococones-stack.ts より抜粋
// 全員の SageMaker Studio Profile につける共用 Policy
const studioCommonPolicy = new iam.ManagedPolicy(this, 'CommonPolicy')
// 共用 CodeArtifact リポジトリへのアクセス許可
studioCommonPolicy.addStatements(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'codeartifact:DescribeDomain',
'codeartifact:DescribeRepository',
'codeartifact:GetAuthorizationToken',
'codeartifact:GetRepositoryEndpoint',
'codeartifact:GetRepositoryPermissionsPolicy',
'codeartifact:ListPackages',
'codeartifact:ListRepositories',
'codeartifact:ListTagsForResource',
'codeartifact:ReadFromRepository',
],
resources: [codeartifactDomain.attrArn, codeartifactRepository.attrArn],
})
)
studioCommonPolicy.addStatements(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['sts:GetServiceBearerToken'],
resources: ['*'],
conditions: {
StringEquals: {
'sts:AWSServiceName': 'codeartifact.amazonaws.com',
},
},
})
)
CDK Python の場合
chococones/chococones.py より抜粋
# 全員の SageMaker Studio Profile につける共用 Policy
studio_common_policy = iam.ManagedPolicy(self, "StudioCommonPolicy")
# 共用 CodeArtifact リポジトリへのアクセス許可
studio_common_policy.add_statements(
iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=[
"codeartifact:DescribeDomain",
"codeartifact:DescribeRepository",
"codeartifact:GetAuthorizationToken",
"codeartifact:GetRepositoryEndpoint",
"codeartifact:GetRepositoryPermissionsPolicy",
"codeartifact:ListPackages",
"codeartifact:ListRepositories",
"codeartifact:ListTagsForResource",
"codeartifact:ReadFromRepository",
],
resources=[
code_artifact_domain.attr_arn,
code_artifact_repository.attr_arn,
],
)
)
studio_common_policy.add_statements(
iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=["sts:GetServiceBearerToken"],
resources=["*"],
conditions={
"StringEquals": {"sts:AWSServiceName": "codeartifact.amazonaws.com"}
},
)
)
Terraform の場合
examples/main.tf より抜粋
# IAM policy document for access shared CodeArtifact
data "aws_iam_policy_document" "codecartifact_access" {
statement {
actions=[
"codeartifact:DescribeDomain",
"codeartifact:DescribeRepository",
"codeartifact:GetAuthorizationToken",
"codeartifact:GetRepositoryEndpoint",
"codeartifact:GetRepositoryPermissionsPolicy",
"codeartifact:ListPackages",
"codeartifact:ListRepositories",
"codeartifact:ListTagsForResource",
"codeartifact:ReadFromRepository",
]
resources = [aws_codeartifact_domain.codeartifact_domain.arn, aws_codeartifact_repository.codeartifact_repository.arn]
effect = "Allow"
}
statement {
actions = ["sts:GetServiceBearerToken"]
resources = ["*"]
condition {
test = "StringEquals"
variable = "sts:AWSServiceName"
values = ["codeartifact.amazonaws.com"]
}
effect = "Allow"
}
}
# IAM policy for access shared CodeArtifact
resource "aws_iam_policy" "allow_codeartifact_access" {
name = "allow-access-codeartifact-repository"
policy = data.aws_iam_policy_document.codecartifact_access.json
}
5. おわりに
呉「いやーきのこの山を閉域に閉じ込めることに成功しました。」
津郷「これでセキュアに SageMaker Studio を使えますね。」
工藤「この SageMaker Studio IaC シリーズは一旦おしまいの予定ですが、また面白いセキュリティ要件とか出てきたら紹介したいですね。」
呉「さて、まとめましょう。」
前回の記事 (case01) では、IaC を用いて SageMaker Studio とその周辺の AWS リソースを管理することで、運用が楽になりスケールできることを紹介しました。それに加え、今回 (case02) はより強固なセキュリティを求めて、(DX や VPN を用いれば) インターネット外に通信が出ていかないアーキテクチャーを IaC で実装しました。
実際に利用する際は濃淡があり、case01 と case02 の間 (あるいは case02 より更に強固な)に皆様にフィットするアーキテクチャーがあるかと思いますので、今回紹介した記事 / コードを参考に切った貼ったして最適化していただけると幸いです。
筆者プロフィール

呉 和仁 (Go Kazuhito / @kazuneet)
アマゾン ウェブ サービス ジャパン合同会社
機械学習ソリューションアーキテクト。
IoT の DWH 開発、データサイエンティスト兼業務コンサルタントを経て現職。
プログラマの三大美徳である怠惰だけを極めてしまい、モデル構築を怠けられる AWS の AI サービスをこよなく愛す。

工藤 朋哉
アマゾン ウェブ サービス ジャパン合同会社
プロトタイプエンジニア
AWS Japan の Prototype Enginner として、プロトタイプ開発を通じてお客様の技術支援をしています。AWS CDK が好き。最近のマイブームはいろいろなお店のカヌレを食べること。

津郷光明
アマゾン ウェブ サービス ジャパン合同会社
ソリューションアーキテクト。
SIerにて金融系システム開発、企画を経て AWS Japan へ入社。
Infrastructure as Code や自動化を好み、関連するサービスをよく利用します。
AWS を無料でお試しいただけます
Did you find what you were looking for today?
Let us know so we can improve the quality of the content on our pages