GCPは使うけどGKEを使わずにKubernetesクラスタを構築してwebサービスを外部公開したい人向けの記事

Q. なんでそんなことするの？
A. GKE使ってたら無料分(300$)が一瞬でなくなったので、GKE使わずに同じことやったらどのくらいお値段が変わるのかと思って。

## 環境

GCPのプロジェクトは既にあるものとします。手元で認証は済んでいるものとします。

また、GCPリソースの管理にはterraformを使います。

```bash
 $ terraform version
Terraform v1.4.5
on linux_amd64
```

Kubernetesリソース管理にはkubectlとhelmfileを使います。
```bash
$ kubectl version --short
Flag --short has been deprecated, and will be removed in the future. The --short output will become the default.
Client Version: v1.26.3
Kustomize Version: v4.5.7
Server Version: v1.26.3

$ helmfile version
helmfile version v0.144.0
```

## VMを複数台立てる

GCPではCompute Engineと呼ばれるVMを利用することができる。

Kubernetesクラスタのmasterノードとworkerノードを用意する。今回は一台ずつ。
```main.tf
provider "google" {
  project     = "your_project"
  region      = "us-central1"
}

locals {
  master_tag  = "k8s-master"
  worker_tag  = "k8s-worker"
}

resource "google_compute_address" "master" {
  name = "k8s-master-address"
}

resource "google_compute_instance" "master" {
  name         = "k8s-master"
  machine_type = "n1-standard-2"
  zone         = "us-central1-a"

  boot_disk {
    initialize_params {
      image = "ubuntu-1804-lts"
    }
  }

  network_interface {
    network = "default"
    access_config {
      nat_ip = google_compute_address.master.address
    }
  }

  tags = [local.master_tag]
}

resource "google_compute_instance" "worker" {
  count        = 1
  name         = "k8s-worker-${count.index}"
  machine_type = "n1-standard-2"
  zone         = "us-central1-a"

  boot_disk {
    initialize_params {
      image = "ubuntu-1804-lts"
    }
  }

  network_interface {
    network = "default"
    access_config {
    }
  }

  tags = [local.worker_tag]
}
```

とりあえず立てるだけ。zoneとかimageとかはお好きに。
マシンタイプがデフォルトだとスペックが足らなかったので`machine_type = "n1-standard-2"`を指定する。

## Kubernetesクラスタを構築する

### masterノードを作る

コンソールのCompute Engineの画面で、「接続」 > 「SSH」 > 「gloudコマンドを表示」で表示されるコマンドを打つとインスタンスにsshできる。

さっきmasterとして作ったインスタンスのシェルで作業をする。

```bash
# Kubernetesリポジトリの追加
$ curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -
$ sudo apt-add-repository "deb http://apt.kubernetes.io/ kubernetes-xenial main"
# もろもろインストール
$ sudo apt-get update
$ sudo apt-get install -y kubelet kubeadm kubectl
# コンテナランタイムとしてContainerdを使う
$ sudo apt-get install -y containerd
$ sudo mkdir -p /etc/containerd
$ containerd config default | sudo tee /etc/containerd/config.toml
$ sudo systemctl restart containerd
# kubeapiを外部に曝すための設定
$ sudo mkdir -p /etc/kubernetes/
$ echo 'apiVersion: kubeadm.k8s.io/v1beta2
kind: ClusterConfiguration
networking:
  podSubnet: 10.244.0.0/16
apiServer:
  certSANs:
  - "localhost"
  - "192.168.1.100"
  - "10.0.0.1"
controlPlaneEndpoint: "EXTERNAL_IP:6443"' > /etc/kubernetes/kubeadm.yaml
# クラスタのセットアップ
$ sudo kubeadm init --config /etc/kubernetes/kubeadm.yaml
# セットアップが終わった後に続くメッセージに従って認証情報を設置
$ mkdir -p $HOME/.kube
$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config
# Podネットワークの設定
$ kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
```

この時点でmasterノードで`kubectl`コマンドが使えるはず。

```main.tf
# 自分のマシンからkubectlコマンドを叩きたいので6443ポートを開ける
resource "google_compute_firewall" "k8s_master" {
  name    = "k8s-master"
  network = data.google_compute_network.default.self_link

  allow {
    protocol = "tcp"
    ports    = ["6443"]
  }

  source_ranges = ["0.0.0.0/0"]

  target_tags = [local.master_tag]
}
```

masterノードの`~/.kube/config`をそのまま自分のマシンにコピーすれば自分のマシンからkubectlコマンドが叩けるようになる。やったぜ。

### workerノードを作ってクラスタにジョインさせる

workerノードのシェルで作業をする。コマンド打ちたくなければインスタンスのセットアップスクリプトみたいなやつが設定できるはずなのでそうしてください。

```
$ sudo apt-get update && sudo apt-get install -y apt-transport-https curl gnupg2
$ sudo apt-get update && sudo apt-get install -y containerd
$ sudo mkdir -p /etc/containerd
$ sudo containerd config default | sudo tee /etc/containerd/config.toml
$ sudo sed -i '/^GRUB_CMDLINE_LINUX=/s/"$/ systemd.unified_cgroup_hierarchy=1"/' /etc/default/grub
$ sudo update-grub
$ sudo reboot
$ sudo apt-get update && sudo apt-get install -y apt-transport-https curl gnupg2 software-properties-common
$ curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -
$ echo "deb https://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list
$ sudo apt-get update
$ sudo apt-get install -y kubeadm kubelet kubectl

# masterノードでkubeadm initを終えるとjoinするためのコマンドが表示される
$ sudo kubeadm join masterノードのIP:6443 --token トークン  --discovery-token-ca-cert-hash sha256:HASH
```

`kubectl get nodes`でmasterとworkerがそれぞれいることが確認できればOK。

## クラスタにwebサーバーをデプロイする

```manifest.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
          volumeMounts:
            - name: html
              mountPath: /usr/share/nginx/html
      volumes:
        - name: html
          configMap:
            name: nginx-configmap

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-configmap
data:
  index.html: |
    <!DOCTYPE html>
    <html>
      <head>
        <title>Hello, Workd!</title>
        <meta charset="UTF-8">
      </head>
      <body>
        <h1>おはよう世界</h1>
      </body>
    </html>
```
ただHTMLファイルを吐き出すだけのnginx。
`kubectl apply -f manifests.yaml`でクラスタにデプロイする。
`kubectl get pods`でpodがいることを確認し、`kubectl port-forward Pod名 8080:80`しながらlocalhsotにアクセスして「おはよう世界」が表示されればOK。

## Ingressで外部に公開

とりあえず外から見られるようにしたい。

ingress-nginxを使う。helmfileで管理する。

```helmfile.yaml
repositories:
  - name: ingress-nginx
    url: https://kubernetes.github.io/ingress-nginx

releases:
  - name: nginx-ingress
    namespace: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.6.0
    values:
      - controller:
          service:
            type: NodePort
            nodePorts:
              http: 30080
```

30080ポートに固定する。

```ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  ingressClassName: nginx
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-service
                port:
                  number: 80
```

```bash
$ helmfile apply
$ kubectl apply -f ingress.yaml
```
これで`ノードのIP:30080`でアクセスできるようになった。うれしい。


```main.tf
# この時点でアクセスできるようにするには30080ポートをどこに対しても開けておくfirewallの設定をする必要がある
# あとで変える
resource "google_compute_firewall" "allow_nodeport_traffic" {
  name    = "allow-nodeport-traffic"
  network = "default"

  allow {
    protocol = "http"
    ports    = ["30080"]
  }

  source_ranges = ["0.0.0.0/0"]

  target_tags = [local.worker_tag]
}
```

## TCPロードバランサーで外部公開

固定のIPからwebサーバーにアクセスできるようにしたい。

特定のIPの80ポートで受けたらそのままworkerノードの30080ポートにTCPで転送するようにする。

```main.tf
# 公開用のIPを予約
resource "google_compute_global_address" "lb" {
  name = "k8s-lb-address"
}

# ポート30080で待ち構えるインスタンスグループをつくる（今回は一台しかいないが）
resource "google_compute_instance_group" "worker_group" {
  name       = "k8s-worker-group"
  zone        = "us-central1-a"

  instances = [for worker in google_compute_instance.worker : worker.self_link]

  named_port {
    name = "http"
    port = 30080
  }
}

# 特定のポートで受けるバックエンドサービスを定義しておく
resource "google_compute_backend_service" "backend_service" {
  name = "lb"
  protocol = "TCP"
  port_name = "http"

  backend {
    group = google_compute_instance_group.worker_group.self_link
  }

  health_checks = [
    google_compute_health_check.health_check.self_link
  ]
}

# 通れば何でもいいヘルスチェック
resource "google_compute_health_check" "health_check" {
  name = "k8s-healthcheck"
  check_interval_sec = 5
  timeout_sec        = 5
  healthy_threshold  = 2
  unhealthy_threshold= 2
  tcp_health_check {
    port = "30080"
    proxy_header = "NONE"
  }
}

# TCPプロキシの設定
resource "google_compute_target_tcp_proxy" "target_tcp_proxy" {
  name = "lb-target-proxy"
  backend_service = google_compute_backend_service.backend_service.self_link
}

# TCPロードバランシングの設定（80で受ける）
resource "google_compute_forwarding_rule" "forwarding_rule" {
  name = "lb-forwarding-rule"
  target = google_compute_target_tcp_proxy.target_tcp_proxy.self_link
  port_range = "80"
  ip_address = google_compute_global_address.lb.address
  ip_protocol = "TCP"
}

# 書いておくと外部IPがすぐわかって便利
output "lb-ipaddress" {
  value = google_compute_global_address.lb.address
}
```

これで外部IPからwebサーバーにHTTPでアクセスできるようになった。
```main.tf
# 制限を厳しくしておく
resource "google_compute_firewall" "allow_nodeport_traffic" {
  name    = "allow-nodeport-traffic"
  network = "default"

  allow {
    protocol = "tcp"
    ports    = ["30080", "80"]
  }
  # ロードバランサーからアクセスできればよい
  source_ranges = ["35.191.0.0/16"]

  target_tags = [local.worker_tag]
}
```

## TLS終端の実現

HTTPSでアクセスできるようにしたい。

適当なDNSサービスで、お持ちのドメインをさっき予約したIPに紐づけておく。

この記事では`my-domain.com`とする。

### Ingress Controllerの設定

HTTPSで受けるためのポートを設定しておく必要がある。今回は30443ポートでingress-controllerがリッスンするようにする。

```helmfile.yaml
repositories:
  - name: ingress-nginx
    url: https://kubernetes.github.io/ingress-nginx

releases:
  - name: nginx-ingress
    namespace: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.6.0
    values:
      - controller:
          config:
            ssl-port: "30443"
          service:
            type: NodePort
            nodePorts:
              http: 30080
              https: 30443
```

### Ingressの設定

tlsの設定をする。

```ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: "clusterissuer"
spec:
  tls:
    - hosts:
        - mydomain.com
      secretName: tls
  ingressClassName: nginx
  rules:
    - host: mydomain.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-service
                port:
                  number: 80
```

SSL証明書はcert-managerで管理する。

### cert-managerのインストール

helmfileで管理する。

```helmfile.yaml
repositories:
  - name: jetstack
    url: https://charts.jetstack.io

releases:
  - name: cert-manager
    namespace: cert-manager
    chart: jetstack/cert-manager
    version: v1.7.1
    createNamespace: true
```

helmfileだけだとCRDsが適用されないので別途適用する。

```bash
$ kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.7.2/cert-manager.crds.yaml
```

証明書のイシュアーをデプロイする。
```cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  # Ingressのmetadata.annotations.cert-manager.io/cluster-issueで指定した名前を同じにする
  name: clusterissuer
spec:
  acme:
    # productionのエンドポイントは証明書発行のレートリミットが厳しめなので注意
    # stagingはhttps://acme-staging-v02.api.letsencrypt.org/directory
    server: https://acme-v02.api.letsencrypt.org/directory
    email: yourmailaddress@maildomain.com
    privateKeySecretRef:
      # Ingressのspec.tls.[*].secretNameで指定したものと同じにする
      name: tls
    solvers:
      - http01:
          ingress:
            class: nginx
```

### TCPロードバランサーに30443への転送設定を追加する

```main.tf
resource "google_compute_instance_group" "worker_group" {
  ===
  # 追加
  named_port {
    name = "https"
    port = 30443
  }
}

resource "google_compute_firewall" "allow_nodeport_traffic" {
  ===
  allow {
    protocol = "tcp"
    # 追加
    ports    = ["30080", "30443", "80", "443"]
  }
  ===
}

resource "google_compute_backend_service" "backend_service_https" {
  name = "lb-https"
  protocol = "TCP"
  port_name = "https"

  backend {
    group = google_compute_instance_group.worker_group.self_link
  }
  # まあ同じでよいだろう
  health_checks = [
    google_compute_health_check.health_check.self_link
  ]
}

resource "google_compute_target_tcp_proxy" "target_tcp_proxy_443" {
  name = "lb-target-proxy-443"
  backend_service = google_compute_backend_service.backend_service_https.self_link
}

resource "google_compute_forwarding_rule" "forwarding_rule_443" {
  name = "lb-forwarding-rule-443"
  target = google_compute_target_tcp_proxy.target_tcp_proxy_443.self_link
  port_range = "443"
  ip_address = google_compute_global_address.lb.address
  ip_protocol = "TCP"
}
```

以上の設定が適用されれば`https://yourdomain.com`で「おはよう世界」が見られる状態のはず。

## おわり

大変すぎるので素直にGKE使ったほうがいい。

スペシャルサンクス：ChatGPT4