k0sをさくらVPSに入れてKubernetesをやっていく

これはKMC Advent Calendar 2023 22日目の記事です。そして、これは去年のアドベントカレンダー記事における「k0s構築の話を書くと完全に間に合わないので、またそのうち書きます。」へのアンサーです。一年かかったんかいな。

adventar.org

Kubernetes(以下k8s)ってメモリ食うのでVPSの安いインスタンスだとかなり辛いんですが、最近出てきたIoT向けk8sだとなんとかなりそうな気がしたのでやってみたメモ。手元にいつでも壊せるroot持ったk8sがひとつあると色々はかどります(?)。有名どこだとk0sとk3sですが、今回はk0sでやっていきます。

k0sproject.io

構成図としてはこんな感じ。

入れる先はさくらVPSのメモリ2G(vCPUx3)プランで、使用感だけ先にお伝えしておくと「平時はLAだいたい3~5あたりで、新規にns/pod/svc追加するなどするとホストのLAが10越えてしばらく沈黙する」ので間違ってもお仕事で使える感じではないです。

やりたいこと

  • さくらVPSに入れる
  • TLSは常識ということで意識しない
  • ドメイン2つ貼り付ける。
  • 一次受けはCloudflare

k0s のインストール

公式のGET STARTEDの内容を行う。

コマンド一発でバイナリが入り、シングルノード設定(ワーカー・コントローラーが同じノードに乗る)で起動。

$ curl -sSLf https://get.k0s.sh | sudo sh
$ sudo k0s install controller --single
$ sudo k0s start

ストレージの設定

素朴に入れた場合はOpenEBS Local PVが入らないので、必要ならばStorage (CSI) - Documentationを参考にしてクラスタをつくりなおす1必要がある。

$ sudo k0s stop
$ sudo k0s reset
$ sudo k0s install controller --single -c /etc/k0s/k0s.yaml

PersitentVolumeとしてhostPath使うのどうなんですか

あるにはあるんですけど、PersistentVolumeSpec v1 coreの説明に「開発/テスト用だぞ!」って書いてあって微妙。他にもlocalってのがあって、詳細はhostPathとlocalのPersistentVolumeの違い #kubernetes - Qiitaを参照。

SLA 0%のシングルノードなら気にする必要ないですね。

kube-proxyをipvsモードにする

Serviceを実装する方法はいくつかあります2が、あとでアクセス制限する際に楽なのでipvsモードにしておきます。

ipvsの設定

  1. ipvsadmをapt-get install ipvsadmとかで入れます。
  2. sysctlでnet.ipv4.vs.conntrack=1を設定する必要がある3ので、/etc/sysctl.confあたりに書いておく。
  3. /etc/modules.conf あたりに ip_vs書くなどしてkernel moduleを読んでおいてもらう。

k0sの設定

spec.network.kubeProxymode:ipvsと設定してk0sを再起動すると、kube-proxyのDaemonSetが書き換えられる。spec.updateStrategy.type: updateStrategyになっていれば4勝手にpodが再作成される(そうでなければ、podを消して再作成してもらう)。

svc疎通用のiptables設定

インターネット直結VPSを使い始める際にはまずiptablessshなど一部のtcp接続を除いてlo以外全部蹴るようにします。今回はさくらVPS使ってるので、さくらVPSドキュメント参考にしてssh以外全部蹴るiptablesが入ってることを前提に話を進めます。

kube-proxyでipvsモードを設定したので、svc ipからpod ipへのDNATはIPVSで行われます。このDNATはINPUTチェイン通過後(かつPOSTROUTING前)に行われるため、podからsvcへ向かうパケットがINPUTチェインを通過5できる必要があります。

sudo iptables -A INPUT -i kube-bridge -s 10.244.0.0/16 -d 10.96.0.0/12 -j ACCEPT

srcにあたるpodCIDRとdstにあたるserviceCIDRIPアドレスレンジですが、k0sのConfigurationに書いてある値6を取ってきます。kube-bridgeはkube-routerが生成したデバイスです。

helmなどを入れる

helm

helmは素朴にbinaryを取ってきて入れるだけ。

helm.sh

ingress-nginx

helm経由で入れる。

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx  --create-namespace --values ingress-nginx.yaml 

Helmに与える値のingress-nginx.yamlは以下。「接続元IPアドレスをCloudflare経由で知る」「ingress-nginxは直接インターネットから接続を受けない」設定です。値の詳細な説明はREADMEを参照してください。

controller:
  config:
    enable-real-ip: true
    forwardred-for-header: cf-connecting-ip
  service:
    enableHttps: false
    type: ClusterIP

Cloudflare経由でHTTPS接続を受ける

直接インターネット接続を受けるのはつらいので、CloudflareをCDN及び簡単なWAFとして前段に置きます。

Cloudflareの設定

Zennに書いた記事を参考に要約。

  1. DashboardDNSでProxiedにして、SSL設定をFull(strict)にする
  2. Origin CATLS証明書を作成してダウンロード
  3. Origin Rulesでk0sクラスタドメインへの接続時は1443/tcpで繋ぐようにdestination portの条件を設定
    1. わたしの環境では既に443/tcpを使っているため。まだ443/tcpを使った何かをしてない場合はこれはいらないと思う。

ダウンロードしたOrigin CAのCertは後でTLS Terminatorに食わせます。

TLS終端の設定

ドメインいっこだけならwildcard証明書をingress-nginxのDefault Certficateに設定すればいい(と思う)んですけど、今回ドメイン2つ運用するのでそうもいかない。ingressで毎回TLS証明書について書くのはだるいので、TLS終端を別プロセスで行うことにしました。

今回はTLS終端としてHitchを使いました。これはVarnishTLS終端用に書かれたものですが、素朴にTLSを解いてHTTPを中継する汎用的ソフトウェアです。HitchでCloudflareからやってくるTLS接続を受け、生TCPingress-nginxに流します。複数のTLS SNIを一つのサーバでホストすると「接続時のTLS証明書(SNI)と一致しないドメインがHostヘッダに書かれる」攻撃7を食らう可能性がありますが、試したところCloudflareがブロック8してくれました。こうすることで、ingressリソースを書く際にTLS設定をしなくてもTLS接続になります。

ingress-nginxはh2cの設定ができないので、ALPNではHTTP1.1を設定します。また、上流CloudflareなのでPROXY protocolによる接続元中継はしません9

nginxとh2cについて

ingress-nginxはh2cの設定が現状できません。nginxがh2からh2cへfallbackしないという理由でpatch10が蹴られたりしています。

なお、nginxはv1.25.1からprefaceによるhttp/2判別を実装していますが、現状ingress-nginxが1.21系のまま11なので、これがなんとかならないと話が進まない。

また、HitchはPROXY protocolでALPNを伝えることができますが、nginxは単に$proxy_protocol_tlv_alpnへ代入するだけでALPNでlistenディレクティブプロトコル指定を変えられるわけではなく、前出のv1.25.1におけるpreface判別しかなさそうです。

YAMLはたいした長さじゃないのでえいやっと貼る。backendfrontendは説明不要だと思うので端折ります。secretに入ってるPEMは素朴にprivate key fileとcertificate fileをconcatしたものになります。

apiVersion: v1
kind: Namespace
metadata:
  name: hitch
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: hitch
  name: hitch
  labels:
    app: hitch
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hitch
  template:
    metadata:
      labels:
        app: hitch
    spec:
      containers:
      - image: hitch:1.7.3-1
        name: hitch
        args:
          - --alpn-protos=http/1.1
          - --write-proxy=off
          - --backend=[ingress-nginx-controller.ingress-nginx]:80
          - --frontend=[0.0.0.0]:443
          - /pems/example.jp.pem
          - /pems/example.net.pem
        volumeMounts:
        - name: pems
          mountPath: /pems
        ports:
          - name: https
            containerPort: 443
      volumes:
      - name: pems
        secret:
          secretName: pems
---
apiVersion: v1
kind: Secret
metadata:
  namespace: hitch
  name: pems
data:
  example.jp.pem: hogehoge
  example.net.pem: fugafuga
---
apiVersion: v1
kind: Service
metadata:
  name: hitch
  namespace: hitch
spec:
  type: ClusterIP
  externalIPs:
  - (VPSの外IP)
  selector:
    app: hitch
  ports:
  - protocol: TCP
    name: https
    targetPort: https
    port: 1443

接続を受けるためにNodePortでなくClusterIPのexternalIPsを使っています。シングルノードなのでNodePortじゃなくてもいいかな~っていうアレです。exposeするポートが1443/tcpなのは先述した通り443/tcpを既に別用で使ってるからで、たぶん443/tcpでも行けると思いますが未検証。

アクセス制限

序盤で「アクセス制限」って言ってたところです。と言っても、IPVSモードの場合INPUTチェインでマッチしなければdropするので、特段の追加設定がない限り外からの接続は蹴られています。 Zennに書いた記事を参考にCloudflareのIPSetを定義しておき、それを参照するiptablesを投入。

sudo iptables -I INPUT -i ens3 -p tcp -m state --state NEW -m tcp --dport 1443 -m set --match-set cloudflare-origin-v4 src -j ACCEPT

kube-proxyがiptablesモードのときどうなのか

DISCLAIMER:現時点でわたしはIPVSモードでの運用をしていて記事書く際の再調査をしていないので、誤りが残っている可能性があります。

iptablesモードでsvcからpodへのDNATはPREROUTING12で行われます。最初NodePortでexposeしていてkube-proxyをipvsモードに切り替えた後にexternalIPsでのexposeに変えたため、以下の説明はNodePortでのexpose時の内容になります。

パケットをフィルタする処理はINPUT(やFORWARD)チェインでないと出来ないので、iptables-extensionconntrackモジュールを使ってDNAT前のIPアドレスを見てDROPすることを考えます。しかし、kube-routerがKUBE-ROUTER-INPUTというカスタムチェインがINPUTチェイン先頭に存在するよう監視していて、カスタムチェインの中でpodに対するパケットをACCEPTしてしまうので蹴ることが出来ない。

仕方ないので、kube-proxyの--nodeport-address127.0.0.1を指定してloでのみ接続を受け付けるような設定をした上で、許可するIPアドレスのときだけチェインに飛ばす作戦で行くことにしました。なお、k0sの場合にk0s.yamlでこの設定ができるのはv1.28.2+k0s.0以降です。

NodePortの処理はPREROUTINGからKUBE-NODEPORTSチェインに飛ぶことで行われる13ので、--nodeport-address=127.0.0.1とした場合127.0.0.1以外への接続のみ処理するようになります。次に、PREROUTINGに通したい接続をKUBE-NODEPORTSチェインへ飛ばすような設定を入れます。

sudo iptables -t nat -I PREROUTING -i ens3 -p tcp --dport 32081 -m set --match-set cloudflare-origin-v4 src -m conntrack --ctstate NEW -j KUBE-NODEPORTS

繰り返しますが、以上の説明はNodePortの場合でありexternalPsの場合は未調査です。

ArgoCDなどの設定

ArgoCDを入れます。入れるの自体はhelmでシュッと出来るんですが、ちょっと準備があります。また、ArgoCDのingress設定をした上でインターネットから見えるURLはhttps://www.example.jp/argocd/と仮定して以下書いていきます。

GitHubの設定

認証認可およびSSOをGitHubに任せるため、githubのorganizationとOAuth appをつくっておきます。

で、organizationにArgoCDが見るmanifestのrepoがあるという方向でわたしは作りました。

Helmで入れる

helm repo add argo-helm https://argoproj.github.io/argo-helm
helm upgrade --install argo-helm argocd/argo-cd --namespace argocd --create-namespace --values ./argocd.yaml

argocd.yamlは以下で、ほとんどが認証認可設定です。 Valueの詳細な説明はrepoのREADMEを見てください。

configs:
  cm:
    admin.enabled: "false"
    dex.config: |
      connectors:
      - type: github
        id: github
        name: GitHub
        config:
          clientID: <GitHub OAuthのClientID>
          clientSecret: $dex.github.clientSecret
          orgs:
          - name: <GitHub Orgnanizationの名前>
    url: https://www.example.jp/argocd
  params:
    server.basehref: /argocd
    server.insecure: true
    server.rootpath: /argocd
  rbac:
    policy.csv: |
      g, <GitHub Orgnanizationの名前>:admin, role:admin
  secret:
    extra:
      dex.github.clientSecret: <GitHub OAuthのClientのSecret>
    githubSecret: <webhookのsecret>

<webhookのsecret> は後で設定するArgoCDのWebhook受信時のsecretなので、適当なランダム文字列を設定します。

ingressをつくる

ついでにAppProjectも作ってしまいます。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-server-ingress
  namespace: argocd
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
  - host: www.example.jp
    http:
      paths:
      - backend:
          service:
            name: argocd-server
            port:
              name: http
        path: /argocd
        pathType: Prefix
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: <なんかいい感じの名前>
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  description: <なんかいい感じの説明>
  sourceRepos:
    - "https://github.com/<GitHub Orgnanizationの名前>*"
  destinations:
    - namespace: '*'
      server: https://kubernetes.default.svc
  clusterResourceWhitelist:
  - group: '*'
    kind: '*'

ここまでやると、https://www.example.jp/argocd/でArgoCDに繋がるはず。policy.csvでorganizationに所属するユーザはadmin roleを持っているので、作成したユーザで入れるはず。

GitHubのWebhook設定

ArgoCDはレポジトリ更新をpollingとwebhookのどちらかで取得します。pollingはださい(?)ので、Webhookでいきます。前節でingressが出来てるので、ArgoCDのWebHook URLが叩けるようになってるはずなので、repositoryにWebhookを作成します。

  • Payload URLはhttps://www.example.jp/argocd/api/webhook
  • Content typeはapplication/json
  • Secretは前節でHelmのValueに書いた<webhookのsecret>

おつかれさまでした。

ArgoCD入れるあたりは記憶やメモが曖昧であんまり参考にならない感じになってしまいましたが、こんな感じでおうちk0sが出来ました。

そのうちやりたいことをメモって、締めとします。

  • メモリ2Gは限界すぎるので家にN100マシン置いてcontrol planeそっちに移したい
  • 東京においてあるRasPi4は暇してるのでNodeに組み込みたい

  1. kubernetes - How do I configure storage class on an existing k0s cluster - Server Fault見てると k0s resetが必要らしい。
  2. 詳細はService > 仮想IPとサービスプロキシー | Kubernetesを参照。
  3. IPVS (LVS/NAT) とiptables DNAT targetの共存について調べた - ntoofu
  4. Perform a Rolling Update on a DaemonSet | Kubernetes
  5. iptablesモードでkube-proxyを動かしている場合、serviceのIPアドレスをpodへ書き換えるDNAT処理はPREROUTINGチェインの中で行われてINPUTチェインに入る際にはDNAT済の状態でやってきます。pod IPへの接続性はCNI(k0sの場合デフォルトではkube-router)がiptables含めてよしなにやるため、手動設定は不要。
  6. この値はソースコード埋め込みなので、そうそう変わらないはず。
  7. NGINXリバースプロキシでTLS Server Name Indication (SNI)と異なるドメイン名のバックエンドホストへルーティングできちゃう件について #nginx - Qiita
  8. このときCloudflareは403を返してきたんですが、421を返すべきっぽい雰囲気もある。
  9. PROXY protocolで中継される接続元IPアドレスはリクエストを中継するCloudflareのIPが入っています。前項で書いたように、ingress-nginxはHTTPCF-Connecting-IPヘッダによりCloudflareが接続を受けた際の接続元IPを認識する設定にしています。
  10. use http2 in http server to support grpc without tls fix #4630 by stamm · Pull Request #4631 · kubernetes/ingress-nginx
  11. upgradeを求めるissue(Nginx 1.25.1+ is required for a specific use case (NLB/TLS+HTTP2) · Issue #10390 · kubernetes/ingress-nginx)参照。
  12. 詳細はIPTABLES :: The Kubernetes Networking Guideを参照。
  13. 詳しくはNodePort :: The Kubernetes Networking Guideを参照。