TOYOKUMO Tech Blog

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

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 で関連づけます。

参考

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


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

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

Clojureエンジニアが育休を4ヶ月とりました

こんにちは。開発本部の飯塚です。 4月に第2子が生まれ、4ヶ月の育児休暇を取得しました。

育休の入るまでにしたこと

開発本部では毎週目標を決めて翌週頭に何ができたか/できなかったなどを振り返るのですが、出産予定日がある程度確定した時点で、育児休暇に入るまでの計画を作成して共有・レビューしました。

この計画作成でありがたかったのが、育休に入る予定の2週間前から待機期間として雑務を中心とした計画とし、いつでも育休に入れる状態にさせていただいたことです。 これにより予定日が近づいてきても途中の業務や引き継ぎに関する不安を抱えなくて済み、精神衛生上とても良かったです。 なお実際に生まれたのが予定日の5日前だったので(私目線では)スムーズに育休に入れたと思っています。

育休を取得して良かったこと

この手の話はよくある話になりがちで、すぐ大きくなってしまって貴重な赤ちゃん時期を、短いながら夫婦2人で育児できたことはとても素敵なことでした。

それ以外だと上の子の幼稚園入園が出産とちょうど重なったことで、入園後3ヶ月ほど幼稚園イベントは私1人で参加していたのですが、以下のようなことができたのは貴重だったかなと思います。

  • 母の日イベントに唯一父1人だけで参加する
  • 顔を覚えられているので幼稚園近くの公園に1人でいても通報されない

理想と現実

元々、上の子が幼稚園に行っている間の1時間程度を使って少しだけ業務を行う予定だった(そういう命令があったわけではなく妻とも相談して了承済みの話)のですが、幼稚園の送迎・買い物・家事・下の子の面倒を見るなどしていると全然時間が取れず、また体力の消耗も激しかったので結局業務は全くできませんでした。 これは完全に2人目の育児の大変さを甘く見ていた結果です。

育休の長さについて

制度的には1年取得しても問題なかったのですが、私は妻とも相談して4ヶ月で復帰しました。 ただこの4ヶ月という期間も、元々暫定で2ヶ月という申請をした上で2回延長した結果になります。 下の子の成長具合や上の子の幼稚園の様子、また妻の体力などを考慮しつつ夫婦納得の上で期間が決められたのは良かったなぁと思います。

復帰に際しての不安

上の子の時は転職前かつ出産時の諸々のトラブル関連で育児休暇を取らずに終わってしまったので、長期間の育児休暇をとるのは初めてでした。 休暇中に取り残される・ちゃんと復帰できるだろうかといった不安が無いわけでは無かったのですが、それ以上に家事・育児が大変だったので考える余裕もあまり無かったのが正直なところです。

実際に復帰してみて(まだ数日しか経っていませんが)ですが、仕事っぽいことはできているつもりであることと、4ヶ月いなくても温かく迎えてくれる職場・メンバーに恵まれたので杞憂だったなという感想です。

最後に

上の子の幼稚園入園に伴って生活リズムが早くなったので、復帰後は 09:00-18:00 勤務から 07:00-16:00 の時差勤務に変更しました。 これまでだと朝に数分顔を合わせて、家に着いたら夕食が終わっているという状態になってしまいますが、時差勤務により子供と顔を合わせる時間が増え、帰宅後に家事も手伝えて、満員電車も(多少)回避できてとみんなハッピーになりました。

育児休暇に入るまでの待機期間に始まり、育児休暇期間の延長、復帰後の勤務形態の変更などいろいろと父として育児しやすい環境が整いつつあり助かったので、育休をとったからと言って幼稚園でママさんたちに「すごーい!」と言われないようになると良いですね。


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

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

インフラエンジニアが育休を取りました

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

昨年第一子が生まれたので一ヶ月間の育休を取りました。 弊社での育休取得者第一号ということで、今回は育休について個人的な感想などを書いていきたいと思います。

育休に入る前の準備

日頃の作業や、ネットワーク構成図、障害時対応などをドキュメントにまとめていきました。

ドキュメントを書く目的は、もちろん他のエンジニアと情報共有するためですが、気づきとして属人化されていた作業が見えたということが上げられます。 属人化して良いことは何もないので、今回のような機会に棚卸しできたのは良かったです。 「後でやる」タスクになっていた、スクリプトの改善だったり、使っていないサービスの削除や古いドキュメントの更新などはやっていて気持ちよかったです。

育休中の取り組み

僕はもともと夜型人間であり、朝起きるのがすごく苦手な人種だったので、育休中は昼夜逆転生活を開始し、夜中のミルク対応やおむつ替えなどをやってました。 だいたい朝の6時頃に寝て昼12時頃に起きるといった感じです。起きた頃には妻は昼飯の時間なので、僕がパスタを作っていたのですが、存外にうまく作れる方法を発見してしまったため、育休一ヶ月間のうち20日くらいはパスタ食べてました。太りました。😭😭😭

周りの反応

妻からは「育休取れるとかいい会社だね」と言われ、親親戚からも似たようなことを言われました。

育休は国の制度なのですが、会社の制度だと思ってる方もいるようで、うちの会社は無いからねぇとか言っているのをよく聞きます。 長期間会社を休むことにうしろめたさを感じる人もいるようですが、子育てはほんとに大変なのでワンオペしていると疲労で潰れかねません。 なので気にせずとっていきましょう。どうしても気後れするというエンジニア諸君は弊社に転職してください。

そういえば保健所に検診に行った際、周りにだいたい30名ほどの親御さんがいらっしゃったのですが、男性は一人しかいませんでした。

お金の話し

これは調べれば分かることなので改めて説明しませんが、育児休業給付金がいつ頃振り込まれるのか気になるという方もいるかと思います。 僕の場合、3月1日〜3月31日まで育休期間で4月1日に復帰しました。すぐに申請を出し、5月7日に職業安定局から振込されていました。

なお育児休業給付金は、育児休業が開始してから2ヶ月毎に、2ヶ月分を申請し支給されます。ただ2ヶ月も待ってたら貯金が底を尽きる!という方は1ヶ月毎の申請もできますのですればいいと思います。会社と相談してください。

復帰後

特に大きな問題もなくサービス提供できていたようで、復帰後は大量のメールと格闘した後、いつもの業務に戻ることができました。

所感

子育てと仕事、どちらが大変かという愚問を自身に投げてみたところ、明らかに子育ての方が大変だと分かりました。

今や核家族がほとんどで、子育てはパートナー以外に頼る人がいないというのが現状だと思います。 一人で無理して頑張ってもお互い不幸になるだけなので、育休とって夫婦とも育児に参加するのが普通の世の中になっていって欲しいと思います。


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

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

Elastic Beanstalk Multicontainer DockerでChromeDriverを使うJavaアプリケーションをデプロイする

こんにちは。 @makinoshi です。

トヨクモでは製品の外形監視などに、Clojureで作成したWebアプリケーションを使っています。

E2E監視に使うためにサーバー上でChrome Driverなどを動かす必要がありましたが、その環境を構成管理ツールでも、ましてや手動で構築する手間をかけたくはありません。 またその環境が正常に動作するかをローカルで確認して、そのままデプロイできれば簡単です。

そこでElastic Beanstalk Multicontainer Docker(以下、EB)Elastic Container Registry(以下、ECR)を使って構築することにしました。

Clojureのアプリケーションは1つのjarファイルにして java -jar で実行するので、ノウハウとしてはJavaアプリケーションのデプロイと変わりません。

手順としては、ECRにコンテナをデプロイし、EBの環境を構築して設定ファイルを準備し、EBにデプロイすれば完了です。

ディレクトリ構成

まずディレクトリ構成からです。今回の記事の主旨以外のソースコード等は省略しています。

.
├── Makefile
├── docker
│   ├── java
│   │   ├── Dockerfile
│   │   └── jar
│   │       └── app.jar
│   └── nginx
│       ├── Dockerfile
│       └── nginx.conf
├── eb
│   ├── Dockerrun.aws.json

ECRにコンテナのリポジトリを作る

まずAWS ECRの管理画面で今回デプロイするコンテナのリポジトリを作ります。ECR > リポジトリの作成から作ることができます。

この例ではNginxとJavaアプリケーションの2つのコンテナが必要なので2つのリポジトリを作りました。

JavaアプリケーションのDockerfile

前述のように今回はJavaアプリケーションが動作するコンテナ上にChromeDriverが動作する環境が必要だったので、次のようなDockerfileを書きました。

FROM amazoncorretto:8

WORKDIR /app

COPY jar/app.jar /app/app.jar

RUN yum update -y && yum install -y \
    wget \
    unzip \
    ipa-gothic-fonts ipa-mincho-fonts ipa-pgothic-fonts ipa-pmincho-fonts \
    https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm \
    && yum clean all \
    && rm -rf /var/cache/yum

RUN wget https://chromedriver.storage.googleapis.com/75.0.3770.90/chromedriver_linux64.zip \
    && unzip chromedriver_linux64.zip \
    && mv chromedriver /usr/local/bin/ \
    && rm chromedriver_linux64.zip

EXPOSE 5000

ENTRYPOINT ["java", "-jar", "-server", "-XX:+TieredCompilation", "-Xms512m", "-Xmx512m", "/app/app.jar"]

JDKとしてAmazon Correttoの公式Dockerコンテナを使っています。

またChromeとChromeDriverのインストールではyumのキャッシュやダウンロードしたzipファイルを削除することで、極力ビルドしたコンテナサイズを小さくするようにしています。

最後の ENTRYPOINT でJVMのオプションを渡していますが、特にメモリ設定についてはお使いのインスタンスサイズ等に合わせたものにしていただければと思います。

NginxのDockerfile

リバースプロキシとしてNginxを使います。

まず nginx.conf は次のようになります。

user  nginx;
worker_processes auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;

    sendfile  on;
    tcp_nopush on;

    upstream app_backend {
        server java:5000;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://app_backend;
        }
    }
}

ポイントは upstream app_backend の設定です。後述しますが、EBに渡す設定ファイルで別のDockerコンテナをどう参照できるか設定でき、ここでは java という名前で参照させ、またJavaのDockerfileで5000をEXPOSEしたので、 server java:5000 と設定しました。

Dockerfileはシンプルです。

FROM nginx:mainline

COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

ECRへのプッシュ方法

作ったリポジトリの詳細画面に入り、「プッシュコマンドの表示」をクリックすると、そのリポジトリにDockerイメージをプッシュする方法が表示されます。

後ほどMakefileの中でECRへのプッシュを含む、全体のデプロイ方法を紹介します。

EBの環境を構築

今回はCLIではなくマネジメントコンソールから作成しました。

環境の作成を選択し、「事前設定済みプラットフォーム」に「Multi-container Docker」を選択します。アプリケーションコードはサンプルアプリケーションのままにします。 本記事ではVPN等のネットワーク環境の構築については解説しませんが、VPNの中に入れ、さらにNAT経由で外にアクセスするようにしています。

作成が完了したら、ステータスやログを確認して、正常に環境が構築できたことを確認します。

確認できたらebcliを使ってEBの環境を参照するための設定ファイルを作ります。

$ cd eb
$ eb init
# 作成した環境を選択

Dockerrun.aws.json

EBに対して具体的なコンテナの設定をするのが Dockerrun.aws.json ファイルです。

{
  "AWSEBDockerrunVersion": 2,
  "containerDefinitions": [
    {
      "name": "java",
      "image": "<ECR上のURL>",
      "essential": true,
      "memory": 1280,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "<Resion>",
          "awslogs-group": "<ロググループ名>"
        }
      }
    },
    {
      "name": "nginx",
      "image": "<ECR上のURL>",
      "essential": true,
      "memory": 128,
      "portMappings": [
        {
          "hostPort": 80,
          "containerPort": 80
        }
      ],
      "links": [
        "java"
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "<Resion>",
          "awslogs-group": "<ロググループ名>"
        }
      }
    }
  ]
}

logConfiguration ではawslogsドライバーを使用してコンテナのログをCloudWatchLogsに集めるための設定をしています。

linksname を指定して参照させています。リバースプロキシするために、nginxにjavaで参照できるように設定を渡しています。

Makefileでデプロイ

最後に一連のデプロイをMakefileで単一のタスクに組み上げます。

# ClojureアプリケーションをLeiningenでビルドし、
# Dockerfileから参照できるディレクトリにJARファイルを配置する
jar-build:
    @lein do clean, uberjar
    @mkdir -p docker/java/jar
    @cp -p target/uberjar/harvest.jar docker/java/jar/

docker-build:
    @cd docker/java && docker build -t <ECRのリポジトリ名> .
    @cd docker/nginx && docker build -t <ECRのリポジトリ名> .

build: jar-build docker-build

login-ecr:
    $(shell aws ecr get-login --no-include-email --region <Resion>)

push-java-docker:
    docker tag <ECRのリポジトリ名>:latest <ECRのイメージURL>:latest
    docker push <ECRのイメージURL>:latest

push-nginx-docker:
    docker tag <ECRのリポジトリ名>:latest <ECRのイメージURL>:latest
    docker push <ECRのイメージURL>:latest

push-docker: login-ecr push-java-docker push-nginx-docker

deploy: build push-docker
    @cd eb && eb deploy

JARファイルのビルド以外は、ECRのリポジトリで解説されている手順通りです。

あとは、make deploy を実行すれば完了です。

おわりに

EBを使うことで非常に簡単にDockerをデプロイすることができました。

弊社では現在運用中のPHP製品をEB+ECRの構成に変更するプロジェクトが進行中です。

Javaアプリケーションだと本記事のようにJARファイル1つを配置して起動するだけですし、起動オプション等もENTRYPOINTで java -jar コマンドの引数に渡すだけとかなり簡単にできますが、PHPのアプリケーションではそうはいきません。

次回以降の記事で、PHPの本番環境をDocker化するノウハウについて共有していこうと思います。


弊社では技術が好きなエンジニアを継続的に募集しています。

興味を持っていただいた方は、ページ下部の採用情報をご覧ください。