「手動デプロイは複利付きの技術的負債だ。
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インフラ。