通过AWS AppSync+Terraform自动创建无服务器的Web应用程序

首先

因为听到了一个声音,它说如果你要做无服务器,不仅仅要了解API Gateway,还要了解AppSync,所以我试着制作了一个。

也许只要习惯了就会很方便。学习GraphQL和VTL会有一些成本。

全体成员组成

以下是构建的结构。由于使用了AppSync,所以就连Lambda也不需要了。静态内容从S3获取,而AppSync从DynamoDB获取数据。虽然不需要经过CloudFront,但由于AppSync无法处理CORS,为了使静态内容与同一来源,需要将其挂载到CloudFront上。

構成図.png

现在,我们来逐个查看每个Terraform资源。

Terraform资源定义

DynamoDB – 动态数据库

DynamoDB将用户ID作为哈希键创建表。
在这里,注册姓名和年龄。

resource "aws_dynamodb_table" "user" {
  name         = local.dynamodb_table_name
  billing_mode = "PAY_PER_REQUEST"

  hash_key = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

resource "aws_dynamodb_table_item" "user" {
  count = 1

  table_name = aws_dynamodb_table.user.name
  hash_key   = aws_dynamodb_table.user.hash_key
  range_key  = aws_dynamodb_table.user.range_key

  item = <<ITEM
{
  "id": {"S": "00001"},
  "name": {"S": "Taro"},
  "age": {"S": "35"}
}
ITEM
}

S3储存桶

定义包含静态内容的S3存储桶。
通常情况下,如果要使用CloudFront,最好将其设置为私有以防止直接访问,并设置原始资源访问标识(Origin Access Identity)。但由于这次只是试用,所以我简略了这一步骤。

另外,为了方便起见,本次我们将使用 Vue.js 2.x 的CDN版来创建Web应用的内容。

resource "aws_s3_bucket" "contents" {
  bucket = local.bucket_name
  acl    = "public-read"

  website {
    index_document = "index.html"
  }
}

resource "aws_s3_bucket_object" "index" {
  bucket       = aws_s3_bucket.contents.id
  source       = "../contents/index.html"
  key          = "contents/index.html"
  acl          = "public-read"
  content_type = "text/html"
  etag         = filemd5("../contents/index.html")
}

resource "aws_s3_bucket_object" "app" {
  bucket       = aws_s3_bucket.contents.id
  source       = "../contents/app.js"
  key          = "contents/app.js"
  acl          = "public-read"
  content_type = "text/javascript"
  etag         = filemd5("../contents/app.js")
}

AppSync 同步应用

这次的关键是AppSync。
可以通过设置 API 密钥作为标头来访问。在这种情况下,建议同时定义 aws_appsync_api_key。可以使用 aws_appsync_api_key.test.key 来引用密钥信息,将其输出会更好。

resource "aws_appsync_graphql_api" "test" {
  name                = local.appsync_graphql_api_name
  authentication_type = "API_KEY"
  schema              = data.local_file.graphql_schema.content
}

data "local_file" "graphql_schema" {
  filename = "./appsync_schema.graphql"
}

resource "aws_appsync_api_key" "test" {
  api_id      = aws_appsync_graphql_api.test.id
  description = "${var.prefix}用APIキー"
}

resource "aws_appsync_datasource" "dynamodb" {
  api_id           = aws_appsync_graphql_api.test.id
  name             = local.appsync_dynamodb_datasource_name
  service_role_arn = aws_iam_role.appsync.arn
  type             = "AMAZON_DYNAMODB"

  dynamodb_config {
    table_name = local.dynamodb_table_name
  }
}

resource "aws_appsync_resolver" "createuser" {
  api_id      = aws_appsync_graphql_api.test.id
  field       = "createUser"
  type        = "Mutation"
  data_source = aws_appsync_datasource.dynamodb.name

  request_template  = file("./createuser_request.template")
  response_template = file("./createuser_response.template")
}

resource "aws_appsync_resolver" "user" {
  api_id      = aws_appsync_graphql_api.test.id
  field       = "user"
  type        = "Query"
  data_source = aws_appsync_datasource.dynamodb.name

  request_template  = file("./user_request.template")
  response_template = file("./user_response.template")
}

在 `aws_appsync_datasource` 中进行 DynamoDB 连接的配置,但需要将访问 DynamoDB 的权限授予服务角色,因此定义如下所示。

resource "aws_iam_role" "appsync" {
  name               = local.appsync_role_name
  assume_role_policy = data.aws_iam_policy_document.appsync_assume.json
}

data "aws_iam_policy_document" "appsync_assume" {
  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "Service"
      identifiers = [
        "appsync.amazonaws.com",
      ]
    }
  }
}

resource "aws_iam_role_policy_attachment" "appsync" {
  role       = aws_iam_role.appsync.name
  policy_arn = aws_iam_policy.appsync_custom.arn
}

resource "aws_iam_policy" "appsync_custom" {
  name   = local.appsync_policy_name
  policy = data.aws_iam_policy_document.appsync_custom.json
}

data "aws_iam_policy_document" "appsync_custom" {
  statement {
    effect = "Allow"

    actions = [
      "dynamodb:BatchGetItem",
      "dynamodb:GetItem",
      "dynamodb:Query",
      "dynamodb:Scan",
      "dynamodb:BatchWriteItem",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
    ]

    resources = [
      "*",
    ]
  }
}

在 AWS AppSync GraphQL API 中定义了以下架构。

schema {
  query: Query
  mutation: Mutation
}

type Query {
  user(id: ID!): User
}

type Mutation {
  createUser(name: String!): User
}

type User {
  id: ID!
  name: String!
  age: String!
}

现在,数据源和模式的定义已经完成,我们要将其设置到解析器中。
在解析器中,可以使用VTL来控制请求和响应的信息,如下所示。

{
    "version" : "2017-02-28",
    "operation" : "PutItem",
    "key" : {
        "id" : { "S" : "$util.autoId()" }
    },
    "attributeValues" : {
        "name" : { "S" : "${context.arguments.name}" },
    }
}
$utils.toJson($context.result)
{
    "version" : "2017-02-28",
    "operation" : "GetItem",
    "key" : {
        "id" : $util.dynamodb.toDynamoDBJson($ctx.args.id)
    }
}
$utils.toJson($ctx.result)

完成到这一步,我们可以确认AppSync的运行情况,接下来尝试进行正常性确认,步骤如下:
x-api-key 是之前分配的API密钥。

curl \
  -w "\n" \
  -H 'Content-Type: application/json' \
  -H "x-api-key: xxx-xxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -X POST -d '
    {
      "query": "query { user(id: \"00001\") { id name } }"
    }' \
  https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql
curl \
  -w "\n" \
  -H 'Content-Type: application/json' \
  -H "x-api-key: xxx-xxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -X POST -d '
    {
      "query": "mutation { createUser(name: \"Jiro-san\") { name } }"
    }' \
  https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql

云前缘

CloudFront的多源定义如下。AppSync的domain_name出现问题是因为Terraform没有一个能够正确获取AppSync domain_name属性的功能…遗憾。

resource "aws_cloudfront_distribution" "appsync" {
  origin {
    domain_name = trimsuffix(trimprefix(aws_appsync_graphql_api.test.uris["GRAPHQL"], "https://"), "/graphql")
    origin_id   = local.cloudfront_appsync_origin_id

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1", "TLSv1.1", "TLSv1.2"]
    }
  }

  origin {
    domain_name = aws_s3_bucket.contents.bucket_regional_domain_name
    origin_id   = local.cloudfront_s3_origin_id
  }

  enabled = true
  comment = "AppSync用CloudFront"

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.cloudfront_appsync_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "allow-all"
  }

  ordered_cache_behavior {
    path_pattern     = "/contents/*"
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.cloudfront_s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "allow-all"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

只要再走一步,就可以到达这里了。

靜態內容

请确保提到分配的 CloudFront 域名和 API 密钥,以以下方式构建 Vue.js 的静态内容。

虽然称之为GraphQL,但本质上只是向服务器发出JSON的POST请求,所以我们可以使用axios轻松拉取数据。

JavaScript的代码可能没有经过eslint处理,或者错误处理不够严谨,但这只是试验阶段,请不要在意这些问题。

<html>
  <head>
    <style>
      [v-cloak] { display: none }
    </style>
    <meta charset="utf-8">
    <title>Vue TEST</title>
  </head>
  <body>
    <div id="myapp" v-cloak>
      <input type="text" v-model="user_id" placeholder="ユーザID(5桁)を入力">
      <button v-on:click="check_employee" v-bind:disabled="is_invalid">確認</button>
      <table border="1" v-if="user_info">
        <tr><th>id</th><th>name</th><th>age</th></tr>
        <tr v-model="item"><td>{{ item['id'] }}</td><td>{{ item['name'] }}</td><td>{{ item['age'] }}</td></tr>
      </table>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/contents/app.js"></script>
  </body>
</html>
const APIGATEWAY_INVOKE_URL = 'https://[CloudFrontのドメイン]/graphql'
const APPSYNC_API_KEY = '[払い出したAPIキー]'

const app = new Vue({
  el: '#myapp',
  data: {
    user_info: false,
    user_id: '',
    is_invalid: true,
    item: null
  },
  watch: {
    user_id: function (newVal, oldVal) {
      this.is_invalid = newVal.length !== 5
    }
  },
  methods: {
    check_employee: function () {
      const headers = {
        'x-api-key': `${APPSYNC_API_KEY}`
      }

      const body = {
        query: `query{
          user(id: "${this.user_id}\")
          { 
            id
            name
            age
          }
        }`
      }

      axios
        .post(`${APIGATEWAY_INVOKE_URL}`, body, { headers: headers })
        .then(response => {
          this.item = response.data.data.user
          this.user_info = true
        })
    }
  }
})

app.$mount('#app')

完成了!当你从浏览器访问CloudFront的内容时,试试看…

キャプチャ.png

我动了!

虽然还没有深入研究过在GraphQL中可以实现多复杂的事情,但至少看起来比API Gateway的AWS服务集成更加灵活。