2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Docker で Git の CI システムを手作りする

Last updated at Posted at 2024-11-04

背景

四苦八苦しながら Docker 環境構築をなんとか完了し、Ubuntu なんかを実験用でサクッと用意できることが分かって感動。

そして思ったのが、「これ活用したら、GitHub Actions っぽいことできるんじゃね?」

Docker をより詳しく知ることも目的に、Git リモートリポジトリに push されたら、自動 CI が動く簡易システムを作ってみることにしたのでした。

つくりたいもの

GitHub Actions のシステムはだいたいこんなイメージ。 (実際にこういう実装と確かめたわけではない)

それを、こんな感じで構築してみます。

git push から、CI 完了まで待たされることになりますが、今回は Docker 勉強がメインなので、利用の快適さは後回しで単純な実装にしています。

軽く仕様を検討

Git リポジトリがあって、そこに CI で実行するスクリプトを workflow.sh という名前で置いておく。

ls
# any_files.js  here.css  workflow.sh

そのリポジトリを push したとき、workflow.sh を実行してくれる。

git add .
git commit -m "Any commit message"
git push origin main
# すると workflow.sh が実行される

もちろん、実環境を汚さないために Docker コンテナ上で動作させる。

...というようなことを実現します。

要素技術とか

0. 作業場

cd ~
mkdir ci-work
cd ci-work

1. Git リモートリポジトリ

--shared --bare オプションで、リモートリポジトリを作成できます。リポジトリ名は cirepo とします。

git init --shared --bare cirepo.git

ls
# cirepo.git

2. リポジトリクローン

通常、サーバ上にリモートリポジトリを作成し、そこに SSH や HTTP でアクセスします。

でも、わざわざサーバを立てずとも、どっちもローカル環境上で clone やら push やらできます。実験のため全てローカル上で行います。

git clone cirepo.git

ls
# cirepo.git  cirepo

一旦、適当なコミットを作っておきます。

cd cirepo

touch any_files.js  here.css  workflow.sh
git add .
git commit -m "commit message"
git push origin main

cd ..

3. push 時にコマンド実行

CI を実現するためには、push されたときにサーバ (リモートリポジトリがある所) でコマンドを実行できる必要があります。そこで Git Hooks を利用します。

リモートリポジトリの hooks ディレクトリ内に、指定の名前でスクリプトを置いておくことで、特定の条件が発生したときにそのスクリプトが実行されます。

デフォルトではサンプルのスクリプトが置いてあります。

ls cirepo.git/hooks
# applypatch-msg.sample      pre-applypatch.sample    pre-receive.sample
# commit-msg.sample          pre-commit.sample        prepare-commit-msg.sample
# fsmonitor-watchman.sample  pre-merge-commit.sample  push-to-checkout.sample
# post-update.dep            pre-push.sample          update.sample
# post-update.sample         pre-rebase.sample

最終的には、この hook をいい感じに作れたらゴールということになります。

今回は post-receive を使います。

4. post-receive hook をもうちょっと詳しく

post-receive には、以下のような標準入力が渡されます。

5ae8f240c3481860687def9028e6c94a0cc82f21 de402c2e48da4b27ce43155f6d4ec1bb487aece9 refs/heads/main
0cc5d4d22489181c1c23c9677e2706e7d5d5c044 e0b6c73c2acf73fe32f0bee7228b84db82e99d05 refs/heads/develop
  • 左 - push される前の古いコミットのハッシュ値
    • 新しいブランチ、つまり push 前の古いコミットが無い場合、000... と全て 0 の値が渡されます
  • 中 - push 後の新しいコミットのハッシュ
    • ブランチの削除、つまり push 後の新しいコミットが無い場合、000... と全て 0 の値が渡されます
  • 右 - push 先リファレンス名 (ブランチ名)
    • refs/heads/<branch_name> というような文字列が渡されます
    • refs/heads/ を除いてやれば、シンプルにブランチ名が得られます

1行 1ブランチでデータが渡され、同時に複数のブランチを push した場合は複数行の入力をされることになります。

ブランチ名は、この先のソースチェックアウトで必要になります。

post-receive は、push を受理した後、つまりリモートリポジトリの更新が確定した後に実行されます。

pre-receiveupdate では、push を反映する前の状態、つまり最新のソースでない所で CI が走ってしまいます。

push されるソースを取得して上手く走らせることも可能とは思いますが、単純に実装できそうという見積もりで post-receive を使用しました。

この標準入力を加工して、先のスクリプトで扱いやすくします。

while read line; do
  HASH_OLD=`echo -n "$line" | awk '{print $1}'`
  HASH_NEW=`echo -n "$line" | awk '{print $2}'`
  REF_NAME=`echo -n "$line" | awk '{print $3}'`
  BRANCH_NAME="${REF_NAME#refs/heads/}"

  # この先、ブランチ毎に実行したいコマンド...
done

while read line; do ... done で、標準入力を 1行ずつ読んで、変数に入れます。(上の例では変数名 line ですが、任意の名前で OK)

awk コマンドで、スペース区切りの文字列の、任意の列を得ます。

awk はデフォルトでスペースを区切り文字として扱いますが、オプション -F で他のセパレータにも対応可能です。

例えばオプション -F ',' なら、コンマ区切りで処理します。

「しれっと登場したコイツ ${REF_NAME#refs/heads/} 何だ?」こちらの記事で詳しく紹介されています。

https://qiita.com/aosho235/items/c36568830a8d47288284

5. git 入り ubuntu イメージ作成

今回は Ubuntu コンテナを CI 環境とします。ただ、デフォルトでは git がインストールされていない、最低限な状態です。CI 実行毎に git をインストールするのでは賢くないので、git インストール済み Ubuntu イメージを作ります。

任意のテキストエディタで dockerfile を作ります。

vim dockerfile

ubuntu イメージをベースに、git をインストールするだけ。

FROM ubuntu

RUN apt update
RUN apt install -y git

あとはビルド。イメージ名は git-ci-img にします。

docker build -t git-ci-img .

6. Docker コンテナ起動 & コマンド実行

コンテナの作成と起動

CONTAINER_ID=`docker create git-ci-img`
docker start $CONTAINER_ID

通常、コンテナに名前をつけ、そのコンテナ名を指定して start などの各種コマンドを呼び出します。が、create したときに出力される ID を直接入力する形でも各種コマンドを実行できます。

名前をつけておくと、干渉する恐れがあります (少なくとも狙って干渉させることが可能になります)。なので、ここでは名前をつけず全て ID で実行します。

この基本形に少しオプションを追加します。

CONTAINER_ID=`docker create --rm -w /home/ci -it git-ci-img`
docker start $CONTAINER_ID > /dev/null
  • --rm
    • コンテナが stop されたとき、コンテナを自動で削除する
  • -w /home/ci
    • ワーキングディレクトリの指定
  • -it
    • STDIN を保持し、疑似 TTY を割り当てる
    • (起動した後に自動で始まるターミナルを保持する、みたいな?)
    • ここでは、コンテナを起動状態で保つのに必要なオプションとお考えください

起動中のコンテナ上でコマンド実行

例えば echo hello を実行するには以下のような感じ。

docker exec $CONTAINER_ID echo hello

リポジトリのクローンをしたり、workflow.sh を実行したりする際に必要になります。

コンテナをシャットダウン

docker stop $CONTAINER_ID

create のときに --rm オプションをつけておいたので、docker rm $CONTAINER_ID は不要です。

7. ソースチェックアウト (経路確保 編)

最後の難関。コンテナにリポジトリの中身 (ソースコード) を展開できなくては、CI システムと呼べないも同然です。

コンテナからリモートリポジトリ (ホストのファイル) へアクセスする経路を確保できれば OK。そのためには mount が使えます。mount はコンテナの create 時に設定できます。

docker create --mount type=bind,source="$REPO_PATH",target="/home/$REPO_NAME",readonly -it git-ci-img
  • type
    • mount のタイプ
    • (bind の他に volume, tmpfs があるみたいだが、違いを分かってない)
  • source
    • コンテナに接続したい、ホスト環境のディレクトリ
    • 相対パスでは指定できないので注意
  • target
    • コンテナ上の接続先パス
  • readonly
    • コンテナ内から書き換えることが不可になる
    • CI がコミットを作成するようなことをしたい場合は、このオプションをつけない

これで、コンテナからリモートリポジトリへの経路が確保できました。試しにこの設定でコンテナを作り、コンテナに入って確かめてみましょう。

# 作業場ディレクトリをカレントで
pwd
# /home/akuad/ci-work

docker create --name test-con -w /home/ci --mount type=bind,source="cirepo.git",target="/home/cirepo.git",readonly -it git-ci-img
docker start test-con
docker exec -it test-con /bin/sh

# -- ここからコンテナ内 --

pwd
# /home/ci
ls /home
# ci  cirepo.git  ubuntu

# あとは git clone のみだが、色々トラブルあったので次の節で詳しく

8. ソースチェックアウト (git clone 編)

/
|- home/
   |- ci/  <- カレント
   |- cirepo.git/
   |- ubuntu (なんかデフォルトであったディレクトリ, 特に触れることはない)

こうなれば、あとは git clone ../cirepo.git だけ!・・・と言いたいところですが、いつも通りの clone と違い、以下の要件を満たしたいです。

  • clone 後、指定のブランチにいる状態にしたい
    • 通常、デフォルトブランチ (通常 main or master) の状態から始まります
    • CI では push したブランチを対象に処理するので、そのまま指定のブランチに checkout した状態から初めたいです
  • 全履歴は要らないので早く clone したい
    • デフォルトでは、全ての履歴を取得します
    • CI では push された最新のコミットさえあれば良いので、全履歴を取得してしまうと時間の無駄になってしまいます
  • 新規ディレクトリを作らず、カレント内にソースを展開したい
    • cirepo ディレクトリをリポジトリにするのではなく、./ をリポジトリにします
    • これにより、cd でリポジトリに入る手間が省けます
    • GitHub Actions の actions/checkout と同じような動きにします

ブランチ指定は -b <branch_name>、クローン深さは --depth <depth>、ローカルリポジトリにするディレクトリはリモートリポジトリ URI 後に指定できます。これらを適用してクローンしてみましょう。

# -- 引き続きコンテナ内 --

git clone --depth 1 -b main ../cirepo.git ./
# Cloning into 'cirepo'...
# warning: --depth is ignored in local clones; use file:// instead.
# done.

ls -a
# .  ..  .git  any_files.js  here.css  workflow.sh

うん?--depth が無視された?

file:// でないとダメなようです。それをつけてやり直し。

git clone --depth 1 -b main file:///home/cirepo.git ./
# Cloning into '.'...
# remote: Enumerating objects: 4, done.
# remote: Counting objects: 100% (4/4), done.
# remote: Compressing objects: 100% (2/2), done.
# remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
# Receiving objects: 100% (4/4), done.

la -a
# .  ..  .git  any_files.js  here.css  workflow.sh

git log
# commit a6aa2b92dc78b5ab201735d31ce9f98d7e23657e (grafted, HEAD -> main, origin/main)
# Author: aKuad <53811809+aKuad@users.noreply.github.com>
# Date:   Fri Nov 1 23:44:22 2024 +0900
#
#     commit message

うまくできました。コミット履歴も 1つだけです。

実装と試運転

以上を踏まえて、post-receive を実装します。

#!/usr/bin/env sh

DOCKER_IMAGE="git-ci-img"
REPO_PATH=`pwd`
REPO_NAME="${REPO_PATH##*/}"
while read line; do
  HASH_OLD=`echo -n "$line" | awk '{print $1}'`
  HASH_NEW=`echo -n "$line" | awk '{print $2}'`
  REF_NAME=`echo -n "$line" | awk '{print $3}'`
  BRANCH_NAME="${REF_NAME#refs/heads/}"

  if [ "$HASH_NEW" = "0000000000000000000000000000000000000000" ]; then
    echo "CI skip for branch deletion '$BRANCH_NAME'"
    continue
  fi

  echo "CI starting for '$BRANCH_NAME' ..."

  CONTAINER_ID=`docker create --rm -w /home/ci --mount type=bind,source="$REPO_PATH",target="/home/$REPO_NAME",readonly -it $DOCKER_IMAGE`
  docker start $CONTAINER_ID > /dev/null

  docker exec $CONTAINER_ID git clone --depth 1 -b $BRANCH_NAME "file:///home/$REPO_NAME" ./
  docker exec $CONTAINER_ID /bin/sh -c "if [ -x workflow.sh ]; then ./workflow.sh; fi"

  echo "CI shutting down ..."
  docker stop $CONTAINER_ID > /dev/null

done

このスクリプトを、cirepo.git/hooks/update に書き込みます。実行権限の付与もお忘れなく。

cd ~/ci-work

ls
# cirepo.git  cirepo

vim cirepo.git/hooks/post-receive  # 任意のテキストエディタで上のスクリプトを書く

chmod +x cirepo.git/hooks/post-receive

それでは、ローカルリポジトリに入って、早速動かしてみましょう。

cd ~/ci-work
cd cirepo

vim workflow.sh  # 任意のテキストエディタで

とりあえず、何か echo してもらうだけの CI です。

workflow.sh
#!/usr/bin/env sh

echo HELLO

そして commit と push。

git add .
git commit -m "commit message"
git push origin main
# Enumerating objects: 5, done.
# Counting objects: 100% (5/5), done.
# Delta compression using up to 16 threads
# Compressing objects: 100% (2/2), done.
# Writing objects: 100% (3/3), 957 bytes | 957.00 KiB/s, done.
# Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
# remote: CI starting for 'main' ...
# remote: Cloning into '.'...
# remote: HELLO
# remote: CI shutting down ...
# To /home/akuad/myci/cirepo.git/
#    a6aa2b9..f79d83f  main -> main

HELLO とプリントされています。うまくできたようです。

もうちょっと応用 - deno test する

echo だけでは味気無いので、CI らしい例として deno test を回してみます。

まず初めに、Deno と git 入り Ubuntu イメージを作成します。Deno 入り Ubuntu イメージが Deno 公式から上げられているので、そこに git を入れる形でイメージを作ります。

FROM denoland/deno:ubuntu

RUN apt update
RUN apt install -y git
CMD ["/bin/bash"]

# CMD /bin/bash
# でも動きますが、Warning が出ます

イメージ ubuntu では、起動後にデフォルトで /bin/bash を実行するように作られています。

しかし denoland/deno ではそうなっていません。すると、ターミナルが起動されることなく、すぐにコンテナが終了してしまいます。

そこで、CMD/bin/bash を実行するようにして、コンテナが立ち上がった状態で保たれるようにします。

ビルドします。

# 古い git-ci-img を削除しておく
docker rmi git-ci-img

docker build -t git-ci-img .

イメージ名は、先の完成品と変えていないので、post-receive 編集の必要は無し。

次に、テストコードの追加。雑に足し算のテストをします。

sum.test.ts
import { assertEquals } from "jsr:@std/assert@1";

Deno.test(function test() {
  const excepted = 2;
  const actual = 1 + 1;

  assertEquals(actual, excepted);
});

最後に、deno test を実行するように、workflow.sh を編集します。

workflow.sh
#!/usr/bin/env sh

echo HELLO

deno test *.test.ts

commit と push。

git add .
git commit -m "deno test"
git push origin main
# Enumerating objects: 6, done.
# Counting objects: 100% (6/6), done.
# Delta compression using up to 16 threads
# Compressing objects: 100% (4/4), done.
# Writing objects: 100% (4/4), 1.15 KiB | 1.15 MiB/s, done.
# Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
# remote: CI starting for 'main' ...
# remote: Cloning into '.'...
# remote: HELLO
# remote: Download https://jsr.io/@std/assert/meta.json
# remote: Download https://jsr.io/@std/assert/1.0.7_meta.json
# ... 他ダウンロードログ
# remote: Download https://jsr.io/@std/internal/1.0.5/types.ts
# remote: Check file:///home/ci/sum.test.ts
# remote: running 1 test from ./sum.test.ts
# remote: sum test ... ok (0ms)
# remote: 
# remote: ok | 1 passed | 0 failed (2ms)
# remote: 
# remote: CI shutting down ...
# To /home/akuad/myci/cirepo.git/
#    f79d83f..9dcbf59  main -> main

一気に CI らしくなりました。

感想

もっと応用すれば、使用するイメージを workflow.sh 内で選択できる、みたいなのも作れるかもしれません。あれもこれもとやると、際限なく複雑になってしまうので、この辺で止めにしました。

完走した感想ですが、ソースのチェックアウトはなかなかに難関でした。おかげで Docker も git も勉強になりましたが。GitHub Action では actions/checkout 呼ぶだけで済みますので、CI ツールってキレイに整備してくれてるんやなって・・・。

完成品ですが、まあ使い心地はそんなに良いものではありません (ログも保存されないし)。よほど特殊な環境でもない限り、こんな感じで実装することはそう無いでしょう。

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?