行動すれば次の現実

テック中心の個人ブログ

Sidekiqの引数とエラーリトライ時の挙動について

Sidekiqを使用するために知っておいてほしい、引数とエラーリトライ時の挙動についてまとめました。

引数のベストプラクティス

Sidekiqのperform_asyncの引数をJSON形式でRedisに永続化します。

例えばモデルオブジェクト自体を引数に渡してしまった場合、デフォルトではto_sにより<Quote:0x0000000006e57288>のようなオブジェクトハッシュ値として扱われるので、正しく復元できない場合があります。

仮に、to_sをカスタマイズして正しくJSONに復元出来たとしても、perform_asyncに渡す前と後でモデルオブジェクトの内容が変わった場合に意図しない挙動を招くことがあるので避けるべきです。引数としてはstring, integer, float, boolean, nil, array, hashの型でなければなりません。

# 悪い例
quote = Quote.find(quote_id)
SomeJob.perform_async(quote)

# 良い例
SomeJob.perform_async(quote_id)

リトライと冪等性

SidekiqではJobの冪等性(何度実行しても同じ結果になる)を意識して設計する必要があります。

Sidekiqはエラー時にリトライする機能が備わっています。エラーが発生した場合に、一定間隔おきに再施行(リトライ)を繰り返す仕組みになっています。

デフォルトでは25回リトライします。これを考慮して設計しなければ思わぬ不具合を引き起こす恐れがあります。

例えばクレジットカードの決済を実行してユーザーに課金処理を行い、完了メールを送るJobがあるとします。

課金処理が成功後に、完了メールを送信する処理でエラーが発生したとします。この場合リトライが発生して再度、課金処理が走ってしまうと二重課金が発生してしまいます。(デフォルトのリトライ回数だと最大で26回課金されてしまう)

このような場合を考慮して、どこまで処理が完了しているのかステータス管理するなどして、冪等性、完全性を保証させる必要があります。

もし冪等性の考慮が難しいなどの理由で意図的にリトライをさせたくない場合、以下のオプションでリトライを無効にすることが出来ます。

class SomeJob
  include Sidekiq::Job

  # リトライを無効にする
  sidekiq_options retry: 0
  # sidekiq_options retry: falseでも無効にできるが後述の違いがある

  def perform(*args)
    # Do something
  end
end

sidekiq_options retry: 0とretry: falseの違い

retry: 0 の場合

エラー時はデッド状態になり画面から再試行可能

retry: false の場合

エラー時は画面から再試行不可能

画面からの再試行もさせたくないなどの理由がなければretry: 0にしておくのが良いかと思います。

リトライ時の挙動について

例えばsidekiq_options retry: 2とした場合、以下のようにステータス管理されてJobがリトライされます。

  1. Jobが実行されてエラーが発生する
  2. 再試行(1回目)状態になり一定時間後にキューに登録される
  3. 1回目のリトライJobが実行されてエラーが発生する
  4. 再試行(2回目)状態になり一定時間後にキューに登録される
  5. 2回目のリトライJobが実行されてエラーが発生する
  6. デッド状態になる(画面から任意で再試行可能)

プロセスの再起動とジョブの再実行

retry: 0やretry: falseを指定しても、処理中にプロセスが再起動された場合、そのジョブは再起動後に再実行される可能性があります。これは、retryオプションがジョブの失敗時の再試行に関する設定であり、プロセスの再起動による中断とは異なるためです。

再起動後も含めてジョブを完全に再実行させたくない場合は、Sidekiq Proのユニークジョブ機能を使用するか、アプリケーション側で適切に制御する必要があります。

参考

https://github.com/mperham/sidekiq/wiki/Best-Practices

Sidekiqの導入手順【令和版】

SidekiqをRailsに導入する手順をまとめました。

Gemのインストール

Gemfile

# sidekiq本体(必須)
gem 'sidekiq'
# sidekiqのモニタリング画面で使用
gem 'sinatra', require: false
# redisのkeyに任意のprefixを付与させることができる
gem 'redis-namespace'

Sidekiqジョブの作成

rails console

rails g sidekiq:job sample

実行するとsample_job.rbが作成されます

app/sidekiq/sample_job.rb

class SampleJob
  include Sidekiq::Job

  def perform(*args)
    # Do something
  end
end

ちなみにインクルードされているSidekiq::Jobは元々はSidekiq::Workerというモジュール名でした。

Workerという言葉の定義が誤解を招くため、SidekiqのOSSプロジェクトではWorkerをJobにリネームするよう動いています。

現状はどちらの名前でも動きますが、ver7では完全移行となるようですので、Sidekiq::Workerを使用している場合は早めにSidekiq::Jobに変更しておくことをおすすめします。

Rename Sidekiq::Worker to Sidekiq::Job · sidekiq sidekiq · Discussion #4971 · GitHub

redisの設定

sidekiqはredisを使用してメッセージングしていますので、開発環境にインストールする必要があります。

redisのインストール方法は各プラットフォームによりますので割愛させていただきます。redisがインストールできたら以下の設定ファイルを作成します。

config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  # redisの設定
  config.redis = {url: 'redis://localhost:6379', namespace: "my_app_sidekiq_#{Rails.env}"}
end

Sidekiq.configure_client do |config|
  # redisの設定
  config.redis = {url: 'redis://localhost:6379', namespace: "my_app_sidekiq_#{Rails.env}"}
end

「redis-namespace」のgemによってnamespace: "my_app_sidekiq_#{Rails.env}"のようにnamespaceを個別に割り振ることができます。 同じredisサーバーを複数の環境で共有する場合などに管理がラクになります。

url: 'redis://localhost:6379'の部分は環境によって変わる部分ですので環境変数などで切り替えられるようにしておくと良いでしょう。

動作確認

rails console上で実際にJobを起動させてみましょう

rails c
> SampleJob.perform_async(1)
=> "51190418577b3dcafd192368"

sidekiqプロセスのログには以下のようにstartとdoneのログが出力されれOKです。

2022-10-19T05:27:06.508Z pid=1 tid=dnp class=SampleJob jid=51190418577b3dcafd192368 INFO: start
2022-10-19T05:27:06.685Z pid=1 tid=dnp class=SampleJob jid=51190418577b3dcafd192368 elapsed=0.177 INFO: done

モニタリングUIについて

sidekiqではJobの状態をモニタリングできる画面を提供してくれています。

routesファイルに記載の上、指定のURLにアクセスすることで画面を起動することが出来ます。

config/routes.rb

require 'sidekiq/web'

Rails.application.routes.draw do
  root "pages#index"
  # 下記を追記
  mount Sidekiq::Web => "/sidekiq"
end

/sidekiqにアクセスするとモニタリング画面が確認できます。

モニタリングUI

また、sidekiqにはキューという概念があり、キューごとに優先度を振ることが出来ます。 例えば優先度が2のキューは1のキューよりも2倍の頻度でチェックされるようになります。

Jobごとに優先度を変えたい場合は複数のキューをSidekiqに登録しておいて、Jobごとに割り振ると良いでしょう。

Advanced Options · sidekiq/sidekiq Wiki · GitHub

Rails7(esbuild+stimulus)にdatepickerライブラリのflatpickrをインストールする

Rails7+esbuild+stimulusの構成でflatpickrを使えるところまでを説明します。

flatpickrのインストール

yarn add flatpickr

app/assets/stylesheets/application.scss

// 下記を記載
@import 'flatpickr/dist/flatpickr';

app/javascript/application.js

// 日本語化するため下記を記載
import "flatpickr/dist/l10n/ja.js"

実装イメージ

app/javascript/controllers/hello_controller.js

import { Controller } from "@hotwired/stimulus"
import flatpickr from "flatpickr";

export default class extends Controller {
  static targets = [ "inputDate" ]

  connect() {
    flatpickr(this.inputDateTarget, {
      locale: 'ja',
      dateFormat: 'Y/m/d(D)',
    });
  }
}

view

<div data-controller="hello">
  <input data-hello-target="inputDate" type="text">
</div>

画面確認

flatpickr

覚えておくと便利な「rails db:migrate:xxx」「rails db:xxx」コマンド集

よく忘れてしまうのでメモとして残しておきます。

rails db:migrate系

rails db:migrate

  • 未適用のmigrationを適用します

rails db:migrate:down VERSION=20221003050833

  • VERSIONに指定されたmigrationを戻します
  • self.downメソッドが実行されます

rails db:migrate:up VERSION=20221003050833

  • VERSIONに指定されたmigrationを適用します
  • self.upメソッドが実行されます

rails db:migrate:redo (オプション引数:STEP=2)

  • 一つ前のmigrationを戻して再度適用させます
  • STEPを指定するとその件数分、適用されます

rails db:migrate:status

  • migrationの適用状態を表示します

rails db:migrate:reset

  • rails db:drop、rails db:create、rails db:migrateをまとめて実行します

rails db系

rails db:rollback (オプション引数:STEP=2)

  • migrationを1つ前の状態に戻します
  • STEPを指定するとその件数分、戻します

db:seed

  • seedデータを投入します

db:create

  • DBを作成します

db:drop

  • DBを削除します

db:reset

  • db:drop、db:create、db:schema:load、db:seedをまとめて実行します

Rails7のimportmapでsemantic uiを使用する方法

Rails7のimportmapでsemantic uiを使用しようとしたところ苦戦したので備忘録として載せておきます。誰かのお役に立てば幸いです。

さっそく結論から

以下のように設定するとsemantic uiが使用できます。

Gemfile

# これによりsprocketsでアセットを管理することができます。sass版のsemantic-uiです。
gem 'semantic-ui-sass'

後述のjavascriptとはバージョンを合わせておくと良いでしょう。

app/assets/stylesheets/application.scss

/* semantic-uiをアセットパイプラインに載せます */
@import 'semantic-ui';

app/javascript/application.js

import "jquery"
import "semantic-ui"

// 動作確認用のコード
$(function () {
  $('.ui.modal').modal('show')
})

また、viewファイルに動作確認用コードとして以下を記述してください。

<div class="ui modal">
  <div class="header">
    Hello
  </div>
  <div class="content">
    <div class="description">
      <p>hello world!</p>
    </div>
  </div>
</div>

config/importmap.rb

pin "application", preload: true
pin "jquery", to: "https://code.jquery.com/jquery-3.1.1.min.js"
pin "semantic-ui", to: "https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"

何に苦戦したのか?

importmapとapplication.jsとの繋ぎ込みに苦戦しました。

私は最初に./bin/importmap pin jqueryコマンドによりjspmのjqueryを使おうとしていましたが、application.jsのimport "semantic-ui"jQuery is not definedとなり上手くいきませんでした。

application.jsに以下のようなコードを記述しても同様でした。

import jQuery from 'jquery';
window.jQuery = jQuery;
import "semantic-ui"

stackoverflowでも同様のトピックがあり、https://code.jquery.com/を使用すると上手くいくという説明がされていましたが、理由がなかったので腑に落ちませんでした。

改めて公式ドキュメントを確認するとhttps://code.jquery.com/のjqueryを使用していることが確認できました。jspmとの違いを深堀りする気にはなりませんでしたが、公式でもこちらのCDNが記述されていますので、何らかの理由があるように思います。

ちなみにhttps://code.jquery.com/経由だと

import jQuery from 'jquery';
window.jQuery = jQuery;

のような記述がなくても$JQueryはグローバル変数に定義されるようです。

おそらく当事象はimportmap(Rails7)だからということではないと思います。 明確な理由は不明ですが、公式がhttps://code.jquery.com/となっているので納得することにしました。

Rails7+bootstrap+esbuld+Postgresqlの構成でDockerの開発環境を構築する

Docker-Composeを使ったRails7の開発環境構築手順を説明します。

意外にもDocker構築を含めた一筆書きのRails7セットアップ手順が見受けられなかったので自分で試行錯誤しながら記事にしました。極力シンプルな手順を心がけています。誰かの参考になれば幸いです。

環境情報

  • MacBook Pro
    • MacOS Monterey(12.4)
  • Rails 7.0.4
  • Ruby 3.1.2
  • Postgresql 13.6
  • Docker 20.10.12
  • docker-compose 1.29.2
  • node.js 18.9.1
  • bootstrap 5.2.1

設定ファイルの準備

プロジェクトフォルダの作成

プロジェクトのフォルダを作成して、カレントディレクトリを移動しておきます。 空のGemfile.lock yarn.lockの空ファイルも事前に作成しておきます。

mkdir my_app
cd my_app
touch Gemfile.lock
touch yarn.lock

※ 「my_app」と記述されている箇所はご自身のアプリ名に置き換えてください。

Docker設定ファイル

RubyとNode.jsイメージを使用してDockerfileを作成します。 RubyのイメージにデフォルトでインストールされているNode.jsのバージョンををNode.jsイメージのバージョンに置き換えて構築します。

Dockerfile

FROM node:18.9.1 as node
FROM ruby:3.1.2

ENV TZ Asia/Tokyo

WORKDIR /my_app

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

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/include/node /usr/local/include/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs && \
    ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm

RUN apt-get update -qq && apt-get install -y postgresql-client
RUN npm install yarn -g -y

COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock
RUN gem update --system && bundle install

COPY package.json yarn.lock ./
RUN yarn install

COPY . ./
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]

続いて、docker-compose.ymlを作成します。webサーバーとdbサーバーの定義を記述します。

docker-compose.yml

version: "3.9"
services:
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bin/dev"
    tty: true
    stdin_open: true
    ports:
      - "3000:3000"
    volumes:
      - .:/my_app:delegated
      - bundle:/usr/local/bundle
    depends_on:
      - db
  db:
    image: postgres:13.6
    volumes:
      - postgres:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: my_app
      POSTGRES_PASSWORD: password
      POSTGRES_DB: my_app_development
      TZ: "Asia/Tokyo"
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"

volumes:
  bundle:
  postgres:

Rails用のファイルの作成

Gemfileを作成します。Rails7以上を使用するという記述します。

Gemfile

source 'https://rubygems.org'
gem 'rails', '~> 7.0.0'

下記の内容でentrypoint.shを作成します。これはRails特有の問題を防ぐために使用されるスクリプトです。 サーバー内にserver.pidというファイルが先に存在していたときに、サーバーが再起動できなくなる問題を回避するために使用されます。

entrypoint.sh

#!/bin/bash
set -e

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

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

package.json

空のjsonオブジェクトを定義しておきます

{}

ファイル構成の確認

上記を踏まえるとファイル構成は以下の通りになります。

my_app
  Dockerfile
  docker-compose.yml
  entrypoint.sh
  Gemfile
  Gemfile.lock
  package.json
  yarn.lock

環境構築

docker-compose build

docker-compose buildコマンドを使用してDocker環境を構築します。

docker-compose build --no-cache

rails new

続いてカレントディレクトリに対してrails newコマンドでファイルを展開します。

docker-compose run web rails new . --force --no-deps --database=postgresql --css=bootstrap --javascript=esbuild

rails newが完了するとターミナル上に以下のメッセージが表示されますのでpackage.jsonに追記します。

Add "scripts": { "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets" } to your package.json
Add "scripts": { "build:css": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules" } to your package.json

package.json

{
 "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
    "build:css": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
  },
 "dependencies": {
  // 省略...
}

application.jsの書き換え

このままではtooltipsやpopoverが動作しませんので、application.jsに以下を追記します。

app/javascript/application.js

document.addEventListener('turbo:load', () => {
  const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]')
  const popoverList = [...popoverTriggerList].map(d => new bootstrap.Popover(d))
  const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
  const tooltipList = [...tooltipTriggerList].map(d => new bootstrap.Tooltip(d))
})

Procfile.devの書き換え

-b '0.0.0.0'の部分を追記します。

web: bin/rails server -p 3000 -b '0.0.0.0'
js: yarn build --watch
css: yarn build:css --watch

database.ymlの書き換え

rails newで作成されたdatabase.ymlの設定を以下のように変更します。

config/database.yml

default: &default
  adapter: postgresql
  encoding: utf8
  host: db
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: my_app
  password: password

development:
  <<: *default
  database: my_app_development

test:
  <<: *default
  database: my_app_test

rails db:create

データベースを構築します。

docker-compose run web rake db:create db:migrate

アプリ起動

docker-compose up

upコマンドでアプリケーションを起動します。

docker-compose up

起動確認

localhost:3000にアクセスして以下の画面が表示されましたら完了です。お疲れ様でした!

起動確認

ハマったときに深掘りすることの重要性

開発をしていると「ハマる」ということはつきものです。

エンジニアがよく使う「ハマる」とはぴったりの穴に嵌ってしまって長時間抜け出せない状態から由来していると言われています。

ハマりは予期せず起きてしまうのです。なるべくならハマりたくないと考えるのが自然だと思います。 しかし私はハマったときこそエンジニアの成長のチャンスだと考えています。

ハマったときの正しいアプローチ方法

ハマったときに、まず第一に考えることは「ハマった状態をどうやって抜け出すか」ということだと思います。

この状態をどうすれば抜け出せるのか?つまりHowに着目してその方法を探すことが第一歩です。

ググったり、あれこれ試行錯誤しながら、何とか方法(How)を見つけ出し、ハマりを抜け出したとします。 ハマったことを振り返りもせず、何事もなかったかのようにそのまま突き進んでしまうと、ハマったことはただ単に時間を浪費したことに過ぎません。

これではせっかくの成長のチャンスを台無しにしています。

ハマったときにHowに対するWhyをきちんと問うことで成長できると私は考えます。

「なぜこの方法を取ることで解決できたのか?」その根本的な理由を明確にすることで、問題の本質が見えてきます。 本質が見えると似たようなハマり事象が発生したときに、その知見を応用して別の解決策を自分から提示することができるようになります。

この作業を「深堀り」と呼びます。

もし深堀りしていなかったら、類似の事象が発生したときに過去に解決した事象との結びつきが見つけられず、再びHowを一から探すという浪費を繰り返してしまいます。

深堀りすることの重要性を示す例として、Rails+Postgresql+Dockerで発生する「There is an issue connecting to your database with your username/password, username xxx」でハマった件という私の過去の記事があります。

この記事で上げられたの解決方法としては「dokerのvolumeを削除する」ことです。 これは試行錯誤する中で適当に実施したことで生まれた解決方法です。

「volumeを削除したらよくわからないけど動いた!先に進もう」となってしまうと、再度類似のことが発生した時も同じく試行錯誤しながら時間をかけて再び「volumeを削除してみよう」となるのかと思います。これでは過去の経験は何も活かされずにただ時間を浪費していることになります。

「なぜvolumeを削除することでエラーが解決されたのか?」のWhyを深掘りすることで問題の本質である「database.ymlのusernameをdocker-composeの初期起動時から変更してしまった」ことに気付くことができ、類似のことが発生したときに応用できるようになるのです。

アウトプットすることでより自分を成長させる

HowやWhyについて深堀りをする作業は、メモ帳などにアウトプットとすることでより再現性が高まり、クオリティが向上します。

人間のメモリはとても少ないため、時間が立つとすぐに物事を忘れてしまいます。そのため、メモ帳というストレージに保存して、知識のデータベースからすぐに引っ張り出せるようにしておくことがとても重要なのです。

特におすすめなのが、メモ帳をブログに公開するということです。

ブログに公開するということは第三者の目がありますので、プライベートなメモ帳よりもクオリティを上げなければならないというプレッシャーが発生します。このプレッシャーによりWhyの深堀りをより質の高いものにするという意識が自然に付くようになります。

ブログと言っても、あくまでも自分へのメモです。振り返ったときにすぐ知識にアクセスできるというデータベースを構築するのは後々とても大きな財産となります。 またブログとして公開することで副次的にセルフブランディングとなる将来的な資産にもなりえますので、一石二鳥なとても有効なアウトプット方法と言えるでしょう。

もしも同じようなブログがインターネット上にすでに存在したとしても問題有りません。

今日時点で執筆したのであれば最新状態の記事ということになりますでの、既存記事よりも価値が高いと捉えることも出来ますので、何も躊躇することはないのです。どんどん深堀り結果をアウトプットしましょう。