KubernetesクラスタをVMware Workstationでオンプレ想定で構築してみる (RHEL9) - (4) 自作アプリのディプロイ/結合テスト編 -

# はじめに
Kubernetesクラスタに自分で開発したアプリケーションをディプロイする際に行う基本的な作業の備忘録。対象のディプロイ環境はオンプレ想定で構成してみる。

ここでは(これまでの記事で実施してきた作業内容のレビューを兼ねて)ディプロイした各コンポーネントを利用したテストアプリケーションを作成し結合テストをしてみる。

具体的にはGoで開発したアプリケーションからPodmanでコンテナイメージを作成し、プライベートレジストリ (Harbor)へPushする。その後、Kubernetesクラスタ環境でプライベートレジストリからイメージをPullしディプロイする。

以下のような作業観点で実施する。

* 永続ボリューム (Ceph File System)の利用
* ConfigMap (設定情報)の利用
* Secret (認証情報や証明書関連)の利用
* Service (LoadBalancerタイプ)の利用
* プライベートレジストリ (Harbor)の利用
* RGW/Amazon S3 API (Ceph Object Store)連携
* Podmanでのコンテナビルド
* Pod間のAPI呼び出し連携
* リソース定義でのディプロイ
* Helmチャートでのインストール
* Helmチャートでの更新 (例: サーバ証明書だけの更新)

# 利用するソフトウェア
- VMware Workstation 17 Pro (Windows 11 / X86_64)
- RHEL 9.5 (VM) 
- Dnsmasq (2.79)
- HA-Proxy (1.8.27)
- Kubernetes (v1.32)
- CRI-O (v1.32)
- Calico (v3.29.3)
- MetalLB (v0.14.9)
- Helm (v3.17.3)
- Harbor (2.13.0Rook-Ceph (v1.17.1)
- Ceph (v19.2.2)
- amazon/aws-cli

#### (参考) テストアプリケーションの作成環境
- go (1.23.6)
- gin-gonic (v1.10.0)
- Podman (5.2.2)
- Visual Studio Code (1.100.0)
- Visual Studio Code拡張: Go (0.46.1)
- Visual Studio Code拡張: Remote Development (0.26.0)
- Visual Studio Code拡張: Remote Explorer (0.5.0)
- Visual Studio Code拡張: Dev Containers (0.413.0)
- Visual Studio Code拡張: YAML (1.18.0)
- コンテナ: ubi9/ubi-minimal (latest)
- コンテナ: ubi9/go-toolset (1.23)
- コンテナ: ubi9 (latest)
- コンテナ: busybox (latest)
- ChatGPT (GPT-4o/o4-mini-high)

※ goのバージョンはコンテナビルド時に利用する「ubi9/go-toolset」イメージのgoのバージョンに（マイナーバージョン含めて）合わせたほうがよさげ。

# Kubernetesクラスタ構成

以下で構築済みの環境を利用する。

https://qiita.com/rhpenguin/items/3347a41493a931953369

https://qiita.com/rhpenguin/items/5e996b25df07b59a8f43

https://qiita.com/rhpenguin/items/fdfaa6354efebac197b6

# テスト環境の構成

これまでセットアップしたKubernetesクラスタ環境に簡単なテストアプリとしてAPIサービスとAPIゲートウェイサービスをディプロイして結合テストを実施してみる。

![test_apps1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/362125/06f012d6-4976-437c-b20e-53a2180fbb5c.png)

## 開発するテストアプリケーション

| テストアプリ名 | API | 説明 |
| --- | --- | --- |
| k8s-test-app | | 前回作成したテストアプリケーション。単純なAPIをエキスポートする |
||GET /ping| ping要求に対してpong応答(json)を返すAPI |
| k8s-test-appgw | | 各コンポーネントアクセスへのAPIゲートウェイ |
||GET /ping| k8s-test-appアプリのAPI (/ping)へ中継する|
||GET /s3-buckets | オブジェクトストアのバケット一覧応答(json)を返すAPI。Ceph RGWへのAPI要求を中継する |
||GET /json-from-cephfs | CephFSに格納されているローカルファイル内容を応答(json)として返すAPI |

# テストアプリケーションの構成定義

## k8s-test-appの構成定義

| 項目 | タイプ | Volumeマウント先 |
| --- | --- | --- |
| TLS: サーバ証明書 | Secret | /app/certs/tls.crt |
| TLS: サーバ秘密鍵 | Secret | /app/certs/tls.key |
| API-KEY | Secret | /app/secret/testapp-secret.yaml |
| アクセスログ | PersistentVolumeClaim | /mnt/cephfs/k8s-test-app/log/history.log <br />本来は同時書き込みを防ぐためPod毎に用意すべきだがテスト目的なので割愛 |

## k8s-test-appgwの構成定義

| 項目 | タイプ | Volumeマウント先 |
| --- | --- | --- |
| k8s-test-app URL | ConfigMap | /app/config/app-config.yaml |
| RGWエンドポイント | ConfigMap | /app/config/app-config.yaml |
| RGWリージョン | ConfigMap | /app/config/app-config.yaml |
| 読み出し先のjsonファイルパス | ConfigMap | /app/config/app-config.yaml |
| TLS: PrivateCA証明書 | ConfigMap | /app/certs/ca.crt <br />rook-ceph:rgw-ca-certからコピーする |
| TLS: サーバ証明書 | Secret | /app/certs/tls.crt |  |
| TLS: サーバ秘密鍵 | Secret | /app/certs/tls.key |  |
| API-KEY | Secret | /app/secret/auth-secret.yaml |
| RGW アクセスID | Secret | /app/secret/s3-secret.yaml |
| RGW シークレットアクセスキー | Secret | /app/secret/s3-secret.yaml |
| 読み出し先のjsonファイル | PersistentVolumeClaim | /mnt/cephfs/k8s-test-appgw/data.json |
| アクセスログ | PersistentVolumeClaim | /mnt/cephfs/k8s-test-appgw/log/history.log<br />本来は同時書き込みを防ぐためPod毎に用意すべきだがテスト目的なので割愛 |

#### (参考) Podへのパラメータ情報の渡し方の備忘録

以下はdeployment定義内で構成できる。

(1) 環境変数として定義しておく (envフィールド)
(2) ConfigMap定義への参照を定義し環境変数として受け取る (configMapRef)
(3) ConfigMap定義をボリューム定義しマウントされた設定ファイルとして受け取る (volumes/volumeMountsフィールド)
(4) Secret定義への参照を定義し環境変数として受け取る (secretKeyRef)
(5) Secret定義をボリューム定義しマウントされた設定ファイルとして受け取る (volumes/volumeMountsフィールド)
(6) アプリケーション実行時の引数として渡す (command/argsフィールド)

# テストアプリケーションの作成とディプロイ

## k8s-test-appアプリのTLS/API-KEY認証対応 (エンハンス)

```text: ディレクトリ構成
/home/hoge/k8s-test-app
├── Containerfile
├── go.mod
├── go.sum
├── k8s_health_probe.md
├── k8s-test-app-api-key.yaml
├── k8s-test-app-deploy.yaml
├── k8s-test-app-svc.yaml
├── k8s-test-app-tls-secret.yaml
├── main.go
├── openssl.cnf
├── test-app
├── tls.crt
├── tls.csr
└── tls.key
```

https://qiita.com/rhpenguin/items/fdfaa6354efebac197b6

※ 小さいテストプログラムなので前回作成した元コードをChatGPTに修正内容を入力して対応。

```console: コード対応
[mng ~]$ cd k8s-test-app

[mng k8s-test-app]$ go mod tidy

[mng k8s-test-app]$ go build

[mng k8s-test-app]$ ls
go.mod  go.sum  main.go  test-app
```

<details><summary>ソースコード (main.go)</summary>

```go: main.go
package main

import (
	"crypto/tls"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/gin-gonic/gin"
	"gopkg.in/yaml.v2"
)

type TestAppSecret struct {
	APIKey string `yaml:"api_key"`
}

var (
	validAPIKey string
)

func main() {
	if err := loadSecret("/app/secret/testapp-secret.yaml"); err != nil {
		log.Println("ERROR: failed to load API key:", err)
		return
	}
	log.Println("INFO: API key successfully loaded")

	r := gin.Default()
	r.Use(authMiddleware)

	r.GET("/ping", handlePing)

	srv := &http.Server{
		Addr:    ":8443",
		Handler: r,
		TLSConfig: &tls.Config{
			MinVersion: tls.VersionTLS12,
		},
	}

	log.Println("INFO: Starting HTTPS server on :8443")
	if err := srv.ListenAndServeTLS("/app/certs/tls.crt", "/app/certs/tls.key"); err != nil {
		log.Println("ERROR: failed to start HTTPS server:", err)
		return
	}
}

func loadSecret(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		log.Println("ERROR: failed to read secret file:", err)
		return fmt.Errorf("read error: %w", err)
	}

	var secret TestAppSecret
	if err := yaml.Unmarshal(data, &secret); err != nil {
		log.Println("ERROR: failed to unmarshal secret YAML:", err)
		return fmt.Errorf("unmarshal error: %w", err)
	}

	if secret.APIKey == "" {
		log.Println("ERROR: API key in secret is empty")
		return fmt.Errorf("API key is empty")
	}

	validAPIKey = secret.APIKey
	return nil
}

func authMiddleware(c *gin.Context) {
	expected := fmt.Sprintf("TestApp %s", validAPIKey)
	header := c.GetHeader("Authorization")
	if header != expected {
		log.Println("WARN: unauthorized request")
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
		return
	}
	c.Next()
}

func handlePing(c *gin.Context) {
	log.Println("INFO: /ping request")
	appendAccessLog("/mnt/cephfs/k8s-test-app/log/history.log", c.FullPath())

	fmt.Println("=== Request Headers ===")
	for key, values := range c.Request.Header {
		for _, value := range values {
			log.Printf("%s: %s\n", key, value)
		}
	}

    c.JSON(http.StatusOK, gin.H{"message": "pong"})
}

func appendAccessLog(path string, route string) {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Println("ERROR: log open error:", err)
		return
	}
	defer f.Close()
	fmt.Fprintf(f, "%s %s\n", time.Now().Format(time.RFC3339), route)
}
```

</details>

### コンテナイメージを再作成

```console: ビルド
[mng k8s-test-app]$ podman build -t k8s-test-app:latest -f Containerfile .

[mng k8s-test-app]$ podman images
REPOSITORY                                       TAG         IMAGE ID      CREATED         SIZE
localhost/k8s-test-app                           latest      3a2c94adf05e  26 seconds ago  229 MB
<none>                                           <none>      0c95e1761eb6  48 seconds ago  1.53 GB
<none>                                           <none>      cadb179cb435  11 minutes ago  229 MB
<none>                                           <none>      9d4640aa3552  11 minutes ago  1.53 GB
harbor.test.k8s.local/k8s-test-app/k8s-test-app  latest      1aba92ed3d43  28 hours ago    229 MB
<none>                                           <none>      ff57c1ac1fe0  28 hours ago    1.53 GB
<none>                                           <none>      3932eb3d7723  28 hours ago    1.38 GB
<none>                                           <none>      384b325ddbb4  29 hours ago    1.13 GB
registry.access.redhat.com/ubi9/go-toolset       1.23        d8663eae6e1a  7 days ago      1.13 GB
registry.access.redhat.com/ubi9                  latest      18ac20acd5ec  2 weeks ago     217 MB
```

```dockerfile: Containerfile
# Stage 1: Build
FROM registry.access.redhat.com/ubi9/go-toolset:1.23 AS builder

USER root
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app

# Stage 2: Runtime
FROM registry.access.redhat.com/ubi9

WORKDIR /app
COPY --from=builder /app/app .

EXPOSE 8443
CMD ["./app"]
```

### k8s-test-app用のサーバ証明書の作成

k8s-test-app用のサーバ証明書を発行する。

```
# 証明書設定の作成
[mng k8s-test-app]$ vi openssl.cnf

# 秘密鍵の作成
[mng k8s-test-app]$ openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-256 -out tls.key

＃ CSRの作成
[mng k8s-test-app]$ openssl req -new -key tls.key -out tls.csr -config openssl.cnf

# 証明書作成
[mng k8s-test-app]$ openssl x509 -req -in tls.csr -CA private_ca.crt -CAkey private_ca.key -CAcreateserial -out tls.crt -days 365 -sha256 -extfile openssl.cnf -extensions server-cert

[mng k8s-test-app]$  openssl x509 -in tls.crt -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
...
        Issuer: CN=private_ca
...
        Subject: C=JP, O=k8s, OU=test, CN=k8s-test-app
...
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment, Key Agreement
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Subject Alternative Name:
                DNS:k8s-test-app.test.k8s.local, DNS:k8s-test-app.default.svc.cluster.local, DNS:k8s-test-app, IP Address:10.0.0.64, IP Address:10.100.133.21, IP Address:127.0.0.1
...
```

※ tls.crt(証明書)とtls.key(秘密鍵)というファイルが生成される。

SAN(alt_name)をService(MetalLB)の(前回に割り当てられた)情報へ合わせて指定しておく。

```config: openssl.cnf
[server-cert]
keyUsage = critical, digitalSignature, keyEncipherment, keyAgreement
extendedKeyUsage = serverAuth
subjectAltName = @alt_name

[req]
distinguished_name = dn
prompt = no

[dn]
C = JP
O = k8s
OU = test
CN = k8s-test-app

[alt_name]
DNS.1 = k8s-test-app.test.k8s.local # 外部アクセス用のホスト名
DNS.2 = k8s-test-app.default.svc.cluster.local # クラスタ内のホスト名
DNS.3 = k8s-test-app # クラスタ内のホスト名 (同一ネームスペース)
IP.1 = 10.0.0.64 # 外部アクセス用のIP
IP.2 = 10.100.133.21 # ClusterIP
IP.3 = 127.0.0.1 # ローカルホストからのアクセス用
```

### ローカルテスト

```text: ディレクトリツリー
/home/hoge/k8s-test-app/podman/
├── certs/                  # TLS証明書（読み取り専用でマウント）
│   ├── tls.crt             # サーバ証明書（PEM形式）
│   └── tls.key             # 秘密鍵（PEM形式）
│
├── secret/                 # 認証用APIキー（読み取り専用でマウント）
│   └── testapp-secret.yaml # APIキー定義ファイル
│
└── cephfs/
    └── k8s-test-app/
        └── log/     # アクセスログ保存先（書き込み可能でマウント）
```

```console: コンテナ起動
[mng k8s-test-app]$ podman run -d \
  -p 8443:8443 \
  -v /home/hoge/k8s-test-app/podman/certs:/app/certs:ro \
  -v /home/hoge/k8s-test-app/podman/secret:/app/secret:ro \
  -v /home/hoge/k8s-test-app/podman/cephfs/k8s-test-app/log:/mnt/cephfs/k8s-test-app/log \
  k8s-test-app
8c874bf93a201a4ebc9449586d0ad99fd5f84725397304d9282bcb218ee91aa1
```

```console: 起動確認
[mng k8s-test-app]$ podman ps
CONTAINER ID  IMAGE                          COMMAND     CREATED         STATUS         PORTS                             NAMES
8c874bf93a20  localhost/k8s-test-app:latest  ./app       26 seconds ago  Up 26 seconds  0.0.0.0:8443->8443/tcp, 8443/tcp  cranky_lewin

[mng k8s-test-app]$ ss -lnt
State         Recv-Q    Send-Q    Local Address:Port    Peer    Address:Port    Process
...
LISTEN        0          128      0.0.0.0:8443                   0.0.0.0:*
...
```

```console: APIアクセステスト
[mng k8s-test-app]$ echo 'api_key: "my-super-secret-key"' > ./podman/secret/testapp-secret.yaml

[mng k8s-test-app]$ curl -k -H 'Authorization: TestApp my-super-secret-key' https://localhost:8443/ping |jq
{
  "message": "pong"
}
```

```console: ログ確認
[mng k8s-test-app]$ podman logs 8c874bf93a20
2025/05/04 01:51:08 INFO: API key successfully loaded
...
[GIN-debug] GET    /ping                     --> main.handlePing (4 handlers)
2025/05/04 01:51:08 INFO: Starting HTTPS server on :8443
2025/05/04 01:52:05 INFO: /ping request
[GIN] 2025/05/04 - 01:52:05 | 200 |     727.947µs |       127.0.0.1 | GET      "/ping"

[mng k8s-test-app]$ cat podman/cephfs/k8s-test-app/log/history.log
2025-05-04T01:40:16Z /ping
```

```console: コンテナ停止＆削除
[mng k8s-test-app]$ podman ps
CONTAINER ID  IMAGE                          COMMAND     CREATED        STATUS        PORTS                             NAMES
8c874bf93a20  localhost/k8s-test-app:latest  ./app       9 minutes ago  Up 9 minutes  0.0.0.0:8443->8443/tcp, 8443/tcp  cranky_lewin

[mng k8s-test-app]$ podman stop 8c874bf93a20
8c874bf93a20

[mng k8s-test-app]$ podman rm 8c874bf93a20
8c874bf93a20

[mng k8s-test-app]$ podman ps -a
CONTAINER ID  IMAGE       COMMAND     CREATED     STATUS      PORTS       NAMES
```

### コンテナイメージをレジストリ(Harbor)へPush

```console: タグ付け
[mng k8s-test-app]$ podman tag k8s-test-app:latest harbor.test.k8s.local/k8s-test-app/k8s-test-app:latest
```

```console: ログイン
[mng k8s-test-app]$ podman login harbor.test.k8s.local
Username: admin
Password:
Login Succeeded!
```

```console: Push
[mng k8s-test-app]$ podman push k8s-test-app harbor.test.k8s.local/k8s-test-app/k8s-test-app:latest
```

### DeploymentとPodを一旦削除

```console: 削除
[mng k8s-test-app]$ kubectl get deployment
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
k8s-test-app   0/3     3            0           14h

[mng k8s-test-app]$ kubectl delete deployment k8s-test-app
deployment.apps "k8s-test-app" deleted

[mng k8s-test-app]$ kubectl get deployment
No resources found in default namespace.

[mng k8s-test-app]$ kubectl get pods
No resources found in default namespace.
```

### k8s-test-app用サーバ証明書のSecretを作成

```console: Secret定義を作成後に適用
# --dry-runで生成
[mng k8s-test-app]$ kubectl create secret tls k8s-test-app-tls --cert=tls.crt --key=tls.key --namespace=default --dry-run=client -o yaml > k8s-test-app-tls-secret.yaml

[mng k8s-test-app]$ kubectl apply -f k8s-test-app-tls-secret.yaml
```

```yaml: k8s-test-app-tls-secret.yaml
apiVersion: v1
data:
  tls.crt: xxx...
  tls.key:  yyy...
kind: Secret
metadata:
  creationTimestamp: null
  name: k8s-test-app-tls
  namespace: default
type: kubernetes.io/tls
```

※ tls.crtやtls.keyはそれぞれtls.crtファイルとtls.keyファイル内のPEM形式データをさらにBase64エンコードしたものになる点へ留意。「kubectl create secret --dry-run」で自動的にエンコードされる。

### k8s-test-app用API-KEYのSecretを作成

```console: 適用
[mng k8s-test-app]$ kubectl apply -f k8s-test-app-api-key.yaml
```

```yaml: k8s-test-app-api-key.yaml
apiVersion: v1
kind: Secret
metadata:
  name: k8s-test-app-api-key
type: Opaque
stringData:
  testapp-secret.yaml: |
    api_key: "xxx..."
```

### k8s-test-appアプリケーションのPodをクラスタへ再ディプロイ

```console: 適用
[mng k8s-test-app]$ kubectl apply -f k8s-test-app-deploy.yaml
```

```yaml: k8s-test-app-deploy.yaml (ChatGPTによる解説)
apiVersion: apps/v1               # DeploymentリソースのAPIバージョン（v1）
kind: Deployment                  # リソースの種類：Deployment
metadata:
  name: k8s-test-app              # Deployment名
spec:
  replicas: 3                     # 起動するPodのレプリカ数（3つ）
  selector:
    matchLabels:
      app: k8s-test-app           # 対象のPodを識別するラベル（matchLabelsとtemplateのラベルが一致する必要がある）
  template:
    metadata:
      labels:
        app: k8s-test-app         # Podに付与するラベル（selectorと一致する必要あり）
    spec:
      imagePullSecrets:
        - name: harbor-creds      # イメージプル時の認証情報として使うSecret名（Harborの認証）

      initContainers:
        - name: k8s-test-app-init         # Initコンテナ名
          image: busybox                  # 使用するコンテナイメージ（軽量なBusyBox）
          command:                        # 実行するコマンド
            - sh
            - -c
            - |
              echo "[INIT] Creating log directory...";  # ログ出力（開始通知）
              if mkdir -p /mnt/cephfs/k8s-test-app/log; then  # ログディレクトリ作成
                chmod -R 777 /mnt/cephfs/k8s-test-app;        # 書き込み権限の付与（全ユーザーにフルアクセス）
              else
                echo "[ERROR] Failed to create /mnt/cephfs/k8s-test-app/log" >&2;  # エラーログ
                exit 1;                        # エラー時に終了
              fi
          volumeMounts:
            - name: history-vol              # マウントするボリューム名（PVCと紐づけられる）
              mountPath: /mnt/cephfs         # コンテナ内でマウントされるパス（CephFS全体）

      containers:
        - name: k8s-test-app                 # メインアプリケーションコンテナ名
          image: harbor.test.k8s.local/k8s-test-app/k8s-test-app:latest  # 使用するイメージ（Harborから取得）
          imagePullPolicy: Always            # 常に新しいイメージをPullする設定
          ports:
            - containerPort: 8443            # コンテナ内の待受ポート（HTTPSなどで使われる）
          volumeMounts:
            - name: certs
              mountPath: /app/certs          # TLS証明書格納パス
              readOnly: true                 # 読み取り専用
            - name: api-key
              mountPath: /app/secret         # APIキー格納パス
              readOnly: true
            - name: history-vol
              mountPath: /mnt/cephfs         # CephFSをアプリコンテナにもマウント（ログ出力などで使用）

      volumes:
        - name: certs
          secret:
            secretName: k8s-test-app-tls     # TLS証明書用のSecret名
        - name: api-key
          secret:
            secretName: k8s-test-app-api-key # APIキー用のSecret名
        - name: history-vol
          persistentVolumeClaim:
            claimName: cephfs-pvc            # CephFSのPVC名（共有ストレージ）
```

### k8s-test-appアプリケーションのService(MetalLB)を更新

```console: 更新
[mng k8s-test-app]$ kubectl apply -f k8s-test-app-svc.yaml
```

```yaml: k8s-test-app-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: k8s-test-app
spec:
  type: LoadBalancer
  selector:
    app: k8s-test-app
  ports:
    - protocol: TCP # TLSポートへ変更
      port: 443
      targetPort: 8443
```

```console: 確認
[mng k8s-test-app]$ kubectl get svc -o wide
NAME           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)         AGE   SELECTOR
k8s-test-app   LoadBalancer   10.100.133.21   10.0.0.64     443:32278/TCP   24h   app=k8s-test-app
```

※ 割り当てられたVIPおよびCLUSTER-IPは変更なし。

### テスト実施

#### 外部クライアントからアクセス疎通確認 (管理端末)

※ 事前にlb.test.k8s.localのdnsmasq設定に「k8s-test-app.test.k8s.local: 10.0.0.64」の名前解決設定を追加しておく。

```console: アクセス疎通確認
[mng k8s-test-app]$ curl -v --cacert ../tls/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-app.test.k8s.local/ping | jq .
*   Trying 10.0.0.64:443...
* Connected to k8s-test-app.test.k8s.local (10.0.0.64) port 443 (#0)
...
*  CAfile: ../tls/private_ca.crt
...
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
...
> GET /ping HTTP/2
> Host: k8s-test-app.test.k8s.local
> user-agent: curl/7.76.1
> accept: */*
> authorization: TestApp xxx...
...
< HTTP/2 200
...
{
  "message": "pong"
}
```

```console: Podのログ確認
[mng k8s-test-app]$ kubectl get pods -o wide
NAME                            READY   STATUS    RESTARTS   AGE   IP               NODE          NOMINATED NODE   READINESS GATES
k8s-test-app-58dfd654cc-h4cqf   1/1     Running   0          44m   172.30.126.28    k8s-worker2   <none>           <none>
k8s-test-app-58dfd654cc-h5r46   1/1     Running   0          45m   172.23.229.145   k8s-worker0   <none>           <none>
k8s-test-app-58dfd654cc-t5l5w   1/1     Running   0          44m   172.20.194.97    k8s-worker1   <none>           <none>

[mng k8s-test-app]$ kubectl logs k8s-test-app-58dfd654cc-h5r46
```
```text: ログ出力
Defaulted container "k8s-test-app" out of: k8s-test-app, k8s-test-app-init (init)
2025/05/03 02:42:33 INFO: API key successfully loaded
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                     --> main.handlePing (4 handlers)
2025/05/03 02:42:33 INFO: Starting HTTPS server on :8443
2025/05/03 02:43:59 INFO: /ping request
[GIN] 2025/05/03 - 02:43:59 | 200 |  211.126456ms |      10.0.0.157 | GET      "/ping"
```

#### 他のPodからアクセス疎通確認

```console: テスト用Podの実行
[mng k8s-test-app]$ kubectl run testpod --image=registry.access.redhat.com/ubi9/ubi-minimal --restart=Never -it -- sh

sh-5.1# microdnf install vim-minimal

# PrivateCA証明書 (PEM形式) をコピペし保存
sh-5.1# vi /tmp/private_ca.crt

# Service名でアクセス疎通
sh-5.1# curl -v --cacert /tmp/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-app/ping
*   Trying 10.100.133.21:443...
* Connected to k8s-test-app (10.100.133.21) port 443 (#0)
...
{"message":"pong"}

sh-5.1# exit
```

```console: テスト用Podを削除
[mng k8s-test-app]$ kubectl get pods
NAME                            READY   STATUS      RESTARTS   AGE
...
testpod                         0/1     Completed   0          13m

[mng k8s-test-app]$ kubectl delete pod testpod
pod "testpod" deleted
```

## k8s-test-appgwアプリの作成 (新規)

```text: ディレクトリ構成
/home/hoge/k8s-test-appgw
├── Containerfile
├── go.mod
├── go.sum
├── k8s-test-appgw-helm
│    ├── charts
│    ├── Chart.yaml
│    ├── templates
│    │    ├── configmap.yaml
│    │    ├── deployment.yaml
│    │    ├── _helpers.tpl
│    │    ├── ingress-service.yaml
│    │    ├── secret.yaml
│    │    ├── service.yaml
│    │    ├── tests
│    │    └── tls-secret.yaml
│    └── values.yaml
├── main.go
├── ngin│ -ingess-controller-options.md
├── old
│    ├── deployment.yaml
│    └── service.yaml
├── openssl.cnf
├── test-appgw
├── tls.crt
├── tls.csr
├── tls.key
└── values-cert-update.yaml
```

※ こちらも小さいテストプログラムなのでChatGPTに機能内容を入力して作成。

```console: コード対応
[mng ~]$ mkdir k8s-test-appgw

[mng ~]$ cd k8s-test-appgw

[mng k8s-test-app]$ go mod init test.k8s.local/test-appgw

[mng k8s-test-appgw]$ go mod tidy

[mng k8s-test-appgw]$ go build

[mng k8s-test-appgw]$ ls
go.mod  go.sum  main.go  test-appgw
```

<details><summary>ソースコード (main.go)</summary>

```go: main.go
package main

import (
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gin-gonic/gin"
	"gopkg.in/yaml.v2"
)

type Config struct {
	TestAppURL     string `yaml:"testapp_url"`
	CephFSJSONPath string `yaml:"cephfs_json_path"`
	S3             struct {
		Endpoint string `yaml:"endpoint"`
		Region   string `yaml:"region"`
	} `yaml:"s3"`
}

type AuthSecret struct {
	APIKey string `yaml:"api_key"`
}

type S3Secret struct {
	AccessKey       string `yaml:"access_key"`
	SecretAccessKey string `yaml:"secret_access_key"`
}

type TestAppSecret struct {
	APIKey string `yaml:"api_key"`
}

var (
	cfg           Config
	authSecret    AuthSecret
	s3Secret      S3Secret
	testAppSecret TestAppSecret
	caCertPool    *x509.CertPool
	s3Session     *session.Session
)

func main() {
	if err := loadConfig("/app/config/app-config.yaml"); err != nil {
		log.Println("ERROR: config error:", err)
		return
	}
	log.Println("INFO: config loaded")

	if err := loadAuthSecret("/app/secret/auth-secret.yaml"); err != nil {
		log.Println("ERROR: auth secret error:", err)
		return
	}
	log.Println("INFO: auth secret loaded")

	if err := loadS3Secret("/app/secret/s3-secret.yaml"); err != nil {
		log.Println("ERROR: s3 secret error:", err)
		return
	}
	log.Println("INFO: s3 secret loaded")

	if err := loadTestAppSecret("/app/secret/testapp-secret.yaml"); err != nil {
		log.Println("ERROR: testapp secret error:", err)
		return
	}
	log.Println("INFO: testapp secret loaded")

	if err := loadCACert("/app/ca/ca.crt"); err != nil {
		log.Println("ERROR: CA cert error:", err)
		return
	}
	log.Println("INFO: CA cert loaded")

	if err := initS3Session(); err != nil {
		log.Println("ERROR: S3 session error:", err)
		return
	}
	log.Println("INFO: S3 session initialized")

	r := gin.Default()
	r.Use(authMiddleware)

	r.GET("/ping", handlePing)
	r.GET("/json-from-cephfs", handleJsonFromCephfs)
	r.GET("/s3-buckets", handleS3Buckets)

	log.Println("INFO: Starting HTTPS server on :8443")
	err := r.RunTLS(":8443", "/app/certs/tls.crt", "/app/certs/tls.key")
	if err != nil {
		log.Println("ERROR: RunTLS error:", err)
		return
	}
}

func loadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		log.Println("ERROR: read error:", err)
		return fmt.Errorf("read error: %w", err)
	}
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		log.Println("ERROR: config unmarshal error:", err)
		return fmt.Errorf("unmarshal error: %w", err)
	}
	return nil
}

func loadAuthSecret(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		log.Println("ERROR: read error:", err)
		return fmt.Errorf("read error: %w", err)
	}
	if err := yaml.Unmarshal(data, &authSecret); err != nil {
		log.Println("ERROR: auth secret unmarshal error:", err)
		return fmt.Errorf("unmarshal error: %w", err)
	}
	return nil
}

func loadS3Secret(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		log.Println("ERROR: read error:", err)
		return fmt.Errorf("read error: %w", err)
	}
	if err := yaml.Unmarshal(data, &s3Secret); err != nil {
		log.Println("ERROR: s3 secret unmarshal error:", err)
		return fmt.Errorf("unmarshal error: %w", err)
	}
	return nil
}

func loadTestAppSecret(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		log.Println("ERROR: read error:", err)
		return fmt.Errorf("read error: %w", err)
	}
	if err := yaml.Unmarshal(data, &testAppSecret); err != nil {
		log.Println("ERROR: testapp secret unmarshal error:", err)
		return fmt.Errorf("unmarshal error: %w", err)
	}
	return nil
}

func loadCACert(caPath string) error {
	caCertPool = x509.NewCertPool()
	caCert, err := os.ReadFile(caPath)
	if err != nil {
		log.Println("ERROR: read error:", err)
		return fmt.Errorf("read error: %w", err)
	}
	if ok := caCertPool.AppendCertsFromPEM(caCert); !ok {
		log.Println("ERROR: CA cert append error")
		return fmt.Errorf("failed to append CA cert")
	}
	return nil
}

func initS3Session() error {
	sess, err := session.NewSession(&aws.Config{
		Region:           aws.String(cfg.S3.Region),
		Endpoint:         aws.String(cfg.S3.Endpoint),
		S3ForcePathStyle: aws.Bool(true),
		Credentials: credentials.NewStaticCredentials(
			s3Secret.AccessKey,
			s3Secret.SecretAccessKey,
			"",
		),
		HTTPClient: &http.Client{
			Transport: &http.Transport{
				TLSClientConfig: &tls.Config{RootCAs: caCertPool},
			},
		},
	})
	if err != nil {
		log.Println("ERROR: session error:", err)
		return fmt.Errorf("session error: %w", err)
	}
	s3Session = sess
	return nil
}

func appendAccessLog(path string, route string) {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Println("ERROR: log open error:", err)
		return
	}
	defer f.Close()
	fmt.Fprintf(f, "%s %s\n", time.Now().Format(time.RFC3339), route)
}

func authMiddleware(c *gin.Context) {
	header := c.GetHeader("Authorization")
	expected := fmt.Sprintf("TestApp %s", authSecret.APIKey)
	if header != expected {
		log.Println("WARN: unauthorized request")
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
		return
	}
	c.Next()
}

func handlePing(c *gin.Context) {
	log.Println("INFO: /ping request")
	appendAccessLog("/mnt/cephfs/k8s-test-appgw/log/history.log", c.FullPath())

	fmt.Println("=== Request Headers ===")
	for key, values := range c.Request.Header {
		for _, value := range values {
			log.Printf("%s: %s\n", key, value)
		}
	}

	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{RootCAs: caCertPool},
		},
	}

	req, err := http.NewRequest("GET", cfg.TestAppURL, nil)
	if err != nil {
		log.Println("ERROR: /ping create request error:", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
		return
	}

	req.Header.Set("Authorization", fmt.Sprintf("TestApp %s", testAppSecret.APIKey))

	resp, err := client.Do(req)
	if err != nil {
		log.Println("ERROR: /ping forwarding error:", err)
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body)
}

func handleJsonFromCephfs(c *gin.Context) {
	log.Println("INFO: /json-from-cephfs request")
	appendAccessLog("/mnt/cephfs/k8s-test-appgw/log/history.log", c.FullPath())

	fmt.Println("=== Request Headers ===")
	for key, values := range c.Request.Header {
		for _, value := range values {
			log.Printf("%s: %s\n", key, value)
		}
	}

	data, err := os.ReadFile(cfg.CephFSJSONPath)
	if err != nil {
		log.Println("ERROR: /json-from-cephfs read error:", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
		return
	}
	var parsed interface{}
	if err := json.Unmarshal(data, &parsed); err != nil {
		log.Println("ERROR: /json-from-cephfs unmarshal error:", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
		return
	}
	c.JSON(http.StatusOK, parsed)
}

func handleS3Buckets(c *gin.Context) {
	log.Println("INFO: /s3-buckets request")
	appendAccessLog("/mnt/cephfs/k8s-test-appgw/log/history.log", c.FullPath())

	fmt.Println("=== Request Headers ===")
	for key, values := range c.Request.Header {
		for _, value := range values {
			log.Printf("%s: %s\n", key, value)
		}
	}

	svc := s3.New(s3Session)
	out, err := svc.ListBuckets(&s3.ListBucketsInput{})
	if err != nil {
		log.Println("ERROR: /s3-buckets list error:", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, out.Buckets)
}
```

</details>

### コンテナイメージを作成

```console: ビルド
[mng k8s-test-appgw]$ podman build -t k8s-test-appgw:latest -f Containerfile .

[mng k8s-test-appgw]$ podman images
REPOSITORY                                       TAG         IMAGE ID      CREATED         SIZE
localhost/k8s-test-appgw                         latest      434e548425a4  14 minutes ago  235 MB
<none>                                           <none>      441699e1d862  14 minutes ago  1.95 GB
<none>                                           <none>      9cf798a71325  34 minutes ago  1.13 GB
localhost/k8s-test-app                           latest      3a2c94adf05e  14 hours ago    229 MB
harbor.test.k8s.local/k8s-test-app/k8s-test-app  latest      3a2c94adf05e  14 hours ago    229 MB
<none>                                           <none>      0c95e1761eb6  14 hours ago    1.53 GB
<none>                                           <none>      cadb179cb435  14 hours ago    229 MB
<none>                                           <none>      9d4640aa3552  14 hours ago    1.53 GB
<none>                                           <none>      1aba92ed3d43  42 hours ago    229 MB
<none>                                           <none>      ff57c1ac1fe0  42 hours ago    1.53 GB
<none>                                           <none>      3932eb3d7723  42 hours ago    1.38 GB
<none>                                           <none>      384b325ddbb4  42 hours ago    1.13 GB
registry.access.redhat.com/ubi9/go-toolset       1.23        d8663eae6e1a  7 days ago      1.13 GB
registry.access.redhat.com/ubi9                  latest      18ac20acd5ec  2 weeks ago     217 MB
```

```dockerfile: Containerfile
# Stage 1: Build
FROM registry.access.redhat.com/ubi9/go-toolset:1.23 AS builder

USER root
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app

# Stage 2: Runtime
FROM registry.access.redhat.com/ubi9

WORKDIR /app
COPY --from=builder /app/app .

EXPOSE 8443
CMD ["./app"]
```

### k8s-test-appgw用のサーバ証明書の作成

```console:
[mng k8s-test-appgw]$ openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-256 -out tls.key

[mng k8s-test-appgw]$ openssl req -new -key tls.key -out tls.csr -config openssl.cnf

[mng k8s-test-appgw]$  openssl x509 -req -in tls.csr -CA ../tls/private_ca.crt -CAkey ../tls/private_ca.key -CAcreateserial -out tls.crt -days 365 -sha256 -extfile openssl.cnf -extensions server-cert
Certificate request self-signature ok
subject=C=JP, O=k8s, OU=test, CN=k8s-test-appgw

[mng k8s-test-appgw]$  openssl x509 -in tls.crt -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
...
        Issuer: CN=private_ca
...
        Subject: C=JP, O=k8s, OU=test, CN=k8s-test-appgw
...
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment, Key Agreement
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Subject Alternative Name:
                DNS:k8s-test-appgw.test.k8s.local, DNS:k8s-test-appgw.default.svc.cluster.local, DNS:k8s-test-appgw, IP Address:127.0.0.1
...
```

※ tls.crt(証明書)とtls.key(秘密鍵)というファイルが生成される。

SAN(alt_name)をService(MetalLB)あくせすを想定して合わせてホスト名で指定しておく。ただし、まだディプロイしていないのでVIPとClusterIPについては不明。後ほど証明書を更新する。

```config: openssl.cnf
[server-cert]
keyUsage = critical, digitalSignature, keyEncipherment, keyAgreement
extendedKeyUsage = serverAuth
subjectAltName = @alt_name

[req]
distinguished_name = dn
prompt = no

[dn]
C = JP
O = k8s
OU = test
CN = k8s-test-appgw

[alt_name]
DNS.1 = k8s-test-appgw.test.k8s.local
DNS.2 = k8s-test-appgw.default.svc.cluster.local
DNS.3 = k8s-test-appgw
IP.1 = 127.0.0.1
```

### コンテナイメージをレジストリ(Harbor)へPush

```console: タグ付け
[mng k8s-test-appgw]$ podman tag k8s-test-appgw:latest harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest

[mng k8s-test-appgw]$ podman images
REPOSITORY                                         TAG         IMAGE ID      CREATED         SIZE
localhost/k8s-test-appgw                           latest      434e548425a4  15 minutes ago  235 MB
harbor.test.k8s.local/k8s-test-app/k8s-test-appgw  latest      434e548425a4  15 minutes ago  235 MB
...
```

```console: ログイン
[mng k8s-test-appgw]$ podman login harbor.test.k8s.local
Username: admin
Password:
Login Succeeded!
```

```console: Push
[mng k8s-test-appgw]$ podman push k8s-test-appgw harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest
```

### Helmチャートの作成

k8s-test-appアプリケーションは個別にリソースを適用したが、別のやり方としてこのアプリケーションではHelmで一括インストールしてみる。

```console: Helmチャートのテンプレート生成
[mng k8s-test-appgw]$ helm create k8s-test-appgw-helm

[mng k8s-test-appgw]$ tree k8s-test-appgw-helm
k8s-test-appgw-helm/
 +-- charts
 +-- Chart.yaml
 +-- values.yaml
 +-- templates
      +-- deployment.yaml
      +-- _helpers.tpl
      +-- hpa.yaml
      +-- ingress.yaml
      +-- NOTES.txt
      +-- serviceaccount.yaml
      +-- service.yaml
      +-- tests
           +-- test-connection.yaml
```

テンプレートをベースに以下のように作成する。

```console: Helmチャートを作成
[mng k8s-test-appgw]$ k8s-test-appgw-helm/
 +-- charts
 +-- Chart.yaml
 +-- values.yaml
 +-- templates
      +-- configmap.yaml
      +-- deployment.yaml
      +-- secret.yaml
      +-- service.yaml
      +-- tls-secret.yaml
      +-- _helpers.tpl
      +-- tests
```

```yaml: Chart.yaml
apiVersion: v2
name: k8s-test-appgw
version: 0.1.0
description: A Helm chart for k8s-test-appgw
appVersion: "1.0.0"
```

```console: サーバ証明書と秘密鍵をBase64エンコード
[mng k8s-test-appgw]$ echo "crt: \"$(base64 -w 0 tls.crt)\""
crt: "LS0tLS1CRUdJ...=="

[mng k8s-test-appgw]$ echo echo "key: \"$(base64 -w 0 tls.key)\"""
key: "LS0...=="
```

※ 上記エンコードされた値を以下のvalues.yaml内で指定する。
* tls.crtフィールド
* tls.keyフィールド

```yaml: values.yaml (ChatGPTの解説コメント)
# 起動するPodのレプリカ数
replicaCount: 3

image:
  # 使用するアプリケーションのコンテナイメージ（Harborから取得）
  repository: harbor.test.k8s.local/k8s-test-app/k8s-test-appgw
  # イメージの取得ポリシー（Alwaysにより毎回Pull）
  pullPolicy: Always
  # 使用するイメージのタグ（latestなど）
  tag: latest

service:
  # Serviceのタイプ（LoadBalancerによって外部に公開）
  type: LoadBalancer
  # Serviceがリッスンするポート番号
  port: 443

authSecret:
  # 外部アクセス用APIキー（Authorization: TestApp XXX）
  api_key: "xxx..."

testappSecret:
  # 内部連携先（k8s-test-app）へのアクセス用APIキー
  api_key: "xxx..."

s3Secret:
  # Ceph RGW S3アクセスに使用するアクセスキー
  access_key: "xxxxxx..."
  # S3のシークレットキー（アクセスキーとペア）
  secret_access_key: "yyyyyy..."

config:
  # k8s-test-app への疎通確認URL（/pingエンドポイント）
  testapp_url: "https://k8s-test-app/ping"
  # CephFS上のJSONファイルパス（APIが応答する内容）
  cephfs_json_path: "/mnt/cephfs/k8s-test-appgw/data.json"
  s3:
    # S3互換のCeph RGWエンドポイントURL
    endpoint: "https://rgw.test.k8s.local"
    # S3 APIで使うリージョン（Ceph側の設定に合わせる）
    region: "us-east-1"

tls:
  # サーバ証明書(k8s-test-appgw)のBase64エンコード文字列（Helm valuesに埋め込む形式）
  # コマンド例: $ echo "crt: \"$(base64 -w 0 tls.crt)\""
  crt: "xxxx... (PEM形式をBase64エンコード)"
  # サーバ秘密鍵(k8s-test-appgw)のBase64エンコード文字列
  # コマンド例: $ echo "key: \"$(base64 -w 0 tls.key)\""
  key: "yyyy... (PEM形式をBase64エンコード)"

# PrivateCA証明書（PEM形式を直接埋め込む）
caBundle: |
  -----BEGIN CERTIFICATE-----
  MIIBjz... (PEM形式)
  -----END CERTIFICATE-----

persistence:
  # 使用する既存のPersistentVolumeClaim名（CephFS）
  existingClaim: cephfs-pvc

resources:
  limits:
    # 最大CPU使用量（例: 0.5コア）
    cpu: 500m
    # 最大メモリ使用量（例: 256MiB）
    memory: 256Mi
  requests:
    # 最小確保CPU（例: 0.1コア）
    cpu: 100m
    # 最小確保メモリ（例: 128MiB）
    memory: 128Mi
```

```yaml: configmap.yaml
# アプリケーション設定
apiVersion: v1
kind: ConfigMap
metadata:
  name: k8s-test-appgw-config
  labels:
    app: k8s-test-appgw
data:
  app-config.yaml: |
    testapp_url: {{ .Values.config.testapp_url | quote }} # 連携先k8s-test-appアプリのURL(APIパス)
    cephfs_json_path: {{ .Values.config.cephfs_json_path | quote }} # API応答するJsonデータファイル
    s3:
      endpoint: {{ .Values.config.s3.endpoint | quote }} # RGW/S3エンドポイント
      region: {{ .Values.config.s3.region | quote }} # RG/S3リージョン ("us-east-1")
---
# PrivateCA証明書を設定
apiVersion: v1
kind: ConfigMap
metadata:
  name: rgw-ca-cert
  labels:
    app: k8s-test-appgw
data:
  ca.crt: |
    {{ .Values.caBundle | nindent 4 }}
```

```yaml: deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: k8s-test-appgw
  labels:
    app: k8s-test-appgw
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: k8s-test-appgw
  template:
    metadata:
      labels:
        app: k8s-test-appgw
    spec:
      imagePullSecrets:
        - name: harbor-creds

      initContainers:
        - name: k8s-test-appgw-init
          image: busybox
          command:
            - sh
            - -c
            - |
              DIR=/mnt/cephfs/k8s-test-appgw
              FILE="$DIR/data.json"
              if [ ! -f "$FILE" ]; then
                if mkdir -p "$DIR"; then
                  echo '{"message": "Hello, world"}' > "$FILE"
                  chmod 644 "$FILE"
                  echo "[INIT] data.json created at $FILE"
                else
                  echo "[ERROR] Failed to create directory $DIR" >&2
                  exit 1
                fi
              else
                echo "[INIT] $FILE already exists. Skipping creation."
              fi
              echo "[INIT] Creating log directory...";
              LOGDIR="$DIR/log"
              if mkdir -p "$LOGDIR"; then
                chmod -R 777 "$DIR";
              else
                echo "[ERROR] Failed to create " "$LOGDIR" >&2;
                exit 1;
              fi
          volumeMounts:
            - name: history-vol
              mountPath: /mnt/cephfs

      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: 8443
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          volumeMounts:
            - name: config-vol
              mountPath: /app/config
              readOnly: true
            - name: secret-vol
              mountPath: /app/secret
              readOnly: true
            - name: tls-vol
              mountPath: /app/certs
              readOnly: true
            - name: history-vol
              mountPath: /mnt/cephfs
            - name: ca
              mountPath: /app/ca/ca.crt
              subPath: ca.crt

      volumes:
        - name: config-vol
          configMap:
            name: k8s-test-appgw-config
        - name: secret-vol
          secret:
            secretName: k8s-test-appgw-secret
        - name: tls-vol
          secret:
            secretName: k8s-test-appgw-tls
        - name: history-vol
          persistentVolumeClaim:
            claimName: {{ .Values.persistence.existingClaim }}
        - name: ca
          configMap:
            name: rgw-ca-cert
            items:
              - key: ca.crt
                path: ca.crt
```

```yaml: secret.yaml
# 認証情報を設定
apiVersion: v1
kind: Secret
metadata:
  name: k8s-test-appgw-secret
  labels:
    app: k8s-test-appgw
type: Opaque
stringData:
  # 本アプリケーションのAPI-KEY
  auth-secret.yaml: |
    api_key: {{ .Values.authSecret.api_key | quote }}
  # 連携先k8s-test-appアプリのAPI-KEY
  testapp-secret.yaml: |
    api_key: {{ .Values.testappSecret.api_key | quote }}
  # 連携先RGW/S3 APIの認証情報 (aws-cliのプロファイルど同じ値)
  s3-secret.yaml: |
    access_key: {{ .Values.s3Secret.access_key | quote }} # アクセスID
    secret_access_key: {{ .Values.s3Secret.secret_access_key | quote }} # シークレットアクセスキー
```

```yaml: service.yaml
apiVersion: v1
kind: Service
metadata:
  name: k8s-test-appgw
  labels:
    app: k8s-test-appgw
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: 8443
  selector:
    app: k8s-test-appgw
```

```yaml: tls-secret.yaml
# 本アプリケーションのサーバ証明書と秘密鍵データ設定
apiVersion: v1
kind: Secret
metadata:
  name: k8s-test-appgw-tls
  labels:
    app: k8s-test-appgw
type: kubernetes.io/tls
data:
  tls.crt: {{ .Values.tls.crt | quote }}
  tls.key: {{ .Values.tls.key | quote }}
```

```yaml: _helpers.tpl (未利用)
{{- define "k8s-test-appgw.fullname" -}}
{{ .Release.Name }}
{{- end -}}
```

### Tipsメモ

(1) SecretとConfigMap における証明書などの認証情報の記述形式の違い

| リソースタイプ | 値の形式   | エンコードの要否 |
|----------------------------|------------------|-----------------------------|
|Secret(`type: kubernetes.io/tls`)|`data:` フィールド|PEM形式やjson形式からbase64エンコードが必要|
|Secret(type: Opaque`)|`data:` フィールド|PEM形式やjson形式からbase64エンコードが必要|
|ConfigMap|`data:` フィールド|PEM形式やjson形式のままでOK|

(2) 他のネームスペースのConfigMap/Secretは参照できないのでコピーするなどが必要

(3) アプリケーション(Deployment定義)間でマウントするボリュームのディレクトリパスやファイルパスがオーバーラップするなど不整合とならないこと。共有ファイルシステムの場合にルートパス名を一致させること。

(4) Podの起動エラー時には以下で確認

```console: 
$ kubectl describe pod ポッド名
$ kubectl logs pod ポッド名
```

(5) Helmでアンインストール

```console: 
$ helm uninstall k8s-test-appgw --namespace default
```

### Helmでインストール

```console: インストール
[mng k8s-test-appgw]$ helm install k8s-test-appgw ./k8s-test-appgw-helm --namespace default --create-namespace
NAME: k8s-test-appgw
LAST DEPLOYED: Wed May 04 20:59:46 2025
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
```

```console: 確認
[mng k8s-test-appgw]$ kubectl get all -l app=k8s-test-appgw -o wide
NAME                                  READY   STATUS    RESTARTS   AGE    IP               NODE          NOMINATED NODE   READINESS GATES
pod/k8s-test-appgw-75d7898466-6bdzn   1/1     Running   0          115s   172.23.229.131   k8s-worker0   <none>           <none>
pod/k8s-test-appgw-75d7898466-jwlgx   1/1     Running   0          115s   172.20.194.102   k8s-worker1   <none>           <none>
pod/k8s-test-appgw-75d7898466-nwc5g   1/1     Running   0          115s   172.30.126.7     k8s-worker2   <none>           <none>

NAME                     TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)         AGE    SELECTOR
service/k8s-test-appgw   LoadBalancer   10.98.206.4   10.0.0.65     443:30695/TCP   116s   app=k8s-test-appgw

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE    CONTAINERS   IMAGES                                                     SELECTOR
deployment.apps/k8s-test-appgw   3/3     3            3           116s   app          harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest   app=k8s-test-appgw

NAME                                        DESIRED   CURRENT   READY   AGE    CONTAINERS   IMAGES                                                     SELECTOR
replicaset.apps/k8s-test-appgw-75d7898466   3         3         3       116s   app          harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest   app=k8s-test-appgw,pod-template-hash=75d7898466

[mng k8s-test-appgw]$ kubectl get secret -l app=k8s-test-appgw -o wide
NAME                    TYPE                DATA   AGE
k8s-test-appgw-secret   Opaque              3      3m6s
k8s-test-appgw-tls      kubernetes.io/tls   2      3m6s

[mng k8s-test-appgw]$ kubectl get cm -l app=k8s-test-appgw -o wide
NAME                    DATA   AGE
k8s-test-appgw-config   1      3m13s
rgw-ca-cert             1      3m13s
```

※Service (MetalLB)によりVIPは「10.0.0.65」が割り当てられている。またClusterIPは「10.98.206.4」になっている。

### テスト実施

#### 外部クライアントからアクセス疎通確認 (管理端末)

※ 事前にlb.test.k8s.localのdnsmasq設定に「k8s-test-appgw.test.k8s.local: 10.0.0.65」の名前解決設定を追加しておく。

```console: APIアクセス確認 (/ping)
[mng ~]$ curl --cacert tls/private_ca.crt -H 'Authorization: TestApp xxx...'
https://k8s-test-appgw.test.k8s.local/ping | jq .
{
  "message": "pong"
}
```

```console: APIアクセス確認 (/json-from-cephf)
[mng ~]$  curl --cacert ../tls/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-appgw.test.k8s.local/json-from-cephfs | jq .
{
  "message": "Hello, world"
}
```

```console: APIアクセス確認 (/s3-buckets)
[mng ~]$ curl --cacert ../tls/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-appgw.test.k8s.local/s3-buckets | jq .
[
  {
    "CreationDate": "2025-05-03T06:13:39.058Z",
    "Name": "bucket-for-testuser1"
  }
]
```

#### 他のPodからアクセス疎通確認

```console: テスト用Podの実行
[mng ~]$ kubectl run testpod --image=registry.access.redhat.com/ubi9/ubi-minimal --restart=Never -it -- sh

sh-5.1# microdnf install vim-minimal

# PrivateCA証明書 (PEM形式) をコピペし保存
sh-5.1# vi /tmp/private_ca.crt

# Service名でアクセス疎通
sh-5.1# curl --cacert /tmp/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-appgw/ping
{"message":"pong"}

# Service名(内部FQDN)でアクセス疎通
sh-5.1# curl --cacert /tmp/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-appgw.default.svc.cluster.local/json-from-cephfs
{"message":"Hello, world"}

# ホスト名(外部FQND)でアクセス疎通
sh-5.1# curl --cacert /tmp/private_ca.crt -H 'Authorization: TestApp xxx...' https://k8s-test-appgw.test.k8s.local/s3-buckets
[{"CreationDate":"2025-05-09T06:13:39.058Z","Name":"bucket-for-testuser1"}]

sh-5.1# exit
```

```console: テスト用Podを削除
[mng ~]$ kubectl get pods
NAME                            READY   STATUS      RESTARTS   AGE
...
testpod                         0/1     Completed   0          13m

[mng ~]$ kubectl delete pod testpod
pod "testpod" deleted
```

# Helmでディプロイを更新 (サーバ証明書だけを更新)

有効期限切れなどのタイミングでサーバ証明書だけを更新するシナリオを想定した操作をやってみる。

今回はService(MetalLB)により新たに割り当てられたVIPやClusterIPをSANに追加するためサーバ証明書を更新する作業内容で実施する。

### k8s-test-appgw用のサーバ証明書を再作成

```console:
[mng k8s-test-appgw]$ openssl req -new -key tls.key -out tls.csr -config openssl.cnf

[mng k8s-test-appgw]$ openssl x509 -req -in tls.csr -CA ../tls/private_ca.crt -CAkey ../tls/private_ca.key -CAcreateserial -out tls.crt -days 365 -sha256 -extfile openssl.cnf -extensions server-cert
Certificate request self-signature ok
subject=C=JP, O=k8s, OU=test, CN=k8s-test-appgw

[mng k8s-test-appgw]$ openssl x509 -in tls.crt -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
...
        Issuer: CN=private_ca
...
        Subject: C=JP, O=k8s, OU=test, CN=k8s-test-appgw
...
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment, Key Agreement
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Subject Alternative Name: ★
                DNS:k8s-test-appgw.test.k8s.local, DNS:k8s-test-appgw.default.svc.cluster.local, DNS:k8s-test-appgw, IP Address:127.0.0.1, IP Address:10.0.0.65, IP Address:10.98.206.4 ★
...
```

※ tls.crt(証明書)というファイルが生成される。秘密鍵ファイル(tls.key)は前回作成したものを使い続ける。

SAN(alt_name)をService(MetalLB)で割り当てられたVIPとClusterIPへ合わせて指定しておく。

```config: openssl.cnf
[server-cert]
keyUsage = critical, digitalSignature, keyEncipherment, keyAgreement
extendedKeyUsage = serverAuth
subjectAltName = @alt_name

[req]
distinguished_name = dn
prompt = no

[dn]
C = JP
O = k8s
OU = test
CN = k8s-test-appgw

[alt_name]
DNS.1 = k8s-test-appgw.test.k8s.local
DNS.2 = k8s-test-appgw.default.svc.cluster.local
DNS.3 = k8s-test-appgw
IP.1 = 127.0.0.1
IP.2 = 10.0.0.65 # ★VIP追加
IP.3 = 10.98.206.4 # ★ClusterIP追加
```

### 更新前のステータスを確認

```console: 更新前のステータス
[mng k8s-test-appgw]$ kubectl get all -l app=k8s-test-appgw -o wide
NAME                                  READY   STATUS    RESTARTS   AGE    IP               NODE          NOMINATED NODE   READINESS GATES
pod/k8s-test-appgw-75d7898466-6bdzn   1/1     Running   0          101m   172.23.229.131   k8s-worker0   <none>           <none>
pod/k8s-test-appgw-75d7898466-jwlgx   1/1     Running   0          101m   172.20.194.102   k8s-worker1   <none>           <none>
pod/k8s-test-appgw-75d7898466-nwc5g   1/1     Running   0          101m   172.30.126.7     k8s-worker2   <none>           <none>

NAME                     TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)         AGE    SELECTOR
service/k8s-test-appgw   LoadBalancer   10.98.206.4   10.0.0.65     443:30695/TCP   101m   app=k8s-test-appgw

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE    CONTAINERS   IMAGES                                                     SELECTOR
deployment.apps/k8s-test-appgw   3/3     3            3           101m   app          harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest   app=k8s-test-appgw

NAME                                        DESIRED   CURRENT   READY   AGE    CONTAINERS   IMAGES                                                     SELECTOR
replicaset.apps/k8s-test-appgw-75d7898466   3         3         3       101m   app          harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest   app=k8s-test-appgw,pod-template-hash=75d7898466

[mng k8s-test-appgw]$ kubectl get secret -l app=k8s-test-appgw -o wide
NAME                    TYPE                DATA   AGE
k8s-test-appgw-secret   Opaque              3      102m
k8s-test-appgw-tls      kubernetes.io/tls   2      102m

[mng k8s-test-appgw]$ kubectl get cm -l app=k8s-test-appgw -o wide
NAME                    DATA   AGE
k8s-test-appgw-config   1      102m
rgw-ca-cert             1      102m
```

```console: 更新前のサーバ証明書Secret
[mng k8s-test-appgw]$ kubectl get secret k8s-test-appgw-tls -o yaml
apiVersion: v1
data:
  tls.crt: LS0tL...==
  tls.key: LS...==
kind: Secret
metadata:
  annotations:
    meta.helm.sh/release-name: k8s-test-appgw
    meta.helm.sh/release-namespace: default
  creationTimestamp: "2025-05-04T11:59:47Z"
  labels:
    app: k8s-test-appgw
    app.kubernetes.io/managed-by: Helm
  name: k8s-test-appgw-tls
  namespace: default
  resourceVersion: "847995"
  uid: 795eed5e-96e6-4a00-9328-00e03bbe530c
type: kubernetes.io/tls
```

### 更新する情報だけのvalues定義ファイルを作成

```console: サーバ証明書をBase64エンコード
[mng k8s-test-appgw]$ echo "crt: \"$(base64 -w 0 tls.crt)\""
crt: "LS0tL..."
```

```yaml: values-cert-update.yaml
tls:
  # 新しいTLS証明書（PEM形式をBase64エンコード）
  crt: "LS0tL..."
  key: "LS..." # 秘密鍵は変更なし。前回と同じものを指定する
```

※ サーバ証明書だけ更新するのでtlsフィールドだけ定義する。秘密鍵フィールド(tls.key)にも(変更はしていないが)指定しないとSecretの変更と認識されない模様なので両方指定しておく。

### Helmで更新を実行

```console: Helmで更新
[mng k8s-test-appgw]$ helm upgrade k8s-test-appgw ./k8s-test-appgw-helm --namespace default --reuse-values -f values-cert-update.yaml
Release "k8s-test-appgw" has been upgraded. Happy Helming!
NAME: k8s-test-appgw
LAST DEPLOYED: Wed May 04 22:46:23 2025
NAMESPACE: default
STATUS: deployed
REVISION: 2 ★ アップしている
TEST SUITE: None
```

### 接続確認テスト (1) - 失敗

```console: 管理端末からアクセス確認 (失敗)
[mng k8s-test-appgw]$ openssl s_client -connect 10.0.0.65:443 -showcerts </dev/null |openssl x509 -noout -text
Connecting to 10.0.0.65
...
Certificate:
    Data:
        Version: 3 (0x2)
...
        Issuer: CN=private_ca
...
        Subject: C=JP, O=k8s, OU=test, CN=k8s-test-appgw
...
        X509v3 extensions:
...
            X509v3 Subject Alternative Name: ★更新されていない
                DNS:k8s-test-appgw.test.k8s.local, DNS:k8s-test-appgw.default.svc.cluster.local, DNS:k8s-test-appgw, IP Address:127.0.0.1 ★
...
```

※ SubjectAltNameが更新されていないので新しい証明書が適用されていない模様。

```console: Helm upgradeしただけではPodは再起動されていない模様
[mng k8s-test-appgw]$ kubectl get all -l app=k8s-test-appgw -o wide
NAME                                  READY   STATUS    RESTARTS   AGE    IP               NODE          NOMINATED NODE   READINESS GATES
pod/k8s-test-appgw-75d7898466-6bdzn   1/1     Running   0          110m   172.23.229.131   k8s-worker0   <none>           <none>
pod/k8s-test-appgw-75d7898466-jwlgx   1/1     Running   0          110m   172.20.194.102   k8s-worker1   <none>           <none>
pod/k8s-test-appgw-75d7898466-nwc5g   1/1     Running   0          110m   172.30.126.7     k8s-worker2   <none>           <none>

NAME                     TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)         AGE    SELECTOR
service/k8s-test-appgw   LoadBalancer   10.98.206.4   10.0.0.65     443:30695/TCP   110m   app=k8s-test-appgw

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE    CONTAINERS   IMAGES                                                     SELECTOR
deployment.apps/k8s-test-appgw   3/3     3            3           110m   app          harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest   app=k8s-test-appgw

NAME                                        DESIRED   CURRENT   READY   AGE    CONTAINERS   IMAGES                                                     SELECTOR
replicaset.apps/k8s-test-appgw-75d7898466   3         3         3       110m   app          harbor.test.k8s.local/k8s-test-app/k8s-test-appgw:latest   app=k8s-test-appgw,pod-template-hash=75d7898466
```

```console: Podを再起動を手動で実行
[mng k8s-test-appgw]$ kubectl rollout restart deployment k8s-test-appgw
```

### 接続確認テスト (2) - 成功

```console: Podの再起動(再作成)を確認
[mng k8s-test-appgw]$ kubectl get pods -o wide
NAME                              READY   STATUS    RESTARTS   AGE    IP               NODE          NOMINATED NODE   READINESS GATES
...
k8s-test-appgw-c4d4bb56c-qxxs4   1/1     Running   0          29s     172.20.194.84    k8s-worker1   <none>           <none>
k8s-test-appgw-c4d4bb56c-wf6t4   1/1     Running   0          37s     172.30.126.45    k8s-worker2   <none>           <none>
k8s-test-appgw-c4d4bb56c-wh7j9   1/1     Running   0          19s     172.23.229.181   k8s-worker0   <none>           <none>
```

```console: 管理端末からアクセスを再確認 (成功！)
[mng k8s-test-appgw]$ openssl s_client -connect 10.0.0.65:443 -showcerts </dev/null |openssl x509 -noout -text
Connecting to 10.0.0.65
Can't use SSL_get_servername
depth=0 C=JP, O=k8s, OU=test, CN=k8s-test-appgw
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 C=JP, O=k8s, OU=test, CN=k8s-test-appgw
verify error:num=21:unable to verify the first certificate
verify return:1
depth=0 C=JP, O=k8s, OU=test, CN=k8s-test-appgw
verify return:1
DONE
Certificate:
    Data:
        Version: 3 (0x2)
...
        Issuer: CN=private_ca
...
        Subject: C=JP, O=k8s, OU=test, CN=k8s-test-appgw
...
        X509v3 extensions:
...
            X509v3 Subject Alternative Name: ★ VIP/ClusterIPが追加されている
                DNS:k8s-test-appgw.test.k8s.local, DNS:k8s-test-appgw.default.svc.cluster.local, DNS:k8s-test-appgw, IP Address:127.0.0.1, IP Address:10.0.0.65, IP Address:10.98.206.4 ★
...
```

### APIアクセス確認

#### 外部クライアントからアクセス疎通確認 (管理端末)

※ URLのホスト名にVIPを指定して確認する。

```console: APIアクセス確認 (/ping)
[mng ~]$ curl --cacert tls/private_ca.crt -H 'Authorization: TestApp xxx...' https://10.0.0.65/ping | jq .
{
  "message": "pong"
}
```

```console: APIアクセス確認 (/json-from-cephf)
[mng ~]$ curl --cacert ../tls/private_ca.crt -H 'Authorization: TestApp xxx...' https://10.0.0.65/json-from-cephfs | jq .
{
  "message": "Hello, world"
}
```

```console: APIアクセス確認 (/s3-buckets)
[mng ~]$ curl --cacert ../tls/private_ca.crt -H 'Authorization: TestApp xxx...' https://10.0.0.65/s3-buckets | jq .
[
  {
    "CreationDate": "2025-05-03T06:13:39.058Z",
    "Name": "bucket-for-testuser1"
  }
]
```