Nginxのngx_http_limit_conn_moduleとngx_http_limit_req_moduleで大量リクエスト制限をする

Nginxのngx_http_limit_conn_modulengx_http_limit_req_moduleを使用すると、Dos攻撃等による大量リクエストがあった際に制限をかけられるとのことで、試してみたいと思います。

ngx_http_limit_conn_moduleでは同時接続数の制限、ngx_http_limit_req_moduleでは時間当たりのリクエスト制限のようです。

今回もdocker-composeを使用して検証してみます。

環境

ngx_http_limit_conn_module

ngx_http_limit_conn_moduleでは、主にlimit_conn_zoneディレクティブとlimit_connディレクティブを設定し、IPアドレス単位での制限を実施します。

以下設定ファイルです(本題でない箇所は割愛してます)。

nginx.conf

http {
  limit_conn_zone $binary_remote_addr zone=addr_limit:10m;

  server {
    listen       80;
    server_name  localhost;

    root /app/public;

    location / {
      limit_conn addr_limit 10;
      limit_conn_status 503;

      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_pass http://puma;
    }

limit_conn_zoneディレクティブでは、メモリに保存するためのキー、ゾーン、テーブルのサイズを指定します。キーに$binary_remote_addrを指定することで、32ビット表現のIPv4アドレス単位で制限をかけられます(メモリ容量に優しい)。

limit_connディレクティブでは、ゾーンを指定し、最大同時接続数をその後に続けます。

http、server、locationの各コンテキストに配置可能ですが、今回はアプリケーションルートへのアクセスで検証したいので、location /に設定してみました。

この設定だと、同一IPアドレスからの最大接続数は10が上限となっています。

検証

abコマンドを使用してリクエストを投げてみます。

$ ab -n 10 -c 10 http://localhost/

オプションのnはリクエスト数、cは同時接続数を設定するものです。

結果

# ...

Complete requests:      10
Failed requests:        0

Failed requestsが0となっており、全てのリクエストが成功した結果になりました。

では同時接続数を変更してみます。

$ ab -n 20 -c 20 http://localhost/

結果

# ...

Complete requests:      20
Failed requests:        9

Complete requestsが20、Failed requestsが9となりました。

最大接続数が10で、20接続が1リクエストずつ送ったことを考えるとFailed requestsは10になってほしかった・・。

何となくupstream側の影響もありそうな気がするのですが、一旦ここでは制限ができていることが確認できたので良しとします(「limit_conn not working」等で調べると色々出てきたりもしました)。

ngx_http_limit_req_module

ngx_http_limit_req_moduleではlimit_req_zonelimit_reqディレクティブを使用し、IPアドレスにつき単位時間あたりのリクエスト数に制限をかけることができます。

nginx.conf

http {
  limit_req_zone $binary_remote_addr zone=req_limit:10m rate=1r/s;

  server {
    listen       80;
    server_name  localhost;

    root /app/public;

    location / {
      limit_req zone=req_limit burst=5;

      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_pass http://puma;
    }

limit_req_zoneディレクティブではlimit_conn_zoneディレクティブと同様、メモリに保存するためのキー、ゾーン、テーブルのサイズを指定します。また、リクエスト回数の許容レート(頻度)を指定します。

limit_reqディレクティブではゾーンを指定し、burstというオプションを指定します。これはlimit_req_zoneディレクティブで指定したレートを越えたリクエストについて、burstに指定した数までをキューイングし、遅延して処理をしてくれるようになるオプションです。burstをオーバーしたリクエストについてはデフォルトで503エラーを返却します。

上記設定だと、IPアドレスにつき秒間1リクエスト相当のリクエスト頻度を許可しており、超過した5リクエスト分までをキューに追加して遅延処理させています。

burstオプションについては穴の空いたバケツを想像するとわかりやすいかもしれません。5個のリクエストが入るバケツがあり、穴は1秒につき1リクエストを通過させています。バケツから溢れたリクエストは503エラーを流します。

検証

$ ab -n 6 -c 1 http://localhost/

結果

# ...

Concurrency Level:      1
Time taken for tests:   5.055 seconds
Complete requests:      6
Failed requests:        0
Total transferred:      79545 bytes
HTML transferred:       74544 bytes
Requests per second:    1.19 [#/sec] (mean)
Time per request:       842.499 [ms] (mean)
Time per request:       842.499 [ms] (mean, across all concurrent requests)
Transfer rate:          15.37 [Kbytes/sec] received

Time taken for testsを見ると5秒かかっていることがわかります。実際コマンドを打って5秒後に結果が出てきています。Time per requestを見るとおよそ0.8秒で、1リクエストがざっくり1秒で処理された結果になりました。

nodelayオプション

nodelayオプションというものも指定可能です。通常、burstオプションで指定したリクエスト分は遅延させてリクエストを処理しますが、その遅延処理をなくし、即座にリクエストを処理するオプションがnodelayです。

初めてnodelayを見た際はアクセス制限として意味が無くなってしまうのでは…?とも思いましたが、例えば「秒間10リクエストまではサービス利用における想定の範囲内で問題無い、体験が悪くなってしまうので遅延させる必要も無い。しかしそれを超えるリクエストが一気に来てしまうのは想定していないし不審な動きと考えられるから制限したい」等、柔軟な制限設定ができるようになるので今は便利なのかもと思ってます。

検証

nginx.conf

http {
  limit_req_zone $binary_remote_addr zone=req_limit:10m rate=1r/s;

  server {
    listen       80;
    server_name  localhost;

    root /app/public;

    location / {
      limit_req zone=req_limit burst=5 nodelay; # nodelay追加

      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_pass http://puma;
    }
$ ab -n 6 -c 1 http://localhost/

結果

Concurrency Level:      1
Time taken for tests:   0.118 seconds
Complete requests:      6
Failed requests:        0
Total transferred:      79529 bytes
HTML transferred:       74544 bytes
Requests per second:    50.69 [#/sec] (mean)
Time per request:       19.730 [ms] (mean)
Time per request:       19.730 [ms] (mean, across all concurrent requests)
Transfer rate:          656.08 [Kbytes/sec] received

Time taken for tests を見ると、およそ0.1秒でテストが終わったことがわかります。

また、Time per request からもわかるように、nodelayを指定していない場合と比べてリクエストの処理が速攻で終わった結果です。コマンドを打って即結果が表示されます。

ちなみに当然ですが、バケツの容量自体は空いていない(リクエスト頻度が高い)ので、同じab -n 6 -c 1 http://localhost/を間髪入れずに叩くとその分Failed requestsがカウントされます。

終わりに

今回はNginxで大量リクエスト制限を試してみました。

当然のことながらセキュリティやパフォーマンスの観点でも有用に思いました。セキュリティで言うと、より低レイヤな攻撃は防げないのでもう少し踏み込んでいきたいところ。

次はキャッシュとしてのNginxも試してみたいです。

参考記事等

書籍:nginx実践入門

Nginxでのアクセス流量制御を検証してみた - Qiita

過度のアクセスに備える(その2)!!Nginxのlimit_reqの設定と検証 | Code & Business

nginx でリクエストを制限できるモジュール「ngx_http_limit_req_module」 - kakakakakku blog

Re: nginx limit_req and limit_conn not working to prevent DoS attack

Nginxのアクセスログをfluentd経由でMySQLに保存する

「nginx実践入門」を呼んでやってみたかったことの一つ、fluentdに入門してみようと思います。

環境構築は https://itoka.hatenadiary.com/entry/2022/03/04/004453 がベースです。

環境

  • Apple M1 Pro
  • macOS Monterey 12.1
  • Docker Engine 20.10.12
  • Docker Compose v2.2.3
  • Ruby 3.1.1
  • Ruby on Rails 7.0.2
  • Nginx 1.21.6
  • fluentd 1.14

fluentdのDockerfile

hub.docker.com

Docker Hub > fluentd > How to build your own image > 3.1 For current images を参考に、mysqlプラグインが入ったDockerfileを作成していきます。

ディレクトリはapp/docker/fluentd/にしました。

ちなみに、&& apk add --no-cache mariadb-dev \ がないと、ビルド時にERROR: Error installing fluent-plugin-mysql:で失敗しました。

FROM fluent/fluentd:v1.14-1

# Use root account to use apk
USER root

# below RUN includes plugin as examples elasticsearch is not required
# you may customize including plugins as you wish
RUN apk add --no-cache --update --virtual .build-deps \
  sudo build-base ruby-dev \
    && apk add --no-cache mariadb-dev \ # これが無いとビルド時エラー
  && sudo gem install fluent-plugin-mysql --no-document \ # mysqlプラグインの指定
  && sudo gem sources --clear-all \
  && apk del .build-deps \
  && rm -rf /tmp/* /var/tmp/* /usr/lib/ruby/gems/*/cache/*.gem

docker-compose.yml

version: '3'

services:
  web:
    build:
      context: .
      dockerfile: ./docker/web/Dockerfile
    volumes:
      - .:/app
      - public:/app/public
      - tmp:/app/tmp
    depends_on:
      - db
    stdin_open: true
    tty: true
    environment:
      DB_ROOT_USERNAME: root
      DB_USERNAME: development
      DB_PASSWORD: password
      TZ: Asia/Tokyo
      DB_HOST: db

  db:
    platform: linux/x86_64
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=development
      - MYSQL_USER=development
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=passwordpassword
      - TZ=Asia/Tokyo
    ports:
      - '3306:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./volumes/mysql/data/db/:/var/lib/mysql
      - ./volumes/mysql/conf/db/my.cnf:/etc/my.cnf.d/my.cnf

  nginx:
    build:
      context: .
      dockerfile: ./docker/nginx/Dockerfile
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - public:/app/public
      - tmp:/app/tmp
      - http-log:/var/log/nginx # 追加
    ports:
      - "80:80"
    depends_on:
      - web

  fluentd: # 追加
    build:
      context: .
      dockerfile: ./docker/fluentd/Dockerfile
    ports:
      - 24224:24224
    volumes:
      - ./docker/fluentd/fluent.conf:/fluentd/etc/fluent.conf
      - http-log:/var/log/nginx

  log_db: # 追加
    platform: linux/x86_64
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=log
      - MYSQL_USER=log
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=passwordpassword
      - TZ=Asia/Tokyo
    ports:
      - '3307:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./volumes/mysql/data/log_db/:/var/lib/mysql
      - ./volumes/mysql/conf/log_db/my.cnf:/etc/my.cnf.d/my.cnf
      - ./docker/fluentd/initdb.d:/docker-entrypoint-initdb.d

volumes:
  public:
  tmp:
  http-log: # 追加 
  • nginxとfluentdでログを共有するため、http-logボリュームを追加しました
  • nginxについて、個別設定ファイルを作成するため、./docker/nginx/conf.d:/etc/nginx/conf.dをvolumeに指定しました
  • ログ保管用にlog_dbというMySQLコンテナを立てます
  • log_db/docker-entrypoint-initdb.dにログ保管用のCREATE TABLE文を記載したSQLを指定し、コンテナ起動時にテーブルが作成されるようにします

nginxのログフォーマットを変更する

使いやすいLTSVにします。

conf.dに格納し、nginx.confでincludeするようにします。

docker/nginx/conf.d/log_format.conf

log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent" "$http_x_forwarded_for"';

log_format  ltsv  'time:$time_iso8601\t'
                  'remote_addr:$remote_addr\t'
                  'status:$status\t'
                  'request_method:$request_method\t'
                  'request_uri:$request_uri\t'
                  'request_length:$request_length\t'
                  'https:$https\t'
                  'uri:$uri\t'
                  'query_string:$query_string\t'
                  'bytes_sent:$bytes_sent\t'
                  'body_bytes_sent:$body_bytes_sent\t'
                  'http_referer:$http_referer\t'
                  'useragent:$http_user_agent\t'
                  'forwardedfor:$http_x_forwarded_for\t'
                  'request_time:$request_time\t'
                  'upstream_addr:$upstream_addr\t'
                  'upstream_response_time:$upstream_response_time\t'
                  'upstream_cache_status:$upstream_cache_status';

docker/nginx/nginx.conf

http {
  upstream puma {
    server  unix:///app/tmp/sockets/puma.sock;
  }

  include /etc/nginx/conf.d/*.conf;

    access_log  /var/log/nginx/access.log ltsv;

# ...

また、/var/log/nginx/access.log/var/log/nginx/error.logですが、nginxのdocker imageだとこれらのファイルは標準出力へのシンボリックリンクとなっており、ファイルに出力してくれないようでした。

https://imanengineer.net/docker-compose-nginx-log/

ログのファイル名を別のものに変えると、ファイルに出力してくれます。

docker/nginx/nginx.conf

# ...

error_log /var/log/nginx/error_dev.log; # error_dev.log

http {
  upstream puma {
    server  unix:///app/tmp/sockets/puma.sock;
  }

  include /etc/nginx/conf.d/*.conf;

    access_log  /var/log/nginx/access_dev.log ltsv; # access_dev.log

# ...

ログ保管用MySQLテーブル

カラム定義は適当です。LTSVのフォーマットに合わせて作成します。

docker/fluentd/initdb.d/Log.sql

DROP SCHEMA IF EXISTS log;
CREATE SCHEMA log;
USE log;

DROP TABLE IF EXISTS nginx_access_log;

CREATE TABLE nginx_access_log
(
  id                      INT AUTO_INCREMENT PRIMARY KEY,
  time                    DATETIME,
  remote_addr             VARCHAR(200),
  status                  INT,
  request_method          VARCHAR(10),
  request_uri             TEXT(3000),
  request_length          INT,
  https                   VARCHAR(10),
  uri                     TEXT(3000),
  query_string            TEXT(3000),
  bytes_sent              INT,
  body_bytes_sent         INT,
  http_referer            TEXT(3000),
  useragent               TEXT(500),
  forwardedfor            TEXT(500),
  request_time            VARCHAR(20),
  upstream_addr           VARCHAR(200),
  upstream_response_time  VARCHAR(20),
  upstream_cache_status   VARCHAR(20)
);

fluent.conf

fluentdの設定ファイルを用意します。

docker/fluentd/fluent.conf

<source>
  @type tail
  format ltsv
  path /var/log/nginx/access_dev.log
  pos_file /var/log/nginx/access_dev.log.pos
  tag nginx.access
  time_key time
  time_format %Y-%m-%dT%H:%M:%S%z
</source>

<match nginx.access>
  @type mysql_bulk
  host log_db
  port 3306
  database log
  username log
  password password
  table nginx_access_log
 
  column_names time,remote_addr,status,request_method,request_uri,request_length,https,uri,query_string,bytes_sent,body_bytes_sent,http_referer,useragent,forwardedfor,request_time,upstream_addr,upstream_response_time,upstream_cache_status
  key_names ${time},remote_addr,status,request_method,request_uri,request_length,https,uri,query_string,bytes_sent,body_bytes_sent,http_referer,useragent,forwardedfor,request_time,upstream_addr,upstream_response_time,upstream_cache_status

    flush_interval 10s
</match>

source

  • @typeには tailを指定します
  • formatにはLTSVを指定します
  • time_formatは、後ほどMySQLのDATETIMEカラムに格納しやすくするために指定しています(ログフォーマットでせっかくtime:$time_iso8601を指定したのに意味がない・・?)

match nginx.access

  • sourcenginx.accessのタグをつけ、match nginx.accessMySQLに飛ばしていきます
  • プラグインMySQLに送りつける場合、@typemysql_bulkのようです
  • host~passwordはログデータを格納するDB接続情報を指定します(ここではlog_dbに指定した接続情報
  • column_namesにはログを保管するMySQLのカラムを指定します
  • key_namecolumn_namesとLTSVのラベルをマッピングさせます

動作確認

ここまで対応したらdocker-compose upをしたうえでrailsアプリケーションにアクセス(http://localhost)し、アクセスログMySQLに送られていることを確認できると思います。

# MySQLにログイン
$ docker-compose exec log_db bash -c 'mysql -u ${MYSQL_USER} -p${MYSQL_PASSWORD}'

mysql> use log
mysql> select * from nginx_access_log\G

*************************** 1. row ***************************
                    id: 1
                  time: 2022-03-16 14:04:59
           remote_addr: 172.18.0.1
                status: 304
        request_method: GET
           request_uri: /
        request_length: 1534
                 https: 
                   uri: /
          query_string: -
            bytes_sent: 788
       body_bytes_sent: 0
          http_referer: -
             useragent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36
          forwardedfor: -
          request_time: 0.506
         upstream_addr: unix:///app/tmp/sockets/puma.sock
upstream_response_time: 0.506
 upstream_cache_status: -
26 rows in set (0.01 sec)

おまけ

本当はMySQLに取り込んだレコードをMetabaseに読み込んでSQL発行までしたかったのですが、コンテナの起動がうまくいったりいかなかったりしたので、おまけ程度にdocker-compose.ymlを載せておきます。。

# ...

log_db:
    platform: linux/x86_64
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=log
      - MYSQL_USER=log
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=passwordpassword
      - TZ=Asia/Tokyo
    ports:
      - '3307:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./volumes/mysql/data/log_db/:/var/lib/mysql
      - ./volumes/mysql/conf/log_db/my.cnf:/etc/my.cnf.d/my.cnf
      - ./docker/fluentd/initdb.d:/docker-entrypoint-initdb.d

  metabase: # 追加
    image: metabase/metabase
    volumes:
      - "./docker/metabase/data:/metabase-data"
    environment:
      MB_DB_FILE: /metabase-data/metabase.db
      MB_DB_TYPE: mysql
      MB_DB_DBNAME: log
      MB_DB_PORT: 3306
      MB_DB_USER: log
      MB_DB_PASS: password
      MB_DB_HOST: log_db
    ports:
      - "3000:3000"
    depends_on:
      - log_db

参考記事

tech-lab.sios.jp

www.aska-ltd.jp

Ruby2.6.8 docker imageで何故かnode v10をインストールできない

対策結論

2.6.8-busterイメージを使用する

経緯

古いRailsアプリケーションのバージョンアップ作業をしている中で遭遇。

Rubyのバージョンアップから進めており、Ruby2.6.0→Ruby2.7.0に更新した際、nodeのバージョンを10に指定しているにも関わらず、なぜか12になってしまいました。

検証の結果、2.6.7までは問題なく、2.6.8から発生することが判明しました。

Ruby2.6.7

FROM ruby:2.6.7

//...

RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - \
    && apt-get install -y nodejs \
root@b59a6e98ec4a:/var/www/app# node -v
v10.24.1

Ruby2.6.8

FROM ruby:2.6.8

//...

RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - \
    && apt-get install -y nodejs \
root@496f4fb0e931:/var/www/app# node -v
v12.22.5

ビルドログを凝視すると違いがありました。

Ruby2.6.7

#10 [ 7/17] RUN apt-get install -y nodejs
#10 sha256:5efae8405303b7a2bcad62cf4e2b5a1cef7808df3945af4cc652eb708cad4768
#10 0.290 Reading package lists...
#10 1.078 Building dependency tree...
#10 1.255 Reading state information...
#10 1.527 The following NEW packages will be installed:
#10 1.529   nodejs
#10 1.703 0 upgraded, 1 newly installed, 0 to remove and 36 not upgraded.
#10 1.703 Need to get 15.9 MB of archives.
#10 1.703 After this operation, 80.5 MB of additional disk space will be used.
#10 1.703 Get:1 https://deb.nodesource.com/node_10.x buster/main amd64 nodejs amd64 10.24.1-1nodesource1 [15.9 MB]
#10 3.667 debconf: delaying package configuration, since apt-utils is not installed
#10 3.705 Fetched 15.9 MB in 2s (8395 kB/s)
#10 3.744 Selecting previously unselected package nodejs.
(Reading database ... 24018 files and directories currently installed.)
#10 3.772 Preparing to unpack .../nodejs_10.24.1-1nodesource1_amd64.deb ...
#10 3.777 Unpacking nodejs (10.24.1-1nodesource1) ...
#10 6.604 Setting up nodejs (10.24.1-1nodesource1) ...
#10 DONE 7.1s

#10 1.703 Get:1 https://deb.nodesource.com/node_10.x buster/main amd64 nodejs amd64 10.24.1-1nodesource1 [15.9 MB]

Ruby2.6.8

#10 [ 7/17] RUN apt-get install -y nodejs
#10 sha256:3b2dc80a498093067ad65fe0fb4f0017f5ffb57f62b010cda059f672cb36a61d
#10 0.310 Reading package lists...
#10 1.055 Building dependency tree...
#10 1.246 Reading state information...
#10 1.532 The following additional packages will be installed:
#10 1.535   javascript-common libc-ares2 libjs-highlight.js libnode72 libuv1 nodejs-doc
#10 1.545 Suggested packages:
#10 1.545   apache2 | lighttpd | httpd npm
#10 1.599 The following NEW packages will be installed:
#10 1.601   javascript-common libc-ares2 libjs-highlight.js libnode72 libuv1 nodejs
#10 1.602   nodejs-doc
#10 1.712 0 upgraded, 7 newly installed, 0 to remove and 25 not upgraded.
#10 1.712 Need to get 11.7 MB of archives.
#10 1.712 After this operation, 50.9 MB of additional disk space will be used.
#10 1.712 Get:1 http://deb.debian.org/debian bullseye/main amd64 javascript-common all 11+nmu1 [6260 B]
#10 1.737 Get:2 http://deb.debian.org/debian bullseye/main amd64 libc-ares2 amd64 1.17.1-1+deb11u1 [102 kB]
#10 1.773 Get:3 http://deb.debian.org/debian bullseye/main amd64 libjs-highlight.js all 9.18.5+dfsg1-1 [397 kB]
#10 1.937 Get:4 http://deb.debian.org/debian bullseye/main amd64 libuv1 amd64 1.40.0-2 [132 kB]
#10 1.975 Get:5 http://deb.debian.org/debian bullseye/main amd64 libnode72 amd64 12.22.5~dfsg-2~11u1 [8333 kB]
#10 5.188 Get:6 http://deb.debian.org/debian bullseye/main amd64 nodejs amd64 12.22.5~dfsg-2~11u1 [147 kB]
#10 5.229 Get:7 http://deb.debian.org/debian bullseye/main amd64 nodejs-doc all 12.22.5~dfsg-2~11u1 [2545 kB]
#10 6.419 debconf: delaying package configuration, since apt-utils is not installed
#10 6.476 Fetched 11.7 MB in 5s (2579 kB/s)
#10 6.507 Selecting previously unselected package javascript-common.
(Reading database ... 22779 files and directories currently installed.)
#10 6.541 Preparing to unpack .../0-javascript-common_11+nmu1_all.deb ...
#10 6.561 Unpacking javascript-common (11+nmu1) ...
#10 6.597 Selecting previously unselected package libc-ares2:amd64.
#10 6.601 Preparing to unpack .../1-libc-ares2_1.17.1-1+deb11u1_amd64.deb ...
#10 6.608 Unpacking libc-ares2:amd64 (1.17.1-1+deb11u1) ...
#10 6.643 Selecting previously unselected package libjs-highlight.js.
#10 6.648 Preparing to unpack .../2-libjs-highlight.js_9.18.5+dfsg1-1_all.deb ...
#10 6.655 Unpacking libjs-highlight.js (9.18.5+dfsg1-1) ...
#10 6.741 Selecting previously unselected package libuv1:amd64.
#10 6.744 Preparing to unpack .../3-libuv1_1.40.0-2_amd64.deb ...
#10 6.748 Unpacking libuv1:amd64 (1.40.0-2) ...
#10 6.796 Selecting previously unselected package libnode72:amd64.
#10 6.800 Preparing to unpack .../4-libnode72_12.22.5~dfsg-2~11u1_amd64.deb ...
#10 6.803 Unpacking libnode72:amd64 (12.22.5~dfsg-2~11u1) ...
#10 8.144 Selecting previously unselected package nodejs.
#10 8.148 Preparing to unpack .../5-nodejs_12.22.5~dfsg-2~11u1_amd64.deb ...
#10 8.152 Unpacking nodejs (12.22.5~dfsg-2~11u1) ...
#10 8.196 Selecting previously unselected package nodejs-doc.
#10 8.200 Preparing to unpack .../6-nodejs-doc_12.22.5~dfsg-2~11u1_all.deb ...
#10 8.204 Unpacking nodejs-doc (12.22.5~dfsg-2~11u1) ...
#10 8.608 Setting up javascript-common (11+nmu1) ...
#10 8.657 Setting up libc-ares2:amd64 (1.17.1-1+deb11u1) ...
#10 8.670 Setting up libuv1:amd64 (1.40.0-2) ...
#10 8.684 Setting up libjs-highlight.js (9.18.5+dfsg1-1) ...
#10 8.693 Setting up libnode72:amd64 (12.22.5~dfsg-2~11u1) ...
#10 8.702 Setting up nodejs-doc (12.22.5~dfsg-2~11u1) ...
#10 8.710 Setting up nodejs (12.22.5~dfsg-2~11u1) ...
#10 8.734 update-alternatives: using /usr/bin/nodejs to provide /usr/bin/js (js) in auto mode
#10 8.743 Processing triggers for libc-bin (2.31-13+deb11u2) ...
#10 DONE 8.9s

#10 1.712 Get:1 http://deb.debian.org/debian bullseye/main amd64 javascript-common all 11+nmu1 [6260 B]

Ruby2.6.7ではdebian busterを見に行くのに対し、2.6.8ではdebian bullseyeを見てます。

ビルド後OSの情報も覗いてみました。

Ruby2.6.7

root@cb6fa434cad2:/etc# cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 10 (buster)"
NAME="Debian GNU/Linux"
VERSION_ID="10"
VERSION="10 (buster)"
VERSION_CODENAME=buster
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

Ruby2.6.8

root@496f4fb0e931:/etc# cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
NAME="Debian GNU/Linux"
VERSION_ID="11"
VERSION="11 (bullseye)"
VERSION_CODENAME=bullseye
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

やはりバージョンの差異がありました。それぞれnodeの要件を見てみます。

buster

パッケージ: nodejs (10.24.0~dfsg-1~deb10u1)

bullseye

パッケージ: nodejs (12.22.5~dfsg-2~11u1)

Ruby2.6.8イメージではOSのバージョンがアップデートされており、nodeの要件の違いにより、v12がインストールされていたということでした。

docker imageとしてはそれぞれ2.6.8-bullseye2.6.8-busterが存在していたので、盲点でした…。

hub.docker.com


ということで、一旦は2.6.8-buster を使用してアップデート作業を進めていくことで解決しました。

※ ツッコミどころであるnodeとOSのバージョンアップは別途対応

DockerでNginx、Puma(Rails7.0)の環境構築

前回の記事からの続きになります。

itoka.hatenadiary.com

環境

作業ディレクトリ作成

前回に引き続き、/dockerの下に/nginxを作成します

app
└─ docker
    ├── nginx
    │   ├── Dockerfile
    │   └── nginx.conf
    └── web
        ├── Dockerfile
        ├── entrypoint.sh
        └── start-server.sh
# ディレクトリの作成
$ mkdir -p /app/docker/nginx
$ cd /app/docker/nginx

Dockerfile

今回はベースイメージにnginx:latestを使用します。

デフォルトで用意される80番ポートをlistenするserverコンテキストを持ったdefault.confファイルは不要なので、RUN rm -f /etc/nginx/conf.d/*で削除します。

また、CMD /usr/sbin/nginxでnginxを起動するようにしますが、デフォルトではデーモンで起動してしまい、フォアグラウンドで起動しないとコンテナが止まってしまうようです。

If you add a custom CMD in the Dockerfile, be sure to include -g daemon off; in the CMD in order for nginx to stay in the foreground, so that Docker can track the process properly (otherwise your container will stop immediately after starting)! https://hub.docker.com/_/nginx

-g 'daemon off;' でフォアグラウンド化して起動します。

$ vim Dockerfile
FROM nginx:latest

# デフォルトで用意されている個別設定ファイルを削除
RUN rm -f /etc/nginx/conf.d/*

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

# Nginxをforeground起動
CMD /usr/sbin/nginx -g 'daemon off;' -c /etc/nginx/nginx.conf

参考:/etc/nginx/conf.d/default.confRUN rm -f /etc/nginx/conf.d/* で削除する)

server {
    listen       80;
    server_name  localhost;

    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

docker-compose.yml

nginxを追加します。volumesはホストのapp/docker/nginx/nginx.conf/etc/nginx/nginx.confにマウントします。

# appディレクトリにて
$ vim docker-compose.yml
version: '3'

services:
  web:
    build:
      context: .
      dockerfile: ./docker/web/Dockerfile
    volumes:
      - .:/app
    ports:
      - '3000:3000'
    depends_on:
      - db
    stdin_open: true
    tty: true
    environment:
      DB_ROOT_USERNAME: root
      DB_USERNAME: development
      DB_PASSWORD: password
      TZ: Asia/Tokyo
      DB_HOST: db

  db:
    platform: linux/x86_64
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=development
      - MYSQL_USER=development
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=passwordpassword
      - TZ=Asia/Tokyo
    ports:
      - '3306:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./volumes/mysql/data/:/var/lib/mysql

  nginx: # 追加
    build:
      context: .
      dockerfile: ./docker/nginx/Dockerfile
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf # ここ
    ports:
      - "80:80"
    depends_on:
      - web

nginx.conf

デフォルトで用意されているnginx.confをベースに、ほかは何もいじらず、必要部分のみを付け足してみます。

railsアプリのリバースプロキシとして、proxy_passhttp://unix:///app/tmp/sockets/puma.sock;を指定し、UNIXドメインソケット通信でpumaに向けます。

app/docker/nginx/nginx.conf

user nginx;
worker_processes auto;

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

events {
  worker_connections  1024;
}

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;

  log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

  access_log  /var/log/nginx/access.log  main;

  sendfile        on;
  #tcp_nopush     on;

  keepalive_timeout  65;

  #gzip  on;

  include /etc/nginx/conf.d/*.conf;

  server { # 追加
    listen       80;
    server_name  localhost;

    root /app/public;

    location / {
      try_files $uri $uri/index.html @app;
    }

    location @app {
      # クライアントのリクエスト情報をpumaに伝える
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $host;

      proxy_pass http://unix:///app/tmp/sockets/puma.sock;
    }
  }
}

puma.rb

puma側ではPORTを使用せず、ソケットファイルをbindしてソケット通信する設定を記載します。

app/config/puma.rb

# port ENV.fetch("PORT") { 3000 }
bind "unix://#{Rails.root}/tmp/sockets/puma.sock"

Dockerfile

rails用のDockerfileについても、PORTを空ける必要がなくなったため削除します。

app/docker/web/Dockerfile

RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

# EXPOSE 3000

# Start the main process.
CMD ["sh", "./docker/web/start-server.sh"]

start-server.sh

TCP通信の必要がなくなったため、rails serverコマンドからポートとバインドの設定を削除します。

app/docker/web/start-server.sh

#!/bin/sh
echo "run start-server.sh" &
rails server

# rails server -p 3000 -b 0.0.0.0

docker-compose.yml

変更点は3箇所です。

  1. ポートを使用しないので、webコンテナのports設定は削除
  2. UNIXドメインソケット通信によるコンテナ間通信となるため、web・nginxコンテナで名前付きvolumeをマウントしてファイルを共有
  3. nginx.confでアプリケーションルートを指定する(rootディレクティブ)ため、2と同様にweb・nginxコンテナで名前付きvolumeをマウントしてファイルを共有

app/docker-compose.yml

version: '3'

services:
  web:
    build:
      context: .
      dockerfile: ./docker/web/Dockerfile
    volumes:
      - .:/app
      - public:/app/public # 追加
      - tmp:/app/tmp # 追加
#   ports:
#     - '3000:3000'
    depends_on:
      - db
    stdin_open: true
    tty: true
    environment:
      DB_ROOT_USERNAME: root
      DB_USERNAME: development
      DB_PASSWORD: password
      TZ: Asia/Tokyo
      DB_HOST: db

  db:
    platform: linux/x86_64
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=development
      - MYSQL_USER=development
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=passwordpassword
      - TZ=Asia/Tokyo
    ports:
      - '3306:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./volumes/mysql/data/:/var/lib/mysql

nginx:
    build:
      context: .
      dockerfile: ./docker/nginx/Dockerfile
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
      - public:/app/public # 追加
      - tmp:/app/tmp # 追加
    ports:
      - "80:80"
    depends_on:
      - web

volumes: # 追加
  tmp: # 追加
  public: # 追加

動作確認

ここまできたら再ビルドして立ち上げます。

$ docker-compose build --no-cache # 一応--no-cache
$ docker-compose up -d

localhostにアクセスすると、nginx経由でrailsの画面が見えるかと思います🎉

f:id:itoka_pi:20220304002836p:plain

終わりに

Dockerの環境構築は久しぶりにやったのですが、昔より構築スピードが上がっていて成長を感じました。 nginxの実務ではSSRなNuxtに通信を向けてみたり、CSPやBasic認証の運用をしたり、IPアドレス直打ちの対策等をした程度でしたが、コンテナではログ・ssl・キャッシュ・ロードバランス辺りを試してみたいなと思ってます。

あとは今後Ansible辺りも触ってみたい。terraformも勉強したので、Ansibleと組み合わせて構築を自動化してみたい。

・・目指すところはDevOps・・?

参考記事

nginxをdockerで動かす時のTips 3選 - インフラエンジニアway - Powered by HEARTBEATS

Docker + Rails + Puma + Nginx + MySQL - Qiita

はじめてのDockerでRails開発環境構築 [NGINX + Rails 5 (puma) + PostgreSQL] - Qiita

DockerでRuby3.1、Rails7.0、MySQL8.0の環境構築

業務でnginxを触っていた中で、より体系的に学んでみたいと思い「nginx実践入門」という書籍を読みました。

当然nginxであれこれする環境を作りたくなったので、まずはアプリケーションとしてrails7系をdockerで構築したメモです。nginxコンテナじゃないんかいとツッコミがありそうですが、nginxコンテナの導入は次の記事で書きます(余力がない)。

環境

準備

基本的な流れは[railsのコンテナを作るための準備をする → railsのコンテナを作る → DBとつなげる]となります。

まずは準備です。

作業ディレクトリ作成

今回はapp > docker > webと掘ります。こんな構成です。

app
└─ docker
    └─ web
       ├── Dockerfile
       ├── entrypoint.sh
       └── start-server.sh
# ディレクトリ作成
$ mkdir -p /app/docker/web
$ cd /app/docker/web

# Dockerfile作成
$ touch Dockerfile

Dockerfile

ruby3.1のイメージを使います。

FROM ruby:3.1

ENV LANG C.UTF-8
ENV APP_ROOT /app
ENV BUNDLE_JOBS 4
ENV BUNDLER_VERSION 2.2.25

RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT

COPY Gemfile $APP_ROOT/Gemfile
COPY Gemfile.lock $APP_ROOT/Gemfile.lock

RUN gem install bundler -v $BUNDLER_VERSION
RUN bundle -v
RUN bundle install

COPY . $APP_ROOT

# Add a script to be executed every time the container starts.
COPY ./docker/web/entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

EXPOSE 3000

# 起動の度にデフォルトでrails sする(entrypoint.shに持たせても良い)
CMD ["sh", "./docker/web/start-server.sh"]

entrypoint.sh

$ vim entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /app/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

start-server.sh

$ vim start-server.sh
#!/bin/sh
echo "run start-server.sh" &
rails server -p 3000 -b 0.0.0.0

gemfile

railsをbundle installできるようにします。

$ cd ../.. # /app
$ bundle init
$ vim Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'rails', '~>7.0.2'

gemfile.lock

ファイルの中身は空でOKです。

$ touch gemfile.lock

docker-compose.yml

mysqlは8.0にしてみます。dataの永続化は/volumes/mysql/data/で行いました。環境変数は適当なので真似しないでね。

$ cd ../.. # /app
$ mkdir -p /volumes/mysql/data/
version: '3'

services:
  web:
    build:
      context: .
      dockerfile: ./docker/web/Dockerfile
    volumes:
      - .:/app # カレントディレクトリ(後のアプリケーションディレクトリ)をマウント
        ports:
      - '3000:3000'
    depends_on:
      - db
    stdin_open: true
    tty: true
    environment:
      DB_ROOT_USERNAME: root
      DB_USERNAME: development
      DB_PASSWORD: password
      TZ: Asia/Tokyo
      DB_HOST: db

  db:
    platform: linux/x86_64
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=development
      - MYSQL_USER=development
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=passwordpassword
      - TZ=Asia/Tokyo
    ports:
      - '3306:3306'
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - ./volumes/mysql/data/:/var/lib/mysql

railsアプリの作成

ここまでくればdocker-compose runにてビルドし、railsアプリを作成することが可能です。データベースはmysqlを指定します。

$ docker-compose run web rails new . --force --database=mysql

rails newによりGemfileが更新されたので、再度イメージをビルドし、railsアプリとしてのイメージを作成します。

$ docker-compose build

mysqlとの繋ぎ込み

この時点でdocker-compose upをしてからlocalhost:3000でアプリケーションにアクセスすることは可能になっていますが、DBとの繋ぎ込みができていないため、ActiveRecord::DatabaseConnectionErrorが返ってきているはずです。

docker-compose.ymlのwebコンテナに記載した環境変数に合わせて、database.ymlを編集していきましょう。dbコンテナでMYSQL_DATABASE、MYSQL_USER、MYSQL_PASSWORDを指定していたため、データベースとともに権限を持ったユーザーも作成されているはずなので、そちらを指定していきます。

$ vim config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: <%= ENV["DB_USERNAME"] %>
  password: <%= ENV["DB_PASSWORD"] %>
  host: db # コンテナ名(db)で名前解決

development:
  <<: *default
  database: development

本格的な開発フェーズに入ったら、ENV.fetchやcredentialsを組み合わせて設定するようにします(割愛します)。

動作確認

コンテナを起動します。

$ docker-compose up -d

localhost:3000にアクセスします。

rails7.0
hello world

お疲れさまでした!

mysqlコンテナの環境変数について

補足です。

上記docker-compose.ymlのmysqlコンテナで、MYSQL_DATABASE、MYSQL_USER、MYSQL_PASSWORD、MYSQL_ROOT_PASSWORDを設定しています。

mysqlのdockerのドキュメントを見ればわかりますが、特にMYSQL_USER、MYSQL_PASSWORDを設定しておくと、MYSQL_DATABASEで指定したデータベースに対するスーパーユーザー権限が付与されたユーザーを作ってくれます。

$ docker-compose exec db bash -c 'mysql -u root -p${MYSQL_ROOT_PASSWORD}'

mysql> select user, host from mysql.user;
+------------------+-----------+
| user             | host      |
+------------------+-----------+
| development      | %         |
| root             | %         |
| mysql.infoschema | localhost |
| mysql.session    | localhost |
| mysql.sys        | localhost |
| root             | localhost |
+------------------+-----------+
6 rows in set (0.04 sec)

mysql> show grants for development;
+--------------------------------------------------------------+
| Grants for development@%                                     |
+--------------------------------------------------------------+
| GRANT USAGE ON *.* TO `development`@`%`                      |
| GRANT ALL PRIVILEGES ON `development`.* TO `development`@`%` |
+--------------------------------------------------------------+
2 rows in set (0.00 sec)

次回はnginxコンテナです。

書きました↓

itoka.hatenadiary.com

参考記事

Rails 7 + MySQLの環境構築をDocker composeで作る - Qiita

クィックスタート: Compose と Rails — Docker-docs-ja 19.03 ドキュメント

Docker Hub

Ruby on Railsの開発環境をDockerで構築する方法(Rails 6.x)

M1 Pro macbook proでarmネイティブのnode v14をインストールしたかった

メモです。

M1 Pro macbook proを購入したため環境構築を進めていたところ、node v14についてはRosettaを使用すれば問題なくインストールできるということで、当初はそちらでインストールしていました。 が、せっかくM1 Proを買ったのにもったいない…。

そう思い調べてみると、どうやらv14.17.0からはapple silicon版がサポートされていたみたいです。

https://github.com/nodejs/node/pull/38051

普通にインストール

$ nvm i v14
Downloading and installing node v14.18.2...
Downloading https://nodejs.org/dist/v14.18.2/node-v14.18.2-darwin-arm64.tar.xz...
curl: (22) The requested URL returned error: 404

Binary download from https://nodejs.org/dist/v14.18.2/node-v14.18.2-darwin-arm64.tar.xz failed, trying source.
grep: /Users/xxx/.nvm/.cache/bin/node-v14.18.2-darwin-arm64/node-v14.18.2-darwin-arm64.tar.xz: No such file or directory
Provided file to checksum does not exist.
Binary download failed, trying source.
Detected that you have 10 CPU core(s)
Running with 9 threads to speed up the build
Clang v3.5+ detected! CC or CXX not specified, will use Clang as C/C++ compiler!
Local cache found: ${NVM_DIR}/.cache/src/node-v14.18.2/node-v14.18.2.tar.xz
Checksums match! Using existing downloaded archive ${NVM_DIR}/.cache/src/node-v14.18.2/node-v14.18.2.tar.xz
$>./configure --prefix=/Users/xxx/.nvm/versions/node/v14.18.2 <
Node.js configure: Found Python 3.8.9...
INFO: configure completed successfully
/Library/Developer/CommandLineTools/usr/bin/make -C out BUILDTYPE=Release V=0
  touch /Users/xxx/.nvm/.cache/src/node-v14.18.2/files/out/Release/obj.target/node_dtrace_header.stamp
  touch /Users/xxx/.nvm/.cache/src/node-v14.18.2/files/out/Release/obj.target/specialize_node_d.stamp
  c++ -o /Users/xxx/.nvm/.cache/src/node-v14.18.2/files/out/Release/obj.target/icuucx/deps/icu-small/source/common/uniset.o ../deps/icu-small/source/common/uniset.cpp '-DV8_DEPRECATION_WARNINGS' '-DV8_IMMINENT_DEPRECATION_WARNINGS' '-D_DARWIN_USE_64_BIT_INODE=1' '-DOPENSSL_NO_PINSHARED' '-DOPENSSL_THREADS' '-DU_COMMON_IMPLEMENTATION=1' '-DU_ATTRIBUTE_DEPRECATED=' '-D_CRT_SECURE_NO_DEPRECATE=' '-DU_STATIC_IMPLEMENTATION=1' '-DUCONFIG_NO_SERVICE=1' '-DU_ENABLE_DYLOAD=0' '-DU_HAVE_STD_STRING=1' '-DUCONFIG_NO_BREAK_ITERATION=0' -I../deps/icu-small/source/common  -O3 -gdwarf-2 -mmacosx-version-min=10.13 -arch arm64 -Wall -Wendif-labels -W -Wno-unused-parameter -std=gnu++1y -stdlib=libc++ -fno-exceptions -fno-strict-aliasing -MMD -MF /Users/xxx/.nvm/.cache/src/node-v14.18.2/files/out/Release/.deps//Users/xxx/.nvm/.cache/src/node-v14.18.2/files/out/Release/obj.target/icuucx/deps/icu-small/source/common/uniset.o.d.raw   -c

// 以下めちゃくちゃ長いビルドログ

Now using node v14.18.2 (npm v6.14.15)
$ node -v
v14.18.2

$ node -p process.arch
arm64

ビルド中は初めてCPU使用率が90%を超え、ファンが回っていたので若干不安になりました。

おわり

ちなみに

nvmのgithubを眺めていたところ、homebrew経由でnvm本体をインストールすることが公式にはサポートされていないとのことで、curlでnvmをインストールし直しました。

Homebrew installation is not supported. If you have issues with homebrew-installed nvm, please brew uninstall it, and install it using the instructions below, before filing an issue. https://github.com/nvm-sh/nvm#important-notes

$ brew uninstall nvm
$ brew cleanup

$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 14984  100 14984    0     0  56646      0 --:--:-- --:--:-- --:--:-- 58531
=> Downloading nvm from git to '/Users/xxx/.nvm'
=> hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint:   git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint:   git branch -m <name>
Initialized empty Git repository in /Users/xxx/.nvm/.git/
=> Compressing and cleaning up git repository

=> nvm source string already in /Users/xxx/.zshrc
=> Appending bash_completion source string to /Users/xxx/.zshrc
=> Close and reopen your terminal to start using nvm or run the following to use it now:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

homebrew用に構成していた.zshrcを修正します。

$ cat .zshrc
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && . "/opt/homebrew/opt/nvm/nvm.sh" # This loads nvm
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && . "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion

$ cat .zshrc
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion
$ source .zshrc
$ nvm -v
0.39.0

参考

https://zenn.dev/kyushun/articles/6550c5a96aabce

https://github.com/nvm-sh/nvm

【Rails / RSpec】モックの基本とcredentialsのモックについて

4月からの案件ではRails / RSpecで開発しており、実務では初めてRSpecのmockを使ったので記事にしておきます。digメソッドを使った場合のcredentialsのmockも少しだけ詰まったので併せて。

RSpecでmockしたいケース

例えば以下のようなコントローラーがあるとします。

class BooksController < ApplicationController
  def create
    begin
      book = Book.create!(book_params)
    rescue => e
      # 何らかのエラー処理
    end

    HogeMailer.perform_later(book)
    FugaMailer.perform_later(book)

    render status: :created
  end
end

リクエストスペックでの成功パターンのテストケースとしては

  • Bookレコードが一件増えていること
  • HogeMailerが正しく動くこと
  • FugaMailerが正しく動くこと
  • 201が返却されること

ざっくりこの辺りでしょうか。

ここでMailerが動くことのテストをどう考えるのか、内部の動きまでテストするかどうか迷うかと思いますが、Mailerに限らず基本的にはモジュール単体でのテストを行い、内部の動きはそちらで担保する方が疎なテスト戦略としては良いのではないかと思っています。(もちろんチームによってテスト戦略は異なると思いますし、結合テストはある意味コスパが良いので、リクエストスペックだけでテストを行う戦略もあると思います)

ではリクエストスペックにおいてMailerの動きをどう扱うのか、ここで登場するのがテストでお馴染みのmockですね。

テストコード

まずMailer以外のテストコードです。

require "rails_helper"

RSpec.describe "Books", type: :request do
  let(:body) { JSON.parse(response.body) }
  let!(:params) { book: attributes_for(:book) }
  subject { post "api/books", params: params }

  describe "成功" do
    it "Bookのレコードが1件増えている" do
      expect { subject }.to change { Book.count }.by(1)
    end

    it "201が返却される" do
      subject

      expect(response).to have_http_status(201)
    end
  end
end

レコードが増え、201が返却されるテストはこんな感じかと思います。

そしてMailerのmockですが、まず構文は以下です。

allow(mockしたいオブジェクト).to receive(:hoge_method)

戻り値をつけたい場合

allow(mockしたいオブジェクト).to receive(:hoge_method).and_return('hogehoge')

クラスメソッドは上記でできますが、インスタンスメソッドをmockしたい場合もあるかと思います。その場合はreceive_message_chainが使用できます。

allow(mockしたいオブジェクト).to receive_message_chain(:new, :fuga_method).and_return('fugafuga')

テストコード上では以下のようにmockを作成します。

# 略

describe "成功" do
    before do
      allow(HogeMailer).to receive(:perform_later)
      allow(FugaMailer).to receive(:perform_later)
    end

# 略

end

このようにmockを作成することで、リクエストスペック上ではxxxxMailerperform_laterが呼び出されたこと、だけを検証することができます。

require "rails_helper"

RSpec.describe "Books", type: :request do
  let(:body) { JSON.parse(response.body) }
  let!(:params) { book: attributes_for(:book) }
  subject { post "api/books", params: params }

  describe "成功" do
    it "Bookのレコードが1件増えている" do
      expect { subject }.to change { Book.count }.by(1)
    end

    it "HogeMailerのperform_laterが呼ばれる" do # 追加
      subject

      expect(HogeMailer).to have_received(:perform_later).once
    end

    it "FugaMailerのperform_laterが呼ばれる" do # 追加
      subject

      expect(FugaMailer).to have_received(:perform_later).once
    end


    it "201が返却される" do
      subject

      expect(response).to have_http_status(201)
    end
  end
end

Railsで言うと、Jobもジョブスペックに任せて、リクエストスペックではmock化するのがいいと考えてます。もちろん自作モジュール等も。

余談ですが、テストにおけるmock化を考えられるようになってからは実装の設計において疎結合ということをよく考えられるようになったかもしれません。Everyday Rails外から中へ進む注釈で紹介されている「リファクタリング」はこのことかもしれないなと思ってます(ちゃんと読まなきゃ)。

credentialsのmock

最後にcredentialsのmockですが、上記Mailerと同様、credentials.ymlに記載している内容の通り書くことができます。

# hogehoge: abcd
allow(Rails.application.credentials).to receive(:hogehoge).and_return('abcd')

ネストしたcredentialsの場合も、receive_message_chainで書けると思います。 一方、digメソッドを使ったネストしたcredentialsの時に少し詰まりました。別環境では使わないのだけど、コード上どうしても読み込まれてしまうcredentialsに対して、nilを返してもらうことで読み込み時の例外発生を避けたい時に使うやつですね。

終わってみて考えればdigメソッドに対して引数を渡しているだけなので、何てことはないものでしたね…。

# hoge: 
#   fuga:
#     piyo: 1234
allow(Rails.application.credentials).to receive(:dig).with(:hoge, :fuga, :piyo).and_return('1234')

終わりに

Railsを本格的に触るのは4月からでしたが、Rspecがとても簡潔に書けるので楽しいです。感覚としてはフロントエンドのテストを書いているようです。Ruby自体も、それまでに開発していたPHPと比べてシンプルで楽しいなと思います。 また経験2言語目ということで、「これ進研ゼミで見たやつだ!」を頻繁に体感しているおかげもあるかもしれません。

本で言うとrubyに関して現在はチェリー本を読んでいますが、この後はオブジェクト指向設計実践ガイドも読んでみたいですね。

参考

使えるRSpec入門・その3「ゼロからわかるモック(mock)を使ったテストの書き方」 - Qiita

Is there a way to stub Rails credential key? · Issue #2099 · rspec/rspec-rails · GitHub

Everyday Rails… Aaron Sumner 著 et al. [Leanpub PDF/iPad/Kindle]