うなすけとあれこれ

2021年08月11日

QUIC の Initial packet を Ruby で受けとる

QUIC と Ruby

QUIC とは

QUIC は、今年 5 月に RFC 9000 や他いくつかの RFC によって標準化された、次世代のインターネットにおける通信プロトコルです。HTTP/3 では、この QUIC を下位層として使うことになっており、今後のより高速なインターネット通信において QUIC の占める役割は非常に大きなものとなるでしょう。

QUIC is now RFC 9000 | Fastly

この記事では、QUIC による通信が始まる第一歩であるところの、Initial packet を Ruby で受けとってみることにします。

はじめに

この記事内では、いくつかの外部の記事を参照しています。それらは QUIC の、ある時点での draft を参考に書いてあるものもありますが、この記事では RFC となった QUIC version 1 に対しての内容となります。

記事内の誤り、誤字脱字等は気軽に twitter @yu_suke1994 にリプライしていただけると嬉しいです。

UDP Packet を受けとる

QUIC では TCP ではなく UDP を使って通信します。つまり、Ruby で QUIC の Packet を受けとるために作成するのは UDP Socket になります。

以下のように Socket を作成し1、そこに対して QUIC の Initial packet を送信してやります。

require 'socket'

socket = UDPSocket.new
socket.bind("0.0.0.0", 8080)

begin
  raw_packet = socket.recvfrom_nonblock(2000)
rescue IO::WaitReadable
  retry
end

pp raw_packet[0]

ここで用意したポートに対して、どのように QUIC の Packet を送信すればいいでしょうか。ここでは、僕がビルドしている HTTP/3 に対応している curl を使用することにします2。docker-compose.yml の内容は割愛します。

https://github.com/unasuke/curl-http3

Initial packet を parse する

さて、Initial packet を受信することができたので、中身を見てみます。

irb(main):001:0> raw_packet[0].unpack("H*")
=> ["c80000000110b61d55525ce5050363d471738ff245271476637acd05d5...

人間が読むようなものではありませんね。そこで、これを parse します。

QUIC の Packet の構造はこのようになっており、header と frame を持ちます。header には short header と long header の 2 種類があり、Initial packet は long header を持ちます。Initial packet は以下のような構造をしています。

Initial Packet {
  Header Form (1) = 1,
  Fixed Bit (1) = 1,
  Long Packet Type (2) = 0,
  Reserved Bits (2),
  Packet Number Length (2),
  Version (32),
  Destination Connection ID Length (8),
  Destination Connection ID (0..160),
  Source Connection ID Length (8),
  Source Connection ID (0..160),
  Token Length (i),
  Token (..),
  Length (i),
  Packet Number (8..32),
  Packet Payload (8..),
}

https://www.rfc-editor.org/rfc/rfc9000.html#name-initial-packet

この定義に従い packet を parse するのですが、ここで bindata という便利な gem を使います。使い方の説明は割愛しますが、以下のようなコードを書くことで今受け取った Initial packet を parse することができます。

ここで気をつけないといけないのが、上記構造でフィールドの長さが (i) となっているもの (Token length と Length) です。このフィールドは可変長で、 “Variable-Length Integer Encoding” という形式で表現されています。この形式は、まず先頭 2 ビット (two most significant bits) を読み、その値によって後続のバイト数が決まるようになっています3

require 'bindata'

def tms(bit)
  case bit
  when 0 then 6
  when 1 then 14
  when 2 then 30
  when 3 then 62
  end
end

class QUICInitialPacket < BinData::Record
  endian :big
  bit1 :header_form, asserted_value: 1
  bit1 :fixed_bit, asserted_value: 1
  bit2 :long_packet_type, asserted_value: 0
  bit2 :reserved_bit
  bit2 :packet_number_length
  bit32 :version
  bit8 :destination_connection_id_length
  bit :destination_connection_id, nbits:  lambda { destination_connection_id_length * 8 }
  bit8 :source_connection_id_length
  bit :source_connection_id, nbits: lambda{ source_connection_id_length * 8 }

  # Variable-Length Integer Encoding for token
  bit2 :token_two_most_significant_bits
  bit :token_length, nbits: lambda { tms(token_two_most_significant_bits) }
  string :token, read_length: lambda { token_length }

  # Variable-Length Integer Encoding for length
  bit2 :length_two_most_significant_bits
  bit :length_length, nbits: lambda { tms(length_two_most_significant_bits) }

  bit :packet_number, nbits: lambda { (packet_number_length + 1) * 8 }
  string :payload, read_length: lambda { length_length - (packet_number_length + 1) }
end

parsed_packet = QUICInitialPacket.read(raw_packet[0])

https://www.rfc-editor.org/rfc/rfc9000.html#name-variable-length-integer-enc

payload に frame が格納されます。 Initial packet の payload には何が入っているのでしょうか。Initial packet の payload には、CRYPTO frame というものが含まれているはず4です。CRYPTO frame は以下のような構造をしています。

CRYPTO Frame {
  Type (i) = 0x06,
  Offset (i),
  Length (i),
  Crypto Data (..),
}

https://www.rfc-editor.org/rfc/rfc9000.html#name-crypto-frames

それでは、先ほど受けとった packet の Packet Payload は、先頭に 0x06 が含まれているはず5ですね。見てみましょう。

irb(main):001:0> parsed_packet
=>
{:header_form=>1,
 :fixed_bit=>1,
 :long_packet_type=>0,
 :reserved_bit=>2,
 :packet_number_length=>0,
 :version=>1,
 :destination_connection_id_length=>16,
 :destination_connection_id=>242071802372027324022003629770543351079,
 :source_connection_id_length=>20,
 :source_connection_id=>675879382196389319306730800640250706941161587532,
 :token_two_most_significant_bits=>0,
 :token_length=>0,
 :token=>0,
 :length_two_most_significant_bits=>1,
 :length_length=>288,
 :payload=>
  "QuqI\xE6\xB10......

含まれていませんね。これはどうしてでしょうか。

現代のインターネットでは、通信は暗号化されてやりとりするのが一般的です。もちろん QUIC も暗号化した情報をやりとりするのが基本です。なので、この時経路上を流れていくパケットは暗号化されている6ため、まずは復号する必要があります。

Initial packet を復号する (packet の保護を解く)

さて復号のためには、どのように暗号化されているのかを知る必要があります。

QUIC におけるパケットの暗号化、それも Initial packet に対して行われる保護については、RFC 9001 - 5.2. Initial Secrets にその詳細があります。日本語では、flano_yuki さんの記事 「QUIC の暗号化と鍵の導出について」kazu-yamamoto さんの記事 「QUIC 開発日記 その 1 参戦」 がわかりやすいです。

QUIC では、まず header を利用して payload を暗号化してから、その暗号化された内容を利用して header を保護します。そこで、復号のためには、まず payload から mask を得て header の保護を解除し、その後得られた header の平文を用いて payload を復号します。

この過程において行われていることは、前述の flano_yuki さんの記事内にて引用されているスライド の p24 及び p27 の図がとてもわかりやすいです。

ここからは実際に受けとった packet の情報をもとに作業をしていくのではなく、検証が容易になるようRFC 9001 に記載されている付録 A の値を用いて暗号化と復号をしてみます。

RFC 9001 - Appendix A.2. でやってみる

まずは平文の packet を暗号化してみます。これが Appendix A.2. にある CRYPTO frame の平文です。

060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868
04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578
616d706c652e636f6dff01000100000a00080006001d00170018001000070005
04616c706e000500050100000000003300260024001d00209370b2c9caa47fba
baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400
0d0010000e0403050306030203080408050806002d00020101001c0002400100
3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000
75300901100f088394c8f03e51570806048000ffff

これを暗号化していくのですが、このままでは 245 bytes であり、header を合わせても 1200 bytes には届きません。なので PADDING frame を付与して header を除く payload 全体を 1162 bytes まで増やします7

payload =
  "060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868" +
  "04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578" +
  "616d706c652e636f6dff01000100000a00080006001d00170018001000070005" +
  "04616c706e000500050100000000003300260024001d00209370b2c9caa47fba" +
  "baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400" +
  "0d0010000e0403050306030203080408050806002d00020101001c0002400100" +
  "3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000" +
  "75300901100f088394c8f03e51570806048000ffff" +
  ("00" * 917) # PADDING frame

これを暗号化します。どのように行うのかは RFC 9001 - 5. Packet Protection に定義があり、Initial packet については AEAD_AES_128_GCM を用いて暗号化します。この AEAD については RFC 5116 に定義されており、暗号化に必要なパラメーターは以下の 4 つです8

これらの入力から、 C (ciphertext) が得られます。それぞれの入力を見ていきます。

K、 secret key ですが、RFC 5869 に定義されている鍵導出関数、HKDF を使用して導出します。ここで、まず初期 secret の導出のため、 HKDF-Extract の入力に QUIC の場合は 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a と Destination Connection ID (この Appendix では 0x8394c8f03e515708)9 を使用します。

この HDKF ですが、一般に HKDF-Extract の後に HKDF-Expand を使用して最終的な鍵を得ます。そのため、Ruby の OpenSSL gem にも、この過程を一度に行うための OpenSSL::KDF.hkdf という関数10があります。しかし、QUIC (および TLS 1.3)ではこれをそのまま使うことができません。理由は、RFC 5869 での定義の他に、RFC 8446 (TLS 1.3) で定義されている HKDF-Expand-Label(Secret, Label, Context, Length) という関数11が必要なためです。

ありがたいことに、Ruby による TLS 1.3 実装 thekuwayama/tttls1.3HKDF-Expand-Label の実装があるので、これを使って鍵を導出します。

require 'openssl'
require 'tttls1.3/key_schedule'

def hkdf_extract(salt, ikm)
  ::OpenSSL::HMAC.digest('SHA256', salt, ikm)
end

initial_salt = ["38762cf7f55934b34d179ae6a4c80cadccbb7f0a"].pack("H*")
destination_connection_id = ["8394c8f03e515708"].pack("H*")

initial_secret = hkdf_extract(initial_salt, destination_connection_id)
# initial_secret.unpack1("H*") => "7db5df06e7a69e432496adedb00851923595221596ae2ae9fb8115c1e9ed0a44"

client_initial_secret = TTTLS13::KeySchedule.hkdf_expand_label(initial_secret, 'client in', '', 32, 'SHA256')
# client_initial_secret.unpack1("H*") => "c00cf151ca5be075ed0ebfb5c80323c42d6b7db67881289af4008f1f6c357aea"

key = TTTLS13::KeySchedule.hkdf_expand_label(client_initial_secret, 'quic key', '', 16, 'SHA256')
# key.unpack1("H*") => "1f369613dd76d5467730efcbe3b1a22d"

iv = TTTLS13::KeySchedule.hkdf_expand_label(client_initial_secret, 'quic iv', '', 12, 'SHA256')
# iv.unpack1("H*") => "fa044b2f42a3fd3b46fb255c"

hp = TTTLS13::KeySchedule.hkdf_expand_label(client_initial_secret, 'quic hp', '', 16, 'SHA256')
# hp.unpack1("H*") => "9f50449e04a0e810283a1e9933adedd2"

このようにして、A.1 Keys と同様の鍵を得ることができました。

では暗号化していきます。RFC 9001 - 5.3. AEAD Usage によれば、AEAD に渡すパラメータは以下のように定義されます。

これらから、以下のようなコードで暗号化された payload を得ることができました。

payload =
  "060040f1010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e868" +
  "04fe3a47f06a2b69484c00000413011302010000c000000010000e00000b6578" +
  "616d706c652e636f6dff01000100000a00080006001d00170018001000070005" +
  "04616c706e000500050100000000003300260024001d00209370b2c9caa47fba" +
  "baf4559fedba753de171fa71f50f1ce15d43e994ec74d748002b000302030400" +
  "0d0010000e0403050306030203080408050806002d00020101001c0002400100" +
  "3900320408ffffffffffffffff05048000ffff07048000ffff08011001048000" +
  "75300901100f088394c8f03e51570806048000ffff" +
  ("00" * 917)

encryptor = OpenSSL::Cipher.new("AES-128-GCM")
encryptor.encrypt
encryptor.key = key
nonce = (iv.unpack1("H*").to_i(16) ^ 2).to_s(16)
encryptor.iv = [nonce].pack("H*")
encryptor.auth_data = ["c300000001088394c8f03e5157080000449e00000002"].pack("H*")

protected_payload = ""
protected_payload << encryptor.update([payload].pack("H*"))
protected_payload << encryptor.final
protected_payload << encryptor.auth_tag

pp protected_payload.unpack1("H*")
# => d1b1c98dd7689fb8ec11
# d242b123dc9bd8bab936b47d92ec356c0bab7df5976d27cd449f63300099f399
# 1c260ec4c60d17b31f8429157bb35a1282a643a8d2262cad67500cadb8e7378c
# 8eb7539ec4d4905fed1bee1fc8aafba17c750e2c7ace01e6005f80fcb7df6212
# 30c83711b39343fa028cea7f7fb5ff89eac2308249a02252155e2347b63d58c5
# 457afd84d05dfffdb20392844ae812154682e9cf012f9021a6f0be17ddd0c208
# 4dce25ff9b06cde535d0f920a2db1bf362c23e596d11a4f5a6cf3948838a3aec
# 4e15daf8500a6ef69ec4e3feb6b1d98e610ac8b7ec3faf6ad760b7bad1db4ba3
# 485e8a94dc250ae3fdb41ed15fb6a8e5eba0fc3dd60bc8e30c5c4287e53805db
# 059ae0648db2f64264ed5e39be2e20d82df566da8dd5998ccabdae053060ae6c
# 7b4378e846d29f37ed7b4ea9ec5d82e7961b7f25a9323851f681d582363aa5f8
# 9937f5a67258bf63ad6f1a0b1d96dbd4faddfcefc5266ba6611722395c906556
# be52afe3f565636ad1b17d508b73d8743eeb524be22b3dcbc2c7468d54119c74
# 68449a13d8e3b95811a198f3491de3e7fe942b330407abf82a4ed7c1b311663a
# c69890f4157015853d91e923037c227a33cdd5ec281ca3f79c44546b9d90ca00
# f064c99e3dd97911d39fe9c5d0b23a229a234cb36186c4819e8b9c5927726632
# 291d6a418211cc2962e20fe47feb3edf330f2c603a9d48c0fcb5699dbfe58964
# 25c5bac4aee82e57a85aaf4e2513e4f05796b07ba2ee47d80506f8d2c25e50fd
# 14de71e6c418559302f939b0e1abd576f279c4b2e0feb85c1f28ff18f58891ff
# ef132eef2fa09346aee33c28eb130ff28f5b766953334113211996d20011a198
# e3fc433f9f2541010ae17c1bf202580f6047472fb36857fe843b19f5984009dd
# c324044e847a4f4a0ab34f719595de37252d6235365e9b84392b061085349d73
# 203a4a13e96f5432ec0fd4a1ee65accdd5e3904df54c1da510b0ff20dcc0c77f
# cb2c0e0eb605cb0504db87632cf3d8b4dae6e705769d1de354270123cb11450e
# fc60ac47683d7b8d0f811365565fd98c4c8eb936bcab8d069fc33bd801b03ade
# a2e1fbc5aa463d08ca19896d2bf59a071b851e6c239052172f296bfb5e724047
# 90a2181014f3b94a4e97d117b438130368cc39dbb2d198065ae3986547926cd2
# 162f40a29f0c3c8745c0f50fba3852e566d44575c29d39a03f0cda721984b6f4
# 40591f355e12d439ff150aab7613499dbd49adabc8676eef023b15b65bfc5ca0
# 6948109f23f350db82123535eb8a7433bdabcb909271a6ecbcb58b936a88cd4e
# 8f2e6ff5800175f113253d8fa9ca8885c2f552e657dc603f252e1a8e308f76f0
# be79e2fb8f5d5fbbe2e30ecadd220723c8c0aea8078cdfcb3868263ff8f09400
# 54da48781893a7e49ad5aff4af300cd804a6b6279ab3ff3afb64491c85194aab
# 760d58a606654f9f4400e8b38591356fbf6425aca26dc85244259ff2b19c41b9
# f96f3ca9ec1dde434da7d2d392b905ddf3d1f9af93d1af5950bd493f5aa731b4
# 056df31bd267b6b90a079831aaf579be0a39013137aac6d404f518cfd4684064
# 7e78bfe706ca4cf5e9c5453e9f7cfd2b8b4c8d169a44e55c88d4a9a7f9474241
# e221af44860018ab0856972e194cd934
#
# https://www.rfc-editor.org/rfc/rfc9001#section-a.2-7 の内容からheader部分を除いたものと同じ

次に、この暗号化された payload を sampling したものをもとに header を保護します。このあたりは、 https://tex2e.github.io/blog/crypto/quic-tls#ヘッダの暗号化 に、このようになっている理由の日本語による解説があります。

どの部分からどの部分までを sampling するかは、 RFC 9001 - 5.4.2. Header Protection Sample に計算式があるので、Initial packet の場合の式を以下に記載します。

pn_offset = 7 + len(destination_connection_id) + len(source_connection_id) + len(payload_length)
pn_offset += len(token_length) + len(token) # 特にInitial packetの場合
sample_offset = pn_offset + 4

# sample_length は、AES を使用する場合は 16 bytes
# https://www.rfc-editor.org/rfc/rfc9001#section-5.4.3-2
sample = packet[sample_offset..sample_offset+sample_length]

それでは計算していきましょう。Appendix では、header は c300000001088394c8f03e5157080000449e00000002 でした12。destinationconnectionid は 8394c8f03e515708 なので 8 bytes、 sourceconnectionid はないので 0 byte、 payloadlength は 449e なので 2 bytes、 token length は 0 なので 1 byte、 token 自体は無いので 0 byte となり、 `pnoffsetは 18 になります。よってsample_offset` は 22 bytes となります。

なので、packet の先頭から 22 bytes 進み、そこから 16 bytes を取得すると d1b1c98dd7689fb8ec11d242b123dc9b になります。

ここから mask を導出します。AES を使用する場合の mask の導出は RFC 9001 - 5.4.3. AES-Based Header Protection に定義されており、これを Ruby で行うのが以下のコードになります。

enc = OpenSSL::Cipher.new('aes-128-ecb')
enc.encrypt

# hp
enc.key = ["9f50449e04a0e810283a1e9933adedd2"].pack("H*")

sample = "d1b1c98dd7689fb8ec11d242b123dc9b"

mask = ""
mask << enc.update([sample].pack("H*"))
mask << enc.final
pp mask.unpack1("H*")
# => "437b9aec36be423400cdd115d9db3241aaf1187cd86d6db16d58ab3b443e339f"

それでは得られた mask を元に、header の保護をしてみましょう。

# https://www.rfc-editor.org/rfc/rfc9001#section-a.2-6
header = "c300000001088394c8f03e5157080000449e00000002"
mask = "437b9aec36be423400cdd115d9db3241aaf1187cd86d6db16d58ab3b443e339f"

# Appendixの例と範囲が異なるのは、文字列で保持しているため
# 2文字 => 1 byte
header[0..1] = (header[0..1].to_i(16) ^ (mask[0..1].to_i(16) & '0f'.to_i(16))).to_s(16)
header[36..43] = (header[36..43].to_i(16) ^ mask[2..9].to_i(16)).to_s(16)

pp header
# => "c000000001088394c8f03e5157080000449e7b9aec34"

このようにして得られた保護された header と、暗号化された payload を結合することで Appendix にある “resulting protected packet”13 と同様のものを得ることができました。

それではここまでの手順を逆に辿り、暗号化された packet の復号をやってみましょう。これは長いので、完全版を gist に置き、ここには抜粋したものを掲載します。

https://gist.github.com/unasuke/b30a4716248b1831bd428af3b7829ce7

require 'openssl'
require 'bindata'

class QUICInitialPacket < BinData::Record
  # ....
end

class QUICProtectedInitialPacket < BinData::Record
  # ....
end

class QUICCRYPTOFrame < BinData::Record
  endian :big
  bit8 :frame_type, asserted_value: 0x06
  bit2 :offset_two_most_significat_bits
  bit :offset, nbits: lambda { tms(offset_two_most_significat_bits) }
  bit2 :length_two_most_significant_bits
  bit :length_length, nbits: lambda { tms(length_two_most_significant_bits) }
  string :data, read_length: lambda { length_length }
end

raw_packet = [
  "c000000001088394c8f03e5157080000449e7b9aec34d1b1c98dd7689fb8ec11" +
  # .......
].pack("H*")

packet = QUICProtectedInitialPacket.read(raw_packet)

# ここからheaderの保護を解除するためのコード
pn_offset = 7 +
  packet.destination_connection_id_length +
  packet.source_connection_id_length +
  (tms(packet.length_two_most_significant_bits) + 2) / 8 +
  (tms(packet.token_two_most_significant_bits) + 2) / 8 +
  packet.token_length

sample_offset = pn_offset + 4

sample = raw_packet[sample_offset...sample_offset+16]

enc = OpenSSL::Cipher.new('aes-128-ecb')
enc.encrypt

enc.key = ["9f50449e04a0e810283a1e9933adedd2"].pack("H*") # hp
mask = ""
mask << enc.update(sample)
mask << enc.final

# https://www.rfc-editor.org/rfc/rfc9001#name-header-protection-applicati
# headerを保護するときとは逆の手順を踏んで保護を解除する
raw_packet[0] = [(raw_packet[0].unpack1('H*').to_i(16) ^ (mask[0].unpack1('H*').to_i(16) & 0x0f)).to_s(16)].pack("H*")

# https://www.rfc-editor.org/rfc/rfc9001#figure-6
pn_length = (raw_packet[0].unpack1('H*').to_i(16) & 0x03) + 1

packet_number =
  (raw_packet[pn_offset...pn_offset+pn_length].unpack1("H*").to_i(16) ^ mask[1...1+pn_length].unpack1("H*").to_i(16)).to_s(16)

# 先頭の0が消えてしまうので、パケット番号の長さに満たないぶんを zero fillする
raw_packet[pn_offset...pn_offset+pn_length] = [("0" * (pn_length * 2 - packet_number.length)) + packet_number].pack("H*")

# headerの保護が外れたpacket (payloadはまだ暗号)
packet = QUICInitialPacket.read(raw_packet)

# 復号のためheaderのみを取り出す
header_length = raw_packet.length - packet.payload.length

# payloadの復号
dec = OpenSSL::Cipher.new('aes-128-gcm')
dec.decrypt
dec.key = ["1f369613dd76d5467730efcbe3b1a22d"].pack("H*") # quic key
dec.iv = [("fa044b2f42a3fd3b46fb255c".to_i(16) ^ packet.packet_number).to_s(16)].pack("H*") # quic iv
dec.auth_data = raw_packet[0...(raw_packet.length - packet.payload.length)]
dec.auth_tag = packet.payload[packet.payload.length-16...packet.payload.length]

payload = ""
payload << dec.update(packet.payload[0...packet.payload.length-16])
payload << dec.final

# 復号したpayloadをCRYPTO frameとしてparse
pp QUICCRYPTOFrame.read(payload)
# => {:frame_type=>6,
# :offset_two_most_significat_bits=>0,
# :offset=>0,
# :length_two_most_significant_bits=>1,
# :length_length=>241,
# :data=>
#  "\x01\x00\x00\xED\x03\x03\xEB\xF8\xFAV\xF1)9\xB9XJ8\x96G.\xC4\v\xB8c\xCF\xD3\xE8h\x04\xFE:G\xF0j+iHL" +
#  "\x00\x00\x04\x13\x01\x13\x02\x01\x00\x00\xC0\x00\x00\x00\x10\x00\x0E\x00\x00\vexample.com\xFF\x01\x00\x01\x00\x00\n" +
#  "\x00\b\x00\x06\x00\x1D\x00\x17\x00\x18\x00\x10\x00\a\x00\x05\x04alpn\x00\x05\x00\x05\x01\x00\x00\x00\x00" +
#  ....

CRYPTO frame の中に何やらそれらしき文字列が出現していることから、payload の復号に成功したことがうかがえますね。

実際に curl から受けとった packet の parse もやっていきたいところですが、一旦この記事ではここまでとします。

さいごに

プロトコルの理解は、実際に手を動かしてみるのが一番ですね。

記事内の誤り、誤字脱字等は気軽に twitter @yu_suke1994 にリプライしていただけると嬉しいです。

記事をチェックしてくれた あらやくんおりさのくんとちくじさん に感謝します。

追記

参考文献


  1. 最低でも 1200 bytes は受信できるようにする必要があります。特に Initial packet については、必ず 1200 bytes 以上のサイズになるように PADDING などを行う必要があります。 https://www.rfc-editor.org/rfc/rfc9000.html#name-datagram-size 

  2. もっと手軽に実験するなら、Python の環境があるなら aioquic 、Rust の環境を用意するのが苦でないなら Neqo を使うのもいいと思います。なかなかビルド済バイナリを落としてくるだけで使用できる QUIC クライアントがないのが現状です。 

  3. 厳密には、この時点で parse を行うことはできません。後述する packet の保護を解除しないと正しい情報は得られません。 

  4. TLS handshake を行うため。 https://www.rfc-editor.org/rfc/rfc9001#name-carrying-tls-messages 

  5. 先頭にあるとは限りませんし、どのみちこの時点では暗号化されており 0x06 の存在を確認することはできません。 https://www.rfc-editor.org/rfc/rfc9000.html#section-17.2.2-8 

  6. 例外もあります。例えば Version Negotiagion パケットは保護されません。 https://www.rfc-editor.org/rfc/rfc9001#section-5-3.1 

  7. https://www.rfc-editor.org/rfc/rfc9001#section-a.2-1 

  8. https://www.rfc-editor.org/rfc/rfc5116.html#section-2.1 

  9. https://www.rfc-editor.org/rfc/rfc9001#section-appendix.a-1 

  10. https://docs.ruby-lang.org/en/3.0.0/OpenSSL/KDF.html#method-c-hkdf 

  11. https://www.rfc-editor.org/rfc/rfc8446.html#section-7.1 

  12. https://www.rfc-editor.org/rfc/rfc9001#section-a.2-4 

  13. https://www.rfc-editor.org/rfc/rfc9001#section-a.2-7 

2021年08月11日
2021年07月30日

スライド置き場のポリシー

JavaScriptがとんでもないことになっている様子

スライドをどこに置くか

スライドをスライド共有サービスに置くのをやめることにした - 私が歌川です を読み、自分の現時点でのスタンスを書いておこうかなの気持ちになったので書くものです。

めんどくさい人間だなという自覚はあります。とにかくスライドに出てくるURLをクリックしたいという気持ちが強く、それができないスライド共有サービスに自分のスライドは置きたくないです。

最適解は、歌川さんも書いているようなPDFを置いておくWebサイトを立てておくことになるでしょうが、ちょっと面倒だなあというので外部サービスに頼っていたいです。あと埋め込みどうすればいいんだろう。

2021年07月30日
2021年06月05日

Electronで作っているPocketリーダーアプリの近況報告

screenshot

ElectronでPocketリーダーアプリを作っています | うなすけとあれこれ

PocketのリーダーアプリをElectronで開発しています。 commit logを見返すと昨年11月頃から開発を進めており、bosyuでベータテスターとして何名かの方々に試してもらってからは、2週間くらいに1度の頻度リリースを行っており、現在 v0.16.0 になっています。

開発中のアプリのベータテスターを募集します!「あとで読む」の管理などにPocketを使っている方をお待ちしています~~~~

Pocketリーダーアプリのベータテスター募集! | うなすけ @yu_suke1994
https://t.co/EHMUPSyXft #bosyu

— うなすけ (@yu_suke1994) January 9, 2021

アプリ自体は、Pocketに保存している、あとで読むための記事をサクサクと読んでいくことにフォーカスしています。キーボードのみで記事リストを上下、アーカイブし、マウスで記事そのもののスクロールができるようになっています。具体的にどのようなものかは、以下の動画で大体掴めると思います。

さてbosyuがサービスをクローズするということになったので、このタイミングで進捗報告を兼ね、Discordに移行することにしました。以下から参加することができます。

https://discord.gg/5jjPx26x25

bosyuがやりとりが2週間途絶えるとやりとりができなくなる仕様ということもあり、それが締め切りの役割となってこれまでリリースを重ねてこれた部分があると思っています。それがDiscordに移行してその制限がなくなったときに、同じようなペースでリリースをできるかどうかはちょっとわかりません。

そしてゆくゆくは何らかの形で有料化するつもりでいます。とはいえ、試しに触ってもらってお金を払うに値するかどうか決められるようになっていて欲しく、そして一体何を課金要素とするのかについては何も考えられていません。

electron-jp

少し話がずれるのですが、electon-jpというSlackがあり、一時期は盛り上がっていたのですが、今ではただのRSS feedが流れるだけの場所になってしまっており、悲しみを感じています。ぽつぽつでもいいから、また人々の交流する場になってほしいと思っています。

2021年06月05日
2021年05月17日

HTTP/3が喋れるcurlを定期的にbuildする

TL;DR

inductorさんが上のような記事を公開されています。ここで公開されているDockerfileによってbuildされたimageがあると嬉しいので、GitHub Actionsで定期的にbuildしたものをGitHub Container Registry上にホストすることにしました。

https://github.com/users/unasuke/packages/container/package/curl-http3

結論としてはこれだけなのですが、docker imageのtagについてちょっと手間取ったのでそれについて書こうと思います。

Docker imageのtagをある程度自由に書きたい

このimageをbuidするにあたり、 curl-http3 というimageに対して以下のtagを付けたいと思っていました。

GitHub ActionsでDocker buildを行いたいとなった場合には、以下のDocker公式が提供しているactionを使用するのが定石でしょう。

https://github.com/marketplace/actions/build-and-push-docker-images

そして、 docker/build-push-action@v2 を利用して動的なtagを付けようと、このようなYAMLを書きました。

- run: date +%Y-%m-%d
  id: date # 結果を参照できるようにidをつけておく
- run: foobar
- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: quiche
    push: true
    tags:
      - ghcr.io/unasuke/curl-http3:quiche-${{ steps.date.outputs.result }} # ここで dateの結果を利用したい
      - ghcr.io/unasuke/curl-http3:quiche-latest
    cache-from: type=local,src=/tmp/.buildx-cache
    cache-to: type=local,dest=/tmp/.buildx-cache-new

${{ steps.date.outputs }} というのは、 idがdateとなっているstepの出力を展開させるための記法です。

https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idoutputs

しかしここで文字列展開はできないらしく、以下のエラーによってActionを実行することができませんでした。

The workflow is not valid. .github/workflows/build.yml (Line: 32, Col: 13): A sequence was not expected .github/workflows/build.yml (Line: 70, Col: 13): A sequence was not expected

(どちらにせよ、上に書いたようなYAMLの内容では dateコマンドの結果を再利用することはできません、 ::set-output などの記法を適切に使用する必要があります)

どうすればいいでしょうか?

docker/metadata-action を使う

docker/metadata-action を使うと、ここで行いたいことができるようになります1

https://github.com/marketplace/actions/docker-metadata-action

もう一度、どのようなタグをつけたいかをおさらいします。

ここで、quiche版のみに注目して考えてみます。まず、quiche-latest については固定値なので docker/build-push-action@v2 だけでも実現できますが、今問題になっているのは、上でも述べたように quiche-2021-05-01 などのビルドした時点での日付が入っているものです。

これは、docker/metadata-action では type-schedule を指定することで実現できます。

https://github.com/marketplace/actions/docker-metadata-action#typeschedule

- uses: docker/metadata-action@v3
  id: meta
  with:
    images: ghcr.io/unasuke/curl-http3
    tags: |
      type=schedule,pattern={{date 'YYYY-MM-DD'}},prefix=quiche-
      type=raw,value=quiche-latest

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}

具体的には、上のような指定を書くことで quiche-2021-05-01 のようなtagを付けたimageをbuildすることができます。注意すべきは、この type=schedule で指定したtagが付与されるのはScheduled eventsの場合だけなので、pushしたタイミングで実行されるactionではtagが付きません。

他にもcommit hashを指定することもできます。

最終的に、 quiche-latestquiche-<YYYY-MM-DD>quiche-<commithash> の3種類のtagを付けるために、以下のようなYAMLを記述しました。

- uses: docker/metadata-action@v3
  id: meta
  with:
    images: ghcr.io/unasuke/curl-http3
    tags: |
      type=schedule,pattern={{date 'YYYY-MM-DD'}},prefix=quiche-
      type=raw,value=quiche-latest
      type=sha,prefix=quiche-

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: quiche
    push: true
    tags: ${{ steps.meta.outputs.tags }}

実際に動いているWorkflowの定義は https://github.com/unasuke/curl-http3/blob/master/.github/workflows/build.yml にあります。

まとめ

HTTP/3が喋れるcurlを定期的にbuildして unasuke/curl-http3 に置いてあります。

また、GitHub Actionsにおいてdocker imageのtagをある程度柔軟に指定したい場合、 docker/metadata-action が解決策になるかもしれません。


  1. もともとは crazy-max/ghaction-docker-meta@v2 でしたが、この記事を書いている間にdocker org公式でメンテナンスされるようになっていました。すごい! https://github.com/docker/metadata-action/pull/78 

2021年05月17日
2021年05月10日

VoiceMeeter Bananaだけでポン出しをする

ミーティングやLT会でポン出しがしたい

昨今の社会情勢から、オンライン上で会議をしたり、LT会に参加したりということが増えてきました。そうすると、たまに拍手音などのちょっとした効果音を流したいという場面も出てきます。そのような「ポン出し」を手軽にできる方法が無いものでしょうか。

VoiceMeeter Banana

そこで、VoiceMeeter Bananaというソフトウェアを使います。

VB-Audio VoiceMeeter Banana (公式サイト)

VoiceMeeter Bananaは仮想ミキサーソフトウェアで、配信用途でその使用方法を紹介している記事が沢山あります。そのようなものを見たことがある方もいらっしゃるでしょう。以下にVoiceMeeter Bananaの紹介や使い方について記載されている記事をいくつか記載しました。

他のアプリと組み合せたポン出し

リモート飲み会やテレビ会議でBGMや効果音を流して和もう! (ソフトウェアミキサーで遊ぶ)

上記の記事にもあるように、VLCなどのメディアプレイヤーとVoiceMeeter Bananaを組み合わせればポン出しは可能です。

しかしVLCを使った場合だと、複数の音を出し分けたい場合に、その種類の数だけウィンドウを開く必要があり少々不便でした。

Macro Buttonsで音を再生する

ところでVoiceMeeter BananaにはMacro Buttonsという機能があります。VoiceMeeter Bananaをインストールしたときに同時にインストールされるものです。

Macro Buttons

このMacro Buttonsでは様々な操作を行うことができます。どのようなことが可能なのかは公式のマニュアルに記載があります。

https://vb-audio.com/Voicemeeter/VoicemeeterBanana_UserManual.pdf

このなかの Recorder.load を使用することで音声を流すことができます。この動画の1:30あたりからMacro Buttonsの使い方についての説明が始まります。

せっかくなのでどのように使用するか、ここでも書いていこうと思います。

まずMacro Buttonsを起動し、どれかのボタンを右クリックすることで設定画面が開きます。

Macro Buttonsの設定画面を開く

ここで、"Request for Button ON / Trigger IN:“ に Recorder.load = "ここに音声ファイルの場所" と書きます。

Macro Buttonsの設定例

音声ファイルの場所はエクスプローラーからコピーするのが手軽でしょう。

explorerからpathのコピー

そして、このように設定したMacro Buttonをクリックすると、VoiceMeeter Banana上のカセットテープの部分にファイルが読み込まれて再生される様子が確認できます。

ポン出しの様子

注意点として、一度に再生できる音声はひとつだけのようなので、何度もクリックしてエコーがかかったような状態にすることはできません。

最後に

この記事ではMacro Buttonsで音声を流す方法について触れましたが、押している間だけマイクをミュートするといったこともできるようです。VoiceMeeter Bananaの説明書には、もっと色々な機能が記載されています。英語ではありますが、解説記事だけではなく一度説明書を読んでみることをオススメします。(僕はそれでMacro Buttonsの使い方を知りました)

また、VoiceMeeter BananaはDonationwareということもあり無料で使うことができますが、活用しているという方は是非寄付をしてみてはいかがでしょうか。

2021年05月10日
2021年05月10日

電子辞書 'Brain' 上でRuby 3.0をビルドするのには○○時間かかる

Brain上ではLinuxが動きます、つまり

puhitakuさんが、SHARPの電子辞書 「Brain」 上で動作するLinux distribusion 「Brainux」を開発、公開しています。

Linuxが動くと、色々なことができるようになります。

そこで、普段Rubyを使っている僕はあることを思いつきました。

「電子辞書上のLinuxでRubyをビルドすると何時間かかるんだろう?」

これってトリビアになりませんか?

やってみる

はじめに参考情報として、GCP e2-medium (vCPU 2, Mem 4GB) でbuildした場合は11分かかりました。

me@ruby-build:~$ time rbenv install 3.0.1
Downloading ruby-3.0.1.tar.gz...
-> https://cache.ruby-lang.org/pub/ruby/3.0/ruby-3.0.1.tar.gz
Installing ruby-3.0.1...
Installed ruby-3.0.1 to /home/me/.rbenv/versions/3.0.1


real    11m5.652s
user    16m57.434s
sys     1m57.655s

物理的な準備

という訳で、まずは電子辞書を入手することにしました。

メルカリで中古のBrain PW-SH2-B を購入し、OTGアダプタやmicroSDも合わせて購入して、以下のような環境を構築しました。

物理準備の様子

OS imageの準備

Brainuxのイメージは、 https://github.com/brain-hackers/buildbrain/releases でビルド済みのものが配布されています。ただ試すだけであればこれをmicroSDに焼くだけで済みます。しかし、これからRubyのbuildや、そのために必要なパッケージのインストール、他にも様々なことを試して遊ぼうとすると、配布されているOS含め3GBのイメージというのは少々心許ないです。 というのも、BrainuxはRaspberry Piのような起動時の容量自動拡張機能がまだ用意されていないため、microSDの空き容量一杯までFSを伸張されることがなく、焼いたimageの容量以上に使用することができません。

そこで「自分でビルドする」という選択が出てきます。これは一見ハードルが高そうですが、 https://github.com/brain-hackers/buildbrain をcloneしてREADMEに従いmakeを何度か実行するだけでimageは出来上がるので簡単です。(時間はそれなりにかかります)

また、この過程において、 image/build_imaege.sh を次のように変更することで、容量を自由に設定することができます。今回は6GB程度にしてみました。

diff --git a/image/build_image.sh b/image/build_image.sh
index 73aaf85..9b07fa5 100755
--- a/image/build_image.sh
+++ b/image/build_image.sh
@@ -25,7 +25,7 @@ for i in $(seq 1 7); do
     esac
 done

-dd if=/dev/zero of=${IMG} bs=1M count=3072
+dd if=/dev/zero of=${IMG} bs=1M count=6000 # ここをいじるだけ!

 START1=2048
 SECTORS1=$((1024 * 1024 * 64 / 512))

Swap領域の用意

build中に何度かメモリ不足によって失敗したので、多めにSwap領域を用意します。これも公式の導入に載っているものをそのまま実行すれば完了です。

https://github.com/brain-hackers/README/wiki/Tips>Swap

$ sudo dd if=/dev/zero of=/var/swap bs=1M count=2048
$ sudo mkswap /var/swap
$ sudo swapon /var/swap

ここでは2GB用意しましたが、buildの過程を見ていた限りでは公式の通り256MB程度でよさそうに見えました。

### ca-certificatesの用意 現時点での最新版リリースでは、起動したばかりの状態では証明書に問題があるためにHTTPS通信ができず、Rubyのソースコードをダウンロードすることができません。なので、公式のScrapboxにTODOとして記載されているコマンドを実行します。

https://scrapbox.io/brain-hackers/Brainux_TODO

$ sudo wget -O /etc/ssl/certs/cacert.pem http://curl.se/ca/cacert.pem
$ sudo update-ca-certificates -f

rbenv、ruby-buildの準備

Rubyをbuildするために、rbenvとruby-buildを導入します。Rubyそのもののソースコードをcloneしないのは、このほうがRubyのversion切り替えなどで楽なためです。

$ git clone https://github.com/rbenv/rbenv.git ~/.rbenv
$ git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
# 公式記載の手順を諸々やっていく

ビルド、開始

それでは、timeコマンド経由でbuildして、どのくらい時間がかかるか見てみます。

$ time rbenv install 3.0.1

ビルド時間は12時間を突破し……

途中経過

終わりました。

終わり

なんと839分もかかりました。

終わり ズームイン

839分、わかりやすく書けば 13時間59分 となります。

$ time rbenv install 3.0.1
Downloading ruby-3.0.1.tar.gz...
-> https://cache.ruby-lang.org/pub/ruby/3.0/ruby-3.0.1.tar.gz
Installing ruby-3.0.1...
Installed ruby-3.0.1 to /home/me/.rbenv/versions/3.0.1


real    839m45.175s
user    689m25.604s
sys     40m30.894s

ということで、 「電子辞書上のLinuxでRubyをビルドするのにかかる時間は"13時間59分"」 です。

参考情報

このBrain上で cat /proc/cpuinfo した結果は以下になります。

$ cat /proc/cpuinfo
proceccor       : 0
model name      : ARM926EJ-S rev 5 (v51)
BogoMIPS        : 119.70
Features        : swp half thumb fastmult edsp java
CPU implementer : 0x41
CPU architecture: 5TEJ
CPU variant     : 0x0
CPU part        : 0x926
CPU revision    : 5

HArdware        : Freescale MXS (Device Tree)
Revison         : 0000
Serial          : 0000000000000000

また /sys/kernel/debug/clk/clk_summary より、CPU clockは240MHzでした。

ビルド時間はわかったけど、どのくらいの速度で動くの?

ここまでの内容を Omotesando.rb #62 で発表したところ、「Ruby自体はどのくらいの性能が出るものなんですか?」という質問がありました。そこで、性能評価をすることにしました。

どうやってベンチマークを取るか

Ruby自体のbuildのように、一般的なマシンである速度で動くRubyのプログラムが、Brain上だとどのくらいの速度で動くのか?というのを比較できるような指標が欲しいところです。

そこで、「Ruby 3x3」宣言の指標として使われた、Optcarrotを動かしてみることにしました。

Optcarrotは、Ruby core teamのmameさんが開発したNES Emulatorです。動作させたときのfps値を性能の指標として使用することができます。

やってみる

まずは冒頭での記載と同様、GCP e2-medium (vCPU 2, Mem 4GB) 上で実行してみました(同一インスタンス)

$ ruby -v -Ilib -r./tools/shim bin/optcarrot --benchmark examples/Lan_Master.nes
ruby 3.0.1p64 (2021-04-05 revision 0fb782ee38) [x86_64-linux]
fps: 28.59686607413079
checksum: 59662

$ ruby -v -Ilib -r./tools/shim bin/optcarrot --benchmark --opt examp
les/Lan_Master.nes
ruby 3.0.1p64 (2021-04-05 revision 0fb782ee38) [x86_64-linux]
fps: 87.00124542792264
checksum: 59662

結果、最適化前で28.5fps、最適化後で87.0fpsまで出ています。

これをBrain上で動かしてみた結果がこちらになります。

Optcarrot 結果

$ ruby -v -Ilib -r./tools/shim bin/optcarrot --benchmark examples/Lan_Master.nes
ruby 3.0.1p64 (2021-04-05 revision 0fb782ee38) [armv5tejl-linux-eabi]
fps: 0.11981534177902647
checksum: 59662

$ ruby -v -Ilib -r./tools/shim bin/optcarrot --benchmark --opt examples/Lan_Master.nes
ruby 3.0.1p64 (2021-04-05 revision 0fb782ee38) [armv5tejl-linux-eabi]
fps: 0.5181845210009312
checksum: 59662

最適化前で 0.11 fps、 最適化後で 0.51 fpsとなりました。

ということで、「電子辞書上のRubyでOptcarrotを動かすと出るfpsは最適化後で0.5fps」 です。

おわりに

こうしてこの世界にまた2つ新たなトリビアが生まれたわけですが、Rubyはaptからインストールできるのに何故わざわざビルドするのでしょうか。

理由として、現在のBrainuxがベースとしているのがDebian busterであり、apt経由でインストールされるRubyは2.5と古いものになっています。

Debian – buster の ruby パッケージに関する詳細

Ruby 2.5はもう Ruby core teamのメンテナンス対象から外れたEOLなバージョンということもあり、できるだけ最新のものを使うことが推奨されます。

このリリースをもって、Ruby 2.5 系列は EOL となります。 即ち、Ruby 2.5.9 が Ruby 2.5 系列の最後のリリースとなります。 これ以降、仮に新たな脆弱性が発見されても、Ruby 2.5.10 などはリリースされません。 ユーザーの皆様におかれましては、速やかに、より新しい 3.0、2.7、2.6 といったバージョンへの移行を推奨します。 https://www.ruby-lang.org/ja/news/2021/04/05/ruby-2-5-9-released/

となると、自分でビルドするしかないですね!

また、この記事でちょこちょこ言及しているように、Brainux自体にはまだまだ改善の余地が転がっている状態です。Brainは中古で数千円程度で入手できるので、みなさんもBrainuxで遊んでみてはいかがでしょうか。

2021年05月10日
2021年04月01日

S3は巨大なKVSなのでRailsのCache storeとしても使える

s3_cache_store

S3 is a Key-Value store

Amazon S3 は、一意のキー値を使用して、必要な数のオブジェクトを保存できるオブジェクトストアです。

Amazon S3 オブジェクトの概要 - Amazon Simple Storage Service


Amazon S3の基礎技術は、単純なKVS(Key-Value型データストア)でしかありません。

Amazon S3における「フォルダ」という幻想をぶち壊し、その実体を明らかにする | DevelopersIO

Amazon S3の実体はKey-Value storeという事実は、既にご存知の方々にとっては何を今更というようなことではありますが、それでも初めて聞くときには驚かされたものです。

さて、Key-Value storeと聞いて一般的に馴染みが深いのはRedisでしょう。そして、RailsにおけるRedisの役割としてCache storeがあります。

2.6 ActiveSupport::Cache::RedisCacheStore Redisキャッシュストアは、メモリ最大使用量に達した場合の自動エビクション(喪失: eviction)をサポートすることで、Memcachedキャッシュサーバーのように振る舞います。

Rails のキャッシュ機構 - Railsガイド

ここで、あるアイデアが降りてきます。

「S3がKey-Value storeであるならば、Cache storeとしてS3を使うこともできるのではないか?」

それでは、実際に ActiveSupport::Cache::S3CacheStore の実装をやってみましょう。

Cache Storeを新規に作成する

そもそも、Cache storeを新規に作成することはできるのか、できるならどのようにすればいいのでしょうか。

activesupport/lib/active_support/cache.rb には、以下のような記述があります。

https://github.com/rails/rails/blob/v6.1.3.1/activesupport/lib/active_support/cache.rb#L118-L122

# An abstract cache store class. There are multiple cache store
# implementations, each having its own additional features. See the classes
# under the ActiveSupport::Cache module, e.g.
# ActiveSupport::Cache::MemCacheStore. MemCacheStore is currently the most
# popular cache store for large production websites.

つまり抽象クラスであるところの ActiveSupport::Cache::Store を継承し、必要なmethodを実装することにより作成できそうです。

新規に実装する必要のあるmethodは何でしょうか。コメントを読み進めていくと、以下のような記述が見つかります。

https://github.com/rails/rails/blob/v6.1.3.1/activesupport/lib/active_support/cache.rb#L124-L125

# Some implementations may not support all methods beyond the basic cache
# methods of +fetch+, +write+, +read+, +exist?+, and +delete+.

とあるように、 fetchwritereadexist?delete の実装をすればいいのでしょうか。もっと読み進めると、以下のような記述と実装があります。

https://github.com/rails/rails/blob/v6.1.3.1/activesupport/lib/active_support/cache.rb#L575-L585

# Reads an entry from the cache implementation. Subclasses must implement
# this method.
def read_entry(key, **options)
  raise NotImplementedError.new
end

# Writes an entry to the cache implementation. Subclasses must implement
# this method.
def write_entry(key, entry, **options)
  raise NotImplementedError.new
end

先程列挙した fetchwrite も内部では read_entry などを呼び出すようになっており、実際にはこれらのmethodを定義すればよさそうということがわかります。他にも Subclasses must implement this method. とされているmethodを列挙すると、以下のものについて実装する必要があることがわかりました。

S3の制限

ということで、まずは愚直に実装してみました。 read_entry の実装のみ抜き出すと、以下のようになります。

def read_entry(key, options)
  raw = options&.fetch(:raw, false)
  resp = @client.get_object(
    {
      bucket: @bucket,
      key: key
    })
  deserialize_entry(resp.body.read, raw: raw)
rescue Aws::S3::Errors::NoSuchKey
  nil
end

これは一見うまくいくように見えます。そこで、既存のテストケースを新規に実装したCache classを対象に実行してみると、次のようなメッセージで落ちるようになりました。

落ちる

落ちているテストの実体は以下です。

https://github.com/rails/rails/blob/v6.1.3.1/activesupport/test/cache/behaviors/cache_store_behavior.rb#L475-L483

def test_really_long_keys
  key = "x" * 2048
  assert @cache.write(key, "bar")
  assert_equal "bar", @cache.read(key)
  assert_equal "bar", @cache.fetch(key)
  assert_nil @cache.read("#{key}x")
  assert_equal({ key => "bar" }, @cache.read_multi(key))
  assert @cache.delete(key)
end

Cache keyの名前として2048文字のものを登録しようとしています。

ここで改めてAmazon S3のドキュメントを読むと、以下のような制限があることがわかりました。

キー名は一続きの Unicode 文字で、UTF-8 にエンコードすると最大で 1,024 バイト長になります。 オブジェクトキー名の作成 - Amazon Simple Storage Service

ということで、基本的なテストケースを通過させることがS3の制限上できません。

……というような話を Omotesando.rb #60でしたところ、「SHA256などでHash化するとどうか」というアイデアを頂きました。

def read_entry(key, options)
  raw = options&.fetch(:raw, false)
  resp = @client.get_object(
    {
      bucket: @bucket,
      key: ::Digest::SHA2.hexdigest(key),
    })
  deserialize_entry(resp.body.read, raw: raw)
rescue Aws::S3::Errors::NoSuchKey
  nil
end

そこで、このようにCache keyとして一度SHA2を通すことにより、cache key長の制限は回避することができました。

一部通るようになった

どこまでがんばるか

ここまで実行してきたテストは、 activesupport/test/cache/behaviors/cache_store_behavior.rb がその実体となります。

Cache storeのテストは、各storeについてのテストが activesupport/test/cache/stores/ 以下にあり、それらの内部で activesupport/test/cache/behaviors/ 以下にある程度まとめられた振る舞いをincludeすることによってstoreに実装されている振る舞いをテストする、という構造になっています。 例を挙げると、 RedisCacheStoreTestMemCacheStoreTest では EncodedKeyCacheBehavior をincludeしていますが、 FileStoreTest ではそうではありません。

ここでは一旦、およそ基本的な振る舞いのテストとなっているであろう CacheStoreBehavior の完走を目指して実装していきます。

Key長の課題を解決した時点で、失敗しているテストは以下3つです。

このうち、 test_delete_multitest_crazy_key_characters については実装を少し修正することによってテストが通るようになりました。しかし、 test_expires_in はそうもいきません。

test_expires_in をどうするか

このテストの内容は以下です。

https://github.com/rails/rails/blob/v6.1.3.1/activesupport/test/cache/behaviors/cache_store_behavior.rb#L392-L407

def test_expires_in
  time = Time.local(2008, 4, 24)

  Time.stub(:now, time) do
    @cache.write("foo", "bar")
    assert_equal "bar", @cache.read("foo")
  end

  Time.stub(:now, time + 30) do
    assert_equal "bar", @cache.read("foo")
  end

  Time.stub(:now, time + 61) do
    assert_nil @cache.read("foo")
  end
end

ここでは、Cacheの内容が指定した時間にちゃんとexpireされるかどうかを確認しています。テスト時に、各Cache storeにoptionとして expires_in を60として渡しており、その時間が経過した後にCache keyがexpireされて nil が返ってくることを確認しています。

このテストが落ちてしまっているのは、 Aws::S3::Errors::RequestTimeTooSkewed という例外が発生しているためで、これはリモートのS3とリクエストを送信しているローカルマシンの時刻が大幅にずれているために発生するものです。

テスト内で Time.now をstubし、2008年4月24日にリクエストを送信するようなテストになっているので、これをそのままなんとかするのは非常に困難 1 です。

では、テスト側を変更するのはどうでしょう。Too skewedで怒られるのであれば、tooでなければいいと思いませんか?

ということで、以下のように変更しました。(ある程度の余裕を持たせて120秒戻しました)

   def test_expires_in
-    time = Time.local(2008, 4, 24)
+    time = Time.current - 120.seconds

     Time.stub(:now, time) do
       @cache.write("foo", "bar")

こうすると、 CacheStoreBehavior に定義されているテストは全てpassしました。

通るようになった

gemにする

ここまでは既存のテストの使い回しなどの都合上、rails/rails の内部に実装していましたが、どうせならgemにしてしまいましょう。

https://github.com/unasuke/s3_cache_store

してしまいました。rubygems.orgでhostするほどのものでもないなと感じたので、使用したい場合は直接GitHubのURLを指定するようにしてください。

Pros/Cons

下らないひらめきがきっかけで実装したこのS3CacheStoreですが、既存のCache storeに対して優位となる点があるかどうか考えてみます。

Pros: 高い可用性

S3の可用性は「99.99%を提供する」とされており2、これは月間で4分程度、年間で1時間に満たない程度のダウンタイムが発生する程度です。これはRedisCacheStoreのバックエンドに同じAWSのElastiCacheを採用した場合のSLAと比較3すると、ElastiCacheは99.9%なので1ケタ高い可用性を持ちます。

S3は、S3CacheStoreを使用するRails appよりはるかに高い可用性を持つことは明らかでしょう。

Pros: (事実上)無限のCache storage

無限ではないですが……少なくともRedisやMemcachedをCache storeにした場合、S3はそれらよりはるかに大容量のCache storageとして振る舞うことができます。 例えばRedisの基本的なdata typeであるStringsは、サイズの上限が512MBとなっています4が、S3では1つのオブジェクトの最大サイズは5TBです5

そんなでかいサイズのものをCacheとして保存する意味はわかりませんが、とにかく大きなデータについてもCacheできます。

Cons: 遅い

……と言い切ってしまいましたが、本当に遅いのでしょうか?試してみましょう。

https://github.com/unasuke/s3_cache_store/blob/master/benchmark.rb

def bench_redis_cache_store
  store = ActiveSupport::Cache::RedisCacheStore.new({
    url: 'redis://localhost:6379'
  })
  redis_start = Time.now
  (1..COUNT).each do |e|
    store.write(e, e)
    store.read(e)
  end
  redis_duration = Time.now - redis_start
  puts "RedisCacheStore duration: #{redis_duration} sec (#{redis_duration / COUNT} s/key)"
end

def bench_s3_cache_store
  store = ActiveSupport::Cache::S3CacheStore.new({
    access_key_id: 'minioadmin',
    secret_access_key: 'minioadmin',
    region: 'us-east-1',
    endpoint: 'http://127.0.0.1:9000',
    force_path_style: true,
    bucket: BUCKET
  })
  s3_start = Time.now
  (1..COUNT).each do |e|
    store.write(e, e)
    store.read(e)
  end
  s3_duration = Time.now - s3_start
  puts "S3CacheStore duration: #{s3_duration} sec (#{s3_duration / COUNT} s/key)"
end

重要な部分のみ切り出したものを上に貼りました。

検証には、どちらもlocalのDockerで起動したRedisとMinIOを使用しました。では実行してみましょう。

$ ruby benchmark.rb
Fetching gem metadata from https://rubygems.org/.............
Fetching gem metadata from https://rubygems.org/.
# ...snip...

===== start benchmark ==========
RedisCacheStore duration: 1.2188961 sec (0.0012188961 s/key rw)
S3CacheStore duration: 9.5209312 sec (0.009520931199999999 s/key rw)
===== end benchmark ============

# ...snip...

何度か実行しても8~9倍の開きがあることがわかりました。よって、遅いですね。MinIOを使用しているのでS3とElastiCacheではまた違った結果になることが予想できますが、localhostに閉じた通信ですらこのような速度差が出ることを考えると、Cache storeとしては実用的ではないでしょう。

まとめ

Amazon S3の内部実装は Key-Value Storeです。


  1. ですよね? 

  2. https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/DataDurability.html 

  3. 実際にはS3のSLAはElastiCacheと同様に99.9%なので、SLAの範囲で比較すると差はないことになります。 https://aws.amazon.com/jp/s3/sla/https://aws.amazon.com/jp/elasticache/sla/ より 

  4. https://redis.io/topics/data-types#strings 

  5. https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/UsingObjects.html 

2021年04月01日
2021年03月29日

distroless imageを実用する

build pyton dinstroless image

distroless image

distrolessは、Googleが提供している、本当に必要な依存のみが含まれているcontainer imageです。そこにはaptはおろかshellも含まれておらず、非常にサイズの小さいimageとなっています。余計なプログラムが含まれていないことは attack surfaceの縮小にも繋がり、コンテナのセキュリティについての事業を展開しているSysdig社が公開しているDockerfileのベストプラクティスとしてもdistroless imageを使うことが推奨されています。

Dockerfileのベストプラクティス Top 20 | Sysdig

軽量Dockerイメージに安易にAlpineを使うのはやめたほうがいいという話 - inductor’s blog

また先日、inductorさんがこのようなブログ記事を書き話題になりました。この記事からdistroless imageのことを知った方も多いと思います。その中で僕が趣味で作った distroless-ruby を取り上げてくださり、ありがたいことに僕の所有しているものの中で一番Star数が多いrepositoryになりました。

とはいえ申し訳ないことに、僕はRubyなどのスクリプト言語を使用する機会が多く、あまりdistrolessを活用してきませんでした。そこで、Rubyのdistroless imageを作成する過程で得た知見を元に、Pythonなど1バイナリで完結しないプログラムをdistrolessで動かす方法について調べてまとめました。

distrolessと共有ライブラリ

個人的に、distrolessはGoで書かれたプログラムとはとても相性が良いと考えています1

dostroless imageを使用するとき、例えばGoでバイナリを配置するだけの場合や、Pythonで共有ライブラリを静的にリンクした成果物をコンテナ内に配置できる場合は工夫しなくても build stage と copy stage を組み合せたmulti stage buildで済みます。

しかし、aptによってインストールした共有ライブラリを動的リンクする必要があったり、単純に外部のプログラムが必要な場合は提供されているdistroless imageをそのまま使うことができません。 「できない」と書いたものの、技術的には不可能ではありません。例えば $ sudo apt install foobar を実行した結果、追加されたファイルを列挙してdistroless image内に配置すればパッケージを使用することはできます。 でも、例えば 外部ライブラリが必要だとして、追加されるファイルが膨大な数になる場合はどうでしょうか。また。それらは特定のディレクトリにまとまっている訳でもないでしょう。

この記事では例として、orisano さん提供の「素のdistroless imageでは動かないPython script」を動かすことを考えてみます。

以下のような app.pyDockerfile は、そのままでは動きません。

import gmpy2
print("gmpy2")
FROM debian:buster-slim AS build
RUN apt-get update && apt-get install --no-install-suggests --no-install-recommends --yes python3-venv gcc libpython3-dev libmpfr-dev libmpc-dev
RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip
FROM build AS build-venv
RUN /venv/bin/pip install --disable-pip-version-check gmpy2
FROM gcr.io/distroless/python3-debian10
COPY --from=build-venv /venv /venv
COPY . /app
WORKDIR /app
ENTRYPOINT ["/venv/bin/python3", "app.py"]
$ docker run --rm distroless-python-test
Traceback (most recent call last):
  File "app.py", line 1, in <module>
    import gmpy2
ImportError: libgmp.so.10: cannot open shared object file: No such file or directory

ここで、最低限必要なライブラリを含んだdocker imageを作成するために、distrolessと同様にbazelを使用します。

以下のような WORKSPACE 及び BUILD を準備します。

workspace(name='python-test')
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")

git_repository(
    name = "rules_pkg",
    remote = "https://github.com/bazelbuild/rules_pkg.git",
    tag = "0.4.0"
)

load("@rules_pkg//pkg:deps.bzl", "rules_pkg_dependencies")
rules_pkg_dependencies()

load("@rules_pkg//deb_packages:deb_packages.bzl", "deb_packages")

http_file(
    name = "buster_archive_key",
    sha256 = "9c854992fc6c423efe8622c3c326a66e73268995ecbe8f685129063206a18043",
    urls = ["https://ftp-master.debian.org/keys/archive-key-10.asc"],
)

deb_packages(
  name = "depends_python_gmpy2_debian_buster",
  arch = "amd64",
  distro = "buster",
  distro_type = "debian",
  mirrors = ["https://ftp.debian.org/debian"],
  packages = {
    "libgmp10": "pool/main/g/gmp/libgmp10_6.1.2+dfsg-4_amd64.deb",
    "libmpc3": "pool/main/m/mpclib3/libmpc3_1.1.0-1_amd64.deb",
    "libmpfr6": "pool/main/m/mpfr4/libmpfr6_4.0.2-1_amd64.deb",
  },
  packages_sha256 = {
    "libgmp10": "d9c9661c7d4d686a82c29d183124adacbefff797f1ef5723d509dbaa2e92a87c",
    "libmpc3": "a73b05c10399636a7c7bff266205de05631dc4af502bfb441cbbc6af0a7deb2a",
    "libmpfr6": "d005438229811b09ea9783491c98b145c9bcf6489284ad7870c19d2d09a8f571",
  },
  pgp_key = "buster_archive_key",
)

http_archive(
    name = "io_bazel_rules_docker",
    sha256 = "95d39fd84ff4474babaf190450ee034d958202043e366b9fc38f438c9e6c3334",
    strip_prefix = "rules_docker-0.16.0",
    urls = ["https://github.com/bazelbuild/rules_docker/releases/download/v0.16.0/rules_docker-v0.16.0.tar.gz"],
)

load(
    "@io_bazel_rules_docker//repositories:repositories.bzl",
    container_repositories = "repositories",
)
container_repositories()

load("@io_bazel_rules_docker//repositories:deps.bzl", container_deps = "deps")
container_deps()

load(
    "@io_bazel_rules_docker//container:container.bzl",
    "container_pull",
)
container_pull(
  name = "python_distroless",
  registry = "gcr.io",
  repository = "distroless/python3-debian10",
  tag = "latest"
)
load("@depends_python_gmpy2_debian_buster//debs:deb_packages.bzl", "depends_python_gmpy2_debian_buster")
load("@io_bazel_rules_docker//container:container.bzl", "container_image")

container_image(
  name ="python_with_gmpy2_depends",
  base="@python_distroless//image",
  debs=[
    depends_python_gmpy2_debian_buster["libgmp10"],
    depends_python_gmpy2_debian_buster["libmpc3"],
    depends_python_gmpy2_debian_buster["libmpfr6"],
  ],
)

https://github.com/unasuke/distroless-with-additional-deps に同じものを用意しました。

このようなBazelによるbuild ruleを定義し、そのディレクトリで bazel run //:python_with_gmpy2_depends を実行すると、 bazel:python_with_gmpy2_depends というdocker imgaeが作成されます。これを FROM として指定した以下のDockerfileをbuild、runしてみると、gmpy2がちゃんと動くようになっているのが確認できます。

FROM debian:buster-slim AS build
RUN apt-get update && apt-get install --no-install-suggests --no-install-recommends --yes python3-venv gcc libpython3-dev libmpfr-dev libmpc-dev
RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip
FROM build AS build-venv
RUN /venv/bin/pip install --disable-pip-version-check gmpy2
# FROM gcr.io/distroless/python3-debian10
FROM bazel:python_with_gmpy2_depends
COPY --from=build-venv /venv /venv
COPY . /app
WORKDIR /app
ENTRYPOINT ["/venv/bin/python3", "app.py"]

このようにして作成したimageは、元々のdistroless imageに加えて1.8MBしかサイズが増えておらず、必要最低限の依存のみを追加することができています。(dlayerで調べてみるとわかります)

Python及びRubyはdistrolessに向いているのか?

僕が思うに、あまり向いていません。

PythonやRubyのようなscript言語は、実行時に必要となるライブラリ群をまとめて1つないし複数個のバイナリとして固めることができません。がんばれば実行時に必要なファイルを列挙することもできるでしょうが、その作業と実際の配置を行うのは困難です。

そのような労力を惜しまず、上記のようなBazel ruleを記述してdistroless imageを使うのか、それともslim imageでやっていくのかは、distrolessにする作業のコストと、image sizeとattack surfaceを減らすことにより得られるメリットを比較して判断することになると思います。

そして多くの場合において、slim imageを使うことが最適解になるのではないかとも思っています。

……あと、そもそもexperimental扱いですし。

おわりに

この記事はZennにクロスポストしました。内容について面白かった、参考になったなどのお気持ちを「サポート」として頂けると非常に嬉しいです。

https://zenn.dev/unasuke/articles/5ee6e2067ab1ba

おまけ musl libcとglibcの違いって何

軽量なイメージのベースとして使われることも多いAlpine Linuxは、標準CライブラリにGNU C Library(glibc)ではなくmusl libcを採用しています。glibcとmusl libcは完全に同じ動作をするものではなく、現にRubyにおいては以下のような報告があります。

musl公式FAQには以下のような記述があります。

Is musl compatible with glibc? Yes and no. At both the source and binary level, musl aims for a degree of feature-compatibility, but not bug-compatibility, with glibc.

(抄訳) muslはglibc互換ですか? はいであり、いいえでもあります。ソースでもバイナリのレベルでも、muslはglibcと機能面においてある程度の互換性に焦点を当てており、バグの互換性についてはそうではありません。

https://www.musl-libc.org/faq.html

具体的な差異については以下のwikiにまとめられています。

https://wiki.musl-libc.org/functional-differences-from-glibc.html

見出しを抜き出して列挙すると

このあたり、用語に詳しくはないので頓珍漢なことを言っているかもしれません。原典にあたることを強く推奨します。

……というようにglibcとmusl libcには挙動の差異が存在します。これについて、musl libcに単純に置き換えてもいいかどうかはしっかり検証を行ったうえで実行する必要があるでしょう。

参考URL


  1. Javaや.Netについては知識が不足しているためなんとも言えません 

2021年03月29日
2021年03月29日

Bazelを使ってRubyのdistroless imageを作る

docker layer of the distroless ruby image

きっかけ

元々、distroless-rubyは手作業で必要なファイルを抜き出して作成したものでした。

ただ、この方針ではsinatraなどRuby単体で完結するプログラムは手軽に動かすことはできても、外部に依存するものがあるプログラムを動かすことはできません。具体的な例を挙げるならPostgreSQLをRubyで使用するために必要なpq gemはlibpq-devをapt経由でインストールする必要がありますが、distroless image内にはaptが存在しないのでインストールすることができません。multi stage buildを使用し、aptによって追加されたファイルを持ってくることも出来無くはないですが、依存する共有ライブラリ全てに対してその作業を行うのはいささか手間がかかり過ぎます。

で、あるならば、本家distroless imageと同じくBazelによりdistroless-ruby imageを作るほうが色々と融通が効いて良いというものでしょう。ということで、作ってみます。

nodejsみたくわりとかんたんかと思ったけど、rubyはバイナリが入っているtar.gz提供してないみたいなので、ソースからコンパイルするか、インストーラーを使ってインストールするかになっちょうので、面倒。 https://t.co/dDIbWY5Oqf

— Ian Lewis (@IanMLewis) March 8, 2021

しかし、Google Cloudの中の方、Ianさんがこのように発言されていることから、一筋縄ではいかなそうであることが予想できますね。「コンテナ内部でRubyをbuildするのの何が面倒なのだ?」と僕も当初は考えましたが、 Googleが開発する最新ビルドツール「Bazel」を使ってみよう | さくらのナレッジ において、

イメージの作成時にコンテナ内で処理を実行することはできない

と記述されています。これが書かれたのは2016年ではあり現在は事情が変わっていることも予想されますが、やはり大変なのではないかという雰囲気を感じます。

やってみる

とはいえやってみましょう。

以下手順は2021年3月15日付近、環境はUbuntu 20.04 (focal) LTS (WSL2)、distroless本家のHEADが 84e71ef9eda0d の状態で行っています。

また、僕はdistrolessを趣味でやっており、Bazelについても初心者なので誤っている点があると思います。そのような点がありましたら、僕に連絡するかご自身で訂正する記事を公開してもらえたら嬉しいです。

Python2を準備する

Bazelとdistrolessのbuildにあたっては /usr/bin/env python が2系である必要があるので、手元の環境をそのようにします。新しめのubuntuでは $ sudo apt install python-is-python2 でそのようになります1

Bazelを準備する

distroless imageの構築にはBazelが必要なので、インストールします。

Installing Bazel on Ubuntu - Bazel 4.0.0

上記リンクにてubuntuの場合のインストール方法が記載されています。ただ、distrolessで使用しているBazelのversionは 3.4.1 なので、インストールに必要なコマンドの一連は以下のようになります。

$ sudo apt install curl gnupg
$ curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor > bazel.gpg
$ sudo mv bazel.gpg /etc/apt/trusted.gpg.d/
$ echo "deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8" | sudo tee /etc/apt/sources.list.d/bazel.list
$ sudo apt update && sudo apt install bazel-3.4.1
$ sudo ln -s /usr/bin/bazel-3.4.1 /usr/bin/bazel

distroless repositoryのclone

https://github.com/GoogleContainerTools/distroless/ を手元にcloneします。

ここからの手順はcloneしてきたdistroless directory下で実行しています。

updateWorkspaceSnapshots.sh を実行する

おもむろに $ ./updateWorkspaceSnapshots.sh します。

というのも、distroless imageのbuildの過程で最新のsecurity patchが適用されたdebian packageを取得するために、 snapshot.debian.org に対して checksums.bzl 内に記載してあるsnapshot versionを取得しにいくのですが、現在のHEADにおいてサーバーが不安定なのか取得に何度も失敗する状況2 3 なので、 updateWorkspaceSnapshots.sh を実行して最新のreleaseを取得しにいくように checksums.bzl を更新します。(最新の状態に更新してもしばしば失敗するので、そういうものだと思ったほうがいいのかもしれません)

また、これはupstreamにおいては毎月実行されているものなので、いずれ不要になる4でしょう。

bazel でcontainer imageを作成してみる

では実際にBazelで実行できるtaskを一覧するために、以下のコマンドを実行します。

$ bazel query ...
//package_manager:util_test
//package_manager:parse_metadata_test
//package_manager:dpkg_parser.par
//package_manager:dpkg_parser
//package_manager:version_utils
//package_manager:util
//package_manager:parse_metadata
//nodejs:nodejs14_debug_arm64_debian10
//nodejs:nodejs12_debug_arm64_debian9
//nodejs:nodejs12_debug_arm64_debian10
//nodejs:nodejs12_arm64_debian9
//nodejs:nodejs12_arm64_debian10
//nodejs:nodejs10_debug_arm64_debian9
............

637ものbuild対象がありました。言語とアーキテクチャとroot/nonrootなどの組み合わせがあるので膨大な量になります。

ひとまず、単純と思われるccについて確認してみるため、以下のコマンドを実行します。

$ bazel run //cc:debug_nonroot_amd64_debian10
INFO: Analyzed target //cc:debug_nonroot_amd64_debian10 (89 packages loaded, 6894 targets configured).
INFO: Found 1 target...
INFO: From ImageLayer base/static_nonroot_amd64_debian10-layer.tar:
Duplicate file in archive: ./etc/os-release, picking first occurrence
Target //cc:debug_nonroot_amd64_debian10 up-to-date:
  bazel-bin/cc/debug_nonroot_amd64_debian10-layer.tar
INFO: Elapsed time: 6.957s, Critical Path: 3.44s
INFO: 31 processes: 31 linux-sandbox.
INFO: Build completed successfully, 61 total actions
INFO: Build completed successfully, 61 total actions
Loaded image ID: sha256:c0003d5371b5168ece90447caee6fee576e3cc9ad89e3773386c5cd4448a60bb
Tagging c0003d5371b5168ece90447caee6fee576e3cc9ad89e3773386c5cd4448a60bb as bazel/cc:debug_nonroot_amd64_debian10

これにより、ローカルに bazel/cc:debug_nonroot_amd64_debian10 という docker imageができています5

$ docker image ls
REPOSITORY      TAG                                 IMAGE ID       CREATED         SIZE
bazel/cc        debug_nonroot_amd64_debian10        c0003d5371b5   51 years ago    22.2MB

このimageの中身をlayer可視化ツールのひとつ、 orisano/dlayer で見てみましょう。

$ docker save -o cc.tar bazel/cc:debug_nonroot_amd64_debian10
$ dlayer -f cc.tar

====================================================================================================
 1.8 MB          $ bazel build ...
====================================================================================================
 198 kB          etc/ssl/certs/ca-certificates.crt
  62 kB          usr/share/doc/tzdata/changelog.gz
  35 kB          usr/share/common-licenses/GPL-3

......snip (zoneinfoやca-certificates的な)

====================================================================================================
  15 MB          $ bazel build ...
====================================================================================================
 2.7 MB          usr/lib/x86_64-linux-gnu/libcrypto.so.1.1
 1.7 MB          lib/x86_64-linux-gnu/libc-2.24.so
 1.1 MB          lib/x86_64-linux-gnu/libm-2.24.so
 655 kB          usr/bin/openssl
 469 kB          usr/lib/x86_64-linux-gnu/gconv/libCNS.so
 443 kB          usr/lib/x86_64-linux-gnu/libssl.so.1.1
 236 kB          usr/lib/x86_64-linux-gnu/gconv/BIG5HKSCS.so

......snip (openssl的な)

====================================================================================================
 1.1 MB          $ bazel build ...
====================================================================================================
 1.1 MB          busybox/busybox
   0  B          busybox/[
   0  B          busybox/[[
   0  B          busybox/acpid

......snip (busybox的な)

====================================================================================================
 1.9 MB          $ bazel build ...
====================================================================================================
 1.6 MB          usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.22
 184 kB          usr/lib/x86_64-linux-gnu/libgomp.so.1.0.0
  93 kB          lib/x86_64-linux-gnu/libgcc_s.so.1
  56 kB          usr/share/gcc-6/python/libstdcxx/v6/printers.py

......snip (glibc的な)

このように、いくつかのlayerに分かれて必要なファイルがガッチャンコされているんだな、ということがわかります。

buildされるときの処理を追う

さて、container imageのbuildが成功したところで、今buildしたcc imageのbuild ruleを見てみます。

https://github.com/GoogleContainerTools/distroless/blob/84e71ef9eda0dd498687aa306e4a812ac477a8f8/cc/BUILD

package(default_visibility = ["//visibility:public"])

load("//base:distro.bzl", "DISTRO_PACKAGES", "DISTRO_SUFFIXES")
load("@io_bazel_rules_docker//container:container.bzl", "container_image")
load("//:checksums.bzl", "ARCHITECTURES")

# An image for C/C++ programs
[
    container_image(
        name = ("cc" if (not mode) else mode[1:]) + "_" + user + "_" + arch + distro_suffix,
        architecture = arch,
        base = "//base" + (mode if mode else ":base") + "_" + user + "_" + arch + distro_suffix,
        debs = [
            DISTRO_PACKAGES[arch][distro_suffix]["libgcc1"],
            DISTRO_PACKAGES[arch][distro_suffix]["libgomp1"],
            DISTRO_PACKAGES[arch][distro_suffix]["libstdc++6"],
        ],
    )
    for mode in [
        "",
        ":debug",
    ]
    for arch in ARCHITECTURES
    for user in [
        "root",
        "nonroot",
    ]
    for distro_suffix in DISTRO_SUFFIXES
]

これを見ると、modeごとに、architectureごとに、userごとに、distroごとにcontainer imageを作成していそうなことが読みとれます。そのcontainer imageには libgcc1libgomp1libstdc++6 が含まれていることも予想できます。DISTRO_PACKAGESDISTRO_SUFFIXbase/distro.bzl から、 ARCHITECTURESchecksums.bzl から来ていることもわかります。

ccについてはそれほど行数もないこともあり、大体の処理を把握できました。それではdistrolessなRubyを作成するにあたり、近いことをしていると予想できるPython3の場合を見てみます。147行と少し長いので、ここぞ!と思われる部分を抜き出します。

https://github.com/GoogleContainerTools/distroless/blob/84e71ef9eda0dd498687aa306e4a812ac477a8f8/experimental/python3/BUILD#L36-L72

container_image(
    name = ("python3" if (not mode) else mode[1:]) + "_root_" + arch + distro_suffix,
    architecture = arch,
    # Based on //cc so that C extensions work properly.
    base = "//cc" + (mode if mode else ":cc") + "_root_" + arch + distro_suffix,
    debs = [
        DISTRO_PACKAGES[arch][distro_suffix]["dash"],
        DISTRO_PACKAGES[arch][distro_suffix]["libbz2-1.0"],
        DISTRO_PACKAGES[arch][distro_suffix]["libc-bin"],
        DISTRO_PACKAGES[arch][distro_suffix]["libdb5.3"],
        DISTRO_PACKAGES[arch][distro_suffix]["libexpat1"],
        DISTRO_PACKAGES[arch][distro_suffix]["liblzma5"],
        DISTRO_PACKAGES[arch][distro_suffix]["libmpdec2"],
        DISTRO_PACKAGES[arch][distro_suffix]["libreadline7"],
        DISTRO_PACKAGES[arch][distro_suffix]["libsqlite3-0"],
        DISTRO_PACKAGES[arch][distro_suffix]["libssl1.1"],
        DISTRO_PACKAGES[arch][distro_suffix]["zlib1g"],
    ] + [DISTRO_PACKAGES[arch][distro_suffix][deb] for deb in DISTRO_DEBS[distro_suffix]],
    entrypoint = [
        "/usr/bin/python" + DISTRO_VERSION[distro_suffix],
    ],
    # Use UTF-8 encoding for file system: match modern Linux
    env = {"LANG": "C.UTF-8"},
    symlinks = {
        "/usr/bin/python": "/usr/bin/python" + DISTRO_VERSION[distro_suffix],
        "/usr/bin/python3": "/usr/bin/python" + DISTRO_VERSION[distro_suffix],
    },
    tars = [
        "//experimental/python2.7:ld_so_" + arch + "_cache.tar",
    ],
)
for mode in [
    "",
    ":debug",
]
for arch in ARCHITECTURES
for distro_suffix in DISTRO_SUFFIXES

base imageを先程見たccとし、debsにPython3が必要するpackageを、かつdistroごとに必要とされているdeb packagesを追加、環境変数の設定、entrypointの設定などを行っています。

ここで指定できるattributeは、以下にまとめられています。

https://github.com/bazelbuild/rules_docker#container_image-1

Rubyの distroless imageをbuildするためのruleを書いてみる

それでは、Python3のbuild ruleを参考にして、Rubyのものを書いてみます。

debian10(buster)とdebian9(stretch)において、Rubyをインストールするための情報は以下に記載されています。

ここから、ruby2.5 (debian10の場合) をインストールするために必要な依存パッケージを全て列挙6し、そのうちまだ記載されていないものを WORKSPACEdpkg_list に追加し、 updateWorkspaceSnapshots.sh を実行して package_bundle_{architecture}_debian{9,10}.versions を更新します。おそらくこれで、追加したdeb packageをこのworkspace以下でcontainer imageにインストールすることができるようになります。

https://github.com/unasuke/distroless/commit/b7a069e3ba4d8a

diff --git a/WORKSPACE b/WORKSPACE
index 3a16ab7..1e1256f 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -246,6 +261,23 @@ load(
             "python3-distutils",
             "python3.7-minimal",

+            #ruby
+            "libgdbm6",
+            "libgdbm-compat4",
+            "libncurses6",
+            "libruby2.5",
+            "libyaml-0-2",
+            "rake",
+            "ruby",
+            "rubygems-integration",
+            "ruby-did-you-mean",
+            "ruby-minitest",
+            "ruby-net-telnet",
+            "ruby-power-assert",
+            "ruby-test-unit",
+            "ruby-xmlrpc",
+            "ruby2.5",
+
             #dotnet
             "libcurl4",
             "libgssapi-krb5-2",

次に、debian9と10で異なるpackageをインストールしたい場合の差異を抜き出してまとめます。

https://github.com/unasuke/distroless/commit/a0de61991f750cc1a15

# distribution-specific deb dependencies
DISTRO_DEBS = {
    "_debian9": [
        "libgdbm3",
        "libncurses5",
        "libruby2.3",
        "libssl1.0.2",
        "libtinfo5",
        "ruby2.3",
    ],
    "_debian10": [
        "libgdbm6",
        "libgdbm-compat4",
        "libncurses6",
        "libruby2.5",
        "libssl1.1",
        "libtinfo6",
        "ruby-xmlrpc",
        "ruby2.5",
    ],
}

あとは共通して必要なdeb packageを container_imagedebs に列挙し、その他諸々を整えます。

https://github.com/unasuke/distroless/commit/a0de61991f750cc1a159

この時点で bazel query ... を実行すると、Rubyに関係するtaskが出現しています。

% bazel query ... | grep ruby
//experimental/ruby:ruby_nonroot_arm64_debian9
//experimental/ruby:ruby_root_arm64_debian9
//experimental/ruby:ruby_nonroot_arm64_debian10
//experimental/ruby:ruby_root_arm64_debian10
//experimental/ruby:ruby_nonroot_amd64_debian9
//experimental/ruby:ruby_root_amd64_debian9
//experimental/ruby:ruby_nonroot_amd64_debian10
//experimental/ruby:ruby_root_amd64_debian10
//experimental/ruby:debug_nonroot_arm64_debian9
//experimental/ruby:debug_root_arm64_debian9
//experimental/ruby:debug_nonroot_arm64_debian10
//experimental/ruby:debug_root_arm64_debian10
//experimental/ruby:debug_nonroot_amd64_debian9
//experimental/ruby:debug_root_amd64_debian9
//experimental/ruby:debug_nonroot_amd64_debian10
//experimental/ruby:debug_root_amd64_debian10

では、 $ bazel build //experimental/ruby:all でこれらを全部buildしてみます。

$ bazel build //experimental/ruby:all
INFO: Build option --host_force_python has changed, discarding analysis cache.
INFO: Analyzed 16 targets (61 packages loaded, 7043 targets configured).
INFO: Found 16 targets...
INFO: From ImageLayer base/static_root_arm64_debian10-layer.tar:
Duplicate file in archive: ./etc/os-release, picking first occurrence
INFO: From ImageLayer base/static_root_arm64_debian9-layer.tar:
Duplicate file in archive: ./etc/os-release, picking first occurrence
INFO: From ImageLayer base/static_root_amd64_debian10-layer.tar:
Duplicate file in archive: ./etc/os-release, picking first occurrence
INFO: From ImageLayer base/static_root_amd64_debian9-layer.tar:
Duplicate file in archive: ./etc/os-release, picking first occurrence
INFO: Elapsed time: 8.261s, Critical Path: 4.47s
INFO: 161 processes: 161 linux-sandbox.
INFO: Build completed successfully, 267 total actions

buildに成功しました。動作検証のため、amd64、debug、debian10のimageを作成します。

$ bazel run //experimental/ruby:debug_nonroot_amd64_debian10
INFO: Analyzed target //experimental/ruby:debug_nonroot_amd64_debian10 (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //experimental/ruby:debug_nonroot_amd64_debian10 up-to-date:
  bazel-bin/experimental/ruby/debug_nonroot_amd64_debian10-layer.tar
INFO: Elapsed time: 0.404s, Critical Path: 0.27s
INFO: 0 processes.
INFO: Build completed successfully, 1 total action
INFO: Build completed successfully, 1 total action
765a3652e862: Loading layer [==================================================>]  22.73MB/22.73MB
84ff92691f90: Loading layer [==================================================>]  10.24kB/10.24kB
Loaded image ID: sha256:881bad115265f80c3b74ddfc054c05958ad4c8ac0d87d9020fd0a743039a9bd2
Tagging 881bad115265f80c3b74ddfc054c05958ad4c8ac0d87d9020fd0a743039a9bd2 as bazel/experimental/ruby:debug_nonroot_amd64_debian10

$ docker image ls
REPOSITORY                 TAG                              IMAGE ID       CREATED         SIZE
bazel/experimental/ruby    debug_nonroot_amd64_debian10     881bad115265   51 years ago    44MB

$ docker run -it --rm --entrypoint=sh bazel/experimental/ruby:debug_nonroot_amd64_debian10
~ $ which ruby
/usr/bin/ruby
~ $ ls -al /usr/bin/
total 780
drwxr-xr-x    1 root     root          4096 Jan  1  1970 .
drwxr-xr-x    1 root     root          4096 Jan  1  1970 ..
-rwxr-xr-x    1 root     root          6332 Jan  1  1970 c_rehash
lrwxrwxrwx    1 root     root             6 Jan  1  1970 erb -> erb2.5
-rwxr-xr-x    1 root     root          4836 Jan  1  1970 erb2.5
lrwxrwxrwx    1 root     root             6 Jan  1  1970 gem -> gem2.5
-rwxr-xr-x    1 root     root           545 Jan  1  1970 gem2.5
lrwxrwxrwx    1 root     root             6 Jan  1  1970 irb -> irb2.5
-rwxr-xr-x    1 root     root           189 Jan  1  1970 irb2.5
-rwxr-xr-x    1 root     root        736776 Jan  1  1970 openssl
-rwxr-xr-x    1 root     root          1178 Jan  1  1970 rake
lrwxrwxrwx    1 root     root             7 Jan  1  1970 rdoc -> rdoc2.5
-rwxr-xr-x    1 root     root           937 Jan  1  1970 rdoc2.5
lrwxrwxrwx    1 root     root             5 Jan  1  1970 ri -> ri2.5
-rwxr-xr-x    1 root     root           187 Jan  1  1970 ri2.5
lrwxrwxrwx    1 root     root             7 Jan  1  1970 ruby -> ruby2.5
-rwxr-xr-x    1 root     root         14328 Jan  1  1970 ruby2.5
~ $ whoami
nonroot
~ $ ruby -v
ruby 2.5.5p157 (2019-03-15 revision 67260) [x86_64-linux-gnu]

$

docker imageも正しく作成されていることがわかります。やりました!成功です!

それでは試しに、このimageを試しに使ってみましょう。以下のようなsinatra applicationを動かしてみます。

require 'sinatra'

set :bind, '0.0.0.0'

get '/' do
  'Hello World!'
end

ひとまず bazel run //experimental/ruby:ruby_nonroot_amd64_debian10 でbuildした、nonrootでbusyboxのないimageをつくり、 このようなDockerfileを書いてbuildしてみます。

FROM bazel/experimental/ruby:ruby_nonroot_amd64_debian10
WORKDIR /home/nonroot
RUN ["/usr/bin/gem", "install", "--user-install", "--no-document", "sinatra"]
COPY server.rb /home/nonroot/
ENV PORT=4567
CMD ["server.rb"]
$ docker build -t distroless-ruby-sinatra-test .
[+] Building 2.7s (9/9) FINISHED
 => [internal] load build definition from Dockerfile                                             0.0s
 => => transferring dockerfile: 264B                                                             0.0s
 => [internal] load .dockerignore                                                                0.0s
 => => transferring context: 2B                                                                  0.0s
 => [internal] load metadata for docker.io/bazel/experimental/ruby:ruby_nonroot_amd64_debian10   0.0s
 => [1/4] FROM docker.io/bazel/experimental/ruby:ruby_nonroot_amd64_debian10                     0.1s
 => [internal] load build context                                                                0.0s
 => => transferring context: 30B                                                                 0.0s
 => [2/4] WORKDIR /home/nonroot                                                                  0.0s
 => [3/4] RUN ["/usr/bin/gem", "install", "--user-install", "--no-document", "sinatra"]          2.2s
 => [4/4] COPY server.rb /home/nonroot/                                                          0.1s
 => exporting to image                                                                           0.1s
 => => exporting layers                                                                          0.1s
 => => writing image sha256:ef0b971fb3ea98f852a4e560075544e6215440eae2c6a4a75cae1044ae4788fb     0.0s
 => => naming to docker.io/library/distroless-ruby-sinatra-test                                  0.0s

$ docker run -it --rm -p 4567:4567 distroless-ruby-sinatra-test
[2021-03-21 07:57:09] INFO  WEBrick 1.4.2
[2021-03-21 07:57:09] INFO  ruby 2.5.5 (2019-03-15) [x86_64-linux-gnu]
== Sinatra (v2.1.0) has taken the stage on 4567 for development with backup from WEBrick
[2021-03-21 07:57:09] INFO  WEBrick::HTTPServer#start: pid=1 port=4567
172.17.0.1 - - [21/Mar/2021:07:57:25 +0000] "GET / HTTP/1.1" 200 12 0.0018
172.17.0.1 - - [21/Mar/2021:07:57:25 UTC] "GET / HTTP/1.1" 200 12
- -> /
$ curl http://localhost:4567
Hello World!

このようにbuildが成功し、リクエストも受け付けるようになりました!

さて、実際の開発においてはbundlerを使用することが一般的だと思うので、このimageにbundlerを追加してみます。

https://github.com/unasuke/distroless/commit/a0de61991f750cc1a159

bundlerを追加したimageでは、このようなDockerfileを書くことでsinatraを起動させることができるようになりました。

FROM ruby:2.5-buster as build
WORKDIR /app
RUN apt update  && apt install -y imagemagick
COPY Gemfile Gemfile.lock /app/
RUN bundle install --path vendor/bundle

FROM bazel/experimental/ruby:ruby_nonroot_amd64_debian10
WORKDIR /home/nonroot
RUN ["/usr/bin/gem", "install", "--user-install", "--no-document", "sinatra"]
COPY server.rb /home/nonroot/
COPY --from=build /app/vendor /home/nonroot/vendor
COPY --chown=nonroot:nonroot Gemfile Gemfile.lock .
ENV PORT=4567
ENTRYPOINT ["/usr/bin/bundle"]
RUN ["/usr/bin/bundle", "config", "set", "path", "vendor/bundle"]
CMD ["exec", "ruby", "server.rb"]

2.3と2.5じゃない、2.7や3.0のRubyを使いたいんだけど。

2.5以降のRubyを含むimageを作成することについても作業を進めていたのですがなかなか上手くいかず、成功を待っていると記事の公開がずるずると遅れてしまうので、成功し次第別で記事を公開します。

まとめ

やってみることによって、できました。

成果については unasuke/distroless の ruby branchにpushしてあります。

https://github.com/unasuke/distroless/tree/ruby

また、この記事はZennにクロスポストしました。内容について面白かった、参考になったなどのお気持ちを「サポート」として頂けると非常に嬉しいです。非常に労力がかかっているので……

https://zenn.dev/unasuke/articles/f51fa23483bec6


  1. 2020年2月21日号 focalの開発: PHP 7.4への切り替えとPython 2の去就:Ubuntu Weekly Topics|gihyo.jp … 技術評論社 

  2. snapshot.debian.org に国内mirrorがあれば切り替えて試したかったのですが、2019年時点で90TBものストレージを必要とするらしく、なかなかmirrorも用意できないですよね 

  3. snapshot.debian.org に地理的に近いサーバーで作業をすることをおすすめします。自分が試したときはオランダにサーバーを用意してビルドを実行しました。日本からだと、このスクリプトの実行に40分前後かかるところがオランダのサーバーだと17分程度で終わりました。 

  4. https://github.com/GoogleContainerTools/distroless/pulls?q=is%3Aclosed+author%3Aapp%2Fgithub-actions 

  5. CREATEDが 51 years ago になっています。docker inspectで情報を見ると、 "1970-01-01T00:00:00Z" とepoch timeになっているので、zero fillされているものと予想できます。Jibを使って作成したdocker imageもこうなるようですね。 

  6. 依存関係は apt-rdepends をインストールして $ apt-rdepends ruby2.5 などでも調べることができます。 https://packages.debian.org/ja/buster/apt-rdepends 

2021年03月29日
2021年02月14日

RubyとWebAssemblyの関係についてわかる範囲でまとめる

Artichoke

はじめに

2021年1月にv1.0がリリースされたWasmerにRuby Gemが存在することに触発されて調べてみました。RubyとWebAssemblyが関わっているものについてわかる範囲でまとめ、軽くどのようなものかを書いていきます。

僕自身、業務はおろかプライベートでもWASMを書いたことはなく浅い理解しかしていないですが……

WebAssembly (WASM)とは

WebAssembly は最近のウェブブラウザーで動作し、新たな機能と大幅なパフォーマンス向上を提供する新しい種類のコードです。基本的に直接記述ではなく、C、C++、Rust 等の低水準の言語にとって効果的なコンパイル対象となるように設計されています。

この機能はウェブプラットフォームにとって大きな意味を持ちます。 — ウェブ上で動作するクライアントアプリで従来は実現できなかった、ネイティブ水準の速度で複数の言語で記述されたコードをウェブ上で動作させる方法を提供します。 https://developer.mozilla.org/ja/docs/WebAssembly/Concepts

なんか、そういうやつです。

Ruby to WASM

Rubyを何らかの方法で最終的にWASM Bytecodeにコンパイルするものたちです。

blacktm/ruby-wasm

https://github.com/blacktm/ruby-wasm

紹介記事がTechRachoさんによって日本語訳されたので見覚えのある方もいるかと思います。

記事内に言及がありますが、以下のようにmrubyを経由して最終的にWASM binaryを生成します。

Ruby script → MRuby bytecode → C → emcc (Emscripten Compiler Frontend) → LLVM → Binaryen → WebAssembly

ここで登場するbinaryenですが、GitHubのWebAssembly org以下で開発されている、公式のtoolchainです。上記で行われているようなWebAssemblyへのコンパイルの他にも、wasm bytecodeからのunassemble(text formatへの変換)などの様々なツールが同梱されています。

https://github.com/WebAssembly/binaryen

Rlang

https://github.com/ljulliar/rlang

a Ruby-like language compiled to WebAssembly

Rubyのsubsetである “Rlang” からWASM bytecodeへのコンパイルを行うものです。このRlangとRubyの差異は以下にまとまっています。例えば整数型のサイズを32bitか64bitかを指定するsyntaxや、可変長引数が使用できないなどの違いがあります。

https://github.com/ljulliar/rlang/blob/master/docs/RlangManual.md

名前の由来として、C言語に変換できるSmalltalkのsubsetであるSlangからもじって付けられているようです。(そういう意味でRと名前が被っているのは仕方がないとも述べられています)

http://wiki.squeak.org/squeak/slang

prism-rb/prism

https://github.com/prism-rb/prism

Build frontend web apps with Ruby and WebAssembly

frontend web applicationをRubyとWebAssemblyで作成できるようにするframeworkです。Prism::Componentから継承したClassに記述されたapplication logicがEmscriptenによりWAMにコンパイルされて実行されるようです。

Ruby on WASM

WASM上でRubyを実行できるようにするものたちです。

Artichoke

https://www.artichokeruby.org

Bundle Ruby applications into a single Wasm executable with Artichoke, a Ruby made with Rust.

Rust実装によるRuby runtimeです。Ruby界隈ではArtichokeの名前を聞いたことのある方は多いと思います。RustはコードをWASMにコンパイルすることができるので、ArtichokeもWASMとして動かすことができます。 https://artichoke.run がPlaygroundなのですが、inspectorからWASMが動いている様子を観測できます。

Artichoke

runrb.io

ruby/rubyをEmscriptenで動かしているようです。もっと詳しく説明すると、独自patchを適用したRuby 2.6からEmscripten(emmake)でminirubyのbytecodeを生成しています。それをさらにEmscripten(emcc)でWASMにコンパイルしています。ここから先がちょっとよくわからなかったのですが、最終的にRubyをWASM bytecodeにしているのでしょうか?

https://github.com/jasoncharnes/run.rb/blob/d0f5cf9c954335795fca7c24760e728dbf47b425/src/emscripten/ruby-2.6.1/Dockerfile

PlaygroundでRubyを実行する度にworkerが生成されて別のbytecodeを実行しているようなのですが……RUBY_PLATFORMx86_64-linuxRUBY_VERSION2.6.1となっていました。Encoding.listの結果が少なかった1から実際に動いているのはminirubyなのかもしれません。

runrb.io

wruby

https://github.com/pannous/wruby

minirubyをEmscriptenでWASMにコンパイルしています。なんとmruby/mrubyをforkしています。

https://github.com/mruby/mruby/compare/master…pannous:master

mwc

https://github.com/elct9620/mwc

The tool for the developer to help them create mruby applications on the WebAssembly.

とのことですが、READMEからはイマイチどういうものかつかめなかったので、Create Projectに記載のあるmwc initが何をするか見てみたところ、<mruby.h>をincludeしてmrubyの機能を使用するC言語のコードをEmscriptenによりWASMへとコンパイルしているようでした。

https://github.com/elct9620/mwc/tree/master/lib/mwc/templates/app

mruby-L1VM

https://github.com/taisukef/mruby-L1VM

BASICが動く教育向け(でいいのか?)マイコンボードのIchigoJam上で動くmruby実装をWebAssemblyに移植したものです。mruby_l1vm.hがキモのようですね。

mruby on web - WebAssemblyのバイナリ4KB以下で動かす超軽量クライアントサイド用Ruby #ruby / 福野泰介の一日一創 / Create every day by Taisuke Fukuno

emruby

https://github.com/mame/emruby

作り込みはぜんぜんダメですが、仲間にいれてあげてください! 最近の ruby/ruby master を emscripten できるようにしてますhttps://t.co/7PvkHsZ0In

— Yusuke Endoh (@mametter) February 18, 2021

作者のmameさんから直接教えていただきました。 ruby/rubyにpatchを当てたものをEmscriptenによってWASMにコンパイルしています。(WASMにしているのはminiruby) https://mame.github.io/emruby/ で試すこともできます。最近作成されていることもあり、Rubyのversionが3.1.0devなのが凄いですね。

読み方は「いーえむるびー」であり、「えむるびー」ではないのに注意。この40行ほどのpatchでWASMにコンパイルできるんですね……

ありがとうございます!patch は、文字列を JS eval する emscripten API を生やすだけなので、なくてもコンパイルできるはずです

— Yusuke Endoh (@mametter) February 18, 2021

Emscriptenって、凄いですね。

Ruby本体 (2022-01-21追記)

2022年1月19日に、kateinoigakukunさんによってCRubyのplatformとしてWASIを対象としたコンパイルができるようになりました。kateinoigakukunさんは1月14日にMatzのapproveによってRuby commiterになられています。すごい……

これは2021年度Rubyアソシエーション開発助成によって支援されている取り組みでもあります。

2021年度Rubyアソシエーション開発助成金 公募結果発表

RubyプログラムをWebAssemblyバイナリにパッケージ化し、1バイナリでの配布を可能にすることも目指す

ともあります。とても期待が高まりますね。

実際にどういう風に試せるかについては、公式のドキュメント及びznzさんの記事が参考になります。

WASM runs by Ruby

RubyからWASMを実行できるようにするものです。

wasmer.io

冒頭で紹介したものです。Wasmerは、WebAssemblyをWebブラウザ外で実行できるランタイムです。wasmer gemはそのWasmerをRubyから扱えるようにするgemとなっており、以下のドキュメントから具体的な使い方を知ることができます。

https://www.rubydoc.info/gems/wasmer/

上記ドキュメントにあるように、例えばRustのコードをWASMにコンパイルしてRubyから実行する、ということができますね。

wasmtime-ruby

https://github.com/dtcristo/wasmtime-ruby

WasmtimeのRuby bindingとあります。Wasmtimeは、(Wasmerのように)あらゆるプラットフォームでのWASMの実行を可能にすることなどを目指すBytecode Allianceのプロジェクトのひとつで、WASM及びWASIの軽量ランタイムです。

require 'wasmtime/require' とすることで、WASMバイナリ及びテキスト表現を直接 requireして実行できるのは面白いですね。

WASM.rb

https://github.com/technohippy/wasmrb

Wasmerとは異なり、pure rubyのWASM処理系です。(languageで半数以上がWASMになっていますが、これはtest dir以下に含まれているものっぽい) また、WASM binaryをRubyのhashっぽくinspectしてくれる機能もあります。

TODO.md を見る限りではまだ未実装の仕様も多いですが、WASMの勉強には(まだ)コードの量も少なく勉強するのには良さそうです。

WASM to Ruby

WASMをRubyのコードに変換するものです。(逆アセンブル?)

edvakf/wagyu

https://github.com/edvakf/wagyu

READMEには

Wagyu aims to be a library to parse and execute Web Assembly binaries on Ruby.

と書かれていますが、2019年に行われたTama Ruby会議の発表によると、WASMのテキスト表現を一度Rubyのコードへ変換してから実行するようなアプローチになっています。

WebAssemblyを Rubyにコンパイルする 黒魔術コード完全解説 - Speaker Deck

その他

「こんなものもありますよ」と教えていただいたものの、現時点でWASMは使用されていなかったものです。

webruby

https://github.com/xxuejie/webruby

Rubyではなくmrubyではありますが、Web上でruby scriptを実行できるようにするものです。 http://joshnuss.github.io/mruby-web-irb/ から実際に試すことができます。

これはEmscriptenによってmrubyをJavaScriptに変換しているのみで、WASMは使用されていませんでした。

DXOpal

まず、RubyのコードをJavaScriptに変換するOpalというコンパイラがあります。例えばRuby公式Webサイトからリンクされている、Webブラウザ上でRubyを試すことのできる https://try.ruby-lang.org ではOpalが使われています。

そして、RubyからDirextXのAPIを利用することができ、RubyによってWindows向けにゲームを開発することのできるライブラリ、DXRubyというものがあります。

DXOpalは、そのDXRubyのAPIを「だいたいそのまま」移植してWebブラウザ上でゲームを開発できるようにしたライブラリです。

そのDXOpalですが、RubyKaigi 2017にて一部をWebAssmeblyにしてみたとの発表がありました。

Ruby, Opal and WebAssembly - Speaker Deck

発表では、RubyをWebAssemblyに移植する難しさについても言及されています。

しかし発表内でデモされていたWebAssembly実装ですが、現時点ではmasterにmergeされてはいないようでした。

追記

2021-02-18

emrubyについての記述を追加しました。

2021-02-22

「その他」を追加しました。

2021-04-13

WASM.rbについての記述を追加しました。

2021-07-28

誤字を修正しました。

2022-01-21

Ruby本体についての記述を追加しました。


  1. https://naruse.hateblo.jp/entry/20110118/1295345908 より 

2021年02月14日
新しい投稿
古い投稿