Toyokumo Tech Blog

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

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 といった事態を無くすことができます。

ヘルスにもとづくローリングデプロイ

手順

最後にこれまでのまとめとしてデプロイの手順を紹介します。例として 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からのみに限定することで、セキュリティーを保ちデータを外部に流出させないようにします。

構成図は以下の通り。

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 ファイルの使い方を説明していきます。

スタックの作成

cf-1

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

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

cf-2

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

パラメータの入力

cf-3

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

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

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

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

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

cf-4

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

ステータス

cf-5

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

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

リソースの一括削除

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化するノウハウについて共有していこうと思います。


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

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

採用に関する情報公開をはじめました

こんにちは。 @makinoshi です。

トヨクモではこの度、scrapboxの公開プロジェクトを使い、エンジニア採用の関連情報を継続的に公開していくことにしました。

なぜ公開するのか

自分自身が就職先・転職先を探す側だった時のことを思い出しながら、求職者の立場に立ってみると、本当に知りたいのは

  • 実際に約束した給与がもらえるのか
  • 賞与を下げるような仕組みはないのか
  • 残業は本当はどれくらいあるのか、ブラック企業の心配はないか
  • 自分のキャリアにとってプラスがあるのか、技術的に成長できる場か
  • 技術的に正しいと思える、やるべきことがやられてる場か

といった実態の生の情報だと思います。

企業側が公開しているインタビュー記事や採用サイトには都合の悪いことは書かずにいいことだけを書いているように思えるのではないでしょうか。

逆に採用する立場になり、企業側として考えていることとしては、採用時点ではなく入社後こそ重要だということです。 面接で来て頂いた方にはよく話すのですが、入社後にお互いがよかったと思えるようにしたいと考えています。 そのためには実態を極力正確に把握してもらうことが重要だと考えました。

そこで今回、scrapboxで様々情報を公開していくことにしました。

なにを公開するのか

給与・年収・文化・雰囲気や開発で実際に使っているツール、社内の勉強会情報などを公開しています。

それだけでなく、弊社がまだできていないものの今後やりたいと思っていることも正直に公開してます。

筆者やその他のエンジニアに頼んで書いてもらいました。内容は社内の他のエンジニアに目を通してもらった上で、実態とズレがないことを確認してもらっています。

もしもっとこういった情報が知りたいということがあれば、こちらから質問していただければ、scrapboxに反映していきます。

おわりに

公開している情報を見て、興味を持っていただけた方は、弊社採用ページよりご応募ください。

またこういった情報公開が当たり前のものになり、エンジニアにとって企業選びがよりやりやすいものになっていけば筆者にとって望外の喜びです。

ClojureのWeb開発でもっとも重要なRing Handlerについて理解する

こんにちは。開発本部の @makinoshi です。

トヨクモではメインの開発言語としてClojureを採用しています。 Clojureの文法は簡潔でデータ型も少なく、比較的覚えやすい言語だと思います。

一方でWeb開発においては、Symfony/LaravelRuby on Railsのような大きなフレームワークに乗るのではなく、 1つのことをうまくやるライブラリを組み合わせてアプリを作っていくという文化であるため、 それぞれを組み合わせていくための知識が必要になります。

その知識は一見すると膨大に思えるのですが、HTTPそのものの知識を除けば実は理解するのに必要な知識は少なく、RingのHandlerがどういうものなのか理解できれば、 ほぼ理解できたといって過言ではありません。

弊社では新卒で入社した方やアルバイトの学生さんなど、徐々にClojureを書く人が増えていることもあり、 この記事で基本的な部分を理解してもらえるように解説していこうと思います。

サンプルコードのリポジトリはこちらにあります。

Ringとは何か

By abstracting the details of HTTP into a simple, unified API, Ring allows web applications to be constructed of modular components that can be shared among a variety of applications, web servers, and web frameworks.

とあるように、HTTPのリクエスト・レスポンスを抽象化し、 仕様 を定めたものです。

抽象化層であるため、実際のサーバーの実装は他が担当します。 実装を提供するサーバーの例としては次のようなライブラリがあります。

これらのサーバーがRingが定めた仕様に沿ってHTTPリクエストを受け、HTTPレスポンスを返します。

弊社製品ではImmutantを使用しています。

Ring Handlerとは何か

Ring Handlerは単なる関数です。

ただし、Ringが定めた仕様に従ったリクエストマップを引数で受け取り、Ringが定めた仕様に従ったレスポンスマップを返す必要があります。 具体的には次のようなものです。

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

Handlerをどう使うか

Ring Handlerは決まった形のInput/Outputを提供する関数だということまでわかりました。

ではそれをどう使うかというと、上述したようにRingの仕様を実装しているサーバーの起動時に引数として渡すだけになります。 ring-jetty-adapter を使った例を示します。

(require 'ring.adapter.jetty)

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

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})

これをREPLで評価して、HTTPieでHTTPリクエストを投げてみます。

*この記事ではHTTPレスポンスを綺麗に表示させるためにHTTPieを使います。

$ http http://localhost:8080
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 08:50:31 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

きちんとリクエストを受け、レスポンスを返せていることが確認できました。

URLによって違った結果を返すにはどうすればいいのか

ここまでで好きな形のレスポンスを返すWebサーバーができていますが、当然ながらどのURLにリクエストしても同じ結果が返ってきます。

$ http http://localhost:8080/hoge
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 08:50:31 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

URLによって異なった結果を返せないと使い物にならないわけですが、そのためにはリクエストマップの中の:uriの値を使います。

(require 'ring.adapter.jetty)

(defn- handler [{:as req :keys [uri]}]
  (cond
    (= uri "/hello")
    {:status 200
     :body "Hello world!"}

    (= uri "/about")
    {:status 200
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body "弊社についてご紹介します。"}

    :else
    {:status 404
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body "お探しのページは見つかりませんでした。"}))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
$ http http://localhost:8080/hello
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 09:05:05 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

$ http http://localhost:8080/about
HTTP/1.1 200 OK
Content-Length: 39
Content-Type: text/plain;charset=utf-8
Date: Tue, 02 Jul 2019 09:05:12 GMT
Server: Jetty(9.4.12.v20180830)

弊社についてご紹介します。

$ http http://localhost:8080/foo
HTTP/1.1 404 Not Found
Content-Length: 57
Content-Type: text/plain;charset=utf-8
Date: Tue, 02 Jul 2019 09:05:23 GMT
Server: Jetty(9.4.12.v20180830)

お探しのページは見つかりませんでした。

一応URLによって異なるレスポンスを返すことはできましたが、handlerにいちいち条件を足していくのはやってられないです。

この問題を解決するために、ルーティングライブラリを使います。 ルーティングライブラリには次のようなものがありますが、やっていることはどれも上記のようなことです。

ここでは弊社の製品でも使っているbidiを使った例を示します。

(require 'ring.adapter.jetty)
(require 'bidi.ring)

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

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(def handler
  (bidi.ring/make-handler route))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})

これで条件分岐をルーティングライブラリにやってもらいつつ、コードを綺麗にすることができました。

*ここで bidi.ring/make-handler は関数を返す関数であることに注意してください。

Middlewareとは何か

ルーティングライブラリを使うことでhandler関数を分けることができましたが、今度はhandlerで共通な処理を含めたいときに困るようになりました。

Ringにはhandlerの処理の前後に任意の処理を実行する概念としてRing Middlewareがあります。

とは言え複雑なものではなく、引数でhandler(関数)を受け取り、新しいhandler(関数)を作って返すだけです。

(require 'ring.adapter.jetty)
(require 'bidi.ring)

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

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(defn- hoge-middleware
  "/hoge以外へのリクエストかつレスポンスが404ならいつもhogeを返すようにする"
  [handler]
  ; 関数が戻り値
  (fn [{:as req :keys [uri]}]                        ; リクエストマップの値を使える
    (let [req (assoc-in req [:params :hoge] "HOGE!") ; リクエストマップの値を自由に書き換えられる
          {:as res :keys [status]} (handler req)     ; ここで引数のhandler(= 元々のhandler)を実行
          ]                                          ; handlerの結果(= レスポンスマップ)に手を加えられる
      (if (and (= status 404) (not= uri "/hoge"))
        {:status 200
         :body "HOGE!"}
        res))))

(def handler
  (-> (bidi.ring/make-handler route) ; handlerを作り、
      (hoge-middleware)              ; middlewareの引数として渡す
      ))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
$ http http://localhost:8080/foo
HTTP/1.1 200 OK
Content-Length: 5
Date: Tue, 02 Jul 2019 09:57:05 GMT
Server: Jetty(9.4.12.v20180830)

HOGE!

APIでJSONを返すにはどうしたらいいのか

最後にRing Middlewareのライブラリを使ってJSONを返すAPIを作ってみます。

そのためにMuuntajaを使います。 MuuntajaはJSONだけでなく様々なデータ形式に対応しており、リクエストされたContent-Typeに対応したレスポンスにしてくれるというとても便利なライブラリです。

(require 'ring.adapter.jetty)
(require 'bidi.ring)
(require 'muuntaja.middleware)

(defn- hello-handler [{:as req :keys [body-params]}]
  {:status 200
   :body body-params}) ; リクエストされたbodyの値をそのまま返すだけ

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(defn- hoge-middleware
  "/hoge以外へのリクエストかつレスポンスが404ならいつもhogeを返すようにする"
  [handler]
  ; 関数が戻り値
  (fn [{:as req :keys [uri]}]                        ; リクエストマップの値を使える
    (let [req (assoc-in req [:params :hoge] "HOGE!") ; リクエストマップの値を自由に書き換えられる
          {:as res :keys [status]} (handler req)     ; ここで引数のhandler(= 元々のhandler)を実行
          ]                                          ; handlerの結果(= レスポンスマップ)に手を加えられる
      (if (and (= status 404) (not= uri "/hoge"))
        {:status 200
         :body "HOGE!"}
        res))))

(def handler
  (-> (bidi.ring/make-handler route) ; handlerを作り、
      (hoge-middleware)              ; middlewareの引数として渡す
      (muuntaja.middleware/wrap-format) ; 追加
      ))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
# リクエストも表示させてみます
$ http -v POST http://localhost:8080/hello foo=bar
POST /hello HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 14
Content-Type: application/json
Host: localhost:8080
User-Agent: HTTPie/1.0.2

{
    "foo": "bar"
}

HTTP/1.1 200 OK
Content-Length: 13
Content-Type: application/json;charset=utf-8
Date: Tue, 02 Jul 2019 10:11:05 GMT
Server: Jetty(9.4.12.v20180830)

{
    "foo": "bar"
}

おわりに

ここまでで、RingのHandlerとMiddlewareについてご理解いただけましたでしょうか。 これらが理解できれば、あとはWebアプリケーションに共通して必要なMiddlewareを足していけば、すぐに通常のWebアプリケーションに必要な機能が揃います。

次回以降、定番のMiddlewareについて解説する記事も書いていこうと思います。


追記

続きはこちらです。

tech.toyokumo.co.jp


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

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