RustでRubyのgemを書く part2

RustでRubyのgemを書く part1 の続き。

クラスやインスタンス変数を使うメソッドを実装したので、まとめておく。

Rubyで実装したいコードのイメージ

module Wasabi
  class Object
    def initialize(name)
      @name = name
    end
    attr_reader :name

    def say
      "say, #{name}"
    end
  end
end

実際のRustのコード

github.com

クラスの定義

まず、RubyのObjectをRustで使うために extern を書く。

extern {
    static rb_cObject: Value;
}

これをを継承するクラス Wasabi::Object を定義する。

let rb_class = class::rb_define_class_under(rb_mod, str_to_cstring("Object").as_ptr(), rb_cObject);

initialize の実装

第一引数はインスタンスになるため、 引数を2つ書く必要がある。

extern fn rb_object_initialize(obj: Value, name: Value) {
    unsafe {
        let name_id = util::rb_intern(str_to_cstring("name").as_ptr());
        class::rb_ivar_set(obj, name_id, name);
    }
}

rb_ivar_setインスタンス変数に引数を保存している。

name の実装

initializeで保存した値をrb_ivar_getで取得して返す。

unsafe extern fn rb_name(obj: Value) -> Value {
    let name_id = util::rb_intern(str_to_cstring("name").as_ptr());
    class::rb_ivar_get(obj, name_id)
}

say の実装

インスタンス変数の値の型はValueなので、Rustで使いやすくするため value_to_string でStringに変換する。 format! で文字列を組み立てた後、またValueに変換して返す。

unsafe extern fn rb_say(obj: Value) -> Value {
    let name_id = util::rb_intern(str_to_cstring("name").as_ptr());
    let ivar_name = class::rb_ivar_get(obj, name_id);
    let name = value_to_string(ivar_name);
    let message = format!("say, {}", name);
    string::rb_utf8_str_new(str_to_cstring(&message).as_ptr(), message.len() as c_long)
}

value_to_string の実装はこんな感じ。

fn value_to_string(value: Value) -> String {
    unsafe {
        let str = string::rb_string_value_cstr(&value);
        CStr::from_ptr(str).to_string_lossy().into_owned()
    }
}

テストコード

ここまで実装したことで、以下のテストが動作した。

RSpec.describe Wasabi::Object do
  let(:obj) { Wasabi::Object.new("foo") }

  describe "#name" do
    it "returns the initialization args" do
      expect(obj.name).to eq "foo"
    end
  end

  describe "#say" do
    it "returns the greeting message" do
      expect(obj.say).to eq "say, foo"
    end
  end
end

調査中

Rustの構造体を使う方法

せっかくRustを使っているのに、関数を使う実装になってしまっている。 Rustの構造体やメソッドをRuby側で呼び出す方法を調べたい。

RustでRubyのgemを書く part1

RubyGems 3.3.11でRust拡張の対応が実験的に入ったので、少し触ってみたメモ。

In particular, it includes experimental supppot for Rust extensions.

引用: https://blog.rubygems.org/2022/04/07/3.3.11-released.html

wasabi

2019年に鹿児島Ruby会議01で作ったRust製gemがあるので、これを修正しました。

speakerdeck.com

github.com

thermite の削除

refs: b16bfd1e2864adbb698f5225f86e7fd18da0b95e

thermite はRust拡張を作るために使用していたが、RubyGemsが対応したことで不要になったので削除する。

ext/wasabi/extconf.rb の内容は正直よく分かっていない。 サンプルコードであるrb-rust-gemのext/rust_ruby_example/extconf.rb をコピペしたら動いた。

GitHub Actionsでのビルド

refs: 4c8b35774b4b1d6e2b94c1b6dc7aee4bf0e97180

GitHub ActionsのUbuntuにはRust環境が含まれているので、以下のYAMLでビルド + テストが可能になる。

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
    - uses: actions/checkout@v3

    - uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true

    - run: gem update --system
    - run: bundle exec rake

実行結果を見ると、 .so のビルドが動いた後にRSpecが流れているのが確認できる。

▶︎ Run bundle exec rake
mkdir -p tmp/x86_64-linux/wasabi/2.7.6
cd tmp/x86_64-linux/wasabi/2.7.6
/opt/hostedtoolcache/Ruby/2.7.6/x64/bin/ruby -I. ../../../../ext/wasabi/extconf.rb
cd -
mkdir -p tmp/x86_64-linux/stage/lib/wasabi
install -c tmp/x86_64-linux/wasabi/2.7.6/wasabi.so lib/wasabi/wasabi.so
cp tmp/x86_64-linux/wasabi/2.7.6/wasabi.so tmp/x86_64-linux/stage/lib/wasabi/wasabi.so
/opt/hostedtoolcache/Ruby/2.7.6/x64/bin/ruby -I/home/runner/work/wasabi/wasabi/vendor/bundle/ruby/2.7.0/gems/rspec-core-3.9.0/lib:/home/runner/work/wasabi/wasabi/vendor/bundle/ruby/2.7.0/gems/rspec-support-3.9.0/lib /home/runner/work/wasabi/wasabi/vendor/bundle/ruby/2.7.0/gems/rspec-core-3.9.0/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb

Wasabi
  has a version number
  .sum
    1 + 2 = 3
  .call_to_s
    1.to_s
      is expected to eq "1"
    class with :to_s defined
      is expected to eq "foo"

Finished in 0.00192 seconds (files took 0.08639 seconds to load)
4 examples, 0 failures

rb-sys or ruby-sys

RustからRuby APIを呼ぶときに使用できそうなクレート。

  • oxidize-rb/rb-sys
    • RubyGemsの対応した人がコントリビュータ
    • 使い方がよく分からない...
    • 現在も更新されている
  • steveklabnik/ruby-sys
    • 2017年で更新停止
    • コード読めば、なんとなく使い方は分かる

仕組みがよく分かっていなくて、このコードでなぜRubyAPIが呼べるのかは謎。

Rust の実装

C拡張のgemでは Init_xxx が最初に呼ばれるので、ここでモジュールやメソッドを定義する。

#[no_mangle]
pub extern fn Init_wasabi() {
    unsafe {
        let rb_mod = class::rb_define_module(str_to_cstring("Wasabi").as_ptr());
        class::rb_define_singleton_method(rb_mod, str_to_cstring("sum").as_ptr(), rb_sum as CallbackPtr, 2);
        class::rb_define_singleton_method(rb_mod, str_to_cstring("call_to_s").as_ptr(), rb_call_to_s as CallbackPtr, 1);
    }
}

Wasabi.sum のRustの実装は以下の通りで、Value型を数字に戻して計算した後にValueに戻して返している。

// 引数の合計数を返す。
extern fn rb_sum(_mod: Value, a :Value, b: Value) -> Value {
    let a = unsafe { fixnum::rb_num2int(a) as i64 };
    let b = unsafe { fixnum::rb_num2int(b) as i64 };
    let sum = a + b;

    unsafe { fixnum::rb_int2inum(sum as SignedValue) }
}

Wasabi.call_to_s の実装はこんな感じ。

// 引数の `to_s` を呼んで返す。
extern fn rb_call_to_s(_mod: Value, obj: Value) -> Value {
    unsafe {
        let method_id = util::rb_intern(str_to_cstring("to_s").as_ptr());
        util::rb_funcallv(obj, method_id, 0, ptr::null())
    }
}

調査中

調べ終わったら、part2の記事を書きたい。

Rustの構造体をRubyで使う

構造体をData_Wrap_StructValueに変換する必要があるらしい。 ただ、この辺りのRubyのマクロをどうやってRustから呼び出すのか理解できてない...。

RustのメソッドをRubyで使う

関数をRubyで使う方法は分かったが、メソッドをRubyで使う方法はまだ分かっていない。 Rustの構造体 + メソッドをRubyのクラス + メソッドとして使いたい。

RailsアプリをRBS + Steepで型チェックするまでの手順

rails new してから steep check が通るまでにやってみた作業をまとめておく。

GitHub

コードはGitHubで公開しているので、詳細な手順を知りたい方は参照してください。

github.com

手順

gemを入れる

steeprbs_railsをGemfileに追加する。

# Gemfile
group :development do
  gem 'rbs_rails', require: false
  gem 'steep', require: false
end

bundle install を実行する。

$ bundle install

rbs_rails のREADMEの手順に従って lib/tasks/rbs.rake を作成する。

# lib/tasks/rbs.rake
require 'rbs_rails/rake_task'

RbsRails::RakeTask.new

gemのrbsを取得する

いくつかのgemの型定義はgem_rbs_collectionから取得して利用できる。

$ bundle exec rbs collection init
$ bundle exec rbs collection install

取得したrbsファイルはGitで管理する必要はないので .gitignore に追加しておく。

/.gem_rbs_collection

rbs_railsrbsを生成する

Railsの提供するメソッド定義をいくつか自動生成してくれる。

bin/rails rbs_rails:all

自動生成されるファイルなので .gitattributes に追加しておくと良い。*1

sig/rbs_rails/** linguist-generated

Steepfileを作る

bundle exec steep init で雛形が作成されます。最小の設定だと以下の通り。

target :app do
  signature "sig"

  check "app"
end

既存ファイルのrbsを用意する

ディレクトリ構造にあわせてrbsのプロトタイプを生成するシェル - アジャイルSEの憂鬱 で紹介したワンライナーを使う。

$ find app/ -name '*.rb' | xargs -I{} bash -c 'mkdir -p sig/$(dirname {}); bundle exec rbs prototype rb {} > sig/{}s;'

app/models/application_record.rb のように最初から存在するファイルに対して、rbsのプロトタイプを生成する。

不足してるrbsを用意する

gem_rbs_collectionにないgemのrbsは自分で書く必要があるので書く。

rails new した直後の状態で不足していた型定義は以下の通り。(オプションによって変わるかもしれない)

# sig/patch.rbs
module ActiveRecord
  class Base
    def self.primary_abstract_class: () -> void
  end
end

module ActionMailer
  class Base
    def self.default: (untyped) -> void
    def self.layout: (untyped) -> void
  end
end

module ActionCable
  module Channel
    class Base
    end
  end

  module Connection
    class Base
    end
  end
end

型チェックを実行する

ここまで設定がおわれば、 steep check を実行できる。

$ bundle exec steep check
# Type checking files:

.....................................................................................................................................................

No type error detected. 🫖

ジェネレーター後にrbsを自動生成する

ジェネレーターを使ったときにrbsのプロトタイプを作成したり、 rbs_rails:all を自動で実行するようにしておく。 少し楽になる。

# config/environments/development.rb
Rails.application.configure do
  config.generators.after_generate do |files|
    files.each do |f|
      next unless f.match?(%r{^app/.+\.rb$})

      rb_path = Rails.root.join(f)
      rbs_path = Rails.root.join('sig', f.sub(/\.rb$/, '.rbs'))
      rbs_path.dirname.mkpath unless rbs_path.dirname.exist?
      system("bundle exec rbs prototype rb #{rb_path} > #{rbs_path}", exception: true)
    end

    if files.any? { |f| f.match?(%r{^app/.+\.rb$}) }
      system("bin/rails rbs_rails:all", exception: true)
    end
  end
end

scaffold を試す

ここまでの設定を済ませた状態で、scaffoldを試してみる。

$ bin/rails g scaffold book title author

型チェックを実行する。

$ bundle exec steep check
# Type checking files:

......................................................................................................................................F......................

app/controllers/books_controller.rb:28:34: [error] Type `::BooksController` does not have method `book_url`
│ Diagnostic ID: Ruby::NoMethod
│
└         format.html { redirect_to book_url(@book), notice: "Book was successfully created." }
                                    ~~~~~~~~

app/controllers/books_controller.rb:41:34: [error] Type `::BooksController` does not have method `book_url`
│ Diagnostic ID: Ruby::NoMethod
│
└         format.html { redirect_to book_url(@book), notice: "Book was successfully updated." }
                                    ~~~~~~~~

app/controllers/books_controller.rb:55:32: [error] Type `::BooksController` does not have method `books_url`
│ Diagnostic ID: Ruby::NoMethod
│
└       format.html { redirect_to books_url, notice: "Book was successfully destroyed." }
                                  ~~~~~~~~~

Detected 3 problems from 1 file

コントローラで呼び出しているメソッドで、エラーが出る。

エラーについて

web上の記事だと check "app/models" だけ型チェックしてる記事が多いので、現状だとコントローラの型チェックは難しいのかも?

直し方が分かったら、またブログに書くかもしれない。

おまけ

GitHub Actionsで型チェックを動かす

# .github/workflows/check.yml
name: check

on:
  push:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
    - uses: actions/checkout@v3

    - uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true

    - run: bundle exec rbs collection install
    - run: bundle exec steep check

Steep VSCode Integration

steep-vscode を使うと、VSCode上で型チェックの結果を見ることができる。

f:id:sinsoku:20220309232348p:plain
VSCode

注意: devcontainerだとrbsの変更が反映されない?

devcontainerでsteep-vscodeを使ってもrbsのファイル変更が検知されない問題が起きた。 Command + Shift + P => Steep: Restart all で再起動すれば直る。

原因はよく分からないですが、ファイルIO関連なのでDocker for Mac限定の問題かもしれません。

*1:プルリクのdiffが閉じた状態になる

ディレクトリ構造にあわせてrbsのプロトタイプを生成するシェル

既存のリポジトリrbsをゼロから書くのは大変なので、とりあえず雑にプロトタイプを生成する方法。

ディレクトリ構造

app 以下のディレクトリ構造にあわせて sigrbsを置くディレクトリ構成です。

app/models/user.rb
sig/app/models/user.rbs

シェル

find と xargs を使ってディレクトリを作成しつつ、rbsを生成する。

$ find app/ -name '*.rb' |  xargs -I{} bash -c 'mkdir -p sig/$(dirname {}); rbs prototype rb {} > sig/{}s;'

ECSを運用で使っていて難しいと思った点

ECSを触っていて今まで難しいと思ったことを雑にまとめておく。

タスクロールとタスク実行ロールの違い

ECSを長く触っているのに、いつも混乱する。

  • タスクロール
    • コンテナ内の権限
    • S3やSESなどの権限をつける
  • タスク実行ロール
    • コンテナ外の権限
    • ECRやParameter Storeの権限をつける

ECSのデプロイ時に静的ファイルが404になる

ECSを触った初期に遭遇した。 詳細は以下のQiitaの記事が分かりやすい。

参照: ECSのデプロイ時に一定確率で静的ファイルが404になる問題を回避する

回避する方法はいくつかある。

  • 静的ファイルをS3に置く
  • CodeDeployの OneAtATime を使う
  • CodeDeployのBlue/GreenとALBのスティッキーセッションを使う

知らないと気づくのは難しい。

ECSサービスを無停止で更新する手順

ECSサービスをTerraformで変更するとき、一部の設定は replace になる。 無停止で更新するために、新しいECSサービスを作成してターゲットグループをうまく切り替える必要がある。

  1. 新しいECSサービスを作成
  2. Host: dummy.exmaple.com のリスナールールに新ECSサービスを紐づける
    • デフォルトリスナーは旧ECSサービス
  3. 新旧のECSサービスを両方ともデプロイするようにデプロイを設定する
  4. リスナールールの Host を正しい値に変更
    • この時点で新ECSサービスにリクエストが流れる
  5. 旧ECSサービスを削除

手順が多くて面倒なので、リソースを作成する前にecs_serviceの全オプションをしっかり読んだ方が良い。

  • EC2 -> Fargate の移行
  • ECSデプロイ -> CodeDeploy の移行
  • Propagate tagsの設定

少なくとも、これらの作業をするときに必要になった。

CannotPullContainerError でECSタスクが無限に再起動する

ECRにDockerイメージをプッシュしないでタスク定義を更新・デプロイすると起きる。

deployment circuit breaker の設定しておくと良い。

参考: Amazon ECS deployment circuit breaker のご紹介

execute-command は最大2セッション

ecs execute-command でECSタスクに接続できるのは 2セッション まで。

複数人がECSタスクに接続する運用がある場合には注意が必要。

Terraformでタスク定義の最新を参照する

デプロイするとタスク定義のリビジョンが変わるので、ecs_task_definitionのコードを参考にコードを書く必要がある。

data "aws_ecs_task_definition" "mongo" {
  task_definition = aws_ecs_task_definition.mongo.family
}

resource "aws_ecs_cluster" "foo" {
  name = "foo"
}

resource "aws_ecs_task_definition" "mongo" {
  family = "mongodb"

  container_definitions = "<略>"
}

resource "aws_ecs_service" "mongo" {
  name          = "mongo"
  cluster       = aws_ecs_cluster.foo.id
  desired_count = 2

  # Track the latest ACTIVE revision
  task_definition = "${aws_ecs_task_definition.mongo.family}:${
    max(aws_ecs_task_definition.mongo.revision, data.aws_ecs_task_definition.mongo.revision)
  }"
}

CodeDeployを使っているとECSサービスのタグが更新できない

AWSコンソールからは普通にタグをつけられるが、Terraformからは更新できずに下記のエラーが起きる。

Unable to update network parameters on services with a CODE_DEPLOY deployment controller. Use AWS CodeDeploy to trigger a new deployment

詳細は調べられていないけど、AWS Providerのバグかも?

CodeDeploy Blue/Greenを使うとTarget Groupは交互に変わる

リソースを用意するときはTerraformだけど、デプロイは別で行う場合にターゲットグループがズレるケースがある。

  1. Terraformでターゲットグループ(TG-1, TG-2)を用意する
  2. Terraformで TG-1 をリスナールールに紐付ける
  3. CodeDeployでTG-1, TG2のデプロイ設定を行う
  4. CodeDeployでデプロイを行う
    • リスナールールに紐づくリソースが TG-1 => TG-2 に変わる
  5. Terraformで設定を変える
    • コード上は TG-1 がリスナールールに紐づいている
    • apply するとECSサービスの紐づいていない TG-1 に変わり障害になる

terraform plan の出力をちゃんと読めば気づける。

タスク定義みたいにTerraformのコードを工夫すれば回避できるのかな...私は知らないが...

GitHub Actions vs CodePipeline

どちらも触った経験があるけど、どちらも難しくて慣れが必要。

  • GitHub Actions: 日本語の記事が少ない、コード例が少ない
  • CodePipeline: GitHubの連携が面倒、慣れないとIAMとVPCでハマる

比較して考察している記事もあるので、読んでみると良い。

参考: ECS用のCDパイプラインに対する考察

Gemfileに記載してあるgemの説明を一覧で表示する #fjordbootcamp

追記: bundle info でgemの情報を表示できるので、一覧じゃなければコレで十分かも。

$ bundle info rails
  * rails (6.1.3.2)
    Summary: Full-stack web application framework.
    Homepage: https://rubyonrails.org
    Documentation: https://api.rubyonrails.org/v6.1.3.2/
    Source Code: https://github.com/rails/rails/tree/v6.1.3.2
    Changelog: https://github.com/rails/rails/releases/tag/v6.1.3.2
    Bug Tracker: https://github.com/rails/rails/issues
    Mailing List: https://discuss.rubyonrails.org/c/rubyonrails-talk
    Path: /app/vendor/bundle/ruby/3.0.0/gems/rails-6.1.3.2

タイトルに #fjordbootcamp がついていますが、これは #fjordbootcamp の課題とかではありません。

フィヨルドブートキャンプとは

bootcamp.fjord.jp

きっかけ

フィヨルドブートキャンプのGitHubリポジトリに以下のIssueが登録されていた。*1

github.com

これを読んでいて、ふと「gemspecから説明を抜き出して、一覧で表示できると便利そう」と思いついたので、試しに実装してみた。

コード

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'bundler'

lockfile = Bundler.default_lockfile.read
parser = Bundler::LockfileParser.new(lockfile)

spec_per_name = parser.specs.group_by(&:name).transform_values(&:first)

parser.dependencies.keys.each do |name|
  spec = spec_per_name[name]

  if spec
    path = "#{Bundler.specs_path}/#{name}-#{spec.version}.gemspec"
    gemspec = Gem::Specification.load(path)
  end

  puts "#{name}: #{gemspec&.summary}"
end

実行結果

$ ./bundler-summaries
bootsnap: Boot large ruby/rails apps faster
byebug: Ruby fast debugger - base + CLI
capybara: Capybara aims to simplify the process of integration testing Rack applications, such as Rails, Sinatra or Merb
jbuilder: Create JSON structures via a Builder-style DSL
listen: Listen to file modifications
puma: Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications
rack-mini-profiler: Profiles loading speed for rack applications.
rails: Full-stack web application framework.
sass-rails: Sass adapter for the Rails asset pipeline.
selenium-webdriver: The next generation developer focused tool for automated testing of webapps
spring: Rails application preloader
sqlite3: This module allows Ruby programs to interface with the SQLite3 database engine (http://www.sqlite.org)
turbolinks: Turbolinks makes navigating your web application faster
tzinfo-data:
web-console: A debugging tool for your Ruby on Rails applications.
webdrivers: Easy download and use of browser drivers.
webpacker: Use webpack to manage app-like JavaScript modules in Rails

*1:フィヨルドブートキャンプでは学習で使うサービス自体がGitHubオープンソースになっている

SlackのGitHub Appをアップグレードするとdeployコマンドと通知が壊れる

先日、SlackのGitHub Appがアップグレードされました。

github.com

しかし、アップグレードすると /github deploy コマンドとデプロイ通知が 壊れる ため、業務で使っている場合はもう少し様子を見てからアップグレードした方が良さそうです。

READMEの記述

以下はREADMEの引用ですが、意図的に機能を消しているので復活しない可能性もある。

Removed deploy command and notification support: Today, the functionality provided by deploy command is very limited and doesn't address all the scenarios. We are removing deploy command and notifications support as part of this version. We want to relook at the scenarios and build a more holistic experience that customers need.

関連するIssue

github.com

github.com

GitHub(Legacy)を復活させる

https://<workspace>.slack.com/apps/A8GBNUWU8-github-legacy から再インストールすると復活できた。
workspace の部分は各ワークスペースの名前に直してください。