AWS Transfer Family for SFTP環境を構築した話

## はじめに
先日、外部システムとの連携用にAWS Transfer Family for SFTPの環境を構築しました。
この構築の中で対応したことを記載しています。

## 構成概要
まず、構築したい環境は以下のようなイメージにでした。
※ 実際は複数のシステムから接続されるためもう少し複雑ですが、簡略化しています。

![sftp-概要.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231658/1d79c198-3572-a1c3-d567-460767d9661e.png)

1. インターネットにTransfer Family for SFTPを公開して、外部システム（SFTPクライアント）から接続可能とする
2. 外部システムからファイルを格納するフォルダ(request/）と、外部システムがファイルを取得するためのフォルダ(response/）を準備しておく
3. `request/`にファイルが格納されたタイミングでLambdaを起動し、処理した結果は`response/`に格納する
4. SFTPの格納ファイル（S3バケットのオブジェクト）は１カ月後にファイル整理を行う
   ※ ただし、フォルダ(request/とresponse/）は削除対象外とする

## Transfer Family for SFTPの設定
### プロトコル/認証方式の検討
今回のプロトコルはSFTPで、認証方式はSSH キーに決まっていました。
この辺りは接続元システムの仕様も踏まえて決める必要があります。

### ユーザー管理方法
ユーザー管理に特別な要件は無かったため、サービスマネージド（Transfer Familyサービス内でユーザーを管理する方法）としました。

### 利用するエンドポイントタイプ
最初にTransfer Family for SFTPをインターネットに公開するにあたり、利用するエンドポイントを検討しました。

本記事執筆時点で、AWS Transfer Familyのエンドポイントタイプは全部で４種類あります。
* Public Endpoint
* VPC(internet-facing)
* VPC(internal access)
* VPC_ENDPOINT

https://repost.aws/ja/knowledge-center/aws-sftp-endpoint-type

各エンドポイントについてはクラスメソッドさんの以下の記事でわかりやすく説明されていましたのでリンクを貼っておきます。

https://dev.classmethod.jp/articles/trasfer-for-sftp-endpoint-types-202004/

このうち、`VPC_ENDPOINT`は非推奨となっており、新しいサーバーの作成には利用できなくなっています。
よって、残りの３つから選択することになります。
※ リンク先を見ると分かるように、SFTPは全エンドポイントでサポートされています。

`Public Endpoint`はVPC等の作成が不要で、一番簡単にインターネット向けの環境を構築することができます。
ただし、接続元IPの制限に対応していません。

次に`VPC(internet-facing)`ですが、このエンドポイントはEIPとPublic Subnetに自動生成されるVPC Endpointとを紐づけることでインターネットへ公開します。

![sftp-PublicEndpoint.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231658/59ce7a1d-f76b-8dbe-9e7e-9e8279b00750.png)

また、接続元IPの制限はエンドポイントのセキュリティグループで制御可能です。

https://aws.amazon.com/jp/blogs/news/use-ip-whitelisting-to-secure-your-aws-transfer-for-sftp-servers/


最後に`VPC(internal access) `ですが、このエンドポイントは基本的にVPC内部からアクセスする際に利用します。
ただし、internet-facingのNLBを前段に配置することでインターネットに公開することが可能です。
NLBのターゲットグループにTransfer Family for SFTPのENIを指定することでインターネットからのアクセスをTransfer Familyに転送します。

また、接続元IP制限は以下のどちらかで行います。
* NLBを配備するSubnetのNACL
* NLBのセキュリティグループ

以下のイメージです。

![sftp-internal_facing.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231658/a5068284-ad31-edf2-67fc-416af51a8e4f.png)

ちなみに、元々NLBにセキュリティグループは設定できませんでしたが、2023/8にサポートが開始されました。

https://aws.amazon.com/jp/about-aws/whats-new/2023/08/network-load-balancer-supports-security-groups/

ただし、セキュリティグループをNLBに関連付けることができるのはNLBを作成するときのみです。作成後に関連付けることはできません。

https://docs.aws.amazon.com/elasticloadbalancing/latest/network/load-balancer-security-groups.html

> 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) `を選択しましたが、<strong>基本的にインターネットへ公開する場合は`VPC(internet-facing)`を選択する</strong>ので問題ないと思います。

もし何かの理由で`VPC(internal access) `が選択できない場合でも、<strong>`VPC(internal access) + NLB(internet-facing)`で対応することも可能</strong>です。


## NLBターゲットグループの設定
`VPC(internal access) + NLB(internet-facing)`の構成を選択したため、NLBのターゲットグループにTransfer FamilyのENIに付与されるプライベートIPアドレスを設定する必要があります。

環境はCloudFormationで構築・管理する方針でしたが、CloudFormationの標準機能でSFTPサーバーのENIに設定されるIPアドレスを取得する方法がありませんでした。

そのため、CloudFormationのカスタムリソースを使ってIPアドレスを取得する処理を実装しました。

以下は関連部分の実装イメージです。

```yaml
#-----------------------------------------------#
# 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のカスタムリソースを使って構築しました。

以下、関連部分の実装イメージです。

```yaml
#-----------------------------------------------#
# 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のファイル）は一定期間（１カ月）経過後に削除する必要がありました。
ただし、フォルダオブジェクトは削除対象外にする必要もありました。

この対応としてオブジェクトサイズで識別することとしました。
前提としてまず、S3バケットのフォルダオブジェクトは 0 バイトのオブジェクトになります。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-folders.html

> Amazon S3 にフォルダを作成すると、S3 は、指定したフォルダ名に設定されたキーを持つ 0 バイトのオブジェクトを作成します。例えば、バケットに photos という名前のフォルダを作成した場合、Amazon S3 コンソールは photos/ キーを使用して 0 バイトのオブジェクトを作成します。コンソールは、フォルダの考え方をサポートするために、このオブジェクトを作成します。

よって、通常のオブジェクトとフォルダオブジェクトは 0 バイトかどうかで識別できます。
この条件をS3バケットのライフサイクル設定に指定することで対応しました。

具体的には`ObjectSizeGreaterThan`プロパティに1（サイズが 1 バイト以上のオブジェクトが対象）を指定します。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket-rule.html#cfn-s3-bucket-rule-objectsizegreaterthan

以下、指定イメージです。

```yaml
  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へのイベント通知を有効化します。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket-eventbridgeconfiguration.html

```yaml
      NotificationConfiguration:
        EventBridgeConfiguration:
          EventBridgeEnabled: true
```

続いて`AWS::EventBridge::Rule`の`EventPattern`でkeyに`prefix: request`を指定し、TargetsにLambdaを指定しました。
これにより、requestフォルダ配下にオブジェクトが格納されたときにLambdaを起動することができます。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-events-rule.html

```yaml
  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バケットのフォルダ作成やライフサイクル設定など、構築や運用を考えると対応項目は色々あると感じました。

本記事が誰かのお役に立てれば幸いです。