megutech

自身の備忘録として主にWEBサーバー周りの技術について投稿しています。

S3バケットを別アカウントへ移設

ダウンタイムなしにS3のアカウントを移設したかったのですが、巷の情報では1回のみのsyncで済ませており、ちょっと困ったので備忘録。

他との違いとしてはs3:GetObjectTaggingの追加がかなめ。

手順

移設用IAMユーザー

移設先のアカウントで以下のポリシーを持ったユーザーを作成してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:GetObjectTagging",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::from-bucket",
                "arn:aws:s3:::from-bucket/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:ListBucket",
                "s3:PutObjectAcl"
            ],
            "Resource": [
                "arn:aws:s3:::to-bucket",
                "arn:aws:s3:::to-bucket/*"
            ]
        }
    ]
}

configureの登録

後程aws cliで同期を行うので、作成したIAMユーザーのconfigureを登録しておいてください。

$ aws configure --profile s3-sync-profile

移設元バケットポリシー

移設元のバケットポリシーに下記jsonを設定してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadAccess",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::xxxxxxxxxxxx:user/sync-user"
            },
            "Action": [
                "s3:ListBucket",
                "s3:GetObject",
                "s3:GetObjectTagging"
            ],
            "Resource": [
                "arn:aws:s3:::from-bucket/*",
                "arn:aws:s3:::from-bucket"
            ]
        }
    ]
}

移設実行

$ aws s3 sync s3://from-bucket/ s3://to-bucket/ --profile s3-sync-plofile

s3:GetObjectTagging

これがない場合、2回目のsyncでAn error occurred (AccessDenied) when calling the GetObjectTagging operation: Access Deniedが発生して差分をsync出来ませんでした。
要注意!

備考

aws s3 syncは差分のみコピーしてくれます。
なのでまず最初に実行し、その後サービス側のS3の向き差を変更、再度aws s3 syncを実行すれば影響範囲を最小に移設が可能です。
ただしすごく活発なサービスや、少しの問題が発生することは許されない、ということであれば、しっかりメンテ時間をとりませう。

KusanagiでNginxが何度も再起動する

Nginxのログを見ていると何度も再起動が行われていました。
幸い動作に影響は無かったものの、ひどいときには数分に一回レベルで再起動が起こるので原因を調査しました。

原因

Kusanagiはデフォルトでmonitが動いています。
monitの設定でNginxで500番台が計測されればNginxを再起動するよう設定されていました。

普段Kusanagiも使わないし、監視にはmonitも使っていなかったのでなかなか気づけず。。

余談

そもそもなぜ500番台が発生していたかというと、Next.jsでBOTからのアクセス時に500になるが原因でした。
普段ならエラー検知サービスを入れておくのですぐ気づけたんですが、今回はいろいろな制約によりサードパーティ製のアレコレが使えず、気づくのが遅れたのでした。

Next.jsでBOTからのアクセス時に500になる

本番稼働中のNext.js アプリケーションのログを眺めていると、稀にエラーを吐いていました。

環境

Key Val
Node.js 20.9.0
Next.js 13.4.13

エラーの内容

- error Failed to handle request for /
TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11372:11)
    at async invokeRequest (/var/www/app/sample-app/node_modules/next/dist/server/lib/server-ipc/invoke-request.js:21:12)
    at async requestHandler (/var/www/app/sample-app/node_modules/next/dist/server/lib/start-server.js:336:33)
    at async Server.<anonymous> (/var/www/app/sample-app/node_modules/next/dist/server/lib/start-server.js:152:13) {
  cause: _RequestContentLengthMismatchError: Request body length does not match content-length header
      at write (node:internal/deps/undici/undici:8302:41)
      at _resume (node:internal/deps/undici/undici:8276:33)
      at resume (node:internal/deps/undici/undici:8173:7)
      at connect (node:internal/deps/undici/undici:8162:7) {
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
  }
}

原因

UND_ERR_REQ_CONTENT_LENGTH_MISMATCHググると、お有難いことに下記記事がヒットしました。
本当にありがとうございます。

本番環境でだけ発生するundiciのエラー

※Version 13.4.13時点 本番環境で以下のようなエラーが発生することがある

さて、今回の環境を見てみましょう。

Next.js 13.4.13ですね!!! まさに引用元のバージョンでした。

これにより、OGPを取得しに来たBOTのアクセス時にエラーが発生していたようです。
この影響でOGPが表示されていませんでした。

対応

NextJS fails with UND_ERR_REQ_CONTENT_LENGTH_MISMATCH after redirect from server

I was experiencing a similar issue. Upgrading to the latest version of NEXT helped resolve it for me. Version 13.4.19 as of this comment.

対応方法についてもお有難いことに既に試している方がいました。
13.4.19では直っているそうです。

ということでnextのバージョンを上げて終了です。

ruby-handlebarsのプレースホルダーに日本語を使いたい

handlebarsって便利ですよね。
さてプレースホルダ{{hoge}} に日本語を使いたい場面などありませんか?
ありませんか。まあ普通はそうです。
しかし私にはそんなタイミングがあったのです。

handlebars.rbでは使えたのですが、さすがにこれはもう古すぎですし、V8エンジンが必要だとかでRails7で使うには面倒です。
そこでruby-handlebarsを使いたいわけですが、こちらは何と日本語に対応していません。

例えば{{名前}}等を使うと、下記エラーが発生します。

Extra input after last repetition at line 1 char 1. (Parslet::ParseFailed)

そこで何とかしていきたいと思います。

環境

Service Version
Ruby 3.2.2
Ruby on Rails 7.0.8
ruby-handlebars 0.4.1

原因

ruby-HandlebarsではParslet::Parserを継承したHandlebars::Parserにルールを定義してparseしています。
しかしここに日本語用のルールが無いためエラーが発生しています。

対応

日本語のルールが無いのであれば、追加すればよいのです。
ということでモンキーパッチを当てていきます。

config/initialize/handlebars.rb

module Handlebars
  class Parser
    # 日本語を含む識別子のための新しいルール
    # \p{Han} は漢字、\p{Hiragana} はひらがな、\p{Katakana} はカタカナを表します
    rule(:japanese_identifier) { match['\p{Han}\p{Hiragana}\p{Katakana}\-a-zA-Z0-9_\?'].repeat(1) }

    # 既存のidentifierルールを拡張
    rule(:identifier) { (else_kw >> space? >> dccurly).absent? >> japanese_identifier }
  end
end

以上デース

PostgreSQLのシーケンスがずれた!

開発中色々いじっていたらシーケンスがずれた、なんてことは数年に一度くらい経験したりしないでしょうか?
そんな時にぱっと直したいときは、以下を流し込めばOKです。

DO
$$
DECLARE
    r RECORD;
    s text := 'public';
BEGIN
    FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = s
    LOOP
        BEGIN
            EXECUTE 'SELECT setval(' || quote_literal(s || '.' || r.tablename || '_id_seq') ||
            ', (SELECT COALESCE(MAX(id), 1) FROM ' || s || '.' || r.tablename || '));';
        EXCEPTION WHEN OTHERS THEN
            RAISE NOTICE 'エラーが発生したテーブル: %', r.tablename;
        END;
    END LOOP;
END;
$$;

schemaやシーケンステーブルの命名規則が異なる場合は適宜修正してください。
エラーが発生したテーブルも適宜良しなに。

Rspecテストで同時アクセスによる排他テストを行う

商品購入に同時にアクセスがあった場合、在庫数がマイナスにならないようにテストしておきたい。
そんなテストをしたいタイミングが多々あるのではないでしょうか?

その際テーブルロックを利用して排他制御を行っていた場合、何も考えずにrspecでThreadを使って同時にロジックを実行させても上手く動きません。
これはrspecはテストの開始時にTransactionを張ってしまうため、Thread内の別プロセスからはメインプロセスで作った未コミットのモデル群にアクセスできないためです。

そこでrspec開始時にtransactionを張らないように修正する必要があります。

前提

今回はDatabaseCleanerを利用しています。

環境

Service Version
Ruby 3.2.2
Ruby on Rails 7.0.6
rspec 3.12.0
database_cleaner 2.0.2
mariadb 11.1.2

対応

rspecのデータ削除の仕組みであるuse_transactional_fixturesを非活性化し、特定条件下ではDatabaseCleanerのtransactionも張らないようにします。

spec/rails_helper.rb

  - config.use_transactional_fixtures = true
  + config.use_transactional_fixtures = false

  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:each) do |example|
    + if example.metadata[:disable_database_cleaner]
    +   DatabaseCleaner.strategy = nil
    + else
      DatabaseCleaner.strategy = :transaction
      DatabaseCleaner.start
    + end
  end

  config.after(:each) do |example|
    + if example.metadata[:disable_database_cleaner]
    +   # do nothing
    + else
      DatabaseCleaner.clean
    + end
  end

  config.after(:all) do
    DatabaseCleaner.clean_with(:truncation)
  end

利用方法

上記設定を読むと、disabled_database_cleanerを設定するとstrategyとしてtransactionを利用しないようにしていることが分かります。
具体的な使い方としては下記のようになります。

RSpec.describe Order, type: :model do
  describe "self.create_from_cart!" do
    context "parallel test", disable_database_cleaner: true do
      let!(:product) { create(:product, :with_inventory) }
      let!(:cart_items) { create_list(:cart_item, 100, product: product, quantity: 1) }

      it "should work on multiple threads" do
        product.product_inventory.update!(amount: 50)

        cart_items.map do |cart_item|
          Thread.new(cart_item.cart) do |cart|
            ActiveRecord::Base.connection_pool.with_connection do
              Order.create_from_cart!(cart) rescue nil
            end
          end
        end.each {|t| t.join }

        expect(product.product_inventory.reload.amount).to eq(0)
        expect(Order.count).to eq(50)
      end
    end
  end
end

context "parallel test", disable_database_cleaner: true doの部分でtransactionを張らないよう設定をしています。

コード自体の説明はしませんが、create_from_cart!内ではproduct_inventorySELECT FOR UPDATEでコールし、排他制御を行っています。

所感

最近はやりのChatGTPに解決方法を聞いたりしたけど、全然的を射た回答はくれないし、「transactionが怪しいのでは?」と気づいたあたりで再度聞いても動きもしないコードを示してきたりで逆に時間がかかってしまった。。。
上司としての才能がないということが証明されてしまったようで辛い。。。

Ruby on RailsでInstagram Graph APIを使いたい

Ruby on Rails上でInstagram Graph APIを使う情報が少なかったので、はまりポイントも併せて備忘録を残していく。

環境

Service Version
Ruby 3.2.2
Ruby on Rails 7.0.4
devise 4.9.2
omniauth-oatuh2 1.3.1
omniauth-rails_csrf_protection 0.1.2
omniauth-facebook 9.0.0
koala 3.4.0

構築

下準備

まずはどんな環境でも必須となる、下記準備を行ってください。

  1. Instagramでビジネスアカウントを作成する
  2. Facebookでアカウントを作成する
  3. Facebookページを作成する
  4. FacebookページとInstagramのビジネスアカウントを連携させる

実装

やることは以下です。

  1. omniauth-facebookでAccessToken取得
  2. 取得したAccessTokenからFacebookページ用のAccessToken取得
  3. Facebookページ用のAccessTokenを使ってInstagram Business Account IDを取得

事前準備

まずはMeta Developerでアプリを作成し、アプリIDとapp secretを取得してください。
そしてFacebookログイン/Instagram Graph API設定を行ってください。

config

まずはomniauthの設定。

一応v16を使おうと思ったのでclient_optionsを指定していますが、ここは不要だと思います。
scopeに関してもとりあえず詰め込んでいますが、ご自身で精査してください。私も後で精査予定。

config/initializes/devise.rb

  config.omniauth :facebook,
    Rails.application.credentials.dig(:facebool, :app_id),
    Rails.application.credentials.dig(:facebool, :app_secret),
    {
      client_options: {
        site: 'https://graph.facebook.com/v16.0',
        authorize_url: "https://www.facebook.com/v16.0/dialog/oauth"
      },
      scope: 'instagram_basic,instagram_content_publish,instagram_manage_comments,instagram_manage_insights,instagram_shopping_tag_products,pages_show_list,pages_read_engagement,instagram_manage_messages'
    }

callback

ここまでは普通のomniauthなので難しくはないと思うので、viewだとかは省略。

ここからは取得したTokenを使ってInstagram Graph APIを使用できるTokenを取得します。

omniauth_callbacks_controller.rb

  def facebook
    token = request.env['omniauth.auth']['credentials']['token']

    # 実際はページングとかもありますが、この解説では無視します。
    accounts = Koala::Facebook::API.new(token).get_connections(:me, :accounts)
    accounts.each do |account|
      graph = Koala::Facebook::API.new(account["access_token"])
      me = graph.get_object(:me, { fields: :instagram_business_account })

      ib_id = me["instagram_business_account"]["id"]
      access_token = account["access_token"]

      # something process
    end

    # something process
  end

fetch media

上記で取得したib_idaccess_tokenを使うことで、Instagram Graph APIを利用できます。

media_field = [:id, :media_url, :caption, :media_type, :thumbnail_url ]
graph = Koala::Facebook::API.new(access_token)
live_media = graph.get_connections(ib_id, "live_media", { fields: media_field })

やったぜ!!

はまりポイント

その1

Q: Facebook/Instagramって別物なの?

ドキュメントを読むと同じエンドポイントっぽいけど、omniauth-instagram-graphinstagram_graph_apiのようなInstagram特化gemがあるよ?

A: 同じです。

それらのGemは使う必要はないです。

omniauth-instagram-graphは名前にgraphって入ってるのに、発行されるtokenはInstagram Basic Display APIようのものでした。
それっぽい名前使うんじゃねぇ!と言いたいところ。
何か意図があるのかしら。。。

instagram_graph_apiは使ってもよいかも。
omniauthで取得したAccessTokenを食わせれば、callbackの部分に書いた内容を丸っと肩代わりしてくれるかもしれない。
ただ更新が3年前で止まっているので今回は見送りました。
koalaで普通にいけるしね。

その2

Q: Tried accessing nonexisting field (media) on node type (User)

下準備はしっかり行ったのに、エラーが出るよ?

A: Access Tokenは3種類ある!

最初はomniauthで取得できるAccessTokenを使用し、かつ{ig-user-id}部分をmeで対応していた。
しかしこれは両方ともダメ。

まずAccessTokenは以下の三種類存在するが、最後のページのTokenを利用する必要がある。

  1. OAuthで取得できるToken
  2. 長期化したToken
  3. 各ページごとのToken

omniauth-facebookは第2トークンまでは勝手に取ってくれる。
第3トークンは実装編に書いている通り。

第2トークンを使っていると、Tried accessing nonexisting field (media) on node type (User)というエラーが出る。
これに長らくはまってしまった。

そしてこれまたはまりポイントが、{id-user-id}はちゃんと指定しなければならない。
この取得方法が最初は分からず、ここも詰まってしまった。 そんな中に見つけた下記サイトにヒントを得て、実践編の形に落ち着いた。
https://tsushiru.com/sns/instagram-graph-api-get-access-token-and-business-account-15-0-2.html

所感

APIFacebook Graph APi/Instagram Graph API/Instagram Basic Display APIと三種類もあるし、
AccessTokenもInstagram Basic Display API用やら短期/長期/ページ用等複数あるし、
app id/app secretもFacebookのものとInstagram Basic Display API用のがあるし、
まずそもそもInstagram Graph APIを使うにはビジネスアカウントにしたりFacebook Pageと連携が必要だったり、
むっずかしいな!!