Lambda からインターネットアクセスを固定IPで実現したい!Serverless Framework で実現してみました

Lambda からインターネットアクセスを固定IPで実現したい!Serverless Framework で実現してみました

こんにちは!

サーバーレス開発が主流になってきた最近では、Lambda で固定IP 制限環境へインターネットアクセスしたいなんてニーズが多くなってきました。

Serverless Framework を使用して Cloud Formation も書いたので、解説と共にシェアします!

想定する読者

  • 制限のある環境へのアクセスを Lambda で実現したい
  • Serverless framework もしくは Cloud Formation を書いたことがある人

はじめに

Lamnda は Edge ロケーションで起動するサーバーなので、大前提としてLambda に固定IP を付与することはできません。

基本的な固定IP の付与の方法は Fargate と一緒で、NATゲートウェイ経由でインターネットへアクセスさせるようにします。

VPC などに慣れていないフロントエンドエンジニアの方の場合は、Edge ロケーション以外のサービスが入ってくるとい辛いところかもしれませんが、頑張ってついてきてください!( 不明点はお気軽に問い合わせください )

Serverless Framework で VPC とLambda を構築

serverless.yaml で諸々インフラ情報を定義していきますが、最終的にはLambda to Lambda でリクエストし検証できるようにします。( 固定IPを持つLambda から Lambda へリクエストし API Gateway の SourceIP を確認することで、本当に固定IPを持っているのか確認することが可能 )

まずはじめに、プラグイン含め npm インストールが必要です。

package.json

{
  "name": "ragate-serverless",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "deploy": "sls deploy -v",
    "remove": "sls remove"
  },
  "author": "Ragate",
  "license": "MIT",
  "devDependencies": {
    "@babel/core": "^7.9.6",
    "@babel/preset-env": "^7.9.6",
    "aws-sdk": "^2.680.0",
    "serverless": "^1.71.1",
    "serverless-webpack": "^5.3.2",
    "webpack": "^4.43.0"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "lodash": "^4.17.15"
  }
}

次に、serverless.yamlを作成します。設定情報を外部ファイル読み込みしているので、後述します。

serverless.yaml

service: Example-Serverless
provider:
  name: aws
  runtime: nodejs12.x
  stage: ${opt:stage,"dev"}
  region: ap-northeast-1
  profile: ragate # ここはよしなに変更してください

resources:
  - ${file(./network.yml)} # 後述

functions: ${file(./api.yml)} # 後述

custom:
  commonTag:
    - Key: "Service"
      Value: ${self:service}-${self:provider.stage}
  webpack:
    includeModules: true
    packager: 'npm'
  apiEndpoint:
    Fn::Join:
      - ""
      - - "https://"
        - Ref: "ApiGatewayRestApi"
        - ".execute-api."
        - ${self:provider.region}.
        - Ref: "AWS::URLSuffix"
        - "/"
        - ${self:provider.stage}
package:
  individually: true

plugins:
  - serverless-webpack

network.yaml

Resources:
  ExampleAppVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: '10.0.0.0/16'
      EnableDnsHostnames: false
      EnableDnsSupport: true # ここがfalseになっていると外へ出て行った後に名前解決しません、Lambdaからインターネットへ通信ができません
      InstanceTenancy: 'default'
      Tags: ${self:custom.commonTag}

  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId:
        Ref: InternetGateway
      VpcId:
        Ref: ExampleAppVpc

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags: ${self:custom.commonTag}

  VpcPrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: 'ap-northeast-1a'
      CidrBlock: '10.0.1.0/24'
      Tags: ${self:custom.commonTag}
      VpcId:
        Ref: ExampleAppVpc

  VpcPublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: 'ap-northeast-1a'
      CidrBlock: '10.0.2.0/24'
      Tags: ${self:custom.commonTag}
      VpcId:
        Ref: ExampleAppVpc

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      Tags: ${self:custom.commonTag}
      VpcId:
        Ref: ExampleAppVpc

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      Tags: ${self:custom.commonTag}
      VpcId:
        Ref: ExampleAppVpc

  PublicRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId:
        Ref: VpcPublicSubnet
      RouteTableId:
        Ref: PublicRouteTable

  PrivateRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId:
        Ref: VpcPrivateSubnet
      RouteTableId:
        Ref: PrivateRouteTable

  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: PublicRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      GatewayId:
        Ref: InternetGateway

  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: PrivateRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId:
        Ref: NatGateway

  PublicSecrityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "It is for public subnet instance"
      GroupName: "PublicSubnetInstanceSecurityGroup"
      SecurityGroupEgress:
        - CidrIp: "0.0.0.0/0"
          FromPort: 0
          ToPort: "-1"
          IpProtocol: "-1"
      SecurityGroupIngress:
        - CidrIp: '0.0.0.0/0'
          FromPort: 80
          ToPort: 443
          IpProtocol: "tcp"
      Tags: ${self:custom.commonTag}
      VpcId:
        Ref: ExampleAppVpc

  PrivateSecrityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "It is for private subnet instance"
      GroupName: "PrivateSubnetInstanceSecurityGroup"
      SecurityGroupEgress:
        - CidrIp: "0.0.0.0/0"
          FromPort: 0
          ToPort: "-1"
          IpProtocol: "-1"
      SecurityGroupIngress:
        - CidrIp: "0.0.0.0/0"
          FromPort: 80
          ToPort: 443
          IpProtocol: "tcp"
      Tags: ${self:custom.commonTag}
      VpcId:
        Ref: ExampleAppVpc

  NatElasticIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: "vpc"
      Tags:  ${self:custom.commonTag}

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId:
        'Fn::GetAtt': [NatElasticIP,AllocationId]
      SubnetId:
        Ref: VpcPublicSubnet
      Tags: ${self:custom.commonTag}

api.yaml

getExample:
  handler: src/functions/example/get.handler
  events:
    - http:
        path: /example/
        method: get
  vpc:
    securityGroupIds:
      - Ref: PrivateSecrityGroup
    subnetIds:
      - Ref: VpcPrivateSubnet

postExample:
  handler: src/functions/example/post.handler
  environment:
    END_POINT: ${self:custom.apiEndpoint}
  events:
    - http:
        path: /example/
        method: post
  vpc:
    securityGroupIds:
      - Ref: PrivateSecrityGroup
    subnetIds:
      - Ref: VpcPrivateSubnet

Lambda 関数を作成します。今回、固定IPでのリクエストを行う関数と、その取得・リクエストのソースIPを確認する Lambda 関数両方を構築します。

src/functions/example/get.js

export async function handler(event, context, callback) {
  callback(null, {
    statusCode: 200,
    body: JSON.stringify({
      IPAddress: event['requestContext']['identity']['sourceIp']
    }),
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Credentials': 'true'
    },
  })
}

src/functions/example/post.js

const axios = require('axios')

export async function handler(event, context, callback) {
  await axios.get(process.env.END_POINT + "/example")
    .then(res => callback(null, {
      statusCode: 200,
      body: JSON.stringify(
        res['data']
      ),
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': 'true'
      },
    }))
    .catch(e => callback(null, {
      statusCode: 200,
      body: JSON.stringify(
        // res['data']
        {result: "Error"}
      ),
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': 'true'
      },
    }))
}

最後に、サーバーレスフレームワークでデプロイする Lambda 関数のソースコードを、webpack でビルドしているので、下記のファイルも追加します。

webpack.config.js

const path = require('path');
const slsw = require('serverless-webpack');

module.exports = {
  entry: slsw.lib.entries,
  target: 'node',
  mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
  stats: 'minimal',
  devtool: 'nosources-source-map',
  performance: {
    hints: false,
  },
  resolve: {
    extensions: ['.js', '.jsx', '.json'],
  },
  output: {
    libraryTarget: 'commonjs2',
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
    sourceMapFilename: '[file].map',
  },
};

これで諸々構築準備が完了しました。

こちらのコマンドでデプロイしてみましょう。

yarn deploy

実際に構築された API ゲートウェイのエンドポイントへ POST リクエストしてみましょう。すると、NAT に設定した固定IP( 厳格には NAT とつながっているENI の IPアドレス )がレスポンスに表示されるはずです。

まとめ

この構築の難点として、Lambda を VPC 内に入れた場合の弊害としてコールドスタートのレイテンシーが高まるということです。

例えば、定期的に Lambda を Cloud Watch で起こして所定の場所へリクエストを行いたい場合は、指定時刻にしっかり実行されない危険性があります。注意して運用しましょう。