Linkers Tech Blog

リンカーズ株式会社の開発者ブログです。

【Elasticsearch】Validation Failed: 1: this action would add [10] total shards, but this cluster currently has [1992]/[2000] maximum shards open

はじめに

情報システム部の横山です。今回はElasticsearchを利用した機能を本番リリースする直前に発生した、表題のエラーについてご紹介します。このエラーが出るのは一部のアプリケーションだけだとは思いますが、同じエラーが出た人の一助となれば幸いです。

背景

Elasticsearchを利用した全文検索機能のMR(マージリクエスト)をdevelopブランチにマージし、社内で使っているStaging環境でも無事動作確認ができ、別のテスト環境にもデプロイしました。それではお客様にも確認して頂き、それがOKだったら本番にもリリースしよう……という時に、テスト環境からSlackにエラーが通知されました。

エラーの概要

表題にもしたこのエラーをGoogle翻訳すると、「このアクションは[10]の合計シャードを追加しますが、このクラスターは現在[1992]/[2000]の最大シャードを開いています」。最初に見た時は何のことやら?と思いましたが、つまり、「シャード数の上限が2000に設定されている状態で、現在は1992のシャードがあり、そこに10個のシャードを新しく追加しようとしているけどこれを追加したら2002になって上限超えるからエラーだよ」と言っているようです。ではシャードとは何でしょうか?

シャード

シャードについては、Elastic社の公式ページに説明が書かれています。

Elasticsearchのデータは、複数のインデックスに整理されます。各インデックスは1つ以上のシャードで構成されています。各シャードはLuceneインデックスのインスタンスです。(How many shards should I have in my Elasticsearch cluster? | Elastic Blogより引用)

どうも各インデックスは1つ以上のシャードで構成されているようです。

デフォルトでは、Elasticsearchの各インデックスは5つのプライマリシャードと1つのレプリカを割り当てられます。これは、クラスタに少なくとも2つのノードがある場合、インデックスは、インデックスにつき合計10個のシャードのうち、5つのプライマリシャードと5つのレプリカシャード(完全なレプリカ1つ)を持つということです。(基本概念 | Elasticsearchリファレンス [5.4] | Elasticより引用)

そしてそのシャード数のデフォルト値は5で、それぞれにレプリカがあるので、つまりシャード数のチューニングをせずにインデックスを作ると、以下の画像のようになることが分かります。

Elasticsearchが内部で利用しているLuceneという全文検索ライブラリが存在するのですが、そのLuceneのインデックスを作成するために、シャードは存在します。このシャードを単位としてElasticsearchのインデックスからLuceneのインデックスに変換するので、小さいシャードが増えすぎてしまったり、逆にシャードの中身が大きくなりすぎたりするとElasticsearchのパフォーマンスに影響が出てしまうようでした。

では、シャードの中身はどれくらいの大きさであれば良いのでしょうか? 公式には以下のように書かれていました。

シャードサイズに関する決まった上限はありませんが、よく言われているシャードサイズの上限は50GBです。これがさまざまなユースケースで実用可能な大きさと考えられています。(How many shards should I have in my Elasticsearch cluster? | Elastic Blogより引用)

エラーが出た時の構成はどうなっていたか

エラーが起きた時、シャード数はデフォルト設定のまま、つまり1インデックスにつき10個のシャードが作られる状態でした。そして4つのModel(テーブル)に対して個別にインデックスを作成したため、これでシャード数は10 × 4 = 40。更に、このアプリケーションはテナントによってデータベースを分けるように設計されていて、この当時、エラーが起きたテスト環境のテナント数は60程度あり……40 × 60 = 2400。よってElasticsearchのシャード数の上限は2000を超え、エラーが起きてしまいました。

kibanaのコンソールから GET /_cat/shards?v して確認を行うと、確かに1インデックスあたり10のシャードができていました。

どう対応したか

テナント別にデータベースが分けられているので、それぞれのテナントのレコード数はそう多くありませんでした。各インデックスのサイズを調査したところ、大きいものでも200MB程度だったため、プライマリシャードとレプリカシャードを1つずつの構成に変更しました。

searchkickというgemを使っていたため、設定は簡単でした。 以下のように設定できます。

class Product < ApplicationRecord
  searchkick settings: { number_of_shards: 1 }
end

そして設定を変更した後、全てのインデックスを作り直し、無事このエラーは解消しました。

おわりに

AWSブログでもシングルシャードを恐れるなと記載されています。

シングルシャードを恐れるな!

もし、あなたのインデックスのサイズが30GB以下であるのであれば、一つのシャードのみを使うべきです。”more is better”というガッツフィーリングをお持ちの方もいらっしゃいますが、誘惑を断ち切りましょう!(Amazon Elasticsearch Service をはじめよう: シャード数の算出方法 | Amazon Web Services ブログより引用)

シャード数は適切にメンテナンスし、Elasticsearchを使っていきましょう。

【MySQL】短縮URLテーブルのレコード大量削除と断片化解消

こんにちは、情報システム部サービス開発チームの石川です。

長年システムを稼働していると、いつの間にかテーブルに大量のデータが溜まってしまうこともあります。
はじめから溜まりすぎないように設計できればベストですが、溜まってしまったものは仕方ないので削除しましょう。

ターゲット

今回整理するのは、タイトルにもある通り、短縮URLを管理するテーブル slugs (仮名)です。

溜まりに溜まったレコード数、約1億6870万件。

中々のものです。

レコードを削除する

今回はジャンプ先のURLをもとに削除するレコードを決めました。

巨大になってしまったテーブルは扱い方によっては簡単にスロークエリになってしまいます。
はやる気持ちを抑えて少しずつ消しながら、逐次状況も確認できるようにRuby on Railsでスクリプトを組んで、アクセスの少ない週末に流しました。

slug_count = 0
loop do
  count = Slug.where('target LIKE "https://linkers.net/xxxxx%"').limit(1000).delete_all
  slug_count += count
  p "/xxxxx: #{slug_count}"

  break if count.zero?
end

数十時間かけてスクリプトが終わった後で、テーブルの状況を見てみます。

SHOW TABLE STATUS WHERE NAME = "slugs";
Name Auto Increment Rows Data Length Index Length Data Free
slugs 168,673,766 868,102 954.0MiB 2.0GiB 66.9GiB

残ったレコード数、約86万件。

実に1億6800万件近いレコードを掃除できました!
AutoIncrementとRowsの差分が今回消したレコード数なので、まさに桁違いなのがわかります。

スッキリ。

これでは終わらなかった

ここで、もう一度先ほどのテーブルの状況を思い出してみましょう。

Name Auto Increment Rows Data Length Index Length Data Free
slugs 168,673,766 868,102 954.0MiB 2.0GiB 66.9GiB

注目するのは、DataFree: 66.9GiB。

66.9GiB!

DataFreeとは割り当てられているけれど利用されていない領域のことです。
データが歯抜けになった結果断片化を引き起こしています。
DataFree領域は新しくレコードを作った際に使われますが、単純に考えて1億6800万件のレコードが新規に作られるまで使いきれないサイズです。

無駄なので解消しましょう。

断片化を解消する

InnoDBテーブルの断片化の解消は簡単です。

OPTIMIZE TABLE slugs;

量が量なのと、HDDのデフラグのイメージから、長時間かかることを覚悟していましたが、10秒程度で終わりました。
内部的には残すデータだけを新しいテーブルにコピーして差し替える、ということをやっていて、テーブルロックがかかるのは最後の一瞬だけです。

さて、再度テーブルの状況を見てみましょう。

Name Auto Increment Rows Data Length Index Length Data Free
slugs 168,673,766 868,102 143.0MiB 115.0MiB 2.0MiB

スッキリ。

DataFreeだけでなくDataLengthやIndexLengthも小さくなっています。

おわりに

巨大になり過ぎて手のつけられないテーブルも掃除できる!ということがわかりました。
他にも昔ながらの巨大なテーブルがあるので、順次整理していきます。

【MySQL】INSERT ... ON DUPLICATE KEY UPDATE の使い方と注意点

はじめに

情報システム部、サービス開発チームの横山です。

MySQLでバルクアップデートをしたいことはままあります。そこで使用するのがこのINSERT ... ON DUPLICATE KEY UPDATEなのですが、Web上では「どうやってアップデート元のレコードが決定されるのか」まで触れた使い方の記事が見つけられなかったので、自分の備忘録も兼ねて書くことにしました。

バージョン

MySQL 5.7.36で動作確認しています

なぜ一括でUPDATEをするべきなのか

理由はシンプルで、個別のUPDATE文を発行するよりもパフォーマンスが良いからです。

  • DB-クライアント間で毎回通信しなくて済む
  • 1クエリで更新すれば、構文のパースもインデックスファイルの更新も1回で済む

構文の概要

INSERT ... ON DUPLICATE KEY UPDATE を端的に説明すると以下のように3つの文で説明できます。使う際はこの3つを必ず押さえてください

  1. 既にあったらUPDATEで、まだなかったらINSERT、つまりUPSERTの構文。なので、更新したい各行ではINSERTができるだけの情報量が必要
  2. UPDATEしたいカラムがどこなのかは、クエリの ON DUPLICATE KEY UPDATE の後に明示する必要がある
  3. UPDATEする元のレコードがどのレコードなのかは暗黙に決定されるので、クエリに書く必要はない

ではどのように既存のレコードがあるのかを突き合わせているかというと、以下のいずれかで決定されます。

  • プライマリキーが同一である
  • ユニークキー(複合ユニークでも可)が同一である

よって、この構文を使うためにはテーブルにプライマリキーかユニークキーが設定されている必要があります。 もしそれらのキーが無かったら、MySQLがどこの行をUPDATEしたら良いかが分かりません。

注意点

プライマリキーかユニークキーのいずれかが既存のレコードに合っていれば、UPDATEになってしまいます。 たとえばプライマリキーがもうあるレコードで、かつユニークキーが新しいレコードの場合は、UPDATEとして扱われ、新しいユニークキーは照合時に無視されます。エラーになりません。

逆にユニークキーがもうあるレコードで、かつプライマリキーが新しいレコードの場合でも、UPDATEとして扱われ、新しいプライマリキーは照合時に無視されます。エラーになりません。

照合時に無視されるだけなので、たとえばプライマリキーで照合させてユニークキー側を更新させることも可能です。

この具体例については、「注意すべき挙動」の項目で書いています。

具体例で理解する

例示のための初期データを定義

ユーザーテーブルがあったとします。

注意すべき点はidがプライマリキーになっているところと、emailがユニークキーになっているところです。

-- テーブル定義
CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL COMMENT '名前',
  `email` varchar(255) NOT NULL COMMENT 'メールアドレス',
  `favorite` varchar(255) DEFAULT NULL COMMENT '好きなプログラミング言語',
  PRIMARY KEY (`id`),
  UNIQUE KEY `index_users_on_email` (`email`)
);

ではいきなり、 INSERT ... ON DUPLICATE KEY UPDATE で初期データを投入しましょう。これは初回なのでINSERTです。

-- 初期データの定義
INSERT INTO
    `users` (
        `id`,
        `name`,
        `email`,
        `favorite`
    )
VALUES
    (
        1,
        '一条太郎',
        'taro.ichijo@test.com',
        'Ruby'
    ),
    (
        2,
        '二条次郎',
        'jiro.nijo@test.com',
        'TypeScript'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`),
    `users`.`favorite` = VALUES (`favorite`);

投入された初期データは以下の通りです。

1 一条太郎    taro.ichijo@test.com    Ruby
2   二条次郎    jiro.nijo@test.com  TypeScript

クエリに ON DUPLICATE KEY UPDATE をつけたことで、後から namefavorite を適当に更新しても元に戻せるようになっています。 是非実際に動かして確認してみてください。

基本的な挙動

名前を更新してみましょう。ON DUPLICATE KEY UPDATE の後に name を指定しているので、 name カラムに更新が走ります。

INSERT INTO
    `users` (
        `id`,
        `name`,
        `email`
    )
VALUES
    (
        1,
        '山田太郎',
        'taro.ichijo@test.com'
    ),
    (
        2,
        '佐藤次郎',
        'jiro.nijo@test.com'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`);

結果はこうなります。

1 山田太郎    taro.ichijo@test.com    Ruby
2   佐藤次郎    jiro.nijo@test.com  TypeScript

一方で、ダメな例も示します。 以下は email が入っていないため、 Field 'email' doesn't have a default value でエラーになります。「別に email は更新しないし良いじゃん…」と思うかもしれませんが、この構文は最低限INSERT文として成立していないといけない制約があります。email はデフォルト値もないし NULL も許されないのでエラーになります。

-- エラー
INSERT INTO
    `users` (
        `id`,
        `name`
    )
VALUES
    (
        1,
        '山田太郎'
    ),
    (
        2,
        '佐藤次郎'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`);

それでは今度は namefavorite を両方変えてみますが、 ON DUPLICATE KEY UPDATE の後には name のみの変更を指示します。

INSERT INTO
    `users` (
        `id`,
        `name`,
        `email`,
        `favorite`
    )
VALUES
    (
        1,
        '田中太郎',
        'taro.ichijo@test.com',
        'Python'
    ),
    (
        2,
        '高橋次郎',
        'jiro.nijo@test.com',
        'Rust'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`);

結果はこうなります。favorite は変更されません。

1 田中太郎    taro.ichijo@test.com    Ruby
2   高橋次郎    jiro.nijo@test.com  TypeScript

favoriteも更新したい場合は、ON DUPLICATE KEY UPDATE の後に更新したいカラムを明示する必要があります。

-- (略)
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`),
    `users`.`favorite` = VALUES (`favorite`);

もちろん明示してあげたら以下のように更新されます。

1 田中太郎    taro.ichijo@test.com    Python
2   高橋次郎    jiro.nijo@test.com  Rust

注意すべき挙動

では初期状態に戻しましょう。

1 一条太郎    taro.ichijo@test.com    Ruby
2   二条次郎    jiro.nijo@test.com  TypeScript

もし、プライマリキーとユニークキー(※ここではメールアドレス)で不整合が起きていたら? 以下の三条さんはプライマリキーが新しい3なのに、メールアドレスが一条さんと同一で、ユニーク制約に違反しています。

INSERT INTO
    `users` (
        `id`,
        `name`,
        `email`,
        `favorite`
    )
VALUES
    (
        3,
        '三条三郎',
        'taro.ichijo@test.com',
        'C#'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`),
    `users`.`favorite` = VALUES (`favorite`);

結果はこうです。ユニークキーが優先され、一条さんが更新されて三条さんになってしまいました! あらやだ!

1    三条三郎    taro.ichijo@test.com    C#
2   二条次郎    jiro.nijo@test.com  TypeScript

それでは逆に、プライマリキーは既にあるのにユニークキー(メールアドレス)が新しい状態にしてみたら?

INSERT INTO
    `users` (
        `id`,
        `name`,
        `email`,
        `favorite`
    )
VALUES
    (
        1,
        '一条隆',
        'takashi.ichijo@test.com',
        'Haskell'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`),
    `users`.`favorite` = VALUES (`favorite`);

おおっと! またしても更新されてしまったようです。どうも、プライマリキーかユニークキーのいずれかが合っていれば、そのレコードを更新してしまうようです。

1 一条隆   taro.ichijo@test.com    Haskell
2   二条次郎    jiro.nijo@test.com  TypeScript

では更に、プライマリキーもユニークキーも既にあるものだけど、それぞれ別々のレコードを参照している場合はどうなってしまうのでしょうか? id: 1 は一条さんだし、 email: jiro.nijo@test.com は二条さんです。

INSERT INTO
    `users` (
        `id`,
        `name`,
        `email`,
        `favorite`
    )
VALUES
    (
        1,
        'キメラ太郎',
        'jiro.nijo@test.com',
        'Brainfuck'
    )
ON DUPLICATE KEY UPDATE
    `users`.`name` = VALUES (`name`),
    `users`.`favorite` = VALUES (`favorite`);

結果

1 キメラ太郎 taro.ichijo@test.com    Brainfuck
2   二条次郎    jiro.nijo@test.com  TypeScript

エラーなく更新され、一条さんはキメラになってしまいました。恐ろしいですね。 INSERT ... ON DUPLICATE KEY UPDATE をお使いの際は、よくお気をつけください。

おわりに

INSERT ... ON DUPLICATE KEY UPDATEでは、プライマリキーかユニークキーのいずれかが既存のレコードに合っていれば、UPDATEになってしまいます。そしてプライマリキーとユニークキーで照合可能なレコードが別々の場合は、そのいずれかで更新がされてしまうということが実験によって分かりました。しかし具体的にどのような優先順位で照合がされるかはMySQLのソースコードを読まない限り不明です。

うっかり想定と違うレコードが更新されないよう、不整合なデータを作らないよう、気をつけてこの構文を使っていきましょう。

参考リンク

ActionCableの基本的な挙動と、ログの意味を理解する

はじめに

ActionCable(Railsで扱えるWebsocketフレームワーク)のログを出力すると、見慣れない言葉が出てきます。subscribebroadcasttransmitとは何を指すのだろう……と疑問を抱いたのでこの記事を書きました。

検証バージョン

  • Rails 6.1
  • ActionCable 6.1
  • アダプタにはRedisを使用

前準備

ログをWebサーバーから分離する

ログを見やすくするのはとても大切です。ログを丁寧に扱わないのは、犯人の指紋が残っているドアノブに、刑事がべたべたと自分の指紋をつけてしまうような愚行です。エラーが起きたときの手がかりを少しでも多く残すために、手間を惜しまないようにしましょう。

ActionCableサーバーのloggerは、デフォルトでRails.loggerになっています。そのため、WebサーバーとActionCableサーバーのプロセスが同一のマシンにあった場合、Rails.loggerにはActiveRecordのログやらActionCableのログやらがごちゃまぜに出力されてしまいます。それは大変見にくいので、ActionCableのログ出力はRails.loggerから分けましょう。

ActionCableサーバーのプロセスとWebサーバーのプロセスが別のマシンにある場合は、この手順は必ずしも必要ありません。

# config/initializers/action_cable.rb
action_cable_logger = ActiveSupport::Logger.new('log/action-cable.log', 'monthly')
action_cable_logger.formatter = ->(severity, datetime, _progname, message) do
  "#{datetime.strftime('%Y-%m-%d %H:%M:%S.%L')} [#{severity}] - #{message}\n"
end
ActionCable.server.config.logger = action_cable_logger

お好きなコマンドでログを見るようにしてください。

tail -n 30 -f log/action-cable.log
ログを確認する

ログを見ると、streaming, Broadcasting, transmittingという単語があるのが分かります。これが何なのかを理解していきましょう。

2022-01-25 12:08:44.747 [INFO] - Started GET "/cable/" [WebSocket] for 127.0.0.1 at 2022-01-25 12:08
2022-01-25 12:08:44.747 [INFO] - Successfully upgraded to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
2022-01-25 12:08:44.795 [INFO] - Registered connection (Z2lkOi8vaG9rdWcvQmFua2VyLzM)
2022-01-25 12:08:44.842 [DEBUG] - ChatChannel is transmitting the subscription confirmation
2022-01-25 12:08:44.842 [INFO] - ChatChannel is streaming from room_1
2022-01-25 12:08:45.210 [DEBUG] - [ActionCable] Broadcasting to room_1: "今夜麻雀しませんか?"
2022-01-25 12:08:45.257 [DEBUG] - ChatChannel transmitting "今夜麻雀しませんか?" (via streamed from room_1)
2022-01-25 12:08:45.313 [DEBUG] - ChatChannel transmitting "今夜麻雀しませんか?" (via streamed from room_1)
2022-01-25 12:08:45.313 [DEBUG] - ChatChannel transmitting "今夜麻雀しませんか?" (via streamed from room_1)

ActionCableの基本用語

用語は、まずは公式に目を通すことが大切です。 Action Cable の概要 - 2 用語について

ただ、英語をそのまま翻訳した文章のようなので少し分かりにくいかもしれないので、ここではいくつかの大事な用語を私の理解で説明します。

channel

MVCフレームワークのController的な存在です。例えば、チャット機能でActionCableを使いたいならChatChannelを作ったり、オンラインゲーム機能で使いたいならGameChannelを作ったりします。 そのChatChannelの中に、「雑談部屋」や「ほのぼのnews部屋」や「ブラウザゲームについて語る部屋」などの論理的な部屋、仕切りを作ることができます。

stream

なぜか公式の用語一覧には載っていない(記事の中には載っている)のですが、channel内の論理的な一つ一つの部屋を呼ぶ用語をstreamといいます。この用語はとても大切で、ChatChannel is streaming from room_1みたいなログとして出てきたり、あとは stream_from のようにコード中にも登場したりします。 streamは日本語で「流れ」のような意味です。

subscribe

subscribeは日本語で「購読する」のような意味です。 ブラウザ(クライアント)は、streamに対してsubscribeを行います。「よし、雑談部屋を見よう」という時には、雑談部屋のstreamをブラウザがsubscribeするということです。subscribeしている人やブラウザのことを、subscriber(サブスクライバ)とも言います。

broadcast

雑談部屋を現在subscribeしている人たちに、「今夜麻雀しませんか?」という言葉を投げかけるとしたら、その部屋のsubscriberたちに一斉送信、つまりbroadcastすることになります。

RedisのPub/Sub

詳しくは後で解説します。RedisにはPub/Subという機能があります。これは普段のKVSとしてのRedisとは全く異なるマイナーな機能なので、注意が必要です。

図解(チャットでメッセージを送る)

この図では、Client 1, 2, 3がそれぞれ「雑談部屋」stream (room_1)を①subscribeしています。

そこへ、Client 1が「今夜麻雀しませんか?」と②broadcastします。すると、ActionCableサーバーがRedisのpub/subに対して、メッセージを発行(PUBLISH)します。すると、それをSUBSCRIBEしている各ActionCableサーバーはメッセージを受け取ることができます。

※この画像ではActionCableサーバーは1つだけです。Redisがあれば、ActionCableサーバーのプロセスが複数あっても問題ありません。 ※ここでいう「SUBSCRIBE」はRedis⇐ActionCableサーバーのSUBSCRIBEなので、ActionCable⇐ブラウザのsubscribeとは別です。

そしてRedisからメッセージを受け取ったActionCableサーバーは、「雑談部屋」stream (room_1)をsubscribeしているsubscriberたちに、「今夜麻雀しませんか?」を③transmit(送信)します。

RedisのPub/Sub

なぜ使用するのか

Redisは複数のActionCableサーバープロセスがある場合に活躍します。ActionCableサーバーのプロセス1とプロセス2で、それぞれChatChannelや雑談部屋streamがあります。複数のサーバー間で新しく流れてくるデータを受け渡すためにRedisを利用できます。

Pub/Subとは何か

Redis - Pub/Subは公式を読むのが一番良いですが、英語なのでかいつまんで説明します。RedisといえばKVSですが、この機能はKVSとは全く異なるマイナーな機能です。 簡単に言うと、SUBSCRIBEしているクライアントたちにPUBLISHでメッセージを送ることができる機能です。何度も言いますが、このSUBSCRIBEはブラウザがstreamに対してsubscribeするのとは別です。

Pub/Sub機能には注意点があります。Pub/SubはRedisがデフォルトで持っているような0 ~ 15のdatabaseとは何も関係がありません。

Pub/Sub has no relation to the key space. It was made to not interfere with it on any level, including database numbers. (訳) Pub / Subはキースペースとは関係ありません。データベース番号を含め、どのレベルでも干渉しないように作られています。

実際に動かして確認する

ローカルの開発環境でRedisを利用する場合、以下のようにしてPSUBSCRIBE(※パターン指定でのSUBSCRIBE)を行い、実際にPUBLISHされるメッセージを確認することができます。オプションのホスト名やポート番号は読み替えてください。

redis-cli -h 127.0.0.1 -p 6379 psubscribe '*'

メッセージが発行されると、このように表示がされます

1) "pmessage"
2) "*"
3) "some_channel_prefix:room_1"
4) "今夜麻雀しませんか?"(実際はエンコードされた文字列が出る)

これをActionCableサーバーがSUBSCRIBEしているため、複数のActionCableサーバーがあっても大丈夫ということです。

おわりに

ActionCableについて何もわからない状態から実際の動作を見つつ調査しました。間違っていたら教えてください。あまりドキュメントが見つけられなかったので、Channelって何? からスタートした後、結構理解するまでが大変でした。

この記事がActionCableを新しく触る人の理解を深めてくれたら幸いです。

参考

「Railsバージョンを倍にしたサービスのそれまでとそれから」をKaigi on Rails 2021で発表しました #kaigionrails

こんにちは、大河原です。情報システム部サービス開発チームにて、Linkers.net の開発チームリーダーをしています。

このたび、私は10/22金-23土に開催されたKaigi on Rails 2021 にて、「Railsバージョンを倍にしたサービスのそれまでとそれから」というタイトルで登壇いたしました。

kaigionrails.org

kaigionrails.org

続きを読む

新入りエンジニアの入社後最初の一週間、そしてリモートワークレポート

はじめに、新型コロナウイルス感染症に罹患された皆さまおよび関係者の皆さまに、心よりお見舞い申し上げます。
一日も早い収束と、皆さまのご健康を心からお祈り申し上げます。

現在弊社では、感染拡大防止の観点から自宅勤務が推奨されています。
この記事では、全くのエンジニア未経験者である私(金)が、入社1週間後にリモートワークを経験することになった中で感じたことや学んだことをお伝えいたします。

  • 対象読者
  • 自己紹介
  • 物理的な1週間
    • 初日
    • 2日目〜
  • 論理的な1週間
  • リモートワークの良いところ
  • リモートワークの課題
    • コミュニケーション
    • 生産性
    • 200.steps
    • 改善したこと、したいこと
  • まとめ
  • We are hiring!!
続きを読む

S3互換ストレージMinIOの開発環境(Rails)への導入

はじめに

この記事は、Ruby on Railsの既存プロジェクトにMinIOを実際に環境に導入し、その際つまずいたところを記録した記事です。

MinIO導入の経緯から、ローカルの開発環境にコンテナとして導入して、最後はCIが緑になって開発ブランチにマージされるまでを書いています。

対象読者

  • Railsプロジェクトの環境にMinIOの導入を検討している人(どんな障壁があったか知りたい人)
  • RailsプロジェクトでMinIOを導入しようとして詰まっている人
  • MinIOをDockerのコンテナ上で動かしたい人
  • MinIOを知らない人

MinIOとは何か

MinIO Object Storage(以下MinIO)はMinIO, Inc.によって開発されているOSSです。Apache 2.0ライセンスで公開されています。

元々プライベートクラウド上で使えるオブジェクトストレージとして設計されていましたが、AWS S3のAPIと互換性があるため、例えば、本番環境はS3を使って、ローカルやCIの環境はMinIOのコンテナで……というように、ローカルやCIでS3の環境を用意したい人にとっても便利に使うことができます。

GitHubでコードを公開しているだけではなく、公式でDockerイメージまでDockerHubに用意してくれており、大変便利に使うことができます。MinIO, Inc.に足を向けて寝れません。

下の画像は、ローカルで起動しているMinIOにアクセスした画面です。シンプルなデザインがお洒落で素敵ですね。

ログイン画面

管理画面

導入のきっかけと目的

これまでの開発環境/本番環境(概略)

私達のプロジェクトのMinIO導入前の本番環境/開発環境について、今回関わる部分のみ簡単にご説明します。

本番環境

本番環境

本番環境は上記のような一般的な構成です。EC2上にRailsのアプリケーションが起動しており、ユーザーが画像やPDFをアップロードすると、Railsがそれを受け取って、Shrineというgemに後は任せます。Shrineは、ファイルをS3やAPサーバー内に保存してくれるgemです。アップロードされたファイルのダウンロードは、後からユーザーがRailsでレンダリングされたWebページを見て、そこにあるS3へのリンクをクリックしダウンロードする、という構成です。

開発環境

そしてMinIO導入以前のローカルの開発環境は、下記のような構成でした。

開発環境

この画像には3つのローカル端末が描かれていますが、全て同一のMacBook Proです。弊社の開発者にはMacBook Proが支給されます。

この画像を見て分かる通り、本番環境と明らかに違うところがあります。本番環境ではS3が画像やPDF等のファイルを返していましたが、この開発環境ではRailsがファイルを返しています。開発環境と本番環境でそれぞれ、S3を見に行くか、それとも自分自身のサーバー内を見に行くかはShrineが抽象化しているため、ソースコード自体はシンプルです。しかし本番環境と開発環境の仕組みの違いが、後々問題を引き起こします。

本番のみ出てしまう挙動

例えば、実際にあった例としてこんな問題がありました。 「自分でアップロードしたCSVファイルを後からダウンロードしようとすると外部サイト移動の警告が出る」という不具合です。

画像は実際にそれが出ていた時のスクリーンショットです。

不具合

開発環境のURLは./bucket/hoge.csvのような相対パスで、本番環境のURLはhttps://aws.s3.domain/bucket/hoge.csvのような異なるURLが生成されていたため、開発環境中では判定の誤りに気づけず、リリースがされてしまった……。という悲しい不具合でした。

環境の差異によって見過ごされていた不具合は他にも出てくる可能性は十分に考えられるため、少しでもローカルで本番環境に近い構成にしたい、というのがMinIOを導入した動機です。

以上、前置きでした。

MinIOが動くまで

[開発環境] docker-compose.ymlに追加

色々と省略していますが、MinIOに関連するところだけ抽出するとこのようになりました。

# docker-compose.yml
version: "3.3"

services:
  minio:
    image: minio/minio:latest
    networks:
      - local_dev
    ports:
      - "9000:9000"
    volumes:
      - ./docker/volume/minio:/data
    command: server /data
    environment:
      MINIO_ACCESS_KEY: minio_access
      MINIO_SECRET_KEY: minio_secret

networks:
  local_dev:

そして、MinIOに初期処理を行う部分は別のymlに切り出しました。

ここに出てくるmcはminio-clientの略で、起動しているminioのコンテナに対してアクセスし、hogeという名前のバケットを削除したり新規作成したりしています。ここに出てくるminio/mcというイメージも、公式から提供されています。

# 別のファイル docker-compose.createbuckets.yml
version: "3.3"

services:
  createbuckets:
    image: minio/mc
    networks:
      - local_dev
    depends_on:
      - minio
    entrypoint: >
      /bin/sh -c "
      until (/usr/bin/mc config host add myminio http://minio:9000 minio_access minio_secret) do echo '...waiting...' && sleep 1; done;
      /usr/bin/mc rm -r --force myminio/hoge;
      /usr/bin/mc mb myminio/hoge;
      /usr/bin/mc policy download myminio/hoge;
      echo '-----------------------------------------';
      echo 'バケットの作成を完了しました。';
      echo 'Ctrl + Cでdocker-composeを終了してください……';
      echo '-----------------------------------------';
      exit 0;
      "

networks:
  local_dev:

普段の開発をする際のdocker-compose upをする前に、初回のみ下記のコマンドを実行します。

docker-compose -f docker-compose.yml -f docker-compose.createbuckets.yml up

複数のファイルを-fで指定してdocker-compose upすると、ファイルの中身がマージされて実行されます。

このコマンドを初回だけ実行することで、初期処理が行われてバケットが作成され、MinIOに書き込み・読み込みができるようになります。以降は普通のdocker-compose upで開発環境が起動します。

ハマった:foreman、突然の死

これはdocker-compose.createbuckets.ymlにMinIOの初期処理を分離する前、docker-compose.ymlにバケットの作成の処理を詰め込んでいた時の話です。

私達のチームではforemanというプロセス管理ツールを使っていました。これはHerokuのProcfileをもとにプロセスを実行してくれるツールで、私達はこれで、docker-compose upしたりrails sしたりしていたのですが、バケットの作成の処理が入っていた場合にbundle exec foreman startしても停止してしまうという問題が発生しました。

理由はforemanが、「全てのプロセスが起動し続ける」前提で動いているツールであったことでした。 docker-compose.createbuckets.ymlに書いてある内容を読めば分かるのですが、ここでexit 0;しています。これによってこのminio-clientのコンテナは停止しますが、それをforemanが検知するとforemanは全てのプロセスを終了しようとします。よって、docker-compose.createbuckets.ymlに書いてあるような内容は、少なくともforemanの外で実行する必要があります。

[CI環境] gitlab.ymlに追加

私達のチームはGitLab CIを使用しています。E2Eテストでもまた、MinIOを使いたいので、Rspecが動くJobなどでMinIOも動かせるよう設定をする必要があります。

# .gitlan-ci.yml

# 予め作っておいたimage。この中にminio-clientも入っている。
# これを各Jobで使う
image: gitlab.example.com/my/image:latest

rspec:
  services:
    - name: minio/minio
      command: ["server", "/data"]
      alias: "minio.example.com"
  script:
    - sh -c "until (/usr/bin/mc config host add myminio http://minio.example.com:9000 minio_access minio_secret) do echo '...waiting...' && sleep 1; done; /usr/bin/mc rm -r --force myminio/hoge; /usr/bin/mc mb myminio/hoge; /usr/bin/mc policy download myminio/hoge; exit 0;"
    - bundle exec rspec

It works!

動きました! 後は、赤くなっているテストを修正します。

テスト(Rspec)が通るまで

素直な修正

これまで"rspec用"として書いていたコードが、より本番環境に近いコードに修正されることになります。

例えば、これまではhref='/bucket/6f4648d.png'を期待していたところを、href='http://localhost:9000/bucket/6f4648d.png'に直すような修正です。より本番環境に近い状態でテストができるようになったため、こうしたテストを修正するのは当然のことでしょう。

Aws::S3::Errors::RequestTimeTooSkewed

Timecopというgemがあります。これによって、Time.nowなどをした時の時間を操作することができます。 しかしRspec内の時間が固定されていたことによって、Minioとの時間差が大きくなりすぎてアップロードできなくなるケースが出てきました。

0 byteのファイルアップロードできないと発覚

MinIOに変えたことで0 byteのファイルをアップロードできない不具合が見つかりました。自動テストで0 byteのダミーファイルをアップロードしていて、そこがErrorになったことで判明しました。S3を使っている本番環境でも同様の問題が発生していると分かったため、MinIOを入れたことで隠れていた問題が浮き上がりました。

調べると、これはShrineの不具合でした。issue #440で報告がされており、2020/01/16にリリースされたShrine v3.2.1だと問題なくアップロードができると分かりました。

ActionController::RoutingError

これまで、開発環境や自動テスト環境ではファイルをダウンロードする時に、相対パスにあるファイルをRspecが読みに行っていました。

例えば、下記は「test.ppt」というパワポのファイルへのリンクが画面に表示されていて、それをクリックしたらパワポのファイルをダウンロードができることをテストしているRspecです。

click_on 'test.ppt'
expect(page.response_headers['Content-Type']).to eq('application/vnd.ms-powerpoint')

しかし、このプロジェクトではファイルをMinIOが返すように変更しました。つまり、Railsがファイルを返すわけではなくなったため、pageで中身を取得することはできなくなりました。

この問題に対してはKernel#openでファイルをダウンロードし、その中身をテストするという方針に変更しました。

System Specのhave_linkでリンクが突然探し出せなくなった

下記のようなテーブルの構造で、hrefの中身のURLが適切に生成されているかをテストしていました。

<table>
  <tbody>
    <tr>
      <td>
        <div>
          <span>
            <a href="./bucket/hogehoge.csv">hogehoge.csv</a>
            <p>something</p>
          </span>
        </div>
      </td>
    </tr>
  </tbody>
</table>

これまでは、withinで<tr>のところまで降りてきて、下記のコードでテストできていました。

expect(td[0]).to have_link 'hogehoge.csv', href: something_expected_url

しかし、MinIOはS3と互換性があり、URLにもS3と同様の長いクエリがついて、最終的には長大なURLになります。たとえば、X-Amz-Credentialや、X-Amz-Signatureなどのパラメータが付与されます。それが原因なのか、have_linkで深いところにあるURLをいきなり探りあてることができなくなりました。 そのため、URLのあるタグまでfindして判定することにしました。

expect(td[0].find('div > span > a')[:href]).to eq something_expected_url

修正完了

テストを修正し、CIの全てのテストが緑になりました。

導入完了

GitLabのマージリクエストで他の開発者にレビュー依頼をし、LGTMをもらい、開発ブランチに変更をマージしました。これにて導入が完了しました。

本流とは関係のないところですが、MinIOとはあまり関係のないところで課題となったところもいくつかありました。

  • ローカル環境でランダムで発生するrspecのエラーが表出した(元々のテストにバグが仕込まれていた)
  • 開発ブランチへのマージ後、ある開発者のみMinIOでアップロードができない状態に陥った(何かの拍子でローカルのgem、aws-sdk-s3の中身が書き換わっていたことが判明した)

MinIOを導入することで、これまで潜んでいた色々な問題が表出する可能性もあるため、既存プロジェクトに導入する際にはそれなりの時間を見積もっていた方が安全かもしれません。

とはいえ、これでやっと導入は完了しました。お疲れ様でした!🎉 最終的に、開発環境は画像のようになりました。

最終的な開発環境

良い開発ライフを!