使用 Fargate、Vapor 和 MongoDB Atlas 部署到 AWS

本指南说明了如何在 AWS 上部署服务器端 Swift 工作负载。该工作负载是一个用于跟踪待办事项列表的 REST API。它使用 Vapor 框架来编写 API 方法。这些方法在 MongoDB Atlas 云数据库中存储和检索数据。Vapor 应用程序被容器化,并使用 AWS Copilot 工具包部署到 AWS Fargate 上的 AWS。

架构

Architecture

先决条件

要构建此示例应用程序,您需要

步骤 1:创建数据库

如果您是 MongoDB Atlas 的新手,请按照此入门指南操作。您需要创建以下项目

在后续步骤中,您将为这些项目提供值以配置应用程序。

步骤 2:初始化新的 Vapor 项目

为您的项目创建一个文件夹。

mkdir todo-app && cd todo-app

初始化一个名为 api 的 Vapor 项目。

vapor new api -n

步骤 3:添加项目依赖项

Vapor 初始化一个 Package.swift 文件用于项目依赖项。您的项目需要一个额外的库,MongoDBVapor。将 MongoDBVapor 库添加到项目和 Package.swift 文件的目标依赖项中。

更新后的文件应如下所示

api/Package.swift

// swift-tools-version:5.6
import PackageDescription

let package = Package(
    name: "api",
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor", .upToNextMajor(from: "4.7.0")),
        .package(url: "https://github.com/mongodb/mongodb-vapor", .upToNextMajor(from: "1.1.0"))
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor"),
                .product(name: "MongoDBVapor", package: "mongodb-vapor")
            ],
            swiftSettings: [
                .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
            ]
        ),
        .executableTarget(name: "Run", dependencies: [.target(name: "App")]),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "XCTVapor", package: "vapor"),
        ])
    ]
)

步骤 4:更新 Dockerfile

您将 Swift 服务器代码作为 Docker 镜像部署到 AWS Fargate。Vapor 为您的应用程序生成一个初始 Dockerfile。您的应用程序需要对这个 Dockerfile 进行一些修改

将 Vapor 生成的 Dockerfile 的内容替换为以下代码

api/Dockerfile

# ================================
# Build image
# ================================
FROM public.ecr.aws/docker/library/swift:5.6.2-focal as build

# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
    && apt-get -q update \
    && apt-get -q dist-upgrade -y \
    && apt-get -y install libssl-dev \
    && rm -rf /var/lib/apt/lists/*

# Set up a build area
WORKDIR /build

# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve

# Copy entire repo into container
COPY . .

# Build everything, with optimizations
RUN swift build -c release --static-swift-stdlib

# Switch to the staging area
WORKDIR /staging

# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./

# Copy resources bundled by SwiftPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;

# Copy any resources from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true

# ================================
# Run image
# ================================
FROM public.ecr.aws/ubuntu/ubuntu:focal

# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
    && apt-get -q update \
    && apt-get -q dist-upgrade -y \
    && apt-get -q install -y \
      ca-certificates \
      tzdata \
      curl \
      libxml2 \
    && rm -r /var/lib/apt/lists/*

# Create a vapor user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor

# Switch to the new home directory
WORKDIR /app

# Copy built executable and any staged resources from builder
COPY --from=build --chown=vapor:vapor /staging /app

# Ensure all further commands run as the vapor user
USER vapor:vapor

# Let Docker bind to port 8080
EXPOSE 8080

# Start the Vapor service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./Run"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]

步骤 5:更新 Vapor 源代码

Vapor 还生成了编写 API 代码所需的示例文件。您必须自定义这些文件,使其代码公开您的待办事项列表 API 方法并与您的 MongoDB 数据库交互。

configure.swift 文件初始化一个应用程序范围的连接池,用于连接到您的 MongoDB 数据库。它在运行时从环境变量中检索到您的 MongoDB 数据库的连接字符串。

将该文件的内容替换为以下代码

api/Sources/App/configure.swift

import MongoDBVapor
import Vapor

public func configure(_ app: Application) throws {

    let MONGODB_URI = Environment.get("MONGODB_URI") ?? ""

    try app.mongoDB.configure(MONGODB_URI)

    ContentConfiguration.global.use(encoder: ExtendedJSONEncoder(), for: .json)
    ContentConfiguration.global.use(decoder: ExtendedJSONDecoder(), for: .json)

    try routes(app)
}

routes.swift 文件定义了您的 API 的方法。这些方法包括一个 POST Item 方法用于插入新项目,以及一个 GET Items 方法用于检索所有现有项目的列表。请参阅代码中的注释以了解每个部分中发生的情况。

将该文件的内容替换为以下代码

api/Sources/App/routes.swift

import Vapor
import MongoDBVapor

// define the structure of a ToDoItem
struct ToDoItem: Content {
    var _id: BSONObjectID?
    let name: String
    var createdOn: Date?
}

// import the MongoDB database and collection names from environment variables
let MONGODB_DATABASE = Environment.get("MONGODB_DATABASE") ?? ""
let MONGODB_COLLECTION = Environment.get("MONGODB_COLLECTION") ?? ""

// define an extension to the Vapor Request object to interact with the database and collection
extension Request {

    var todoCollection: MongoCollection<ToDoItem> {
        self.application.mongoDB.client.db(MONGODB_DATABASE).collection(MONGODB_COLLECTION, withType: ToDoItem.self)
    }
}

// define the api routes
func routes(_ app: Application) throws {

    // an base level route used for container healthchecks
    app.get { req in
        return "OK"
    }

    // GET items returns a JSON array of all items in the database
    app.get("items") { req async throws -> [ToDoItem] in
        try await req.todoCollection.find().toArray()
    }

    // POST item inserts a new item into the database and returns the item as JSON
    app.post("item") { req async throws -> ToDoItem in

        var item = try req.content.decode(ToDoItem.self)
        item.createdOn = Date()

        let response = try await req.todoCollection.insertOne(item)
        item._id = response?.insertedID.objectIDValue

        return item
    }
}

main.swift 文件定义了应用程序的启动和关闭代码。更改代码以包含一个 defer 语句,以便在应用程序结束时关闭与 MongoDB 数据库的连接。

将该文件的内容替换为以下代码

api/Sources/Run/main.swift

import App
import Vapor
import MongoDBVapor

var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
try configure(app)

// shutdown and cleanup the MongoDB connection when the application terminates
defer {
  app.mongoDB.cleanup()
  cleanupMongoSwift()
  app.shutdown()
}

try app.run()

步骤 6:初始化 AWS Copilot

AWS Copilot 是一个用于在 AWS 中生成容器化应用程序的命令行实用程序。您可以使用 Copilot 构建 Vapor 代码并将其作为容器部署在 Fargate 中。Copilot 还为您的 MongoDB 连接字符串值创建并跟踪 AWS Systems Manager 密钥参数。您将此值存储为密钥,因为它包含数据库的用户名和密码。您永远不想将其存储在源代码中。最后,Copilot 创建一个 API Gateway 以公开 API 的公共端点。

初始化一个新的 Copilot 应用程序。

copilot app init todo

添加一个新的 Copilot 后端服务。该服务引用您的 Vapor 项目的 Dockerfile,以获取有关如何构建容器的说明。

copilot svc init --name api --svc-type "Backend Service" --dockerfile ./api/Dockerfile

为您的应用程序创建一个 Copilot 环境。环境通常与阶段对齐,例如 dev、test 或 prod。出现提示时,选择您使用 AWS CLI 配置的 AWS 凭证配置文件。

copilot env init --name dev --app todo --default-config

部署 dev 环境

copilot env deploy --name dev

步骤 7:为数据库凭证创建 Copilot 密钥

您的应用程序需要凭证才能向 MongoDB Atlas 数据库进行身份验证。您永远不应将此敏感信息存储在源代码中。创建一个 Copilot 密钥 来存储凭证。这会将您的 MongoDB 集群的连接字符串存储在 AWS Systems Manager 密钥参数中。

从 MongoDB Atlas 网站确定连接字符串。在集群页面上选择连接按钮,然后选择连接您的应用程序

Architecture

选择Swift version 1.2.0作为驱动程序,并复制显示的连接字符串。它看起来像这样

mongodb+srv://username:<password>@mycluster.mongodb.net/?retryWrites=true&w=majority

连接字符串包含您的数据库用户名和密码占位符。将 <password> 部分替换为您的数据库密码。然后创建一个名为 MONGODB_URI 的新 Copilot 密钥,并在提示输入值时保存您的连接字符串。

copilot secret init --app todo --name MONGODB_URI

Fargate 在运行时将密钥值作为环境变量注入到您的容器中。在上面的步骤 5 中,您在 api/Sources/App/configure.swift 文件中提取了此值,并使用它来配置您的 MongoDB 连接。

步骤 8:配置后端服务

Copilot 为您的应用程序生成一个 manifest.yml 文件,该文件定义了您的服务的属性,例如 Docker 镜像、网络、密钥和环境变量。更改 Copilot 生成的 manifest 文件以添加以下属性

要实施这些更改,请将 manifest.yml 文件的内容替换为以下代码。更新 MONGODB_DATABASE 和 MONGODB_COLLECTION 的值,以反映您在 MongoDB Atlas 中为此应用程序创建的数据库和集群的名称。

如果您在 Mac M1/M2 机器上构建此解决方案,请取消注释 manifest.yml 文件中的 platform 属性以指定 ARM 构建。默认值为 linux/x86_64

copilot/api/manifest.yml

# The manifest for the "api" service.
# Read the full specification for the "Backend Service" type at:
#  https://aws.github.io/copilot-cli/docs/manifest/backend-service/

# Your service name will be used in naming your resources like log groups, ECS services, etc.
name: api
type: Backend Service

# Your service is reachable at "http://api.${COPILOT_SERVICE_DISCOVERY_ENDPOINT}:8080" but is not public.

# Configuration for your containers and service.
image:
  # Docker build arguments. For additional overrides: https://aws.github.io/copilot-cli/docs/manifest/backend-service/#image-build
  build: api/Dockerfile
  # Port exposed through your container to route traffic to it.
  port: 8080
  healthcheck:
    command: ["CMD-SHELL", "curl -f https://127.0.0.1:8080 || exit 1"]
    interval: 10s
    retries: 2
    timeout: 5s
    start_period: 0s

# Mac M1/M2 users - uncomment the following platform line
# the default platform is linux/x86_64

# platform: linux/arm64

cpu: 256       # Number of CPU units for the task.
memory: 512    # Amount of memory in MiB used by the task.
count: 2       # Number of tasks that should be running in your service.
exec: true     # Enable running commands in your container.

# define the network as private. this will place Fargate in private subnets
network:
  vpc:
    placement: private

# Optional fields for more advanced use-cases.
#
# Pass environment variables as key value pairs.
variables:
 MONGODB_DATABASE: home
 MONGODB_COLLECTION: todolist

# Pass secrets from AWS Systems Manager (SSM) Parameter Store.
secrets:
 MONGODB_URI: /copilot/${COPILOT_APPLICATION_NAME}/${COPILOT_ENVIRONMENT_NAME}/secrets/MONGODB_URI

# You can override any of the values defined above by environment.
#environments:
#  test:
#    count: 2               # Number of tasks to run for the "test" environment.
#    deployment:            # The deployment strategy for the "test" environment.
#       rolling: 'recreate' # Stops existing tasks before new ones are started for faster deployments.

步骤 9:为您的 API Gateway 创建 Copilot 插件服务

Copilot 不具备为您的应用程序添加 API Gateway 的功能。但是,您可以使用 Copilot “插件”为您的应用程序添加额外的 AWS 资源。

通过在您的 Copilot 服务文件夹下创建一个 addons 文件夹,并创建一个 CloudFormation yaml 模板来定义您希望创建的服务,从而定义插件。

为插件创建一个文件夹

mkdir -p copilot/api/addons

创建一个文件来定义 API Gateway

touch copilot/api/addons/apigateway.yml

创建一个文件,将参数从主服务传递到插件服务中

touch copilot/api/addons/addons.parameters.yml

将以下代码复制到 addons.parameters.yml 文件中。它将 Cloud Map 服务的 ID 传递到插件堆栈中。

copilot/api/addons/addons.parameters.yml

Parameters:
   DiscoveryServiceARN:  !GetAtt DiscoveryService.Arn

将以下代码复制到 addons/apigateway.yml 文件中。它使用 DiscoveryServiceARN 创建一个 API Gateway,以便与 Copilot 为您的 Fargate 容器创建的 Cloud Map 服务集成。

copilot/api/addons/apigateway.yml

Parameters:
  App:
    Type: String
    Description: Your application's name.
  Env:
    Type: String
    Description: The environment name your service, job, or workflow is being deployed to.
  Name:
    Type: String
    Description: The name of the service, job, or workflow being deployed.
  DiscoveryServiceARN:
    Type: String
    Description: The ARN of the Cloud Map discovery service.

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

  ApiGatewayV2Api:
    Type: "AWS::ApiGatewayV2::Api"
    Properties:
      Name: !Sub "${Name}.${Env}.${App}.api"
      ProtocolType: "HTTP"
      CorsConfiguration:
        AllowHeaders:
          - "*"
        AllowMethods:
          - "*"
        AllowOrigins:
          - "*"

  ApiGatewayV2Stage:
    Type: "AWS::ApiGatewayV2::Stage"
    Properties:
      StageName: "$default"
      ApiId: !Ref ApiGatewayV2Api
      AutoDeploy: true

  ApiGatewayV2Integration:
    Type: "AWS::ApiGatewayV2::Integration"
    Properties:
      ApiId: !Ref ApiGatewayV2Api
      ConnectionId: !Ref ApiVpcLink
      ConnectionType: "VPC_LINK"
      IntegrationMethod: "ANY"
      IntegrationType: "HTTP_PROXY"
      IntegrationUri: !Sub "${DiscoveryServiceARN}"
      TimeoutInMillis: 30000
      PayloadFormatVersion: "1.0"

  ApiGatewayV2Route:
    Type: "AWS::ApiGatewayV2::Route"
    Properties:
      ApiId: !Ref ApiGatewayV2Api
      RouteKey: "$default"
      Target: !Sub "integrations/${ApiGatewayV2Integration}"

步骤 10:部署 Copilot 服务

在部署服务时,Copilot 执行以下操作:

copilot svc deploy --name api --app todo --env dev

步骤 11:配置 MongoDB Atlas 网络访问

MongoDB Atlas 使用 IP 访问列表来限制对数据库的访问,仅限于特定的源 IP 地址列表。在您的应用程序中,来自容器的流量源自您应用程序网络中 NAT 网关的公有 IP 地址。您必须配置 MongoDB Atlas 以允许来自这些 IP 地址的流量。

要获取 NAT 网关的 IP 地址,请运行以下 AWS CLI 命令:

aws ec2 describe-nat-gateways --filter "Name=tag-key, Values=copilot-application" --query 'NatGateways[?State == `available`].NatGatewayAddresses[].PublicIp' --output table

输出

---------------------
|DescribeNatGateways|
+-------------------+
|  1.1.1.1          |
|  2.2.2.2          |
+-------------------+

使用这些 IP 地址在您的 MongoDB Atlas 账户中为每个地址创建一个网络访问规则。

Architecture

步骤 12:使用您的 API

要获取 API 的终端节点,请使用以下 AWS CLI 命令:

aws apigatewayv2 get-apis --query 'Items[?Name==`api.dev.todo.api`].ApiEndpoint' --output table

输出

------------------------------------------------------------
|                          GetApis                         |
+----------------------------------------------------------+
|  https://[your-api-endpoint]                             |
+----------------------------------------------------------+

使用 cURL 或诸如 Postman 之类的工具与您的 API 交互

添加待办事项列表项

curl --request POST 'https://[your-api-endpoint]/item' --header 'Content-Type: application/json' --data-raw '{"name": "my todo item"}'

检索待办事项列表项

curl https://[your-api-endpoint]/items

清理

完成应用程序后,使用 Copilot 删除它。这将删除在您的 AWS 账户中创建的所有服务。

copilot app delete --name todo