自由帳

@_nibral の技術ブログ

ECS + FargateでgRPCを動かす

gRPCでリクエストを受けるアプリをECS + Fargateで動かしつつ、ちゃんと負荷分散するために調べたことのメモ。

先に結論

リバースプロキシとしてenvoyを走らせて、ECS Service Discovery経由でつなぐのが良さそう。インターネットからの通信を受けたいならALBではなくNLBを使う。

検証用プログラム

gRPCでUUIDを返す。UUIDはサーバの起動時に1回だけ生成するので、UUIDの値を比較すれば負荷分散ができているかがわかる。

github.com

hello.proto

syntax = "proto3";

package hello;

option go_package = ".;main";

message HelloRequest {

}

message HelloReply {
  string msg = 1;
}

service Hello {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

server.go

package main

import (
    "context"
    "github.com/google/uuid"
    "google.golang.org/grpc"
    "log"
    "net"
)

type Service struct {
    id string
}

func (service *Service) SayHello(ctx context.Context, message *HelloRequest) (*HelloReply, error) {
    return &HelloReply{
        Msg: "Hello from " + service.id,
    }, nil
}

func main() {
    port, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalln(err)
    }
    server := grpc.NewServer()
    helloService := &Service{
        id: uuid.New().String(),
    }
    RegisterHelloServer(server, helloService)
    err = server.Serve(port)
    if err != nil {
        log.Fatalln(err)
    }
}

client.go

package main

import (
    "context"
    "flag"
    "fmt"
    "google.golang.org/grpc"
    "log"
)

func main() {
    flag.Parse()
    conn, err := grpc.Dial(flag.Arg(0), grpc.WithInsecure())
    if err != nil {
        log.Fatalln(err)
    }
    defer conn.Close()
    client := NewHelloClient(conn)
    msg := &HelloRequest{}
    res, err := client.SayHello(context.TODO(), msg)
    if err != nil {
        fmt.Printf("error::%#v \n", err)
    }
    fmt.Println(res.Msg)
}

ECSで動かしたいのでDockerfileも用意した。単にgo buildをするだけでなく、マルチステージビルドにしてdockerイメージを軽くするとかAlpine Linuxglibcを入れるとかもやっている。

FROM golang:latest as builder

WORKDIR /go/src/grpc_sample
COPY server.go hello.proto ./

RUN apt update \
    && apt install -y protobuf-compiler \
    && go get google.golang.org/grpc \
    && go get github.com/golang/protobuf/protoc-gen-go
RUN protoc --go_out=plugins=grpc:. hello.proto

RUN go get -v ./...
RUN go build -o server server.go hello.pb.go

FROM alpine:latest

COPY --from=builder /go/src/grpc_sample/server server

RUN apk update && apk add libc6-compat

EXPOSE 50051

ビルドしたdockerイメージをECRにプッシュしたらECSの検証に移る。

ECSとロードバランサーとgRPC

ECSの負荷分散だとApplication Load Balancer(ALB)が使われることが多いが、ALBのターゲット側はHTTP/1.1にしか対応していないためgRPC(HTTP/2)では使えない。

f:id:nibral:20200906172522p:plain
ALB構成

ではどうするかというと、ALBの代わりにNetwork Load Balancer(NLB)を使う。NLBはレイヤー4(TCP)で処理を行うので、gRPCの通信も問題なく通過できる。これで一見良さそうに見えるが、設定を進めてみるといくつかの問題点が判明した。

  • HTTP/2は1つのTCPコネクションを長く使うため、適切な負荷分散が行われない可能性がある(らしい)
    • 特定のコンテナに負荷が偏る
    • 今回使用したサンプルプログラムでは1回1回通信を張るので確認できず
  • セキュリティグループの設定ができない
    • コンテナにはクライアントの送信元IPがそのまま届くので 0.0.0.0/0 を許可するしかない
  • SSLの終端ができない
    • NLBより後ろでSSLの処理を行う必要がある
    • ACMで発行した無料のSSL証明書が使えない

f:id:nibral:20200906172533p:plain
NLB構成

セキュリティグループは頑張ってIPを設定する、SSLはいったん諦めるとして、負荷分散はちゃんとやりたいということでたどり着いた構成が以下。

NLBとサーバの間にEnvoyというプロキシを挟む。envoyからサーバへ通信するためには各サーバコンテナのIPを知る必要があるので、ECS Service Discoveryを使って server.grpc.local がコンテナのIPを返すようにしておく。

f:id:nibral:20200906173807p:plain
envoy + ECS Service Discovery構成

NLBのDNS名に対してクライアントからリクエストした様子。c9cf2091- で始まるUUIDと 62aa1e41- のUUIDが返ってきているのでちゃんと動いているはず。

f:id:nibral:20200906175206p:plain
サーバ2台で負荷分散

envoy.yaml

envoy 1.16.0-dev-0b24c6で動作確認。envoy API v3。ネット上で見つかる記事だとv2の記述が多いので、調べるときは記事の日付を見た方が良い。

admin:
  access_log_path: '/dev/null'
  address:
    socket_address:
      address: 127.0.0.1
      port_value: 9901
static_resources:
  listeners:
    - name: listner_0
      address:
        socket_address:
          protocol: TCP
          address: 0.0.0.0
          port_value: 5000
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http
                codec_type: AUTO
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: backend
                      domains:
                        - '*'
                      routes:
                        - match:
                            prefix: '/'
                          route:
                            cluster: grpc_sample
                access_log:
                  - name: envoy.access_loggers.file
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
                      path: '/dev/stdout'
                http_filters:
                  - name: envoy.filters.http.router
  clusters:
    - name: grpc_sample
      connect_timeout: 0.25s
      type: LOGICAL_DNS
      lb_policy: ROUND_ROBIN
      http2_protocol_options: {}
      health_checks:
        - timeout: 5s
          interval: 10s
          unhealthy_threshold: 2
          healthy_threshold: 2
          tcp_health_check: {}
      load_assignment:
        cluster_name: grpc_sample
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: server.grpc.local
                      port_value: 50051

Amazon Auroraで"Unknown MySQL error"が出る

DjangoチュートリアルをDockerで動かしていて、DBにAmazon Aurora (MySQL 5.7)を使おうとしたらERROR 2000 (HY000): Unknown MySQL errorが出た話。

docs.djangoproject.com

先に結論

クエリキャッシュを無効にする (query_cache_type = 0) と解決する。

環境

  • masOS Catalina 10.15.2
  • docker desktop community 2.1.0.5 (40693)

問題のSQLクエリ

トップページ用にQuestionの一覧を取得する。

SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;

調査

手元のMac + brewで入れたMySQL 8.0のクライアント → OK

% mysql -V
mysql  Ver 8.0.18 for osx10.15 on x86_64 (Homebrew)
% 
% mysql -uadmin -p -h <Auroraのクラスターエンドポイント> -D mysite_db
Enter password: 
mysql> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;
+----+---------------+----------------------------+
| id | question_text | pub_date                   |
+----+---------------+----------------------------+
|  1 | What's up?    | 2020-01-16 07:45:11.757886 |
+----+---------------+----------------------------+
1 row in set (0.02 sec)

Alpine LinuxのDockerコンテナ + mariadb-client(10.4.10) → ERROR 2000 (HY000): Unknown MySQL error

% docker run -it --rm alpine:latest /bin/ash
/ # apk update
/ # apk add mariadb-client
/ # 
/ # mysql -V
mysql  Ver 15.1 Distrib 10.4.10-MariaDB, for Linux (x86_64) using readline 5.1
/ # 
/ # mysql -uadmin -p -h <Auroraクラスターのエンドポイント> -D mysite_db
Enter password: 
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;
ERROR 2000 (HY000): Unknown MySQL error

クエリの一部(DESC LIMIT 5)を消すとエラーコードすら出なくなるが、何度か再実行すると正常な結果が返ってきたりして不安定。

MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date`;
ERROR: 
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date`;
ERROR: 
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date`;
+----+---------------+----------------------------+
| id | question_text | pub_date                   |
+----+---------------+----------------------------+
|  1 | What's up?    | 2020-01-16 07:45:11.757886 |
+----+---------------+----------------------------+
1 row in set (0.000 sec)

MySQL [mysite_db]> 
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;
+----+---------------+----------------------------+
| id | question_text | pub_date                   |
+----+---------------+----------------------------+
|  1 | What's up?    | 2020-01-16 07:45:11.757886 |
+----+---------------+----------------------------+
1 row in set (0.000 sec)

UbuntuのDockerコンテナ + mariadb-client(10.1.43) → ERROR 2027 (HY000): Malformed packet

% docker run -it --rm ubuntu:latest /bin/bash        
root@cc00c89acc69:/# apk update
root@cc00c89acc69:/# apk upgrade
root@cc00c89acc69:/# apk install mariadb-client
root@cc00c89acc69:/# 
root@cc00c89acc69:/# mysql -V
mysql  Ver 15.1 Distrib 10.1.43-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2
root@cc00c89acc69:/# 
root@cc00c89acc69:/# mysql -uadmin -p -h <Auroraクラスターのエンドポイント> -D mysite_db
Enter password: 
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;
ERROR 2027 (HY000): Malformed packet

解決

"Unknown MySQL error"では有益な情報にたどり着けなかったので"Malformed packet"で調べたところ、クエリキャッシュを切ったら直るという情報を発見。

stackoverflow.com

Auroraのパラメータグループを変更したあと、Alpineのコンテナで確認。

/ # mysql -uadmin -p -h <Auroraクラスターのエンドポイント> -D mysite_db
Enter password: 
MySQL [mysite_db]> SHOW VARIABLES LIKE '%query_cache_type%';
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| query_cache_type | OFF   |
+------------------+-------+
1 row in set (0.019 sec)

MySQL [mysite_db]> 
MySQL [mysite_db]> SELECT `polls_question`.`id`, `polls_question`.`question_text`, `polls_question`.`pub_date` FROM `polls_question` ORDER BY `polls_question`.`pub_date` DESC LIMIT 5;
+----+---------------+----------------------------+
| id | question_text | pub_date                   |
+----+---------------+----------------------------+
|  1 | What's up?    | 2020-01-16 07:45:11.757886 |
+----+---------------+----------------------------+
1 row in set (0.013 sec)

直った。少なからずパフォーマンスは落ちると思われる。

Auroraが変なレスポンスを返しているのか、MariaDBのクライアントがちゃんと解釈できてないのか、それ以外のところが悪いのか、よくわからない。

i3-9100Fで自宅PCを組んだ

メモリとSSDが安くなってたので勢いで。

構成

まず、いままで使っていたPCの構成がこちら。Netflixで映画を見たりETS2でヨーロッパを走り回る分には特に問題ないが、組んでから4年半が経過してるのでそろそろ入れ替え時。

パーツ 型番
CPU Intel Core i5-4460
CPUクーラー サイズ 虎徹 (SCKTT-1000)
メモリ Patriot DDR3-1600 4GB x2 (PSD38G1600KH)
マザーボード ASUS H97-PRO
GPU MSI GTX 1060 AERO ITX 6G OC
SSD OCZ Arc 100 240GB
HDD WD Green 2TB (WD20EZRX)
ケース Fractal Design Define R5
電源 ENERMAX Platimax EPM750AWT

で、新しいマシンの構成がこちら。CPU/メモリ/マザーの3点セットとSSD以外は流用した。

パーツ 型番
CPU Intel Core i3-9100F
CPUクーラー サイズ 虎徹 (SCKTT-1000)
メモリ CFD Crucial DDR4-2666 8GB x2 (W4U2666CM-8G)
マザーボード ASRock B365M Pro4
GPU MSI GTX 1060 AERO ITX 6G OC
SSD WD Blue SN500 NVMe M.2 SSD 500GB (WDS500G1B0C)
ケース Fractal Design Define R5
電源 ENERMAX Platimax EPM750AWT

現行のi3は4コア4スレッドかつターボブーストも効く*1のでかつてのi5より性能が良い。グラボがあるので内蔵GPUなしのFモデルにしたのが今回のポイント。

組み立て

数年ぶりにケースをサイドパネルをオープン。Define R5は空気を取り入れるところにフィルタがついているので、ケース内のホコリは少なめ。

マザーボードから外してグリスをきれいにしたi5-4460。グリスの塗りなおしはしなかったので、表面の刻印を見たのは組んだ日とバラす日の2回だけ。

i3-9100F。ヒートスプレッダの形状が少し変わった。

マザーボードの説明書に従ってCPUクーラーを先に付けたら、M.2のヒートシンク取り付けがやりにくい。

マザーボードが一回り小さくなったのと、HDDがなくなったのとでケース内部がスッキリした。この写真を見ていて気付いたが、ケースファンが吸気x2 排気x1の構成なのでケース内が陽圧になっていてホコリが少なかったのかもしれない。

ベンチマーク

FF14ベンチは「非常に快適」判定。

f:id:nibral:20191227173310p:plain

CPUに100%負荷をかけても40℃ちょっとで安定している。(室温20℃)

f:id:nibral:20191227173320p:plain

お手頃価格とはいえさすがNVMeという速度。

f:id:nibral:20191227173330p:plain

まとめ

トータル3万円ちょっとでPCの世代交代ができてよかった。

*1:9100Fは最大4.2GHz

オフィスにCisco Meraki MR33を導入してみた

NTTから貸し出されるホームゲートウェイ(RS-500KI)の無線LANがどうにも不安定なので、ちゃんとしたアクセスポイントを導入した話。

Cisco Merakiとは

数年前にCiscoが買収したプロダクトで、無線LANのアクセスポイントやスイッチ、ファイヤーウォールなどを揃える。設定は全てクラウド上で一元管理されているのが特徴で、一般的なネットワーク機器のようにそれぞれの設定画面にログインして設定する必要がない。

www.publickey1.jp

Merakiシリーズはエンタープライズ向けなので一般消費者向けの小売りはされておらず、代理店経由で購入する必要がある。より小規模なネットワーク向けにMeraki Goというシリーズもあり、機能面では本家Merakiに及ばないものの、こちらはAmazonで買える。

無償検証用アクセスポイント

2019/10現在、Ciscoが提供するオンラインセミナー(ウェビナー)を受講すると検証用としてアクセスポイントがもらえるキャンペーンを展開している。

法人向けのためか多少のやりとりが必要なので、簡単に手順を。

まず、Merakiオンデマンドウェビナーのサイトにアクセスして「イントロダクション : クラウド管理型IT パワフルなITをよりシンプルに」の「視聴する」をクリック。

merakiresources.cisco.com

申し込みフォームが表示されるのですべて入力して「送信」。セミナー動画(1時間くらい)が再生できるようになるのでちゃんと見る。見終わると再度メールアドレスの入力フォームが表示されるので、メールアドレスを入力。

f:id:nibral:20191017115905j:plain

正常に受け付けられると、「アクセスポイントの送付先住所をMerakiの営業に送ってね」というメールが来る。

f:id:nibral:20191017120226j:plain

指定されたアドレスにアクセスポイントの送付を希望する旨と送付先をメール。ほどなくして相手方から電話があり、ネットワークの規模やアクセスポイントの使い方を聞かれたので素直に答える。問題なさそうだと判断されると(?)アクセスポイントが発送される。

今回はMR33というアクセスポイントと3年分のサブスクリプションがもらえることになった。定価で考えると機器代が99,800円、サブスクリプションが39,900円なのでかなり太っ腹。

発送されると「Your Meraki AP has shipped - xxxxxxxxx」というメールが来るので、大切に保存しておく。

外観

エンタープライズ向けらしくシンプルな箱。

MR33本体のほか、壁掛け用の台座と金具類が付属。

接続口はLANと電源のみ。底面が丸みを帯びていて端のほうは隙間ができるので、フラットでない普通のLANケーブルでも問題ない。

大きさはティッシュ箱と同じか一回り小さいくらい。構造的に縦置きはできず、平置きか壁掛けのどちらかを選ぶ必要がある。

小さいほうの箱にはACアダプターが入っていた。後で調べたところ、MR33はPoE給電が基本なのでACアダプターは別売とのこと。電話で「フレッツ光のホームゲートウェイ使ってます」という話をしたので、気を使ってくれたのかも。

初期設定

アクセスポイントのネットワーク設定

前述したようにMerakiのアクセスポイントはクラウド上で設定を行う。逆に言うとアクセスポイントがインターネットにつながっていないと何もできない。

DHCPIPアドレスが配られているネットワークであればLANケーブルと電源をつなぐだけでよいが、静的IPを振る場合はいったんアクセスポイントに直接接続して設定を変える必要がある。詳細な手順は公式の設置ガイドを参照のこと。ステータスLEDが緑になればOK。

www.cisco.com

アクセスポイントを設置したら、以降はMerakiのWebサイトで設定を行う。

Meraki ダッシュボードアカウントの作成

MerakiのWebサイトにアクセスして右上の「Login」をクリック。

f:id:nibral:20191017124347j:plain

アカウントを作成するので「Create an account」。

f:id:nibral:20191017124418j:plain

リージョンは Asia を選択して「Next」。

f:id:nibral:20191017124503j:plain

続いてアカウントの情報を入力し「Create account」。

f:id:nibral:20191017125255j:plain

入力したメールアドレスに「Cisco Meraki Email Verification」というメールが届くので、本文中のURLを開いてメールアドレスの検証を行う。

f:id:nibral:20191017125402j:plain

バイスとライセンスの登録

ダッシュボードを開くとWelcome的な画面が表示されるので「Register Meraki devices」を選択して「Next」。

f:id:nibral:20191017144047j:plain

ネットワーク名とネットワークに参加させるデバイスの選択画面。Merakiにおけるネットワーク機器の管理は 組織 → ネットワーク → デバイス という階層構造になっている。

  • ABC株式会社 (組織)
    • 東京オフィス (ネットワーク)
    • 大阪オフィス (ネットワーク)

のようなイメージ(たぶん)。また、ネットワークに所属していないデバイスは インベントリ にプールされる。

f:id:nibral:20191017144744j:plain

インベントリにデバイスを追加する際は、購入した際のオーダー番号か製品記載のシリアルナンバーを使う。今回は1台だけなのでシリアルナンバーを入力して「Add devices」した。

f:id:nibral:20191017144936j:plain

ネットワーク名を入力し、追加したMR33にチェックを入れたら「Create network」。

f:id:nibral:20191017145039j:plain

正常にネットワークが作成されるとネットワークに所属しているアクセスポイントの一覧画面が表示されるが、この段階ではデバイスを登録しただけでサブスクリプションが有効になっていない。

サブスクリプションを有効にするには「Your Meraki AP has shipped - xxxxxxxxx」のメールに記載のURLをクリックする。サブスクリプションが有効になると、左側のメニュー Organization → License info のLicense statusが Ok になる。

f:id:nibral:20191017145623j:plain

これで無線LANアクセスポイント1台 x 3年のライセンスが有効になった。

MR33の面白いところ

電波状態モニターの表示が細かい

各チャンネルの混雑具合と時間軸での変化が色分けで表示される。ほぼリアルタイムで更新されるので、チャンネルを割り当てるときの参考にするもよし、スピードテストを走らせて色が赤くなるのを楽しむのもよし。

f:id:nibral:20191017152627j:plain

NATとファイアウォールがついてる

IP割り当てをNAT modeにすると、アクセスポイントがDHCPサーバとして動作して配下の端末に10.0.0.0/8のIPアドレスを配るようになり、無線LANクライアント間での通信が行えなくなる。

f:id:nibral:20191017153003p:plain

ファイアウォールでは、IPアドレスやポートを指定して通信を拒否できる。デフォルトでは有線LAN側端末へのアクセスを拒否する設定なので、NASにアクセスする場合などは許可する必要がある。

f:id:nibral:20191017152954j:plain

2つの設定をうまく組み合わせるとゲスト用のネットワークのセキュリティを高められる。

ゲスト用SSIDにスプラッシュページが出せる

インターネットに出る前に「ご来社ありがとうございます」的なページを出したり、認証を求めることができる。試してはいないが、ページの種類をBillingにして課金制にすることもできるようだ。

f:id:nibral:20191017152937p:plain

導入して1週間、今のところは安定して動作しているしネットワークの管理もやりやすいと思う。家庭用の無線LANルータが3,000円ちょっとで買える時代ではあるが、エンタープライズ向けはいろいろ設定できて楽しい。

AWS SAMでDefaultAuthorizerとCORSが共存できるようになった

半年ほど前に以下のような記事を書いたが、その後のアップデートでDefaultAuthorizerとCORSが問題なく共存できるようになった。

nibral.hateblo.jp

関係しそうなところ

  • AWS::Serverless::ApiのAuthプロパティに「AddDefaultAuthorizerToCorsPreflight」が追加され、CORSのプリフライトリクエストにDefaultAuthorizerを適用するかどうかを指定できるようになった (AWS SAM v1.13~)
    • 以前テストをパスできなくて取り下げられていたもの

github.com

  • AWS::Serverless::FunctionのAuthプロパティに「NONE」を指定して、Authrorizerを無効にできるようになった (AWS SAM v1.15~)

github.com

  • 「sam local start-api」がCORSに対応した (AWS SAM CLI v0.21.0~)
    • Authorizerには直接関係しないが、ローカルでプリフライトリクエストの動作確認ができるようになった

github.com

設定例

以下のような template.yml を作成すると、

  • GET /
    • CognitoAuthorizer
  • GET /public
    • Authorizerなし
  • OPTIONS / および OPTIONS /public
    • Authorizerなし (プリフライトリクエスト成功)

という動作が実現できる。AWS SAM CLI v0.22.0で確認。

Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        AddDefaultAuthorizerToCorsPreflight: false
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: <Cognito UserPool ARN>
      Cors:
        AllowOrigin: "'*'"

  MyFunction1:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: src/
      Events:
        GetIndex:
          Type: Api
          Properties:
            Path: /
            Method: get

  MyPublicFunction1:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: src/
      Events:
        GetPublic:
          Type: Api
          Properties:
            Path: /public
            Method: get
            Auth:
              Authorizer: 'NONE'

もともとやりたかったことができるようになったので満足。

AWS SAMでDefaultAuthorizerを設定するとCORSに失敗する

-------- 2019/10/17追記 --------

AWS SAMのアップデートでこの問題は解消した。

nibral.hateblo.jp

-------- 追記ここまで --------

問題

AWS SAMのAWS::Serverless::Apiでは、AuthプロパティのDefaultAuthorizerをセットすることですべてのエンドポイントに対してオーソライザーを設定できる。

Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !Ref CognitoUserPoolArn

また、Corsプロパティをセットすることですべてのエンドポイントに対してCORSを有効にできる。

Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      Cors:
        AllowOrigin: "'*'"

ところが、これら2つのプロパティを同時にセットすると、CORSのプリフライトリクエスト(OPTIONS)に対してもオーソライザーが有効になってしまい、CORSに失敗する。(401 Unauthorizedが返る)

解決策

AWS::Serverless::ApiのDefaultAuthorizerを削除し、AWS::Serverless::FunctionのEventsに対して個別にAuthプロパティを設定する。

Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      Auth:
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !Ref CognitoUserPoolArn
      Cors:
        AllowOrigin: "'*'"

  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: src/
      Events:
        GetIndex:
          Type: Api
          Properties:
            Path: /
            Method: get
            Auth:
              Authorizer: CognitoAuthorizer

github.com

Authorizerの設定を忘れると誰でもアクセス可能になってしまうので好ましい解決策とは言えないが、現時点ではこうするしかないようだ。

プリフライトリクエストに対してDefaultAuthorizerを適用するかどうかのプロパティ(AddDefaultAuthorizerToCorsPreflight)を追加するプルリクエストを作成した人がいたようだが、ユニットテストをパスできないとかで取り下げられてしまっている。

github.com

Writing An Interpreter In Go その9(終)

その8はこちら

この本に取り組み始めて1か月が経ってしまいそうだったので、日曜丸1日を使って残りを全部終わらせることにした。

4章では文字列型、配列、ハッシュの構文と、それに付随する組み込み関数をいくつか追加する。インタプリタの仕組みはすでに完成しているので、それぞれの構文について

  1. 構文に必要な記号の処理をLexerに追加
  2. 構文を表す構造体を定義し、AST上で表現できるようにParserを変更
  3. 文法的な意味を評価する処理をEvaluatorに追加

という作業を行うことになる。

4.2: 文字列型

4.2: Parse and evaluate strings · nibral/monkey_interpreter@aeaa9b1 · GitHub

Monkeyにおける文字列は、"foobar" のように対象をダブルクオーテーションで挟む形で表記する。

上で示した流れに沿って、

  1. Lexerに「 " が出現したら次の " までを文字列トークンとする」処理を追加
  2. Parserに文字列を表す構造体を追加
  3. Evaluatorでは構造体の持つ文字列をそのまま評価値とする

の順に改良すると文字列が扱えるようになる。

4.2: Concat strings · nibral/monkey_interpreter@782322f · GitHub

あわせて+演算子を使った文字列の連結も追加する。これは簡単で、演算子の両辺が文字列の場合は連結した文字列を評価値とすればよい。

>> "hello" + " world"
hello world
>> let greet = fn(first, last) { "Hello, " + first + " " + last + "!" }
>> greet("John", "Doe")
Hello, John Doe!
>>

4.3: 組み込み関数 (len)

4.3: Add built-in function (len) · nibral/monkey_interpreter@0a22d78 · GitHub

文字列の長さを返す組み込み関数 len() を追加する。

長さを求める処理はgolangに同名の関数があるのでそのまま使うことにして、"len"というキーワードが出たら識別子ではなく組み込み関数の呼び出しとして評価するようにEvaluatorの処理を変更。

>> len("Hello World!")
12
>> len("")
0

4.4: 配列

4.4: Implement array · nibral/monkey_interpreter@92fcd31 · GitHub

Monkey以外の多くの言語と同じように、配列は大括弧とカンマを使って [var1, var2, var3] と表現する。

Lexerで [ と ] を処理できるようにするとParserで配列宣言の始まりと終わりが認識できるようになるので、あとはカンマ区切りの式を順番に評価していく。この動きは関数の機能を付けた時に引数の処理ですでに実装したものがあるので、宣言の終わりを意味する文字を指定できるように改良して流用できる。

配列の仕組みそのものはgolangの配列をそのまま使っているので目新しいことはなく、なんか消化試合っぽいなぁと思ったり。

4.4: Add built-in functions for array · nibral/monkey_interpreter@2630420 · GitHub

組み込み関数も改良。len()で配列の長さを取得できるようにしたのと、first/last/rest という配列特有の処理を行う関数を追加した。

この辺の関数と高階関数をうまいこと組み合わせるとmapとかreduceも実現できるらしい。すごい。

>> let a = [1, 2, 3, 4];
>> let double = fn(x) { x * 2 };
>> map(a, double);
[2, 4, 6, 8]

4.5: ハッシュ

4.5: Implement hash · nibral/monkey_interpreter@166b489 · GitHub

最後に追加するのはハッシュ。言語によってはマップとか辞書とか呼ばれるが、要はkey-valueの形で値を保持する仕組みのこと。

ここで面白かったのは key の扱い。いまのつくりでは各行に現れた文字列を別々のオブジェクトとして扱うので、keyとなるオブジェクトをそのまま使ってしまうと同じ文字列なのにHashから値が取り出せなくなってしまう。

文章だとわかりにくいので本文のコードを抜粋。Javaの文字列を .equals じゃなくて == で比較してアレ?ってなるのと同じ。たぶん。

name1 := &object.String{Value: "name"}
monkey := &object.String{Value: "Monkey"}

pairs := map[object.Object]object.Object{}
pairs[name1] = monkey
fmt.Printf("pairs[name1]=%+v\n", pairs[name1])       // => pairs[name1]=&{Value:Monkey}

name2 := &object.String{Value: "name"}
fmt.Printf("pairs[name2]=%+v\n", pairs[name2])       // => pairs[name2]=<nil>
fmt.Printf("(name1 == name2)=%t\n", name1 == name2)  // => (name1 == name2)=false

この問題を解決するために、MonkeyではHashKeyという整数値を使う。booleanなら0/1、intならその値、文字列ならFNV-1というアルゴリズムで求めたハッシュ値をkeyにすることで、同じ値を示す別々のオブジェクトを通してHashの値を取り出せるようになった。

FNV-1って初めて聞いたけど、SHA-1MD5と比べると軽くて速いらしい。(その代わり暗号化には使えない?)

qiita.com

4.6: puts

4.6: Add puts() · nibral/monkey_interpreter@6018c7f · GitHub

一番最後に文字列の出力を付けて「Hello, world!!」を出せるようになるのエモい。

おわりに

本を読みながら一通り手を動かしてみたことで、構文解析とかインタプリタに対する認識が「何してるかさっぱりわからない」から「どんな感じで解釈してるか何となく想像できる」くらいになった。

これをやったからといって既存言語の開発にコミットできるとか新しい言語を作れるというわけではないけど、苦手意識というかブラックボックス感を取り除くことはできた。

英語版については、そこまで難しい言い回しが出てこないので違和感なく読み進めることができた。たまに「?」ってなる文があると大体成句で決まった意味があるのでググればすぐ解決する。

golangの文法に関する説明がほとんどないので全くのプログラミング初心者にはおすすめできないが、いくつかプログラミング言語を使ったことがあって、でもどうやって実行されてるのかは知らないみたいな人には良い教材だと思う。

interpreterbook.com

www.oreilly.co.jp