はじめに ─ アプリを開くきっかけをアプリ側から作る
当社が社内試作している資格学習アプリの PWA 化も、いよいよ最終フェーズです。Phase 1+2 で「ホーム画面に並ぶアプリ」になり、Phase 3+4 で「オフラインでも学習できる」状態になりました。
ここまでは「ユーザがアプリを開いたとき」の体験を整える話でした。残っているのは逆向きの課題 ── 「アプリを開くきっかけ」をアプリ側から作ることです。資格学習は継続が全てなので、「今日もやろう」と思い出させる仕掛けが要ります。これを実現するのが Web Push 通知です。
Phase 5 のゴールは、opt-in したユーザに学習リマインダーを毎日 1 通プッシュすることです。技術要素としては、web-push gem、VAPID 鍵、push_subscriptions テーブル、Solid Queue の recurring schedule、Service Worker の push ハンドラ、と一通り出てきます。
ただ、本記事で一番伝えたいのは技術スタックの並びではなく、iOS 16.4+ の制約をどう UI で吸収するかです。iOS の Web Push は「ホーム画面に追加して standalone モードで起動した PWA からのみ通知許可を出せる」という独特の制約があり、これを「面倒な仕様」として愚痴るのではなく、ユーザを正しい手順に導く設計でカバーします。対象読者は、Web Push を Rails で実装したいエンジニア、「web-push gem は入れたけど通知が届かない」で詰まったことがある方です。
1. Web Push の全体像と iOS 16.4+ の制約
Web Push の登場人物を整理しておきます。
- ブラウザ ── ユーザの許可を得て「購読(subscription)」を作る。endpoint と暗号鍵のセット
- Push サービス ── ブラウザベンダーが運営する中継所(Chrome なら FCM 等)。endpoint はここの URL
- アプリサーバー(Rails) ── VAPID 秘密鍵で署名して、Push サービスに「この endpoint に送って」と依頼する
- Service Worker ── 届いた push イベントを受け取り、
showNotification()で通知を出す
VAPID(Voluntary Application Server Identification)は、アプリサーバーが「私は正規の送信元です」と Push サービスに名乗るための公開鍵/秘密鍵の仕組みです。公開鍵はブラウザに渡し(渡さないと購読できない)、秘密鍵はサーバーに秘匿します。
そして本記事の主役、iOS 16.4+ の制約です。iOS Safari は 2023 年に Web Push へ対応しましたが、条件があります。
iOS で Web Push の通知許可をリクエストできるのは、「ホーム画面に追加して standalone モードで起動した PWA」からのみ。タブ内の Safari で Notification.requestPermission() を呼んでも、許可ダイアログ自体が表示されない。
つまり、ユーザがブラウザのタブでアプリを見ているうちは、どうやっても通知を有効化できません。先に「ホーム画面に追加」を済ませてもらう必要がある。これは回避策が存在しない Apple の仕様なので、UI でユーザを正しい順序に導くしかありません(§6-3 で実装)。
2. VAPID 鍵の生成と Rails credentials での管理
まず Gemfile に gem "web-push" を足します。VAPID 鍵の生成は WebPush.generate_key で行えます。
鍵の保管先は Rails credentials(config/credentials.yml.enc)にしました。VAPID 秘密鍵は「漏れたら自分のサーバーになりすまして通知を送られる」機密情報なので、暗号化されてリポジトリに入る credentials が素直です。通常は bin/rails credentials:edit で対話的に編集しますが、エディタ起動を挟まず自動化したかったので、Rails runner で書き込むスクリプトを使いました。
# bin/rails runner で実行
keys = WebPush.generate_key
current_yaml = Rails.application.credentials.read
current_data = YAML.safe_load(current_yaml, permitted_classes: [Symbol]) || {}
current_data["web_push"] = {
"vapid_public_key" => keys.public_key,
"vapid_private_key" => keys.private_key,
"vapid_subject" => "mailto:you@example.com"
}
Rails.application.credentials.write(current_data.to_yaml)ポイントは 3 つです。Rails.application.credentials.read は復号済みの YAML 文字列を返します(Rails 7.1+)。.write(yaml_string) で再暗号化して保存します。そして YAML.safe_load に permitted_classes: [Symbol] を渡しておかないと、既存の credentials に Symbol キーがあった場合にロードに失敗します。vapid_subject は mailto: か https:// で始まる URL を要求されます(RFC 8292)。Push サーバー側のエラーログ追跡で、通知の送信主体として使われます。
ジョブやコントローラから直接 Rails.application.credentials を読むのではなく、初期化子で config.web_push に展開しておきます。テスト時の差し替えやログ出力が楽になり、本番で鍵が未設定なら警告ログを出すこともできます。
# config/initializers/web_push.rb
Rails.application.config.web_push = ActiveSupport::OrderedOptions.new
creds = Rails.application.credentials.web_push || {}
creds = creds.symbolize_keys if creds.respond_to?(:symbolize_keys)
Rails.application.config.web_push[:vapid_public_key] = creds[:vapid_public_key]
Rails.application.config.web_push[:vapid_private_key] = creds[:vapid_private_key]
Rails.application.config.web_push[:vapid_subject] =
creds[:vapid_subject] || "mailto:hello@example.com"
if Rails.env.production? &&
Rails.application.config.web_push[:vapid_public_key].blank?
Rails.logger.warn "[web_push] VAPID keys are not configured."
end3. 購読のデータモデル(push_subscriptions)
ブラウザが作る「購読」をサーバー側に保存するテーブルを作ります。
create_table :push_subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.string :endpoint, null: false
t.string :p256dh_key, null: false
t.string :auth_key, null: false
t.string :user_agent
t.timestamps
end
add_index :push_subscriptions, :endpoint, unique: trueendpointは UNIQUE ── Push サービス上のその端末の宛先 URL。1 ユーザが複数端末(iPhone と Android など)を持てば、複数レコードを持つp256dh_key/auth_key── ECDH 用の公開鍵と認証シークレット。ブラウザのPushManager.subscribe()結果のgetKey()から取り出すuser_agent── 購読登録時にrequest.user_agentから取得。どの端末の購読かを後から識別するため
モデルは belongs_to :user と presence / uniqueness validation のみ。User 側に has_many :push_subscriptions, dependent: :destroy を足します。opt-in / opt-out のフラグは持ちません。購読レコードがあるか無いかが、そのまま通知の有効/無効の状態です(FAQ 参照)。
4. 通知を送る ── 失敗購読の自動破棄
特定ユーザに 1 通送るジョブが SendPushNotificationJob です。ユーザの全購読(=全端末)に対してループで送ります。
class SendPushNotificationJob < ApplicationJob
queue_as :default
def perform(user_id, title:, body:, path: "/")
user = User.find_by(id: user_id)
return unless user
config = Rails.application.config.web_push
return if config[:vapid_public_key].blank?
user.push_subscriptions.find_each do |sub|
payload = JSON.generate(
title: title,
options: {
body: body, icon: "/icon-192.png", badge: "/icon-192.png",
data: { path: path }
}
)
begin
WebPush.payload_send(
message: payload,
endpoint: sub.endpoint,
p256dh: sub.p256dh_key,
auth: sub.auth_key,
vapid: {
public_key: config[:vapid_public_key],
private_key: config[:vapid_private_key],
subject: config[:vapid_subject]
},
ttl: 24 * 60 * 60
)
rescue WebPush::ExpiredSubscription,
WebPush::InvalidSubscription,
WebPush::Unauthorized
sub.destroy # 恒久的に届かない → その場で破棄
rescue WebPush::Error => e
Rails.logger.warn "[push] failed sub_id=#{sub.id}: #{e.class}"
end
end
end
end設計上のポイントが 2 つあります。
1 つ目は 失敗購読の自動破棄です。ExpiredSubscription / InvalidSubscription / Unauthorized は、Push サービスが「この endpoint はもう有効でない」と明示しているケース(ユーザが通知を OFF にした、端末を変えた等)です。これらは恒久的に届かないことが確定しているので、その場で sub.destroy します。次回の sweep 処理は不要です。一方、それ以外の WebPush::Error(ネットワーク起因の一時エラー等)は削除せず、ログに残すだけにします。エラーの種類で「恒久」と「一時」を切り分けるのが要点です。
2 つ目は ttl: 24 * 60 * 60(24 時間)です。学習リマインダーは 24 時間で価値が失われます。TTL を長くすると、Push サービスのリソースを無駄に消費し、ユーザにも古い通知が遅れて届く不快感があります。なお TTL の単位は秒なので、24.hours のような ActiveSupport::Duration を渡すとエラーになります(後述のハマりどころ)。
5. 日次リマインダー ── Solid Queue の recurring schedule
個別送信ジョブができたので、それを毎日叩く DailyStudyReminderJob を作ります。
class DailyStudyReminderJob < ApplicationJob
queue_as :default
def perform
user_ids = PushSubscription.distinct.pluck(:user_id)
User.where(id: user_ids).find_each do |user|
I18n.with_locale(user.locale.presence || I18n.default_locale) do
SendPushNotificationJob.perform_later(
user.id,
title: I18n.t("pwa.notification.daily_reminder_title"),
body: I18n.t("pwa.notification.daily_reminder_body"),
path: "/exams/sap/sessions/new"
)
end
end
end
endPushSubscription.distinct.pluck(:user_id) で「購読を持つユーザ」だけを重複なく抽出します。User テーブルにフラグを持たせていないので、これが「通知対象ユーザ」の定義になります。ユーザごとに I18n.with_locale で文言をローカライズし、個別の送信は perform_later で並列化します(Solid Queue が並列実行)。
スケジュールは config/recurring.yml に書きます。本案件はユーザごとの希望時刻はサポートせず、毎日 20:00 JST 固定にしました(MVP の割り切り)。
# config/recurring.yml
production:
daily_study_reminder:
class: DailyStudyReminderJob
queue: default
schedule: every day at 11:00 # 20:00 JST (UTC+9)コンテナのタイムゾーンは UTC なので、every day at 11:00 が 20:00 JST に当たります。Solid Queue の recurring は fugit ベースで、これは cron の 0 11 * * * と等価です。dev 環境では recurring を有効化せず、検証は DailyStudyReminderJob.perform_now を Rails runner で叩いて行いました。
注意点として、Solid Queue の recurring は ワーカープロセスが落ちている時間帯のスケジュールは消化しません(cron のような取りこぼし実行はしない)。学習リマインダーは多少の取りこぼしを許容できるジョブなので現状は監視を入れていませんが、「絶対に取りこぼせない」ジョブなら実行記録の監視が別途必要です。
6. クライアント側 ── Service Worker と購読管理 UI
サーバー側が揃ったので、ブラウザ側 ── 通知を受け取る Service Worker と、購読を管理する UI を実装します。
6-1. push / notificationclick ハンドラ
Phase 1+2 で作った service-worker.js に、push と notificationclick のハンドラを足します。
self.addEventListener("push", (event) => {
if (!event.data) return
let payload
try {
payload = event.data.json()
} catch (_e) {
payload = { title: "資格学習アプリ", options: { body: event.data.text() } }
}
const title = payload.title || "資格学習アプリ"
const options = payload.options || {}
event.waitUntil(self.registration.showNotification(title, options))
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const path = event.notification.data?.path || "/"
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if (new URL(client.url).pathname === path && "focus" in client) {
return client.focus()
}
}
if (clients.openWindow) return clients.openWindow(path)
})
)
})push ハンドラは、SendPushNotificationJob が JSON.generate({ title, options }) で送ったペイロードを event.data.json() で取り出して showNotification() に渡します。notificationclick は、既に開いているタブがあればフォーカス、なければ新規 open という挙動です。includeUncontrolled: true で、その Service Worker が制御していないタブも候補に含めます。Service Worker のロジックを変えたので、VERSION を v1 → v2 にバンプして古いキャッシュを破棄させます。なお、この v2 は Phase 5 時点の版です。本シリーズ完成時点の最終形は v3 で、第 1 回の動作確認で見つかった Turbo 対応が v3 で入っています(v1 = Phase 2 / v2 = Phase 5 / v3 = Phase 1+2 記事の動作確認)。
6-2. Stimulus 購読コントローラ
購読の有効化/無効化を担う push_subscription_controller.js を作ります。主要メソッドは次のとおりです。
connect()── 対応チェック(serviceWorker+PushManager+Notificationが揃っているか)、iOS standalone チェック、現在の購読状態を取得して UI に反映enable()──Notification.requestPermission()→pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })→ サーバーにPOST /push_subscriptionsdisable()──pushManager.getSubscription()→sub.unsubscribe()→DELETE /push_subscriptionstest()──POST /push_subscriptions/testでSendPushNotificationJob.perform_nowを即時実行(動作確認用)
1 点だけ変換ユーティリティが要ります。VAPID 公開鍵は URL-safe base64 文字列で渡ってきますが、applicationServerKey は Uint8Array を要求します。urlBase64ToUint8Array() という 10 行程度のユーティリティで変換します。VAPID 公開鍵はクライアントに渡しても安全です(というより、渡さないと購読できない)。秘密鍵は絶対にサーバー外に出しません。
6-3. iOS standalone 制約を UI で吸収する
§1 で述べたとおり、iOS では「ホーム画面に追加して standalone モードで起動」しないと通知許可を出せません。ここを UI で吸収します。通知セクションをプロフィールページに置き、connect() 時に状態を判定して表示を出し分けます。
| 状態 | UI |
|---|---|
| 非対応ブラウザ | 「お使いの環境は通知に対応していません」 |
| iOS かつ非 standalone | 「先にホーム画面に追加してください」のヒント(有効化ボタンは出さない) |
| 対応・未購読 | 「通知を有効にする」ボタン |
| 対応・購読済み | 「通知を OFF にする」+「テスト送信」ボタン |
ポイントは、iOS かつ非 standalone のときに有効化ボタンを出さないことです。ボタンを出してしまうと、押しても無反応(ダイアログが出ない)で、ユーザは「壊れている」と感じます。代わりに「先にホーム画面に追加してください」と案内すれば、ユーザは正しい手順に進めます。Apple の制約を「面倒な仕様」ではなく「ユーザが意図せず通知許可を求められないための安全装置」と捉えれば、この UI は自然な誘導になります。
7. 動作確認 ─ サーバーログまで見て切り分けた
Web Push は「実装したが通知が届かない」で詰まりやすい機能です。実際にどう確認し、どう切り分けたか ── ここはサーバーログまで見た一次情報が役に立ちます。
7-1. iPhone 実機での購読〜通知フロー
iPhone 実機で、購読から通知到達まで一通り確認しました。
- Safari でホーム画面に追加した PWA から起動(iOS の Web Push は standalone 起動が必須。Chrome 追加だと不可)
- プロフィールページ → 「学習リマインダー通知」セクション → 「有効にする」
- iOS の通知許可ダイアログ → 「許可」
- 状態表示が「有効」に変わり、「無効にする」「テスト通知」ボタンが出る
- 「テスト通知」ボタン → iOS の通知として「資格学習アプリ テスト通知 / 通知が正しく届きました!」が届く
7-2. CloudWatch ログでサーバー側を切り分ける
実は最初、「テスト通知が届かない」と思った時間がありました。そこで本番の CloudWatch ロググループ(/ecs/app-rails)を直接見て、サーバー側とクライアント側を切り分けました。ログには次のものが記録されていました。
POST /push_subscriptions→ 購読保存成功POST /push_subscriptions/testが複数回SendPushNotificationJobが複数回、すべて "Performed" 成功(各 340〜500ms)、エラーゼロ
WebPush.payload_send がエラーなく完了している ── これは 通知が Apple のプッシュサーバーに正常に引き渡されていることを意味します。つまり「サーバー側は完璧に動いている、問題は表示側」とこの時点で切り分けられました。Web Push のデバッグでは、まずサーバーログで「送信成功か」を確認するのが、原因の半分を一気に潰す近道です。
7-3. 「届かない」の真相は iOS の集中モードだった
結局、通知が見えなかった原因は ── iPhone が「おやすみモード(集中モード)」だったから、でした。集中モードはバナー表示を抑制しますが、通知自体は届いていて、通知センターにはちゃんと残っていました。「バグかと思ったら OS の設定だった」という、よくある落ちです。
ここから引ける実用的な切り分け手順は、「通知が来ない」と感じたら、まずサーバーログで送信成功を確認 → 次に端末側の集中モード / 通知設定を疑う、という順番です。サーバーログで送信成功が取れているなら、コードを疑うより先に端末を疑う。この順序を知っているだけで、無駄なコード調査を回避できます。
8. ハマったところ・判断の記録
- iOS の standalone 制約はテストしにくい ── dev 環境では
localhostのhttp://経由だとホーム画面追加すらできない。ngrok / Tailscale を挟んでhttps://で実機検証する必要がある。Phase 1+2 のときと同じ構成だが、Web Push は許可ダイアログまで確認する分、手間が一段増える - Web Push の
ttlは秒の整数で渡す ── 最初ttl: 24.hours(ActiveSupport::Duration)を渡したらWebPush::Errorで「ttl must be integer」と怒られた。24 * 60 * 60の素朴な秒数で渡す credentials.writeの戻り値は undocumented ── Rails のソースを読むとEncryptedConfiguration#writeはPathname#writeを呼ぶだけで、戻り値は書き込みバイト数。成否は例外で表現される。戻り値で成否判定しようとして一瞬混乱したcredentials.web_pushが Hash と OrderedOptions を行き来する ── 環境によって型が変わることがあり、初期化子で.symbolize_keysをrespond_to?でガードしている(OrderedOptionsにはsymbolize_keysが無いため)
9. PWA 化 5 フェーズの振り返り
Phase 1 から Phase 5 まで、この資格学習アプリの PWA 化を一通り終えました。全体を振り返ります。
| Phase | 内容 | 主要技術 |
|---|---|---|
| 1+2 | PWA の形を作る(ホーム画面追加 / アプリシェルキャッシュ) | manifest / Service Worker |
| 3 | 問題データをクライアントに持たせる | IndexedDB / JSON API |
| 4 | オフライン専用練習モード + 回答キュー同期 | IndexedDB v2 / bulk sync |
| 5 | Web Push 通知(日次リマインダー) | web-push / VAPID / Solid Queue |
5 フェーズを通して効いたのは、Rails 8 が PWA のひな型を最初から持っていることでした。Phase 1 のスタート地点が「ゼロから設計」ではなく「用意された枠の中身を埋める」だったのが大きい。Phase 2 以降は基本的に 「PWA の標準パーツを Rails モノリスに馴染ませる」作業で、特別なアーキテクチャを持ち込む必要はありませんでした。
iOS の制約(Web Push は standalone 必須、Background Sync は非対応)は最後まで残りましたが、これは「ネイティブを選ばないコスト」として受容しています。回避策のない制約は、UI で吸収するか、別経路で代替するか ── どちらも本シリーズで実際にやってきたことです。残りは、ユーザ数が増えた後に Push 配信の到達率や同期失敗率を計測しながらチューニングしていく領域になります。
あとがき
本記事は Rails 8 PWA シリーズの最終回として、Web Push 通知の実装をまとめました。技術的には web-push gem + VAPID + Solid Queue で素直に組めますが、iOS 16.4+ の standalone 制約を「面倒な仕様」で終わらせず、ユーザを正しい手順に導く UI でカバーするところに、実装の価値が出ます。回避できない制約とどう付き合うかは、Web Push に限らず PWA 開発全体のテーマでもあります。
全 5 フェーズを通して、Rails 8 のモノリスに PWA の標準パーツ(manifest / Service Worker / IndexedDB / Web Push)を段階的に馴染ませてきました。「ネイティブアプリを書かずに、どこまでアプリらしい体験を作れるか」の一つの実例として、同じ判断を迫られている方の参考になれば幸いです。
MOOBON では Rails / Web アプリケーションの新規開発・既存案件への機能追加・PWA 化の設計 を承っています。「Web Push を入れたいが iOS 対応で詰まっている」「PWA 化の全体ロードマップを引きたい」といったご相談も歓迎です。info@moobon.jp までお気軽にどうぞ。
よくある質問
Qなぜ User テーブルに notifications_enabled フラグを足さなかったのですか?
通知の opt-in / opt-out 状態は、push_subscriptions テーブルにそのユーザのレコードがあるか無いかで判定できます。User に notifications_enabled のような boolean を別途持たせると、「フラグは true なのに購読レコードが無い」「フラグは false なのにレコードが残っている」という不整合状態が生まれ得ます。購読の有無そのものが状態なので、フラグは冗長です。「通知対象ユーザ」を抽出したいときも User.where(id: PushSubscription.distinct.pluck(:user_id)) で済みます。状態を二重に持たない、というシンプルさの判断です。
QiOS でホーム画面に追加せずに通知許可ダイアログを出す方法はありますか?
ありません。これは Apple の仕様です。iOS Safari は 16.4(2023 年)で Web Push に対応しましたが、「ホーム画面に追加して standalone モードで起動した PWA からのみ」通知許可をリクエストできます。タブ内の Safari で Notification.requestPermission() を呼んでも、許可ダイアログ自体が表示されません。回避策はなく、UI で吸収するしかありません。本実装では isIOS() && !isStandalone() を検知して、「先にホーム画面に追加してください」というヒントを通知セクションに表示し、ユーザを正しい手順に誘導しています。
QVAPID 鍵は環境変数と Rails credentials、どちらに置くのが正しいですか?
本案件では Rails credentials(config/credentials.yml.enc)に置きました。VAPID の秘密鍵は「漏れたら自分のサーバーになりすまして通知を送られる」性質の機密情報です。環境変数は手軽ですが、プロセス一覧やログ、エラートラッキングサービスに紛れ込むリスクがあります。credentials.yml.enc は暗号化されてリポジトリに入り、復号鍵(master.key)だけを別管理する仕組みなので、機密の取り回しとしては素直です。一方、12-factor 的に環境変数で統一している組織もあり、そこは組織のポリシー次第です。重要なのは「秘密鍵を平文の YAML やコードに書かない」ことで、その条件を満たすならどちらでも構いません。
Q失敗した push_subscription を自動削除する判断は妥当ですか?
妥当だと考えています。WebPush.payload_send が ExpiredSubscription / InvalidSubscription / Unauthorized を返すのは、Push サービス(FCM など)が「この endpoint はもう有効ではない」と明示しているケースです。ユーザが通知を OFF にした、ブラウザのデータを消した、端末を変えた、などで発生します。これらは「次に送れば届くかもしれない」一時エラーではなく、恒久的に届かないことが確定した状態なので、その場で sub.destroy して問題ありません。逆に、ネットワーク起因の一時的なエラー(WebPush::Error の他のサブクラス)は削除せずログに残すだけにして、次回配信で再試行されるようにしています。エラーの種類で「恒久」と「一時」を切り分けるのがポイントです。
QSolid Queue の recurring schedule の信頼性はどの程度ですか?
Solid Queue の recurring schedule は config/recurring.yml に cron 式相当のスケジュールを書く仕組みで、データベース駆動です。注意点として、ワーカープロセスが落ちている時間帯のスケジュールは消化されません(cron のように「起動時に取りこぼしを実行」はしない)。日次リマインダーのように「その時刻に送れなければ価値が薄れる」ジョブとは相性が良いですが、「絶対に取りこぼしてはいけない」ジョブには、別途実行記録の監視を入れる必要があります。本案件は学習リマインダーなので、多少の取りこぼしは許容範囲とし、現状は監視を入れていません。重要度が上がれば、ジョブ実行ログのアラートを足す前提です。
