/** * WPML compatibility functions * * @global array $duplicated_posts Array to store the posts being duplicated. * * @package Yoast\WP\Duplicate_Post * @since 3.2 */ add_action( 'admin_init', 'duplicate_post_wpml_init' ); /** * Add handlers for WPML compatibility. */ function duplicate_post_wpml_init() { if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 ); } } global $duplicated_posts; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: Renaming a global variable is a BC break. $duplicated_posts = []; /** * Copy post translations. * * @global SitePress $sitepress Instance of the Main WPML class. * @global array $duplicated_posts Array of duplicated posts. * * @param int $post_id ID of the copy. * @param WP_Post $post Original post object. * @param string $status Status of the new post. */ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) { global $sitepress; global $duplicated_posts; remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 ); remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 ); $current_language = $sitepress->get_current_language(); $trid = $sitepress->get_element_trid( $post->ID ); if ( ! empty( $trid ) ) { $translations = $sitepress->get_element_translations( $trid ); $new_trid = $sitepress->get_element_trid( $post_id ); foreach ( $translations as $code => $details ) { if ( $code !== $current_language ) { if ( $details->element_id ) { $translation = get_post( $details->element_id ); if ( ! $translation ) { continue; } $new_post_id = duplicate_post_create_duplicate( $translation, $status ); if ( ! is_wp_error( $new_post_id ) ) { $sitepress->set_element_language_details( $new_post_id, 'post_' . $translation->post_type, $new_trid, $code, $current_language ); } } } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: see above. $duplicated_posts[ $post->ID ] = $post_id; } } /** * Duplicate string packages. * * @global array() $duplicated_posts Array of duplicated posts. */ function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break. global $duplicated_posts; foreach ( $duplicated_posts as $original_post_id => $duplicate_post_id ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $original_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $original_post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $new_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $duplicate_post_id ); if ( is_array( $original_string_packages ) ) { foreach ( $original_string_packages as $original_string_package ) { $translated_original_strings = $original_string_package->get_translated_strings( [] ); foreach ( $new_string_packages as $new_string_package ) { $cache = new WPML_WP_Cache( 'WPML_Package' ); $cache->flush_group_cache(); $new_strings = $new_string_package->get_package_strings(); foreach ( $new_strings as $new_string ) { if ( isset( $translated_original_strings[ $new_string->name ] ) ) { foreach ( $translated_original_strings[ $new_string->name ] as $language => $translated_string ) { do_action( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. 'wpml_add_string_translation', $new_string->id, $language, $translated_string['value'], $translated_string['status'] ); } } } } } } } } 今回の再発防止パッケージ – Raqqa

今回の再発防止パッケージ

1) 依存の固定(根本対応

portal-api

  • requirements.txtpyproject.toml厳密ピン止め+constraints で固定: # requirements.in(開発用) fastapi==0.110.0 uvicorn==0.29.0 SQLAlchemy==1.4.54 # ← 2.xに上がらないよう固定 psycopg2-binary==2.9.9 # …他 # constraints.txt(ロック用途) SQLAlchemy==1.4.54 numpy<2 # Chroma の依存が NumPy 2.0 の breaking に引っかからないよう pip install -r requirements.txt -c constraints.txt
  • CIで pip-compile(pip-tools)や Poetry lockを使い lockfile をリポジトリにコミット。

chroma

  • 公式の ghcr.io/chroma-core/chroma はバージョンアップで挙動が変わります。
    こちらも タグ固定(例: 0.4.24)+ ヘルスプローブは /api/v1/heartbeat を使用。

2) コード側の恒久化(sitecustomize から卒業)

  • いま sitecustomize.py で回避している内容を 本体コードに取り込み、ホットフィックスを外す。
  • ポイント:
    • SQLAlchemy 2.x 非互換パターン禁止
      • if stmt: / A and B / A or B絶対使わない
      • 代わりに sa.and_(...), sa.or_(...), if stmt is not None: を使う
    • portal_chroma_doc_repo.list_queued()Core で実装(今の差し替え版を移植)
    • さらに安全にするなら DBAPI フォールバックを用意(環境差がひどい時のみ使う)
      例)環境変数 PORTAL_USE_DBAPI_LIST_QUEUED=true で切替

参考実装(Core 版・恒久化用)

# app/repos/portal_chroma_doc_repo.py
import sqlalchemy as sa

def list_queued(self, *, collections=None, limit=1000):
    t = sa.table(
        "portal_chroma_doc",
        sa.column("id"), sa.column("doc_id"),
        sa.column("entity"), sa.column("natural_key"), sa.column("lang"),
        sa.column("collection"), sa.column("doc_text"), sa.column("meta"),
        sa.column("state"),
        # 存在すれば model を読む(無くても動くよう列存在チェックを別で)
    )
    stmt = (
        sa.select(
            t.c.id, t.c.doc_id, t.c.entity, t.c.natural_key, t.c.lang,
            t.c.collection, t.c.doc_text, t.c.meta.label("metadata")
        )
        .where(t.c.state == sa.literal("queued"))
        .order_by(t.c.id.asc())
        .limit(int(limit))
    )
    if collections:
        stmt = stmt.where(t.c.collection.in_(list(collections)))
    rows = self.s.execute(stmt).mappings().all()
    # …変換は現行のまま

※ 本番では 1.4固定なのでこれで十分。将来2.x対応するなら SQLAlchemy==2 のCIマトリクスでテスト追加。

3) DBスキーマ差異の吸収

  • model 列が無い環境があることが分かったので、マイグレーションで揃えるか、情報スキーマで存在確認→動的SELECTを恒久化(今回のsitecustomizeの実装を本体へ)。
  • Alembic などで ADD COLUMN IF NOT EXISTS model TEXT NULL; を流せるよう、アプリ起動前に Jobで migrate しても良い。

4) 起動時プリフライト(失敗を早く止める

  • portal-api 起動時に以下をチェックしてログへ:
    • SQLAlchemy バージョンと import 元パス
    • DB の portal_chroma_doc の列一覧(model 有無等)
    • CHROMA_URL/api/v1/heartbeat が 200/{“database”:”ok”} か
  • 失敗なら /startupz を Fail にし、Kubernetes の startupProbe でローリングを止める。

5) Kubernetes 側の堅牢化

  • portal-api の initContainer で簡易プリフライト:
    • python -c "import sqlalchemy,sys; assert sqlalchemy.__version__.startswith('1.4')"
    • wget -qO- $CHROMA_URL/api/v1/heartbeat が成功するまで待機(タイムアウト/リトライ上限つき)
  • readinessProbe/healthz だけでなく、内部から CHROMA_URL へ簡易 GETする軽量エンドポイントを追加し、それを readiness にしても良い。
  • chroma は StatefulSet + PVCPERSIST_DIRECTORY=/chroma、イメージタグ固定、プローブ /api/v1/heartbeat

6) CI/CD でのミニ・スモーク(壊れたタグを弾く

  • minikube(profile 固定)で以下を自動実行する Job/Workflow を用意:
    1. kubectl -n portal-dev wait --for=condition=Available deploy/portal-api
    2. kubectl -n portal-dev run curltest --rm --image=curlimages/curl:8.10.1 -- curl -fsS http://chroma:8000/api/v1/heartbeat
    3. port-forwardして POST /chroma/upsert (dry_run=true)
  • どれか落ちたら デプロイ失敗(オフショアの方も同じスクリプトを流せばOK)。

7) 静的チェック(地雷の芽を摘む

CI で grep ルールを追加:

# Clause の真偽値評価につながる典型NG
grep -RInE '\.(where|filter)\([^)]*( and | or )[^(]*\)' app/ && exit 1 || true
grep -RInE 'if[[:space:]]+(stmt|sql|where(_clause)?|filters?)\s*:' app/ && exit 1 || true

8) 監視と運用

  • portal-api ログに SQLAlchemy/NumPy バージョン、CHROMA_URL を起動時に1行出力(今回の診断が早くなる)。
  • /admin/diag のような短い診断APIを追加(SA/NumPy/CHROMA heartbeat/DB列一覧をまとめて返す)。

9) チーム向けワンコマンド復旧スクリプト

オフショア含めて、「困ったらこれ」の統一スクリプトをリポジトリに置く(例:hack/recover_chroma.sh):

  • chroma の sts/probe 確認と再起動
  • portal-api の /healthz, /chroma/heartbeat, /chroma/upsert(dry_run) まで自動テスト
  • 失敗時は原因候補を色付きで表示(DNS / Endpoint / Pod 状態 / 依存バージョン等)

10) いまのホットフィックスの整理(クリーンアップ手順)

  • 本体に反映できたら: # env と volume 片付け(使わなくなったら) kubectl -n portal-dev set env deploy/portal-api PYTHONPATH- kubectl -n portal-dev patch deploy portal-api --type=json -p='[ {"op":"remove","path":"/spec/template/spec/volumes/1"}, {"op":"remove","path":"/spec/template/spec/containers/0/volumeMounts/1"} ]' kubectl -n portal-dev rollout restart deploy/portal-api (順番はマニフェストに合わせて調整。残すならそのままでも可)

すぐできる“最小セット”

  1. 依存固定(SA 1.4.54 / NumPy<2 / Chromaタグ固定)
  2. list_queued() を Core 実装に置換(本体に移植)
  3. CI にミニ・スモーク(heartbeat + upsert dry_run)
  4. grep ルールで NG パターンを検出

これだけで、**次回のイメージ更新や依存ズレでも「起動前に検知・弾く/自動で安全側に倒す」**状態にできます。
必要なら、上記の CI スクリプト雛形や Helm/K8s パッチもすぐ出します。

あなた:

上記の CI スクリプト雛形や Helm/K8s パッチ

ChatGPT:

思考時間: 6s

了解!“そのまま置けば動く”系の雛形をまとめました。最小構成ですが、今回ハマったポイント(ChromaのHB、/chroma/upsert dry-run、依存ピン止めの検証)をCIで自動チェックします。


1) GitHub Actions: ミニ E2E スモーク(minikube 上)

.github/workflows/smoke.yml

name: smoke-on-minikube

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  smoke:
    runs-on: ubuntu-22.04
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Set up kubectl & helm
        uses: azure/setup-kubectl@v4
        with:
          version: "v1.30.5"
      - uses: azure/setup-helm@v4
        with:
          version: "v3.14.4"

      - name: Start minikube
        uses: medyagh/setup-minikube@v0.0.18
        with:
          minikube-version: "v1.33.1"
          kubernetes-version: "v1.30.0"
          driver: docker
          addons: "ingress"

      - name: Create namespace
        run: kubectl create ns portal-dev || true

      - name: Deploy Chroma (pinned)
        run: |
          kubectl -n portal-dev apply -f k8s/chroma.yaml
          kubectl -n portal-dev rollout status sts/chroma --timeout=180s
          kubectl -n portal-dev run curltest --rm --restart=Never \
            --image=curlimages/curl:8.10.1 -- \
            sh -lc 'curl -fsS http://chroma:8000/api/v1/heartbeat'

      - name: Deploy portal-api
        run: |
          # 例: 事前に container registry のイメージを使う or build/push 済みを pull
          # ここでは単純にデモ用マニフェストを適用
          kubectl -n portal-dev apply -f k8s/portal-api.yaml
          kubectl -n portal-dev rollout status deploy/portal-api --timeout=240s

      - name: Port-forward & smoke
        run: |
          set -euo pipefail
          kubectl -n portal-dev port-forward svc/portal-api 18080:80 >/tmp/pf.log 2>&1 &
          PF_PID=$!
          sleep 5
          trap "kill $PF_PID || true" EXIT

          # /healthz
          curl -fsS http://127.0.0.1:18080/healthz

          # chroma heartbeat via cluster svc
          kubectl -n portal-dev run c2 --rm --restart=Never \
            --image=curlimages/curl:8.10.1 -- \
            curl -fsS http://chroma:8000/api/v1/heartbeat

          # /chroma/upsert dry-run
          curl -fsS -X POST http://127.0.0.1:18080/chroma/upsert \
            -H 'Content-Type: application/json' \
            -d '{"limit":10,"dry_run":true}'

      - name: Dump diagnostics on failure
        if: failure()
        run: |
          kubectl -n portal-dev get all -o wide
          kubectl -n portal-dev describe sts/chroma || true
          kubectl -n portal-dev describe deploy/portal-api || true
          kubectl -n portal-dev logs -l app=portal-api --tail=200 || true

2) ミニスモーク実行スクリプト(ローカル/CI共通)

hack/smoke.sh

#!/usr/bin/env bash
set -euo pipefail

NS=${NS:-portal-dev}

echo "== 1) Chroma heartbeat"
kubectl -n "$NS" run curltest --rm --restart=Never \
  --image=curlimages/curl:8.10.1 -- \
  curl -fsS http://chroma:8000/api/v1/heartbeat

echo "== 2) portal-api /healthz"
kubectl -n "$NS" wait --for=condition=Available deploy/portal-api --timeout=180s
PF_PORT=${PF_PORT:-18080}
kubectl -n "$NS" port-forward svc/portal-api "$PF_PORT":80 >/tmp/pf.log 2>&1 &
PF_PID=$!
sleep 5
trap "kill $PF_PID || true" EXIT
curl -fsS "http://127.0.0.1:${PF_PORT}/healthz"

echo "== 3) /chroma/upsert dry-run"
curl -fsS -X POST "http://127.0.0.1:${PF_PORT}/chroma/upsert" \
  -H 'Content-Type: application/json' \
  -d '{"limit":10,"dry_run":true}'

echo "OK"

3) Helm/K8s: 最小値のマニフェスト・パッチ

3.1 Chroma(StatefulSet + Service)

k8s/chroma.yaml

apiVersion: v1
kind: Service
metadata:
  name: chroma
  namespace: portal-dev
labels:
  app: chroma
spec:
  type: ClusterIP
  selector:
    app: chroma
  ports:
    - name: http
      port: 8000
      targetPort: 8000
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: chroma
  namespace: portal-dev
spec:
  serviceName: chroma
  replicas: 1
  selector:
    matchLabels: { app: chroma }
  template:
    metadata:
      labels: { app: chroma }
    spec:
      containers:
        - name: chroma
          image: ghcr.io/chroma-core/chroma:0.4.24 # ← タグ固定
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8000
          env:
            - name: PERSIST_DIRECTORY
              value: /chroma
            - name: CHROMA_SERVER_HOST
              value: "0.0.0.0"
            - name: CHROMA_SERVER_HTTP_PORT
              value: "8000"
          readinessProbe:
            httpGet:
              path: /api/v1/heartbeat
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 12
          livenessProbe:
            httpGet:
              path: /api/v1/heartbeat
              port: http
            initialDelaySeconds: 30
            periodSeconds: 20
            timeoutSeconds: 5
            failureThreshold: 6
          volumeMounts:
            - name: data
              mountPath: /chroma
      volumes: []  # pvc は volumeClaimTemplates から
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 20Gi
        storageClassName: standard

3.2 portal-api(Deployment + Service)

  • 起動前プリフライト:ChromaのHB待ち
  • readiness/healthz
  • 依存のピン止めはイメージ側で実施(CIで検証)。必要なら initContainer で簡易検証だけ追加可能。

k8s/portal-api.yaml

apiVersion: v1
kind: Service
metadata:
  name: portal-api
  namespace: portal-dev
labels:
  app: portal-api
spec:
  type: ClusterIP
  selector:
    app: portal-api
  ports:
    - name: http
      port: 80
      targetPort: 8000
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: portal-api
  namespace: portal-dev
spec:
  replicas: 2
  selector:
    matchLabels: { app: portal-api }
  template:
    metadata:
      labels: { app: portal-api }
    spec:
      serviceAccountName: portal-api
      initContainers:
        - name: wait-chroma
          image: curlimages/curl:8.10.1
          command: ["/bin/sh","-lc"]
          args:
            - |
              for i in $(seq 1 60); do
                if curl -fsS http://chroma:8000/api/v1/heartbeat >/dev/null; then
                  echo "chroma ok"; exit 0;
                fi
                echo "waiting chroma.."; sleep 2;
              done
              echo "chroma timeout"; exit 1
      containers:
        - name: api
          image: portal-api:dev   # ← あなたのレジストリの固定タグに
          imagePullPolicy: IfNotPresent
          env:
            - name: CHROMA_URL
              value: http://chroma:8000
          ports:
            - name: http
              containerPort: 8000
          readinessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 15
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3

4) Helm の values での差し込み例

あなたのチャートに「追加の initContainers / env / probes / volumes」を入れるための values 例です。

values.dev.yaml(抜粋)

portalApi:
  replicaCount: 2
  image:
    repository: your-registry/portal-api
    tag: "v2025-10-12"  # ← 固定
    pullPolicy: IfNotPresent
  env:
    - name: CHROMA_URL
      value: http://chroma:8000
  extraInitContainers:
    - name: wait-chroma
      image: curlimages/curl:8.10.1
      command: ["/bin/sh","-lc"]
      args:
        - |
          for i in $(seq 1 60); do
            if curl -fsS http://chroma:8000/api/v1/heartbeat >/dev/null; then
              echo "chroma ok"; exit 0;
            fi
            echo "waiting chroma.."; sleep 2;
          done
          echo "chroma timeout"; exit 1
  readinessProbe:
    httpGet:
      path: /healthz
      port: http
    initialDelaySeconds: 5
    periodSeconds: 5
    timeoutSeconds: 2
    failureThreshold: 3
  livenessProbe:
    httpGet:
      path: /healthz
      port: http
    initialDelaySeconds: 15
    periodSeconds: 10
    timeoutSeconds: 2
    failureThreshold: 3

chroma:
  image:
    repository: ghcr.io/chroma-core/chroma
    tag: "0.4.24" # ← 固定
    pullPolicy: IfNotPresent
  persistence:
    enabled: true
    size: 20Gi
  service:
    port: 8000
  probes:
    readiness:
      path: /api/v1/heartbeat
    liveness:
      path: /api/v1/heartbeat

チャート側テンプレートで extraInitContainers, env, readinessProbe, livenessProbewith/toYaml でマージする構造にしておくと将来楽です。


5) Kustomize パッチ(既存マニフェストに後付け)

kustomization.yaml

resources:
  - chroma.yaml
  - portal-api.yaml

patches:
  - target:
      kind: Deployment
      name: portal-api
      namespace: portal-dev
    path: patches/portal-api-probes.yaml
  - target:
      kind: StatefulSet
      name: chroma
      namespace: portal-dev
    path: patches/chroma-probes.yaml

patches/portal-api-probes.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: portal-api
spec:
  template:
    spec:
      initContainers:
        - name: wait-chroma
          image: curlimages/curl:8.10.1
          command: ["/bin/sh","-lc"]
          args:
            - |
              for i in $(seq 1 60); do
                if curl -fsS http://chroma:8000/api/v1/heartbeat >/dev/null; then
                  exit 0
                fi
                sleep 2
              done
              exit 1
      containers:
        - name: api
          readinessProbe:
            httpGet: { path: /healthz, port: http }
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet: { path: /healthz, port: http }
            initialDelaySeconds: 15
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3

patches/chroma-probes.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: chroma
spec:
  template:
    spec:
      containers:
        - name: chroma
          readinessProbe:
            httpGet: { path: /api/v1/heartbeat, port: http }
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 12
          livenessProbe:
            httpGet: { path: /api/v1/heartbeat, port: http }
            initialDelaySeconds: 30
            periodSeconds: 20
            timeoutSeconds: 5
            failureThreshold: 6

6) 依存のピン止め(CIで保証)

例:requirements.txt / constraints.txt

requirements.txt

fastapi==0.110.0
uvicorn==0.29.0
SQLAlchemy==1.4.54
psycopg2-binary==2.9.9
# ほか依存…

constraints.txt

SQLAlchemy==1.4.54
numpy<2

Dockerfile(抜粋)

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt constraints.txt ./
RUN pip install --no-cache-dir -r requirements.txt -c constraints.txt
COPY app/ /app/app/
CMD ["uvicorn", "app.main:app", "--host","0.0.0.0","--port","8000"]

必要なら、既存のHelmチャートに合わせた values.yaml キー名(extraVolumes, extraVolumeMounts, extraEnv, extraInitContainers, podAnnotations など)に合わせて書き換えます。
また、オフショア向けにhack/smoke.shk8s/*.yaml をリポジトリ直下に置いて「READMEに3コマンド」だけ載せておくと迷子になりません。


Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です