hnwの日記

独自ドメインGmailのメール紛失事件をCloudflare Email Routingで解決した話

この記事は、KLab Engineer Advent Calendar 2024 の25日目の記事です。

はじめに

独自ドメインを長年維持している方々の多くは、現在もGoogle Workspace(旧G Suite無償版)を利用しているのではないでしょうか。筆者もその一人です。独自ドメインのメールを送受信でき、Gmailの便利な機能を無償で利用できるのは本当にありがたいですね。

そんなわけで長年独自ドメインのメールを運用してきたわけですが、最近になって一部のメールが届かないトラブルに見舞われました。その解決策としてCloudflare Email Routingを導入したところ、あっさり問題が解決しました。本記事では、その設定手順および得た知見を紹介します。

メール紛失事件について

今回の取り組みのきっかけになった「メール紛失事件」について説明します。ここ6ヶ月ほど、au IDの二段階認証メールが筆者所有の独自ドメインのメールアドレスに届かない問題が起きていました。この独自ドメインのメールアドレスはGoogle Workspaceで管理しており、このメール以外は特にトラブルなく利用できています。

まずauのサポートに問い合わせましたが、同様の事例報告はないそうで、auサポートでは解決できませんでした。次に、Googleに問い合わせようとしましたが、無償ユーザーのため正式なサポートに頼ることができません。また、Google Workspace管理コンソールの「メールログの検索」も無償ユーザーでは利用できませんでした1。

他サービスからのメールは問題なく届いているため、auが行儀の悪いメールを送っているか、Googleがエラーメール扱いにする条件が厳しすぎるか、どちらかが原因と考えられます。いずれにせよ旧G Suite無償版ではトラブル時の打ち手が少なく、リスクのある環境であることを再認識しました。

やったことの概要

上記の問題を解決するため、Google Workspaceの前段にCloudflare Email Routingを入れることにしました。Cloudflare Email Routingを利用して、問題が起きているメールだけをYahoo! メールなど別サービスに転送すれば問題が解決するのではないか?と考えたためです。

図1:Cloudflare Email Routing導入の概要

手順は以下の通りです。説明上、Google Workspaceで管理しているメールアドレスを[email protected]とします。

  1. ユーザーエイリアスドメインの設定 Google Workspaceの「ユーザーエイリアスドメイン」を利用し、サブドメインのメールアドレスでもメールを受信できるように設定します([email protected])。

  2. Cloudflare Email Routingの導入 元のアドレス宛のメールをCloudflare Email Routingで受信し、サブドメインのアドレスに転送する構成にします。

この方法だと元のGoogle Workspaceのフィルタ設定をそのまま利用可能ですから、他の方法に比べて移行の手間が少なく、万一事故があっても元に戻しやすいという利点があります。

旧G Suite無償版とは

詳細の説明に入る前に、旧G Suite無償版について説明します。

Googleのサービス(Gmail・Google カレンダー・Google Driveなど)を独自ドメインで組織メンバー向けに提供するサービスがGoogle Workspace ですが、以前はG Suite(さらに以前はGoogle Apps)と呼ばれていました。

これが大昔(2012年以前)は10人以下の組織であれば無料で使えるという大盤振る舞いをしていました。当時個人でドメインを所有していた人は全員利用していたように思います(筆者の周りでは本当にそんな印象でした)。2012年に無償版の新規提供は終了しましたが、それ以前からの契約者は引き続き無償提供されています。

2022年にGoogleが「無償版やめるんでお金払うか引っ越して」と言い出したものの、しばらくして「やめるのやめます」となり、移行せずモタモタしていた人(筆者を含む)は再び救われたような経緯があります。

若い人には信じられないような話だと思いますが、古参の人が独自ドメインのメールアドレスを維持できているのはそんなカラクリがあるのです。本当にありがたいことです。

Cloudflare Email Routingとは

Cloudflare Email Routing についても紹介します。

CloudflareはCDNの会社として有名ですが、独自ドメインを維持している人にとっては無償でDNSを提供してくれる会社でもあります。それ以外にも様々なサービスを提供しています。

Cloudflare Email Routingはメールの転送サービスです。1ドメインあたり最大200個の宛先・転送先を管理でき、キャッチオールアドレス(未定義のアドレス全部を受け取るアドレス)も提供しています。大盤振る舞いなことに、利用料金は無料です。

図2:メールの転送状況が可視化されている

図2のように管理画面からメールの転送状況を確認できるのも便利ですね。

設定1:ユーザーエイリアスドメインの設定

まずはGoogle Workspaceでユーザーエイリアスドメインを設定する手順を説明します。ユーザーエイリアスドメインを設定することで、サブドメインのメールアドレスでもメールを受信できるようになります。例えば、[email protected]というアドレスでメールを受信することが可能になります。

手順

  1. Google Workspace管理コンソールにログイン 管理者アカウントでGoogle Workspaceの管理コンソール にログインします。

  2. ドメインの追加 「アカウント」「ドメイン」「ドメインの管理」ページに移動し、「ドメインをを追加」を選択します。ドメイン名を入力し(例:ws.example.com)、「ユーザーエイリアスドメイン」チェックボックスを選択して「ドメインを追加して所有権を証明」ボタンを押します。

  3. ドメインの所有権の確認 ページ内の指示に従ってドメインのDNS設定でTXTフィールドを追加して「確認」ボタンを押します。

  4. ユーザーエイリアスドメインの有効化 「自社ドメインで Gmail の使用を開始する」ボタンを押し、ページ内の指示に従ってMXレコードを設定します。完了後に「確認」ボタンを押すと設定完了です。

  5. メール送信テスト 設定が反映されたか確認するために、サブドメインのメールアドレスにテストメールを送信します。

設定2:Cloudflare Email Routingの導入

次に、Cloudflare Email Routingを導入する方法を説明します。Cloudflare Email Routingを利用することで、独自ドメインのメールを受信し、指定したアドレスに転送することができます。

注意点ですが、対象ドメインのDNS管理にCloudflareを利用していることがEmail Routing利用の前提になります。他社でDNS管理している場合は利用できません。

手順

  1. Cloudflare管理コンソールにログイン Cloudflareの管理コンソールにログインし、ドメイン管理画面に移動します。

  2. 転送ルールの設定 「Email」「Email Routing」ページに移動し、「Routing Rules」タブで転送アドレスの設定を行います。例えば、[email protected] 宛のメールを [email protected] に転送する設定を行います。

  3. 転送先アドレスの確認 転送先として設定したメールアドレスに確認メールが届くので、メールの「Verify email address」ボタンを押します。

  4. Email Routingの有効化 「Overview」タブから「Enable Email Routing」リンクをクリックします。これによりMXレコードが書き換わり、メール転送が開始されます。

  5. メール送信テスト 設定が反映されたか確認するために、テストメールを送信します。メールが正しく転送されることを確認します。

結果:何もしていないのに問題が解決した

上記の設定を行ったところ、それだけでau IDの二段階認証メールがGoogle Workspaceで受け取れるようになりました。

仮に元のメールに何かしら問題があるとすれば、Cloudflare Email Routingを経由したところで問題は変わらないはずですから2、受け取れるようになったのは全く予想外です。

状況が改善したのでいったんは様子見になりますが、メール紛失事件が再発する可能性も否定できません。もしそうなったら改めて対処したいと思います。

今回得られた知見

知見1:ユーザーエイリアスドメインに「gmail」は使えない

Google Workspaceのユーザーエイリアスドメインとして「gmail.example.com」のようなドメインは指定できません。指定しようとすると「無効なドメインです」と怒られますが、なぜ怒られるのかしばらく理解できませんでした。

フィッシング目的で紛らわしいURLを作られないように、という配慮なんでしょうね。

知見2:サービスごとに登録メールアドレスを変えておくと便利

サービスごとに異なるメールアドレスを使用するのは一般的に良いテクニックですが、Cloudflare Email Routingを使う場合は特に有用です。今回のように一部の送信者からのメールだけトラブルがあるような場合に、特定のメールだけ転送先を変えることができます。

知見3:GmailはRFC違反のMessage-IDを書き換える

今回、Google Workspaceで受け取ったメールを別のアカウントに転送しようとしたところ、一部メールがDMARCエラーになりCloudflareが受け取ってくれないことがわかりました。今回の場合、1回目は同じメールを正常として受け取っているはずなので、少々不思議と言えば不思議です。

図3:Gmailからのメール転送時に発生したトラブル

図4:転送時にCloudflare Email Routingで出たエラー

調べたところ、GmailはMessage-IDがRFC違反のメールを受け取るとMessage-IDを書き換えるそうです。一方で、DKIMの署名検証はメールヘッダも対象なので、ヘッダを書き換えたメールが転送されて署名検証で失敗してしまうということなのでしょう3。

RFC違反のメールを送るのが悪いのかGmailがメッセージIDを書き換えてしまうのがやり過ぎなのかCloudflareの実装が悪いのか筆者の知識では判断できませんが、挙動がわかっていれば対処もできるので良しとしましょう。今回の場合は転送先をGoogle Workspaceのサブドメイン宛にすることで転送時もエラーが起きなくなりました。

ちなみに、私が受け取っているメールの中でこのような挙動になるのは「【au ID】2段階認証確認コード通知」「【au PAY】ご利用のお知らせ」などauからのメールの一部のみです。これらのメールのメッセージIDは012.345.678.JavaMail.spluser@c1hsm01aのような形式なのですが、RFCの定義では<と>で囲まれている必要があるようです。

まとめ

  • æ—§G Suite無償版でメール受信トラブルがあった場合、Cloudflare Email Routingを前段に入れる選択肢がある
    • エラーログを確認できる
    • 問題のあるメールだけ転送先を変更できる
    • (記事中では触れませんでしたが)Email Workers で複雑な処理をすることもできる
  • au IDの二段階認証メールのMessage-IDはRFC違反
    • トラブルの原因になりうる
    • 今回の問題の直接の原因とまでは言い切れないが、少なくとも遠因ではありそう

  1. 有償ユーザーであればログからエラー内容が確認できるはずで、もう少し手がかりが得られるはずです。
  2. 転送することで送信元IPアドレスが変わってSPFチェックでsoftfailするので、むしろ状況が悪化しても不思議はありません。
  3. GmailもCloudflare Email RoutingもARC (Authenticated Received Chain) をサポートしており、本来ならこのような事故を防げるはずだと思うのですが、どうにも謎です。

Hono上にストレージレスなログインセッション管理を実装してみた

セッションストレージなしでログインセッションを維持する仕組みを作ったので、簡単に紹介します。

先日oidc-authというHonoのミドルウェアを実装して3rd-party middlewareとして採用していただきました。これは外部IDプロバイダーで認証を行ない、自前発行したJWTを毎リクエスト検証することで、サーバ側でセッションIDを記録することなくログインセッションを維持するものです。

このセッションストレージ不要という特徴はCDNエッジと親和性が高く、たとえばCloudflare Pagesで提供する静的コンテンツにGoogle認証をつける、といったことをエッジのCPUだけで実現できます。加えて、HonoのポータビリティのおかげでDeno Deployでも同じ仕組みが使えたりします。

個人的には実用性とセキュリティを両立した面白いものが作れたと考えていますが、セキュリティ面で不安を感じる人もいると思うので解説記事を書いてみます。

エッジコンピューティングとは

本題に入る前に、エッジコンピューティングについて簡単に紹介します。

Webの文脈でエッジコンピューティングと言った場合、ごく短時間だけ動くような処理をCDNエッジサーバなどユーザーの近所で動作させる仕組みを指すと思います。Cloudflare Workers、Lambda@Edge、Vercelなどがその代表格と言えるでしょう。

こうした環境の動作環境としてJavaScriptエンジンが多く採用されているため、エッジ上で動かすスクリプトは基本的にJavaScriptやTypeScriptで記述することになります1。

ユースケースとしては簡単な処理を前提としていることが多く、どの環境でも実行時間の上限は厳しめです。例えばCloudflare Workersは実行時間の上限が50ms2です。

やや古い文書ですが、@yusukebeさんの「CDNのエッジで実行する系が面白い」を読むと背景やユースケースをより詳しく理解できると思います。

このエッジコンピューティングですが、特にWebフロントエンド界隈の方々に支持されている印象があります。私は門外漢なので想像になりますが、利用言語の親和性と実際のニーズと両方が噛み合っているということなのでしょう。特に、エッジでSSRした結果を一定期間キャッシュしておくような使い方はメリットが大きそうです。

Honoの紹介とoidc-authの利用例

Honoは@yusukebeさんが作られているTypeScript製のWebアプリケーションフレームワークです。

Honoの特徴を一言で説明すると、「事業者ごとの差分が大きいエッジ処理にポータビリティを提供するエッジ用フレームワーク」となります3。各社のエッジコンピューティング環境は基本的にJavaScript/TypeScriptで記述するものが多いのですが、それぞれの差分がかなり大きく、複数社の環境で同内容の処理を書くだけでも意外と苦労する印象です。Honoで書くことで小さい変更で別の環境でも動かすことができます。

論より証拠で、今回私が作成したoidc-authを使って静的ページにOpenID Connectで認証・認可をつけるコードを見てみましょう。Cloudflare Pagesの場合は下記のようになります。

import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth'

const app = new Hono()

app.use('*', oidcAuthMiddleware())
app.use('*', async (c, next) => {
  // Authorize user with email address
  const auth = await getAuth(c)
  if (!auth?.email.endsWith('@gmail.com')) {
    return c.text('Unauthorized', 401)
  }
  await next()
})

app.get('*', async (c) => {
  const response = await c.env.ASSETS.fetch(c.req.raw);
  // clone the response to return a response with modifiable headers
  const newResponse = new Response(response.body, response)
  return newResponse
});

export default app

認証用のURL、クライアントIDやシークレットなどは環境変数経由で渡しています。上記の場合であればGoogle認証を経由して@gmail.comのアドレスだった場合だけ静的ページを閲覧できます。

次にDeno Deploy版を見てみましょう。

import { Hono } from 'npm:hono'
import { serveStatic } from 'npm:hono/deno'
import { oidcAuthMiddleware, getAuth } from 'npm:@hono/oidc-auth';

const app = new Hono()

app.use('*', oidcAuthMiddleware())
app.use('*', async (c, next) => {
  // Authorize user with email address
  const auth = await getAuth(c)
  if (!auth?.email.endsWith('@gmail.com')) {
    return c.text('Unauthorized', 401)
  }
  await next()
})

app.use('*', serveStatic({ root: 'public/' }))

Deno.serve(app.fetch)

異なる環境で似たコードが動くことがわかると思います。

実際の挙動やソースコードを確認したい方は下記リンクから試してみてください。

ご自身で動作確認したい場合、環境変数を最低5つ設定した上でIDプロバイダ側にコールバックURLを登録する必要があります。詳細は@hono/oidc-authをご確認ください。

JWTによるセッション管理の実装

ようやく本題です。oidc-authの実装を紹介します。

oidc-authのシーケンス図

シーケンス図を見ると少し複雑に見えますが、IDプロバイダからIDトークンを取り出すところまでは普通のOpenID Connectの認証フローです。

その後、取り出したIDトークンを元に自前でJWTを作ります。このJWTにはユーザーID4、eメールアドレス、リフレッシュトークン、リフレッシュ期限(デフォルト15分)とセッション期限(デフォルト1日)が含まれており、HS256で署名しています。

このJWTをSet-Cookieヘッダでブラウザに返し、ブラウザから送信されるJWTを毎リクエスト検証します。JWTの署名検証をパスしてJWT内のセッション期限を経過していなければセッションが有効ということになります。

上記の説明から、いわゆるセッションストレージを使っていないのがわかるかと思います。本来ならセッションストレージにおくべき情報を署名付きでブラウザのクッキーに保存し、毎回署名検証を行なっているわけです。

セキュリティにも配慮して実装しています。oidc-authではoauth4webapiというブラウザ向けのOAuthクライアント実装を利用しており、OpenID Connectの実装で必要とされるセキュリティ対策は全て実施しています5。また、クッキーのSecure属性やHttpOnly属性もちゃんと指定しています。

JWTをセッションとして使うことの是非について

5年ほど前に、SPAなどの文脈でJWTをセッション管理に使うのがいいのか悪いのかが議論になったことがあります。たとえば「どうしてリスクアセスメントせずに JWT をセッションに使っちゃうわけ?」などを読むと当時の議論がわかります。

私の理解では、セッション管理にJWTを使うべきじゃない派の主張は次のようにまとめられます。

  1. 万一JWTが漏洩したときにセッションを無効化する手段がない、JWTの有効期限切れを待つしかない
  2. ログアウト時に当該JWTを無効化できない(そういう実装が多い)
  3. 内部犯がJWT署名用の共通鍵を悪用すると任意のJWTを作って認証を回避できる
  4. JWTは巨大かつ検証コストが高いのでネットワークとCPUの無駄遣いである
  5. JWTを採用するとセッションID方式より複雑度が上がってバグを作りやすい

これらの指摘のうち、特に最初のものは致命的な問題点と言えます。一定規模の組織での運用を考えた場合、利用者がPCを紛失してJWTが悪意ある第三者に渡るシナリオは現実的な脅威です。一方、JWTは署名検証をパスすればJWTの中身を信用する仕組みなので、JWTが漏洩した場合でも管理者が無効化できないのです。

この問題に対応するため、今回実装したoidc-authではJWT内にリフレッシュトークンを格納し、比較的短いスパン(デフォルト15分)で暗黙的にトークン再発行を行っています。JWT漏洩の恐れがある場合、管理者が当該ユーザーのリフレッシュトークンを無効化すれば15分以内にアクセスできなくなるというわけです。管理者がリフレッシュトークンを無効化できるかどうかはIDプロバイダーによると思いますが、Google Workspace、Auth0、AWS Cognitoなどでは無効化可能です。

また、2つ目の指摘点への対応としてログアウト時にJWTに含まれるリフレッシュトークンを無効化しています6。これにより、JWTが仮に漏洩したとしても次回のトークンリフレッシュに失敗してJWT自体が無効になる仕組みになっています。すでに説明した通りセッション無効化を試みてから一定期間(デフォルト15分)のタイムラグを許容する前提なので、タイムラグ1分でも許せない場合はこの仕組み自体を採用できないことになります。

3つ目の指摘点も深刻な内容だと私は考えています。対策としては定期的に鍵をローテートするくらいしかないと思いますが、現状だと運用者が頑張るしかないので、oidc-auth側でも何か仕組み化したいところです。

4つ目は大変ごもっともだと思うのですが、エッジコンピューティング環境だとネットワーク転送量とエッジのCPUは安価なリソースであり、セッションストレージは相対的に高価なので許してほしい、というのが私の主張です。

5つ目も本当にその通りで、セキュリティの専門性が低い小予算のチームがJWTを使ったセッション管理を自前実装するのは一般論として危険だと思います。とはいえ、今回私は一定以上検討して作ったつもりですし、OSSの形でみんなで叩いていけば安全なものができるのではないでしょうか。そうしたノウハウを蓄積できる環境としてHonoは素晴らしいプラットフォームだと思っています。

まとめ

OpenID Connectのログインセッション維持をブラウザクッキーだけで実現するようなHonoのミドルウェアoidc-authを実装しました。CDNエッジと相性が良く、安価に認証・認可を実現できて便利だと思います。またセキュリティについても考慮しているつもりです。

とはいえoidc-authにどの程度のニーズがあるか作者自身もよくわかっていませんので、使ってみての感想やご意見をいただけると嬉しいです。

最後に、(おそらく得体のしれない状態で)oidc-authをHonoの3rd-party middlewareとして採用いただいた@yusukebeさんに感謝いたします。この記事を元にREADMEに追記して、もう少し安心して使える状態にしたいと思います。


  1. 環境によってはRustなどで記述してWebAssemblyで動作させることもできますが、最有力の選択肢とは言えない気がします
  2. フリープランだと10msとさらに制約が厳しくなります。また、50msより長時間使える別プランも提供されています。
  3. あくまで私の解釈なので、違った利点に着目しているユーザーも多いと思います
  4. IDトークンのsubフィールド
  5. oauth4webapi自体が利用者も多く継続的にメンテナンスされているライブラリで、OpenID Connectで必須とされるstateパラメータ、nonceパラメータ、code_challengeパラメータの3つの検証を標準でサポートしています。また、alg:noneを拒否するなどセキュリティ観点のベストプラクティスが実装されているように思います。
  6. 本稿サンプルコードやシーケンス図ではログアウト処理を省略していますが、ちゃんと実装してあります

ダイキン製エアコンのリモコンホルダーを3Dプリンタで自作した

筆者は自宅のダイキン製エアコンのリモコンホルダーを3Dプリンタで自作しました。これでリモコンが行方不明になる生活とはおさらばです。

壁にリモコンがつきました

このSTLファイルはThingiverseにアップロードしてあります(Wall mounted Daikin AC remote control holder by hnw - Thingiverse)。必要な方は各自プリントしてみてください。

ニッチな話題ですが、同じ悩みを持っている人向けにもう少し説明します。

ダイキン製エアコンはリモコンホルダーが別売で困った

筆者は最近引っ越したんですが、引っ越し先の備え付けエアコンがダイキン製でした。

引っ越してから気づいたんですが、ダイキンってエアコンの壁掛けリモコンホルダーが別売なんですね。一方で筆者はリモコンを行方不明にする特技を持っているので、リモコンが定位置にないと本当に不便なんですよ。

問題解決のため別売のリモコンホルダーを買おうかとも思ったんですが、純正のリモコンホルダーは壁にネジ止めする前提だったので買うのをやめました。物件の賃貸契約のルールとして壁に穴を開けちゃダメなんです。

八方塞がりの状況ですね。同じ悩みを抱えている人が日本中に数百人くらいいるんじゃないでしょうか。

他人の作ったリモコンホルダーの設計図を探してみる

今回のような実生活のニッチな悩みを解決する上で、3Dプリンタは非常に便利な道具です。任意の形状のプラスチック製パーツを実用性のある強度で作成できます。

また、大抵の悩みは世界中の誰かが既に解決しているので、欲しいものがあれば設計図(STLファイル)を探してみるのが良いでしょう。うまくいけば他人の設計図を3Dプリントするだけで問題解決というわけです。

実際、「daikin remote holder 3d」などで検索するとSTLファイルが何個か見つかるんですが、残念ながら筆者のニーズに合うものは見つかりませんでした。

筆者のニーズは次の通りです。

  • ホルダーを両面テープで壁に固定できる
  • リモコンを壁にかけたままでリモコン操作が可能
  • 形状は箱型ではなくフック

3番目のフック形状というのを補足すると、対象のリモコンは裏側にフックで引っ掛けるための凹みがあるんですね。ここで引っ掛けて使いたい、ということです。

リモコン裏側に引っ掛けるための凹みがある

なければ作ろう!リモコンホルダーを自作

他人の設計図が見つからなかった場合には自分で3Dモデリングすることになります。とはいえ、必ずしもゼロから作る必要はありません。他人の作品を改造して作ることもできます。

筆者も惜しい作品を改造して自分の欲しいものを作ってみました。筆者はモデリング素人なのでゼロから作るのは厳しいんですが、改造なら雰囲気で何とかなりました。

左:オリジナル作品*1 / 右:筆者の作った改造版

底面が狭いのでプリント難易度が少し高いかもしれません。ご注意ください。

使ってみての感想

試行錯誤の甲斐あって、自分のニーズにマッチしたものができました。レビューを見ると純正ホルダーでもリモコンが壁から浮いてしまうようなので、純正品より実用性が高い気がします。ダイキン製エアコンをお持ちの方は是非お試しください。

3Dプリントしたホルダーを壁に固定する両面テープは以下の製品が便利です。

*1:New Daikin AC remote wall hanger by gmarcell / CC-BY

自宅のPC環境を改善したら四十肩が治った話

コロナの影響もあり、この3年ほどで在宅勤務の会社さんが多くなった印象があります。 それに伴い、自宅のPC環境を改善した人って多いんじゃないでしょうか。 ご多分に漏れず私もPC環境改善を行いまして、下記のような環境が普段使いの環境になりました。

筆者のメインPC環境

一見ありふれた環境に見えるかもしれませんが、私はこの環境を手に入れてから、それまで悩んでいた四十肩の症状が大きく改善しました。

本稿では、この環境に至った経緯と購入した製品を紹介していきます。同じ悩みを持つ方はもちろん、四十肩・五十肩予備軍の方も参考にして頂ければと思います。

キーボード環境の紹介

上記の写真だとわかる人にしかわからないと思うので、実際の使い方を紹介します。

通常ポジション

コンパクトキーボード(HHKB)2台を1つのPCに接続し、左右分割キーボードのように利用しています。

トラックボール使用時ポジション

マウスポインターを動かしたい時は真ん中のトラックボールを使います。トラックボールの位置が異常に見えるかもしれませんが、慣れると非常に使いやすいです。

また、リストレストと自作のキーボードカバーにより、ホームポジションに戻りやすいように工夫しています。

ちなみにHHKBを2台買うと相当なお値段になるんですが、当時の私は四十肩解消のためなら全然惜しくないと思っていました。それくらい四十肩がストレスだったんですよね。

四十肩とは

四十肩、名前だけは聞いたことがある人が多いと思うんですが、どういう状態かピンとくる人は多くないのではないでしょうか。

私の理解では、四十肩とは肩関節の可動域が狭くなり、可動域ギリギリ付近で肩の痛みが出てくるような症状の総称です。 最初のうちは肩が上がりにくいな?くらいの感覚なんですが、悪化してくると高い棚のものを取ったりYシャツに着替えたりするたびに痛みで泣きそうになります。 また、悪い方の肩を下にして寝返りを打つと痛いというのも典型的症状です。このレベルまで悪化するとよく眠れなくてつらいんですよね。

私は在宅勤務開始後に四十肩を発症してどんどん悪くなっていきました。 今にして思えば在宅勤務になって机や椅子が変わったことで姿勢が悪くなり肩への負担が大きくなっていたんだと思いますが、当初は自宅環境に問題があるとは夢にも思いませんでした。今までも土日に同じ環境で作業してきたので、むしろ良い環境だという思い込みがあったかもしれません。

その後、かなり悪化してから病院にかかりましたが中々良くならず、藁にもすがる思いで作業環境の改善に投資してみたところ目に見えて四十肩が改善したというわけです。

おそらく、私の場合は巻き肩がクセになっていて四十肩が悪化していたのを、肩を極端に開くポジションにすることでうまく矯正できたということでしょう。

もちろん、キーボードを変えただけで四十肩が治る保証はありません。肩に石灰ができるタイプの四十肩もあるそうで、その場合は投薬か手術でないと治らないでしょう。念のため補足でした。

机の高さ数cmの調整が重要

これも後から気づいたことですが、元々使っていた机は私の座高に対して高すぎでした。小柄な男性や女性にとって、市販の机はキーボード作業には高すぎることが多いようです。

椅子と机の適切な高さは下記サイトなどで調べられます。私の場合はもともと使っていた机が最適な高さより5cm高いことがわかりました。これも四十肩を悪化させた原因だったように思います。

私はHHKBと同時期に電動スタンディングデスクを購入しました。このデスクが0.1cm刻みで気軽に高さ調整できた点も四十肩改善に役立ったように思います。また、椅子に座る姿勢と立った姿勢とを頻繁1に切り替えることで肩を動かしやすくなる利点もありました。

まとめ

肩を開くポジションでキーボード作業を行うこと、また机の高さを適切に調整することで四十肩が改善した体験談を紹介しました。同じ悩みを抱えている方の参考になれば幸いです。

私の場合は四十肩の苦しみから逃れたい一心で高額な商品を買ってしまいましたが、ここまでお金を使わなくても良かったのかもしれません。とはいえ、HHKBも電動スタンディングデスクも良い買い物だったと個人的には感じています。

FAQ

Q1: HHKBを2台買うならセパレート型キーボードで良くない?

A1: いいと思います!ただ、私は右手で6をタイプしたい派で市販品のセパレート型キーボードだと選択肢が少なかったこと、また自作キーボード系は沼っぽいので遠慮した結果、買っても絶対ハズレのないHHKBを選択しました。

Q2: キーボードを2台使うスタイルだとCtrl+pとかが打てなくない?

A2: MacだとHHKB2台が別キーボード扱いになり、2台にまたがったタイプを実現するにはキーボードカスタマイズソフトが必須です。Karabiner-Elementsを入れましょう。Windowsの場合は何もしなくても動くらしいです。 2台にまたがってFnキーが使えないのは事実だと思いますが、私はEmacsバインディングだけで生きているので平気でした。

Q3: 写真に写ってるMacBook ProとMac mini、どっちを使ってるの?

A3: MacBook Proだけが会社の貸与品で、業務時間かどうかで使い分けています。KVM切り替え機だとUSB Type-C 2入力に対応できないのでHHKBは都度差し替えています。都度差し替えるのはイケてないですね…

Q4: ディスプレイは何を使ってるの?

A4: Acerの34インチUWQHD(3440×1440)ディスプレイをエルゴトロンのモニターアームで吊っています。


  1. 平均すると1日に10回くらいは立ったり座ったり切り替えている気がします

PHPerKaigi2022でPHPからGoogle Assistantを使う話をしました

かなり時間が空いてしまいましたが、先月行われたPHPerKaigi 2022にて、「PHPerだってPHPから「OKグーグル」したい!」というタイトルで発表しました。

発表の内容としてはPHPからgRPCを使ってGoogle Assistant APIを叩くというものでした。プレゼンの最後にPHPから「全部消して」という命令を投げて自分の部屋を真っ暗にするデモを実施したのですが、無事成功してウケたので満足です。

今回のコードは下記URLで公開しています。皆さんもOKグーグルしてみてください。

PHP+gRPC 仲間が少なすぎてしんどい問題

今回の発表内容は「やればできるでしょ」というレベルの話に見えると思うんですが、実は動かすまでにかなり苦労しました。gRPCなので使えそうなクラスや引数の型定義などは自動生成されるのですが、自分のやりたいことを実現するのに何をどう呼び出すかのドキュメントがなく、かなりの試行錯誤が必要でした。他の言語の実装を参考にしていたのですが、言語が変わるとインターフェースも変わってしまうので、そこに惑わされたところもあります。

そもそもPHPからGoogle Assistant APIを叩いたのは私が世界で初めてだった可能性があるようで、少なくともGitHub上では仲間は見つかりませんでした(いま検索しても見つかるのは私が上げたものだけです)。

また、PHP+gRPCでSSL接続する方法を調べていたら、半年前まで不可能だったのを修正したよ!という記事を発見して衝撃を受けました。皆さんSSLなしでどうやって利用してたんですか?というレベルの話に見えます。

note.com

そもそもPHP+gRPCの記事自体ネット上であまり見ないんですよね。PHPについていうとgRPCクライアントは作れるけどgRPCサーバは作れない言語1なので、PHPユーザーの中でgRPCが流行ってないというのはありそうですが、それにしたって情報が少なすぎる印象です。

PHP+gRPCを利用している会社さんはそれぞれ工夫や苦労があると思いますので、小ネタでも公開して頂けると界隈も盛り上がるように思います。

gRPCの所感

私はgRPC自体初体験だったので、その所感も少し紹介します。

gRPCの特徴的な点は、各言語のライブラリ・モジュールレベルで自動生成が行われる点だと感じました。

たとえばGoの場合はgRPCの自動生成コードをGitリポジトリとして共有すればそのままライブラリとして利用することができます(参照:https://github.com/googleapis/go-genproto)。大量のAPIを大人数に使って貰うような状況であれば、gRPCを採用することでライブラリのメンテナンスコストを下げられそうですね。

PHPの場合も多くのクラスが自動生成されるので、誰が書いても同じコードになりやすく、大人数開発では特にメリットが大きそうです。

その代償というわけではないでしょうが、仕組みが複雑すぎてハードルが高いように感じました。私は環境セットアップで心が折れかけました。

PHPerKaigi 2022について

今回のPHPerKaigi 2022は会場参加もできるしリモート参加もできる、ハイブリッド開催でした。私は初日は会場に行き、2日目はリモート参加していたのですが、好きな方を選べるのは参加の幅を広げられて良いですね。

発表内容も多岐にわたっていて面白いものばかりで、久々に刺激をもらえました。はせがわさんはじめ運営の皆様、本当にありがとうございました。


  1. かなり頑張ればできるという説もありますが、実用的かは疑問です

PHPerKaigi 2021でPHPの不変配列が高速かつ省メモリだという話をしました

この3/26〜3/28にPHPerKaigi 2021 という勉強会があり、私は「PHP7から不変配列がOPcacheに乗るのでKVSを置き換えられるかもしれないという話」というタイトルで発表しました。

改めて見直してみると発表タイトルちょっと何言ってるか分からないですね。言いたかったこととしては「PHP5まではPHP単体よりKVSを使った方が断然マシな状況があったけど、PHP7+OPcacheならKVSに勝てる」ということなんですが、全然伝わらないタイトルになっていましたね…。反省です。

内容としてはOPcahce有効のときに限りPHPコンパイル時に全要素を確定できる配列(不変配列)が特別扱いされて、これが高速かつ省メモリですという話を紹介しました。

本ブログの記事「PHP7から定数配列がOPcacheに乗るので巨大配列が使い放題という話」の焼き直しではあるんですが、新たに調べた内容もあり、たとえば以下のグラフは新作です。

f:id:hnw:20210329004404p:plain
OPcacheとKVSの速度比較

f:id:hnw:20210329004306p:plain
OPcacheとKVSのメモリ消費の比較

今回、プレゼンした後に何回か質問されたので、それらについて補足します。

Q1: コレどういうユースケースを想定してるの?

発表した内容は実際に活用してるの?と複数回聞かれました。私の身近では近い利用法をしているプロジェクトがあるのですが、更新が低頻度で巨大なマスターデータを扱うというのは珍しいニーズかもしれません。

私の会社はスマートフォンゲームの開発をしているのですが、スマートフォンゲームではプランナー職の方が巨大なマスターデータを作ることがあるんですね。今回、39万要素の配列を例に出しましたけど、長期運営タイトルだと本当にこれくらいの規模になったりします1。さらに、こうしたデータはサーバだけでなくアプリにも組み込むこともあり、更新の頻度はそこまで高くありません。

そんなわけで、たとえばECサイトとかではあまり使い道がなさそうですが、我々の同業他社さんだと近いニーズがあるように思います。

Q2: OPcache無しのとき不変配列の扱いはどうなるの?

OPcache拡張が有効になっていない場合、全ての配列は従来通りopcode列にコンパイルされるので速度面のメリットはありません。不変配列の判定と構築はOPcacheの最適化フェーズで行われており、PHP本体のコンパイル処理では不変配列に関する特別な処理は何もないのです。

Q3: 不変配列が爆速になるのって2回目以降のアクセスでしょ?

その通りです。説明をサボってました。

1回目のアクセスはOPcacheに乗っていないので、PHPスクリプトのコンパイルをしてOPcahceの最適化処理が走って共有メモリ上に配列を構築する必要があるので、かなり遅いです。本気で使うならpreloadingとopcache_compile_file()の利用を検討した方がいいでしょう。

逆に2回目アクセスではキャッシュから取り出したopcode列の時点で不変配列を参照しているので、そりゃ速いよねという理屈です。

感想など

今年のPHPerKaigiはオンラインでしたが、非常に面白かったです。講演は事前収録でしたが、自分の収録を見ながらDiscordで出た質問に答えるという体験のは新鮮でした。

また、飽きさせないような仕掛けが多いのも良かったと思います。講演中にニコニコでコメントが流れてくるのも良かったですし、休み時間にDiscordで雑談がはじまったり、Zoomでアンカンファレンスが開催されていたりで、オフラインとは違った面白さを感じました。

最後になりますが、スタッフの皆様、今年もおつかれさまでした。これほどの規模になると運営は本当に大変だと思いますが、来年も期待しておりますのでよろしくお願いいたします。


  1. 縦だけでなく横にも長いことが多いです

特定ホスト名の通信だけVPN経由にするルータ設定(OpenWrt編)

自宅のルーターの設定で、普段の通信はデフォルトゲートウェイを使いたいけど、一部のホスト名の通信だけはVPNトンネルインターフェースを使いたい、という状況がまれにあります。一般的なニーズではないと思いますが、少なくとも私にはそういうニーズがありました。

IPアドレスごとに外向きインターフェースを切り替えたいのであればiptablesの設定だけで実現できます。一方で、ホスト名によってインターフェースを切り替えるのは一般的には困難です(ホスト名の解決はアプリケーション層で行われるのに対し、iptablesはネットワーク層・トランスポート層での処理になるため)。このような場合に、IPset経由でdnsmasqとiptablesを連携してルーティングを切り替える方法があります。本稿ではこのやり方を説明します。

OpenWrtとは

OpenWrtは組み込み用途のLinuxディストリビューションで、家庭用の有線LANルータ・Wi-Fiルータのファームウェアを置き換えることで機能追加をしたりカスタマイズ性を高めたりしようというプロジェクトです。ルータの持つ基本的な機能(PPPoEやファイアウォールの設定)に加え、DDNS・VPN・VLAN・QoSなどの設定がGUIから可能になります。また、ルータにsshでログインできるようになるので、トラブルの切り分けがやりやすくなるメリットもあります。自宅のネットワークで色々遊びたい人にはお勧めの選択肢です。

必要パッケージの追加

ではOpenWrtでの設定を見ていきましょう。まずは必要パッケージをインストールします。

# opkg update
# opkg install ipset kmod-ipt-ipset dnsmasq-full luci-app-mwan3

dnsmasqは最初からインストールされているものでは機能不足で、full版に差し替える必要があります。

DNS正引きのログ出力

今回のような実験的な取り組みを行う場合、動作確認やトラブル対応のためにDNSクエリをログに出しておくと便利です。

Web管理画面(LuCI)から「Network」「DHCP and DNS」「General Settings」「Log queries」をチェックするとログに全DNSクエリがログに残ります。ログは logread コマンドで確認できます。

# logread
(略)
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 query[A] android.googleapis.com from 192.168.2.101
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 forwarded android.googleapis.com to 192.168.1.1
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 reply android.googleapis.com is 172.217.24.138

IPsetのセット作成・動作確認

IPsetとは、IPアドレスやその他のネットワーク情報を高速に検索できるLinux上のオンメモリデータベースです。iptablesと組み合わせて使う前提の仕組みで、iptablesで大量のルールを扱うのに利用したり、今回のように別ツールと組み合わせて使ったりできます。

IPsetでは1つのデータベースをセットとよび、セットに対してIPアドレスやネットワーク範囲を追加・削除することができます。今回はforce_usというセットを作ります。

# ipset create force_us hash:ip

次に、/etc/config/dhcp を直接変更して、特定ホスト名をDNS正引きするとそのIPアドレスがforce_usセットに追加されるようにします。

config dnsmasq
    (略)
    list ipset '/api.example.com/api2.example.com/force_us'

このように、スラッシュ区切りで複数のホスト名を指定できます。また、ホスト名の後方一致でドメイン名を指定することもできます。

最後に dnsmasq を再起動して動作確認をします。DNS正引きした結果がセットに追加されていれば成功です。

# kdig a api.example.com +short
192.0.2.32
# ipset list force_us
Name: force_us
Type: hash:ip
Revision: 6
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 368
References: 1
Number of entries: 1
Members:
192.0.2.32

mwan3の設定

mwan3は外向きロードバランシング用のOpenWrt独自パッケージで、iptablesのラッパーです。今回のように外向きインターフェースを使い分けたいだけの場合にはオーバースペックですが、Web管理画面から設定できて楽なので利用しています。

mwan3の設定で、先ほど作ったforce_usセットにマッチするIPアドレス宛てなら別のインターフェースを使うよう設定していきましょう。

まずWeb管理画面「Network」「Load Balancing」「Interfaces」の「Add」からvpnusインターフェース(OpenVPNで設定したVPNトンネルインターフェース)を設定します。これで既存インターフェースがmwan3の管理下に置かれて死活監視が行われるようになります。

次に、「Network」「Load Balancing」「Members」からvpnus_m1_w3というメンバーを追加します。ここでインターフェースとして先ほど設定したvpnusインターフェースを指定します。

f:id:hnw:20210222230837p:plain
mwan3のmember設定

さらに「Network」「Load Balancing」「Policies」からvpnus_onlyポリシーを追加します。ここで先ほどのvpnus_m1_w3メンバーを指定します。

f:id:hnw:20210222231019p:plain
mwan3のpolicy設定

最後に「Network」「Load Balancing」「Rules」でforce_us_v4というルールを作ります。IPsetについては先ほど作成した「force_us」を指定し、「Policy assigned」は先ほど作成した「vpnus_only」ポリシーを指定します。

f:id:hnw:20210222231137p:plain
mwan3のrule設定

以上の設定で、特定ホスト宛の通信をVPN経由にすることができました。

ipsetの保存

ルータを再起動しても同じ設定を維持するため、IPsetのforce_usセットをルータ起動時に作るようにします。

/etc/config/firewallを直接変更しましょう。

config ipset
    option enabled '1'
    option name 'force_us'
    option storage 'hash'
    option family 'ipv4'
    option match 'dest_ip'

制限事項

今回の仕組みが期待通り動作するためには、通信を行う前にDNSの正引きが行われる必要があります。逆に言うと、IPアドレスでアクセスするようなサービスでは使えません。ストリーミング系サービスの中にはAPIサーバからストリーミングサーバのIPアドレスが返ってくるようなものがありますが、こうした場合には適用できません。また、端末がルータのDNSを利用していないような場合も無力です。

まとめ

OpenWrtルータ上でdnsmasqとIPsetを連携させて特定ドメイン・特定ホスト宛の通信だけをVPN経由にすることができました。1ヶ月ほど使っていますが、今のところ期待通りに動作しています。

参考URL

'); $entries_chunk.insertBefore(sections[0]); } else { chunk_id += 1; var $prev_entries_chunk = $entries_chunk; var $read_more_link = $('

これ以前の記事を表示する

'); $read_more_link.on('click', {chunk_id: chunk_id}, function(e){ $(e.target).hide(); $(this).remove(); $('#entries-chunk-' + e.data.chunk_id).fadeIn("slow"); }); $prev_entries_chunk.append($read_more_link); var $entries_chunk = $('
'); $entries_chunk.hide(); $entries_chunk.insertAfter($prev_entries_chunk); } } $(sections[i]).appendTo($entries_chunk); } });