0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

# GitOps CI/CD:Flask・Kubernetes・Webhookオーケストレーションで作るプッシュtoデプロイ

0
Posted at

「手動デプロイは複利付きの技術的負債だ。kubectl apply を手で叩くたびに、将来の信頼性を担保に借金をしている。」

FlaskのWebhookオーケストレーションサーバー、リソースクォータ付きの隔離テスト名前空間、最小権限スコープのRBAC、テストと本番のネットワークポリシー分離、自動ロールバック付きブルーグリーンデプロイメント——これはKubernetes上にプッシュtoデプロイのGitOpsパイプラインを構築した全記録だ。手動プロセスが限界に達したから作り、障害モードを知っておく価値があるから文書化した。

手動デプロイの何が問題か

# 旧プロセス
docker build -t my-app:v1.2.3 .
docker push my-registry/my-app:v1.2.3
kubectl set image deployment/my-app my-app=my-registry/my-app:v1.2.3
kubectl rollout status deployment/my-app
# ConfigMapを更新し忘れたことに気づく
kubectl apply -f configmap.yaml
kubectl rollout restart deployment/my-app
# Podのクラッシュループを眺める
kubectl get pods --watch

障害モードは複合する。ConfigMapを忘れる。環境を間違える。ローカルで編集してコミットしていないマニフェストを適用する。手動プロセスはトイルを生むだけでなく、一貫性を破壊する——そしてインシデントは一貫性のない場所から生まれる。

GitOpsはこれを根本から解決する。GitがSource of Truth。コミットされていなければクラスターには存在しない。すべてのデプロイが監査可能で、すべてのロールバックはrevertだ。

アーキテクチャ

Git Push
    ↓
GitLab Webhook(HTTPS + 署名検証)
    ↓
Flaskオーケストレーションサーバー
    ├─ 署名バリデーション
    ├─ ペイロードパース
    └─ パイプラインのトリガー
    ↓
Kubernetes — test名前空間
    ├─ リポジトリのクローン
    ├─ テストの実行
    └─ イメージのビルド
    ↓
Kubernetes — production名前空間
    ├─ ブルーグリーンロールアウト
    ├─ ヘルスチェック
    └─ 失敗時の自動ロールバック

Flaskオーケストレーションサーバー

FlaskはWebhookイベントを受け取り、検証し、KubernetesジョブをトリガーするだけのサーバーだがKubernetes内にコンテナとしてデプロイできる。

from flask import Flask, request, jsonify
import subprocess
import logging
import hmac
import hashlib
import os

app = Flask(__name__)
app.logger.setLevel(logging.INFO)

WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')

def verify_webhook_signature(payload: bytes, signature: str) -> bool:
    """WebhookペイロードがGitLabから来たものか検証する"""
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

def trigger_test_pipeline(repo_url: str, branch: str, commit_sha: str) -> bool:
    app.logger.info(f"Pipeline: {repo_url}@{branch} ({commit_sha[:8]})")

    result = subprocess.run([
        'kubectl', 'create', 'job', f'test-{commit_sha[:8]}',
        '--image=python:3.9-slim',
        '--namespace=test',
        '--env', f'REPO_URL={repo_url}',
        '--env', f'BRANCH={branch}',
        '--env', f'COMMIT_SHA={commit_sha}',
        '--', 'sh', '-c',
        'git clone $REPO_URL -b $BRANCH /app && cd /app && pip install -r requirements.txt && python -m pytest tests/ -v'
    ], capture_output=True, text=True, timeout=30)

    if result.returncode != 0:
        app.logger.error(f"Pipeline trigger失敗: {result.stderr}")
        return False

    return True

@app.route('/webhook', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-GitLab-Token', '')
    if not verify_webhook_signature(request.get_data(), signature):
        app.logger.warning("不正なWebhook署名 — 拒否")
        return "Invalid signature", 401

    payload = request.json
    if not payload or 'repository' not in payload:
        return "Invalid payload", 400

    branch = payload['ref'].split('/')[-1]
    commit_sha = payload['after']
    repo_url = payload['repository']['git_http_url']

    success = trigger_test_pipeline(repo_url, branch, commit_sha)
    return jsonify({"status": "started" if success else "failed"}), 202

ssl_context='adhoc' について:Flaskの開発サーバーはローカルのTLSテスト用にこれをサポートしているが、本番用ではない。本番ではNginxまたはKubernetes IngressコントローラーとちゃんとしたTLS証明書でFlaskを動かすこと。オーケストレーションサーバー自体は内部でHTTPで動作し、TLS終端はingress層で行う。

Webhookの署名検証は省略不可だ。 これなしだと、エンドポイントを発見したHTTPクライアントが誰でもデプロイをトリガーできる。最初これをサボったら、Webクローラーがエンドポイントを叩いて古いコードに対してパイプラインを走らせた。ペイロードに触る前に署名を検証しろ。

Kubernetesテスト環境

名前空間とリソースクォータ

kubectl create namespace test
apiVersion: v1
kind: ResourceQuota
metadata:
  name: test-quota
  namespace: test
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 4Gi
    limits.cpu: "4"
    limits.memory: 8Gi
    pods: "10"

Podだけでなく名前空間にリソース制限をかける。無限ループのテストに制限がなければ、クラスターのメモリをすべて食い尽くす。身をもって学んだ。

テストランナーJob

apiVersion: batch/v1
kind: Job
metadata:
  name: test-runner
  namespace: test
spec:
  backoffLimit: 2
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: test-runner
        image: python:3.9-slim
        env:
        - name: REPO_URL
          value: "https://gitlab.com/your-repo.git"
        - name: BRANCH
          value: "main"
        - name: COMMIT_SHA
          value: "abc123"
        command: ["sh", "-c"]
        args:
          - |
            set -e
            git clone $REPO_URL -b $BRANCH /test-code
            cd /test-code
            pip install -r requirements.txt
            python -m pytest tests/ -v --junitxml=test-results.xml
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
      volumes:
      - name: test-results
        emptyDir: {}

ttlSecondsAfterFinished: 3600——完了したJobは1時間後に自動クリーンアップされる。これがないと、失敗したJobが蓄積して最終的に名前空間のクォータを枯渇させ、新しいJobが起動できなくなる。

set -e——非ゼロの戻りコードで即座に終了する。これがないと、pip install が失敗してもテスト実行に進み、誤解を招く失敗が出る。

RBAC — 最小権限

オーケストレーションサーバーは特定の名前空間で特定の権限を必要とする。cluster-adminでも、ワイルドカードリソースでもない。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: orchestration-sa
  namespace: orchestration
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: test
  name: test-job-manager
rules:
- apiGroups: ["batch"]
  resources: ["jobs"]
  verbs: ["create", "delete", "list"]
- apiGroups: [""]
  resources: ["pods", "pods/log"]
  verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: production
  name: production-deployer
rules:
- apiGroups: ["apps"]
  resources: ["deployments"]
  verbs: ["get", "patch", "list"]
- apiGroups: [""]
  resources: ["services", "configmaps"]
  verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: orchestration-test-binding
  namespace: test
subjects:
- kind: ServiceAccount
  name: orchestration-sa
  namespace: orchestration
roleRef:
  kind: Role
  name: test-job-manager
  apiGroup: rbac.authorization.k8s.io

オーケストレーションのサービスアカウントは、testでJobを作成・削除でき、productionでDeploymentをpatchできる。Secretへのアクセス、名前空間の削除、他の名前空間への干渉は一切できない。オーケストレーションサーバーが侵害されても、爆発半径はその2つの名前空間の2つの動詞に限定される。

ネットワークポリシー — 名前空間の隔離

テストジョブは本番DBや内部サービスに到達できてはいけない。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-namespace-isolation
  namespace: test
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          name: orchestration
  egress:
  - to:
    - namespaceSelector:
        matchLabels:
          name: production
    ports:
    - protocol: TCP
      port: 443

test名前空間へのIngress:orchestration名前空間からのみ。testからのEgress:443番ポートでproductionへのみ。侵害されたテストジョブは本番DBや内部APIに到達できない。

ブルーグリーン本番デプロイ

def deploy_to_production(image_tag: str):
    # greenデプロイを適用
    subprocess.run([
        'kubectl', 'apply', '-f',
        f'deployment-green-{image_tag}.yaml'
    ], check=True)

    # greenが健全になるまで待つ
    subprocess.run([
        'kubectl', 'rollout', 'status',
        'deployment/green-deployment',
        '--timeout=300s'
    ], check=True)

    # トラフィックをgreenに切り替え
    subprocess.run([
        'kubectl', 'patch', 'service', 'my-app',
        '-p', '{"spec":{"selector":{"version":"green"}}}'
    ], check=True)

    # blueはロールバック用に残す
    app.logger.info(f"Deployed {image_tag} — blue retained for rollback")
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  minReadySeconds: 10
  revisionHistoryLimit: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
      - name: my-app
        image: my-registry/my-app:v1.2.3
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

maxUnavailable: 0——新しいPodが立ち上がる前に既存のPodは落とさない。ゼロダウンタイムロールアウト。revisionHistoryLimit: 3——高速ロールバック用に直近3つのReplicaSetを保持。minReadySeconds: 10——起動後10秒間readyであることを確認してから安定とみなす。起動後にクラッシュするPodを健全とカウントするのを防ぐ。

Kubernetesはヘルスチェックが失敗した場合に自動でロールバックする。blueデプロイはgreenの安定が確認されるまで残り、必要な場合は即座にトラフィックを戻せる。

トラブルシューティングリファレンス

Webhookタイムアウト——GitLabが失敗を報告する:

# Flask開発サーバーは並行する長時間リクエストを処理できない
# 本番WSGIサーバーを使うこと
from gevent.pywsgi import WSGIServer
http_server = WSGIServer(('0.0.0.0', 5000), app)
http_server.serve_forever()

JobがPendingのまま——リソースクォータ枯渇:

kubectl describe resourcequota test-quota -n test
# どのリソースが上限に達しているか確認
kubectl delete jobs --field-selector status.successful=1 -n test

最初から ttlSecondsAfterFinished を設定しておけばこの蓄積は防げる。

テストJobでImagePullBackOff:

# テストランナーのServiceAccountにpullシークレットをアタッチ
apiVersion: v1
kind: ServiceAccount
metadata:
  name: test-runner-sa
  namespace: test
imagePullSecrets:
- name: registry-credentials

KubernetesジョブでのGit認証:

volumes:
- name: git-credentials
  secret:
    secretName: git-credentials
    defaultMode: 0400

クレデンシャルは読み取り専用のSecretボリュームとしてマウントすること。環境変数で渡すな——kubectl describe pod の出力に出てしまう。

結果

メトリクス 手動 自動化後
デプロイ時間 15〜30分 2〜5分
デプロイ頻度 週1回 1日複数回
ロールバック時間 10〜15分 30秒
本番デプロイ失敗 定期的に発生 150回超で2回、両方自動ロールバック

プロジェクト期間中に150回超のパイプライン実行。本番での2回の失敗はどちらもヘルスチェックで検出され、ユーザーに影響が出る前に自動でロールバックされた。

次にやるなら変えること

最初から外部Secretsマネージャーを使う。 KubernetesシークレットとしてマウントするgitクレデンシャルやWebhookシークレットは動くが、個人プロジェクトを超える規模ならSecrets ManagerかVaultでローテーション管理するのが正解だ。

パイプライン可観測性を最初から組み込む。 メトリクスとアラートは後付けになった。ビルド時間・成功率・失敗理由は、パイプラインが実際の負荷を扱う前からインストルメントしておくべきだ。

testとproductionの間にステージング環境を置く。 今の構成はtest → productionだ。本番の設定をミラーするステージング名前空間があれば、本番固有の失敗を本番に到達する前に捕まえられる。

ソースコード

完全なコードとマニフェストはGitHubで公開しています。


より詳細なケーススタディはこちらで公開しています。

Elijah Udom (@elijahu_) — ラゴス拠点のインフラ・クラウドエンジニア。AWS、Kubernetes、eBPFセキュリティ、AI/MLインフラ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?