なぜこの技術・この形を選んだかをここに置く。手順と進捗は ../ROADMAP.md。
移行の途中でバックアップの互換性が切れないように、「不変にするもの」を先に決める。
| 不変(移行をまたいで同じ) | 捨てる(k3s 期限定) |
|---|---|
| restic リポジトリ形式・パスフレーズ・バケット | ホスト側 backup スクリプト + systemd timer |
local-path provisioner の PV ディレクトリ構造(pvc-<uid>_<ns>_<name>。親のパスは変わる ── k3s は /var/lib/rancher/k3s/storage、Talos は user volume の /var/mnt/local-path) |
state.db の sqlite .backup スナップショット |
bootstrap/ の中身(Argo CD・Infisical・Cilium・cert-manager・auth の定義)。Talos では、入れるもの(Helm と SOPS の Secret)は machine config の cluster.inlineManifests、平文で当てるものは CI(下記「層の分け方」) |
|
公開の復元手順(秘密を含まない、curl | sh できる)という性質 |
setup-network.sh / k3s-server-config.yaml / init.sh / restore.sh(k3s 期専用。Talos 期は machine config + git からの再構築 + k8up の Job に置き換わる) |
Infisical のデータ(Postgres PVC)と ENCRYPTION_KEY |
HelmChart CRD(helm.cattle.io)、k3s 同梱の Traefik / ServiceLB |
ArgoCD + ApplicationSet(repos.yaml)+ 各 repo の deploy/argocd.yaml |
firewalld 無効化などホストの手作業 |
| Traefik(k3s 同梱だから使っていただけ。IngressRoute / Middleware(forward-auth)/ mydnschallenge ACME は Talos では持ち込まない) |
Talos ではホストに触れないので、「k8s オブジェクト」は git から ArgoCD で再構築(etcd スナップショットは近道)、 「ホスト設定」は machine config(git 管理)、「PV データ」だけが従来どおりバックアップツールの仕事になる。 ツール選定は PV データの部分にしか効かない。
当初は「HP Z440 へ Ubuntu のまま移してから Talos」の順だった。逆にした:先にいまの DL360 の上で k3s → Talos (Phase 2、migration-day.md)、そのあと Talos のまま Z440 へ(Phase 2.5、z440-migration.md)。
- Z440 は単体の GPU が無いと POST しない(Xeon E5 に内蔵 GPU は無い)ので、ヘッドレスで組めない。 Intel Arc A310 を常設する(UEFI + CSM 無効が要る)。これが denpa の QSV / AV1 エンコードも兼ねる
- Z440 の遠隔管理は AMT 9.x(電源と SOL だけ、KVM なし)。一番危ない OS の入れ替えは、 iLO(10.0.0.3)で画面まで見える DL360 でやる
- Talos に移ってしまえば、箱の違いは schematic(GPU の extension、SOL 用の
console=)と NIC の名前に閉じる
ルーターの IPv6 ホスト公開はノードを bond0 の MAC(e6:2c:1b:1a:a0:39)で指している
(router.md「IPv6 はノードだけを公開する」)。Ubuntu では bond0 の MAC を固定しておらず、
2026-10-17 に再起動のあと 3a:43:95:a9:3e:b1 に変わっているのを見つけた(このままだとエントリから外れる)。
- Ubuntu: netplan で固定した(ネットワークは 2026-09-24 から netplan → systemd-networkd。それまでは NetworkManager)
- Talos: talos/patches/cluster.yaml を型付きの
BondConfig/LinkConfigに移し、hardwareAddr: e6:2c:1b:1a:a0:39を書いた。bond に固定値を与えるので、Talos への移行でも Z440 への移設でも変わらない
判断基準は、(1) k3s 期は ホスト側で、Talos 期は in-cluster で動かせること、(2) その両方で 同じリポジトリ・同じ復元コマンド が使えること、 (3) 復元の依存物が「バイナリ 1 つ + 秘密 1 組」で済むこと、の 3 つ。
| 候補 | k3s 期(ホスト) | Talos 期(in-cluster) | 判定 |
|---|---|---|---|
| restic | systemd timer で直接 | k8up(restic の operator)か restic イメージの CronJob | ◎ 両期で同じ repo 形式。手動復元も restic restore 1 本 |
| kopia | 同上 | Velero の node-agent が kopia だが Velero 固有のレイアウトで、kopia CLI からの手動復元が素直でない | ○ 単体性能は上だが後継が繋がらない |
| Velero | 使えない(ホスト対象外) | k8s オブジェクトも一緒に取れる | △ Talos では git からの再構築と役割が被り、復元に Velero 自体の導入が要る |
| k8up 単体 | 使えない | ○ | k3s 期のホスト側(state.db、/etc)を別途どうにかする必要がある |
| tar + rclone crypt | ○ | 使えない | △ 変更最小だが Talos に持っていけない、整合性 check が無い |
| borg / duplicacy | ○ | 使えない or ライセンス | × |
| Longhorn 等のストレージ層バックアップ | ストレージ入れ替え | ○ | × 単一ノードには過剰 |
結論: restic。 k3s 期はホストの systemd timer、Talos 期は k8up に乗り換えるが、リポジトリもパスフレーズも復元コマンドも変えない。 k8up の中身は素の restic なので、k8up が気に入らなければ CronJob + restic イメージに落とせるし、k8up の取った スナップショットも手元の restic CLI で読める。将来 rustic(restic 互換の Rust 実装)へ逃げる道もある。
手で持つのは age のパスフレーズ 1 つだけ。 他は全部、そのパスフレーズから辿れるところに置いてある。
| 置き場 | 開け方 | |
|---|---|---|
| R2 の鍵 + restic のパスフレーズ | recovery/env.age(公開 repo) |
パスフレーズで直接(age -d) |
| SOPS の age 秘密鍵 | recovery/sops-age.key.age(公開 repo) |
パスフレーズで直接(age -d) |
Talos の secrets.yaml |
talos/secrets.yaml(公開 repo・SOPS 済み) |
上の age 鍵で(sops -d) |
bootstrap/ の Secret 4 つ |
公開 repo・SOPS 済み | 同上 |
recovery/ の 2 つだけ age -p なのは、それが「まだ何も無い状態で最初に開けるもの」だから。
それ以外は age 鍵さえ手に入れば開くので、bootstrap/ と同じ SOPS に揃えてある(例外を増やさない)。
talosconfig は保存しない。 secrets.yaml から talosctl gen config で毎回出てくる派生物で、
TTL も付いている。使い捨てにするほうが安全(../talos/README.md)。
公開 repo に暗号文が載っている以上、オフラインで無限に総当たりされる。強度がすべてなので
diceware 6〜7 語相当を使う。置き場は次の 3 つ。同期ストレージ(OneDrive など)を正本にしない ──
復元のときに Microsoft アカウントへのログインと MFA デバイスが要るようになり、
「curl 1 本とパスフレーズだけ」という前提が崩れる。
| 何を置くか | 理由 | |
|---|---|---|
| パスワードマネージャ(正本) | パスフレーズ + 何を開ける鍵かと復元手順の URL | 携帯から取れる。クラスタの Entra とは独立した認証であること(Infisical は循環するので不可) |
| 紙、2 か所(1 つは家の外) | 同上 + recovery/*.age を base64 で印刷 |
火事・盗難・端末紛失に耐える。2 ファイル合わせて 19 行しかない |
| 記憶 | パスフレーズ | 出先で全部が閉じている状況を消す。ただし単独では信用しない |
紙に recovery/*.age も刷るのは、GitHub が使えない場合のため。 「GitHub へのログインは要らない」設計では
あるが、リポジトリが読めることは前提にしている(公開停止・障害・アカウント凍結)。
env.age が 660 バイト、sops-age.key.age が 371 バイトなので、印刷しても数分の手間にしかならない。
紙には必ず「何の鍵か」と recovery/README.md への道筋を書く。 数年後に見つけた自分が、
それが何か分からないのでは意味がない。
暗号文の副本は OneDrive の Personal Vault でよい。 あれは公開 repo に載っているものなので、 置き場に機密性を求めていない(Microsoft が鍵を持っていても関係ない)。無料プランの 「3 ファイルまで」にも収まる。ただしパスフレーズは同じ場所に入れない ── 入れると Microsoft アカウント 1 つで全部になる。
- 暗号文を公開 repo から外す。 「パスフレーズ + 保管先」の 2 つが要る形になり防御は一段厚くなるが、
curl1 本で始められるという復旧手順の軽さを失う。暗号文が公開でも、パスフレーズが diceware 6〜7 語なら総当たりは成立しない。 - YubiKey(
age-plugin-yubikey)で SOPS の鍵をハードウェアに置く。 効くのは保管場所の問題ではなく 「復号のたびに秘密が手元の PC の RAM とディスクに載る」ほうで、そちらのほうが実害の確率は高い。 ただし YubiKey 2 本 + 紙の復旧用鍵が要り、復旧時にpcscdと plugin の導入が増える。 入れるとしたら SOPS の鍵だけ(recovery/env.ageは最初に開けるものなので軽いまま残す)。age -pと他の recipient は同じファイルに混ぜられないので、この分け方は技術的にも自然。
秘密 gist や平文コミットにしなかった理由: R2 の鍵は「バックアップを消せる鍵」で、GitHub のトークンはあちこちに
散っている(~/.git-credentials、gh、Actions、ArgoCD の App 資格情報)ので、GitHub 側が丸ごと漏れても
バックアップだけは無事な状態を残す。
リモートは Cloudflare R2 のバケット doany-restic(APAC、Standard)に決定(2026-09-06)。API トークンは
Object Read & Write をこのバケットだけに絞った Account API token。
復元時に rclone config の対話が要らなくなり、in-cluster(k8up)からも同じ設定で使える。
- k3s 期: ホストの
backup/k3s-backupが、対象 namespace を scale down してからコピーする。 - Talos 期: scale down しない。 k8up の
k8up.io/backupcommandで論理バックアップを取る。 2026-09-08 に全 11 namespace で完了(RDBMS 3 つはpg_dump/mariadb-dump、SQLite 6 つはbun:sqliteのserialize()、ファイルの PVC は注釈)。どれが何で取れているかの一覧は ../apps/k8up/README.md ── ここには写さない(二重に持つと片方が腐る)。
k3s の HelmChart CRD(helm.cattle.io/v1)は Talos に無い。いま bootstrap/ に残っている
argocd と infisical はどちらもそれで入れているので、移行時に置き換えが要る。
「inlineManifests に載せる」と一言で書いていたが、inlineManifests は Helm を実行できないので
そのままでは移せない。
「ArgoCD に infisical を預けると鶏卵になる」としていたが、実際には循環していない。
ArgoCD の設定にある $argocd-oidc:client-secret のような参照は、
util/settings/settings.go で
こう扱われる:
secretVal, ok := secretValues[secretKey]
if !ok {
log.Warn("secret key does not exist in secret") // 警告するだけ
return val // 文字列をそのまま返す
}Secret が無くても落ちない。 OIDC が未解決のまま起動し、SSO ログインだけが効かない。 そして 同期の動作自体に誰のログインも要らない。つまり順序は素直に解ける:
Cilium → ArgoCD 起動 → ArgoCD が Infisical を入れる → operator が Secret を作る → SSO が効くようになる
承知しておくこと: admin.enabled: false にしてあるので、この窓の間は UI に誰も入れない
(SSO 未解決 + ローカル admin 無効)。困ることがあれば kubectl で見る。移行当日に UI が要るなら、
一時的に admin.enabled を戻す。
| 層 | 中身 | 資格情報 |
|---|---|---|
| machine config | bootstrap/ のほぼ全部。 inline が 9 つ(cilium / local-path / metrics-server / bootstrap-applier の RBAC / argocd / cert-manager / infisical / SOPS の Secret 2 つ)、CRD だけ URL で 5 つ(gateway-api / argocd ×3 / cert-manager) |
machine config 自体が SOPS 済みなので同じ信頼水準 |
| GitHub Actions | bootstrap/ の残り(ClusterIssuer・Gateway・auth・InfisicalSecret など、平文のもの) |
OIDC。Secret 権限なし・delete なしの狭い RBAC |
| ArgoCD | apps/(infisical は来ない。下の「infisical だけは ArgoCD に移せない」) |
— |
CRD だけ URL で渡すのは大きさの都合。 argocd 1.83 MB・cert-manager 1.30 MB・
gateway-api 1.1 MB で、埋めると machine config が 341 KB → 4 MB を超える。
KubeExternalManifestConfig は Talos がそのために用意している入口
(talos/render.sh)。
なぜ ArgoCD を CI 側に置かないか。 ArgoCD の導入には CRD・ClusterRole・Secret が要る ── つまり実質 cluster-admin。CI の RBAC を狭く保っている意味が消える (bootstrap/apiserver/rbac.yaml)。入れるものと当てるものを層で分ける。
なぜ SOPS の Secret を machine config に入れるか。 CI には age 鍵を渡さない方針なので、
CI からは当てられない。一方 machine config はもともと SOPS で暗号化して git に置いている
(クラスタ CA の秘密鍵を含むので当然)。同じ場所に置いても信頼水準は変わらないうえ、
起動時点で存在するので順序の問題も消える。復号は手元の talosctl gen config のときだけ起きる。
Helm の出力をコミットすることについて。 Cilium は CNI なので inlineManifests 以外に置きようがなく、
helm template の出力を持つのは避けられない(Talos 公式も同じ形)。ArgoCD も同じ扱いにすれば、
Helm を実行する場所がクラスタの外(手元の生成時)だけになり、クラスタ内に Helm コントローラを
持たなくて済む。版の追従は talos/versions.yaml と同じく Renovate に見せる。
当初の見立てとのずれ: 「手で apply する層は消える」「inlineManifests は reconcile されない」はどちらも外れ
(上の二段になり、talosctl upgrade-k8s を通せば更新も削除もされる。talos.md)。
Talos では Cilium を inlineManifests に載せる。では bootstrap/cilium/values.yaml と
machine config の中の Cilium を、人が手で合わせるのか。 合わせない。machine config を
「派生物」にする。
helm template の出力を KubeInlineManifestConfig に包んで、ただの --config-patch として渡す
(talos/render.sh がやる)。Cilium の値がリポジトリに二度書かれることが無くなる。
2026-09-08 に確かめた: 2329 行の manifest が埋まり、talosctl validate --mode metal を通る。
生成物では 1 行のエスケープ文字列になるので、machine config 自体は 450 行のまま読める。
Renovate は bootstrap/cilium/version.yaml を見ているので、版が上がれば PR が来る。
machine config は毎回そこから描き直されるだけで、追従の作業は無い。
inlineManifests は talosctl upgrade-k8s を通すと reconcile される(talos.md)。
upgrade-k8s は Kubernetes を上げるときの通常の操作でもあるので、
machine config の中の Cilium が古いまま流すと、走っている Cilium が巻き戻る。
したがって upgrade-k8s の前には必ず machine config を描き直す。手順の一部として書くこと。
Cilium の更新経路は「values.yaml を直す → machine config を描き直す → upgrade-k8s」になる。
bootstrap/cilium/values.yaml が正本なのは変わらないが、当てる道具が替わる。
upgrade-k8s 自身も差分を出す(< configured ... と diff)。
cilium-drift.yml は Talos でも続ける(2026-10-05 から、サーバの版で
k3s / Talos を見分けて talos/cilium-values.yaml を重ねる)。
Cilium を machine config に載せず、bootstrap のあとに helm install する。 罠は消えるが、
再構築のたびに手作業が 1 つ増える(しかも「CNI が無いので何も動かない」状態での作業)。
Sidero は inline manifest を production 推奨、CLI での install を "least declarative" としている。
移行前に Traefik に依存していたもの: 標準 Ingress 15、IngressRoute 4、IngressRouteTCP 1(3proxy)、
Middleware 4、ACME は Traefik 内蔵(Cloudflare DNS-01)。forward-auth の先は oauth2-proxy + redis(IdP は Entra ID)。
LoadBalancer は k3s 組み込みの ServiceLB(Klipper)。
前提: Ingress API は凍結済みで、ingress-nginx は 2026-03-24 に retire(read-only、CVE 修正なし)。
新規に組むなら Gateway API。F5/NGINX Inc. の nginxinc/kubernetes-ingress は別プロジェクトで継続中。
Talos で後から替えるのが高いのは CNI だけなので、そこを先に決める。Ingress コントローラ・LB・証明書は Gateway API に寄せてあれば後から差し替えられる。単一ノードのうちは Cilium は過剰に見えるが、 ハイブリッド化の見込みがあるならここで払うのが一番安い。VM で実際に組んで動くことを確認済み(2026-09-06)。
| 選択 | 置き換わるもの | |
|---|---|---|
| CNI | Cilium | flannel |
| kube-proxy | Cilium の kubeProxyReplacement | kube-proxy |
| LoadBalancer | 使わない(hostPort と Gateway の hostNetwork で足りた。下記) | ServiceLB(Klipper)。MetalLB は不要になる |
| Ingress | Cilium Gateway(Gateway API) | Traefik |
| 証明書 | cert-manager(Cloudflare DNS-01) | Traefik 内蔵 ACME |
| 認証 | oauth2-proxy を ext auth で外付け(現状維持) | — |
認証だけは Gateway 内蔵に寄せない。 Entra のトークンは大きく(oauth2-proxy の redis セッション実測 4293 バイト)、 Cookie に載せる方式は 4096 バイトの壁に当たる。oauth2-proxy なら Cookie はセッション ID だけなのでトークン長に依存しない。 これは実装選択と独立に効く判断。
Cilium 1.20 は Gateway API v1.6.1 の CRD を要求する(backendtlspolicies / tlsroutes を含む)。古いと
GatewayClass が Waiting for controller のまま止まる。上書きは --server-side --force-conflicts と cilium-operator の再起動。
k3s のまま flannel と kube-proxy を外して Cilium 1.20.1 を入れた(30 分ほど)。詰まりは 2 つ:
- flannel の残骸(
flannel.1/flannel-v6.1/cni0)が Cilium の VXLAN と衝突する。ip link deleteで消す(conflist の退避だけでは足りない) terminationGracePeriodSecondsが長い Pod(denpa と tuner-agent、6 時間)は Recreate と組むと戻らない。--grace-period=0 --forceで消す
ノード自身からは 80/443 に届かない。 Gateway の nodePort が L7LB リダイレクトとして載っているためで、 それが外から来る通信の経路そのものなので外せない(他のポートと、Pod からは届く)。
Talos には k3s の ServiceLB(klipper)が無いので、その差分を k3s のうちに埋めた。hostPort を選んだ。
ServiceLB はノード自身の IP(10.0.0.2 / 10.10.0.4 / 240f:6d:842b:1::2)をそのまま EXTERNAL-IP にする作りで、
ルータの DMZ 転送先と AdGuard の split-horizon がこの IP に固定されている。Cilium LB-IPAM で仮想 IP を払い出すと
クラスタ外(ルータと DNS)の変更が必要になり、IPv6 のプレフィックスは RA 由来で変わりうるため externalIPs への
固定書きも危うい。hostPort ならノードの全アドレスで受けられ、送信元 IP も保たれ、Talos でも同じ形が使える。
ノードが増えたときに VIP が要るなら、そのとき LB-IPAM に移ればよい。
| 対象 | hostPort |
|---|---|
Gateway(cilium-gateway-doany) |
80 / 443。Envoy が hostNetwork で bind するが、実通信は nodePort の L7LB リダイレクト経由(2026-09-08 実測。bootstrap/cilium/values.yaml の nodePort)。手で当てた 80/443 は今も要る |
| adguardhome | 53 UDP・53 TCP・853 TCP |
| matrix の LiveKit(Element Call。もとは Mattermost Calls の番号) | 8443 UDP・8443 TCP |
| 3proxy(tls-terminator サイドカー) | 3129 TCP |
詰まった点 3 つ:
- Cilium の hostPort は既定で無効。
hostPort.enabled=true(あわせてnodePort.enabledとexternalIPs.enabled)を 有効にしないと、Pod は Running なのにホストのポートに何も来ない。ssには現れない(eBPF なので)。 確認はcilium-dbg service list | grep HostPort。 - hostPort は Pod のサンドボックス作成時に設定される。 設定を有効にしたあと、対象の Pod を作り直す必要がある。
- hostPort と RollingUpdate は両立しない。 新旧の Pod が同じホストポートを奪い合い、新しい方が Pending で止まる。
AdGuard と 3proxy は
strategy: Recreateにして解決した(当時の Traefik はdeployment.kind: DaemonSet)。
Gateway はアドレスが付くまで Programmed=False で、Envoy にリスナーが載らない。gatewayAPI.hostNetwork に
したらノードの IP がそのまま status.addresses に入ったので、LB-IPAM と L2 アナウンスは外した(2026-09-07)。
cert-manager の Cloudflare トークンは cert-manager の namespace にも Secret が要る。
Traefik では IngressRouteTCP が HostSNI(px.doany.io) で TLS を終端し、中身を素の TCP として 3proxy:3128 に流していた。
Gateway API では protocol: TLS + mode: Terminate のリスナーに TCPRoute を付ける形が同じ意味になるが、
Cilium はそのリスナーに TLSRoute しか許さない(仕様上は書けるが実装が無い。Envoy Gateway は実装している)。
同じ 443 に Terminate と Passthrough を同居させると ProtocolConflict になり、443 の https リスナーごと落ちた。
採った形(2026-09-06): Gateway を使わず、Pod のサイドカーがホストのポートを直接受ける。
3proxy の Pod に nginx(stream)のサイドカーを足し、hostPort 3129 で TLS を終端して 127.0.0.1:3128 に渡す。
証明書は cert-manager が px.doany.io で発行してマウントする。クライアントは px.doany.io:3129 に向け直した ──
SNI で振り分けるよりポートで分ける方が構成として素直なので本採用。
Gateway の Service は externalTrafficPolicy: Cluster。単一ノードでは backend が必ず同じノードに居るので
SNAT されない。実測: denpa は公開側からだと 401・LAN からだと 200(SNAT されていれば公開側も通る)、
AdGuard の DoH のクエリログに端末のグローバル IPv6 が残る。
ノードを足すときにやること: externalTrafficPolicy: Local に変える
(Cilium の Helm 値 gatewayAPI.externalTrafficPolicy)。そうしないと Envoy が居ないノードに
届いたぶんが SNAT されて、AdGuard のクライアント別統計と denpa の住所判定が壊れる。
Local にすると Envoy の居るノードだけが応答するので、外側の振り分けもそれに合わせる。
切り替えの翌朝、ブラウザの名前解決が全部死んだ。 原因は d.doany.io の backend を平文の 80 にしたこと。
AdGuard は DoH(/dns-query)を HTTPS の口(443)でしか出さない。80 は 404 を返す。
Edge の「セキュア DNS」に DoH を入れていたので、DoT と plain は生きたままブラウザだけ死ぬ、という分かりにくい形になった。
Traefik では backend にも HTTPS で繋いでいた。その置き換えを「クラスタ内なので平文でよい」と判断したのが誤り。
DoH は平文の口には出ない。
| 試して駄目だった案 | 結果 |
|---|---|
BackendTLSPolicy(Gateway API 標準) |
Cilium 1.20.1 は Accepted にするだけで実装していない。 backend へ平文のまま繋ぎ、Web UI まで 400 になった |
AdGuard の tls.allow_unencrypted_doh: true |
AdGuard は停止時に設定ファイルを書き直すので、Pod を止めずに足した値は消える |
採った形: nginx のサイドカーを足して /dns-query だけをそこへ回す(同じ Pod の https://127.0.0.1:443 へ)。
3proxy と同じ形なので、Cilium が BackendTLSPolicy を実装したら両方まとめて消せる。
教訓: 前段の置き換えでは「同じ URL が同じものを返すか」だけでなく、その backend が 何をポートごとに出し分けているかを見る。HTTP のステータスだけ見ていると気付けない。
Cilium には Gateway API の OIDC が無い。 CiliumEnvoyConfig で Envoy の oauth2 フィルタを書く道は未サポート
(cilium/cilium#24848、#30587)、HTTPRoute の ExternalAuth(GEP-1494)も未実装(cilium/cilium#45704)。
forward-auth が要るルートは oauth2-proxy を前段のプロキシとして置く(HTTPRoute → oauth2-proxy → アプリ)。
アプリ側(ArgoCD、denpa、NetBird など)はそれぞれ自前で OIDC を持っている。
Entra のトークンを小さくする(グループクレーム → アプリロール)こと自体は独立して価値がある(entra.md)。
Traefik の hostPort 80/443 を外すのと Gateway をそこへ出すのは同時にしかできない。Gateway 側は Service の 80/443 を nodePort として開く形にした。詰まった点:
- Cilium は nodePort の範囲に入っている hostPort を張らない。 範囲を
80-32767に広げたら 853・8443・3proxy が 一斉に落ちた。nodePort.rangeを80,443の 2 つだけに絞ることで両立する。 - Traefik を消したあとも Service に残った
externalIPs: [10.10.0.4]が 80/443 を黒穴にする。service.kubernetes.io/load-balancer-cleanupfinalizer を null にして消した。 - 自分で配っている chart の
IngressRouteを取りこぼした。 denpa は chart で公開していたのでdp.doany.ioが 404 に。 chart にhttpRoute.enabledを足した(denpa#70)。gitops のIngressを数えるだけでは足りない。
2026-09-07 に Traefik のマニフェストと PVC traefik-acme を全部消した。ACME は cert-manager の ClusterIssuer letsencrypt が引き継いだ。
Service の sticky cookie だけは Cilium 側に受け皿が無い。 使っていたのは tamasagashi(ts.doany.io)1 本で、
ローリング更新中に「HTML を新 Pod から受けたブラウザが、ハッシュ付きの CSS/JS を旧 Pod に取りに行って 404」を防ぐためのもの。
- Gateway API の session persistence(GEP-1619)は experimental チャネル限定で、入っている v1.6.1 standard には無い
- Cilium 1.20.1 が未実装。 cilium/cilium#48029 で main に入ったので 1.21 から
Service.spec.sessionAffinity: ClientIPは Gateway 経路には効かない。 Envoy が EndpointSlice を直接見て振り分け、 送信元も全クライアント同じ(Gateway の Pod)になる
いまは穴が開いたまま受け入れている(replicas 1・状態なし。影響は更新中の 15 秒ほど。緩和は danything/tamasagashi の deploy/README.md)。
Cilium 1.21 に上げるときに、Gateway API CRD を experimental チャネルへ入れ替えて
tamasagashi の HTTPRoute に sessionPersistence を足すのが本筋。いまの CRD は traefik-crd の Helm リリース所有のまま
(helm.sh/resource-policy: keep で残存)なので、入れ替えの際は注意。
将来どれかのアプリを複数 replica にするなら、1.21 未満のあいだは Gateway 越しのセッション固定が無いことを先に確認すること。
| 案 | 理由 |
|---|---|
| Traefik 継続 | 書き換えは 0 で済むが、Traefik 固有 CRD への依存が続き、ハイブリッド化したときに移植性が無い。Talos では ServiceLB が無いので結局 MetalLB を足すことになり、部品は減らない |
| Envoy Gateway | Gateway API の機能は最も揃うが、単一ノードでは MetalLB と cert-manager を連れてきて部品が増える。内蔵 OIDC は Cookie 4096 バイトの制約(#7315)で当てにできず、主要な動機が消える。マイナーが 2 週間強に 1 回でバージョンマトリクスが重い |
| ingress-nginx | EOL |
| HAProxy / Kong / Istio | 単一ノードに過剰、または Gateway API 対応で劣る |
将来 Envoy Gateway を選ぶなら、OIDC はトークンを Cookie に保存するので次の既知の問題を先に見る:
| Issue | 内容 |
|---|---|
| envoyproxy/gateway#7315 | トークンが 4096 文字を超えるとブラウザが Cookie をセットせず、認証がループする。Envoy 側にエラーログが出ない |
| envoyproxy/gateway#8441 | OIDC Discovery が失敗すると毎リクエスト token introspection にフォールバック。IdP 停止時に白画面 |
| envoyproxy/gateway#8649 | Gateway レベルとルートレベルで SecurityPolicy を二重掛けすると CSRF 検証で落ちる |
「HelmChart」は 2 つの別物を指す。 ここを混ぜると話が噛み合わない。
| 何か | この構成での扱い | |
|---|---|---|
HelmChart CRD(helm.cattle.io/v1) |
k3s の入れ方。helm-controller が CR を見て helm を Job で走らせる | やめた(下記) |
| Helm chart そのもの | 配る形。Chart.yaml + templates/ を OCI で push する |
続ける。denpa と yosegaki は今も配っている |
chart が悪いのではなく、入れ方が合わなくなった。
- k3s 固有で Talos に無い。 OS を替える時点で全部書き直しになる
- CR を消すと helm がアンインストールされる(下の「HelmChart CRD から ArgoCD の Application へ」)
- ArgoCD と二重管理になる。 同じリソースを 2 つのリコンサイラが見ることになり、 差分も同期状態も prune の制御も ArgoCD 側から見えない
- Job で走るので chart 側の事情が漏れる。 erpnext(2026-10-06 に撤去)は Job 名に描き出した時刻を入れるので、 同期のたびに作り直された
ArgoCD が居ない k3s 単体なら悪い選択ではない。居るなら重複でしかない。
実際に踏んだものだけを挙げる(例の erpnext は 2026-10-06 に撤去したが、教訓は残す)。自分の chart(denpa / yosegaki)は
どれも該当していない(CRD 無し、名前は固定、podAnnotations と resources を出せる)。
| やらないこと | 踏んだ例 |
|---|---|
| リソース名に時刻や乱数を入れない | erpnext の erpnext-new-site-20260907103337。GitOps のリコンサイラは同期のたびに別物として作り直す。一度きりの Job なら名前を固定して、再実行は利用者に任せる |
CRD は templates/ ではなく crds/ に置く |
infisical の secrets-operator。templates/ にあるとアンインストールで CRD ごと消え、CR が全部巻き添えになる。crds/ なら helm は消さない |
全ワークロードに podAnnotations を出す |
erpnext の mariadb-sts に無く、k8up の backupcommand を付けられなかった。注釈は operator(k8up・Infisical・ArgoCD)が振る舞いを足す口 |
resources を出す。既定を空にしない |
erpnext 8.0.78 が連れてきた valkey subchart が resources: {} かつ maxmemory 無し。ノードの空きまで伸びうる |
| patch で中身を入れ替えない | erpnext 8.0.15 → 8.0.78(patch)が Dragonfly を Valkey に差し替え、values に書いたチューニングが黙って無効になった |
| 入口を既定で作らない | Ingress を既定 true にしない。denpa / yosegaki は httpRoute / ingress / traefik を全部既定 false にして選ばせている |
chart 側は入れ方を知らなくていい。helm install でも ArgoCD の Application でも
Flux の HelmRelease でも同じものが入るのが正しい。このクラスタは ArgoCD の
Application に統一した。
HelmChart(helm.cattle.io)は k3s の helm-controller が提供するもので Talos には無い。
OS 交換時の変数を減らすため、k3s のうちに ArgoCD の Application へ移した。
移す前に必ず helm template の出力と live を突き合わせる。 リリース名まで揃えれば
描き出されるものは一致し、引き取っても Pod は入れ替わらない。
secrets-operator のように名前を切り詰める chart があるので、リリース名を変えると別物として作り直しになる。
helm-controller は wrangler.cattle.io/on-helm-chart-remove finalizer で削除ジョブを走らせる。
finalizer を先に外してもすぐ付け直されるので効かない。しかも secrets-operator は
CRD を crds/ ではなく templates/ に置いているため、アンインストールすると CRD ごと消えて
InfisicalSecret 15 本が巻き添えになる(Secret は creationPolicy: Orphan なので残るが、
値の同期は止まる)。
移し方は 3 段: Application を先に作って Synced を確かめる → helm のリリース Secret
(owner=helm,name=<releaseName>)を消して、アンインストールを「対象なし」にする → HelmChart CR を消す。
cloudflare-ddns・infisical-operator・infisical-push-bridge・yosegaki・erpnext を移し終えた。argocd と infisical は移さない(下記)。
理由は鶏卵ではない。 ArgoCD は Infisical が無くても起動する(Secret が無いと SSO が 効かないだけ)ので、そこは問題にならなかった。駄目なのは chart の作り。
infisical-standalone 1.10.0 は接続文字列を Deployment の平文 env に焼き込む:
DB_CONNECTION_URI postgresql://infisical:<パスワード>@postgresql:5432/infisicalDB
REDIS_URL redis://default:<パスワード>@redis-master:6379
templates/_helpers.tpl が .Values.postgresql.auth.password と
.Values.redis.auth.password をそのまま printf している。ArgoCD の Application は
平文なので、ここに値を書くことはできない。
逃げ道は片方にしか無い。 postgresql.useExistingPostgresSecret はあるが、
postgresql.enabled: trueと併用するとDB_CONNECTION_URIが 2 回描き出される (kubelet は後ろ勝ちなので平文の方が勝つ)。使うなら同梱の Postgres を切って 自前で建てることになる- Redis には逃げ道が無い。
redisConnectionStringは.Values.redis.auth.passwordを 直接埋めるだけで、Secret を指す分岐が無い
postgresql.auth.existingSecret / redis.auth.existingSecret は subchart(bitnami)には
効くが、infisical 本体の env には効かない ── 指定すると本体側は chart の既定値
(root / mysecretpassword)で URI を組み立て、自分のデータベースに繋げなくなる。
実際に helm template して確認した。
なので bootstrap/ に残す。 Talos では ArgoCD 本体と同じく machine config の
inlineManifests に載せる ── talos/render.sh は age の鍵を持っている
ので、SOPS 済みの値を復号して helm template に渡せる。生成物は machine config の中に
しか出ないので、git には平文が残らない。
もとは名前空間ごとに ghcr-pull を imagePullSecrets で参照していた。2026-09-06 にノード単位の資格情報へ寄せ、
2026-09-15 からは ghcr.io の資格情報そのものが要らない ── 非公開のリポジトリを Forgejo に移し(apps/forgejo/README.md)、
ghcr.io に残るイメージは全部公開。ノード単位で持つのは fj.doany.io の資格情報だけ(talos/registries.yaml、talos/README.md)。
実機には Earthsoft PT3(earth_pt3、Altera 1172:4c15)が刺さっていて、Talos のカーネルは CONFIG_DVB_PT3 を
有効にしておらず、公式 extension にも無かった。denpa の tuner-agent はこの箱の /dev/dvb と B-CAS リーダーに依存している。
結論(2026-10-13): 上流に入った。 siderolabs/pkgs#1682
(CONFIG_DVB_PT3=m)が v1.15.0-alpha.1 に入り、siderolabs/dvb-pt3 が Image Factory の公式 extension に出た。
本番の schematic を 96371c8f…(dvb-pt3 + intel-ucode + IOMMU の引数)に作り直し、Talos を v1.15.0-alpha.1 に上げた
(talos/versions.yaml)。extension は earth_pt3 を blacklist して配るので、talos/patches/main.yaml の
machine.kernel.modules で読ませる。alpha をノード 1 台の本番に乗せることになるが、正式版を待つより PT3 を先に取った。
駄目だったときの保険: KubeVirt の VM にパススルー。 実機で確認済み — VT-d 有効、PT3(05:00.0)は
IOMMU グループ 36 に単独、B-CAS は USB(Gemalto GemPC Twin 08e6:3437)。tuner-agent だけ VM(Ubuntu)で動かせば
Talos 本体は素のまま。intel_iommu=on は schematic に残してあるが、vfio は 2026-10-13 に
talos/patches/main.yaml から外した(PT3 をホストのドライバと取り合う)ので、この道に戻るなら足し直す。
自前 extension を imager で焼く案は採らない。 Image Factory から外れて、アップグレードのたびに自前ビルドになる。
Renovate は共有プリセット(5ym/repo-config。2026-10-07 に 5ym/renovate から改名)を使っていて、全部を 1 つの PR にまとめて自動マージする
設定だった。separateMajorMinor: false も付いていたので、postgres 17 → 18 が nginx のパッチと
同じ PR に入り、自動マージの対象になっていた(#7)。
直したこと: プリセット側でメジャーを別の PR に分け、automerge: false にした(5ym/repo-config#2)。
パッチとマイナーはこれまでどおり 1 つにまとめて自動マージする。小さくて頻繁で、タグを戻せば済むため。
SOPS で暗号化したファイルは Renovate に触らせない(2026-09-08)。argo-cd 10.8.2 の PR が暗号文の中の version: 10.8.1 を
平文置換して自動マージされ、MAC が壊れて sops -d が通らなくなった(コミットから戻した)。renovate.json の ignorePaths で外し、
その 2 ファイルの版は人が見る(../bootstrap/README.md)。
Renovate 自体は 2026-10-16 から k8s の CronJob(apps/renovate/)で動かしている
(GitHub Actions の workflow と Forgejo Actions の doa/renovate は 2026-10-17 に削除)。
いまの自動マージの範囲(プリセット 5ym/repo-config):
| まとまり | 自動マージ |
|---|---|
all dependencies(メジャー以外の全部) |
する |
major dependencies |
しない |
helm charts(更新の種類を問わない) |
しない |
PostgreSQL は 17 の線に固定した(renovate.json の allowedVersions: "<18")。
PostgreSQL はメジャーが変わるとデータディレクトリの互換が切れる。
イメージのタグを差し替えると database files are incompatible with server で起動を拒否し、上げるには
dump と restore が要る。17 のサポートは 2029-11 まであるので急がない。
プリセットを直した直後、Renovate は erpnext の chart 8.0.15 → 8.0.78 を自動マージした。版の付け方はパッチだが、 中身は Dragonfly → Valkey の入れ替えで、values に書いてあったチューニングが丸ごと無効になった。
版の番号は中身の大きさを表さない。 chart の場合はとくにそうで、自動マージに任せる範囲を 決めるときは「メジャーかどうか」だけでは足りない。
そこでプリセット側で helm の datasource を丸ごと自動マージから外した
(5ym/repo-config#3)。chart は更新の種類を問わず人が見る。helm charts という別の
グループにしてあるのは、chart を止めることで all dependencies の PR まで自動マージ
されなくなるのを避けるため。
「git で消したものは消える」を優先する、という判断(本人)。それまでは二重に守っていた。
| 仕掛け | やめた理由 |
|---|---|
PVC の argocd.argoproj.io/sync-options: Prune=false,Delete=false |
これが本丸。 これがある限り git から消しても PVC は残る。GitOps の一貫性を損なう |
StorageClass local-path-retain(reclaimPolicy: Retain) |
追われない状態を作る。 実際、35 日と 44 日放置された Released の PV が 3 本あった(2026-09-07 の掃除で発見) |
代わりの後ろ盾はバックアップ。 日次の restic(R2)に加えて、DB は k8up が論理バックアップを取る。 誤って消したときの最大損失は 24 時間ぶん。この判断は復元リハーサルが Cilium 構成で通ってから行った (docs/restore-drill.md)。通らなければ守りを外す根拠が無かった。
承知しておくこと: Delete=false も外したので、Argo CD の Application を消すと PVC も消える。
マニフェストを消すのは git のレビューを通るが、Application の削除は UI のボタン 1 つで済む。
そこの重みは違う、という点は残る。
storageClassName はバインド済み PVC では変更も削除もできない(API が拒否する)ので、
2026-10-05 に k3s のうちに付け替えた: 28 本の PVC を止め、PV を Retain にして古い PVC を消し、
PV の claimRef を外して storageClassName を local-path に書き換え、新しい PVC を volumeName で同じ PV に結んだ
(データはコピーしていない)。local-path-retain はもう使っていない(../ROADMAP.md)。
R2 の無料枠は 10 GB。もう超えている。
| 日付 | リポジトリの実サイズ | できごと |
|---|---|---|
| 2026-09-06 | 3.3 GiB | |
| 2026-10-05 | 37.3 GiB | 途中 88.6 GiB まで育った(下)。録画はエンコード済みの 1 世代(約 35 GiB)だけにした |
決定(2026-09-08): 有料でも取る。 無料枠に収めることを目的にすると録画を捨てることになり、 本末転倒。R2 は下り(egress)が無料なので、復元のたびに費用が跳ねる心配も無い。 無料枠は制約ではなく目安として扱う。容量そのものが問題になるのは、桁が変わったとき(数百 GB)。
ホストのスクリプトが全部を二重に持っている(2026-09-10 に気づいた)。
backup/k3s-backup は /var/lib/rancher/k3s/storage を丸ごと取るので、k8up が PVC ごとに
取っているものと同じ中身が host=main のスナップショットにも入っている。restic は重複を除くので容量は二重にならないが、
片方で世代を減らしてももう片方が同じ blob を掴んでいるので解放されない。
実際、denpa-library を 3 世代 → 1 世代にしても解放されたのは 36 MiB だけだった
(狙っていたのは 10 GiB)。restic stats --mode raw-data <snapshot> が返すのは
「そのスナップショットが参照する量」で「そのスナップショットだけが持つ量」ではない、
という読み違い。per-PVC の保持設計はホストのスクリプトがある間は効かない。
これは移行で消える(Talos にはシェルが無い)。host=main が保持から落ちきったところで解放される。
料金: R2 のストレージは**期間中の日次ピークの平均(GB-month)**で課金され、無料枠は 10 GB-month。 いまの規模で月 $0.4 前後(2026-09-10 の見積もり)。操作の回数は桁違いに余っている。
容量を削るために外した / 外さなかったもの:
| 対象 | 判断 |
|---|---|
AdGuard のクエリログ(querylog.json) |
保持を 90d → 7d に短縮(2026-09-06)。単体で 2.0 GB あった。純粋な計測データで、復元時に無くても困らない。いま 8.8 MB |
| 録画データ | エンコード済み(denpa-encoded、旧 denpa-library)だけを含める。 放送は取り直せないので容量より価値を優先する(2026-09-06 本人判断)。ただし世代は持たない(2026-09-10、../apps/k8up/denpa-encoded-forget.yaml)── 中身が入れ替わるので、古い世代が消えたファイルを抱え続ける。2026-10-05 にホストのスクリプトからも外した ── 世代を持つ側が録画を掴んでいて、ライブラリ 39.66 GiB がリポジトリを 90 GiB まで押し上げていた。過去 10 本からも restic rewrite で抜いた |
denpa-recorded(生 TS の作業領域) |
除外した(2026-09-07)。 1.6 GB だったものが 14 GB まで育ち、リポジトリ実サイズが 13.92 GiB と R2 の無料枠 10 GB を超えていた。エンコードが終われば消える置き場で、中身は数時間で入れ替わるので日次のスナップショットに残す意味も薄い |
| 保持世代 | 17 → 13(--keep-daily 7 --keep-weekly 4 --keep-monthly 2)。遡れる範囲は約 2 か月 |
| 消した namespace の PV(epg / vpn / opengist、541 MiB) | 退避のうえ削除済み。バックアップ対象外 |
wg-easy を使う理由は プライバシー(全部を自宅経由にする)・DNS(AdGuard)・iLO(10.0.0.3)に入るの 3 つだけで、 どれも「自宅の LAN に入れれば済む」もの。UI に不満はあったが、スマホを足すときの QR は実際よく使うので UI を捨てる方向は取らなかった。
| 結論 | |
|---|---|
| wg-easy 続投 | 動いてはいる。ただし自前の認証を切れず(要望 wg-easy#1923 が 2025-06 から open)、SSO の後ろに置いても二重ログインが残る |
| wg-portal | 機能は足りる。ただし乗り換える動機が wg-easy 比で薄い |
| Headscale | 完成度は高い。が、FAQ が「headscale を動かすマシンをサブネットルータにするな」と明記している。 ノードが 1 台のこの構成では回避できない |
| NetBird | 採用。 routing peer が独立した概念で、コンテナで動かす前提のドキュメントがある。1 台構成でも上の問題が起きない |
公式 Helm chart は使わない。 netbirdio/helms の charts/netbird は
更新が止まっていて appVersion が本体から大きく遅れている(#39)。
上流は v0.65.0 で management/signal/relay を 1 つにまとめた combined コンテナに移っており、
素の Deployment で足りる(apps/netbird/)。
組み方で引っかかった点:
- gRPC と HTTP が同じホスト名・同じポートに混在する。
management.ManagementServiceとsignalexchange.SignalExchangeを GRPCRoute に、/api/oauth2/relay/ws-proxyを HTTPRoute に、 残りをダッシュボードに振った。GRPCRoute なら Cilium が backend を h2c 扱いするので、enable-gateway-api-app-protocolを有効にせずに済む(= Gateway の設定を触らない)。 同じリスナー・同じホスト名に GRPCRoute と HTTPRoute を同居させても既存の 16 ルートは無傷だった - STUN(UDP 3478)はリバースプロキシを通せないので hostPort。namespace の PSA が
privilegedなのはこのため livenessProbeは付けない。 healthcheck はlocalhost:9000にしか bind されず、kubelet からは必ず落ちる- 外部 IdP(Entra)は設定ファイルに書けない。 0.62 以降、外部 IdP は config ではなくストアに入るデータになった。
ダッシュボードか
POST /api/identity-providersで足す。リダイレクト URI は issuer +/callbackで 決まる(idp/dex/connector.go)のでhttps://nb.doany.io/oauth2/callback固定、Entra 側を先に作れる
apps/gateway-routes/ に全部まとめていたのをやめ、それぞれが公開しているものと同じ場所に置いた。
ArgoCD は sourcePath 配下を recurse で拾い、追跡は argocd.argoproj.io/tracking-id 注釈で行うので、
ファイルの場所は挙動に影響しない。純粋に読み手の都合。
| 行き先 | 対象 |
|---|---|
このリポジトリの apps/<name>/httproute.yaml |
adguardhome / forgejo / headlamp / infisical / matrix / mta-sts / netbird / rybbit など |
各アプリのリポジトリの deploy/httproute.yaml |
blog / yosegaki / lgtm / tamasagashi / worklog / xool / yuzuriha |
bootstrap/ |
argocd / auth ×3 / redirect-https |
bootstrap/ に移したものが本題。 argocd と auth の実体はこの層にあり、ここは ArgoCD が
同期していない。つまり「ArgoCD が ArgoCD 自身を公開している経路を握っている」状態で、
ArgoCD が壊れているときにその経路を ArgoCD 経由でしか直せない、という順序の逆転があった。
まとめて 1 か所に置く利点(公開しているものの一覧になる)は失う。 代わりに
この表と bootstrap/README.md が索引。外部露出を一覧したいときは kubectl get httproute,grpcroute -A が
正確で、git を読むより早い(一覧の一枚は作らない、2026-09-09)。
落とさずに動かす: ArgoCD は「git から消えた + 自分が追跡している」ものを prune する。別リポジトリへは
移送先を先にマージして tracking-id が書き換わってから元を消す。bootstrap/ へは引き取り手が居ないので、
先に live に Prune=false を効かせてから移し、tracking-id 注釈を剥がして Prune=false も外す。
社内用や通信量が多いアプリは直接つなぐ。SaaS のように外へ出すアプリは Cloudflare のプロキシを通す(本人)。
- 直接つなぐもの:
*.doany.ioのワイルドカード(proxied ではない CNAME →doany.io)で引ける。明示のレコードは持たない - Cloudflare を通すもの:アプリのリポジトリの HTTPRoute に
external-dns.kubernetes.io/cloudflare-proxied: "true"を付け、 ExternalDNS(apps/external-dns/)が proxied の CNAME を作る
DNS もアプリのリポジトリで持つ(疎結合)。 それまでは Cloudflare を通すアプリの CNAME(l・tk・ts・w・x・y)を
手で作っていた。中央の IaC(Pulumi・DNSControl・OctoDNS)に一覧を置く案もあったが、アプリを足すたびに gitops と
アプリのリポジトリの 2 か所を直すことになる。ExternalDNS は HTTPRoute の注釈だけで決まり、自分が作ったレコード
(所有の印の TXT 付き)にしか触れないので、メールや DDNS の apex と同居できる。
比べたもの:ExternalDNS / DNSControl / OctoDNS / Cloudflare の Terraform provider / cloudflare-operator / k8s_gateway / Crossplane。疎結合にできるのは ExternalDNS・cloudflare-operator・Crossplane だけで、その中で DNS が主役で利用者が多いのは ExternalDNS。
Entra のリダイレクト URI は中央(pulumi/entra.ts)のまま。 共用のアプリ登録 1 つに全アプリの URI が入るので、
そもそも中央に集まる性質のもの。アプリごとに分けるにはアプリ登録を分ける必要があり、それを Kubernetes から作る
手軽な道具がまだ無い。