HAなPritunl ServerをKubernetes(GKE)上に構築する


リモートワークの影響、、は関係ありませんが、VPNサーバーを構築する必要があったので手順を記録します。

調べた限りでは、可用性やSSL設定まで踏み込んだPritunlの記事がなかったので、そこそこ実用的な環境でPritunlを使いたい方の参考になるんじゃないかなと思います。

Pritunl: https://github.com/pritunl/pritunl/releases/tag/1.29.2423.17

環境

  • GKE: 1.14.10-gke.27
  • Pritunl: 1.29.2423.17
  • Docker Container Base Image: Ubuntu 18.04
  • MongoDB: 4.2

(インスタンスタイプは小さくても動かせますが、要件によってはネットワーク速度がボトルネックになりそうです。)

全体像

今回はGKE上にPritunlサーバーを構築します。
Pritunlは、設定管理用のWeb UIとVPN接続用のportを使用するので、GCPのL4ロードバランサーを使用します。そのため、ロードバランサー側ではなくPritunlサーバー側でHTTPSを捌きます。幸いPritunl自体にLet's Encryptでの証明書管理機構が付いているのでこちらを利用します。

また、PritunlはDBにMongoDBを採用しており、GCPにはManaged MongoDB Cluster的なサービスは無いため、Kubernetes上にMongoDBクラスタも併せて構築します。

まとめると、大まかに以下の手順が必要になります。

  • K8s上へMongoDBクラスタを構築
  • PritunlのDocker Imageを作成
  • K8s上へ複数のPritunl Podを構築し、L4ロードバランサーを置く
  • DNS設定とSSL化
  • VPN Clientの設定

K8s上へMongoDBクラスタを構築

主にこちらの記事の通りですが、少し古い内容なのと、GKE向けの内容も交えたいので説明していきます。

Storage Class

まず、MongoDBの永続化データを格納するstorageを用意します。
GKEにはデフォルトでstandardという名前でHDDのStorageClassが定義されていました。ここではそのまま使いますが、SSDを使いたい場合やreplicationをいじりたい場合は独自に定義します。

Headless Service

次に、Kubernetes内部(今回だとPritunl)からMongoDBクラスタにアクセスするための内部DNSの設定をします。

MongoDBに接続する際にはどれがPrimary Nodeかなど知らないといけないので、ロードバランサなど使わず、それぞれに個別のDNSを割り当てたいです。また、後述するStatefulSetを作成すると、Podそれぞれに固有の名前(mongo-statefulset-0など)が付けられます。
そこで、これらPodの名前をDNS名に割り当てることが出来るHeadless Serviceを使用します。

yamlはこのように、clusterIP: Noneと設定する形です。

apiVersion: v1
kind: Service
metadata:
  name: mongo-svc
spec:
  ports:
    - port: 27017
      targetPort: 27017
  clusterIP: None
  selector:
    app: mongo

これにより、mongo-statefulset-0という名前のPodに対しては、mongo-statefulset-0.mongo-svcといったDNS名が割り当てられるようになります。

MongoDB StatefulSet

最後にMongoDBのPodsを作っていきます。

DBのPodは普通のstatelessなPodとは違い、

  • それぞれ固有の識別子を持つ
  • それぞれ固有のStorageを持つ
  • rolling updateやterminateは、一定の順序で行う必要がある

といった特徴があるため、StatefulSetを使います。上記で定義したStorage Classをここで使用します。

yamlとしてはこんな感じ。

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongo-statefulset
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mongo
  serviceName: "mongo-service"  # 1
  template:
    metadata:
      labels:
        app: mongo
    spec:
      terminationGracePeriodSeconds: 10
      containers:
        - name: mongo
          image: mongo:4.2
          resources:
            requests:
              memory: "200Mi"
          command:
            - mongod
            - "--replSet"
            - rs0
            - "--bind_ip"
            - "0.0.0.0"
          ports:
            - containerPort: 27017
          volumeMounts:
            - name: mongo-persistent-storage  # 2
              mountPath: /data/db
  volumeClaimTemplates:
    - metadata:
        name: mongo-persistent-storage  # 3
        annotations:
          storageClassName: "standard"  # 4
      spec:
        accessModes:
          - ReadWriteOnce  # 5
        resources:
          requests:
            storage: 30Gi

名前から推測できるところは置いておいて、

1 のserviceNameで、先程のHeadless Serviceを指定します。
https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#statefulsetspec-v1-apps を読む限り、Pod作成と同時にUniqueなDNS名を持たせるために、先にServiceを作りそれを指定することが必要みたいです。

3で、永続化用のVolumeを用意しておいて2 で参照することで、該当Podが死んでも再度MountしてStateを保ち続けることが出来ます。
4で、用意したStorageClassを参照します。今回はGKEに最初から定義されていたstandardを、一台あたり30GB用意した形になります。
5については、他のPodにも同じDiskをMount出来るようにするかどうかのようですが、大抵のクラスタ構成を組めるDB Systemに置いては他の設定は使う必要がないように思います。

なお、先ほど触れたUpdate Storategyについてですが、デフォルトでは.spec.updateStrategy.type = RollingUpdate となり、Update時にはPodは一つずつ順番に更新されていくようになります。

MongoDBのクラスタ化

StatefulSetをGKEにdeployすると、それぞれmongo-statefulset-0.mongo-svcなどのDNSを持ったPodが立ち上がります。ただ、そのままではクラスタを組めていないので、どれか一台に入り、clientコマンドでクラスタ化します。

kubectl exec -it mongo-statefulset-0 -- /bin/bash

mongo >
rs.initiate( {
   _id : "rs0",
   members: [
      { _id: 0, host: "mongo-statefulset-0.mongo-svc:27017" },
      { _id: 1, host: "mongo-statefulset-1.mongo-svc:27017" },
      { _id: 2, host: "mongo-statefulset-2.mongo-svc:27017" }
   ]
})

以上でGKE上にMongoDBのクラスタを構築出来ました。今回はk8sクラスタ内部からしか繋がないのでpasswordなどは設定していません。

PritunlのDocker Imageを作成

公式がDocker Imageを公開してないようだったので、今回はUbuntu 18.04のイメージから自分でビルドします。

Dockerfile
FROM ubuntu:18.04

RUN apt update
COPY --chown=root:root ["install.sh", "/root"]
RUN bash /root/install.sh

COPY start.sh /root/

EXPOSE 80
EXPOSE 443
EXPOSE 1999

CMD ["bash", "/root/start.sh"]
install.sh
set -ex

RUN apt install -y gnupg2
echo 'deb http://repo.pritunl.com/stable/apt bionic main' > /etc/apt/sources.list.d/pritunl.list
apt-key adv --keyserver hkp://keyserver.ubuntu.com --recv 7568D9BB55FF9E5287D586017AE645C0CF8E292A
apt update
apt install -y pritunl iptables
start.sh
set -ex

pritunl set-mongodb mongodb://mongo-statefulset-0.mongo-svc,mongo-statefulset-1.mongo-svc,mongo-statefulset-2.mongo-svc/pritunl?replicaSet=rs0
pritunl set app.redirect_server true
pritunl set app.server_ssl true
pritunl set app.server_port 443
pritunl set app.reverse_proxy true
pritunl start

Pritunlサーバー起動時前にmongoDBの設定などを動的にしたいので起動用のshellを用意しました。ここではDNSをベタ書きしていますが、必要に応じて環境変数などにしてもらえれば。

(特に最適化もしてないし汎用性も無いので説明はありません。)

K8s上へ複数のPritunl Podを構築し、L4ロードバランサーを置く

PritunlのDeployment作成

まず、作成したPodをk8sにデプロイします。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pritunl-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pritunl
  template:
    metadata:
      labels:
        app: pritunl
    spec:
      containers:
        - name: pritunl-container
          image: asia.gcr.io/<project name>/pritnul:1
          securityContext:
            privileged: true
          ports:
            - containerPort: 80
            - containerPort: 443
            - containerPort: 1999

ここで取り上げることとしては、Pritunlがiptablesをいじるため、securityContextを設定することくらいです。他は普通のdeploymentですね。

デプロイが成功したら、初期設定としてパスワードは変えておきましょう。
kubectl execを使ってPodに入り、pritunl reset-password でパスワードを変更します。

L4ロードバランサー

事前にstatic IPを用意します。ここでは、L4 LoadBalancerがregionalで配置されるので、static IPもGKEクラスタと同じregionで取得します。(ちなみに今回はIPv4を使っています)

apiVersion: v1
kind: Service
metadata:
  name: pritunl-lb-service
spec:
  selector:
    app: pritunl
  ports:
    - protocol: TCP
      name: "1999"
      port: 1999
      targetPort: 1999
    - protocol: TCP
      name: "80"
      port: 80
      targetPort: 80
    - protocol: TCP
      name: "443"
      port: 443
      targetPort: 443
  type: LoadBalancer
  loadBalancerIP: "<your static IP>"

ここで、web UI用とVPN Clientが使うportを空けておきます。複数のVPNを用意するのであればそれぞれportを空けておくことになります。

また、GCPのL4 LoadBalancerではTCPとUDPを同時に設定することは出来ないようだったので、今回はVPN ClientはTCPを使うようにしました。

DNS設定とSSL化

ここまでで、static IPを使ってPritunlサーバー群にアクセスすることは出来るようになりました。が、L4ロードバランサーを使っているためSSL化が出来ていません。最後にその設定をしましょう。

まずDNS設定をします。ただお使いのdomainにAレコードにstatic IPを入れるだけです。

その設定が反映されたら、ブラウザでまだ証明書の取れてないWeb UIにアクセスし、ログインした後にLet's EncriptでSSL証明書を取るためにDomainを入力します。80番portも空けた状態でしばらく待つと、Pritunlが勝手にSSL証明書を取得してくれます。

VPN Clientの設定

最後にVPN Clientの設定です。PritunlでServer, Organization, Userと作っていくとovpnファイルをダウンロード出来るようになります。これをVPN Clientにimportするのですが、そのままではPritunlサーバーのPrivateアドレスになってしまっているので、ロードバランサーに届くようにドメイン名を指定します。

~~.ovpn
...
remote your.domain.name 1999 tcp-client
...

以上で、接続できるようになるはずです。

終わりに

個人的には、StatefulSetと、Docker Container側でNetwork設定(iptables)を使うというのが初めてだったので良い題材になりました。

今回はGCPのロードバランサーの制約でUDPは使えませんでしたが、もしTCP/UDPを合わせて使用できるやり方をご存知の方がいましたらぜひ教えて下さい。