プライベートな環境でTransfer Familyを利用したFTPサーバの構成を考えてみた

# はじめに
Transfer Familyを利用したFTP連携に触れる機会があったので、これを機にまとめてみたいと思います。

# 目次
[Transfer Familyとは](#Transfer-Familyとは)
[Transfer Familyを利用する際の注意事項](#Transfer-Familyを利用する際の注意事項)
[検証構成](#検証構成)
[構成の前提](#構成の前提)
[事前準備](#事前準備)
[検証①(VPCエンドポイント経由でFTP転送するパターン)](#検証①(VPCエンドポイント経由でFTP転送するパターン))
[NLBを挟む構成を考えてみた](#NLBを挟む構成を考えてみた)
[検証②（NLBを各AZに構築したRoute53のフェールオーバー構成パターン）](#検証②（NLBを各AZに構築したRoute53のフェールオーバー構成パターン）)
[所感](#所感)

# Transfer Familyとは
AWSユーザガイドに書かれているように一言で言うと「AWSストレージサービスとの間でファイルを送受信できる安全な転送サービスです。」です。SFTPやFTPなどのプロトコルに対応しているマネージドサービスと言う感じです。

https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/what-is-aws-transfer-family.html

# Transfer Familyを利用する際の注意事項
FTPを利用する場合、Transfer Familyの制約がいくつかあります。利用の際はご注意ください。
パッシブモードのみサポートやイメージ/バイナリモードのみがサポートなどあります。
いざ触ってみるとこの辺にハマりました・・・（最初に制約は見るべし！）

https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/transfer-file.html

# 検証構成
下記の構成を構築してみました。
構築作業は事前作業の中で記載します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/46a5402c-5c2e-23a5-7983-f457aeb50a70.png)

# 構成の前提
- VPC1とVPC２をpeeringした構成にしてプライベート環境を構築しました。
- Transfer Familyも色々な認証方法と配置先（S3/EFS）が選べますが、シンプルなカスタムIDプロバイダー(Lambda)＋S3を利用した構成にしました
- 検証用のEC2からFTP操作します。
- NAT GatewayとInternet Gatewayを作ったのはEC2にモジュールをインストールに便利だったためで、FTP検証にはなくても問題ないです。

# 事前準備
### VPC1環境構築
基本的にCloudFormationで構築しました。ググればこの辺は大体出てきます。先人の方に感謝です。

少し脱線しますが、この辺をAmazon Qに聞いてみたらフォーマットが崩れる・・・
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/b2ddec10-67fc-2ee7-bd36-daee61e655c6.png)

<details><summary>VPC1を構築するYAML</summary>

```yaml:vpc1.yml
AWSTemplateFormatVersion: 2010-09-09

Resources:
  CfVPC1:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.1.0.0/16
      EnableDnsSupport: "true"
      EnableDnsHostnames: "true"
      Tags:
        -
          Key: Name
          Value: CfVPC1

  CfVPC1PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.1.1.0/24
      MapPublicIpOnLaunch: true
      VpcId: !Ref CfVPC1
      AvailabilityZone: 
        Fn::Select: 
          - 0
          - Fn::GetAZs: ""
      Tags:
        - Key: Name
          Value: CfVPC1PublicSubnet
  CfVPC1PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.1.2.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref CfVPC1
      AvailabilityZone: 
        Fn::Select: 
          - 1
          - Fn::GetAZs: ""
      Tags:
        - Key: Name
          Value: CfVPC1PrivateSubnet

  CfVPC1InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: CfVPC1InternetGateway

  AttachCfInternetGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId : !Ref CfVPC1InternetGateway
      VpcId: !Ref CfVPC1

  CfVPC1RouteTableForPublicSubnet:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref CfVPC1
      Tags:
        - Key: Name
          Value: CfVPC1RouteTableForPublicSubnet

  CfVPC1RouteForPublicSubnet:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref CfVPC1RouteTableForPublicSubnet
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref CfVPC1InternetGateway

  CfVPC1AssocciateRouteTableForPublicSubnet:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref CfVPC1RouteTableForPublicSubnet
      SubnetId: !Ref CfVPC1PublicSubnet

  NatGatewayEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  CfVPC1NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId:
        Fn::GetAtt:
          - NatGatewayEIP
          - AllocationId
      SubnetId: !Ref CfVPC1PublicSubnet
      Tags:
        - Key: Name
          Value: CfVPC1NatGateway

  CfVPC1RouteTableForPrivateSubnet:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref CfVPC1
      Tags:
        - Key: Name
          Value: CfVPC1RouteTableForPrivateSubnet

  CfVPC1RouteForPrivateSubnet:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref CfVPC1RouteTableForPrivateSubnet
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref CfVPC1NatGateway

  CfVPC1AssocciateRouteTableForPrivateSubnet:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref CfVPC1RouteTableForPrivateSubnet
      SubnetId: !Ref CfVPC1PrivateSubnet
```
</details>

NW環境ができたら、検証用のEC2（linux）とVPCエンドポイント経由でアクセスできるSSM環境を構築するCloudFormationを流します。

<details><summary>VPC1のEC2とSSMを構築するYAML</summary>

```yaml:vpc1-ec2-ssm.yml
AWSTemplateFormatVersion: 2010-09-09

Parameters:
  VPCId:
    Type: String
  SubnetId:
    Type: String
  KeyPairName:
    Type: String
  LinuxLatestAmi:
    Description: "EC2 AMI image SSM path"
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
    AllowedValues:
      - /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2


Resources:
  VPC1EC2SG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Bastion EC2 Security Group"
      VpcId: !Ref VPCId
      Tags:
        - Key: Name
          Value: private-ec2-bastion-sg

  VPC1EndpointSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Bastion Endpoint Security Group"
      VpcId: !Ref VPCId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          SourceSecurityGroupId: !Ref VPC1EC2SG
      Tags:
        - Key: Name
          Value: private-endpoint-bastion-sg
  IamRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: private-ec2-bastion-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        
  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref IamRole

  VPC1NewKeyPair:
    Type: "AWS::EC2::KeyPair"
    Properties:
      KeyName: !Ref KeyPairName

  VPC1EC2Instance:
    Type: "AWS::EC2::Instance"
    Properties:
      Tags:
        - Key: Name
          Value: private-bastion
      ImageId: !Ref LinuxLatestAmi
      InstanceType: t3.medium
      KeyName: !Ref VPC1NewKeyPair
      DisableApiTermination: false
      EbsOptimized: false
      SecurityGroupIds:
        - !Ref VPC1EC2SG
      SubnetId: !Ref SubnetId
      IamInstanceProfile: !Ref InstanceProfile

  VPC1SSMVPCEndpoint:
    Type: "AWS::EC2::VPCEndpoint"
    Properties: 
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ssm'
      VpcId: !Ref VPCId
      SubnetIds: 
      - !Ref SubnetId
      SecurityGroupIds:
        - !Ref VPC1EndpointSG
  
  VPC1SSMMessagesVPCEndpoint:
    Type: "AWS::EC2::VPCEndpoint"
    Properties: 
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ssmmessages'
      VpcId: !Ref VPCId
      SubnetIds: 
      - !Ref SubnetId
      SecurityGroupIds:
        - !Ref VPC1EndpointSG

  VPC1EC2MessagesVPCEndpoint:
    Type: "AWS::EC2::VPCEndpoint"
    Properties: 
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ec2messages'
      VpcId: !Ref VPCId
      SubnetIds: 
      - !Ref SubnetId
      SecurityGroupIds:
        - !Ref VPC1EndpointSG
```
</details>

### VPC2環境構築
こちらも同じく、CloudFormationで構築しました。

<details><summary>VPC2のNWを構築するYAML</summary>

```yaml:vpc2.yml
AWSTemplateFormatVersion: 2010-09-09

Resources:
  CfVPC2:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.2.0.0/16
      EnableDnsSupport: "true"
      EnableDnsHostnames: "true"
      Tags:
        -
          Key: Name
          Value: CfVPC2

  CfVPC2PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.2.1.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref CfVPC2
      AvailabilityZone: "ap-northeast-1a"
      Tags:
        - Key: Name
          Value: CfVPC2PrivateSubnet1

  CfVPC2PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.2.2.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref CfVPC2
      AvailabilityZone: "ap-northeast-1c"
      Tags:
        - Key: Name
          Value: CfVPC2PrivateSubnet2

  CfVPC2PrivateSubnet3:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.2.3.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref CfVPC2
      AvailabilityZone: "ap-northeast-1a"
      Tags:
        - Key: Name
          Value: CfVPC2PrivateSubnet3

  CfVPC2PrivateSubnet4:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.2.4.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref CfVPC2
      AvailabilityZone: "ap-northeast-1c"
      Tags:
        - Key: Name
          Value: CfVPC2PrivateSubnet4
```
</details>

### VPC peering構築
この辺から触り慣れていないので、手作業で作りました。
VPC1とVPC2をピアリング設定を行います。
同一アカウントですのでリクエストの承認は右上のアクションタブから承認を押すだけです。

VPC1のルートテーブルにVPC2のCIDRを追加しました。
| 送信先 | ターゲット |
|:------------:|:------------:|
| 10.2.0.0/16       | ピアリングのID        |

VPC2のルートテーブルにVPC1のプライベートサブネットを追加しました。
| 送信先 | ターゲット |
|:------------:|:------------:|
| 10.1.2.0/24       | ピアリングのID        |

### Route53のプライベートホストゾーン構築
検証②で利用するため、適当なプライベートホストゾーンを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/bda74d52-8485-7cd4-a80a-4de8a2c136da.png)

### Transfer Familyで利用するセキュリティグループの構築
インバウンドルールに関しては21、8192-8200ポートをアクセスさせたいところから開けておきます。
今回はVPC1のプライベートサブネットとVPC2のプライベートサブネットからアクセスできるようにしています。
アウトバウンドルールは全部開けです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/9150ac9a-76c6-df4f-34d4-262ead3e5e0c.png)

### Transfer fmilyで利用するS3の構築
FTP連携されてきたファイルを保存するストレージ用にS3バケットを構築します。
特に追加の設定は必要なく、デフォルトの設定にしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/dbcf5267-1cc4-2ccc-e9f5-8ec99bdba488.png)

### Transfer FmailyのFTP認証で利用するIAMロールとポリシーの構築

先ほど構築したS3バケットに対してアクセスできる権限を設定をします。
```json:IAM Policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::transferfamily-test-bucket",
                "arn:aws:s3:::transferfamily-test-bucket/*"
            ]
        },
        {
            "Effect": "Deny",
            "Action": [
                "s3:DeleteBucket",
                "s3:CreateBucket"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

「カスタム信頼ポリシー」の設定でIAMロールを作成します。
信頼されたエンティティは下記にしています。
先ほど構築したIAMポリシーをIAMロールにアタッチします。

```json:信頼されたエンティティ
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "transfer.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
```

### Transfer Family、VPCエンドポイント、Lambdaの構築
Transfer FamilyのカスタムIDプロバイダーを利用するスタックテンプレートはAWSからサンプル提供があるので、それを使います。

https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/custom-lambda-idp.html

サンプルのCloudFormationにプライベート環境で利用するため、必要なネットワーク情報の追加、利用するストレージの追加、対象プロトコルを書き加えました。

Cloudformationを実行時に指定するパラメータは下記になります。
| キー | 値 |
|:------------:|:------------:|
| CreateServer      | True        |
| SecretsManagerRegion       | ap-northeast-1	        |
| SubnetIds       | VPC2の中でエンドポイントを配置したいサブネットを指定        |
| VpcId       | VPC2のVPCを指定        |

<details><summary>VPC2にTransfer Family、VPCエンドポイント、Lambdaを構築するYAML</summary>

```yaml:transferfamily.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: A basic template that uses AWS Lambda with an AWS Transfer Family server
  to integrate SecretsManager as an identity provider. It authenticates against an
  entry in AWS Secrets Manager of the format SFTP/username. Additionally, the secret
  must hold the key-value pairs for all user properties returned to AWS Transfer Family.
  You can also modify the AWS Lambda function code to update user access.
Parameters:
  CreateServer:
    AllowedValues:
      - 'true'
      - 'false'
    Type: String
    Description: Whether this stack creates a server internally or not. If a server is created internally,
      the customer identity provider is automatically associated with it.
    Default: 'true'
  SecretsManagerRegion:
    Type: String
    Description: (Optional) The region the secrets are stored in. If this value is not provided, the
      region this stack is deployed in will be used. Use this field if you are deploying this stack in
      a region where SecretsManager is not available.
    Default: ''
  SecurityGroup:
    Type: AWS::EC2::SecurityGroup::Id
  SubnetIds:
    Type: List<AWS::EC2::Subnet::Id>
  VpcId:
    Type: AWS::EC2::VPC::Id
Conditions:
  CreateServer:
    Fn::Equals:
      - Ref: CreateServer
      - 'true'
  NotCreateServer:
    Fn::Not:
      - Condition: CreateServer
  SecretsManagerRegionProvided:
    Fn::Not:
      - Fn::Equals:
          - Ref: SecretsManagerRegion
          - ''
Outputs:
  ServerId:
    Value:
      Fn::GetAtt: TransferServer.ServerId
    Condition: CreateServer
  StackArn:
    Value:
      Ref: AWS::StackId
Resources:
  TransferServer:
    Type: AWS::Transfer::Server
    Condition: CreateServer
    Properties:
      Domain: S3
      Protocols:
        - FTP    
    Properties:
      EndpointType: VPC
      EndpointDetails:
        VpcId: !Ref VpcId
        SecurityGroupIds:
          - !Ref SecurityGroup
        SubnetIds:
          - !Select [0, !Ref SubnetIds]
          - !Select [1, !Ref SubnetIds]      
      IdentityProviderDetails:
        Function:
          Fn::GetAtt: GetUserConfigLambda.Arn
      IdentityProviderType: AWS_LAMBDA
      LoggingRole:
        Fn::GetAtt: CloudWatchLoggingRole.Arn
  CloudWatchLoggingRole:
    Description: IAM role used by Transfer  to log API requests to CloudWatch
    Type: AWS::IAM::Role
    Condition: CreateServer
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - transfer.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: TransferLogsPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:DescribeLogStreams
                  - logs:PutLogEvents
                Resource:
                  Fn::Sub: '*'
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      ManagedPolicyArns:
      - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
      - PolicyName: LambdaSecretsPolicy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - secretsmanager:GetSecretValue
            Resource:
              Fn::Sub:
                - arn:${AWS::Partition}:secretsmanager:${SecretsRegion}:${AWS::AccountId}:secret:aws/transfer/*
                - SecretsRegion:
                    Fn::If:
                      - SecretsManagerRegionProvided
                      - Ref: SecretsManagerRegion
                      - Ref: AWS::Region
  GetUserConfigLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile:
          Fn::Sub: |
            import os
            import json
            import boto3
            import base64
            from botocore.exceptions import ClientError

            def lambda_handler(event, context):
                resp_data = {}

                if 'username' not in event or 'serverId' not in event:
                    print("Incoming username or serverId missing  - Unexpected")
                    return response_data

                # It is recommended to verify server ID against some value, this template does not verify server ID
                input_username = event['username']
                input_serverId = event['serverId']
                print("Username: {}, ServerId: {}".format(input_username, input_serverId));

                if 'password' in event:
                    input_password = event['password']
                    if input_password == '' and (event['protocol'] == 'FTP' or event['protocol'] == 'FTPS'):
                      print("Empty password not allowed")
                      return response_data
                else:
                    print("No password, checking for SSH public key")
                    input_password = ''

                # Lookup user's secret which can contain the password or SSH public keys
                resp = get_secret("aws/transfer/" + input_serverId + "/" + input_username)

                if resp != None:
                    resp_dict = json.loads(resp)
                else:
                    print("Secrets Manager exception thrown")
                    return {}

                if input_password != '':
                    if 'Password' in resp_dict:
                        resp_password = resp_dict['Password']
                    else:
                        print("Unable to authenticate user - No field match in Secret for password")
                        return {}

                    if resp_password != input_password:
                        print("Unable to authenticate user - Incoming password does not match stored")
                        return {}
                else:
                    # SSH Public Key Auth Flow - The incoming password was empty so we are trying ssh auth and need to return the public key data if we have it
                    if 'PublicKey' in resp_dict:
                        resp_data['PublicKeys'] = resp_dict['PublicKey'].split(",")
                    else:
                        print("Unable to authenticate user - No public keys found")
                        return {}

                # If we've got this far then we've either authenticated the user by password or we're using SSH public key auth and
                # we've begun constructing the data response. Check for each key value pair.
                # These are required so set to empty string if missing
                if 'Role' in resp_dict:
                    resp_data['Role'] = resp_dict['Role']
                else:
                    print("No field match for role - Set empty string in response")
                    resp_data['Role'] = ''

                # These are optional so ignore if not present
                if 'Policy' in resp_dict:
                    resp_data['Policy'] = resp_dict['Policy']

                if 'HomeDirectoryDetails' in resp_dict:
                    print("HomeDirectoryDetails found - Applying setting for virtual folders")
                    resp_data['HomeDirectoryDetails'] = resp_dict['HomeDirectoryDetails']
                    resp_data['HomeDirectoryType'] = "LOGICAL"
                elif 'HomeDirectory' in resp_dict:
                    print("HomeDirectory found - Cannot be used with HomeDirectoryDetails")
                    resp_data['HomeDirectory'] = resp_dict['HomeDirectory']
                else:
                    print("HomeDirectory not found - Defaulting to /")

                print("Completed Response Data: "+json.dumps(resp_data))
                return resp_data

            def get_secret(id):
                region = os.environ['SecretsManagerRegion']
                print("Secrets Manager Region: "+region)

                client = boto3.session.Session().client(service_name='secretsmanager', region_name=region)

                try:
                    resp = client.get_secret_value(SecretId=id)
                    # Decrypts secret using the associated KMS CMK.
                    # Depending on whether the secret is a string or binary, one of these fields will be populated.
                    if 'SecretString' in resp:
                        print("Found Secret String")
                        return resp['SecretString']
                    else:
                        print("Found Binary Secret")
                        return base64.b64decode(resp['SecretBinary'])
                except ClientError as err:
                    print('Error Talking to SecretsManager: ' + err.response['Error']['Code'] + ', Message: ' + str(err))
                    return None
      Description: A function to lookup and return user data from AWS Secrets Manager.
      Handler: index.lambda_handler
      Role:
        Fn::GetAtt: LambdaExecutionRole.Arn
      Runtime: python3.11
      Environment:
        Variables:
          SecretsManagerRegion:
            Fn::If:
              - SecretsManagerRegionProvided
              - Ref: SecretsManagerRegion
              - Ref: AWS::Region
  GetUserConfigLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:invokeFunction
      FunctionName:
        Fn::GetAtt: GetUserConfigLambda.Arn
      Principal: transfer.amazonaws.com
      SourceArn:
        Fn::If:
          - CreateServer
          - Fn::GetAtt: TransferServer.Arn
          - Fn::Sub: arn:${AWS::Partition}:transfer:${AWS::Region}:${AWS::AccountId}:server/*
```
</details>

### Transfer Familyで利用するSecrets Managerの構築

CloudFormationで作られたLambdaのコードを見ると、認証するシークレットIDは下記の形式なので
「aws/transfer/[Transfer FamilyのサーバID]/[FTPで利用するユーザ名]」で作成する。

```python:Lambdaのコード
# Lookup user's secret which can contain the password or SSH public keys
resp = get_secret("aws/transfer/" + input_serverId + "/" + input_username)
```

必要なシークレットの値はガイドの通り、設定します。

| キー | 値 |
|:------------:|:------------:|
| Role      | 先ほど作成したIAMロール名（ARN）        |
| Password       | FTPで利用するパスワードを指定	        |
| HomeDirectory       | 作成したS3のバケット名        |


https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/custom-lambda-idp.html

ストレージをS3ではなくEFSにする場合は他にも追加のパラメータが必要になります。

# 検証①(VPCエンドポイント経由でFTP転送するパターン)
問題なく、FTPでファイル転送ができました。

```tex:VPCエンドポイント宛にFTP転送する
[root@ip-10-1-2-248 ~]# ftp vpce-07f513a873b84f0f5-mva0f0dr.vpce-svc-0c6fc1c14ceb5b0d8.ap-northeast-1.vpce.amazonaws.com
Trying 10.2.4.125...
Connected to vpce-07f513a873b84f0f5-mva0f0dr.vpce-svc-0c6fc1c14ceb5b0d8.ap-northeast-1.vpce.amazonaws.com (10.2.4.125).
220 Service ready for new user.
Name (vpce-07f513a873b84f0f5-mva0f0dr.vpce-svc-0c6fc1c14ceb5b0d8.ap-northeast-1.vpce.amazonaws.com:root): hogehoge
331 User name okay, need password for hogehoge.
Password:
230 User logged in, proceed.
Remote system type is UNIX.
ftp>
ftp> bin
200 Command TYPE okay.
ftp> put test.txt
local: test.txt remote: test.txt
227 Entering Passive Mode (10,2,4,125,32,5)
150 File status okay; about to open data connection.
226 Transfer complete.
10 bytes sent in 3.4e-05 secs (294.12 Kbytes/sec)
ftp>
ftp> exit
221 Goodbye.
[root@ip-10-1-2-248 ~]#
```

無事にファイルがS3に格納されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/b9c11654-b0f6-14c4-9b08-4f40ebf1df52.png)

# NLBを挟む構成を考えてみた
本構成でもリージョンDNSに向けてアクセスする分には、AZ障害に対応できる可用性はとれています。
しかし、連携元単位でアクセス先を絞りたい、固定IPでアクセスしたい、プロトコル毎にアクセスを止めたいなど運用によってはNLBを挟んだ方が良いケースも出てくるかもしれません。しかしFTPを利用する上で、パッシブモードでの制約があるのでマルチAZ構成ではNLBを構築できないため、冗長構成を取りつつTransfer Familyを利用できないか考えてみます。

### マルチAZ構成のNLBではFTP転送ができない
マルチAZ構成でNLBの構築します。
リスナーはTCPで21,8192-8200の通信が必要なので、ポート毎にターゲットグループを作成し、ターゲットはIP指定でVPCエンドポイントのIPを指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/26e79a26-2cd8-b1c0-dd00-df9ece449d3b.png)

検証構成は下記のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/65b39127-5c06-a070-877b-ea16b251b1a3.png)

もちろんこの構成ではFTP通信ができません。Transfer FamilyのPassiveIPを指定する必要があり、デフォルトはAutoですが、マルチAZ構成のNLBでは設定するIPが決まらないためです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/b391b1c7-7553-90a9-b145-21ab9ebbd126.png)

### シングルAZ構成のNLBではFTP転送できる
NLBをシングルAZ構成で構築する以外は上記と同じです。クロスゾーン負荷分散はOFFにしているので、宛先のターゲットはNLBを構築したAZと同じVPCエンドポイントのIPを指定します。

またTransfer FamilyのPassiveIPをNLBのIPアドレスを指定します。
Transfer FamilyのPassiveIPを設定を反映させるにはTransfer Familyの「停止」→「起動」が必要になります。

NLBのIPを調べるには、「ネットワークインターフェイス」からNLBのENIを指定して確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/a93174bf-a1bb-bdd3-65b1-560d6d23141e.png)

検証構成は下記のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/d44da95f-a492-23a9-797b-992b35995b8a.png)

問題なく、FTPでファイル転送ができましたが、シングルAZ構成になっているので可用性は劣ります。
```tex:NLBに対してFTPした時
[root@ip-10-1-2-248 ~]# ftp nlb-test-single-az-1a-f3c7d008f1afcdde.elb.ap-northeast-1.amazonaws.com
Connected to nlb-test-single-az-1a-f3c7d008f1afcdde.elb.ap-northeast-1.amazonaws.com (10.2.1.151).
220 Service ready for new user.
Name (nlb-test-single-az-1a-f3c7d008f1afcdde.elb.ap-northeast-1.amazonaws.com:root): hogehoge
331 User name okay, need password for hogehoge.
Password:
230 User logged in, proceed.
Remote system type is UNIX.
ftp>
ftp> bin
200 Command TYPE okay.
ftp> put test.txt
local: test.txt remote: test.txt
227 Entering Passive Mode (10,2,1,151,32,3)
150 File status okay; about to open data connection.
226 Transfer complete.
10 bytes sent in 2.9e-05 secs (344.83 Kbytes/sec)
ftp>
ftp>
ftp> exit
221 Goodbye.
[root@ip-10-1-2-248 ~]#
```

# 検証②（NLBを各AZに構築したRoute53のフェールオーバー構成パターン）
NLBでAZ障害の可用性が担保できるように各AZにNLBを配置してみました。Transfer FamilyのPassiveIPの設定上、NLB1に紐づくTransfer FamilyのサーバとNLB2に紐づくTransfer Familyのサーバを構築する必要がありました。Lambdaや配置先のS3は同じものでいいのですが、SecretsManagerのIDにはTransfer FamilyのIDを設定する必要があるので、各々のTransfer Familyのサーバに対応するように作る必要がありました。

また検証用のEC2からは同じDNSで引っ張りたいので、プライベートホストゾーンでフェールオーバー構成（プライマリ：NLB1、セカンダリ：NLB2）にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/80feb314-f5bb-3761-db06-90a206e40382.png)

Route53のヘルスチェックはプライベート環境なのでCloudwatch アラームで「HealthyHostCount」を設定しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/09bc2b05-1865-bec6-f16e-6405927fdced.png)

検証構成は下記のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/e2de2eb3-f673-5759-d329-7bcf74eaf445.png)

通常時にプライベートなDNSでFTP転送した時とnsloolupの結果は下記です。プライマリ側のNLBに対してFTPアクセスしていることがわかり、FTP転送できることがわかりました。

```tex:プライベートなDNSでFTP転送した時とnsloolupの結果
[root@ip-10-1-2-248 ~]# nslookup cname-nlb1-nlb2.ftp-transferfamily.com
Server:         10.1.0.2
Address:        10.1.0.2#53

Non-authoritative answer:
cname-nlb1-nlb2.ftp-transferfamily.com  canonical name = nlb-test-single-az-1a-f3c7d008f1afcdde.elb.ap-northeast-1.amazonaws.com.
Name:   nlb-test-single-az-1a-f3c7d008f1afcdde.elb.ap-northeast-1.amazonaws.com
Address: 10.2.1.151

[root@ip-10-1-2-248 ~]#
[root@ip-10-1-2-248 ~]#
[root@ip-10-1-2-248 ~]# ftp cname-nlb1-nlb2.ftp-transferfamily.com
Connected to cname-nlb1-nlb2.ftp-transferfamily.com (10.2.1.151).
220 Service ready for new user.
Name (cname-nlb1-nlb2.ftp-transferfamily.com:root): hogehoge
331 User name okay, need password for hogehoge.
Password:
230 User logged in, proceed.
Remote system type is UNIX.
ftp>
ftp> bin
200 Command TYPE okay.
ftp>
ftp> put test.txt
local: test.txt remote: test.txt
227 Entering Passive Mode (10,2,1,151,32,2)
150 File status okay; about to open data connection.
226 Transfer complete.
10 bytes sent in 3.3e-05 secs (303.03 Kbytes/sec)
ftp>
ftp>
ftp> exit
221 Goodbye.
[root@ip-10-1-2-248 ~]#
```

Route53でフェールオーバー後にアクセスした際の挙動をみてみようと思います。
FISを利用してゾーン障害を起こしてもよかったのですが、手間だったのでCloudwatchアラームの設定でエラーを起こしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232966/2dcd60c0-237d-8a62-6ae8-26876f040a74.png)

フェールオーバー後にプライベートなDNSでFTP転送した時とnsloolupの結果は下記です。セカンダリ側のNLBに対してFTPアクセスしていることがわかり、FTP転送できることがわかりました。これでゾーン障害があったとしても可用性がとれた構成になりました。

```tex:プライベートなDNSでFTPした時とnsloolupの結果
[root@ip-10-1-2-248 ~]# nslookup cname-nlb1-nlb2.ftp-transferfamily.com
Server:         10.1.0.2
Address:        10.1.0.2#53

Non-authoritative answer:
cname-nlb1-nlb2.ftp-transferfamily.com  canonical name = nlb-test-single-az-1c-7a7a8bcb1d9a7ecc.elb.ap-northeast-1.amazonaws.com.
Name:   nlb-test-single-az-1c-7a7a8bcb1d9a7ecc.elb.ap-northeast-1.amazonaws.com
Address: 10.2.2.64

[root@ip-10-1-2-248 ~]#
[root@ip-10-1-2-248 ~]# ftp cname-nlb1-nlb2.ftp-transferfamily.com
Connected to cname-nlb1-nlb2.ftp-transferfamily.com (10.2.2.64).
220 Service ready for new user.
Name (cname-nlb1-nlb2.ftp-transferfamily.com:root): hogehoge
331 User name okay, need password for hogehoge.
Password:
230 User logged in, proceed.
Remote system type is UNIX.
ftp>
ftp> bin
200 Command TYPE okay.
ftp> put test.txt
local: test.txt remote: test.txt
227 Entering Passive Mode (10,2,2,64,32,4)
150 File status okay; about to open data connection.
226 Transfer complete.
10 bytes sent in 3.4e-05 secs (294.12 Kbytes/sec)
ftp>
ftp> exit
221 Goodbye.
[root@ip-10-1-2-248 ~]#
```

# 所感
FTP連携のニーズは減ってきているとは感じるものの、連携元の制約等でどうしてもFTPを利用しなければいけない場面は出てくるかと思います。ただNLBまで配置した構成を考えると複雑になってしまうので、実装するかはコストと要相談かと思います。EC2にFTPサーバを立てて運用するよりは、マネージドサービスを利用した方が運用は楽になると思いますので、誰かの助けになれば幸いです。