はじめに
この記事はClaude Codeによって生成されています。
目的
自宅ラボのOpenShift(SNO)上で、CI/CDの一連の流れを組みました。
- Gitへpushすると、テスト → イメージのビルド → レジストリへのpush が自動で走る(CI)
- マニフェストの変更を検知して、クラスタへ自動でデプロイされる(CD)
- pushから数秒で同期が始まるよう、GiteaからArgo CDへWebhookで通知する
構築はAnsible Playbookで行い、push → CI → CD → アプリ反映 まで通しで動作を確認しています。
環境
| 項目 | 内容 |
|---|---|
| クラスタ | Single Node OpenShift(インターネットに出られる構成) |
| Gitリポジトリ | Gitea 1.27.0(クラスタ外の管理ホストでPodman稼働) |
| CI | Gitea Actions + act_runner(Docker-in-Docker) |
| CD | OpenShift GitOps(Argo CD) |
| イメージレジストリ | OpenShiftの内部レジストリ |
| サンプルアプリ | Python(FastAPI)のクイズアプリ |
構成
CIはPush型、CDはPull型です。CIにはクラスタの変更権限を持たせず、「Gitに書かれた状態」と「クラスタの状態」の同期はArgo CDに任せます。
リポジトリ構成
アプリとマニフェストを1つのリポジトリに置いています。
app-demo/
├── app/ # FastAPIアプリ
├── tests/ # pytest
├── Dockerfile
├── .gitea/workflows/ci.yml # CIの定義
└── manifests/base/ # Deployment / Service / Route / kustomization.yaml
1. CIランナー: act_runnerはDocker-in-Dockerで動かす
Gitea Actionsを動かすには、セルフホストのランナー(act_runner)が必要です。
ハマり: host実行モードが動かない
最初は、コンテナを使わずホスト上で直接ジョブを実行する「host」モードで動かそうとしました。ところがact_runner v0.6.1(検証時点のlatest)では、素のrun:ステップですら失敗しました。
bash: .cache/act/<hash>/act/workflow/0.sh: No such file or directory
ジョブのスクリプトを書き込むパスと、実行するときに参照するパスが食い違っていました。原因までは追っていませんが、hostモードは使えないと判断し、公式で実績のあるDocker-in-Docker方式に切り替えました。
構成
gitea/act_runner:latest-dindイメージを、Podman(Quadlet)で動かします。環境変数を渡すだけで、起動時にGiteaへ自動登録されます。
| 項目 | 値 |
|---|---|
| イメージ | docker.io/gitea/act_runner:latest-dind |
| 環境変数 |
GITEA_INSTANCE_URL、GITEA_RUNNER_REGISTRATION_TOKEN、GITEA_RUNNER_NAME、GITEA_RUNNER_LABELS
|
| コンテナの設定 |
--privileged、Network=host
|
| ジョブのイメージ |
catthehacker/ubuntu:act-latest(git・node・python3・docker CLI入り) |
登録トークンは、Giteaコンテナ内でgitea actions generate-runner-tokenを実行して取得します。
act_runnerのconfig.yamlでは次の3つを設定しました。
container:
network: "host" # ジョブコンテナから管理ホストのGitea(localhost:3000)へ届かせる
privileged: true
valid_volumes: ["**"]
networkを既定のbridgeのままにすると、ジョブコンテナのlocalhostは自分自身を指すので、Giteaに届きません。
ハマり: 自己署名のレジストリへdocker pushできない
Docker CLIには、podmanの--tls-verify=falseのようなコマンド単位のオプションがありません。act_runnerコンテナ内のdockerdに、/etc/docker/daemon.jsonをマウントして許可します。
{
"insecure-registries": ["192.168.150.0/24"]
}
ホスト名ではなくクラスタのサブネットで指定しておくと、レジストリのホスト名が決まる前に設定できます。
2. 内部レジストリをクラスタの外へ公開する
CIは管理ホストからイメージをpushするので、内部レジストリのRouteを公開します。
oc patch configs.imageregistry.operator.openshift.io/cluster \
--patch '{"spec":{"defaultRoute":true}}' --type=merge
ホスト名はdefault-route-openshift-image-registry.apps.<cluster>.<domain>になります。
push用には、アプリのNamespaceに専用のServiceAccount ci-pusherを作り、system:image-builderロールだけを付与しました。トークンはkubernetes.io/service-account-token型のSecretで発行しています(管理者権限は使わない)。
podman loginにホスト名だけを渡すと、HTTPにフォールバックして失敗することがありました。https://<host>と明示すると通ります。
3. Argo CD(OpenShift GitOps)の導入
redhat-operatorsカタログから、channel latestでOpenShift GitOps Operatorを入れます。openshift-gitopsNamespaceにArgo CDが立ち上がり、Routeも自動で作られます。
SNOのメモリが足りず、application-controllerとapplicationset-controllerがPendingになりました。メモリの割り当てが96%に達していたためで、VMのメモリを16GB→20GBに増やして解消しました。
4. Giteaの準備: リポジトリとCI用アカウント
GiteaのAPIでリポジトリを作り、CI専用のユーザーci-botに、このリポジトリへの書き込み権限だけのトークンを発行します。
ハマり(Gitea API)
- Ansibleの
uriモジュールは、force_basic_auth: trueを付けないとBasic認証を送らず401になる - ユーザー作成時のメールアドレスに
@localhost(TLDなし)を指定すると422で拒否される。@example.localのようにTLD付きにする - Gitea ActionsのSecrets/Variablesで、
GITEA_で始まる名前は予約語で使えない(GitHubのGITHUB_と同じ)。MANIFEST_PUSH_TOKEN・REPO_SLUGのように別の名前にする - Variables APIは、新規作成が
POST、更新がPUT。存在しないキーにPUTすると404になる(SecretsはPUTで作成・更新の両方ができる)
5. CIのワークフロー
pushされると、テスト → ビルド → push → マニフェストの更新 までを行います。
# .gitea/workflows/ci.yml
name: CI
on:
push:
branches: [main]
jobs:
build-and-deploy:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
# ジョブコンテナのDNSにはレジストリのホスト名がないので追記する
- run: echo "${{ vars.REGISTRY_IP }} ${{ vars.REGISTRY_HOST }}" >> /etc/hosts
- uses: actions/checkout@v4
- name: Run tests
run: |
python3 -m venv .venv && . .venv/bin/activate
pip install -q -r requirements-dev.txt
python -m pytest tests/ -v
- id: vars
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build and push
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
IMG=${{ vars.REGISTRY_HOST }}/app-demo/app-demo:${{ steps.vars.outputs.tag }}
docker build -t $IMG .
echo "$REGISTRY_TOKEN" | docker login "${{ vars.REGISTRY_HOST }}" -u ci-pusher --password-stdin
docker push $IMG
- name: Update manifest image tag
env:
MANIFEST_PUSH_TOKEN: ${{ secrets.MANIFEST_PUSH_TOKEN }}
run: |
sed -i "s|newTag:.*|newTag: ${{ steps.vars.outputs.tag }}|" manifests/base/kustomization.yaml
git config user.name "ci-bot"
git config user.email "ci-bot@localhost"
git add manifests/base/kustomization.yaml
git diff --cached --quiet && exit 0
git commit -m "ci: bump image tag to ${{ steps.vars.outputs.tag }} [skip ci]"
git push "http://ci-bot:${MANIFEST_PUSH_TOKEN}@localhost:3000/${{ vars.REPO_SLUG }}.git" HEAD:main
- イメージのタグはgitの短いコミットハッシュにしています
- CIが自分でマニフェストをpushするので、コミットメッセージに
[skip ci]を付けてループを防ぎます
ハマり: pushとpullでレジストリのアドレスを分ける
kustomization.yamlのイメージ名に、pushで使った外部Routeのホスト名を書くと、PodがImagePullBackOffになりました。
x509: certificate signed by unknown authority
ノードがRouteの自己署名証明書を信頼していないためです。pushは外部Route、Podのpullはクラスタ内のサービスアドレスと使い分けます。
# manifests/base/kustomization.yaml
images:
- name: app-demo
newName: image-registry.openshift-image-registry.svc:5000/app-demo/app-demo
newTag: abc1234 # CIが書き換える
注意: CIのコミットで手元のpushが拒否される
CIは毎回mainへコミットを追加するので、手元から次にpushすると! [rejected] (fetch first)になります。不具合ではないので、取り込んでからpushします。
git pull --rebase origin main && git push origin main
6. Argo CDのApplication
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: app-demo
namespace: openshift-gitops
spec:
project: default
source:
repoURL: http://<GiteaのIP>:3000/admin/app-demo.git
targetRevision: main
path: manifests/base
destination:
server: https://kubernetes.default.svc
namespace: app-demo
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
-
repoURLには、クラスタ内のPodから届くGiteaのアドレスを書きます(管理ホストのlocalhostはクラスタからは届かない) - 非公開リポジトリなので、
ci-botのトークンをargocd.argoproj.io/secret-type: repositoryラベル付きのSecretで登録します
ハマり: Syncがforbiddenで失敗する
Service・Deployment・Routeの作成が、いずれもis forbiddenで失敗しました。
OpenShift GitOpsのArgo CDは、既定ではopenshift-gitops以外のNamespaceを操作できません。デプロイ先のNamespaceにラベルを付けると、Operatorが必要な権限を自動で付与します。
oc label namespace app-demo argocd.argoproj.io/managed-by=openshift-gitops
7. Webhookでpushから数秒で同期させる
Argo CDは既定で約3分ごとにGitを確認するので、そのままではpushから反映まで数分かかります。GiteaのWebhookでArgo CDへ通知すると、数秒で同期が始まります。
7.1 設定
- Argo CDにWebhookの共有シークレットを設定し、argocd-serverを再起動する
oc patch secret argocd-secret -n openshift-gitops --type=merge \
-p '{"stringData":{"webhook.gitea.secret":"<共有シークレット>"}}'
oc rollout restart deployment openshift-gitops-server -n openshift-gitops
- Giteaの
app.iniで、プライベートIP宛てのWebhookを許可する。GiteaはSSRF対策として、既定ではプライベートIP宛てのWebhookの送信を拒否します
[webhook]
ALLOWED_HOST_LIST = private
SKIP_TLS_VERIFY = true ; Argo CDのRouteが自己署名証明書のため
- Giteaのリポジトリに、Argo CD宛てのWebhookを作る
| 項目 | 値 |
|---|---|
| 種類 | Gitea |
| URL | https://<Argo CDのRoute>/api/webhook |
| Content type | application/json |
| シークレット | 1.と同じ値 |
| イベント | push |
7.2 ハマり: Webhookは届くのに同期されない
Webhookの送信は成功しているのに、Argo CDが同期しませんでした。argocd-serverのログを見ると、受信はしています。
Received push event repo: http://localhost:3000/admin/app-demo ...
原因はGiteaのURL設定ROOT_URLです。GiteaはWebhookの中身にROOT_URLを元にしたリポジトリのURLを入れます。Argo CDは、このURLとApplicationのrepoURLを文字列で比較して対象を探すので、localhostと<GiteaのIP>で一致せず、何も起きませんでした。エラーも出ません。
GiteaのROOT_URLを、ApplicationのrepoURLと同じアドレスに変えると解決しました。
[server]
ROOT_URL = http://<GiteaのIP>:3000/
コンテナで動かしているGiteaのapp.iniをホスト側から編集すると、ファイルの所有者がrootに変わります。Giteaはコンテナ内のUID 1000で動くため、permission deniedで起動しなくなりました。編集後はchown 1000:1000 app.iniで所有者を戻します。
Webhookを入れる前は、Gitを更新してもすぐに反映されないことがありました(repo-serverのキャッシュ)。次のコマンドで強制的に再取得できます。
oc patch application app-demo -n openshift-gitops --type=merge -p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"hard"}}}'
8. 動作確認
- アプリのコードを変更してGiteaへpush
- Gitea ActionsでCIが走る(テスト → ビルド → push →
kustomization.yamlのタグを新しいコミットハッシュに更新) - Webhookで数秒後にArgo CDが同期し、新しいイメージのPodに入れ替わる
入れ替わったことは、Podのイメージタグか、Argo CDの画面で確認できます。
oc get pod -n app-demo -o jsonpath='{.items[*].spec.containers[*].image}'
# → image-registry.openshift-image-registry.svc:5000/app-demo/app-demo:<新しいコミットハッシュ>
Argo CDの画面では、Applicationの同期先のコミットが、CIが作ったコミット(ci: bump image tag to ...)になっていれば成功です。
ハマりポイントまとめ
| # | 事象 | 対応 |
|---|---|---|
| 1 | act_runnerのhostモードでrun:ステップすら失敗 |
Docker-in-Docker方式(act_runner:latest-dind)にする |
| 2 | ジョブコンテナから管理ホストのGiteaに届かない | container.network: "host" |
| 3 | 自己署名のレジストリにdocker pushできない |
dockerdのinsecure-registries
|
| 4 |
podman loginがHTTPにフォールバックする |
https://を明示 |
| 5 | Argo CDのコントローラーがPending
|
SNOのメモリを増やす(16GB→20GB) |
| 6 | Gitea ActionsでGITEA_で始まる名前が使えない |
別の名前にする |
| 7 | PodがImagePullBackOff(x509) |
pullはクラスタ内のサービスアドレスを使う |
| 8 | Syncがforbidden
|
Namespaceにargocd.argoproj.io/managed-byラベル |
| 9 | Webhookが届くのに同期されない | GiteaのROOT_URLをApplicationのrepoURLと一致させる |
| 10 |
app.ini編集後にGiteaが起動しない |
所有者をUID 1000に戻す |
まとめ
- Gitea Actions(Docker-in-Docker)+ Argo CDで、push → テスト → ビルド → デプロイ が自動で流れる構成を組めた
- CIにはクラスタの権限を持たせず、レジストリへのpushとマニフェストの更新だけをさせる。デプロイはArgo CDが担う
- Webhookを使うとpushから数秒で同期できる。ただしGiteaのURL(
ROOT_URL)とApplicationのrepoURLが一致しないと、エラーなしで無視される