Categories
English GIS

Creating Vector Tiles from Scratch using TypeScript

I currently work at a Japanese mapping startup called Geolonia. We specialize in customizing and displaying geospatial data, and a big part of this is making vector tiles. There are a lot of ways to do this, the most popular being tippecanoe from Mapbox, ST_AsMVT in PostGIS, OpenMapTiles, and tilemaker. We normally use tippecanoe to convert GeoJSON data to vector tiles and tilemaker to generate OpenStreetMap-based tiles, but there isn’t really an easy way to generate arbitrary vector tiles in plain old TypeScript / JavaScript.

Recently, I’ve been working on a proxy for converting data directly to vector tiles on the fly, and that is exactly what I need to do — generate arbitrary tiles without relying on outside processes like tippecanoe or PostGIS (this specific proxy will be running in AWS Lambda).

The Mapbox Vector Tile (MVT) spec is the de-facto standard for delivering tiled vector data, and that’s what we’ll be using. MVT tiles are Protocol Buffers-encoded files that include the drawing instructions for map renderers. The MVT spec handily includes the protobuf definition file, so we can use this to compile to JavaScript and generate the TypeScript definition files.

For the following, I’m assuming you have a git repository with the basic JavaScript and TypeScript essentials already set up.

Working with the MVT format in TypeScript

First, we create a submodule to bring in the MVT protobuf definition.

git submodule add https://github.com/mapbox/vector-tile-spec.git

This should create a new directory vector-tile-spec, with the contents of the MVT vector tile specification repository inside. Now, we can set up the protobuf-to-JS compiler.

My project uses ES modules, so these instructions will generate ES modules, but if you need something else, check out the protobufjs documentation.

In package.json, create a new script called build:protobuf:

"scripts": {
  ...
  "build:protobuf": "pbjs -t static-module -w es6 -o src/libs/protobuf.js vector-tile-spec/2.1/vector_tile.proto && pbts -o src/libs/protobuf.d.ts src/libs/protobuf.js"
}

Install protobufjs:

npm install --save protobufjs

Now, we’re ready to generate the code.

npm run build:protobuf

This command will generate two files: src/libs/protobuf.d.ts and src/libs/protobuf.js. Now, we’re ready to generate tiles.

The Mapbox Vector Tile format

Mapbox provides a very good guide on how data is laid out in the MVT container in their documentation. I highly recommend reading through it, but here are a few takeaways especially relevant to us:

  • Feature property values and keys are deduplicated and referred to by index.
  • Coordinates are encoded using “zigzag” encoding to save space while supporting negative coordinates. Starting from zero, 0, -1, 1, -2, 2, -3, 3…
  • Drawing points, lines, and polygons differ from GeoJSON in a few major ways:
    • Instead of absolute coordinates, drawing is done using a cursor and relative coordinates to the last operation.
    • Instead of declaring whether a geometry is a point, line, or polygon, MVT uses commands to imperatively draw geometry.
    • Coordinates are local to the tile, where (0,0) refers to the top-left corner of the tile.

Now that we’re ready to jump in to actually building up these tiles, let’s try some code.

import { vector_tile } from "../libs/protobuf";

// zigzag encoding
const zz = (value: number) => (value << 1) ^ (value >> 31);

const tile = vector_tile.Tile.create({
  layers: [
    {
      version: 2, // This is a constant
      name: "test", // The name of the layer
      extent: 256, // The extent of the coordinate system local to this tile. 256 means that this tile has 256×256=65536 pixels, from (0,0) to (256,256).
      features: [
        {
          // id must be unique within the layer.
          id: 1,
          type: vector_tile.Tile.GeomType.POLYGON,
          geometry: [
            // We start at (0,0)
            ((1 & 0x7) | (1 << 3)), // MoveTo (1) for 1 coordinate.
              zz(5), zz(5), // Move to (5,5)
            ((2 & 0x7) | (3 << 3)), // LineTo (2) for 3 coordinates.
              zz(1),  zz(0), // Draw a line from (5,5) to (6,5)
              zz(0),  zz(1), // Draw a line from (6,5) to (6,6)
              zz(-1), zz(0), // Draw a line from (6,6) to (5,6)
            15, // Close Path (implicitly closes the line from (5,6) to (5,5))
          ],
          tags: [
            0, // test-property-key-1
            0, // value of key 1

            1, // test-property-key-2
            1, // value of key 2 and 3

            2, // test-property-key-3
            1, // value of key 2 and 3
          ],
        }
      ],
      keys: [
        "test-property-key-1",
        "test-property-key-2",
        "test-property-key-3"
      ],
      values: [
        {stringValue: "value of key 1"},
        {stringValue: "value of key 2 and 3"},
      ],
    }
  ]
});

I tried to annotate the code as much as possible. Can you figure out what shape this draws?

Now, to convert this to binary:

const buffer: Buffer = vector_tile.Tile.encode(tile).finish();

And that’s it!

Conclusion

I was initially pretty scared about creating vector tiles from scratch — I’ve found them pretty hard to work with in the past, leaning on tools like vt2geojson to first convert them to GeoJSON. I was pleasantly surprised to find out that it wasn’t as hard as I thought it was going to be. I still got stuck on a few parts — it took me a few trial and error runs to figure out that my absolute-to-relative math was off — but once everything was working, it was definitely worth it.

Let me know what you think.

Categories
Uncategorized

My Desktop Environment

With the release of the Mac Studio recently — something that, for quite a long time, I thought I had been waiting for — I started thinking about what the ideal desktop environment for me is. This is what I use currently:

  • A PC (i9-10900k with 32GB of RAM) running Ubuntu, hooked up to a 28-inch 4K monitor.
  • A MacBook Air (M1, 2020 model)

I used to run macOS on the PC, but that experiment finished after a year or so. It was pretty stable and I had almost no problems, but hardware compatibility and performance was lacking. (Having a very Docker and Linux heavy workload meant that most of the time I was running Linux, virtualized in macOS, anyways, so I thought — hey, it would be better to just run Linux anyways, right?)

I think the setup I have now gets 95% of the way there, but it is far from perfect. There is a long list of nitpicks for both macOS and Ubuntu Linux, but they both have their time and place in my workflow.

For example, macOS has excellent keyboard shortcuts (the command key is the “killer feature” for me), a healthy ecosystem of apps (I’m still waiting for something like iTerm 2 for Linux, currently using WezTerm), and a general cohesiveness between apps. On the other hand, some programs just run much more smoothly on Linux: QGIS and Docker, to name the biggest pain points on macOS for me.

I then thought: if money was no object, what would I do? Would I throw away all my Linux stuff and go all-in on the fully loaded Mac Studio? Honestly, probably. But still — the M1 Ultra is “only” 20 cores. I could get a Threadripper system with 64 cores and 128 threads, with more IO than I could use and be happy with that for years.

So, I guess this all boils down to two paths: continue the dual Linux-Mac lifestyle, or relegate the PC to PC gaming and do all my work on the Mac.

Continuing down the dual Linux-Mac path probably means a little beefier workstation in a few years (I got the i9-10900k for macOS compatibility, if it’s just going to run Linux I would be able to get a Threadripper). Moving work back to the Mac means I’d have to work on the KVM setup.

Honestly, I think I’m going to continue dual Linux-Mac, at least for the next few years. I’m satisfied with my M1 MacBook Air, and for CPU heavy tasks I do, the i9-10900k is a great balance between power and versatility. Additionally, desktop Linux has gotten so much better. I’ve tried various distributions of Linux as my daily driver at various points of time in the past, but this is the first time that I’ve used it every day for more than a few months.

I’ve always wanted to get in to desktop Linux development. Maybe now’s the time to get more involved in the apps I use every day.

Categories
GIS

Serving real-time, tiled, point data directly from DynamoDB part 2 – the plan

I previously wrote about something I wanted to do with DynamoDB and geospatial data, and got a lot of responses on Twitter.

In the end, I think I’m going to go with a hybrid approach — using Uber’s H3 geocoding algorithm to generate clusters and to take care of indexing points, and then generating vector tiles as a separate process based on that data.

Here’s a bird’s-eye view of how the data will flow.

DynamoDB
trigger
DynamoDB…
New Point
H3 Index resolution 9
PK = PointData#89283470d93ffff
SK = {ULID}
New Point…
when n=0
when n=0
Refresh H3 Aggregation
H3 Index resolution n-1
PK = HexAgg#88283472b3fffff
SK = {ULID}
Refresh H3 Aggregation…
until n=0
until n=0
Generate Vector Tile
Tile Zoom n-1
PK = GenTile#z/x/y
SK = {ULID}
Generate Vector Tile…
until n=0
until n=0
Viewer does not support full SVG 1.1

And here’s the plan:

  1. A point is created/updated/deleted. This point has a H3 index resolution of 9, and looks something like this. I probably would be able to get away with resolution 8, but I’m going with 9 for now because it’s a good resolution for clusters when zoomed out.A screenshot of a highlighted H3 resolution 9 hexagon encompassing a portion of Tokyo Station(map data is © OpenStreetMap contributors, the hexagon grid viewer is clupasq/h3-viewer)
  2. This event is passed off to a function that calculates the parent hexagon of the point at resolution n-1 (for the first iteration, this would be 8). Then, all points within this hexagon are queried and aggregated, and written to an aggregation corresponding to resolution n-1. This function loops until we get to resolution 0.
  3. After the aggregation hexagons have finished calculating, we will do a similar process for generating the actual vector tiles. Some kind of mapping will have to be made between zoom levels and H3 resolutions, but this is going to be highly subjective and I plan to figure it out on the fly.

And that’s about it — all I have to do now is actually implement it. I have a few worries about what the end result is going to turn out: higher level aggregations not being perfect due to hexagons not cleanly subdividing, or positions of clusters not lining up with the data (for example, if the data in a hexagon is heavily weighted to one side, showing a corresponding cluster at the center of the hexagon would not be appropriate)… These problems will become apparent when I start the implementation, and I already have seeds of ideas that I might be able to use to mitigate them.

Anyway, that’s it for today. Thank you to everyone who responded to my tweets! If you have any comments, please don’t hesitate to comment.

Categories
English GIS

Serving real-time, tiled, point data directly from DynamoDB

Recently, I’ve been interested in how to serve and work with geographic data with the least amount of “work” possible. Everything here can be done pretty easily with PostGIS and a large enough server, but I’m always up for the challenge of doing things in a slightly different way.

I’m working on an app that will store lots of points around the world — points that people have submitted. Users will open this app, and be presented with a map with points that have been submitted in their vicinity. Once they zoom out, I want to show clusters of points to the users, with a number showing how many points are represented in that cluster.

There are two major ways to do this:

  • Get the bounding box (the coordinates of what the user’s map is currently displaying), send it to the server, and the server will return a GeoJSON object containing all the points in that bounding box.
  • Split the data in to predefined tiles and the client will request the tiles that are required for the current map.

There are pros and cons for both of these methods:

  • GeoJSON would contain the raw data for all points within the bounding box. When the user is zoomed in, this is not a big problem, but when they’re zoomed out, the client will have to do a lot of work calculating clusters. Also, as the user pans around, new queries would have to be made to piece together the point data for areas they don’t have the data for. Not only can this data get pretty big, the user experience is subpar — data is normally loaded after the map pan finishes.
  • Tiles at the base zoom level (where all data is shown, without any clustering) can be generated on-the-fly easily, but tiles at lower zoom levels need to use all of the information in the tile bounds to generate clusters.

This basically boils down to two solutions: let the client process the data, or let the server process the data. Serving the whole GeoJSON document may work up to a point (I usually use something like 1-2MB as a general limit), datasets larger than that need to be tiled. I use Mapbox’s tippecanoe for this, but tippecanoe takes a GeoJSON file and outputs a static mbtiles file with precomputed tiles. Great for static data, but updating this means regenerating all the tiles in the set.

This is where the title of this post comes in. I’ve been thinking about how to serve multiple large tilesets directly from DynamoDB, and I think I have a pretty good idea of what might work. If there’s something like this that already exists, or you find something wrong with this implementation, please let me know!

Here is the basic data structure:

PKSK
Raw DataTileset#{tileset ID}#Data#{first 5 characters of geohash}{full geohash}#{item ID}
Pregenerated TileTileset#{tileset ID}#Tile#{z}/{x}/{y}{generated timestamp}

This is how it’s supposed to work: raw point data is stored conforming to the “Raw Data” row. Updates to raw data are processed by DynamoDB streams, and a message to update the tiles that point exists in is enqueued.

Because this potentially can get very busy (imagine a case where hundreds or thousands of points in the same tile are updated within seconds of each other), the queue would have a delay associated with it . The processor that generates tiles based on messages in the queue would take a look at the tile it’s about to generate, and compare the generated timestamp with the timestamp of the item update. If the tile timestamp is newer then the item, that item can be safely disregarded, because another process has already picked it up.

I initially thought about using tile numbers to index the raw data as well, but decided to use geohash instead. The reasoning behind this is because geohash is a Z-order grid, we can use this property to optimize DynamoDB queries spanning multiple cells. Another problem with using tile numbers is the ambiguity of points that lie on tile boundaries. In contrast, the precision of a geohash can be arbitrarily increased without affecting queries.

Is there a way to encode tile numbers in to a Z-order curve? Maybe interpolating the X and Y values bitwise? How can this account for the zoom parameter? Am I overthinking this? Can I get away with using a sufficiently high zoom parameter (like z=22 or 23)? (How about a crazier idea: can the geohash algorithm be massaged in to corresponding directly to tiles? Is it worth it?)

Anyways, this is sort of something that I’ve been thinking about for a while, and I want to get started on a proof-of-concept implementation in the near future. Let me know on Twitter if you have any input / comments, or even better, if you want to collaborate with me on making something like this.

Categories
AWS

Working with DynamoDB Global Tables

Just some stuff I’ve picked up while working with DynamoDB Global Tables. This was my first time using it; I used it to move a few tables from one region to another without downtime.

When deleting replica tables…

Note that this operation will delete the replica table and is non-reversible. This replica table cannot be re-added later to the global table.

This warning message is a little misleading — the replica table will be deleted, but it’s possible to re-create a new replica table in the region that was deleted.

Replica cannot be deleted because it has acted as a source region for new replica(s) being added to the table in the last 24 hours.

You have to wait for 24 hours before you can delete the source region.

Other stuff:

  • If you create a GSI in one region, it will automatically be created in all other regions as well.
  • If you delete a table from the list of tables (instead of the “Global Tables” tab), it will delete normally. All of the other tables in other regions will be unaffected.
Categories
English

For those times you don’t want to eval…

I wanted to make some advanced logic available, easily configurable via a database, in a couple apps that I’ve been working on recently.

Honestly, I could have just stored the code in the database and eval’d it — but no, I don’t want to take the risk of arbitrarily executing code. I could have done some gymnastics like running it in a network-less container with defined inputs and outputs. I could have made the configuration more capable. What I decided to do in the end, though, was to write an extremely compact domain-specific language (DSL).

To write this DSL, I chose a Lisp-style syntax due to its dead-simple parsing. The basic idea is to parse the string for tokens, generate an abstract syntax tree (AST), then just recurse through the AST and run whatever code is required.

In one example, I wanted to have an extendible SQL WHERE clause, with a very limited set of operators — AND, OR, LIKE, =.

(and (like (attr "person.name") (str "%Keita%")) (= (attr "person.city) (str "Tokyo")))

This example will generate the SQL WHERE clause:

((person.name LIKE '%Keita%') AND (person.city = 'Tokyo'))

Here’s pseudocode for how I write the parser / interpreter for this:

COMMANDS = {
  "and": (left, right) => { return f"(({left}) AND ({right}))" }
  "or":  (left, right) => { return f"(({left}) OR ({right}))" }
  "like":(left, right) => { return f"(({left}) LIKE ({right}))" }
  "=": (left, right) => { return f"(({left}) = ({right}))" }
  "str": (str) => { return escape_sql(str) }
  "attr": (str) => { return escape_sql_for_attr_name(str) }
}

def parse_ast(code):
  # parse the "code" string into nested arrays:
  # "(1 (2 3))" becomes ["1", ["2", "3"]]
  ...

def execute_node(ast):
  cmd = ast[0]
  argv = ast[1:]
  resolved_argv = [ execute_node(x) for x in argv ]
  return COMMANDS[cmd](*resolved_argv)

def execute(code):
  ast = parse_ast(code)
  execute_node(ast)

As you can see, this is a very simple example that takes the DSL and transforms it in to a SQL string. If you wanted to do parameterized queries, you might return a tuple with the string as the first element and a map of parameters for the second, for example.

The ability to map the language so closely to the AST, and being able to evaluate the AST just by recursion, makes this implementation simple and easy to write, easy to extend, and easy to embed in existing applications. While I probably won’t be switching to writing Common Lisp full time (for practical reasons), I definitely do get the appeal of the language itself.

This tool isn’t something I use all the time. It’s probably something that should be used very sparingly, and in specific circumstances. That said, it’s a good tool in my toolbox for those times for when I want to have on-the-fly customizable logic without the security concerns of using eval, or the complexity of creating a sandboxed environment for potentially unsafe code.

Last note: while this solution may be more secure than eval, it is definitely not 100% secure. In the simple example above, we do escape strings so SQL injection shouldn’t be a problem, but it doesn’t check if the column defined by the attr function is valid, or if the user is allowed to query information based on that column or not (although something like that would be possible). I would not use something like this to process completely untrusted input.

Categories
AWS English

My Brief Thoughts on the AWS Kinesis Outage

There have been multiple analyses about the recent (2020/11/25) outage of AWS Kinesis and its cascading failure mode, taking a chunk of AWS services with it — including seemingly unrelated Cognito — due to dependencies hidden to the user. If you haven’t read the official postmortem statement by AWS yet, go read it now.

There are an infinite amount of arguments that can made about cascading failure; I’m not here to talk about that today. I’m here to talk about a time a few years ago I was evaluating a few systems to do event logging. Naturally, Kinesis was in consideration, and our team interviewed an AWS Solution Architect about potential design patterns we could implement, what problems they would solve, what hiccups we may encounter on the way, et cetera.

At the time, I didn’t think much of it, but in hindsight it should have been a red flag.

ME: “So, what happens when Kinesis goes down? What kind of recovery processes do you think we need to have in place?”

SA: “Don’t worry about that, Kinesis doesn’t go down.”

The reason I didn’t think much of it at that time was that our workload would have been relatively trivial for Kinesis, and I mentally translated the reply to “don’t worry about Kinesis going down for this particular use case”.

We decided to not go with Kinesis for other reasons (I believe we went with Fluentd).

Maybe my mental translation was correct? Maybe this SA had this image of Kinesis as a system that was impervious to fault? Maybe it was representative of larger problem inside AWS of people who overestimated the reliability of Kinesis? I don’t know. This is just a single data point — it’s not even that strong. A vague memory from “a few years ago”? I’d immediately dismiss it if I heard it.

The point of this post is not to disparage this SA, nor to disparage the Kinesis system as a whole (it is extremely reliable!), but to serve as a reminder (mostly to myself) that one should be immediately suspicious when someone says “never” or “always”.

Categories
English

Venturing in to the realm of Hackintosh-ing

Like you, I’ve been finding myself working from home more often than not. These days, I probably go to an office once a month. I have a 16 inch MacBook Pro, but using it in clamshell mode, all the time, connected to a 4K monitor was… not ideal. It would often thermally throttle way down (often, it would be really sluggish — I wondered, how fast is this running? 800MHz. 90C. Fans at 100%.)

The 16″ MacBook Pro was a great machine for when I’d go to an office 3-5 days a week, but it just doesn’t make sense when it’s essentially used as a desktop.

This is where I thought, why don’t I just get a desktop Mac, then? That gives me a few choices: iMac, iMac Pro, Mac Pro, Mac Mini.

iMac (Pro): Pretty good performance, but a little expensive for my target price. Also, I had just gotten a new 4K monitor, and I didn’t want to get rid of it so soon.

Mac Pro: Way too expensive.

Mac Mini: Probably pretty similar in performance and thermal characteristics to my MacBook Pro.

I didn’t want to spend too much on it — especially with the Apple Silicon Macs coming out, meaning Intel support is on its way out, limiting the longevity of whatever I’d be buying. I’ll probably get a 2nd or 3rd generation Apple Silicon Mac, so what I wanted is something high performance that makes sense to bridge the gap of a couple years.

This led me down the rabbit hole of building a Hackintosh. My target price was “less than $2,000” (the price also is due to tax reasons — I thought it would be more fun to build a PC than it would be to calculate depreciation over 4 years). r/hackintosh on Reddit was a huge help — it was an invaluable resource when picking parts. I would look through everyone’s success stories and pick similar (or identical) parts. Here’s my entry (from when it had Catalina — I have since upgraded it to Big Sur).

Using some parts from my previous PC, the new parts came out to about $1,500. Not bad for a 10-core i9-10900k with 32GB RAM and a 1TB SSD.

I’ve never done water cooling before, and it sounded pretty cool, so I got an all-in-one unit that was really easy to install. Building a custom water cooling loop sounds fun, but really time and tool intensive — not something I have a lot of here in Tokyo. Maybe next time.


All in all, doing this Hackintosh was a fun little project. There are a lot of excellent resources that hold your hand through the whole process. If you’re interested in trying it out for yourself, I recommend reading the OpenCore Install Guide and looking through the aforementioned Reddit community.

When I started out to write this blog post, I installed Catalina 10.15.7 with OpenCore 0.6.2. Since then, I’ve upgraded to Big Sur 11.0.1 with OpenCore 0.6.3. I’ve only done one major upgrade, but it was relatively smooth.

One word of warning, though: Hackintosh-ing is definitely not for the faint of heart, or someone who is not prepared to spend hours debugging some issue that involves reading a bunch of white text on a black background. I would not recommend this to anyone who doesn’t like fiddling with computers. I had a bunch of small, strange issues (Ethernet instability, hardware video decoding, to name a couple) that required multiple reboots and trial-and-error with the config.plist configuration file.

Thanks for reading! If you have any questions, don’t hesitate to leave a comment or send me a Tweet.

Categories
WordPress 日本語

このブログの多言語対応について

このブログは、WordPress のカテゴリーを使って言語を分けています。

私自身は英語ネイティブなのでデフォルトの言語は英語に設定しておりますが、日本語の投稿も書く機会もあります。僕が理想と思ったプラグインがなかなか無くて、簡単なのを作ろうと思いました。

大体の多言語対応プラグインは機能が多くて管理が結構大変なイメージだったけど、私のような「ほとんど英語で書くけど、たまには日本語で書きたい」ニーズに答えられるのがありませんでした。

日本語の投稿の執筆時に、「日本語」というカテゴリーを選択します(厳密にいうと、カテゴリーのスラッグが japanese であることを使っています)。そうすると、その記事の設定が日本語設定になって、フォントの設定やテーマの翻訳が自動的に日本語になります。

コードは GitHub 上に公開しましたが、日本語しか対応していないためプラグインディレクトリに提出しておりません。使いたい方はご自身でダウンロードしてカスタマイズした上で使ってください。もし不明点や興味があればコメントや Twitter で声かけてくれると嬉しいです。

Categories
AWS WordPress 日本語

WordPress を AWS Lambda で運用する

以前 WordPress を AWS Lambda で 運用する記事を投稿 (英語) しましたが、EFS対応前に執筆しました。EFS を使える様になって、WordPress を AWS Lambda 内の実行環境が完全に変わったので新しい記事を書きました。

今回は、SAM より Terraform を選びました。理由はいくつかありますが、主には私が管理するインフラはほぼ Terraform で管理されているのため、既存環境と融合性が優れてる。

Terraform モジュールとして公開しています。ソースコードは GitHub で公開しています。

実際どうなの?

まあまあいいよ。このリンク先で稼働しています。めちゃくちゃ速いわけでもないけど、遅すぎというわけでもない。CloudFrontを利用して静的アセットをキャッシュし、 opcache をチューニングしたらだいぶ速くなった。

通常なら Lambda が同時並行で起動されるときは個々のインスタンスが独立されて実行されれますが、 EFS を使えば異なる Lambda のインスタンスを跨いでファイルシステムを同期させることができる。このため、通常通り WordPress の更新、テーマやプラグインインストール、アップロード等利用できる。

準備するもの

今回のチュートリアルでは、 Lambda のソースコードに入ってるのは PHP を実行する環境のみ。 WordPress のファイル等は、 EFS のボリュームにインストールするので、別途 EC2 のインスタンスを用意する必要があります。

下記に、具体的に何を用意しないといけないのをリストしました。

  1. 有効なAWSアカウント
  2. インターネットにアクセスできるプライベートサブネット。 EFS を使うために VPC 内に Lambda を起動する制約がありますが、そうすると NATゲートウェイNATインスタンス (比較) を使わないとインターネットにアクセスできない。
  3. Terraform 0.12 以上
  4. MySQL データベース
  5. WordPress のファイルを初期インストールするための EC2 インスタンス

Terraform が作成するリソース一覧はこちらにあります

ステップ

今回は独立した Terraform モジュールを使いますが、他の Terraform の環境に埋め込む場合は適宜修正してください。

このチュートリアルをそのまま使う場合、us-west-2 リージョンを使ってください。PHP は標準で提供されていないため、カスタムの Lambda レイヤーを使って実行します。私が公開したレイヤーが現在、 us-west-2 しか公開していない。他のリージョンでも公開することを努めていますが、その間は私がフォークした php-lambda-layer を直接作成することができます。

1. EC2インスタンスを起動する

今回、 t3a.nano を選びました。amazon-efs-utils のパッケージを予めインストールしておくのはおすすめです。

コンソールにいるついでに、データベースにアクセスできるセキュリティグループのIDと Lambda を起動するプライベートサブネットのIDをメモしておいてください。

2. Terraform 環境を作成する
$ git clone https://github.com/KotobaMedia/terraform-aws-wordpress-on-lambda-efs
$ cd ./terraform-aws-wordpress-on-lambda-efs

ディレクトリに local.auto.tfvarsというファイルを作って、下記の情報を入れます。

# ステップ1でメモしたセキュリティグループのIDを配列に入れます
security_group_ids = ["sg-XXX"]

# ステップ1でメモしたサブネットのIDを配列に入れます
subnet_ids = ["subnet-XXX", "subnet-XXX", "subnet-XXX"]

もし ~~.cloudfront.net のデフォルトドメインより、カスタムドメインを使う場合は、 acm_certificate_namedomain_name 変数も指定します。

Apply すると、terraform がリソースを作ってくれます。

$ terraform apply

AWS 認証情報を求められる場合、中止 (Ctrl-C) した上、環境変数で認証情報を設定してください。私の場合、複数のAWS環境を管理しているので、AWS_PROFILE という環境変数をよく使います。

Terraform が実際にインフラのリソースを作成する前に、実際稼働しているインフラのリソースの差分を出します。今回は新しく作成しているはずなので、全て「追加」というように出ると思います。確認した上で、 yes を答えてください。

CloudFront distribution が入ってるため、apply に多少時間かかる(僕の場合は、全部で5分ぐらいかかりました)。完了したら、アウトプット変数がいくつか表示されます。この変数をまただす場合は、 terraform output コマンドを使ってください。

そのターミナルをそのまま開いてください、後で使います。

3. EC2 に EFS のファイルシステムをマウントする

EC2 が EFS にアクセスするためにセキュリティグループをアサインする必要があります。ステップ2の Terraform が EFS にアクセスできるためのセキュリティグループを作ってくれたので、それを使いました。 efs_security_group_id のアウトプット変数の値を EC2 インスタンスに貼り付けてください

次、EC2 にログインして、 EFS をマウントしましょう。下記のコマンドから、fs-XXXXXefs_file_system_id のアウトプット変数の値で置き換えてください。

$ sudo -s
# mkdir /mnt/efs
# mount -t efs fs-XXXXX:/ /mnt/efs

もし問題などあれば、ユーザーガイドによくある問題をリストしているので、確認してください。

4. WordPress をインストールしましょう

ファイルシステムがマウントされて、やっと WordPress のファイルをインストールできるようになりました。 Terraform がランダムで新しいディレクトリを作った( /mnt/efs/roots/wp-lambda-$ランダム )ので、そこに cd しましょう。

最新の WordPress をダウンロードして、そこに解凍してください。

ここから、通常通りの WordPress のインストールを進められることができると思います。カスタムドメインを利用していない場合は cloudfront_distribution_domain_name でアクセスできます。カスタムドメインを利用している場合は、CloudFront のdistribution のドメインに CNAME を向けて、指定したドメインでアクセスできると思います。

ここから何ができるか

このままだとパーフォーマンスが最高とは言えないのですが、下記の最適化も加えられると考えられます。

  • アップロードファイルを EFS より S3 にアップロードする。私は Humanmade の S3 Uploads プラグインをよく使います。
  • src/php.ini に入ってる opcache の設定を調節する。
  • 静的アセット( JS / CSS 等)を軽量な nginx サーバーで返す。
  • handler.php を調節して Cache-Control を追加する。これによって、 CloudFront のキャッシュがもっと使えるようになります。

制約

AWS のサービスの技術的な制約によって下記のようなリミットがあります。

この運用の形によって、他の制約もあります。

  • FTP やログイン可能な SSH アクセスがありません。EC2 インスタンスを使ってファイルを管理しないといけない。
  • 無限に同時並行で実行できるプラットフォーム Lambda から接続数が有限な MySQL に接続します。もし接続で問題になるところがあれば Aurora Serverless を試してみる価値があると思います。

最後まで読んでくれてありがとうございます!

質問、難しかったところ、改善してほしいところ、コメント等は下記のコメントフォームや Twitter で連絡してください。