SendGridのv3 Mail Send APIのRuby Clientを書いた

年末で少し時間があったので、書こう書こうと思っていたSendGridのv3 Mail Sendを書きました。

V3 Mail Send API Overview - SendGrid Documentation | SendGrid

github.com

READMEのUsageを見ればわかるとおり、ほぼAPIに即した薄いクライアントです。

とりあえず、現状で公式クライアントは利用しにくいので、自作した感じです。 非公式とはいっても公式がスキーマを公開しているので、rakeタスクで公式のスキーマを取得して、メール送信ポストリクエストのボディーのバリデーションをJSON Schemaで行うようにしています。

require 'kani_laser'

client = KaniLaser::Client.new(api_key: 'ZAPZAPZAP')

# see https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html
client.send_mail(
  personalizations: [
    {
      to: [
        {
          email: 'john@example.com'
        }
      ],
      subject: 'Hello, World!'
    }
  ],
  from: {
    email: 'from_address@example.com'
  },
  content: [
    {
      type: 'text/plain',
      value: 'Hello, World!'
    }
  ]
)

で、公式クライアント

SendGrid公式のRubyクライアントは、あまりインターフェースがよいとは思えなくて

require 'sendgrid-ruby'
include SendGrid

from = SendGrid::Email.new(email: 'test@example.com')
to = SendGrid::Email.new(email: 'test@example.com')
subject = 'Sending with Twilio SendGrid is Fun'
content = SendGrid::Content.new(type: 'text/plain', value: 'and easy to do anywhere, even with Ruby')
mail = SendGrid::Mail.new(from, subject, to, content)

sg = SendGrid::API.new(api_key: ENV['SENDGRID_API_KEY'])
response = sg.client.mail._('send').post(request_body: mail.to_json)
puts response.status_code
puts response.body
puts response.parsed_body
puts response.headers
  • いくつかクラスを利用しているけれどすべてJSON生成のヘルパーで必須ではなく冗長
  • 送信用のメソッドが#_で、ちょっとわかりにくすぎると思う…(メタプログラミングを避けた? あー、sendメソッドが使いにくかったせいか)
  • method_missingを使ったかなり薄いラッパーでメソッドやパラメータのチェックが弱い。ヘルパークラスも空白などを除去しているだけでチェックの役に立っていない
  • 気になる書き方がいくつか(固定文字列のJSONをわざわざパースしてHashにしている・必須引数に空白のデフォルト値を設定している)

といった感じで、うーん…とクビをかしげる状態になっています。 v1からv2への変更で大幅に修正が入っていて、なぜこのようなわかりにくいインターフェースになったのか、ぜんぜん経緯を理解できていないです。v3 APIへの対応リソースが足りなかったんだろうか?

一方でOpen API specificationも公開していて、今回私の書いたKaniLaserはそこからschemaをとってきてリクエストパラメーターのJSONJSON Schemaでパリデーションするようにしています。

リクエストパラメーターのバリデーションができれば、インターフェースはシンプルになりそうですが、公式クライアントはなんか頑張っている?せいで煩雑です。なぜ

API定義はしっかりしているように見えるので、そこからどうとでもクライアントは生成できると思うのですが、なんでこんな状況になっているのかよくわからない…

わからない。全くわからない。


公式がちょちょいとやれば、メタ情報から各種言語のクライアントを自動生成できそうだけれど、なぜやってないんだろう? oas.jsonからクライアントを自動生成できそうだけど。 やっぱりリソースの問題かなぁ、Ruby優先度低いのかなぁ…うーん…

蛇足

https://sendgrid.api-docs.io/ を参照すればちょちょいのちょい…と思っていたんですが、oas.jsondefinitions、足りなくないですかね。email_objectとか。

最終的に https://github.com/sendgrid/sendgrid-oai/ のマスターをスキーマのソースにするようにしたんだけど、これ、正しいのかなぁ

カニレーザー

dic.pixiv.net

ridgepole: v0.7.6でignoreという属性をつけられるようにした

ridgeporeのv0.7.6でignoreという属性をカラム・インデックス・FKにつけられるようにした。

github.com


あんまり周知していなかったせいで、全然知られていないが、ridgepoleでもexecuteがつかえる。

https://github.com/winebarrel/ridgepole#execute

create_table "authors", force: :cascade do |t|
  t.string "name", null: false
end

create_table "books", force: :cascade do |t|
  t.string  "title",     null: false
  t.integer "author_id", null: false
end

add_index "books", ["author_id"], name: "idx_author_id", using: :btree

execute("ALTER TABLE books ADD CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES authors (id)") do |c|
  # Execute SQL only if there is no foreign key
  c.raw_connection.query(<<-SQL).each.length.zero?
    SELECT 1 FROM information_schema.key_column_usage
     WHERE TABLE_SCHEMA = 'bookshelf'
       AND CONSTRAINT_NAME = 'fk_author' LIMIT 1
  SQL
end

冪等性を担保するために少しだけ拡張していて、ブロック内の返り値がtrueの場合のみ、executeを実行するようにしている。 ブロックにはコネクションを渡しているので、存在チェックをすることで重複してexecuteが実行されないようにすることができる。

…が、たとえばN-gramのフルテキストインデックスのように、Railsが対応していないカラムやインデックスをexecuteで作成しようとするとめんどくさいことになる。

例えば

create_table "books", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
  t.string "title", null: false
  t.index ["title"], name: "ft_index", type: :fulltext
end

execute("CREATE FULLTEXT INDEX ft_index ON books(title) WITH PARSER ngram") do |c|
  rows = c.raw_connection.query('SHOW INDEX FROM books', as: :hash)
  !rows.map{|r| r.fetch('Key_name') }.include?('ft_index')
end

のような感じでインデックスを貼ろうと思うと、先に「N-gramではないインデックス」が作成されてしまって、「N-gramのインデックス」はexecuteでは作成されなくなる。

executeのブロック内のクエリで頑張って「N-gramではない場合は再作成」というようにすれば、「N-gramのインデックス」を貼ることはできるが、流石にめんどくさいので、カラムやインデックスにignoreという属性をもたせることにした。

ignoreを使うとこんな感じになる

# -*- mode: ruby -*-
# vi: set ft=ruby :
create_table "books", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
  t.string "title", null: false
  t.index ["title"], name: "ft_index", type: :fulltext, ignore: true
end

execute("CREATE FULLTEXT INDEX ft_index ON books(title) WITH PARSER ngram") do |c|
  rows = c.raw_connection.query('SHOW INDEX FROM books', as: :hash)
  !rows.map{|r| r.fetch('Key_name') }.include?('ft_index')
end

…先ほどとほぼ同じだけれど、ignore: trueがついたカラム・インデックス・FKについては、ridgepoleは無視、全く変更に関与しないようにしている(つもり)。 なので、このスキーマ定義はRailsでは対応していない「N-gramのインデックス」を冪等性を保ったまま作成することができる(はず)。

テストが通ったらリリースする予定。

apexでrubyをデプロイする

ちょうど4日ほど前に、apexにrubyサポートのPRがマージされていた。

github.com

なので、すこし試してみた。

apexのビルド

goのバージョンは1.11.4

GO111MODULE=onでビルド・インストールできた。

(apexのmasterをcloneしてから)
export GO111MODULE=on
make
make local

バージョンが更新されないのは、なんでなんだろう…?

$ apex version
Apex version 1.0.0-rc2

lambdaファンクションのデプロイ

ディレクトリ構成はこんな感じ。

test
├── functions/
│   └── my_ruby/
│       ├── function.json
│       └── lambda.rb
└── project.json

デフォルトのファイル名はlamba.rbでハンドラ名はhandler

def handler(event:, context:)
  {hello: 'world'}
end
$ apex deploy my_ruby
   • config unchanged          env= function=my_ruby
   • updating function         env= function=my_ruby
   • updated alias current     env= function=my_ruby version=1
   • function updated          env= function=my_ruby name=test_my_ruby version=1
$ apex invoke my_ruby
{"hello":"world"}

Gemの同梱

bundlerでpathを指定すれば普通にできた。

source 'https://rubygems.org'

gem 'hashie'
cd functions/my_ruby/
bundle install --path vendor/bundle
test/
├── functions/
│   └── my_ruby/
│       ├── Gemfile
│       ├── Gemfile.lock
│       ├── function.json
│       ├── lambda.rb
│       └── vendor/
└── project.json
require 'hashie'

def handler(event:, context:)
  {test: Hashie::Mash.new(hello: 'world')}
end

require "bundler/setup"とかは不要なよう。

C拡張の同梱

docker run -v $(pwd):/mnt -w /mnt ruby:2.5 bundle install --path vendor/bundle

とかやったらできるかと思ったら、libruby.so.2.5: cannot open shared object fileでエラー。

Amazon Linuxで拡張を作成しないとだめっぽい?ので、こちらを参考にDockerイメージを作成。

FROM amazonlinux

RUN yum -y install \
      gcc-c++ \
      openssl-devel \
      readline \
      libyaml-devel \
      readline-devel \
      zlib \
      zlib-devel \
      git \
      bzip2 \
      tar \
      make

RUN git clone https://github.com/sstephenson/rbenv.git ~/.rbenv && \
    git clone git://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build && \
    ~/.rbenv/plugins/ruby-build/install.sh

ENV PATH /root/.rbenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN rbenv install 2.5.3
RUN bash -c 'rbenv init - > /etc/profile'
RUN rbenv global 2.5.3
RUN bash -l -c 'gem install bundler'

ENTRYPOINT ["bash", "-l", "-c"]
source 'https://rubygems.org'

gem 'postfix_status_line'
docker run -v $(pwd):/mnt -w /mnt ruby-for-lambda bundle install --path vendor/bundle
require 'postfix_status_line'

def handler(event:, context:)
  status_line = "Feb 27 09:02:37 MyHOSTNAME postfix/smtp[26490]: D53A72713E5: to=<myemail@bellsouth.net>, relay=gateway-f1.isp.att.net[204.127.217.16]:25, conn_use=2, delay=0.57, delays=0.11/0.03/0.23/0.19, dsn=2.0.0, status=sent (250 ok ; id=20120227140036M0700qer4ne)"
  {parsed: PostfixStatusLine.parse(status_line)}
end
$ apex deploy my_ruby
   • config unchanged          env= function=my_ruby
   • updating function         env= function=my_ruby
   • updated alias current     env= function=my_ruby version=3
   • function updated          env= function=my_ruby name=test_my_ruby version=3
$ apex invoke my_ruby
{"parsed":{"time":"Feb 27 09:02:37","hostname":"MyHOSTNAME","process":"postfix/smtp[26490]","queue_id":"D53A72713E5","to":"*******@bellsouth.net","domain":"bellsouth.net","relay":"gateway-f1.isp.att.net[204.127.217.16]:25","conn_use":2,"delay":0.57,"delays":"0.11/0.03/0.23/0.19","dsn":"2.0.0","status_detail":"(250 ok ; id=20120227140036M0700qer4ne)","status":"sent"}}

ただこれは、Amazon Linuxが必要というわけじゃなくて、rbenvでインストールしたC拡張にlibruby.so.2.5へのリンクがないせいじゃないだろうか?

misc

apexのアクティビティをみると、先行き不安な気がする。

f:id:winebarrel:20181217231739p:plain https://github.com/apex/apex/graphs/contributors

前回のリリースは5月だし、https://github.com/apex/up に力を入れているようだし、うーむ…

とはいえCFnを使わないでlambdaだけに焦点をあてたメジャーなツールがapexぐらいしか見つからないので、しばらくはapexだよりかなぁ

active_record_mysql_xverify

active_record_mysql_xverifyというgemファイルを書いた。

github.com

これは何?

RailsでAuroraを使うときに、フェイルオーバー時に旧マスタにつなぎに行く問題をなんとかするやつ。 世間様を見渡すに「コネクションプール使うな。接続を切れ」というのが現状の解決策のようだけど、もうちょっとなんとかしたかったので作成。

使い方

config/environments/production.rbとかに

ActiveRecordMysqlXverify.verify = ->(conn) do
  conn.ping && conn.query('show variables like "innodb_read_only"').first.fetch(1) == 'OFF'
end

または

ActiveRecordMysqlXverify.verify = ActiveRecordMysqlXverify::Verifiers::AURORA_MASTER

と書いておくと、いい感じにしてくれます。

R/W splittingをしして、コネクション毎に有効にするかしないかを分けたい場合はActiveRecordMysqlXverify.handle_ifを使ってください。

ActiveRecordMysqlXverify.handle_if = ->(config) do
  config[:host] == 'my-cluster...'
end

あと、デフォルトではSQLの実行時にエラーが起きた場合、コネクションを検証するようになっているので、すべてのリクエストでコネクションを検証したい場合は

ActiveRecordMysqlXverify.only_on_error = false

としてください。

実装について

Railsはコネクションプールからのチェックアウト時にverify!を実行して*1、検証に失敗すると再接続する*2ようになっているので、verify!を上書きしてinnodb_read_onlyを見るようにしただけです。

すべてのリクエストでshow variablesを実行するのもアレなので、デフォルトではエラー時のみ。

MariaDB Connector/J

これを実装するにあたってMariaDB Connector/Jの実装を読んだり動作検証をしたりしたのだけれど、faster failoverでやっていることは

  1. SQLを実行
  2. エラーをハンドリング
  3. 再接続
  4. SQLを再実行

というのが大きな流れのようだった。

エラーハンドリングの判断にエラーの種別を見たり、別スレッドでコネクションの状態をチェックしていたり*3、といろいろやっているけれど、基本的にはactiverecord-mysql-reconnectと変わらないような気がする。

トランザクションが切れた状態でのSQLの再実行って、トラブルありそうだけどあんまり問題になってないのかな?

ActiveRecord::QueryCache

コネクションプールからのcheckoutとcheckinのタイミングが重要なので、ActiveRecordのコードをちまちま読んでいたんだけれど、Rails5でQueryCacheはRackのミドルウェアじゃなくなってた*4

checkoutは最初にコネクションにアクセスしたタイミングでされるけど*5checkinはQueryCacheに依存している気がする*6

ミドルウェアじゃなくなったのも、その辺の「コネクションプールの管理に必須だから」みたいな判断もあるのかな?あるいは単にAPIの変更の影響だろうか?

QueryCacheのレイヤが完全に削除されるとcheckinが機能しなくなりそうなんだけど、そのへんどうなんだろう?よくわからない。

audit.logのパーサを書いた

先日、Kyoto.rbがあったので、参加してもくもくとLinuxauidt.logのパーサを書いてきた。

github.com

audit.logは構造化されているようでいて、微妙に例外のあるフォーマットで*1Red Hatのドキュメントを見ても、以下のような感じで

type=SYSCALL msg=audit(1364481363.243:24287): arch=c000003e syscall=2 success=no exit=-13 a0=7fffd19c5592 a1=0 a2=7fffd19c4b50 a3=a items=1 ppid=2686 pid=3538 auid=500 uid=500 gid=500 euid=500 suid=500 fsuid=500 egid=500 sgid=500 fsgid=500 tty=pts0 ses=1 comm="cat" exe="/bin/cat" subj=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 key="sshd_config"
type=CWD msg=audit(1364481363.243:24287):  cwd="/home/shadowman"
type=PATH msg=audit(1364481363.243:24287): item=0 name="/etc/ssh/sshd_config" inode=409248 dev=fd:00 mode=0100600 ouid=0 ogid=0 rdev=00:00 obj=system_u:object_r:etc_t:s0
type=DAEMON_START msg=audit(1363713609.192:5426): auditd start, ver=2.2 format=raw kernel=2.6.32-358.2.1.el6.x86_64 auid=500 pid=4979 subj=unconfined_u:system_r:auditd_t:s0 res=success
type=USER_AUTH msg=audit(1364475353.159:24270): user pid=3280 uid=500 auid=500 ses=1 subj=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 msg='op=PAM:authentication acct="root" exe="/bin/su" hostname=? addr=? terminal=pts/0 res=failed'
  • ヘッダ部?は固定のように見えるが、rsyslogを通すと node=... がつく
  • ボディ部?はkey=value の空白区切りのように見えるが
    • auditd start, というメッセージが現れたりする
    • user pid= のように、キー部分に空白を許容している
    • msg='op=PAM:authentication acct="root" のように、入れ子になっていたりする

また、エスケープについては \" みたいなエスケープではなくて、空白・"' が入ると16進数文字列にエスケープされる(ほかにもエスケープされる文字はあるかも)

Nov  4 03:15:09 localhost audispd: node=ldap-client-001 type=USER_CMD msg=audit(1541301309.434:92): pid=1757 uid=10000 auid=10000 ses=1 msg='cwd="/home/sugawara" cmd=636174202F7661722F6C6F672F666F6F332E6C6F67 terminal=pts/0 res=success'
["636174202F7661722F6C6F672F666F6F332E6C6F67"].pack('H*')
#=> "cat /var/log/foo3.log"

フォーマットについては、公式のWikiがやや詳しいような気がする。

パーサぐらいその辺にありそうだと思って、いろいろ探してみたけれど、結局見つからなかったのでRubyで書いた。


パーサを書いたついで…というかこっちが本命だけれど、fluentdのfilterプラグインも書いた。

github.com

audit.logはrsyslogに対応しているので、集約のためにfluentdを使う必要はないんだけれど、集約先でいろいろ加工してほかのサービスに送ったりするには、やはりfluentdが便利。

(追記)

ausearchで絞り込めているのを見ると、内部的に何らかのパース処理をやっていると思うんだけど。 ソースコード読まねば。

(追記2)

既存のあった

github.com

*1:postfixのログもそんな感じだった

Postfixでの流量制限について

そろそろ腐りそうな知見なので、腐りきる前にメモだけ残しておく。

まあ、基本的にはSendGridなどのクラウドサービスを使った方がいいと思うけど、どこかの誰かの役に立つかもしれないので…


Postfixでメール配信の流量制限を行う場合、送る量を制限するか、MTAに入ってくる量を制限するかの2通りの方法があると思う。 送る量を制限しようと思うとslow_destination_rate_delay=1sみたいな設定値を追加して、送信毎に遅延をかけるような感じになると思う。

knowledge4linux.blogspot.com

しかし、この方法だと1s以下に設定することができないので、毎分何万通も送るようなシステムだと極端に配信量が下がってしまう。 また、流量制限を行う場合は単に遅延を設けるだけでなく「毎分xxx通」といった厳密な指定がしたい。

そこで、MTAに入ってくる量を制限する。

単純にMTAに入ってくる量を制限すると、上限超えたときにクライアントでエラーが出る。 なので、以下のようにPostfixを2段構成にする。

f:id:winebarrel:20180829040934p:plain

インターネットに面したPostfixでは、master.cfで送信先毎の流量を設定する

11125     inet  n       -       n       -       -       smtpd
  -o syslog_name=postfix/gmail
  -o smtp_destination_concurrency_limit=1
  -o smtp_destination_recipient_limit=1
  -o smtpd_client_message_rate_limit=1000
11126     inet  n       -       n       -       -       smtpd
  -o syslog_name=postfix/docomo
  -o smtp_destination_concurrency_limit=1
  -o smtp_destination_recipient_limit=1
  -o smtpd_client_message_rate_limit=400

クライアントに面したPostfixでは流量制限したい宛先のポートをtransportで変更する。

gmail.com smtp:[outside-load-balancer]:11125
docomo.ne.jp smtp:[outside-load-balancer]:11126

また、main.cfで再送間隔を短くしておく。

minimal_backoff_time = 3m
maximal_backoff_time = 15m
queue_run_delay = 3m

こういう構成にしておくと、gmail宛には1000通/分、docomo宛には400通/分、みたいな感じで流量を制限することができる。

経験上、3キャリアとGMailについては流量に制限をかけておいた方が無難だと思う…

またこの構成でのもう一つのメリットは、流量制限によってメールが溜まるキューがインターネット側のPostfixではなく、クライアント側のPostfixであるという点だ。 インターネット側のPostfixはたぶんグローバルIPを持つことになると思うが、特定のIP のPostfixのキューにメールが溜まってしまうと、そのIPが大量配信などで受け取り拒否されたときに困ったことになってしまう。 クライアント側のキューの方にメールが溜まっている状態なら、流量制限のリミットの引っかかった場合、再送でロードバランサーが別のPostfixに適度に振り分けてくれる。

DatadogでのECSタスクのCPUの監視について

ECSタスクのDockerコンテナのCPU使用率については、監視自体に意味があるのか微妙なところもあるけれど、タスクの制限いっぱいまで常にCPUを使い切っているコンテナは不健康な場合もありそうだということで、Datadogで監視しようとしたことがあった。

docker.cpu.usageはホストの1コアに対するCPUの使用率なので、50%となっていても、ECSのタスク定義でCPU=512としていたら、リミットいっぱいいっぱいまで使っていることになるので、タスクやイメージによって閾値を変える必要が出てくる。そうなるとmulti alertが使えない。

docker.cpu.throttledというメトリクスがあって

www.datadoghq.com

これはnr_thresholdの値で、割り当てられたCPU時間の限界に達した数をカウントしてくれるのでこれを見ると良さそうだ…と思ったんだけれど、この値が変動するのは(たしか)--cpusだったか--cpu-quotaだったかのオプションの時で、ECSのタスク定義でつかうcpu--cpu-sharesの時には変動しない。

どうしたものか、と思っていたところ /sys/fs/cgroup/cpu/cpu.shares からcpu.sharesの値が取れたので、それで正規化すればいいじゃんということで、パッチを投げた。

github.com

めでたくマージされ、v6も変更が入って、docker.cpu.sharesというメトリクスが入った。

これで、

avg:docker.cpu.usage{*} by {container_name} * 1024 / avg:docker.cpu.shares{*} by {container_name}

というモニタを作れば、100%に正規化された値を閾値として、multi alertが作れる…はずだったんだけれど

  • cpuが定義されていないと、使えない
  • たまに cpu.shares=3 みたいなタスクがあって、値が1000%みたいなことになる

ということがあって、結果は微妙だった…

あんまりこの手の知見が見当たらなかったので、とりあえずメモとして残しておく。