TOYOKUMO Tech Blog

トヨクモ株式会社の開発本部のブログです。

Clojure Ring Middleware大全

これはClojure Advent Calendar 2019の21日目の記事です。

そして、こちらの記事の続きにもなっています。

tech.toyokumo.co.jp

本記事では、RingのMiddlewareについて解説していきます。 基本的に上記の記事を理解いただいた前提で話を進めていこうと思います。

Ring Middlewareについてなぜ解説が必要なのか

まずなぜこのような記事が必要なのかについてです。

Clojureのエコシステムには、基本的にRuby on RailsやDjangoのような重畳なフレームワークはありません。 そのため、Handlerで実行する前に共通的にやっておきたい処理が複数あることは想定されるでしょうが、 それらが自動的に行われるような仕組み、または何かの設定ファイルでONにすればすべて"いい感じ"にやってくれる仕組みなどはありません。

そこで共通処理をHandlerの実行前後に足す仕組みとしてRing Middlewareが使われます。

しかしながらClojureのライブラリの考え方として、1つ1つの関心領域を小さくして、その固有の問題だけを解くようにすると言えるようなものがあります。 それは今すでに慣れている筆者にとっては非常に好ましく思いますし、ぜひClojureでのWeb開発にこれから慣れていく人にも体験して欲しいのもではありますが、 一方で、"標準的な"Webアプリケーションを作ろうと思うと多数のMiddlewareを最初から使う必要があり、必要なものを判断して組み合わせる学習コストがかかってしまいます。 筆者も最初は何がどの効果を発揮しているのかわからず困惑した経験があります。

そこで本記事では、実際に弊社の製品で使われている構成を元に、抑えておくべきと思われるRing Middlewareライブラリと、 その前に仕組みについても解説することで、これからClojureでのWeb開発を始める上でのハードルを下げることを目的としています。

Ring Middlewareとは何か

まず定義としてこのように記述されています。

Middleware are higher-level functions that add additional functionality to handlers. The first argument of a middleware function should be a handler, and its return value should be a new handler function that will call the original handler.

要は、 引数にHandlerを受け取り、新しいHandlerを返す関数ということですね。

ここでおさらいをしておくと、(Ring)Handlerとは、リクエストマップを受け取り、レスポンスマップを返す関数でした。

(defn handler [request]
  {:status 200
   :body "Hello world!"})

つまり、「引数にHandlerを受け取り、新しいHandlerを返す」ということは上のような関数を受け取って、別の関数を返すということですね。

まずは、何もしない無意味なHandlerを書いてみます。

(defn no-mean-middleware [handler]
  (fn [request]
    (handler request)))

引数で受け取ったHandlerをそのまま使っているだけです。 適用しても何も意味はありませんが、使うにはMiddlewareの引数にHandlerを渡せばいいわけなので、このように書きます。

(no-mean-middleware handler)

もしくはThreading Macroを使えば、

(-> handler
    (no-mean-middleware))

こうなりますね。 これを評価した戻り値である関数をWeb Serverに渡して起動すれば、Ring Middlewareを適用したHandlerで動くことになります。

少し意味のあるMiddlewareを書いてみます。 例として、引数として受け取ったHandlerを実行する前にリクエストマップの値を出力して、Handlerを実行した後に結果を返す前にレスポンスマップを出力するMiddlewareを書いてみます。

(defn debug-middleware [handler]
  (fn [request]
    (clojure.pprint/pprint request) ; 前処理
    (let [res (handler request)]
      (clojure.pprint/pprint res)   ; 後処理
      res)))

何もしない no-mean-middleware と見比べてみると、 (handler request) と引数でもらったhandlerを使う前後に clojure.pprint/pprint しているだけなのがわかると思います。

これをこんな感じで適用してやると、リクエストマップの中身とレスポンスマップの中身を見ることができます。

(-> handler
    (no-mean-middleware)
    (debug-middleware))

ここまで、順を追えば簡単なことだと思いますが、実際のところこれがRing MIiddlewareのすべてです。

  • (handler request) をする前に何か処理をしたり、handlerの引数であるrequestをいじったりする(前処理)
  • (handler request) の結果を補足して、結果をいじったりする(後処理)

これだけです。

例えば、requestの中身をみて、URLごとにリクエストボディをいじるようなことをしたり、認証をかけたりといった具合です。

もちろん引数として渡されたhandlerを使わずに無視して他のことをすることもできます。 次のMiddlewareでは /hatena にリクエストが来たら、handlerを無視して https://www.hatena.ne.jp/ にリダイレクトします。

(defn hatena-middleware [handler]
  (fn [request]
    (if (= (:uri request) "/hatena")
      {:status 303
       :headers {"Location" "https://www.hatena.ne.jp/"}
       :body ""}
      (handler request))))

最後にMiddlewareで適用した前処理と後処理が実行される順番ですが、 あるMiddlewareに渡されるhandlerは、別のMiddlewareの前処理と後処理を含んだものであるので、 あるMiddlewareの前処理は先に適用されたMiddlewareの前に、逆に後処理は先に適用されたMiddlewareの後に適用されます。

文章で書くと複雑ですが、Threading Macroを利用すると、次のように視覚的にわかりやすく説明可能です。

(-> handler
    (hatena-middleware)  ; 前処理3番目、後処理1番目
    (debug-middleware)   ; 前処理2番目、後処理2番目
    (no-mean-middleware) ; 前処理1番目、後処理3番目
    )

Ring Middleware大全

ここからは、実際に使われるMiddlewareを解説していきます。

URLに応じて適用する

まず前処理・後処理として、具体的な役割をするMiddlewareの前に、 Middlewareそれ自体を条件に一致したときに特定したり・しなかったりするのを制御するものを紹介します。

正確にはこれはRing Middlewareではないですが、Middlewareと一緒に使うものなので先に紹介します。

これにはring.middleware.conditionalを使います。

if-url-starts-with, if-url-doesnt-start-with , if-url-matches , if-url-doesnt-match の4つのAPIがあります。

例えば次のようにすると、URLが /api から始まる時だけ先ほど書いた debug-middleware を適用することができます。

(require '[ring.middleware.conditional :as r.m.c])

(-> handler
    (r.m.c/if-url-starts-with ["/api"] debug-middleware))

リクエスト・レスポンスをロギングする

ring-loggerを使うと、リクエストの開始・終了や、リクエストボディ(debugレベル)をロギングすることができます。

(require '[ring.logger :as r.l])

(-> handler
    (r.l/wrap-with-logger))

[開発用]スタックトレースを出力する

ring.middleware.stacktrace/wrap-stacktraceを使うと、例外をキャッチして、stacktraceをHTMLで、もしくは標準出力に出してくれます。

(require '[ring.middleware.stacktrace ::as r.m.strace])

(-> handler
    (r.m.strace/wrap-stacktrace {:color? true}))

X-Forwarded-For ヘッダからリクエスト元IPアドレスを取得する

IPアドレス制限の機能を作るときなど、ユーザーのIPアドレスを取得したい時があります。

X-Forwarded-Forを使って、途中にProxyがある場合を考慮しつつ、ユーザーのIPアドレスを取得します。

ring.middleware.proxy-headers/wrap-forwarded-remote-addrを使います。

(require '[ring.middleware.proxy-headers :as r.m.proxy])

(-> handler
    (r.m.proxy/wrap-forwarded-remote-addr))

適用すると、リクエストマップの :remote-addr が更新されます。

X-Forwarded-Proto ヘッダからプロトコルを判別する

X-Forwarded-Proto を使って、例えばロードバランサーの背後のアプリでもプロトコルがHTTPなのかHTTPSなのか判別できます。

ring-sslを使います。

(require '[ring.middleware.ssl :as r.m.ssl])

(-> handler
    (r.m.ssl/wrap-forwarded-scheme))

適用すると、リクエストマップの :scheme:http または :https が入ります。

HTTPで来たアクセスをHTTPSにリダイレクトする

これもring-sslを使います。

(require '[ring.middleware.ssl :as r.m.ssl])

(-> handler
    (r.m.ssl/wrap-ssl-redirect))

これはリクエストマップの :scheme をみて判別するので、ロードバランサとhttpで通信している場合などは、 上述の wrap-forwarded-scheme と一緒に使うことが必要です。

HSTSに対応する

Strict-Transport-Securityに対応します。

これもring-sslを使います。

(require '[ring.middleware.ssl :as r.m.ssl])

(-> handler
    (r.m.ssl/wrap-hsts))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

X-Content-Type-Options をレスポンスにつける

X-Content-Type-Optionsをレスポンスにつけます。

ring.middleware.x-headers/wrap-content-type-optionsを使います。

(require '[ring.middleware.x-headers :as x])

(-> handler
    (x/wrap-content-type-options :nosniff))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

X-Frame-Options をレスポンスにつける

X-Frame-Optionsをレスポンスにつけます。

ring.middleware.x-headers/wrap-frame-optionsを使います。

(require '[ring.middleware.x-headers :as x])

(-> handler
    (x/wrap-frame-options :deny))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

X-XSS-Protection をレスポンスにつける

X-XSS-Protectionをレスポンスにつけます。

ring.middleware.x-headers/wrap-xss-protectionを使います。

(require '[ring.middleware.x-headers :as x])

(-> handler
    (x/wrap-xss-protection true :block))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

If-Modified-Since に対応する

If-Modified-Sinceに対応することで、 ファイルに更新がなければ、キャッシュを使うように指示できます。

ring.middleware.not-modified/wrap-not-modifiedを使います。

(require '[ring.middleware.not-modified :as r.m.nm])

(-> handler
    (r.m.nm/wrap-not-modified))

Content-Type がレスポンスにない場合につける

ring.middleware.default-charset/wrap-default-charsetを使います。

例えば、レスポンスマップの Content-Typetext/html が設定されている時に、 text/html; charset=utf-8 と変更してくれます。 文字化け防止ですね。

(-> handler
    (r.m.dc/wrap-default-charset "utf-8"))

URLの拡張子をみて Content-Type をつける

ring.middleware.content-type/wrap-content-typeを使います。

これはレスポンスマップに Content-Type が設定されていない時に、例えばリクエストマップの :uri/hoge.csv ならば text/csvContent-Type に設定してくれるというものです。

動的コンテンツを返す場合というより、後述するディレクトリ上のファイルやクラスパス上のファイルを直接返す時にセットで使うものですね。

(require '[ring.middleware.content-type :as r.m.ct])

(-> handler
    (r.m.ct/wrap-content-type))

ディレクトリ上のファイルをレスポンスする

ring.middleware.file/wrap-fileを使います。

リクエストされたパスに一致する、サーバーのディレクトリ上のファイルをそのまま返したい時に使います。

クラウド上の使い捨て前提のサーバーにデプロイする前提だと、そのサーバー上のディレクトリのファイルに依存することはあまりないでしょうから、 今はあまり使わないものではないでしょうか。

(require '[ring.middleware.file :as r.m.f])

(-> handler
    (r.m.f/wrap-file "/my/static/file/root/"))

クラスパス上のファイルをレスポンスする

ring.middleware.resource/wrap-resourceを使います。

こちらはよく使うと思います。 Clojureアプリケーションはビルドして単一のJARファイルにしてそれを java -jar で起動することが多いと思います。 その時にクラスパス上にCSSファイルやJSファイルなどを含めておいて配信するということをします。

クラスパス上のファイルパスは clojure.java.io/resource で参照することができますが、このMiddlewareはこれを使ってリクエストに一致するファイルが存在すればそれを返してくれます。 ちゃんと Last-Modified 付きです。

root-path を指定するので、見えて良いパス配下だけに限定することができます。

(require '[ring.middleware.resource :as r.m.r])

(-> handler
    (r.m.r/wrap-resource "public"))

相対パスのリダイレクトURLを絶対パスに変換する

ring.middleware.absolute-redirects/wrap-absolute-redirectsを使います。

(ring.util.response/redirect "/foo") とすると、

{:status 302
 :headers {"Location" "/foo"}
 :body ""}

このような相対パスのリダイレクトになりますが、これを http から始まる絶対パスに直してくれます。

(require '[ring.middleware.absolute-redirects :as r.m.ar])

(-> handler
    (r.m.ar/wrap-absolute-redirects))

Cookieを読み書きする

ring.middleware.cookies/wrap-cookiesを使います。

リクエストからCookieを読み :cookies に入れたり、レスポンスマップから :cookies を読み Set-Cookie ヘッダを設定してくれます。

実際のアプリケーションでは後述する wrap-session を使ってセッションを実現し、これ単体ではあまり使うケースはないと思います。

(require '[ring.middleware.cookies :as r.m.cookie])

(-> handler
    (r.m.cookie/wrap-cookies))

フォームからPOSTされたデータやクエリパラメータを読み :params に入れる

ring.middleware.params/wrap-paramsを使います。

次の2つをやってくれます。

  • Content-Typeapplication/x-www-form-urlencoded だった時に(FormタグでPOSTした時に)、bodyを読み込んで、 :form-params:params に入れてくれます
  • クエリパラメータがあれば、読み込んで :query-params:params に入れてくれます

なお、この時両方あれば :params はマージされたものになります。

(require '[ring.middleware.params :as r.m.params])

(-> handler
    (r.m.params/wrap-params))

multipart/form-data のリクエストボディを読み :params に入れる

ring.middleware.multipart-params/wrap-multipart-paramsを使います。

Content-Typemultipart/form-data だった時に、bodyを読み込んで :multipart-params:params に入れてくれます。 これらのキーがすでにある場合はマージされます。

bodyのファイルは、デフォルトでは一時ファイルとしてディレクトリ上におかれ、 :params は次のキーと値が入ります。

  • :filename
  • :content-type
  • :tempfile (java.io.Fileインスタンス)
  • :size
(require '[ring.middleware.multipart-params :as r.m.mparams])

(-> handler
    (r.m.mparams/wrap-multipart-params))

:params 内のネストしたキー文字列をネストしたマップにする

ring.middleware.nested-params/wrap-nested-paramsを使います。

これまで紹介してきた wrap-params もしくは wrap-multipart-params を使うと、フォームからPOSTされたデータ、もしくはクエリパラメータを :params に集めることができます。

その時にネストしたデータを表現したい時にこのMiddlewareを使うと、 フォームのnameもしくはクエリパラメータのkeyを "foo[bar]" のようにすることで、 :params の中身を {"foo" {"bar" "値"}} のように展開することができます。

できることの詳細は上のリンクを参照してください。

(require '[ring.middleware.nested-params :as r.m.nested])

(-> handler
    (r.m.nested/wrap-nested-params))

:params のキーをKeywordに変換する

ring.middleware.keyword-params/wrap-keyword-paramsを使います。

wrap-params wrap-multipart-params を使うことで :params フォームからPOSTされたデータ、もしくはクエリパラメータを :params に集めることができましたが、 :params のキーはまだStringのままで、Clojureで扱うにはKeywordであったほうが扱いやすいです。

このMiddlewareを適用すると、 :params のキーをKeywordに変えてくれます。 ここで、 :form-params:query-params などは変更されないので注意してください。

(require '[ring.middleware.keyword-params :as r.m.kw])

(-> handler
    (r.m.kw/wrap-keyword-params))

sessionに対応する

ring.middleware.session/wrap-sessionを使います。

これを適用するとCookieと後述するsession storeからsessionを読み込み、リクエストマップに :session キーとしてデータを追加してくれます。 また、レスポンスマップに :session を追加すると更新され、またその値がnilであれば削除されます。

どこにsessionデータを保存するかを制御するために、 :store というオプションを使います。 指定しなかった場合は、メモリ上に保存されますが、そのままでいいことはあまりないでしょう。

ここでは実践的な例として、Redisに保存する例を紹介します。Redisクライアントとしてcarmineを使います。

(require '[ring.middleware.session :as r.m.session]
         '[taoensso.carmine.ring :as carmine])

(let [redis-conn-opts {:pool {}
                       :spec {:host "localhsot"
                              :port "6379"
                              :password "foobar"
                              :timeout-ms 1000
                              :db 1}}
      session-store (carmine/carmine-store redis-conn-opts)]
  (-> handler
      (r.m.session/wrap-session {:store session-store})))

一度きりのsessionを使う

ring.middleware.flash/wrap-flashを使います。

レスポンスマップに :flash というキーがあれば、それを次のリクエストの時だけ、リクエストマップに :flash というキーでデータを渡してくれます。 これはsessionに依存しているので、上記の wrap-session と共に使うことが前提です。

(require '[ring.middleware.flash :as r.m.flash])

(-> handler
    (r.m.flash/wrap-flash))

CSRFへの対策をする

ring-anti-forgeryを使います。

このMiddlewareは使うだけならば簡単なのですが、このMiddlewareを入れるとGET/HEAD/OPTIONS以外のリクエスト全てで、CSRFトークンがないとエラーになるようになります。

そのため、 フォームからPOSTするときは、 ring.util.anti-forgery/anti-forgery-field を含め、 XMLHTTPRequestする時は、 ring.middleware.anti-forgery/*anti-forgery-token* をHTMLに含めて返し、 JS上でそれを読んだ上で、 ヘッダに x-csrf-token として設定する必要があります。

またCSRFトークンエラーが起こった時の処理として :error-handler にhandler関数を渡すことができます。 実践的にはこれも必要でしょう。

こちらもsessionに依存しているので、上記の wrap-session と共に使うことが前提です。

(require '[ring.middleware.anti-forgery :as r.m.af])

(-> handler
    (r.m.af/wrap-anti-forgery {:error-handler (fn [_] {:status 403
                                                       :headers {}
                                                       :body "Invalid csrf token"})}))

閑話休題 - Ring-Defaultsで一度に設定する

ここまで多くのMiddlewareを設定してきましたが、1つ1つrequireしていくのは大変です。

そこで ring-defaultsを使って、 wrap-forwarded-remote-addr から wrap-anti-forgery までを一括で適用します。

ring-defaultsにはapi-defaults、site-defaults、secure-api-defaults、secure-site-defaultsの4つのデフォルト設定が用意されていますが、 オススメなのはこれを使わずに自分で以下のようにマップを作って直接渡すことです。 なぜオススメなのかというと、結局細かいことをしようとした時にデフォルト設定をいじる必要があり、その時にassocなりで操作したのを渡していると、 結局何がどうなっているのか見えづらいためです。

(require '[ring.middleware.defaults :as defaults]
         '[taoensso.carmine.ring :as carmine])

(let [redis-conn-opts {:pool {}
                       :spec {:host "localhsot"
                              :port "6379"
                              :password "foobar"
                              :timeout-ms 1000
                              :db 1}}
      session-store (carmine/carmine-store redis-conn-opts)
      default-config {:params {:urlencoded true
                               :multipart true
                               :nested true
                               :keywordize true}
                      :cookies false
                      :session {:flash true
                                :cookie-attrs {:http-only true
                                               :same-site :strict
                                               :secure true}
                                :store session-store}
                      :security {:anti-forgery {:error-handler (fn [_] {:status 403
                                                                        :headers {}
                                                                        :body "Invalid csrf token"})}
                                 :hsts false
                                 :ssl-redirect false}
                      :static {:resources "public"}
                      :responses {:not-modified-responses true
                                  :absolute-redirects true
                                  :content-types true
                                  :default-charset "utf-8"}
                      :proxy true}]
  (-> handler
      (defaults/wrap-defaults default-config)))

Basic認証に対応する

ring-basic-authenticationを使うのが簡単です。

特定のURLだけにかけたい場合は、前述の if-url-starts-with などと併用しましょう。

(require '[ring.middleware.basic-authentication :as r.m.basic])

(def basic-username "foo")
(def basic-pass "pass")

(-> handler
    (r.m.basic/wrap-basic-authentication (fn [username pass]
                                           (and (= username basic-username)
                                                (= pass basic-pass)))))

CORSに対応する

Ring CORSを使います。

このライブラリの良いところは、 Access-Control-Allow-Origin に正規表現を使えるところです。

(require '[ring.middleware.cors :as r.m.cors])

(-> handler
    (r.m.cors/wrap-cors :access-control-allow-origin [#"https://.*example.com"]
                        :access-control-allow-headers #{:accept :content-type :authorization}
                        :access-control-allow-methods #{:get :put :post :delete}))

認証・認可を行う

buddy-authを使います。

buddy-authはbasic, session, token, JWT, JWEなど各種の認証に対応していますが、 詳細はドキュメントに譲るとして、 ここではセッション認証のみの実例を以下に示します。

ここではコード上のコメントでやるべきことを解説します。

(require '[ring.util.http-response :as res]
         '[buddy.auth.accessrules :as baa]
         '[buddy.auth.backends :as bab]
         '[buddy.auth.middleware :as bam])

(defn login-post
  "ログインフォームからのポストを受けるハンドラー関数だと思ってください"
  [{:keys [params session]}]
  (if-let [account (login params)]
    (-> (res/ok)
        ;; セッションに、identityというキーでログイン情報を入れます
        ;; buddy-authとは関係ありませんが、 :recreate をtrueとしてセッションを再作成しています
        (assoc :session (vary-meta (assoc session :identity account)
                                   assoc :recreate true)))
    (res/bad-request "login error")))

(defn- any-access [_]
  (baa/success))

(defn- account-access
  [{:keys [identity]}]
  (if identity
    (baa/success)
    (baa/error)))

(def ^:private rules ;; ruleは先頭から順に評価される
  [
   ;; 例えば、ログイン画面誰でもアクセス可能にする
   {:uris ["/login"]
    :handler any-access}
   ;; 例えば、APIはクライアント側で認証が切れていることを検知したら401を返す
   {:pattern #"\A/api.*\z"
    :handler account-access
    :on-error (fn [_ _] (res/unauthorized))}
   ;; 例えば、その他の全てのパスはsession認証している必要があることにし、
   ;; 認証されていなければログイン画面に飛ばす
   {:pattern #"\A/.*\z"
    :handler account-access
    :on-error (fn [_ _] (res/see-other "/login"))}])

(defn- session-authfn
  "上のlogin-postでセッションにidentityを入れると、
  次のアクセス時にidentityがセッションに入ってくる。
  identityがセッションにある場合にこの関数が呼ばれ、その中身が正しいものか
  検証した上で、正しければ、identityを返す。
  ここでidentityを改変することも可能。"
  [identity]
  ;; 例えば、パスワードは変更されていないか?などの検証をここでする
  identity)

(let [session-backend (bab/session {:authfn session-authfn})]
  (-> handler
      ;; 認可
      ;; 上のルールに指定した挙動をさせる
      (baa/wrap-access-rules {:rules rules})
      ;; 認証
      ;; backendは複数指定可能
      ;; 例えば、セッションかtokenでの認証を許可するなどする
      ;; if-url-starts-with などと併用すれば、URLごとに処理を分けることもできる
      (bam/wrap-authentication session-backend)))

APIのリクエストに対応する

Muuntajaを使うのがオススメです。

リクエストマップの Content-Type をみて、bodyをパースし、 :params に入れてくれます。 またレスポンスマップも同様に適切にbodyを変換してくれます。

ただしこのMiddlewareを通す前にレスポンスマップに Content-Type を設定してしまうと、 動作しないことに注意が必要です。

(require '[muuntaja.middleware :as muu]
         '[ring.util.http-response :as res])

(defn hoge-api [_]
  ;; handlerではこれだけで返すようにする
  (res/ok {:data "foobar"}))

(-> handler
    ;; こっちは :body-params を :params にマージしてくれるもの
    (muu/wrap-params)
    ;; こっちがパースして、 :body-param に設定してくれるもの
    (muu/wrap-format))

ルーティングしやすいようにURL末尾の / を削除する

これはライブラリを使わず自分で書きます。

リクエストマップの :uri/foo/ のように末尾に / に入っていることで、ルーティングがうまく機能しないケースがあります。

そこで次のようなMiddlewareを使うことで、この問題を吸収します。

(defn trailing-slash [path]
  (if (and (string? path)
           (not= path "/")
           (clojure.string/ends-with? path "/"))
    (subs path 0 (dec (count path)))
    path))

(defn wrap-trailing-slash
  "URLから末尾の'/'を削除"
  [handler]
  (fn [req]
    (-> req
        (update :uri r/trailing-slash)
        (update :path-info r/trailing-slash)
        handler)))

(-> handler
    (wrap-trailing-slash))

URLとリクエストメソッドごとに異なるhandlerを呼ぶ

最後に、ルーティングとそれぞれのhandlerとを結合させる方法を紹介して、本記事を終わりとします。

ここでは、ルーティングライブラリとしてbidiを使い、Multimethodと組み合わせた方法を紹介します。 これは実際に弊社製品で使っている方法でもあります。

(require '[ring.util.http-response :as res])

(def routes
  ["/" [["" :route/home]
        ["foo" :route/foo]
        [true :route/not-found]]])
;; (bidi/match-route routes "/foo")
;; => {:handler :route/foo}

(defmulti handler
  ;; リクエストマップの :handler キーの値でディスパッチ
  (fn [request] (:handler request)))

(defmethod handler :route/home
  (-> "Hello world"                                         ; 本来はHTMLをここで作るようにする
      (res/ok)
      (res/content-type "text/html")))

(defmethod handler :route/foo
  (-> "foo"
      (res/ok)
      (res/content-type "text/html")))

(defmethod handler :route/not-found
  [_]
  (res/content-type (res/not-found) "text/html"))

(extend-protocol bidi.ring/Ring
  ;; 元々のbidiはclojure.lang.Fnかclojure.lang.Varにしか対応していないので、
  ;; keywordに対応を広げておく
  clojure.lang.Keyword
  (request [kw request _]
   ;; kwには :route/foo のようなkeywordがくる
   ;; マッチしたhandlerをリクエストマップに入れておくことで、
   ;; マルチメソッドのディスパッチに使う
   (handler (assoc request :handler kw))))

(-> (bidi.ring/make-handler routes)
    ;; 以下にMiddlewareが続く
    )

おわりに

概ね代表的なRing Middlewareについては紹介したように思いますが、また別な有用なものがあれば追記していこうと思います。

次回は状態管理を入れて、アプリケーション全体の構成を作る方法について紹介しようと思います。 またその時には実用的なサンプルアプリも紹介できればと思います。

それでは良いお年を!


トヨクモ株式会社ではClojureを書きたいエンジニア、PHP/Vue.jsを書きたいエンジニア、技術が好きなエンジニアを募集しております。

よろしければ採用ページをご覧ください。

Zipperでコードフォーマッターを実装する (greglook/cljstyle に学ぶ)

北川です。

Clojure 始めました。 Clojure のお勉強をするにあたり何か良い題材はないかと探していたところ greglook/cljstyle というツールが目につきました。 以前からフォーマッターや linter の実装に興味があったということもあり、ちょうど良いのでこれを題材にお勉強を始めていきます。

コンテキスト不明なものをいきなり読んでも眠くなってしまいそうなので greglook/cljstyle のソースコードをそのまま抜粋するなどはしません。 しかし、この記事を読み終える頃には自分好みのコードフォーマッターを再実装できそうな気持ちになれます。*1

フォーマット処理の流れ

例えば cljstyle check コマンドは以下の通りです。

  1. Clojure コードの文字列を取得
  2. 文字列をパースしてツリーを取得
  3. ツリーをトラバースしてフォーマットルールに反した箇所を探す
  4. 変更する
  5. オリジナルソースと変更後ソースの diff をとる
  6. 結果を整形して表示する

1, 5, 6 についてはやるだけなので 2, 3, 4, について見ていきます。

Zipper

本題に入る前に Zipper についてほんのり触れておきます。Zipper とは1996年に Gérard Huet という人が発明したデータ構造です。 Clojure のような immutable なデータ構造を変更する際 Zipper を適用すると比較的簡単な操作で効率的に再構築できるというものです。Zipper の特徴は現在の注目点(loc)と、そこに至るまでのパスを保持している点です。あまり Zipper について思いを馳せると長くなってしまうので、今回は便利なAPIだなあと思うことにします。さっそく使い方を見ていきます。

(require [clojure.zip :as zip])

(-> (zip/vector-zip [1 2 [3 4]])
    (zip/down))

;;=> [1 {:l [], :pnodes [[1 2 [3 4]]], :ppath nil, :r (2 [3 4])}]

vector-zip を使うと vector をトラバースするための Zipper が得られます。zip/down でルートからひとつ下に降り、注目点が 1, right-node が [2 [3 4]] という状態になりました。

(require [clojure.zip :as zip])

(-> (zip/vector-zip [1 2 [3 4]])
    (zip/down)
    (zip/right))

;;=> [2 {:l [1], :pnodes [[1 2 [3 4]]], :ppath nil, :r ([3 4])}]

さらに right-node へ進むと、注目点が 2, right-node が [3 4]、left-node が [1] という状態になりました。

(require [clojure.zip :as zip])

(-> (zip/vector-zip [1 2 [3 4]])
    (zip/down)
    (zip/right)
    (zip/edit inc)
    (zip/root))

;;=> [1 3 [3 4]]

注目点の値を1増やし zip/root でデータを再構築すると変更後の vector を取得することができました。このように Zipper はデータを歩き回り変更したい時に便利ですね。

rewrite-clj で文字列をパース

greglook/cljstyle では文字列をパースしてツリーを取得する工程で、xsc/rewrite-clj が使われています。rewrite-clj の返す Zipper は clojure.zip の上に実装されているので rewrite-clj の提供するAPIはもちろん使えますし先ほどの zip/right のような clojure.zip が提供する操作も問題なく使うことができます。動きを見ていきます。

(require [rewrite-clj.zip :as z])

(z/of-string "(defn add [a b] (+ a b)) (add 1 2)")

;;=> [<list: (defn add [a b] (+ a b)) >
;;  {:l [], :pnodes [<forms: (defn add [a b] (+ a b)) (add 1 2) >], :ppath nil,
;;  :r (<whitespace: " " > <list: (add 1 2) >)}]

z/of-string は文字列から Clojure コードツリーの Zipper を生成します。現在の注目点が (defn add [a b] (+ a b)) フォームとなり、right-node が残りの (add 1 2) という部分です。 ここでうれしいのが、ホワイトスペース等のフォームの評価時には不要なトークンを保持しているという点です。これはコードフォーマッターを実装する時にはとても都合が良さそうです。

実際にフォーマットしてみる - surrounding-whitespace の除去

greglook/cljstyle でもフォーマットルールとして定義されている surrounding-whitespace の除去をやってみます。

(   * a 3 ) このようなフォームを (* a 3) このようにフォーマットします。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

(defn surrounding-whitespace?
  [zloc]
  (and (z/whitespace? zloc)
       (or (nil? (zip/left zloc))
           (nil? (z/skip zip/right z/whitespace? zloc)))))

(defn remove-surrounding-whitespace
  [form]
  (loop [zloc (z/of-string form)]
    (if-let [zloc (z/find-next zloc zip/next surrounding-whitespace?)]
      (recur (zip/remove zloc))
      zloc)))

remove-surrounding-whitespace では最初に (z/of-string form) で受け取った Clojure コードをトラバースするための Zipper を作ります。

(z/find-next zloc zip/next surrounding-whitespace?) では、述語 surrounding-whitespace? にマッチするノードを探します。

zip/nextz/find-next が使用する次の注目点への移動関数です。rewrite-clj.zip が提供する z/next はホワイトスペースやコメントをスキップするため普通に Zipper を歩く分には便利なのですが、コードフォーマッターとしてはスキップして欲しくないです。そこで clojure.zip が提供する zip/next を使います。zip/next はホワイトスペース等を無視せず Zipper を深さ優先的に探索するので今回はこれが使えます。

surrounding-whitespace? は注目点がホワイトスペースなノードであることと、フォーム左端または右端であることを調べています。

(z/skip zip/right z/whitespace? zloc) では、Zipper 全体ではなくトップレベルの1フォーム内をホワイトスペース等は無視せず探索したいため z/skipzip/right を使用していることがポイントです。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

;; ...

(z/root-string (remove-surrounding-whitespace "(    * a 3  )"))
;;=> "(* a 3)"

(z/root-string (remove-surrounding-whitespace "(  + 2 3 (    * 4 5    )  )   (   + d 3    )"))
;;=> "(+ 2 3 (* 4 5))   (+ d 3)"

動いているように見えます。

一般化する

先ほどの remove-surrounding-whitespace から、フォーマットルールに反しているか調べる述語と、ルール違反を検知したら何をするか、を分離すると一般化することができます。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

(defn edit-all
  [form p? f]
  (loop [zloc (z/of-string form)]
    (if-let [zloc (z/find-next zloc zip/next p?)]
      (recur (f zloc))
      zloc)))

さらにコードフォーマッター用の設定ファイル等からマップを生成し、設定が有効ならば何をするかを定義することができます。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

(defn remove-surrounding-whitespace
  "Transform this form by removing any surrounding whitespace nodes."
  [form]
  (z/root-string (edit-all form surrounding-whitespace? zip/remove)))

(defn reformat-form
  "Transform this form by applying formatting rules to it."
  [form config]
  (cond-> form
    ;; config は
    ;; { :remove-surrounding-whitespace? true, :another-rule? false, ... }
    ;; のようなマップ
    (:remove-surrounding-whitespace? config true)
    (remove-surrounding-whitespace)
    ;; ...
    ))

あとは、フォーマットルールに反しているか調べる述語と、ルール違反を検知したら何をするか、をひとつひとつ実装していけばコードフォーマッターが完成しそうですね。

ここまでくると greglook/cljstyle のソースコードにかなり近くなってきました。実際にはもっと細やかな気配りのもと書かれていますが、要旨は押さえられていると思います。

(上記の実装だと設定項目の数だけソースコード全体をパースして走査することになるのが少し気になりますね。)

最後に

自分でもコードフォーマッターが実装できそうな気持ちになれました。トークン列からソースコード上の位置などを取得できれば linter も同じように実装できそうです。

また、rewrite-clj といったツールを作るためのツールの存在は偉大ですね。ぼくも rewrite-clj を使って何かやりたいなあと思えてきました。

*1:気持ちになれます

会社の金で買った HHKB Professional HYBRID Type-S (2台) の打鍵感はいかほどか

こんにちは。このブログでまだ技術的なことを1つも書いていない開発本部の飯塚です。

自分の好きなキーボードを買ってくれる会社も最近では珍しくなくなってきたかと思います。 ご多分に漏れず弊社トヨクモでもPCとキーボードは自分で好きなものを選び買ってもらうことができます。

しかし「左右の手で1台ずつ使いたいから」と言うバカみたいな理由で 2 台買ってくれる会社はどれだけあるでしょうか? それもつい最近発売された HHKB Professional HYBRID Type-S を!

f:id:uotan:20191217074420j:plain
『会社の金で買ってもらった』 HYBRID Type-S 2 台 (上の Pro2 Type-S 2台は今まで使っていた個人的に買ったもの)

経緯

私は元々使っていた HHKB Professional2 Type-S があったので、キーボードに関しては買ってもらっていなかったのですが、 Type-S 好きとして HYBRID Type-S は見逃せず買ってもらうことは確定事項でした。

ただ懸念が1つあり、私は普段 HHKB を 2 台並べて左右の手で1台ずつ使う「なんちゃって分離HHKBスタイル」を採用しているので、 同じことをやろうとすると、この高級キーボードを2つ買う必要があります。 1台くらい自腹でいいかと思いつつ、ダメ元で2台買ってもらえたりするんですか?と聞いてみたところ、

「2台買えるルールがあるわけじゃないけれど、飯塚さんこだわり強いから特例で」

おぉ、普段から2台体制でこだわってますアピールしていて良かった。。

到着

f:id:uotan:20191217074940j:plain
産まれたての HYBRID Type-S

買ってもらったのは英語配列の色は墨です。 配列はもう日本語配列を使えない体になってしまっているので英字一択でしたが、色は Type-S として今回初めて墨が選べるようになったので折角なので墨にしました。

刻印はキーマップの変更もあるので無刻印も少し考えましたが、個人的に何となく好みじゃないので刻印ありです。

f:id:uotan:20191217080304j:plain
左右に仲良くならぶ双子の HYBRID Type-S

諸事情で今は有線接続していますが、無線接続でも2台体制は問題ないそうです。 打鍵音は同じ Type-S なので Pro2 Type-S 同様に「トコトコ」と軽い音ですが、心なしか HYBRID の方がより軽い音な気がします。 しかし打鍵感がないわけではなく、しっかりと打てている感はありとても気持ちいいです。

キーマップ変更

さて今回の目玉機能の1つであるキーマップの変更ですが、結論から言うと今はしていません。

私は普段 Vim 上で Clojure を書いているので、以下のようなキーマップを Vim 上で行っています。

  • コロンとセミコロンの入れ替え
  • 数字と記号の入れ替え(e.g. "9" と "(" や "5" と "%" の入れ替え)

しかし現状の Happy Hacking Keyboard キーマップ変更ツールではどうもこれらの入れ替えができないようです。 今後の更新で対応可能な範囲かはわからないですが、ここまで変更できると個人的にはかなり嬉しいです。

f:id:uotan:20191217081631p:plain
キーの場所は変えられても同じキーで入力内容が入れ替えられない

最後に

会社の金で買った HHKB Professional HYBRID Type-S (2台) の打鍵感は最高です!

どうも一部のモデルがすでに在庫切れになっているようですが、Type-S を使っていない人はもちろん、使っている人も触ってみるのをおすすめします。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

nil punningが難しい(seq vs empty?)

これはClojure Advent Calendar 2019 - Qiitaの二日めの記事です。nil punningが難しいなあと個人的に感じているので、なぜリッチヒッキーさんはnil punningを選択したのだろうか、nil punningが優れている点は何か、ということを調べて書こうと思います。調べていくうちに、いろんな考えがあるなあ、というような気持になることはできました。nil punningにも一長一短があり、実際リッチヒッキーさんも「確かに複雑ではある」とは言っていることはわかりました。オチのない記事になってしまいましたが大目に見てください。

nil punningとは何か

"pun"とは日本語では「 (同音意義語を利用した)だじゃれ」と訳される ようで、Lispでいえば、nilが異なるコンテキストでは別の意味を持って用いられること(=同音異義語)、のことを指します。特にClojureでは、ifの条件の部分ではnilは偽として扱われること、nilは空のリストや空のhash map、空のvectorとして多相的に扱われること、これらのことを指してnil punningといいます。

参考: https://lispcast.com/what-is-nil-punning/

調べようと思ったきっかけ

Clojureのスタイルガイド を見ていたら、nil punningの節で違和感を感じました。

;; good
(defn print-seq [s]
  (when (seq s)
    (prn (first s))
    (recur (rest s))))

;; bad
(defn print-seq [s]
  (when-not (empty? s)
    (prn (first s))
    (recur (rest s))))

「goodとbadが逆では?」という気持ちになりました。私が初めて触れたLispはSchemeであったこと(参考: Schemeではnilはfalsyな値ではない。(if () 1 2) => 1 になる)も要因の1つではあるかもしれないですが、文芸的プログラミングの観点から見てもempty?のほうが意思が感じられてよさそうだなと思いました。実際、私と同じように感じる人も少なからず存在するようで、nil punning confusing? · Issue #44 · bbatsov/clojure-style-guide · GitHubのようなissueがstyle guideのリポジトリに立てられていました。これに対して、リポジトリのオーナーのbbatsovさんは 「リッチヒッキーがnil punningを推奨している」というようなコメントをしています。このseq vs empty?に関しては、nil punningの問題が背後に潜んでいるとみることができます。seq派の人は、nil punningを推進しているので、whenなどの条件分岐にnilを利用することを好みます。一方empty?派の人(私もその一人)は、nilを多義的に扱うことを好まないので、whenであったりifの条件部にnilの値が紛れ込まないよう、empty?でbooleanにわざわざ変換します。empty?は(not (seq coll))として実装されているからなのでは 、という意見もあるようですが、ちょっとくらい効率が落ちても分かりやすく書いたほうが嬉しいのではと思っているので、あまり納得がいきませんでした。

以後、google groupsにあるnil punningの推進派、反対派の意見を整理していきたいと思います。

nil punning反対派のpuzzlerさん

https://groups.google.com/d/msg/clojure/gWvXoHa7-t4/rPY-nWpt4zQJ

puzzlerさんはnilが存在しないこと、偽、空を表す3つの意味で使われていることに一貫性が感じられないと言っています。「多くのケースでは動くが、nilを要素に含むlist/map/vectorを渡すと動かない」関数を多く見てきたそうです。具体的にどういうことなのか別のコメントを見てみましょう。

https://groups.google.com/d/msg/clojure/gWvXoHa7-t4/6Qw3yIq0BEAJ

(filter identity s)(keep identity s) を例として挙げています。前者はnilとfalseを消すのに対し、keepはnilだけを削除するのがconfusingだと言っていますね。filterとkeepの使い分けが生じて、バグを生む原因になってしまったということなのでしょう。 参考

user=> (keep identity [true false 1 nil])
(true false 1)
user=> (filter identity [true false 1 nil])
(true 1)

nil punning推進派のリッチヒッキーさん

google groupでnil punningについて議論が盛り上がっている中、リッチヒッキー本人がコメントを残しています。

https://groups.google.com/d/msg/clojure/gWvXoHa7-t4/HjdiQu3dqbwJ

The is no doubt nil punning is a form of complecting.

nil punningが複雑さを生むことは認めていますね。(文頭のTheはThereのtypoだと思われる)

But you don't completely remove all issues merely by using empty collections and empty?, you need something like Maybe and then things get really gross (IMO, for a concise dynamic language)

empty?を使ってもすべての問題が解決するわけではない、と言っていますね。Maybe(Optional)のようなものは動的言語には似つかわしくないという考えのようです。

I like nil punning, and find it to be a great source of generalization and reduction of edge cases overall, while admitting the introduction of edges in specific cases. I am with Tim in preferring CL's approach over Scheme's, and will admit to personal bias and a certain comfort level with its (albeit small) complexity.

慣れは必要だが、nil punningはより一般的なアプローチにつながると言っています。

However, it couldn't be retained everywhere. In particular, two things conspire against it. One is laziness. You can't actually return nil on rest without forcing ahead. Clojure old timers will remember when this was different and the problems it caused. I disagree with Mark that this is remains significantly complected, nil is not an empty collection, nil is nothing.

このコメントの文脈について補足します。 https://groups.google.com/d/msg/clojure/gWvXoHa7-t4/Ndf2EHynZdAJ puzzlerさんが (when s ...)と書いていたため、sが空のlazy-seqの時に(空のlazy-seqはtrueとして扱われる)変な動作になってしまった、というような体験を語っています。これは、nilがfalsyな値であるため、空のlazy-seqのもfalsyなのでは、と思った結果バグらせてしまった、これはnil punningが原因だ、というような趣旨のようです。

https://groups.google.com/d/msg/clojure/gWvXoHa7-t4/DLxx_dCoRkUJ それに対して、daly(=リッチヒッキーのコメントに出てくるTim)が、昔のClojureは空のlazy-seqもfalsyだった、すなわち、(when s ...) が透過的に (when (seq s) ...) と扱われていた、このアプローチのままだったらpuzzlerの言う問題は生じなかった、という指摘をしています。これ踏まえてコメントを見直してみましょう。

You can't actually return nil on rest without forcing ahead. Clojure old timers will remember when this was different and the problems it caused. I disagree with Mark that this is remains significantly complected, nil is not an empty collection, nil is nothing.

おそらく、透過的にseqを呼ぶと遅延リストのまま引きまわすことができないため問題が生じたことがあったのではないか、と解釈できそうです。nilと空のコレクションを混同するなと言っていますね。dalyのアプローチを採用しても依然として複雑さは残るだろうという考えのようです。

Second, unlike in CL where the only 'type' of empty collection is nil and cons is not polymorphic, in Clojure conj is polymorphic and there can only be one data type created for (conj nil ...), thus we have [], {}, and empty?. Were data structures to collapse to nil on emptying, they could not be refilled and retain type.

Common Lispと違ってmap, set, vector, listなど複数の種類のemptyな値があるということを言っています。nilを用意することで、map, set, vector, listなどに多相的に使うことのできる関数が書きやすくなったりするのでは、と思いましたが、具体的な例は思いつきませんでした。

At this point, this discussion is academic as nothing could possibly change in this area.

academicという言葉の解釈が難しくてわかりませんでした。pragmaticと対比する意味で使っているのかと思いましたが、リッチヒッキーがnil punningのメリットを感じる点があるならその解釈はおかしい気がしました。"nothing could possibly change in this area" は、nil punningかそうでないか、という議論に決着がつくことはないだろう、ということを言っているのでしょう。

The easiest way to think about is is that nil means nothing, and an empty collection is not nothing. The sequence functions are functions of collection to (possibly lazy) collection, and seq/next is forcing out of laziness. No one is stopping you from using rest and empty?, nor your friend from using next and conditionals. Peace!

最後に、nil punningに親しむための考えを紹介してくれています。nilとempty collectionは違うんだぞー、ということを意識しろと言ってます。でもempty?を使うのは君の自由だ!らしいです。

empty?の存在意義

最後に、上記のことを踏まえて、初めの seq vs empty?の問題に戻りたいと思います。nil punningを採用している以上、empty?という関数の存在意義は何だろうか、という疑問が浮かびます。これはclojureがJavaのライブラリを呼んだりする状況を考えれば腑に落ちるのではと考えます。clojureだけで記述している限り、nil punningの思想を徹底していればbooleanを表す値は不要ですが、Javaのライブラリがbooleanを期待しているところにnilを渡すわけにはいきません。そういう場合にsequentialな値をbooleanに変換する関数empty?が必要になってくるということなのでしょう。個人的にはjavaとの連携の部分にempty?を使い、それ以外はseqでnilかどうかを判定する方針で書いていこうかなと思いました。

書きたかったが書けなかったもの

  • coreの関数でbooleanを返す関数 contains? などの設計について書きたかったが、疲れたのでやめた。要素を含んでいるか調べる系の関数ではnilを返すことで「nilが要素として発見されたのか、それとも(条件を満たす)要素がなかったのか」が分かりづらくなるので、booleanを返すようになっていたりしないかなと適当に仮説を立てているが、調べてないので嘘かもしれない。

参考にしたもの

https://github.com/bbatsov/clojure-style-guide/issues/44

https://lispcast.com/what-is-nil-punning/

https://groups.google.com/forum/?fromgroups=#!topic/clojure/gWvXoHa7-t4

https://ejje.weblio.jp/content/pun

kintone REST API の query の BNF を書いてみました

北川です。

弊社では安否確認サービス2の他 kintone連携サービス の開発もしているということもあり、kintone REST API を頻繁に利用します。

中でも query という独自SQLのような機能をよく使うのですが、記法や SQL との差異をよく忘れるので BNF として記述しておこうと思い至りました。

また、BNF があると構文解析器も書きやすいですし文法チェックなど製品で役立つ機能開発に使うことができます。

W3S's EBNF での記述となります。文法のチェックには Railroad Diagram Generator を使用しました。

BNF

query           ::= conditions? order-by-clause? limit-clause? offset-clause?

or-conditions   ::= and-conditions ('or' and-conditions)*
and-conditions  ::= parenethesized ('and' parenethesized)*
parenethesized  ::= condition | '(' or-conditions ')'
condition       ::= comp-condition | in-condition | like-condition
comp-condition  ::= field comp-operator value
in-condition    ::= field in-operator values
like-condition  ::= field like-operator '"' string '"'
field           ::= string ('.' string)?
comp-operator   ::= '!'? '=' | '>' | '<' | '>=' | '<='
in-operator     ::= 'not'? 'in'
like-operator   ::= 'not'? 'like'
values          ::= '(' value (',' value)* ')'
value           ::= num | '"' string '"' | function

order-by-clause ::= 'order by' sortor (',' sortor)*
sortor          ::= field ('asc' | 'desc')?

limit-clause    ::= 'limit' num

offset-clause   ::= 'offset' num

num             ::= "任意の数値"
string          ::= "任意の文字列"
function        ::= "LOGINUSER()..."

注意点

  • もちろん非公式である
  • 細かい点で実際の仕様との差異はあるはず

補足

kintone REST API には「フィールド、システム識別子ごとの利用可能な演算子と関数」という制約がありますが、こういった意味的な正しさはBNFでは検査しないことにしています。

andor では優先度を同じにしてしまうと結果が決定的にならないため、優先度を明示する必要があります。

再帰下降型の構文解析器を前提としているので左再帰にならないように意識しています。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。

気になった方はこちらからご応募ください。

Symfony アプリを Elastic Beanstalk Multi-Container Docker で運用する

北川です。

Symfony アプリを AWS Elastic Beanstalk Multi-Container Docker を用いて構築・運用する事例の紹介です。

弊社が提供するフォームブリッジというサービスは実際にこの記事の内容に基づき作られています。

なおベストプラクティスを模索中だったり、生々しい妥協案も出てくるので暖かい目で読んでいただきますと幸いです。

構成

  • Nginx
  • Symfony, Composer
  • Vue, Yarn

フロントエンドとバックエンドがある程度分離されたSPA

Symfonyアプリでの事前準備

EB での運用を快適にするためにはアプリ側にもいくつか要件があります。

環境変数の設定

アプリの振る舞いを変える方法を環境変数のみにしておきます。 Symfony では parameters.yaml を配置して振る舞いを変えますが、parameters.yaml から環境変数を読み込ませる機能がありますのでこれを利用します。Symfony 3系でも使えます。

# parameters.yaml
parameters:
    database_host: '%env(DATABASE_HOST)%'
    database_port: '%env(int:DATABASE_PORT)%'
    database_name: '%env(DATABASE_NAME)%'
    database_user: '%env(DATABASE_USER)%'
    database_password: '%env(DATABASE_PASSWORD)%'

注意点として、環境変数にセットした値がデフォルトでは string でパースされるのでポート番号など型を指定したい場合は '%env(int:DATABASE_PORT)% のように記述する必要があります。 環境変数は EB の環境プロパティでセットします。

ログを標準出力に出す

必要なログを標準出力に出し CloudWatch にストリーミングするようしておくと ssh で接続してファイルを取得といったことをする必要が無くなります(後述)。 また Docker でコンテナ内のログファイルを永続化するのは面倒です。 このためアプリケーションログはファイルでは無く標準出力に出しておくと便利です。

monolog を使用した例です。

# config.yaml
monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
        nested:
            type: stream
            path: "php://stdout"
            level: debug
        console:
            type: console

CI やテストがやかましくなるので test env ではファイルに出力するようにしておくと良いでしょう。

ジョブのAPI化

バッチサーバーなどを EB で運用する場合、Worker環境を使うことになります。 詳細はドキュメントを読めば分かりますが、Woker環境の仕組み上、HTTPリクエストを受けジョブを実行する流れになります。 逆に言えば、Web環境とまったく同じ構成でWorker環境を作ることもできます。FormBridge でも Web と Worker は同じ構成です。

例えば、バッチサーバーで以下の cron を実行している場合、Symfony の Controller にアクションを追加し処理を移行すれば良いです。スケジューリングは cron.yaml という仕組みが用意されています(後述)。

0 9 * * * php /var/www/html/app/bin/console app:hoge

非同期ジョブなどはWorker環境では以下のように処理します。

  • SQS にメッセージを送信
  • aws-sqsd がメッセージを取得し指定のpathにHTTPリクエスト
  • ジョブを実行

この場合、SQS にメッセージを送信する部分とジョブのAPI化の2箇所を対応する必要があります。

Docker のビルド

ECR

イメージのリポジトリには ECR を利用しています。タグ運用をする場合はイメージタグの変更可能性を変更不可にしておくと事故が減ります。

PHP Dockerfile

FROM composer:version as composer
WORKDIR /app

COPY . /app/

RUN composer install --no-dev

FROM php:7.2-fpm
WORKDIR /app

RUN apt-get update && apt-get install -y ...
RUN docker-php-ext-install ...
RUN pecl install ...

ADD ./path/to/ini/*.ini $PHP_INI_DIR/conf.d/

COPY . /app/

COPY --from=composer /app/vendor /app/vendor

RUN cp app/config/parameters_env.yaml app/config/parameters.yaml
RUN chown -R www-data:www-data /app

ところどころぼかしていますが、これが Symfony アプリのイメージビルドに使用している Dockerfile です。ソースコードを丸ごとコピーしているように見えますが .dockerignore で不要なディレクトリをコピーしないようにしています。parameters_env.yaml は、先ほどの環境変数を読み込むようにした parameters.yaml のことです。最後にデフォルトの PHP-FPM 実行ユーザーの www-data にパーミッションを変更しています。

Nginx Dockerfile

FROM node:version as node
WORKDIR /app

ADD ./package.json /app/
ADD ./yarn.lock /app/

RUN yarn install
RUN yarn build

FROM nginx:version

ADD ./path/to/nginx.conf /etc/nginx/nginx.conf
ADD ./path/to/default.conf /etc/nginx/conf.d/default.conf

COPY ./document-root /app/document-root
COPY --from=node /app/document-root/dist /app/document-root/dist

ところどころぼかしていますが、これが Nginx での最も単純な Dockerfile です。Nginx には静的コンテンツを管理している document-root と webpack でのビルドの成果物のみを配置します。

ところが、使用しているライブラリによってはこう単純にはいかず、実際には以下のような Dockerfile を使っています。

FROM composer:version as composer
WORKDIR /app

ADD . /app

RUN composer install

FROM node:version as node
WORKDIR /app

ADD . /app/

RUN yarn install

COPY --from=composer /app/document-root/js /app/document-root/js

RUN yarn build

FROM nginx:version

ADD ./path/to/nginx.conf /etc/nginx/nginx.conf
ADD ./path/to/default.conf /etc/nginx/conf.d/default.conf

ADD ./document-root /app/document-root
COPY --from=composer /app/vendor /app/vendor
COPY --from=composer /app/document-root/bundles /app/document-root/bundles
COPY --from=node /app/document-root/dist /app/document-root/dist

node のマルチステージビルドでは、 composer でインストールしたライブラリに同梱された js/css が document-root/js に落ち、その中から必要な js/css を yarn build の成果物に含めるようにしています。どんな状況だよという感じですね。

下の方では composer でインストールしたライブラリに同梱された js/css が document-root/bundles に落ちてくるパターンに対応しています。document-root/bundles からさらにシンボリックリンクが vendor 以下に貼られる場合もあるので vendor もイメージに含めています。vendor は js/css のみを目当てに配置しているということです。つらい。

Elastic Beanstalk 設定ファイルの管理

ディレクトリ構成

EB 管理用リポジトリは下記のようなディレクトリ構成を取ります。

.gitignore
eb
├── production
│   ├── worker
│   │   ├── .ebextensions
│   │   │   ├── ...
│   │   │   └── cache_clear.config
│   │   ├── .elasticbeanstalk
│   │   │   └── config.yaml
│   │   ├── Dockerrun.aws.json
│   │   └── cron.yaml
│   └── web
│       ├── .ebextensions
│       │   ├── cache_clear.config
│       │   ├── ...
│       │   └── migrate_db.config
│       ├── .elasticbeanstalk
│       │   └── config.yaml
│       └── Dockerrun.aws.json
└── staging
    ├── worker
    │   ├── .ebextensions
    │   │   ├── ...
    │   │   └── cache_clear.config
    │   ├── .elasticbeanstalk
    │   │   └── config.yaml
    │   ├── Dockerrun.aws.json
    │   └── cron.yaml
    └── web
        ├── .ebextensions
        │   ├── cache_clear.config
        │   ├── ...
        │   └── migrate_db.config
        ├── .elasticbeanstalk
        │   └── config.yaml
        └── Dockerrun.aws.json

production と staging の2種類のEBアプリケーションがあり、それぞれのEBアプリケーションはworker環境とweb環境を持ちます。 各環境には .ebextensions/, .elasticbeanstalk/, Dockerrun.aws.json が配置されています。

config.yaml

デプロイの利便性のため .elasticbeanstalk/config.yaml をソース管理に含めています。EB CLI コマンドeb init を実行した場合、デフォルトではソース管理から外されますがあえてソース管理しています。

# .gitignore
# Elastic Beanstalk Files
eb/**/.elasticbeanstalk/*
!eb/**/.elasticbeanstalk/config.yaml #追加

config.yaml にはEBアプリケーション名および環境名を記述します。

# config.yaml
branch-defaults:
  default:
    environment: web
    ...
global:
  application_name: formbridge-production
  ...

各環境ディレクトリに対して eb deploy を実行することでデプロイを行います。

弊社では完全な自動化はしていませんが、さらに .ebextensions 以下に config を追加したりJSONドキュメントを活用することで eb deploy だけでなく eb initeb create による自動化も達成できるはずです。

参考

Dockerrun.aws.json

各環境毎の Dockerrun.aws.json では ECR リポジトリとイメージタグの指定やログの設定を行なっています。

# Dockerrun.aws.json
{
  "AWSEBDockerrunVersion": 2,
  "containerDefinitions": [
    {
      "name": "php",
      "essential": true,
      "memory": 1024,
      "image": "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/php-repository-name:tag",
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "/aws/elasticbeanstalk/containers/php"
        }
      }
    },
    {
      "name": "nginx",
      "essential": true,
      "memory": 256,
      "image": "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-repository-name:tag",
      "links": [
        "php"
      ],
      "portMappings": [
        {
          "containerPort": 80,
          "hostPort": 80
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "/aws/elasticbeanstalk/containers/nginx"
        }
      }
    }
  ]
}

Dockerrun.aws.json のコンテナ定義セクションとボリュームセクションは、Amazon ECS タスク定義ファイルの対応するセクションと同じ形式を使用できますので、logConfiguration を設定し CloudWatch へログをストリーミングしています。awslogs-group に対応するロググループを CloudWatch 上で手で作成する必要があります。

cron ジョブ

Worker環境に cron.yaml を配置すると cron のように定期的なタスクを実行することができます。

# cron.yaml
version: 1
cron:
  - name: "hoge"
    url: "/hoge"
    schedule: "0 0 * * *"

注意点としては、スケジューリングをUTCで指定する必要があることと、FIFOキューとの併用ができないことです(2019.8 時点)。実際に動かしてみると cron.yaml を元にキューにメッセージを詰める aws-sqsd がエラーを出すので単に対応していないようです。このためFIFOキューを使うWorker環境が欲しい場合、cronジョブを捌く環境とは別の環境を用意すると良さそうです。

.ebextensions

Symfony アプリの運用では cach_clear.configmigrate_db.config があると便利なので紹介します。それぞれDockerコンテナ起動後に実行されるコマンドです。

上記のディレクトリ構成ではこれらのファイルが重複し冗長な構成となっていますが、別にいいんじゃないかなという気持ちです。

# cache_clear.config
files:
  "/opt/elasticbeanstalk/hooks/appdeploy/post/99_cache_clear.sh":
    mode: "000755"
    owner: root
    group: root
    content: |
      #!/usr/bin/env bash
      docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` php bin/console cache:clear -e prod
      docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` chown -R www-data:www-data /app/var/cache

Symfony は Cache まわりがよく問題となるので、どこかのタイミングで php bin/console cache:clear を実行し Cache を削除しておくと安心です。この例では root ユーザーで実行しているので PHP-FPM の実行ユーザー www-data にパーミッションを戻しています。

# migrate_db.config
files:
  "/opt/elasticbeanstalk/hooks/appdeploy/post/10_post_migrate.sh":
    mode: "000755"
    owner: root
    group: root
    content: |
      #!/usr/bin/env bash
      if [ -f /tmp/leader_only ]
      then
        rm /tmp/leader_only
        docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` php bin/console doctrine:database:create --if-not-exists
        docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` php bin/console doctrine:migrations:migrate --allow-no-migration
      fi

container_commands:
  01_migrate:
    command: "touch /tmp/leader_only"
    leader_only: true

migrate_db.config のポイントは冗長化されていても1回だけ実行するためにリーダーインスタンスのみで実行していることです。

デプロイ

ローリングデプロイ

EB コンソールで次のような設定をしておくとデプロイ時にコンテナ起動中の状態で公開されてしまい 502 Bad GateWay といった事態を無くすことができます。

f:id:kitagawasyunta:20190815144316p:plain
ヘルスにもとづくローリングデプロイ

手順

最後にこれまでのまとめとしてデプロイの手順を紹介します。例として eb/production/web の環境にデプロイします。

# イメージタグを更新する
vi eb/production/web/Dockerrun.aws.json
# 本番用イメージのビルド
docker build -t nginx-image /path/to/nginx/Dockerfile
docker build -t php-image /path/to/php/Dockerfile
# ECRにログイン
aws ecr get-login --no-include-email --region ap-northeast-1
 # イメージを ECR にプッシュ
docker tag nginx-image:latest xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-repository:tag
docker tag php-image:latest xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/php-repository:tag
docker push xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-repository:tag
docker push xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/php-repository:tag
# eb へデプロイ
cd eb/production/web
eb deploy

あとはこれと似たようなものをそれぞれの環境についても記述し、お好みのツールとビルド環境で自動化すればおしまいです。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

堅牢な VPC を CloudFormation でサクッと構築する方法

こんにちは。トヨクモ開発本部インフラエンジニアの井上です。

AWS には CloudFormation というリソースの構築を自動化できるサービスがあります。 今回は CloudFormation を使って、堅牢な RDS ネットワークを構築してみたいと思います。

背景

日々データストアを運用していると、以下のようなことをやりたい場面が出てくるのではないでしょうか。

  • 本番の RDS に対して、マイグレートやスクリプトの実行時間を検証したい
  • 稼働中のEC2インスタンスからアクセスさせたくない
  • 恒久的な構成ではなく一時的に作成、削除したい

やり方は色々あると思いますが弊社では次のような方法で解決しています。

方法

アクセス元が限定された検証用の堅牢なネットワークを CloudFormation で作成し、RDS のインスタンスをその VPC ネットワーク内に構築する。

RDS は本番のリードレプリカからマスターに昇格させたものを使います。これで本番のデータが壊れることはなくなります。 さらにアクセス元を弊社IPからのみに限定することで、セキュリティーを保ちデータを外部に流出させないようにします。

構成図は以下の通り。

f:id:iriya_ufo:20190808120527p:plain
fig

ネットワークリソースの要件

  • 検証用 VPC
  • ルートテーブル
  • インターネットゲートウェイ
  • AvailabilityZone の異なるサブネット2つ
  • RDS サブネットグループ
  • 特定 IP 接続のみ許可したセキュリティグループ

RDS インスタンスの設置

  • RDS インスタンスのリードレプリカを作成する
  • 作成したリードレプリカをマスターに昇格させる
  • 検証用 VPC のネットワークに移動させる

コード

少し長いですが CloudFormation コードの全体像です。 PostgreSQL と MySQL に対応しています。

---
AWSTemplateFormatVersion: '2010-09-09'
Description: Robust RDS Network for Testing

# ------------------------------------------------------------#
#  Metadata
# ------------------------------------------------------------#
Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Project Name Prefix"
        Parameters:
          - PJPrefix
      - Label:
          default: "Network Configuration"
        Parameters:
          - VPCCIDR
          - AvailabilityZone1
          - AvailabilityZone2
          - RDSPublicSubnet1CIDR
          - RDSPublicSubnet2CIDR
      - Label:
          default: "Connection Source IP"
        Parameters:
          - ConnectionSourceIP
    ParameterLabels:
      VPCCIDR:
        default: "VPC CIDR"
      AvailabilityZone1:
        default: "AvailabilityZone 1"
      AvailabilityZone2:
        default: "AvailabilityZone 2"
      RDSPublicSubnet1CIDR:
        default: "RDS PublicSubnet 1 CIDR"
      RDSPublicSubnet2CIDR:
        default: "RDS PublicSubnet 2 CIDR"
      ConnectionSourceIP:
        default: "Source IP"

# ------------------------------------------------------------#
#  Input Parameters
# ------------------------------------------------------------#
Parameters:
  PJPrefix:
    Type: String
  VPCCIDR:
    Type: String
    Default: "14.100.0.0/16"
  AvailabilityZone1:
    Type: String
    Default: "ap-northeast-1b"
  AvailabilityZone2:
    Type: String
    Default: "ap-northeast-1c"
  RDSPublicSubnet1CIDR:
    Type: String
    Default: "14.100.21.0/24"
  RDSPublicSubnet2CIDR:
    Type: String
    Default: "14.100.22.0/24"
  ConnectionSourceIP:
    Type: String
    Default: "Set Source IP xxx.xxx.xxx.xxx/32"

Resources:
  # ------------------------------------------------------------#
  #  VPC
  # ------------------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: 'true'
      EnableDnsHostnames: 'true'
      InstanceTenancy: default
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-vpc"

  # ------------------------------------------------------------#
  #  Internet Gateway
  # ------------------------------------------------------------#
  IGW:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-igw"
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref IGW

  # ------------------------------------------------------------#
  #  RDS PublicSubnet
  # ------------------------------------------------------------#
  RDSPublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref RDSPublicSubnet1CIDR
      AvailabilityZone: !Ref AvailabilityZone1
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-rds-public-subnet-1"
  RDSPublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref RDSPublicSubnet2CIDR
      AvailabilityZone: !Ref AvailabilityZone2
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-rds-public-subnet-2"

  # ------------------------------------------------------------#
  #  RDS Subnet Group and Security Group
  # ------------------------------------------------------------#
  RDSSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: Robust Subnet Group
      SubnetIds:
      - Ref: RDSPublicSubnet1
      - Ref: RDSPublicSubnet2
  RDSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId:
        Ref: VPC
      GroupDescription: Robust DB SecurityGroup
      SecurityGroupIngress:
      # MySQL
      - IpProtocol: tcp
        FromPort: '3306'
        ToPort: '3306'
        CidrIp: !Ref ConnectionSourceIP
      # PostgreSQL
      - IpProtocol: tcp
        FromPort: '5432'
        ToPort: '5432'
        CidrIp: !Ref ConnectionSourceIP

  # ------------------------------------------------------------#
  #  Public RouteTable
  # ------------------------------------------------------------#
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-public-rt"

  # ------------------------------------------------------------#
  #  Public Routing
  # ------------------------------------------------------------#
  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref IGW

  # ------------------------------------------------------------#
  #  Public RouteTable Association
  # ------------------------------------------------------------#
  RDSPublicSubnetAttach1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref RDSPublicSubnet1
  RDSPublicSubnetAttach2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref RDSPublicSubnet2

# ------------------------------------------------------------#
#  Output Parameters
# ------------------------------------------------------------#
Outputs:
  VPCID:
    Value: !Ref VPC
    Export:
      Name: !Sub "${PJPrefix}-vpc-id"
  VPCCIDR:
    Value: !Ref VPCCIDR
    Export:
      Name: !Sub "${PJPrefix}-vpc-cidr"
  #
  # RDS
  #
  RDSPublicSubnet1ID:
    Value: !Ref RDSPublicSubnet1
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-1-id"
  RDSPublicSubnet1CIDR:
    Value: !Ref RDSPublicSubnet1CIDR
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-1-cidr"
  RDSPublicSubnet2ID:
    Value: !Ref RDSPublicSubnet2
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-2-id"
  RDSPublicSubnet2CIDR:
    Value: !Ref RDSPublicSubnet2CIDR
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-2-cidr"
  #
  # Route
  #
  PublicRouteTableID:
    Value: !Ref PublicRouteTable
    Export:
      Name: !Sub "${PJPrefix}-public-route-id"

使い方

コードの解説をする前に、上記 yml ファイルの使い方を説明していきます。

スタックの作成

f:id:iriya_ufo:20190807174215p:plain
cf-1

最初に AWS コンソールから CloudFormation リソースにアクセスし、スタックの作成を選択します。

テンプレートのアップロード

f:id:iriya_ufo:20190807174243p:plain
cf-2

先ほどのコードをテンプレートとしてアップロードします。ファイル名はなんでもいいですが、拡張子は .yml にします。

パラメータの入力

f:id:iriya_ufo:20190807174246p:plain
cf-3

パラメータに適当な値を入れていきます。

AvailabilityZone は各リージョン毎に適したものをいれます。

東京なら ap-northeast-1b などです。ここでは例としてロンドンリージョンに作成する場合を書きました。

Source IP はアクセス元となる IP アドレスを CIDR 表記(ex. 12.34.56.78/32)で書きます。

スタックオプションの設定

f:id:iriya_ufo:20190807174250p:plain
cf-4

スタックオプションの設定は特に何も指定せずに 次へ を選択します。

ステータス

f:id:iriya_ufo:20190807174254p:plain
cf-5

数分待ってから CREATE_COMPLETE という表示が出れば完了です。

これでアクセスが限定された堅牢 VPC の出来上がりです。あとは作成された環境に RDS インスタンスのリードレプリカを作ってマスターに昇格させてから、適宜ツールなどを用いてエンドポイントに接続します。

リソースの一括削除

f:id:iriya_ufo:20190807174258p:plain
cf-6

最後に検証が終わったら、RDS のリソースを削除した上で、CloudFormation の削除を選択すると、作成されたリソースを一括で削除することができます。RDS が残った状態だとエラーになって削除できません。

説明

ポイントとなる部分だけかいつまんでコードの解説をしていきます。

メタデータとパラメータ

# ------------------------------------------------------------#
#  Metadata
# ------------------------------------------------------------#
Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Project Name Prefix"
        Parameters:
          - PJPrefix
      - Label:
          default: "Network Configuration"
        Parameters:
          - VPCCIDR
          - AvailabilityZone1
          - AvailabilityZone2
          - RDSPublicSubnet1CIDR
          - RDSPublicSubnet2CIDR
      - Label:
          default: "Connection Source IP"
        Parameters:
          - ConnectionSourceIP
    ParameterLabels:
      VPCCIDR:
        default: "VPC CIDR"
      AvailabilityZone1:
        default: "AvailabilityZone 1"
      AvailabilityZone2:
        default: "AvailabilityZone 2"
      RDSPublicSubnet1CIDR:
        default: "RDS PublicSubnet 1 CIDR"
      RDSPublicSubnet2CIDR:
        default: "RDS PublicSubnet 2 CIDR"
      ConnectionSourceIP:
        default: "Source IP"

# ------------------------------------------------------------#
#  Input Parameters
# ------------------------------------------------------------#
Parameters:
  PJPrefix:
    Type: String
  VPCCIDR:
    Type: String
    Default: "14.100.0.0/16"
  AvailabilityZone1:
    Type: String
    Default: "ap-northeast-1b"
  AvailabilityZone2:
    Type: String
    Default: "ap-northeast-1c"
  RDSPublicSubnet1CIDR:
    Type: String
    Default: "14.100.21.0/24"
  RDSPublicSubnet2CIDR:
    Type: String
    Default: "14.100.22.0/24"
  ConnectionSourceIP:
    Type: String
    Default: "Set Source IP xxx.xxx.xxx.xxx/32"

まずはメタデータとパラメータの部分です。ここで書いた内容はスタック作成の際にパラメータとして入力できるようになります。

CloudFormation を書く中でマジックナンバー的な要素を書かなくてはならないときはパラメータとして追い出してやるといいです。

ここでは VPC の範囲 複数リージョンの対応のための AZ アクセス元 IP をパラメータとして分離しました。

RDS サブネットとサブネットグループ

  # ------------------------------------------------------------#
  #  RDS PublicSubnet
  # ------------------------------------------------------------#
  RDSPublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref RDSPublicSubnet1CIDR
      AvailabilityZone: !Ref AvailabilityZone1
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-rds-public-subnet-1"
  RDSPublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref RDSPublicSubnet2CIDR
      AvailabilityZone: !Ref AvailabilityZone2
      Tags:
      - Key: Name
        Value: !Sub "${PJPrefix}-rds-public-subnet-2"

  # ------------------------------------------------------------#
  #  RDS Subnet Group and Security Group
  # ------------------------------------------------------------#
  RDSSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: Robust Subnet Group
      SubnetIds:
      - Ref: RDSPublicSubnet1
      - Ref: RDSPublicSubnet2
  RDSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId:
        Ref: VPC
      GroupDescription: Robust DB SecurityGroup
      SecurityGroupIngress:
      # MySQL
      - IpProtocol: tcp
        FromPort: '3306'
        ToPort: '3306'
        CidrIp: !Ref ConnectionSourceIP
      # PostgreSQL
      - IpProtocol: tcp
        FromPort: '5432'
        ToPort: '5432'
        CidrIp: !Ref ConnectionSourceIP

サブネットグループ ( Type: AWS::RDS::DBSubnetGroup )

次に RDS のサブネットについて見ていきます。 RDS インスタンスの配置するネットワークは最初は少々戸惑います。

RDS は RDS 専用のサブネットグループという場所に設置するのですが、これは RDS リソースから作成します。

サブネットグループとは通常の複数のサブネットを1つにまとめたものです。

Multi AZ にするには2つ以上のサブネットを選択して、サブネットグループを作成する必要があります。

セキュリティグループ ( Type: AWS::EC2::SecurityGroup )

そしてセキュリティグループの作成です。

RDS リソースに対するセキュリティグループなのに AWS::EC2::SecurityGroup でいいのかという感じがしますが、これで大丈夫です。

DB セキュリティグループと勘違いして作成しがちですが、DB セキュリティグループは VPC 内にない EC2-Classic DB インスタンスへのアクセスを制御するためのものなので今回は関係ありません。

セキュリティグループはインバウンドで MySQL と PostgreSQL のポートをアクセス元を限定して解放しています。アクセス元は CidrIp: !Ref ConnectionSourceIP で関連づけます。

参考

https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/Overview.RDSSecurityGroups.htmldocs.aws.amazon.com

出力パラメータ

# ------------------------------------------------------------#
#  Output Parameters
# ------------------------------------------------------------#
Outputs:
  VPCID:
    Value: !Ref VPC
    Export:
      Name: !Sub "${PJPrefix}-vpc-id"
  VPCCIDR:
    Value: !Ref VPCCIDR
    Export:
      Name: !Sub "${PJPrefix}-vpc-cidr"
  #
  # RDS
  #
  RDSPublicSubnet1ID:
    Value: !Ref RDSPublicSubnet1
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-1-id"
  RDSPublicSubnet1CIDR:
    Value: !Ref RDSPublicSubnet1CIDR
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-1-cidr"
  RDSPublicSubnet2ID:
    Value: !Ref RDSPublicSubnet2
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-2-id"
  RDSPublicSubnet2CIDR:
    Value: !Ref RDSPublicSubnet2CIDR
    Export:
      Name: !Sub "${PJPrefix}-rds-public-subnet-2-cidr"
  #
  # Route
  #
  PublicRouteTableID:
    Value: !Ref PublicRouteTable
    Export:
      Name: !Sub "${PJPrefix}-public-route-id"

最後にアウトプットパラメータについてです。

これは今回の作業においては必要ない部分ですが、設定しておくと別のテンプレートからリソースを呼び出せたり、AWS コンソールて手軽に id などが確認できるので便利です。

まとめ

CloudFormation の利用例は Web サービスインフラの構築の例とかが多いと思いますが、拡張性がある形で設計するのは結構たいへんです。今回のように限定的な使われ方をするけど何回か使い回すような利用シーンだと手軽で便利に使えるのではないかと思います。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。