Closed12

Serverless Framework × Private API Gateway × VPC Lambda × ExpressでHTTP Proxy

海都海都

動機

  • VPC内からしかアクセスできないHTTPプロキシを作りたい
  • プロキシ先がIP制限下のため、LambdaのIPを固定したい
  • IP節約のためLambda関数はなるべく少なく→ルーティングはアプリ内で行う
海都海都

まずは何はともあれ

$ npx serverless create --template aws-nodejs-typescript
海都海都
package.json
  "dependencies": {
    "@vendia/serverless-express": "^4.10.1",
    "express": "^4.18.2",
    "http-proxy-middleware": "^2.0.6"
  },
  "devDependencies": {
    ...
    "serverless-offline": "^11.1.3",
    ...
  },

この4つを入れる

海都海都
app.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';

const app = express();

app.use(
    '/test',
    createProxyMiddleware({
        target: 'https://httpbin.org/ip',
        pathRewrite: { '^/test': '' },
        changeOrigin: true,
    })
);

export default app;
handler.ts
import serverlessExpress from '@vendia/serverless-express';
import app from './app';

export const main = serverlessExpress({ app });

これだけでプロキシとしては動作する

海都海都

レスポンスデータの頭に「1e3f\r\n」のような謎文字列がつくので、回避する場合以下を適用

app.ts
onProxyRes: (proxyRes, req, res) => {
	// https://github.com/chimurai/http-proxy-middleware/discussions/574
	// Capture the response from the backend and check if response is being chunked
	const chunked = /chunked/.test(proxyRes.headers['transfer-encoding']);
	if (chunked) {
		// If chunked, then gather all chunks and pass it on unchunked. Remove transfer-encoding header.
		delete proxyRes.headers['transfer-encoding'];
		res.write = ((override) => {
			return (chunk, encoding, callback) => {
				override.call(res, chunk, 'binary', callback);
			};
		})(res.write) as Response['write'];
		res.end = ((override) => {
			return (chunk, encoding, callback) => {
				override.call(res, chunk, 'binary', callback);
			};
		})(res.end) as Response['end'];
	}
},

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Transfer-Encoding
https://github.com/chimurai/http-proxy-middleware/discussions/574#discussioncomment-655639

プロキシ先がTransfer-Encoding: chunkedでデータを返してくる場合に、チャンク化された生データをTransfer-Encoding: chunkedして返してる=二重にチャンク化してるのではないか、と推測した
なので、proxyResでヘッダーを消した上で、チャンクをまとめた解釈後のデータを返してもらうようにする…みたいな?

海都海都
serverless.ts
import type { AWS } from '@serverless/typescript';

import proxy from '@functions/proxy';

const serverlessConfiguration: AWS = {
    ...
    provider: {
        ...
        endpointType: 'PRIVATE',
        ...
    },
    ...
    custom: {
        ...
        'serverless-offline': {
            reloadHandler: true,
        },
    },
};

module.exports = serverlessConfiguration;

必須変更点はこのぐらい

海都海都
serverless.ts
import type { AWS } from '@serverless/typescript';

import proxy from '@functions/proxy';

const serverlessConfiguration: AWS = {
    ...
    params: {
        default: {
            prefix: 'XXX-${self:provider.stage}-proxy',
            subnetIds: '${ssm:${param:prefix}-lambda-instance-subnet-ids-param}',
            securityGroupIds: '${ssm:${param:prefix}-lambda-instance-sg-id-param}',
        },
    },
    ...
};

module.exports = serverlessConfiguration;

事前にSSM Parameter StoreにVPC Lambdaを作成する上で必要な情報を仕込んでおく
paramsでそれを取得

海都海都
index.ts
import { handlerPath } from '@libs/handler-resolver';

export default {
    handler: `${handlerPath(__dirname)}/handler.main`,
    vpc: {
        subnetIds: { 'Fn::Split': [',', '${param:subnetIds}'] },
        securityGroupIds: ['${param:securityGroupId}'],
    },
    events: [
        {
            http: {
                method: 'any',
                path: '{proxy+}',
            },
        },
    ],
};

今回サブネットIDはコンマ区切り文字列で持たせたので、配列にしたいが、JavaScriptによるsplit()は通用しないので注意が必要
CloudFormationの関数で対応できた

海都海都
Error:
CREATE_FAILED: ApiGatewayDeployment1666675080734 (AWS::ApiGateway::Deployment)
Resource handler returned message: "Private REST API doesn't have a resource policy attached to it (Service: ApiGateway, Status Code: 400, Request ID: fa1a4947-f9a0-4fe4-af86-db680721fe34)" (RequestToken: 06717e61-2022-bc16-612b-2dd6ae878f3f, HandlerErrorCode: InvalidRequest)

この状態でデプロイしても、「API Gatewayにリソースポリシーがねーよ」って怒られた

海都海都
serverless.ts
import type { AWS } from '@serverless/typescript';

import proxy from '@functions/proxy';

const serverlessConfiguration: AWS = {
    ...
    provider: {
        ...
        apiGateway: {
            ...
            resourcePolicy: [
                {
                    Effect: 'Allow',
                    Resource: ['execute-api:/*'],
                    Action: ['execute-api:Invoke'],
                    Principal: '*',
                },
                {
                    Effect: 'Deny',
                    Resource: ['execute-api:/*'],
                    Action: ['execute-api:Invoke'],
                    Principal: '*',
                    Condition: {
                        StringNotEquals: {
                            'aws:SourceVpce': '${param:ingressVpceId}',
                        },
                    },
                },
            ],
        },
        ...
    },
    ...
    params: {
        default: {
            ...
            ingressVPCEndpointId: '${ssm:${param:prefix}-api-gateway-ingress-vpce-id-param}',
        },
    },
};

module.exports = serverlessConfiguration;

公式ドキュメントを参考にしてリソースポリシーを設定したところ、デプロイ成功

execute-api:/*execute-api:/* を使用して、現在の API のすべてのステージ、メソッド、パスを表すことができます。ってことらしい。へー
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-resource-policies-examples.html

海都海都

Webコンソールからテストを行う
Webコンソールからテストを行う
何度実行してもIPアドレスに変化がない・NAT Gatewayに割り付けられたElastic IPと同一であることを確認

このスクラップは2022/11/06にクローズされました