AWS Copilot を使用して API Gateway × Fargate の構成で API サーバを構築する

# はじめに

よくある構成として API Gateway × Lambda がありますが,
RDS を使いたいがコネクション問題を解決しにくい場合などで Lambda の採用を見送るケースがあります.
そこで今回, AWS Copilot を使用して API Gateway × Fargate の構成で API サーバを構築します.

なお, ドメインを example.com としている箇所がありますが, お使いのドメインに置き換えてください.

# 構成図


![プレゼンテーション1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73775/19cf83c0-ee3c-6ba4-4037-a789e21f45a8.png)

# サンプルコード

## 全体像

```shell
.
├── copilot
│   ├── environments
│   │   └── test
│   │       └── manifest.yml
│   └── services-example-api
│       ├── addons
│       │   ├── addons.parameters.yml
│       │   └── api-gateway.yml
│       └── manifest.yml
└── services
    └── example-api
        ├── src
        │   └── index.ts
        ├── Dockerfile
        ├── package.json
        └── tsconfig.json
```

## copilot

```yaml:copilot/environments/test/manifest.yml
name: test
type: Environment

observability:
  container_insights: false
```

```yaml:copilot/services-example-api/addons/addons.parameters.yml
Parameters:
  DiscoveryServiceArn: !GetAtt DiscoveryService.Arn
```

```yaml:copilot/services-example-api/addons/api-gateway.yml
Parameters:
  App:
    Type: String
  Env:
    Type: String
  Name:
    Type: String
  DiscoveryServiceArn:
    Type: String
  HostedZoneId:
    Type: AWS::Route53::HostedZone::Id
    Default: # ホストゾーン ID を指定してください

Mappings:
  test:
    ApiGateway:
      DomainName: example-api-test.example.com
    LogGroup:
      RetentionInDays: 1

Resources:
  VpcLink:
    Type: AWS::ApiGatewayV2::VpcLink
    Properties:
      Name: !Sub ${App}-${Env}-${Name}-VpcLink
      SubnetIds:
        !Split [",", Fn::ImportValue: !Sub "${App}-${Env}-PublicSubnets" ]
      SecurityGroupIds:
        - Fn::ImportValue: !Sub ${App}-${Env}-EnvironmentSecurityGroup

  HttpApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub ${App}-${Env}-${Name}
      ProtocolType: HTTP
      DisableExecuteApiEndpoint: true

  Stage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref HttpApi
      StageName: $default
      AutoDeploy: true
      AccessLogSettings:
        DestinationArn: !GetAtt AccessLogGroup.Arn
        Format: '{
                   "request-id": "$context.requestId",
                   "extended-request-id": "$context.extendedRequestId",
                   "ip": "$context.identity.sourceIp",
                   "request-time": "$context.requestTime",
                   "http-method": "$context.httpMethod",
                   "resource-path": "$context.resourcePath",
                   "status": "$context.status",
                   "protocol": "$context.protocol",
                   "response-length": "$context.responseLength"
                 }'

  Integration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref HttpApi
      ConnectionId: !Ref VpcLink
      ConnectionType: VPC_LINK
      IntegrationMethod: ANY
      IntegrationType: HTTP_PROXY
      IntegrationUri: !Sub ${DiscoveryServiceArn}
      PayloadFormatVersion: "1.0"

  PreflightRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: OPTIONS /
      Target: !Join
        - /
        - - integrations
          - !Ref Integration

  PostRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApi
      RouteKey: POST /
#      API Gateway のオーソライザーに Cognito を使用する場合は下記を参考にしてください
#      AuthorizationType: JWT
#      AuthorizerId: !Ref Authorizer
      Target: !Join
        - /
        - - integrations
          - !Ref Integration

#  API Gateway のオーソライザーに Cognito を使用する場合は下記を参考にしてください
#  Authorizer:
#    Type: AWS::ApiGatewayV2::Authorizer
#    Properties:
#      ApiId: !Ref HttpApi
#      AuthorizerType: JWT
#      IdentitySource:
#        - $request.header.Authorization
#      JwtConfiguration:
#        Audience:
#          - # Cognito ユーザプールのクライアント ID を指定してください
#        Issuer: !Sub
#          - "https://cognito-idp.ap-northeast-1.amazonaws.com/${UserPoolId}"
#          - UserPoolId: # Cognito ユーザプール ID を指定してください
#      Name: !Sub ${App}-${Env}-${Name}-authorizer

  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !FindInMap [ !Ref Env, ApiGateway, DomainName ]
      DomainValidationOptions:
        - DomainName: !FindInMap [ !Ref Env, ApiGateway, DomainName ]
          HostedZoneId: !Ref HostedZoneId
      ValidationMethod: DNS

  CustomDomainName:
    Type: AWS::ApiGatewayV2::DomainName
    Properties:
      DomainName: !FindInMap [ !Ref Env, ApiGateway, DomainName ]
      DomainNameConfigurations:
        - CertificateArn: !Ref Certificate
          EndpointType: REGIONAL

  ApiMapping:
    Type: AWS::ApiGatewayV2::ApiMapping
    DependsOn: CustomDomainName
    Properties:
      DomainName: !FindInMap [ !Ref Env, ApiGateway, DomainName ]
      ApiId: !Ref HttpApi
      Stage: !Ref Stage

  CustomDomainRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: !Ref CustomDomainName
      Type: A
      HostedZoneId: !Ref HostedZoneId
      AliasTarget:
        DNSName: !GetAtt CustomDomainName.RegionalDomainName
        HostedZoneId: !GetAtt CustomDomainName.RegionalHostedZoneId

  AccessLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/apigateway/${App}-${Env}-${Name}"
      RetentionInDays: !FindInMap [ !Ref Env, LogGroup, RetentionInDays ]
```


```yaml:copilot/services-example-api/manifest.yml
name: services-example-api
type: Backend Service

image:
  build: ./services/example-api/Dockerfile
  port: 3000

cpu: 256
memory: 512
count: 1

environments:
  test:
    logging:
      retention: 1
```

## services/example-api

今回は GraphQL で API サーバを実装します.

```ts:services/example-api/src/index.ts
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

const typeDefs = `#graphql
type User {
    id: Int
    name: String
}

type Query {
    users: [User]
}
`;

const users = [
    {
        id: 1,
        name: "Alice",
    },
    {
        id: 2,
        name: "Bob",
    },
];

const resolvers = {
    Query: {
        users: () => users,
    }
};

const bootstrap = async() => {
    const server = new ApolloServer({
        typeDefs,
        resolvers,
    });

    const { url } = await startStandaloneServer(server, {
        listen: { port: 3000 },
    });

    console.log(`🚀  Server ready at: ${url}`);
};

bootstrap().catch(console.error);
```

```Dockerfile:services/example-api/Dockerfile
FROM node:18-slim AS deps

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci


FROM node:18-slim AS builder

WORKDIR /app

COPY . .
COPY --from=deps /app/node_modules ./node_modules

RUN npm run build


FROM node:18-slim AS runner

WORKDIR /app

COPY --from=builder /app/dist/index.js .

EXPOSE 3000

CMD ["index.js"]
```

```json:services/example-api/package.json
{
  "name": "example-api",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc --noEmit && esbuild --platform=node --format=cjs --bundle --minify --outdir=dist src/index.ts",
    "start": "npm run build && node ./dist/index.js"
  },
  "dependencies": {
    "@apollo/server": "^4.7.1",
    "graphql": "^16.6.0"
  },
  "devDependencies": {
    "@types/node": "^20.2.5",
    "esbuild": "^0.17.19",
    "typescript": "^5.0.4"
  }
}
```

```json:services/example-api/tsconfig.json
{
  "compilerOptions": {
    "rootDirs": ["src"],
    "outDir": "dist",
    "lib": ["es2022"],
    "target": "es2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "types": ["node"]
  }
}
```

## 動作確認

```shell:localhost
$ cd services/example-api
$ npm i
$ npm start
$ curl --request POST \
    --header 'content-type: application/json' \
    --url http://localhost:3000/ \
    --data '{"query":"query Users {\n  users {\n    id\n    name\n  }\n}"}'
{"data":{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}}
```

```shell:example-api-test.example.com
$ copilot init
# 割愛
$ curl --request POST \
    --header 'content-type: application/json' \
    --url 'https://example-api-test.example.com/' \
    --data '{"query":"query Users {\n  users {\n    id\n    name\n  }\n}"}'
{"data":{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}}
```

問題なくデプロイできていますね.



# 最後に

AWS Copilot を使用して API Gateway × Fargate の構成で API サーバを構築しました.
API Gateway のバックエンドとして Lambda の採用が難しい場合の選択肢の一つになれば.
※ 試した後は $ copilot app delete を行ってリソースを削除してください

# 備考

```shell:動作確認環境
$ copilot -v
copilot version: v1.28.0
$ node -v
v18.16.0
```