はじめに
先日、外部システムとの連携用にAWS Transfer Family for SFTPの環境を構築しました。
この構築の中で対応したことを記載しています。
構成概要
まず、構築したい環境は以下のようなイメージにでした。
※ 実際は複数のシステムから接続されるためもう少し複雑ですが、簡略化しています。
- インターネットにTransfer Family for SFTPを公開して、外部システム(SFTPクライアント)から接続可能とする
- 外部システムからファイルを格納するフォルダ(request/)と、外部システムがファイルを取得するためのフォルダ(response/)を準備しておく
-
request/
にファイルが格納されたタイミングでLambdaを起動し、処理した結果はresponse/
に格納する - SFTPの格納ファイル(S3バケットのオブジェクト)は1カ月後にファイル整理を行う
※ ただし、フォルダ(request/とresponse/)は削除対象外とする
Transfer Family for SFTPの設定
プロトコル/認証方式の検討
今回のプロトコルはSFTPで、認証方式はSSH キーに決まっていました。
この辺りは接続元システムの仕様も踏まえて決める必要があります。
ユーザー管理方法
ユーザー管理に特別な要件は無かったため、サービスマネージド(Transfer Familyサービス内でユーザーを管理する方法)としました。
利用するエンドポイントタイプ
最初にTransfer Family for SFTPをインターネットに公開するにあたり、利用するエンドポイントを検討しました。
本記事執筆時点で、AWS Transfer Familyのエンドポイントタイプは全部で4種類あります。
- Public Endpoint
- VPC(internet-facing)
- VPC(internal access)
- VPC_ENDPOINT
各エンドポイントについてはクラスメソッドさんの以下の記事でわかりやすく説明されていましたのでリンクを貼っておきます。
このうち、VPC_ENDPOINT
は非推奨となっており、新しいサーバーの作成には利用できなくなっています。
よって、残りの3つから選択することになります。
※ リンク先を見ると分かるように、SFTPは全エンドポイントでサポートされています。
Public Endpoint
はVPC等の作成が不要で、一番簡単にインターネット向けの環境を構築することができます。
ただし、接続元IPの制限に対応していません。
次にVPC(internet-facing)
ですが、このエンドポイントはEIPとPublic Subnetに自動生成されるVPC Endpointとを紐づけることでインターネットへ公開します。
また、接続元IPの制限はエンドポイントのセキュリティグループで制御可能です。
最後にVPC(internal access)
ですが、このエンドポイントは基本的にVPC内部からアクセスする際に利用します。
ただし、internet-facingのNLBを前段に配置することでインターネットに公開することが可能です。
NLBのターゲットグループにTransfer Family for SFTPのENIを指定することでインターネットからのアクセスをTransfer Familyに転送します。
また、接続元IP制限は以下のどちらかで行います。
- NLBを配備するSubnetのNACL
- NLBのセキュリティグループ
以下のイメージです。
ちなみに、元々NLBにセキュリティグループは設定できませんでしたが、2023/8にサポートが開始されました。
ただし、セキュリティグループをNLBに関連付けることができるのはNLBを作成するときのみです。作成後に関連付けることはできません。
You can associate security groups with a Network Load Balancer when you create it. If you create a Network Load Balancer without associating any security groups, you can't associate them with the load balancer later on. We recommend that you associate a security group with your load balancer when you create it.
検討ではまずPublic Endpoint
は接続元IPの制限に対応していないため、不採用としました。
VPC(internet-facing)
とVPC(internal access) + NLB(internet-facing)
は、どちらを選択しても構築可能でしたが、エンドポイントの仕様とは関係ない個別の事情によりVPC(internal access) + NLB(internet-facing)
を選択しました。
今回はVPC(internal access)
を選択しましたが、基本的にインターネットへ公開する場合はVPC(internet-facing)
を選択するので問題ないと思います。
もし何かの理由でVPC(internal access)
が選択できない場合でも、VPC(internal access) + NLB(internet-facing)
で対応することも可能です。
NLBターゲットグループの設定
VPC(internal access) + NLB(internet-facing)
の構成を選択したため、NLBのターゲットグループにTransfer FamilyのENIに付与されるプライベートIPアドレスを設定する必要があります。
環境はCloudFormationで構築・管理する方針でしたが、CloudFormationの標準機能でSFTPサーバーのENIに設定されるIPアドレスを取得する方法がありませんでした。
そのため、CloudFormationのカスタムリソースを使ってIPアドレスを取得する処理を実装しました。
以下は関連部分の実装イメージです。
#-----------------------------------------------#
# NLB Target Group
#-----------------------------------------------#
NLBTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
# AWS::Transfer::Serverの作成を待つ
DependsOn: SFTPServer
Properties:
Name: nlb-targetgroup
# -------
# HealthCheck等の設定は省略
# -------
TargetType: ip
Targets:
# Custom Resourceの処理結果を設定する
- AvailabilityZone: !GetAtt SFTPServerIPs.AvailabilityZone0
Id: !GetAtt SFTPServerIPs.PrivateIpAddress0
Port: 22
- AvailabilityZone: !GetAtt SFTPServerIPs.AvailabilityZone1
Id: !GetAtt SFTPServerIPs.PrivateIpAddress1
Port: 22
- AvailabilityZone: !GetAtt SFTPServerIPs.AvailabilityZone2
Id: !GetAtt SFTPServerIPs.PrivateIpAddress2
Port: 22
#-----------------------------------------------#
# Lambda Custom Resource
# TransferSFTP ServerのEndpointからプライベートIPアドレスを取得する
#-----------------------------------------------#
SFTPServerIPs:
Type: Custom::SFTPServerIPs
Properties:
ServiceToken: !GetAtt RetrieveSFTPServerIPsFunction.Arn
# AWS::Transfer::ServerのServerIdを渡す
ServerId: !GetAtt SFTPServer.ServerId
RetrieveSFTPServerIPsFunction:
Type: AWS::Lambda::Function
DependsOn:
- RetrieveSFTPServerIPsLogGroup
# AWS::Transfer::Serverの作成を待つ
- SFTPServer
Properties:
FunctionName: retrieve-sftp-server-ips-function
Handler: index.handler
Role: !GetAtt RetrieveSFTPServerIPsLambdaRole.Arn
Code:
ZipFile: |
const {
TransferClient,
DescribeServerCommand,
} = require("@aws-sdk/client-transfer");
const {
EC2Client,
DescribeVpcEndpointsCommand,
DescribeNetworkInterfacesCommand,
} = require("@aws-sdk/client-ec2");
const response = require("cfn-response");
exports.handler = async (event, context) => {
try {
console.log("Received event:\n" + JSON.stringify(event));
if (event.RequestType === "Delete") {
await response.send(event, context, response.SUCCESS);
return;
}
if (event.RequestType !== "Create" && event.RequestType !== "Update") {
await response.send(event, context, response.FAILED);
return;
}
// Transfer Server情報取得
const transferClient = new TransferClient({});
const describeServerCommand = new DescribeServerCommand({
ServerId: event.ResourceProperties.ServerId,
});
const describeServerResponse = await transferClient
.send(describeServerCommand)
.catch((err) => {
response.send(event, context, response.FAILED);
return;
});
const vpcEndpointId =
describeServerResponse.Server.EndpointDetails.VpcEndpointId;
// ENI ID情報取得
const ec2Client = new EC2Client({});
const describeVPCEndpointsCommand = new DescribeVpcEndpointsCommand({
VpcEndpointIds: [vpcEndpointId],
});
const describeVPCEndpointsResponse = await ec2Client
.send(describeVPCEndpointsCommand)
.catch((err) => {
response.send(event, context, response.FAILED);
return;
});
const networkInterfaceIds =
describeVPCEndpointsResponse.VpcEndpoints[0].NetworkInterfaceIds;
const describeNetworkInterfacesCommand =
new DescribeNetworkInterfacesCommand({
NetworkInterfaceIds: networkInterfaceIds,
});
const describeNetworkInterfacesResponse = await ec2Client
.send(describeNetworkInterfacesCommand)
.catch((err) => {
response.send(event, context, response.FAILED);
return;
});
const responseData = {
PrivateIpAddress0: describeNetworkInterfacesResponse.NetworkInterfaces[0].PrivateIpAddress,
AvailabilityZone0: describeNetworkInterfacesResponse.NetworkInterfaces[0].AvailabilityZone,
PrivateIpAddress1: describeNetworkInterfacesResponse.NetworkInterfaces[1].PrivateIpAddress,
AvailabilityZone1: describeNetworkInterfacesResponse.NetworkInterfaces[1].AvailabilityZone,
PrivateIpAddress2: describeNetworkInterfacesResponse.NetworkInterfaces[2].PrivateIpAddress,
AvailabilityZone2: describeNetworkInterfacesResponse.NetworkInterfaces[2].AvailabilityZone,
};
await response.send(event, context, response.SUCCESS, responseData);
} catch (error) {
console.log(error);
await response.send(event, context, response.FAILED);
}
};
Runtime: nodejs18.x
Timeout: 30
RetrieveSFTPServerIPsLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: retrieve-sftp-server-ips-lambda-role
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: root
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !GetAtt RetrieveSFTPServerIPsLogGroup.Arn
- Effect: Allow
Action:
- transfer:DescribeServer
- ec2:DescribeVpcEndpoints
- ec2:DescribeNetworkInterfaces
Resource: "*"
S3バケットのフォルダ作成
外部システムからのSFTP接続時に以下のフォルダを表示させる必要がありました。
/
|- request/
|
|- response/
これを実現するためには、事前に S3 側でフォルダオブジェクトを作成しておく必要があります。
CLIやマネジメントコンソールで作成する方法もありますが、他のリソースと同様にCloudFormationで管理したいため、フォルダオブジェクトの作成もCloudFormationのカスタムリソースを使って構築しました。
以下、関連部分の実装イメージです。
#-----------------------------------------------#
# Lambda Custom Resource
# S3バケットのフォルダを確認し、無い場合は作成する
#-----------------------------------------------#
CreateS3Folder:
Type: Custom::CreateS3Folder
Properties:
ServiceToken: !GetAtt CreateS3FolderFunction.Arn
BucketName: sftp-bucket
Folders:
- request/
- response/
CreateS3FolderFunction:
Type: AWS::Lambda::Function
DependsOn: CreateS3FolderLogGroup
Properties:
FunctionName: create-s3-folder-function
Handler: index.handler
Role: !GetAtt CreateS3FolderLambdaRole.Arn
Code:
ZipFile: |
const {
S3Client,
ListObjectsV2Command,
PutObjectCommand,
} = require("@aws-sdk/client-s3");
const response = require("cfn-response");
exports.handler = async (event, context) => {
try {
console.log("Received event:\n" + JSON.stringify(event));
if (event.RequestType === "Delete") {
await response.send(event, context, response.SUCCESS);
return;
}
if (event.RequestType !== "Create" && event.RequestType !== "Update") {
await response.send(event, context, response.FAILED);
return;
}
const bucketName = event.ResourceProperties.BucketName;
const folders = event.ResourceProperties.Folders;
const s3Client = new S3Client({});
for (const folder of folders) {
const listObjectCommand = new ListObjectsV2Command({
Bucket: bucketName,
Prefix: folder,
});
const listObjectResponse = await s3Client
.send(listObjectCommand)
.catch((err) => {
response.send(event, context, response.FAILED);
return;
});
if (
listObjectResponse["Contents"] &&
listObjectResponse.Contents.length >= 0
)
continue;
const putObjectCommand = new PutObjectCommand({
Bucket: bucketName,
Key: folder,
});
await s3Client.send(putObjectCommand).catch((err) => {
response.send(event, context, response.FAILED);
return;
});
}
await response.send(event, context, response.SUCCESS);
} catch (error) {
await response.send(event, context, response.FAILED);
}
};
Runtime: nodejs18.x
Timeout: 30
# Lambda Role:
CreateS3FolderLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: create-s3-folder-lambda-role
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: root
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !GetAtt CreateS3FolderLogGroup.Arn
- Effect: Allow
Action:
- s3:ListBucket
- s3:PutObject
Resource: "*"
S3バケットのライフサイクルポリシー設定
S3バケットのファイル(SFTPのファイル)は一定期間(1カ月)経過後に削除する必要がありました。
ただし、フォルダオブジェクトは削除対象外にする必要もありました。
この対応としてオブジェクトサイズで識別することとしました。
前提としてまず、S3バケットのフォルダオブジェクトは 0 バイトのオブジェクトになります。
Amazon S3 にフォルダを作成すると、S3 は、指定したフォルダ名に設定されたキーを持つ 0 バイトのオブジェクトを作成します。例えば、バケットに photos という名前のフォルダを作成した場合、Amazon S3 コンソールは photos/ キーを使用して 0 バイトのオブジェクトを作成します。コンソールは、フォルダの考え方をサポートするために、このオブジェクトを作成します。
よって、通常のオブジェクトとフォルダオブジェクトは 0 バイトかどうかで識別できます。
この条件をS3バケットのライフサイクル設定に指定することで対応しました。
具体的にはObjectSizeGreaterThan
プロパティに1(サイズが 1 バイト以上のオブジェクトが対象)を指定します。
以下、指定イメージです。
SFTPBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub sftp-bucket-${AWS::AccountId}
LifecycleConfiguration:
Rules:
- Id: Expiration
ExpirationInDays: 30
NoncurrentVersionExpiration:
NoncurrentDays: 30
#フォルダを対象外とするため、0バイトのオブジェクトをライフサイクルの対象外とする(フォルダオブジェクトは 0 バイト)
ObjectSizeGreaterThan: 1
Status: Enabled
VersioningConfiguration:
Status: Enabled
ファイル格納時のLamba起動設定
requestフォルダ配下にファイルが格納された際にLambdaを起動する処理は、S3およびEventBridgeの設定で対応しました。
まず、S3バケットのNotificationConfiguration
でEventBridgeへのイベント通知を有効化します。
NotificationConfiguration:
EventBridgeConfiguration:
EventBridgeEnabled: true
続いてAWS::EventBridge::Rule
のEventPattern
でkeyにprefix: request
を指定し、TargetsにLambdaを指定しました。
これにより、requestフォルダ配下にオブジェクトが格納されたときにLambdaを起動することができます。
SFTPPutEventNotificationRule:
Type: AWS::Events::Rule
Properties:
Name: sftp-put-event-rule
State: ENABLED
EventPattern:
source:
- aws.s3
detail-type:
- Object Created
detail:
bucket:
name:
- !Sub sftp-bucket-${AWS::AccountId}
object:
key:
- prefix: request
Targets:
- Arn: !GetAtt SFTPPutEventFunction.Arn
Id: sftp-put-event-function
RetryPolicy:
MaximumRetryAttempts: 3
MaximumEventAgeInSeconds: 3600
まとめ
AWS Transfer Family for SFTP環境構築時に対応した内容を記載しました。
AWS Transfer Family for SFTP自体は簡単に利用できるサービスですが、エンドポイントの選択やそれに伴うネットワークの検討、S3バケットのフォルダ作成やライフサイクル設定など、構築や運用を考えると対応項目は色々あると感じました。
本記事が誰かのお役に立てれば幸いです。