ぽよメモ

レガシーシステム考古学専攻

おうちKubernetes feat. cybozu-go/neco-apps

はじめに

 これはCybozu Advent Calendar 2021 7日目の記事です。是非他の記事も読んでみてください。

 Kubernetesの名を聞くようになって久しく、皆様も業務・プライベート問わず日々YAMLを書かれていることでしょう。自分専用のプライベートクラスタが欲しいと思われている方もきっとたくさん居られるはず!今日は自分がプライベートで遊んでいるKubernetesクラスタを紹介したいと思います。

 注意  この記事では私費で機材を購入したりプライベートでKubernetesについて学んだりしていますが、完全に筆者の趣味でありたまたま実益を兼ねているだけです。会社としてそれらの行為を指示及び推奨するものではありません。

なぜおうちKubernetes

 Docker DesktopのKubernetes、minikube、kindなどで手軽にクラスタを建てることができ、簡単な開発環境であれば非常に容易に作成することができるのもKubernetesの魅力です。
 しかし、これらで作成したクラスタクラウドプロバイダの提供するマネージドサービスと比較していくつか足りない機能があります。

 などなど、ちゃんと独立したクラスタで自由にやりたくなります。ではなぜクラウドプロバイダのマネージドKubernetesではなく、わざわざオンプレミスなのか?

……。

家にKubernetesクラスタがあるとなんかかっこいい

ではやっていきましょう!*1

ハードウェアの選定

 情報収集していると、Raspberry Piを使ってクラスタを組んでおられる方が多いようです。

ryusa.hatenablog.com

blog.chatagiriii.com

developers.cyberagent.co.jp

 RasPiの小さなクラスタも非常にかわいらしく魅力的なのですが、世は大半導体不足時代、Raspberry Piの値段も高止まりしていますし在庫も全然ありません。

www.switch-science.com

PoE Hatを買ったりPoEスイッチを買ったりmicroSDは不安だからSSDで……とかやっているとコスト的にも中古でx86のPCを購入するのと大差無いことに気付いたため、NUCを中古で4台購入しました。

f:id:pudding_info:20211130225725j:plain
NUC7i5DNKE 4台

小さくてかわいいですね。2コア4スレッド、メモリ最大32GB搭載可能なモデルです。SSDはM.2 NVMeなものを一つ搭載できるのみ、NICも一つしかないのが不満ではありますが、まぁ実用上困ることはないでしょう。

f:id:pudding_info:20211130215929j:plain
NUCに備わっている端子

vPro対応プロセッサーを搭載しており、Intel AMT*2によってリモート電源管理可能なProモデルです*3

 NUC本体が4台で7万円、8GBメモリを4枚・500GB SSDを4枚で4万円、合計11万円程度で揃いました。

クラスタのブートストラップ

 cybozuKubernetesクラスタはsabakan、そしてCKEを用いて管理されています。

github.com

github.com

これらを使うことも検討しましたが、Intel AMTをサポートしているMAAS*4がいいかんじだったので、MAAS + kubesprayクラスタを構築することにしました。MAASは家に元々あったサーバにインストールしています。

 最初にIntel AMTの設定をしておけば、後は自動でUbuntuをインストール・セットアップすることができるようになりました。各NUCにディスプレイやキーボードを繋いで何度も試行錯誤したりする必要が無く便利です。ただしMAASがDHCPサーバなどを提供するため、家の中で他の端末が使うものとは別のVLANを切ってそこで運用しています。

f:id:pudding_info:20211130230929p:plain
MAASのダッシュボード

cybozu-go/neco-apps

 ここまでで素のKubernetesクラスタはたちましたが、このままではちょっとスペックのいいMinikube程度の機能しか無いので、どんどんコンポーネントをデプロイしていきます。

 まず、やりたいことを決め、それを実現できるコンポーネントをデプロイすることにしました。自分が当初決めた要件は以下の通りです。

  • type: LoadBalancer なServiceを利用できる。これには指定した特定のレンジのIPアドレスを割り当てることができること。
  • 自動でSSL証明書を発行・更新し、httpsでサービスを提供できる。
  • なんらかのIngress controllerによってリクエストのルーティングができる。
  • PersistentVolumeClaim を使ってデータの永続化領域を動的に切り出して利用できる。ノード障害耐性があるとよい。
  • GitOpsによって外部からアクセスさせることなくサービスをCDできる。
  • メトリクスの収集・可視化、および監視ができる。

これらを実現するためには様々なコンポーネントが利用可能ですが、一体何を選択すればいいのか……
うーん……

あっ、cybozu-go/neco-appsで実際に使われているコンポーネントが公開されてるじゃーーん! github.com

ということで勝手に参考にしました。

 注意 筆者はNecoのプロジェクトメンバーではありません。以下は勝手に参考しているだけです。

Metallb

metallb.universe.tf

 おうちKubernetesでLoadBalancerを使うためには実質ほとんど選択肢がありませんでした。
 MetallbはKubernetesにおいてtype: LoadBalancer なServiceを提供するためのコンポーネントです。BGPで経路を広告するBGPモードと、ARP*5を使ってVIPに紐付くノードの付け替えを行うL2モードがあります。BGPモードの場合はルータ側でECMP(Equal-cost multipath)を使うことで複数のノードに分散してトラフィックを送ることができますが、L2モードの場合はリーダーとなった特定のノードのみにトラフィックが集中します。今回は、一般的なルータでも利用可能なL2モードを利用することにしました。
 Kubesprayのオプションを有効にすることでインストールできるのでそれを使いました。設定方法は https://kubespray.io/#/docs/metallb にある通りです。指定したIPアドレスレンジからtype: LoadBalancerなServiceにアドレスを割り当てるので、他と被らないレンジを指定します*6。MAASのUIからDHCPで割り当てたくないIPアドレスレンジを指定できるので、それを使ってLoadBalancer用のIPアドレスレンジを確保しておく必要がありました。

cert-manager

cert-manager.io

 おなじみ証明書管理のためのコンポーネントです。特に説明することは無いと思います。
 これもKubesprayでインストールできるため、それを使いました https://kubespray.io/#/docs/ert_manager。証明書発行はLet's Encryptを、マネージドDNSサービスとしてGoogle Cloud DNSを利用しています。便利な時代に感謝🙏

Contour

projectcontour.io

 ContourはHTTPProxyというカスタムリソースを使うIngress controllerです。その実態としてはEnvoyのコントロールプレーンです。HTTPProxyリソースは個人的にはIngressよりも好きです。
 インストールする方法は非常に簡単で、以下にあるものをほぼそのまま利用するだけです。自分はkustomizeを使ってイメージのバージョンを固定、リソースの設定を追加しました。

github.com

 HTTPProxyリソースはIngressリソースよりも簡便に書けて良いと自分は思っていますが、一方で他のコンポーネントとのインテグレーションはIngressと比べると弱いです。例えばcert-manager用にアノテーション付与することにより証明書を自動発行する機能などは利用できません。そのためCybozuではHTTPProxyリソースを監視し、ExternalDNSのためにDNSEndpointリソースを、cert-managerのためにCertificateリソースを自動で生成する機能を追加したカスタムコントローラであるcybozu-go/contour-plusを開発・利用しています。これによりHTTPProxyリソースを作るだけで以下を追加で自動的に行うようにしています。

  • DNSレコードの登録
  • 証明書の発行

今回自分は外部へのサービス公開をほとんど考えていない*7ことから採用をスキップしましたが、非常に便利なので外部にサービス公開を考えている場合は是非使ってみてください。

github.com

TopoLVM

github.com

 Cybozuが開発・運用している、LVMを使ってPersistentVolumeを提供するCSIプラグインです。詳しくは以下をご覧下さい。

blog.cybozu.io

 後述するRookでPVC basedなクラスタを構築したり、mocoでMySQLクラスタを構築するために利用しています。PVとノードが紐付くため、そのノードが利用できなくなった場合そのPVの情報は失われてしまいます。利用する側でデータの冗長性を確保する必要があります。
 Kubernetes v1.21からbetaとなったGeneric Ephemeral Volumeとして利用することもできます。TopoLVM用のVGからボリュームを切り出すため、emptyDirでホストOSのストレージ領域が圧迫されてしまうことを防ぐことができます。

kubernetes.io

Rook

rook.io

 分散ストレージソフトウェアであるCephをKubernetesで管理するためのオペレータです。CephやRookについては以下の記事をご参照ください。

blog.cybozu.io

blog.cybozu.io

 TopoLVMによってPVCからDynamic Provisioningできるようになっているため、RookではPVC basedなクラスタを作成しました。id:tenzen_hgst さんの以下の記事を参考にしました。

tenzen.hatenablog.com

とはいえ今回のクラスタには3つのワーカーノード、各ノードに1台のストレージが載っているのみなので、大きめのボリュームのOSDが各ノードに一台いるという感じの簡単な構成になっています。あんまりOSDの割り当てなどで悩む余地もありませんでした。Rook/Ceph全然わからんと言いながら使っています。

 TopoLVMとは異なり、可用性のあるPVを提供することができます。また、S3互換なAPIを備えたオブジェクトストレージも提供しており、Minioなどを別途建てなくてもオブジェクトストレージを利用できます。やはりオブジェクトストレージがあるとぐっとクラウドネイティブっぽくなりますね(?)。

https://rook.io/docs/rook/v1.7/ceph-object.html

SealedSecret

github.com

 GitOpsのためにはSecretもGit管理したいところですが、プライベートリポジトリにしたとしても流出に備えて暗号化できるならしておきたいところです。BitnamiのSealedSecretコントローラとkubesealコマンドを使えば、簡単に既存のSecretを暗号化して利用することができます。
 詳しい導入・利用方法については他のサイトに譲りますが、ArgoCDでいつまで経ってもSealedSecretのSyncが完了しない問題がv0.17.0で解消している*8ので紹介しておきます。

github.com

ArgoCD

argo-cd.readthedocs.io

 GitOpsのためのコントローラです。おうちKubernetesでは可能な限り外側に露出するアタックポイントを減らして、毎日メンテできない不安を軽減したいものです。GitOpsならば、外側からアクションを実行するような口を開けることなく継続的にアプリケーションをデプロイすることができます。他に有名なGitOpsのためのツールとしてFlux 2などが知られています。

 基本的なインストール方法は公式ドキュメントの通りです。ただし一部いじりたい箇所もあったので、kustomizeを使って一部変更しています。

GitHubアカウントでSSOする

 一人なのでadminのパスワードをkubectlコマンドでぶっこ抜いて使っても良いのですが、せっかくなのでGitHubでSSOすることにしました。まず認証のためのorgが必要なので、適当なorgを用意します。そのorgの設定から、GitHubのOAuth Applicationを用意します。

docs.github.com

用意したOAuth AppのClient IDとClient Secretを用意し、Secretリソースとして作成します。実際にはこれをkubesealコマンドを使ってSealedSecretリソースとして暗号化しています。

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: argocd-github-client-secret
  namespace: argocd
spec:
  stringData:
    clientID: Client ID
    clientSecret: Client Secret

上記Secretを使うようにArgoCDを設定します。以下は最低限の設定です。必要な他の設定は公式のサンプルを見てください。

apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
data:
  # ArgoCDにアクセス可能なURLを指定する。コールバックのFQDNもここを指す。
  url: https://argocd.sample.com
  # falseならばadminユーザを作成しない
  admin.enabled: "false"
  dex.config: |
    connectors:
      - type: github
        id: github
        name: GitHub
        config:
          # $シークレット名:key の形で指定する
          clientID: $argocd-github-client-secret:clientID
          clientSecret: $argocd-github-client-secret:clientSecret
          orgs:
          # どのorgを許可するか
          - name: org名
          teamNameField: slug
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
data:
  # どのorgのどのteamにどの権限を割り当てるか。roleはadminとread-onlyのみがデフォルトで用意されている。
  # https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/#basic-built-in-roles
  policy.csv: |
    g, org名:team名, role:admin
  # どのチームにも所属しないユーザに何を割り当てるか。空文字にすると何も割り当てない == 閲覧権限すら無し。
  policy.default: ""

これらを適用することでArgoCDにGitHubでログインするボタンが表示されるようになります。

gRPC用とWeb UI用でサービスを分ける

 neco-appsを見ていると、ArgoCD Server用になぜか2種類のserviceがあることに気付きました。

片方には projectcontour.io/upstream-protocol.tls: 443,https が、もう片方には projectcontour.io/upstream-protocol.h2: 443,https が付いています。これらはContour用のannotationで、EnvoyでTLS終端せずにupstreamのサービスにプロキシするための設定です。

https://projectcontour.io/docs/v1.19.1/config/upstream-tls/

どうもContourは同じホスト・ポートに対して異なるプロトコル(http/https or gRPC (http/2))でサーブすることを許していないようで、うまく動かなくなってしまうという挙動に対するワークアラウンドのようです。2つのアノテーションを一つのServiceに付与して動かしてみたりもしましたが、実際にargocdコマンドがうまく動かなかったりしたため自分も同様に二つのServiceに分けました。HTTPProxyリソースの書き方は下記の通りです。

neco-apps/httpproxy.yaml at release-2021.12.01-27858 · cybozu-go/neco-apps · GitHub

VictoriaMetrics

docs.victoriametrics.com

 普通にPrometheusを使っても良かったのですが、下記の記事を読んでVictoriaMetricsを使ってみることにしました。

blog.cybozu.io

 おうちKubernetesをやる上で嬉しいこととしては、以下の辺りでしょうか。

https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html#prominent-features

  • It uses 10x less RAM than InfluxDB and up to 7x less RAM than Prometheus, Thanos or Cortex when dealing with millions of unique time series (aka high cardinality).
  • It provides high data compression, so up to 70x more data points may be crammed into limited storage comparing to TimescaleDB and up to 7x less storage space is required compared to Prometheus, Thanos or Cortex.
  • It is optimized for storage with high-latency IO and low IOPS (HDD and network storage in AWS, Google Cloud, Microsoft Azure, etc). See disk IO graphs from these benchmarks.

まとめるとRAMの使用量が少ない!データの圧縮率が良い!遅いストレージでも大丈夫!(今回はNVMe SSDだけど)
特にストレージ容量は比較的小さめであることが多いと思うので、データ圧縮率が良いのは嬉しいですね。加えて比較的構成がシンプルであることも地味に嬉しい点です。理解が楽なので。

 今回はVictoria Metrics operatorを使ってクラスタを構築しました。導入自体は割と簡単です。

github.com

全然不要なのに調子に乗ってHA構成で組んだので、そのうち飽きたら解体します。今のところちゃんと動いていて良さそう。

Grafana operator

github.com

 VictoriaMetricsはWeb UIを持たないので、可視化のためにGrafanaを入れます。GitOpsしたいのでGrafana operatorを導入し、ダッシュボードやデータソースをコードで管理します。また、せっかくArgoCDではGitHubでSSOするようにしたので、Grafanaも同様にします。↓いろいろ省略して認証周りのみに絞っています。

apiVersion: integreatly.org/v1alpha1
kind: Grafana
metadata:
  name: grafana
spec:
  config:
    auth:
      disable_login_form: False
      disable_signout_menu: True
    auth.anonymous:
      enabled: False
    # GitHubでのSSOの設定
    auth.github:
      enabled: true
      allow_sign_up: true
      scopes: user:email,read:org
      auth_url: https://github.com/login/oauth/authorize
      token_url: https://github.com/login/oauth/access_token
      api_url: https://api.github.com/user
      allowed_organizations: org名
    server:
      domain: 公開するドメイン名
      root_url: https://公開するドメイン名
    users:
      viewers_can_edit: true
      auto_assign_org_role: Viewer
  deployment:
    envFrom:
      - secretRef:
          # このsecretにclient idやclient secretを入れておく
          name: grafana-github-client-secret

Secretでは特定の環境変数に値を渡します。この辺りの設定はこことかここにあるので、その辺りを参照すれば他のプロバイダでもSSO出来ると思います。

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: grafana-github-client-secret
spec:
  stringData:
    GF_AUTH_GITHUB_CLIENT_ID: Client ID
    GF_AUTH_GITHUB_CLIENT_SECRET: Client Secret

DataSourceも適当に足します。今のところVictoriaMetricsしかいないのでこれだけです。

apiVersion: integreatly.org/v1alpha1
kind: GrafanaDataSource
metadata:
  name: vm-source
spec:
  name: victoriametrics.yaml
  datasources:
    - name: victoriametrics
      type: prometheus
      access: proxy
      # vmselectのserviceを指定
      url: http://vmselect-vmcluster.monitoring.svc:8481/select/0/prometheus
      version: 1
      isDefault: true
      editable: false
      jsonData:
        tlsSkipVerify: true
        timeInterval: "30s"

ダッシュボードも grafana.comで公開されているダッシュボード は以下の様に簡単に追加できます。例えばNode Exporterのダッシュボードのrevision 23をデプロイする場合は以下のようにします*9

apiVersion: integreatly.org/v1alpha1
kind: GrafanaDashboard
metadata:
  name: node-exporter
spec:
  url: "https://grafana.com/api/dashboards/1860/revisions/23/download"
  datasources:
    - inputName: "DS_PROMETHEUS"
      datasourceName: "victoriametrics"

各NodeにNode Exporterをたてておき、VMNodeScrapeリソースなどを使って情報を収集させておけば以下の様にダッシュボードを表示できます。

f:id:pudding_info:20211204140948p:plain
Node Exporterのダッシュボード

moco

 ここまでで基本的なことはだいたいできるKubernetesクラスタができました。ついでなのでもう一個使えるコンポーネントをデプロイしておきます。
 mocoはCybozuが開発しているMySQLオペレータで、MySQLのSemi-sync replicationを使ったクラスタを提供します。使い勝手が通常のMySQLと変わらないこと、最悪どうしようもなくなったら社内で聞けばなんとかなるやろと思ったので導入してみました。インストールも↓のドキュメントに従うだけでよく、非常に簡単です。

cybozu-go.github.io

 作成するMySQLClusterリソースで永続化ボリュームの大きさを指定できるのですが、例では1GBとなっています。自分の手元では容量不足で起動しなかったりしたため、5~10GB程度は割り当てておくと良いと思います。またnameは mysql-data で固定です。最初適当な名前を付けて失敗しました*10

apiVersion: moco.cybozu.com/v1beta1
kind: MySQLCluster
metadata:
  name: test
spec:
  ...
  volumeClaimTemplates:
  - metadata:
      name: mysql-data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 10Gi

開発環境

 大きな変更をいきなり本番クラスタに当てるのは怖いですよね。というわけでおうちKubernetesクラスタも開発環境を用意するようにしました。
 クラウドインスタンスを作ることも考えましたが、たまたま手元にそこそこスペックの良いWindowsデスクトップマシンがあるので、これにVMを立てまくることにしました。

  • CPU:Ryzen7 3700X(8コア 16スレッド)
  • RAM:64GB
  • SSD:2TB

VMを5つ建てます。それぞれ以下の役割を持ちます。

  • maas-master:MAASをインストールしておくサーバ
  • maas-node1:KubernetesのMasterノード
  • maas-node2:KubernetesのWorkerノードその1
  • maas-node3:KubernetesのWorkerノードその2
  • maas-node4:KubernetesのWorkerノードその3

f:id:pudding_info:20211204141409p:plain
HyperVで建てたVMたち

雑に作っていったらスペックがまちまちになってしまい、でもまぁ困ってないのでいいかなということでそのままになっているという雑な図です。動作確認のためにデスクトップPCを起動しておく必要があるとか、GitOpsでデプロイはできるが外部からのアクセスは許可していないので実質開発環境がこのデスクトップPCに固定化されているところがイケてないところです。

 基本的にこの環境で動作を検証し、問題なければ変更をreleaseブランチにマージ→本番環境のArgoCDがそれをsyncして適用という流れになっています。とはいえ本番環境でしか起きない問題なども度々引いており、なかなかうまくいかないなという感じです*11

今動いているもの

 汎用的なWebクローラーのようなものを書いて新着通知をしたりしています。特に速報性は求めていないのでかなり緩やかなペースでの通知ですが、DBがないとつらくAWS LambdaやGoogle Cloud Functionsに地味に乗せづらかったものです。DBスキーマの変更までGitOpsで完結しているので、コードを書いてリリースまでクラスタに触れる必要がありません。このあたりについてはまたいずれ記事を書ければ良いなと思っています。
 他は気になった物をときどきデプロイする程度で、あまり安定的に動いてるコンポーネントはありません。何か面白いものを思いつくのを待っています。

これからやりたいこと

バックアップとリストア

 今のところRook/Cephが崩壊するとデータを全ロストします。うちにはNASもあるので、定期的にデータのバックアップを取りたいところです。また、バックアップは取っただけでは意味が無く、それをリストアできる必要があるのでリストアの方法についても探求していきたいと思っています。  おうちクラスタでは軽い気持ちでバージョン上げたり新しいものを入れたりしがちな上、壊れると直すのが面倒になって放置しがちです。最悪クラスタを一度壊して作り直してもOKという体制を整えて長く使ってあげたいですね。

監視の充実

 VictoriaMetricsとGrafanaを入れたとはいえ、まだまだ中身が追いついていません。少しずつ拡充していければなと思っています。PromQLが難しすぎる……

まとめ

以上です。皆さんの素敵なおうちKubernetesクラスタ情報お待ちしております。

明日以降のCybozuアドベントカレンダーもお楽しみください!


*1:真面目なことを言っておくと、現実世界の複雑な部分をクラウドプロバイダに押し付けっぱなしにするだけでなく、自分で体感した方がよいと思っているためです。やればやるほどマネージドサービスのすごさを体感できて安く感じるのでお得です。

*2:インテル® アクティブ・マネジメント・テクノロジー (インテル® AMT) | インテル

*3:NUCでvPro対応しているものは非常に少ないためレアですが、LenovoDELL・HPなどから出ている小型PCには対応しているものが豊富にあります。入手性はそれらの方が良いでしょう。

*4:poyo.hatenablog.jp

*5:IPv4の場合。IPv6の場合はNDP。

*6:ここで割り当てるIPアドレスはプライベートIPアドレスです。外部にサービスを公開したい場合、ルータのNAPTを使ってルーティングする必要があります。

*7:主に家の中で引ければ良いので、名前解決にはルータ搭載の簡易DNSサーバを使っています。証明書は必要ですがDNS-01チャレンジならばHTTPサーバを公開しておく必要もなく、外部に一切公開せずに運用が可能です

*8:正確にはv0.16.0の時点で追加のフラグを与えることで解消できるようになり、v0.17.0でそのオプションがデフォルトで有効化されました。 github.com

*9:適用直後しばらくはダッシュボードの取得時に Too Many Requests のようなエラーが出ていたのですが、しばらく放置すると取得できたのかダッシュボードが見れるようになっていました

*10:ちゃんとドキュメントを読め……

*11:逆にGrafana operatorでは自分の開発環境でのみ起きる問題を引き当ててしまい、現状本番環境に当てないと動作確認できないという最悪な状況になったりもしています。 github.com

マルチアーキテクチャ対応イメージのビルドをどうにか早くしたかった

マルチアーキテクチャ対応イメージって何?

 最近ではApple Siliconの登場や、Oracle CloudのAmpere A1 Computeインスタンスなど、ARMアーキテクチャ採用のプロセッサに実際の開発などで触れる機会も増えてきました。 現在広く流通するx86_64に対応するIntelAMD製のプロセッサと比べて、ARM系のCPUには高い省電力性能などに期待が集まっています。趣味開発レベルの話をすると、Oracle CloudのAlways FreeなA1インスタンスは非常に魅力的であり、是非活用したいところです。

 ただしARM系CPUではx86_64向けにビルドされたバイナリをそのまま動かすことはできません。当然Dockerイメージについてもx86_64向けのイメージをarm64のマシンで動かすことはできない*1ため、arm向けにビルドされたDockerイメージを別で用意する必要があります。簡単に思いつく方法としては

  • イメージごと分けてしまう方法
    • 例:hoge/fuga-arm64:latest, hoge/fuga-amd64:latest
  • タグごとに分けてしまう方法
    • 例:hoge/fuga:latest-arm64, hoge/fuga:latest-amd64

などがあると思います。しかし、これは利便性を大きく損ないます。docker-compose.ymlだったり、コンテナを起動するスクリプトだったり、Kubernetesマニフェストだったりには基本的にイメージ名やタグがハードコードされています。環境ごとにこれを書き換えるのは非常に面倒でしょう。

 マルチアーキテクチャ対応イメージとは、ある単一のイメージおよびそのタグに、複数のアーキテクチャ向けのイメージを紐付けたものを指します。利用者側で自身のアーキテクチャに合わせて最適なイメージが選択されて使用されるため、アーキテクチャを意識してイメージ・タグを書き換える仕組みを用意する必要がありません。例えばDocker Hubで配布されているベースイメージ( docker.io/library/* )などが多数のアーキテクチャに対応したマルチアーキテクチャ対応イメージになっています*2

どのアーキテクチャに対応しているか、Dockerとしてのサポートの範囲などは以下に記述があります。 https://github.com/docker-library/official-images#architectures-other-than-amd64

どうやって作るか

単に別アーキテクチャでビルドして同じイメージ・タグでレジストリにpushすると、後からpushされた方で上書きされて単一のアーキテクチャ向けのイメージになってしまいます。正しいマルチアーキテクチャ対応イメージの作り方には大きく分けて2つの方法があります。

docker manifestコマンドを使う

 どうにかして各アーキテクチャで動く環境を手に入れ、その上でdocker buildしたイメージを別々のイメージ名ないしタグでレジストリにpushしておきます*3。ここでは例えば amd64/image:latestarm64/image:latest としておきます。これらをまとめて一つの my/image:latest としたい場合、下記の様にコマンドを実行します。

docker manifest create my/image:latest \
    amd64/image:latest \
    arm64/image:latest

これで my/image:latestマニフェストができたのでこれをレジストリにpushします。

docker manifest push my/image:latest

 この方法は(おそらく)Docker Hubのオフィシャルイメージで使用されています。例えばUbuntuのイメージは対応アーキテクチャごとにそれぞれ別々のorgに存在します。

利用者側では単にubuntu:20.04などのイメージを指定するだけで、適したアーキテクチャのイメージがpullされます。

docker buildxコマンドでビルドする

 buildxとは、BuildKit*4に基づいてより発展的なビルド方式を提供するdockerコマンドのプラグインです。

github.com

 buildxではQEMUを利用した他アーキテクチャのエミュレーション環境や、実際にそのアーキテクチャで動作するノードを仮想的な Builder に紐付け、それらを使ってビルドを行い、最初からマルチアーキテクチャに対応したマニフェストを用意することが出来ます。 ただし、ローカルのDocker環境からマルチアーキテクチャマニフェストをそのまま取り扱うことはできないため、一度レジストリにpushした後に自身のアーキテクチャに対応するイメージをpullすることでしかそのビルドしたイメージを使うことは出来ません。

 試した環境の情報は以下の通りです。

 まずはBuilderを用意します。ここではDocker Desktop for Macを使っています。WindowsのDocker Desktopではおよそ同じ手順で出来るとは思いますが、Linuxでは追加の手順が必要になると思います*5。本当はLinuxでも確かめるつもりだったのですが、諸事情でデスクトップが死んでいるので諦めました*6

 ローカルでマルチアーキテクチャ対応イメージをビルドする場合、docker-container ドライバの Builder を作ることが必要です。

docker buildx create \
     --name multi-arch-builder \
     --driver docker-container \
     --platform linux/arm64,linux/amd64

今回はサンプルとしてGo製のcowsayであるNeo-cowsay*7をビルドするだけのDockerfileを用意してみました。 golangイメージはマルチアーキテクチャ対応しているので、Dockerfile自体に工夫がなくともamd64/arm64両方に向けてビルドできます。

FROM golang:1.17.1 as builder

ARG VERSION=latest

RUN go install github.com/Code-Hex/Neo-cowsay/cmd/cowsay@${VERSION}

FROM gcr.io/distroless/static

COPY --from=builder /go/bin/cowsay /usr/bin/cowsay

ENTRYPOINT ["/usr/bin/cowsay"]

先ほどの Builder を指定して、linux/amd64linux/arm64 向けにビルドしてみます。

$ docker buildx build \
    --builder multi-arch-builder \
    --platform linux/amd64,linux/arm64 \
    -o type=image,push=false \
    -t cowsay:latest \
    --no-cache .
[+] Building 91.3s (15/15) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                    0.4s
 => => transferring dockerfile: 274B                                                                                                                    0.0s
 => [internal] load .dockerignore                                                                                                                       0.3s
 => => transferring context: 2B                                                                                                                         0.0s
 => [linux/arm64 internal] load metadata for gcr.io/distroless/static:latest                                                                            1.0s
 => [linux/arm64 internal] load metadata for docker.io/library/golang:1.17.1                                                                            1.4s
 => [linux/amd64 internal] load metadata for docker.io/library/golang:1.17.1                                                                            1.3s
 => [linux/amd64 internal] load metadata for gcr.io/distroless/static:latest                                                                            1.4s
 => CACHED [linux/amd64 stage-1 1/2] FROM gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1              0.0s
 => => resolve gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1                                         1.0s
 => CACHED [linux/amd64 builder 1/2] FROM docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0       0.0s
 => => resolve docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0                                  1.1s
 => CACHED [linux/arm64 stage-1 1/2] FROM gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1              0.0s
 => => resolve gcr.io/distroless/static@sha256:912bd2c2b9704ead25ba91b631e3849d940f9d533f0c15cf4fc625099ad145b1                                         1.1s
 => CACHED [linux/arm64 builder 1/2] FROM docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0       0.0s
 => => resolve docker.io/library/golang:1.17.1@sha256:285cf0cb73ab995caee61b900b2be123cd198f3541ce318c549ea5ff9832bdf0                                  1.1s
 => [linux/amd64 builder 2/2] RUN go install github.com/Code-Hex/Neo-cowsay/cmd/cowsay@latest                                                          17.1s
 => [linux/arm64 builder 2/2] RUN go install github.com/Code-Hex/Neo-cowsay/cmd/cowsay@latest                                                          80.5s
 => [linux/amd64 stage-1 2/2] COPY --from=builder /go/bin/cowsay /usr/bin/cowsay                                                                        1.0s
 => [linux/arm64 stage-1 2/2] COPY --from=builder /go/bin/cowsay /usr/bin/cowsay                                                                        0.9s
 => exporting to image                                                                                                                                  5.5s
 => => exporting layers                                                                                                                                 4.0s
 => => exporting manifest sha256:d1ee75cd239140bd0210554ada9707162750d4766635e6abc17725aa70bac5d3                                                       0.3s
 => => exporting config sha256:afe4e4a473376655e0377c92aa75ef7bdcddc3a3fffd95e285fda7d0db4ce1df                                                         0.3s
 => => exporting manifest sha256:df3198ab96b70b1e5b0584d5cc39065772c1268946e088e0bb2f1f038ff81400                                                       0.3s
 => => exporting config sha256:af3de61b4d4202ee4c8fa513b189e620443a462b5487d39cc491fee08776d39d                                                         0.3s
 => => exporting manifest list sha256:e1e7b18094b953f584370083971a13fdcc12290239e575739e0b7f91a9d83ca1                                                  0.3s

無事イメージがビルドできたようです。とはいえ、これを使うためにはレジストリにpushする必要があるのですが…… 。現状生成されたマニフェストdocker manifest inspect等で見ることもできないような気がします。見る方法を知っている方、教えてください🙏

 ちなみに docker ドライバーのBuilderを使い、単一のplatformを指定し、-o type=image,push=false ではなく --load というオプションを組み合わせると、 docker image ls などで表示されるイメージ一覧にビルドされたイメージが追加されます。動作確認したい場合は試してみてください。

buildxとQEMUによるビルドは遅い

 非常に簡単にマルチアーキテクチャ対応イメージをビルドすることができました。しかし、実行時間を見てみると、 go install してcowsayをビルドしている部分の時間は以下の様になっていました。

およそ5倍近い差が開いています。複数回実行しても、おおよそ4〜6倍程度、arm64向けのビルドが遅いことがわかりました。QEMUの分のオーバーヘッドがあるため仕方ないのですが、ビルド対象の規模が大きくなるほど遅くなる他、apt-getなど他の操作も有意に遅くなりました。場合によっては10倍、20倍と差が開くこともあり、CIなどで長時間待たされることになりました。

CIでのビルドを速くしたい

 当初、プライベートリポジトリでマルチアーキテクチャ対応イメージをCIでビルド・pushしたいと考えていました。このリポジトリではCIにGitHub Actionsを用いています。GitHub Actionsは従量課金で毎月2000分のビルドまでは無料ですが、色々複雑なことをしていたら一回のビルド時間が容易に15分、20分と伸びていってしまいました。できれば課金はしたくないですし、手作業でビルド・pushもしたくありません。何より一度コードをpushしてから結果が分かるまで20分もかかるというのはかなり苦痛です。

 どうにか高速化できないかなと少し足掻いてみました。試したリポジトリ・アップロードしたイメージは以下にあります。

github.com

https://hub.docker.com/repository/docker/pddg/multi-arch-image-sample

アーキテクチャごとに別ジョブでビルドする

 buildx自体はビルドをリモートマシンに委譲できるなどスケーラブルな仕組みになっているのですが、CIの仕組みとは非常に相性が悪いです。CIでは定義された複数のジョブを決められたスペックのインスタンスで実行します。スケールする単位はジョブやワークフローごとであり、ある一つのジョブ内で計算リソースをスケールさせるためには動的にクラウドのリソースを作るようなことが必要になります*8。そんな面倒なことはしたくないので、対象アーキテクチャごとにジョブを分けて並列にビルド・レジストリにpushして、それらが全て完了した後に後続のジョブで docker manifest コマンドを叩く方法を検討してみました。

ただ今回は各アーキテクチャごとに別ジョブでビルドしても、ワークフロー全体に占める時間はほとんど変わりませんでした。より大規模なプロジェクトや、多数のアーキテクチャをサポートなどするなどすると差が出てくると思います。

クロスビルドを活用する

 Goは幸いクロスビルドできる言語です。QEMUなんて挟まなくとも、Pure Goで記述されていればx86_64のマシンからarm向けのバイナリをビルドすることが出来ます。これを使えば、わざわざ重い環境でビルドしなくてもよくなるはずです。

github.com

実際に今回はこの方法で2分近くかかっていたCIを40秒程度まで短縮することが出来ました。

 ただし嬉しくない点がいくつかあります。

  1. Dockerfile内でアーキテクチャを認識する必要があるかもしれない
  2. docker buildで完結しないため、開発者の個々の環境に左右される可能性がある

 まず1についてですが、Goのアーキテクチャの指定方法と、Dockerのアーキテクチャの指定方法に差異があるため、これをどこかで吸収する必要があります。各アーキテクチャ向けにビルドしたい場合、ビルド時に以下の様に環境変数を設定します*9。arm64で GOARM=8 を付けると Invalid GOARM value. Must be 5, 6, or 7. というエラーが出るので注意が必要です。

GOOS=linux GOARCH=amd64 go build .
GOOS=linux GOARCH=arm GOARM=7 go build .
GOOS=linux GOARCH=arm64 go build .

Dockerfile内からこれらの値について知ることができる特殊な引数は以下の通りです。

# linux
ARG TARGETOS

# amd64, arm, arm64
ARG TARGETARCH

# linux/arm/v7 のみ v7 。他は空文字。
ARG TARGETVARIANT

arm/v7の場合は TARGETVARIANT に値が存在しますが、amd64やarm64の場合は現状 TARGETVARIANT は空文字列です。もしかしたら将来的にarm64/v9とかが出てくるかもしれないと思うと、結構嬉しくない状態だなと思っています(ARMの事情に明るくないので適当言っています)。x86とarm以外については今回検討しなかったため、もしかしたら他にも厄介な点があるかも知れません。

 2については言うまでも無く、これまではmulti stage buildによってDockerさえあればビルド出来る状態でした。しかしクロスビルドによってDockerイメージの生成とバイナリの生成を分離したことで、DockerだけでなくGoの環境が必要になってしまいました。幸いGoはインストールも簡単なので、他言語と比べると非常に楽な方ではあると思います。

buildxに対する雑感

 buildx周りのコマンドは洗練されていない印象が強いです。buildx のサブコマンドは対応するリソースとアクションが分かりづらく、また未だ知見も少ないことからかなり手探りでした。一方でGitHub Actionsで使えるように用意されているactionは広く使われることを意識しているのか、かなりわかりやすく整えられていると感じました。とはいえスケールする構成にするためにはdocker manifestコマンドなどを知っていなければいけないなど、まだ発展途上な仕組みであることには違いないようです。

 また、複数プラットフォームのイメージをラップトップで一度にビルドするのは計算リソースの問題から厳しいことが分かってきました。検証しているとひっきりなしにMacBook Proのファンが唸るのでかなり厳しい気持ちになってしまいました。力こそパワー、ラップトップを捨ててつよつよワークステーションを買いましょう。

結論

  • 強い計算リソースを買えば問題は解決

こうすると良いよ的な情報があればどしどし寄せて頂けると幸いです。

参考

  • www.docker.com
    • docker manifestによる方法を「The hard way」と言っていますが、正直こっちの方がわかりやすいかつスケーラブルな仕組みにしやすいと思います。

 

*1:QEMUなどによってパフォーマンスを犠牲にして動かすことはできます

*2:www.docker.com

*3:実際には後述するbuildxを使って別プラットフォームを指定しても良いです

*4:github.com

*5:www.yukkuriikouze.com

*6:どうもメモリがぶっ壊れたようでメモリテストで異常が検知されるのですが、32GBモジュール×2の交換品は果たしてちゃんと確保されているのでしょうか……

*7:github.com

*8:もしかしたら将来的にはGitHub Actionsで動的にbuildxのBuilderノードを追加して叩くようなAPI/actionができるかもしれませんが、わざわざそこまでしてくれるかは微妙だろうと自分は思っています

*9:github.com

MAASでストレージレイアウトのカスタマイズを探求したがよくわからなかった話

MAASとは

maas.io

Metal as a Service の略で、Ubuntuの開発元であるCanonicalが開発している、オープンソースのベアメタルサーバ管理ソリューションです。
以下の様な機能を提供しています。

  • OSのインストール自動化
    • PXEブートして curtin + cloud-init でプロビジョニング
  • VLAN管理
  • DNSおよびDHCPの提供
  • 各種Bare Metal Controller(IPMI、HP iLOIntel AMTなど)との連携による電源管理*1
  • 物理マシンのインベントリ
  • KVM・LXDホストおよび仮想マシン管理

Web UIからこれらの一連の操作を行うことが出来ます。例えばAWSのWebコンソールのように、ボタンをぽちぽちしたりAPI経由で仮想マシンおよび物理マシンの構築がオンプレミスでもできるようになります。

環境

pudding@maas-master:~$ snap list | grep maas
maas      2.9.2-9165-g.c3e7848d1  12555  2.9/stable     canonical*  -
maas-cli  0.6.5                   13     latest/stable  canonical*  -

MAASのストレージレイアウトつらい問題

2021年6月現在の最新安定版である v2.9 では、デフォルトで以下のレイアウトがサポートされています*2

  • Flat layout
  • LVM layout
    • パーティションレイアウトはFlat layoutと同じ。ただし、LVMによる論理ボリューム(LV)管理ができるようになっている。
    • ボリュームグループの空き容量全てが単一のLVによって占有されており、レイアウトを変えるためにはunmountしてresize2fsしてlvresizeする必要があるが、ルートにマウントされているのでいじるのが大変。
  • bcache layout
    • HDDのような遅いデバイスSSDなどをキャッシュとして噛ませるモード。
    • SSDが搭載されていない場合は自動でFlat layoutにフォールバックされる。
  • VMFS6 layout
    • VMWare ESXiをデプロイする際に自動で選択されるモード。よく知らない。
  • Blank layout
    • 手動でのパーティションレイアウト作成が必要なモード。そのままではOSのインストールができない。

これ以外のレイアウトをUIから指定したい場合、MAASでCommissionしてそのマシンの構成情報を取得・UI上からデバイスパーティションレイアウト・ファイルシステム手動で編集するしかありません。

f:id:pudding_info:20210619153034p:plain
CommissionしてReadyになったらこのUIでストレージレイアウトを編集出来る

数台程度しかマシンがない一般的なご家庭ならまだしも、逸般的な誤家庭とか、企業のプロダクション環境のサーバは単一のディスクしかないシンプルな環境とはほど遠いはずです。デフォルトで用意されているストレージレイアウトはどう見ても機能不足で、一台ずつ手動設定というのはさすがに目も当てられません。

curtin

Ubuntuの有名さに対して、curtinは驚くほど知られていません。例えばQiitaで curtin を検索してみても、軽く読んだ限り明示的に利用しているのは以下の記事だけのようでした。

qiita.com

公式ドキュメントを見ても何ができて何を目指しているのか全然分からないというのが正直な感想でした。

curtin.readthedocs.io

curtinのconfigを抽出する

とにかく、MAASは内部的にはこのcurtinを利用してマシンのセットアップを行っています。以下のドキュメントで説明されています。

maas.io

MAASは選択したストレージレイアウトに従って動的にcurtinの設定を生成し、デプロイしているようです。一度手動でストレージのレイアウトを編集してデプロイし、MAAS CLIからその設定を抜き出すことで、独自のレイアウトを再利用することができます。

$ MAAS_USER=MAASのログインユーザ名
$ MAAS_URL=MAASのURL(例:http://localhost:5240/MAAS/)
# API KeyはWeb UIから入手可能
$ maas login ${MAAS_USER} ${MAAS_URL}
API key (leave empty for anonymous access):
# ホスト名と紐付けて目的のノードのsystem idを探す
$ maas ${MAAS_USER} machines read | jq -r '.[] | .hostname + ": " + .system_id'
maas-node1: xh4xrn
maas-node3: qbyswd
maas-node2: t3f4ba
# 今回はmaas-node1のcurtin configを適当なファイルに吐く
$ maas ${MAAS_USER} machine get-curtin-config xh4xrn > /tmp/curtin-config-lvm

重要なのは storage: から始まるセクションだけなので他は消してしまって良いです。以下の様なマシンでLVM layoutを選択した場合のストレージレイアウトです。

storage:
  config:
  - grub_device: true
    id: sda
    model: Virtual Disk
    name: sda
    ptable: gpt
    serial: 6002248094595034c40f053014e7387d
    type: disk
    wipe: superblock
  - device: sda
    flag: boot
    id: sda-part1
    name: sda-part1
    number: 1
    offset: 4194304B
    size: 536870912B
    type: partition
    uuid: 33524d54-d698-43e5-985e-cc68b470f720
    wipe: superblock
  - device: sda
    id: sda-part2
    name: sda-part2
    number: 2
    size: 68174217216B
    type: partition
    uuid: 9d681714-39ad-42e2-a555-84f52b9cb216
    wipe: superblock
  - devices:
    - sda-part2
    id: vgroot
    name: vgroot
    type: lvm_volgroup
    uuid: 280744d7-d041-40e2-98c8-4d4810d780f2
  - id: vgroot-lvroot
    name: lvroot
    size: 68170022912B
    type: lvm_partition
    volgroup: vgroot
  - fstype: fat32
    id: sda-part1_format
    label: efi
    type: format
    uuid: 4314dc3b-b93a-4d9f-a0f2-68c167f6bb03
    volume: sda-part1
  - fstype: ext4
    id: vgroot-lvroot_format
    label: root
    type: format
    uuid: d422bd61-cd28-473e-ad17-7e80fc51a241
    volume: vgroot-lvroot
  - device: vgroot-lvroot_format
    id: vgroot-lvroot_mount
    path: /
    type: mount
  - device: sda-part1_format
    id: sda-part1_mount
    path: /boot/efi
    type: mount
  version: 1

公式ドキュメントを読むとおおよその意味は分かります。

curtin.readthedocs.io

この設定でmaas-node1を固定するには以下の様にします(storageセクション以外は消しておくこと)*3

$ sudo cp /var/snap/maas/current/preseeds/curtin_userdata.sample /var/snap/maas/current/preseeds/curtin_userdata_ubuntu_amd64_generic_focal_maas-node1
$ cat /tmp/curtin-config-lvm | sudo tee -a /var/snap/maas/current/preseeds/curtin_userdata_ubuntu_amd64_generic_focal_maas-node1

抽出したconfigを流用する

現在の設定には2つつらみがあります。

  • uuidやserialが指定されている
  • ボリュームのサイズが指定されている

前者を解決するために、とりあえずそれらの値を全部削除します。type: partition および type: format についてはuuidが無くても動く*4ようですが、 type: disk では serial もしくは path の指定が必要なようです*5。よって以下の様にpathを指定します。

  storage:
    config:
    - grub_device: true
      id: sda
      model: Virtual Disk
      name: sda
      ptable: gpt
-     serial: 6002248094595034c40f053014e7387d
+     path: /dev/sda
      type: disk
      wipe: superblock

この設定はかなりハードウェアの変更に弱いです。全然良い方法とは思えませんが共通化するためにはこれくらいしか方法が無いように思われました。

後者は解決できませんでした。

https://curtin.readthedocs.io/en/latest/topics/storage.html#partition-command

Note Curtin does not adjust size values. If you specific a size that exceeds the capacity of a device then installation will fail.

このように書いてあり「残り全部」みたいな指定ができません。管理するマシンのストレージ容量の最低値を基準にすれば一応動くはず……*6

この設定を Ubuntu 20.04 かつ amd64 なマシンのセットアップに利用するためには、ノードごとの設定と同じようにファイルを配置します。

$ sudo cp /var/snap/maas/current/preseeds/curtin_userdata.sample /var/snap/maas/current/preseeds/curtin_userdata_ubuntu_amd64_generic_focal
$ cat /tmp/curtin-config-lvm | sudo tee -a /var/snap/maas/current/preseeds/curtin_userdata_ubuntu_amd64_generic_focal

ノードごとの設定が無い場合、デフォルトの設定を無視してこのストレージレイアウトが読まれます。

疑問点

さて、全ノードが全て同容量の単一ディスクしか持っていない場合はこれでもうまくいきそうです。しかし現実には、

  • 導入した世代によって容量が違う
  • 導入した世代によってはSATAではなくNVMe SSDを搭載している
  • ディスクが2本以上搭載されている

など様々なケースが考えられそうです。果たしてこれをプロダクション環境で利用している人たちはどうやっているんでしょうか……

ホストごとに設定を作ることは出来ますが、ホスト名のプレフィクスなどで読み込むconfigを変えるようなロジックは無さそう*7であり、

  • インストールするOSのバージョン・マシンのアーキテクチャの組ごとにマシンの構成の方を固定化してしまう
  • 全ホストで独自の設定ファイルを生成するようにする

のいずれかの対策をとらなければいけないように思われます。

まとめ

おうちでベアメタルクラウドをやる第一歩としてMAASでPoCしていたところ、ストレージ周りの工夫が必要なことが判明したため少し調べました。

現状では一度手動で設定し、その設定を流用することで以降独自のストレージレイアウトでプロビジョニングできそうです。ただしノードごとに構成が揃っていることが必要そうです。

これをプロダクション環境で使っている人たち、どうしているんですかおしえてください 🙏

参考


*1:いずれもそのマシンがサポートしていない場合、手動で電源をオンにしたりする必要があります

*2:maas.io

*3:https://maas.io/docs/snap/2.9/ui/custom-machine-setup#heading--template-naming

*4:そもそもpartitionのドキュメントには uuid の項目がありません。このパラメータがどう使われているのかは不明です。 https://curtin.readthedocs.io/en/latest/topics/storage.html#partition-command

*5:https://curtin.readthedocs.io/en/latest/topics/storage.html#disk-command

*6:使えない領域が出来ます

*7:https://github.com/maas/maas/blob/1aa6276c0b6d7d702f1ed3036fd11d30e879c285/src/maasserver/preseed.py#L614