これはKMC Advent Calendar 2023 22日目の記事です。そして、これは去年のアドベントカレンダー記事における「k0s構築の話を書くと完全に間に合わないので、またそのうち書きます。」へのアンサーです。一年かかったんかいな。
Kubernetes(以下k8s)ってメモリ食うのでVPSの安いインスタンスだとかなり辛いんですが、最近出てきたIoT向けk8sだとなんとかなりそうな気がしたのでやってみたメモ。手元にいつでも壊せるroot持ったk8sがひとつあると色々はかどります(?)。有名どこだとk0sとk3sですが、今回はk0sでやっていきます。
構成図としてはこんな感じ。
入れる先はさくらVPSのメモリ2G(vCPUx3)プランで、使用感だけ先にお伝えしておくと「平時はLAだいたい3~5あたりで、新規にns/pod/svc追加するなどするとホストのLAが10越えてしばらく沈黙する」ので間違ってもお仕事で使える感じではないです。
やりたいこと
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を参照。
kube-proxyをipvsモードにする
Serviceを実装する方法はいくつかあります2が、あとでアクセス制限する際に楽なのでipvsモードにしておきます。
ipvsの設定
- ipvsadmを
apt-get install ipvsadm
とかで入れます。 - sysctlで
net.ipv4.vs.conntrack=1
を設定する必要がある3ので、/etc/sysctl.confあたりに書いておく。 - /etc/modules.conf あたりに
ip_vs
書くなどしてkernel moduleを読んでおいてもらう。
k0sの設定
spec.network.kubeProxyでmode:ipvs
と設定してk0sを再起動すると、kube-proxyのDaemonSetが書き換えられる。spec.updateStrategy.type: updateStrategy
になっていれば4勝手にpodが再作成される(そうでなければ、podを消して再作成してもらう)。
svc疎通用のiptables設定
インターネット直結VPSを使い始める際にはまずiptablesでsshなど一部の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にあたるserviceCIDR
のIPアドレスレンジですが、k0sのConfigurationに書いてある値6を取ってきます。kube-bridge
はkube-routerが生成したデバイスです。
helmなどを入れる
helm
helmは素朴にbinaryを取ってきて入れるだけ。
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に書いた記事を参考に要約。
- DashboardのDNSでProxiedにして、SSL設定をFull(strict)にする
- Origin CAのTLS証明書を作成してダウンロード
- Origin Rulesでk0sクラスタのドメインへの接続時は1443/tcpで繋ぐようにdestination portの条件を設定
ダウンロードしたOrigin CAのCertは後でTLS Terminatorに食わせます。
TLS終端の設定
ドメインいっこだけならwildcard証明書をingress-nginxのDefault Certficateに設定すればいい(と思う)んですけど、今回ドメイン2つ運用するのでそうもいかない。ingressで毎回TLS証明書について書くのはだるいので、TLS終端を別プロセスで行うことにしました。
今回はTLS終端としてHitchを使いました。これはVarnishのTLS終端用に書かれたものですが、素朴にTLSを解いてHTTPを中継する汎用的ソフトウェアです。HitchでCloudflareからやってくるTLS接続を受け、生TCPでingress-nginxに流します。複数のTLS SNIを一つのサーバでホストすると「接続時のTLS証明書(SNI)と一致しないドメインがHostヘッダに書かれる」攻撃7を食らう可能性がありますが、試したところCloudflareがブロック8してくれました。こうすることで、ingressリソースを書く際にTLS設定をしなくてもTLS接続になります。
ingress-nginxはh2cの設定ができないので、ALPNではHTTP1.1を設定します。また、上流CloudflareなのでPROXY protocolによる接続元中継はしません9。
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はたいした長さじゃないのでえいやっと貼る。backend
とfrontend
は説明不要だと思うので端折ります。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
DISCLAIMER:現時点でわたしはIPVSモードでの運用をしていて記事書く際の再調査をしていないので、誤りが残っている可能性があります。
iptablesモードでsvcからpodへのDNATはPREROUTING12で行われます。最初NodePortでexposeしていてkube-proxyをipvsモードに切り替えた後にexternalIPsでのexposeに変えたため、以下の説明はNodePortでのexpose時の内容になります。
パケットをフィルタする処理はINPUT(やFORWARD)チェインでないと出来ないので、iptables-extensionのconntrack
モジュールを使ってDNAT前のIPアドレスを見てDROPすることを考えます。しかし、kube-routerがKUBE-ROUTER-INPUT
というカスタムチェインがINPUTチェイン先頭に存在するよう監視していて、カスタムチェインの中でpodに対するパケットをACCEPTしてしまうので蹴ることが出来ない。
仕方ないので、kube-proxyの--nodeport-addressで127.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に組み込みたい
-
kubernetes - How do I configure storage class on an existing k0s cluster - Server Fault見てると
k0s reset
が必要らしい。↩ - 詳細はService > 仮想IPとサービスプロキシー | Kubernetesを参照。↩
- IPVS (LVS/NAT) とiptables DNAT targetの共存について調べた - ntoofu↩
- Perform a Rolling Update on a DaemonSet | Kubernetes↩
- iptablesモードでkube-proxyを動かしている場合、serviceのIPアドレスをpodへ書き換えるDNAT処理はPREROUTINGチェインの中で行われてINPUTチェインに入る際にはDNAT済の状態でやってきます。pod IPへの接続性はCNI(k0sの場合デフォルトではkube-router)がiptables含めてよしなにやるため、手動設定は不要。↩
- この値はソースコード埋め込みなので、そうそう変わらないはず。↩
- NGINXリバースプロキシでTLS Server Name Indication (SNI)と異なるドメイン名のバックエンドホストへルーティングできちゃう件について #nginx - Qiita↩
- このときCloudflareは403を返してきたんですが、421を返すべきっぽい雰囲気もある。↩
-
PROXY protocolで中継される接続元IPアドレスはリクエストを中継するCloudflareのIPが入っています。前項で書いたように、ingress-nginxはHTTP
CF-Connecting-IP
ヘッダによりCloudflareが接続を受けた際の接続元IPを認識する設定にしています。↩ - use http2 in http server to support grpc without tls fix #4630 by stamm · Pull Request #4631 · kubernetes/ingress-nginx↩
- upgradeを求めるissue(Nginx 1.25.1+ is required for a specific use case (NLB/TLS+HTTP2) · Issue #10390 · kubernetes/ingress-nginx)参照。↩
- 詳細はIPTABLES :: The Kubernetes Networking Guideを参照。↩
- 詳しくはNodePort :: The Kubernetes Networking Guideを参照。↩