S3の「SlowDown」エラー対応とコスト削減

はじめに

この記事は、スタンバイ Advent Calendar 2025の24日目の記事です

私のチームでは求人の取り込みシステムを開発・運用を行っています。 現在はシステムのリプレイスを行っておりその中で、S3へのデータ保存処理において「SlowDown」エラーが発生したためその解決に取り組みました。 この記事では、S3の「SlowDown」エラーの原因とその解決方法について紹介したいと思います。

S3のレート制限(503 SlowDown)について

S3のAPIを利用していると、以下のようなエラーメッセージが返却されることがあります。

SlowDown: Please reduce your request rate.

これはS3のレート制限を超過したことで発生するエラーです。AWS公式ドキュメントによると、パーティショニングされたAmazon S3プレフィックスごとに以下のリクエストレート制限が存在するようです

  • PUT/COPY/POST/DELETE
    • 毎秒 3,500 リクエスト
  • GET/HEAD
    • 毎秒 5,500 リクエスト

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/optimizing-performance.html

プレフィックスについて

AWS公式ドキュメントでは、プレフィックスはオブジェクトキー名の先頭にある文字列と定義されています。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-prefixes.html

例えば バケット内に以下のオブジェクトが存在するとします。

この場合、AとBのプレフィックスは以下のようになります。

Aのプレフィックス Bのプレフィックス
photos/ photos/
photos/2006/ photos/2006/
photos/2006/January/ photos/2006/February/

パーティショニングされたプレフィックスについて

AWSのカスタマーサポートにお問い合わせを行い、S3がどのように自動的にプレフィックスを分割して、アクセス負荷を分散させるかという仕組みについて教えていただきました。

バケット

パーティショニングされたプレフィックス:

バケット内のオブジェクト:

  • A /folder0/fileA.txt
  • B /folder0/fileB.txt
  • C /folder1/fileC.txt

上記の例では、3つのオブジェクトはいずれもプレフィックス1 (/) に含まれます。

この状況でファイルCに対するリクエストレートが上限に近づいた場合に、S3は自動的にパーティショニングされたプレフィックスを分割します。

分割後のプレフィックス

パーティショニングされたプレフィックス:

ファイルA、ファイルBはプレフィックス1に含まれ、ファイルCはプレフィックス2に含まれることとなります。それぞれのプレフィックスごとにリクエストレートの上限が適用されるため、バケット全体としてのパフォーマンスがスケールされます。

つまり、プレフィックスに対してリクエスト負荷が高まると、S3は自動的にパーティションを行い、アクセス負荷を分散させるようです。

問題が発生した理由を理解するために、私たちのシステムでのS3のオブジェクト構造について説明します。 (簡略化した構造になっています) 私たちのシステムでは、S3バケット内にユニークIDごとのディレクトリを作成し、その中に求人ファイルを保存しています。

私たちのシステムでのS3オブジェクト構造

MyBucket/
└── {ユニークID}/
    ├── 求人ファイル1.json
    ├── 求人ファイル2.json
    ├── 求人ファイル3.json
    └── ...

初期状態では、バケット全体で3,500 PUT/s 5,500 GET/sとなっていますが、プレフィックス(ユニークID)にリクエスト負荷が高まると、S3は自動的にパーティションを行うようです。 ただし、即時反映されるわけではないそうです。 実行ごとに異なるプレフィックス配下へデータをアップロードするとS3側でパーティションが行われていない状態になります。そのため、S3の自動スケーリングが完了する前の初期状態の制限値(毎秒 3,500 リクエスト)に引っかかり503 SlowDownエラーが発生していました。

解決方法

1. 暫定対応:レートリミットの実装

503 SlowDownエラーの解消が最優先であったため、まずはS3のリクエストを制限するレートリミットを実装しました。 私たちが使用しているGo言語では、golang.org/x/以下の準標準パッケージにレートリミットを制限するライブラリが存在しておりこれを利用しました。 https://pkg.go.dev/golang.org/x/time/rate

ただし、上記の対応を行うことで新たな問題も発生しました。S3へのリクエスト回数を制限したことでSlowDownエラーは解消されましたが、レートリミットをいれたことでスループットが低下しました。

2. 恒久対応:アーキテクチャの刷新

暫定対応を入れたことでSlowDownエラーは解消されましたが、パフォーマンス低下の問題が残りました。もう一つ前々からあった問題でしたが、S3のリクエスト料金が高額になるというコスト面での問題もありました。 そこで、S3へのリクエスト回数を削減するため、アーキテクチャの刷新を行いました。具体的には以下の2案を検討しました。

案1:リクエストごとにプレフィックスをランダムに変更(不採用)

不採用の理由

  • 現状のアーキテクチャでは、1つのリクエスト(求人ID)ごとにプレフィックスをランダムな文字列に変更することが大幅な仕様の変更に繋がるため、影響範囲が大きい
  • プレフィックスをランダムにすることで、 パフォーマンス低下の問題は解消するかもしれないが、リクエスト回数は変わらないためS3のリクエスト料金の削減には繋がらない

案2:リクエストの集約(採用)

求人情報1件ごとにS3へリクエストを送信していた既存のアーキテクチャを見直し、1000件の求人情報を1つのファイルにまとめてからS3へ保存するアーキテクチャに刷新しました。 これにより、S3へのリクエスト回数を大幅に削減できるため、SlowDownエラーの解消とコスト削減が期待できます。

  • 変更前 1求人 = 1リクエスト
  • 変更後 1000求人 = 1リクエスト

実装方針

Go言語のgoroutineとチャネルを利用して、以下の方針で実装を行いました。

  • 求人を1件ずつ処理するのではなく、チャネルから受け取ったデータをメモリ上のスライスに一時保存する
  • チャネルの求人データ数が設定された閾値に達するか、全求人データを処理し終えた時点で、一時保存していたデータを1つのファイルにまとめてS3にアップロードする
  • プレフィックス内容を修正して、ユニークIDに連番を付与する
    • {ユニークID}_{IndexNumber}
プレフィックスの設計変更

変更前

MyBucket/
└── {ユニークID}/
    ├── 求人ファイル1.json
    ├── 求人ファイル2.json
    ├── 求人ファイル3.json
    └── ...

変更後

MyBucket/
└──{ユニークID}_{IndexNumber}/
    ├── 求人ファイル1.json
    ├── 求人ファイル2.json
    ├── 求人ファイル3.json
    └── ...

結果

エラーの解消とパフォーマンス向上

まとめてリクエストを送ることでS3へのリクエスト数が大幅に削減されているため、「SlowDown」エラーを解消できました。 また、リクエスト数が減少したことで、S3のリクエストを制限していた部分は大幅に緩和され、パフォーマンスも向上しました。

コスト削減

1求人1リクエストから1000求人1リクエストに変更したことで、S3へのリクエスト数が約1000分の1に削減されました。 S3のリクエスト料金を比較したところ、1日約200ドルのコスト削減に成功しました。 月額にすると約6,000ドルのコスト削減となるので非常に大きな効果があったのではないかと思っています。

おわりに

S3の「SlowDown」エラーの原因とその解決方法について紹介しました。 プレフィックスの内容やどのようにしてS3が負荷分散を行っているかを理解することで、適切なアーキテクチャ設計が可能になると感じました。 明日は@hakochaさんの記事です。お楽しみに!

toolchainの挙動について学んだ

はじめに

スタンバイ Advent Calendar 2024の19日目の記事です。

18日目は@Richter-Aで「Go ではインタフェースは最小限にするべき?」でした。 出社した際にはいつもコーヒーを一緒に飲んでいます。コーヒー仲間です。 今回の記事では、Goのtoolchainディレクティブの挙動について、検証をもとに解説します。

概要

Goのバージョン指定には、従来のgoディレクティブに加えて、toolchainディレクティブを利用する方法があります。この2つの指定方法は、バージョンや環境によって挙動が異なる場合があります。

この記事では、toolchainディレクティブを使った際の挙動を詳しく解説します。この記事の内容は、@tenntenn さんが社内勉強会で説明してくださった内容をもとにしています(掲載許可をいただいています)。

toolchainディレクティブの挙動を検証する

以下のようなディレクトリ構成で、toolchainの挙動を確認します。

├── a
│   ├── a.go
│   └── go.mod

以下は、シンプルなGoプログラムの例です。このプログラムは、現在実行されているGoのバージョンを出力します。

a/a.go

package main
 
 import (
  "fmt"
  "runtime"
 )
 
 func main() {
  fmt.Println("a", runtime.Version())
 }

a/go.mod

module example.com/a

 go 1.22.0

ローカルにインストールされているGoを1.21.7で動かします。

結果

go1.21.7 run

a go1.22.0 

command not found: go1.21.7が出た場合は、以下のコマンドを実行してください

go install golang.org/dl/go1.21.7@latest

go1.21.7 download

// 上記のコマンドを実行することで特定のバージョンのtoolchainを使用することができます。

go1.21.7が go1.22.0より低いので自動的にアップデートされています

a/go.modを修正し、go1.21.7より低いバージョンを指定します。

a/go.mod

module example.com/a

 go 1.21.6

結果

go1.21.7 run

a go1.21.7

ローカルにインストールしたGoのバージョンの方が新しいので、go1.21.7が動きます。

a/go.modにtoolchainを追加した状態で動かします
module example.com/a

go 1.22.0
toolchain go1.23.0
go.1.21.7 run .

b go1.23.0
a go1.23.0

toolchainディレクティブに書いたバージョンで動きます。 ローカルにインストールしたGoのバージョンとgoディレクティブに書かれたバージョンよりtoolchainディレクティブに書かれたバージョンが優先されるようになっています

依存モジュールのバージョン管理

├── a
│   ├── a.go
│   └── go.mod
└── b
    ├── b.go
    └── go.mod

b/go.mod

module example.com/b

go 1.22.0

toolchain go1.23.0

b/b.go

package b

import (
    "fmt"
    "runtime"
)

func B() {
    fmt.Println("b", runtime.Version())
}

a/go.mod

module example.com/a

go 1.22.0

toolchain go1.23.0
require example.com/b v0.0.0 
replace example.com/b => ../b

requireの記述でexample.com/bをimportできるようにしています。ただしrequireだけだと該当バージョンのモジュールをダウンロードできるようにリポジトリ上にリリースする必要があります。 replaceを追記することで、依存モジュールの場所をローカルディレクトリに指定し、開発中のコードを参照できるようにしています。

a/a.go

package main

import (
    "example.com/b"
    "fmt"
    "runtime"
)

func main() {
    b.B()
    fmt.Println("a", runtime.Version())
}

結果

go1.21.7 run .

b go1.23.0
a go1.23.0

b/go.modを修正し、toolchainのバージョンをaモジュールよりあげます。

module example.com/b

go 1.22.0

toolchain go1.23.1

結果

// a/a.goを実行
go1.21.7 run .

b go1.23.0
a go1.23.0

いまbuildしようとしているモジュールに対してはtoolchainが優先されるため、b/go.modのtoolchainのバージョンをあげても、実行時にはa/go.modのtoolchainが適用されます。

bモジュールのGoバージョンがaモジュールより高い場合

a/go.mod

module example.com/a

go 1.21.7
toolchain go1.22.0

require example.com/b v0.0.0

replace example.com/b => ../b

a/a.go

package main

import (
    "example.com/b"
    "fmt"
    "runtime"
)

func main() {
    b.B()
    fmt.Println("a", runtime.Version())
}

b/go.mod

module example.com/b

go 1.23.0
toolchain go1.23.1

結果

go1.21.7 run .
go: module ../b requires go >= 1.23.0 (running go 1.22.0)

今回のようにbモジュールを他の箇所でimportしている場合、bモジュールのGoバージョンが、importされてるモジュールよりGoバージョンが高い場合に、buildできなくなります。 bモジュールのバージョンを下げるか、bモジュールを使用するモジュールでGoのバージョンを上げる必要が出てくる必要があります。

go.modにtoolchainを書くべきかどうか

基本的にどちらでもよいのかなと思っていますが、依存関係があるモジュールを開発している、もしくはその可能性がある場合はtoolchainを書いておいて良いと思います。 以下の資料でもtoolchainディレクティブを使用するように推奨されていました。 speakerdeck.com

まとめ

この記事では、go.modにtoolchainディレクティブを使用した場合の挙動について解説しました。依存モジュールの管理やバージョン間の優先順位について、実際の挙動をコード例を用いて確認しました。 明日は @arata-honda さんが「Goのテスト関連で最近まで知らなかったこと」を解説してくださる予定です。ぜひご覧ください!

参考文献

go.dev

Rubyのsort_byを理解する

Rubyのsort_byとsortでどう違うのかわかっていないので調べた。

Rubyのリファレンスマニュアルを読む

リファレンスマニュアルをみると下記のように書いていた。

sort_byの説明

Enumerable#sort と比較して sort_by が優れている点として、比較条件が複雑な場合の速度が挙げられます。 sort_by を使わない以下の例では比較を行う度に downcase が実行されます。 従って downcase の実行速度が遅ければ sort の速度が致命的に低下します。

p ["BAR", "FOO", "bar", "foo"].sort {|a, b| a.downcase <=> b.downcase }

一方、次のように sort_by を使うと downcase の実行回数は要素数と同じです。 つまり、その部分の実行時間は O(n) のオーダーです。

p ["BAR", "FOO", "bar", "foo"].sort_by {|v| v.downcase }

sort_byの挙動

sort_by を使うと downcase の実行回数は要素数と同じというのがわからなかった。
リファレンスには、以下のコードとほぼ同じ動作をします。と書いてあったのでsort_byのコードを読むことにした。 (ユニさんの解説も参考にしています。ありがとうございます。)

class Array
  def sort_by
    self.map {|i| [yield(i), i] }.
       sort {|a, b| a[0] <=> b[0] }.
       map {|i| i[1]}
  end
end

以下のコードを実行して、sort_byのコードを分解しながらみていくと理解できた。

p ["BAR", "FOO", "bar", "foo"].sort_by {|v| v.downcase }
# =>  ["BAR", "bar", "FOO", "foo"]

self.map {|i| [yield(i), i] }まで

self.map {|i| [yield(i), i] }では、yield(i)の値とiの値は以下のようになる。

# yield(i)の値
"bar", "foo", "bar", "foo" # (downcaseした値)
# iの値
"BAR", "FOO", "bar", "foo" # (元の配列の値)で

上記を二次元配列にしている。ここまでの返す値

# => [["bar", "BAR"], ["foo", "FOO"], ["bar", "bar"], ["foo", "foo"]] 

sort {|a, b| a[0] <=> b[0] }まで

sort {|a, b| a[0] <=> b[0] }では、
["bar", "foo", "bar", "foo"](downcaseした配列)の値を比べてソートしている。 ここまでの返す値

# => [["bar", "BAR"], ["bar", "bar"], ["foo", "FOO"], ["foo", "foo"]]

map {|i| i[1]}まで

map {|i| i[1]}では、
[["bar", "BAR"], ["bar", "bar"], ["foo", "FOO"], ["foo", "foo"]](ソート済みの二次元配列)から、元の要素だけを取り出している。 ここまでの返す値

# => ["BAR", "bar", "FOO", "foo"]

コードをひとつひとつ分解してみると、downcaseの実行回数は要素数と同じというのがわかった。

じゃあsortはいつのタイミングで使うのかという疑問が残ったけどそれはまた今度調べる。

XserverでPHPのバージョンを5.6から7.2にあげました

はじめに

Xserverで動かしているWordpressにて、PHPのバージョンアップを行ったのでその手順を紹介します。

ざっくりと上記の流れで対応しました。今回はPHPのバージョンあげただけで、WordPressプラグインWordPressのバージョンはあげていません。 テスト環境がない場合は手元で環境をつくるよいと思います。 Local by Flywheelなどがお手軽に環境を作れて便利です。 自分はテスト環境で上記の作業をして問題がないことを確認してから本番で作業を行いました。

1. プラグインおよびテーマファイルがPHP7に対応しているかチェック

PHP Compatibility CHeckerを使いました。 プラグインがPHP7.2に対応しているか、テーマがPHP7.2に対応しているかのチェックを行ってくれます。 はまりどころとしては、テスト環境でPHP Compatibility Checker が動かない問題がありました。

  • wp-cronが動かないと動かない
  • wp-cronが動かない理由はベーシック認証がかかっているため動かない

解決策は、htaccessに特定のサーバーのIPアドレスを許可することで解決できました。(XserverとかであればそのサーバーのIPアドレス)

書き方

Satisfy any →これがあると特定のIPアドレスが正しい or BASIC認証できたユーザーのどちらかという意味。

order deny,allow
allow from 111.22.333.44 ← サーバーのIPアドレス

deny from all

AuthUserFile "/home/.htpasswd"
AuthName "Input ID and Password"
AuthType BASIC
require valid-user

2. DBのバックアップを取る

バックアップをとりましょう。Xserverなら管理画面からphpmyadminにアクセスして取得できます。 無理ならsshでサーバーに接続してdumpファイルを取得します。

3. サイトのバックアップを取る。

zipコマンドでバックアップを取っておきます。

zip -rq  ZIPファイル名 バックアップしたいフォルダ/* -x "*.png*" "*.jpg*" 

pngやjpgファイルがあるとバックアップ量が膨大になるので、-xオプションで除外しています。

4. エラーログを記録できるようにしておく。

実はこの作業は本番環境では、行いませんでした。というのもWordPress エラーログ 記録などで検索すると、 wp-configファイルに下記を記述するようにするといった記事が多かったのですが、WordPress Codexで、WP_DEBUGについて調べると、通常は WordPress の開発環境の wp-config.php ファイル内で true に設定します。と書いてあったからです。

https://wpdocs.osdn.jp/WordPress%E3%81%A7%E3%81%AE%E3%83%87%E3%83%90%E3%83%83%E3%82%B0

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'WP_DEBUG_LOG', true );

5. PHPをバージョンアップ

Xserverの管理画面から行えます。バージョンアップに時間がかかるのではと懸念がありましたが、数秒で切り替わったので問題なく行えました。

6. 動作確認

PHP Compatibility CHeckerでテーマファイル以下のファイルが、PHP7.2に対応しているかどうかのチェックは通っていたのですが、動作確認してみるとcount関数を使用している箇所でいくつかの箇所でwarningが出ていました。適切に変更していくだけなのでそこまで手間ではなかったです。 cruw.co.jp

おわりに

PHP5系は2018年でサポートが切れてしまっているみたいなので、早めにバージョンアップすることをおすすめします。

stocker.jp

PHP7にすると速度もあがるはずです。実際にバージョンアップ前とバージョンアップ後ではPage Speed Insightのスコアも上昇していました。パフォーマンス面においても変わってくるので、バージョンアップやっていきましょう!

マネーフォワードさんの京都オフィスをお借りして、kyoto.rb開催しました。

毎回コアーキングスペースを借りて、ゆるくやっているkyoto.rbですが、今回はマネーフォワードさんのオフィスをお借りして、勉強会を開きました。

kyotorb.doorkeeper.jp


入り口が、京都の旅館みたいな感じで、テンションあがりました。

f:id:taca10:20190317142903j:plain


kyoto.rbでは、毎回もくもく会をしているのですが、参加者の人たちともっと話す機会があったら楽しいのではという意見があったので、今回から、自己紹介の時に気になるテーマや、キーワードを発表してもらい、その中で、良さそうなものを参加者の人達と議論するようにしてみました。
今回はこんな感じのテーマがでてきました。

f:id:taca10:20190317145838j:plain

シェアハウス募集してます。といった、全く技術と関係ない話もありましたw

人数が、18人集まったので、初心者と、中級者以上のグループに別れて話しました。
自分は、初心者グループにいたのですが、やってよかったことが、初心者グループのところでは、Scrapboxにページを作って、聞きたいことをたくさん書いてもらって、それについて、わかる人が答えていくっていうのをしました。ちょっと聞きにくいなってことでも、Scrapboxに書くのであれば抵抗感なくかけて、結構よかったなと思いました。
個人的にも良かったのが、オブジェクト指向の勉強を最近していて、自分でオブジェクト指向で書いたと思っているコードを、上級者の人にみてもらってFBしてもらえたので、かなり学びがありました。
悪かったところが、初心者グループが、12人いて、対話ができない人がちらほらでてきてしまっていたり、初参加の人や知識が十分にないひとからの質問を引き出せなかったことが課題でした。
次回は、6人ぐらいにグループわけして、グループ毎に進行役みたいな人を置いてやっていこうかなという感じでした。
懇親会でも、そのままマネーフォワードさんのオフィスをお借りしていて、お酒やジュースがマネーフォワードさんから提供されて最高でした。本当にありがとうございました。

次回は4月13日(土曜)に同じくマネーフォワードさんの京都オフィスで開催予定なので、気になる人は遊びにきてください!

kyotorb.doorkeeper.jp





IP電話を導入しました

半年ほど前に、IP電話を導入しました。
これから、導入する人がいれば、参考になるかと思い、ブログに書いておきます。
ip電話でも色々あると思いますが、今回はwebブラウザ、アプリケーション上で電話設定をしたかったのでクラウドPBXのみに絞っています。

biztel.jp



  • オペレーターさんが使用している電話が廃盤になっていてこれ以上電話回線を増やせない
  • 営業時間外も電話がなってしまう。
  • 電話しながらPC操作が不便(片手がふさがるので)

 

使用している電話が廃盤になっていて、電話回線を増やせないことはけっこう問題だったので、なんとかしないといけませんでした。

導入したサービスはCall Connectというものです。 

www.callconnect.jp


プランが複数ありますが、僕たちは、starterプラン(一番安いプラン)で始めました。
startemrプランでも、Google拡張機能を開発すれば、電話番号をキーにして、僕たちのCRMにリンクをとばせそうだったので、starterプランから始めることにしました。


何ができるようになるのか?

  • 回線をいくらでも増やせる。(お金はかかる)
  • 営業時間外に留守電を入れることが可能
  • PCで通話可能(両手が空く)
  • 僕たちが開発しているCRMに登録しているお客さんであれば、何を注文しているかすぐに調べることができる。
  • 番号の確認。転送
  • 着信ポップアップ

できないこと(プランを変更すればできるようになる)

  • 顧客情報登録、管理
  • 外部サービス連携(slack連携など)
  • キューイング機能(電話待ちの人にアナウンスを流す機能)
  • 通話メモ
  • etc

starterプランでは、顧客情報を登録することができない。つまり電話がかかってきたときにどんなお客さんか、わからないのですが、Google拡張機能を開発すれば、電話番号をキーにして、自分たちのCRMにリンクでとばせそうなので、starterで十分だと感じました。

料金( 3アカウントの場合で計算しています。)

初期費用
¥108円 (電話番号代) + ¥3,682×5(ヘッドセット予備も含め5個)

¥18,518

月々のランニングコスト
= ¥1,800×3アカウント +  (携帯への発信料金が全体の85%くらいを占めていたので、1ヶ月で携帯に発信する通話のみで計算しています 1083分×16.45(携帯に発信) +  3000分×1.25 (着信電話))
¥26,965

他社との料金比較
(きちんと調べたつもりですが、間違っていたらすいません。)

クラウドテレコール
初期費用
= ¥50,000(初期導入費) + ¥380 (電話番号代) + ¥3,682×5(ヘッドセット予備も含め5個)
¥68,790

月々のランニングコスト
= ¥10,000 × 3アカウント + ¥17,220 (発信)
¥80,220
楽天コミュニケーション社とのIP電話回線契約が必要

INNOVERA PBX
初期費用
= ¥100,000(初期導入費) + ¥2,500×3アカウント + ¥1,000 (電話番号代) + ¥3,682×5(ヘッドセット予備も含め5個)
¥126,910

月々のランニングコスト
= ¥1,000×3アカウント +1083分×15円(携帯に発信) + 20,000 (API連携)
¥39,245

機能が揃っていいて、日々のランニングコストも割と安いのはINNOVERA PBXでしたが、弊社のCRMと連携できるか微妙でした。かつUIが使いにくそうだなと感じたので、Call Connectを導入しました。



before_validation コールバック関数に引数を持たせる

やりたいこと

電話番号入力欄で後ろに半角、全角スペースがあった時に空白を取り除いてからvalidationを行いたい時に、before_validationを使って書きました。
電話番号だけじゃなくメール入力欄など他の場面でも利用できると思いmoduleを使って書いた時に詰まった部分があったのでどうすればいいか共有します。

とりあえずapp/models/user.rbに下記のように最初は書いていました。

app/models/user.rb
class User < ActiveRecord::Base
# ... 省略 ...

  before_validation :trim_space

  def trim_space
    self.phone.gsub!(/[ \p{blank}\r\t\n\f]/, "")
  end

end

gsub!なのですが!を使ってオブジェクトの状態を変更しておかないと返り値が全角、半角を取り除いた状態にならないので!を使うかもしくは下記のような書き方にしてみてください

app/models/user.rb
  self.phone = self.phone.gsub(/[ \p{blank}\r\t\n\f]/, "")
  return self.phone


これでまずは電話番号の入力欄だけは全角、半角を取り除けるのですが、他にも使いたい時に何度も同じコードを書かなければならないのでmoduleに書いて呼び出すようにしてみました。

app/model/validate_common.rb

module ValidateCommon
  extend ActiveSupport::Concern
  included do
    def trim_space
      self.phone.gsub!(/[ \p{blank}\r\t\n\f]/, "")
    end
  end
end
app/model/user.rb
class User < ActiveRecord::Base
  include ValidateCommon
  # ... 省略 ...

  before_validation :trim_space
end

ただこのままだと電話番号にしか使用出来ない状態なので、app/model/user.rbの:trim_spaceに引数を持たそうとした時につまりました。

app/model/user.rb
class User < ActiveRecord::Base
  include ValidateCommon
  # ... 省略 ...

   before_validation -> {trim_space("phone", "email")}
end

このようにして書けば引数をもたせる事ができるので、DRYなコードにできるかと思います。
app/model/validate_common.rb側も少し編集します。

module ValidateCommon
  extend ActiveSupport::Concern
  included do
    def trim_space(*target_columns)
      target_columns.each do |target_column|
        if self.send(target_column)
          self.send(target_column).gsub!(/[ \p{blank}\r\t\n\f]/, "")
        end
      end
    end
  end
end

条件分岐の部分なのですが、 self.send(target_column)がnilの場合つまり今回だと電話番号、メール入力欄が何も入ってこない場合だとNoMethodError Exception: undefined method `gsub!' for nil:NilClassになるので書いています。

before_validationで書く時は基本シンボルの書き方でメソッドを書いていたので結構詰まりました。
後、sendメソッドのレシーバの持っているメソッドを呼び出すというのも知らなかったので非常に学びがありました。
他にも良い書き方あれば教えて欲しいです。