2024.04.08
AWSのセキュアな開発環境の検証
結論
- IAMロールと2段階認証を利用してローカル環境からシステム構成をセキュアに変更出来る
- SSM Session Managerのポートフォワードを利用してインターネットからのインバウンドアクセスが不要なWebサービス環境が構築できる
はじめに
こんにちは。次世代システム研究室のT.Tです。
現在開発運用に携わっているサービスのシステム基盤のAWSへの移行を検証しています。現在は開発環境を構築している段階で、CDKを利用して開発環境を構築しています。
運用中のサービスが稼働している環境はZcom EPのクラウド環境で、この環境では開発環境にはVPN経由で接続出来るようになっていて、VMにはインターネットからのインバウンドアクセスは出来ないネットワーク構成になっています。ダークカナリアリリース用の環境も同じような構成になっていて、AWSでも開発運用で検証する環境はインターネットからのインバウンドアクセスが出来ないネットワーク構成を検討しています。
本記事では、AWS環境でインターネットからのインバウンドアクセスが出来ないネットワークを手軽に構築するために検証した内容についてご紹介します。
1.前提
ローカル環境とAWSに以下の設定が完了している状態になっています。
- AWS CLIをインストール済み
- AWS CLIのIAMロールでのユーザー認証を設定して2段階認証を必須化済み
- CDKを初期設定済み
- ECRにNginxのDockerイメージを登録済み
2.システム構成
quiverさんの記事を参考にして、SSM Session Managerのポートフォワードでインターネットからのインバウンドアクセスを許可せずに、ローカル環境でFargateのサービスにアクセス出来るシステム構成にしてみます。以下の図のようにFargateにSSMで接続して、ポートフォワードでFargate内のコンテナにアクセスする形です。
https://storage.googleapis.com/zenn-user-upload/dfd0b3f64f55-20221124.png
主なシステム構成は以下の内容で、詳細はCDKの実装に記述しています。
- VPCのネットワークはプライベートネットワークのみ
- FargateでNginxを稼働する
- NginxのイメージはECRから取得
- タスクロールにAmazonSSMManagedInstanceCoreを付与(demoExecutionRoleをコンソールで作成)
- SSMに必要なVPCエンドポイントを設定
bin/cdk-demo.ts
#!/usr/bin/env node import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { DemoVpcStack } from '../lib/demo-vpc-stack'; import { DemoEcsStack } from '../lib/demo-ecs-stack'; const app = new cdk.App(); const demoVpcStackStack = new DemoVpcStack(app, 'DemoVpcStack', { env: { account: 'xxxxxxxxxxxx', region: 'ap-northeast-1' }, }); new DemoEcsStack(app, 'DemoEcsStack', { env: { account: 'xxxxxxxxxxxx', region: 'ap-northeast-1' }, vpc: demoVpcStackStack.vpc, subnetPrivateAz1: demoVpcStackStack.subnetPrivateAz1, });
lib/demo-vpc-stack.ts
import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; export class DemoVpcStack extends cdk.Stack { public readonly vpc: ec2.CfnVPC; public readonly subnetPrivateAz1: ec2.CfnSubnet; constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const vpc = new ec2.CfnVPC(this, 'cdk-demo-vpc', { cidrBlock: '172.31.0.0/24', enableDnsSupport: true, enableDnsHostnames: true, instanceTenancy: 'default', tags: [{ key: 'Name', value : 'cdk-demo-vpc' }], }) const subnetPrivateAz1 = new ec2.CfnSubnet(this, 'cdk-demo-subnet-private-az1', { vpcId: vpc.ref, cidrBlock: '172.31.0.0/28', availabilityZone: 'ap-northeast-1a', tags: [{ key: 'Name', value : 'cdk-demo-subnet-private-az1' }], }); const routeTablePrivateAz1 = new ec2.CfnRouteTable(this, 'cdk-demo-route-table-private-az1', { vpcId: vpc.ref, tags: [{ key: 'Name', value : 'cdk-demo-route-table-private-az1' }], }); new ec2.CfnSubnetRouteTableAssociation(this, 'cdk-demo-private-subnet-route-table-association-az1', { subnetId: subnetPrivateAz1.ref, routeTableId: routeTablePrivateAz1.ref, }); const securityGroupEndpoint = new ec2.CfnSecurityGroup(this, 'cdk-demo-security-group-endpoint', { vpcId: vpc.ref, groupDescription: 'Allow https access', securityGroupIngress: [ { ipProtocol: 'tcp', fromPort: 443, toPort: 443, cidrIp: '0.0.0.0/0' } ], securityGroupEgress: [ { ipProtocol: '-1', cidrIp: '0.0.0.0/0' } ], tags: [{ key: 'Name', value : 'cdk-demo-security-group-endpoint' }], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-s3', { serviceName: 'com.amazonaws.ap-northeast-1.s3', vpcEndpointType: 'Gateway', vpcId: vpc.ref, routeTableIds: [routeTablePrivateAz1.ref], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-ecr-api', { serviceName: 'com.amazonaws.ap-northeast-1.ecr.api', vpcEndpointType: 'Interface', vpcId: vpc.ref, privateDnsEnabled: true, subnetIds: [subnetPrivateAz1.ref], securityGroupIds: [securityGroupEndpoint.ref], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-ecr-dkr', { serviceName: 'com.amazonaws.ap-northeast-1.ecr.dkr', vpcEndpointType: 'Interface', vpcId: vpc.ref, privateDnsEnabled: true, subnetIds: [subnetPrivateAz1.ref], securityGroupIds: [securityGroupEndpoint.ref], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-cw', { serviceName: 'com.amazonaws.ap-northeast-1.logs', vpcEndpointType: 'Interface', vpcId: vpc.ref, privateDnsEnabled: true, subnetIds: [subnetPrivateAz1.ref], securityGroupIds: [securityGroupEndpoint.ref], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-ssm', { serviceName: 'com.amazonaws.ap-northeast-1.ssm', vpcEndpointType: 'Interface', vpcId: vpc.ref, privateDnsEnabled: true, subnetIds: [subnetPrivateAz1.ref], securityGroupIds: [securityGroupEndpoint.ref], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-ec2messages', { serviceName: 'com.amazonaws.ap-northeast-1.ec2messages', vpcEndpointType: 'Interface', vpcId: vpc.ref, privateDnsEnabled: true, subnetIds: [subnetPrivateAz1.ref], securityGroupIds: [securityGroupEndpoint.ref], }); new ec2.CfnVPCEndpoint(this, 'cdk-demo-vpc-endpoint-ssmmessages', { serviceName: 'com.amazonaws.ap-northeast-1.ssmmessages', vpcEndpointType: 'Interface', vpcId: vpc.ref, privateDnsEnabled: true, subnetIds: [subnetPrivateAz1.ref], securityGroupIds: [securityGroupEndpoint.ref], }); this.vpc = vpc; this.subnetPrivateAz1 = subnetPrivateAz1; } }
lib/demo-ecs-stack.ts
import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; export interface DemoEcsStackProps extends cdk.StackProps { vpc: ec2.CfnVPC; subnetPrivateAz1: ec2.CfnSubnet; } export class DemoEcsStack extends cdk.Stack { constructor(scope: Construct, id: string, props: DemoEcsStackProps) { super(scope, id, props); const vpc = props.vpc; const subnetPrivateAz1 = props.subnetPrivateAz1; const cluster = new ecs.CfnCluster(this, 'demoEcsCluster', { clusterName: 'demoEcsCluster' }); const taskDefinitionProps = { networkMode: ecs.NetworkMode.AWS_VPC, requiresCompatibilities: ['FARGATE'], cpu: '1024', memory: '3GB', runtimePlatform: { cpuArchitecture: 'X86_64', operatingSystemFamily: 'LINUX', }, executionRoleArn: 'arn:aws:iam::xxxxxxxxxxxx:role/demoExecutionRole', family: 'demo-ecs-task', taskRoleArn: 'arn:aws:iam::xxxxxxxxxxxx:role/demokExecutionRole', containerDefinitions: [ { essential: true, image: 'xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-demo', name: 'nginx-demo', cpu: 0, portMappings: [ { name: 'nginx-demo-80-tcp', containerPort: 80, hostPort: 80, protocol: 'tcp', appProtocol: 'http', }, { name: 'nginx-demo-443-tcp', containerPort: 443, hostPort: 443, protocol: 'tcp', appProtocol: 'http', }, ], logConfiguration: { logDriver: "awslogs", options: { "awslogs-create-group": "true", "awslogs-group": "/ecs/nginx-demo-task", "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "ecs" }, secretOptions: [], }, }, ], tags: [ { key: 'Name', value : 'demoTaskDefination' } ], }; const taskDefinition = new ecs.CfnTaskDefinition(this, 'demoEcsCdkTaskDefinition', taskDefinitionProps); const securityGroupPrivate = new ec2.CfnSecurityGroup(this, 'demoSecurityGroupPrivate', { vpcId: vpc.ref, groupDescription: 'Allow access all', securityGroupIngress: [ { ipProtocol: '-1', cidrIp: '0.0.0.0/0' } ], securityGroupEgress: [ { ipProtocol: '-1', cidrIp: '0.0.0.0/0' } ], tags: [ { key: 'Name', value : 'demoSecurityGroupPrivate' } ], }); new ecs.CfnService(this, 'demoEcsService', { serviceName: 'demoEcsService', taskDefinition: taskDefinition.ref, cluster: cluster.clusterName, networkConfiguration: { awsvpcConfiguration: { subnets: [subnetPrivateAz1.ref], assignPublicIp: 'DISABLED', securityGroups: [securityGroupPrivate.ref], }, }, launchType: 'FARGATE', }); } }
3.環境構築
IAMロールを利用して一時的にシステム構成を変更するための権限を持ったセッションを発行してCDKを実行します。IAMロールのセッション持続時間を短かくして、2段階認証を必須にすることでローカル環境からセキュアにCDKを実行出来ます。
. enable_aws_cli.sh xxxxxx cdk deploy --all
enable_aws_cli.sh
#!/bin/bash TOKEN_CODE=$1 if [ -z "$TOKEN_CODE" ]; then echo "Usage: $0" exit 1 fi # aws sts assume-roleコマンドを実行 OUTPUT=$(aws sts assume-role --profile demo-profile --role-arn arn:aws:iam::xxxxxxxxxxxx:role/demo-role --role-session-name DemoSession --serial-number arn:aws:iam::xxxxxxxxxxxx:mfa/aws-user-login --token-code $TOKEN_CODE) AWS_ACCESS_KEY_ID=$(echo $OUTPUT | jq -r '.Credentials.AccessKeyId') AWS_SECRET_ACCESS_KEY=$(echo $OUTPUT | jq -r '.Credentials.SecretAccessKey') AWS_SESSION_TOKEN=$(echo $OUTPUT | jq -r '.Credentials.SessionToken') export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY export AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN
4.SSMポートフォワードの検証
quiverさんの記事により、Fargateのssmのtargetにはecs:クラスター名_タスクID_ランタイムIDを指定すれば良いので、aws ecs describe-tasksを実行してコンテナのランタイムIDを取得してssmコマンドを実行します。
$ aws ssm start-session --target ecs:demoEcsCluster_XXX_XXX-YYY An error occurred (TargetNotConnected) when calling the StartSession operation: ecs:demoEcsCluster_XXX_XXX-YYY is not connected.
構成に不備があるためか、エラーになり接続出来ませんでした。接続出来れば以下のポートフォワードに変更してローカルの10080でFargate内のNginxに接続出来る見込みです。
$ aws ssm start-session --target ecs:demoEcsCluster_XXX_XXX-YYY --document-name AWS-StartPortForwardingSession --parameters '{"host":["172.31.XXX.YYY"],"portNumber":["80"],"localPortNumber":["10080"]}'
同じサブネット内にFargateと同じロールを割当ててEC2インスタンスを作成してSSMが繋がるか確認してみます。
$ aws ssm start-session --target i-XXX Starting session with SessionId: CdkDeployerDevSession-YYY
Nginxへの接続を確認してみます。
$ sh-5.2$ curl http://172.31.0.10 ...Welcome to nginx!
... ...
ローカルPCからのVPCの接続とSSMを実行するための権限は問題なさそうです。他の原因を調査する必要がありそうです。
5.まとめ
FargateへのSSM Session Managerの設定に不備があり、ポートフォワードの検証がうまく出来ませんでした。SSM Session Managerを利用することでインターネットからのインバウンドアクセスが不要で開発環境にアクセス出来たり、操作ログも残せるようになりセキュリティの向上に繋がるため引き続き検証して導入を進めたいと思います。
次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。
皆さんのご応募をお待ちしています。
参考リンク
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD