Nginxのngx_http_limit_conn_moduleとngx_http_limit_req_moduleで大量リクエスト制限をする
Nginxのngx_http_limit_conn_module
とngx_http_limit_req_module
を使用すると、Dos攻撃等による大量リクエストがあった際に制限をかけられるとのことで、試してみたいと思います。
ngx_http_limit_conn_module
では同時接続数の制限、ngx_http_limit_req_module
では時間当たりのリクエスト制限のようです。
今回もdocker-composeを使用して検証してみます。
環境
- Apple M1 Pro
- macOS Monterey 12.3
- Docker Engine 20.10.13
- Docker Compose v2.2.3
- Ruby 3.1.1
- Ruby on Rails 7.0.2
- Nginx 1.21.6
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_zone
とlimit_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
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
source
でnginx.access
のタグをつけ、match nginx.access
でMySQLに飛ばしていきます- プラグインでMySQLに送りつける場合、
@type
はmysql_bulk
のようです - host~passwordはログデータを格納するDB接続情報を指定します(ここでは
log_db
に指定した接続情報 column_names
にはログを保管するMySQLのカラムを指定しますkey_name
でcolumn_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
参考記事
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の要件を見てみます。
パッケージ: nodejs (10.24.0~dfsg-1~deb10u1)
パッケージ: nodejs (12.22.5~dfsg-2~11u1)
Ruby2.6.8イメージではOSのバージョンがアップデートされており、nodeの要件の違いにより、v12がインストールされていたということでした。
docker imageとしてはそれぞれ2.6.8-bullseye
と2.6.8-buster
が存在していたので、盲点でした…。
ということで、一旦は2.6.8-buster
を使用してアップデート作業を進めていくことで解決しました。
※ ツッコミどころであるnodeとOSのバージョンアップは別途対応
DockerでNginx、Puma(Rails7.0)の環境構築
前回の記事からの続きになります。
環境
- 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
作業ディレクトリ作成
前回に引き続き、/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 theCMD
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.conf
(RUN 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_pass
にhttp://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箇所です。
- ポートを使用しないので、webコンテナのports設定は削除
- UNIXドメインソケット通信によるコンテナ間通信となるため、web・nginxコンテナで名前付きvolumeをマウントしてファイルを共有
- 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の画面が見えるかと思います🎉
終わりに
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コンテナの導入は次の記事で書きます(余力がない)。
環境
- 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
準備
基本的な流れは[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にアクセスします。
お疲れさまでした!
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コンテナです。
書きました↓
参考記事
Rails 7 + MySQLの環境構築をDocker composeで作る - Qiita
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
, pleasebrew 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
参考
【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を作成することで、リクエストスペック上ではxxxxMailer
のperform_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]