中年engineerの独り言 - crumbjp

LinuxとApacheの憂鬱

コマンド直列化スクリプト

並列で動いているバッチプロセスが叩く外部コマンド(ffmpeg)がメモリーをバカ食いするケースが有って、タイミングが重なるとサーバーメモリーが枯渇するのでそこだけ直列化した。

作ってみたら便利だったので記事にした

#!/usr/bin/bash
set +e

PID_FILE=/tmp/`echo $1 | sed 's/\//_/g'`.pid
LOCK_FILE=/tmp/`echo $1 | sed 's/\//_/g'`.lock
WAITING='1'
while [ "$WAITING" = '1' ]; do
  if [ -e $LOCK_FILE ]; then
    echo 'Locked'
    sleep 1
    continue
  fi
  touch $LOCK_FILE
  exec 9> $LOCK_FILE
  flock -n 9
  if [ "$?" = "1" ]; then
    echo 'Flocked'
    sleep 1
    continue
  fi
  WAITING="0"
  echo touch $LOCK_FILE
  if [ -e $PID_FILE ]; then
    PID=`cat $PID_FILE`
    if [ "$PID" != '' ]; then
      ps -v -p $PID | grep -c $1 > /dev/null
      if [ "$?" = "0" ]; then
        echo "Wait for $PID ($1)"
        WAITING='1'
        rm $LOCK_FILE
        sleep 1
      fi
    fi
  fi
done
echo '====START===='
echo "$*"
$* &
echo $! > $PID_FILE
rm $LOCK_FILE
set -e
wait `cat $PID_FILE`
echo '====END===='

mac の場合は brew install flock が必要。

axiosのソケットリークでハマった

このシンプルなコードでソケットリークが起こる事に気付いた。

実際には膨大なコードの中からココに疑いも持った段階で9合目って感じだが・・・

const axios = require('axios');
const consumers = require('stream/consumers');

const get = (url) => {
  return new Promise(async (resolve, reject) => {
    try {
      const res = await axios.get(url, {responseType: 'stream'});
      resolve(await consumers.buffer(res.data));
    } catch (e) {
      reject(e);
    }
  });
};

responseType: 'stream' を指定した場合は、Stream (Socket)がまだ開いていて自前で処理してあげなきゃならない。

consumers.buffer(res.data) は、普通にchunk処理してBlobに渡してるだけだが、Streamを最後まで読んでいるのでcloseまで到達しているはずである。

github.com

ここは問題ない

問題はここ

    } catch (e) {
      reject(e);
    }

盲点だったのだが、接続エラーで到達するならば問題ないが、なんとBad Request などでも到達するのだ。当然ペイロードがある。

そして responseType: 'stream' ならば未処理のStreamである。

コイツがリークする!!

例外で飛んできてるので、response が受け取れてないが、例外の中に埋められている。

const _ = require('lodash');
const axios = require('axios');
const consumers = require('stream/consumers');

const get = (url) => {
  return new Promise(async (resolve, reject) => {
    try {
      const res = await axios.get(url, {responseType: 'stream'});
      resolve(await consumers.buffer(res.data));
    } catch (e) {
      const socket = _.get(e, 'response.data.socket');
      if (socket) {
        socket.destroy();
      }
      reject(e);
    }
  });
};

これで解決。

全然情報が無かったのだけど、、

レスポンスが正常に返ってくれば問題ないから気付き難いだけで、みんなリーク喰らってるんじゃないかな?

dockerコンテナ(ubuntu 20.04 LTS arm64)内でChromiumを動かすのが大変だった件

経緯

ubuntu 18.04 LTSのサポートが1年を切ったのでubuntu 20.04 LTSにアップデートしようとしていた。

実サーバー側の検証はそれ程大きな問題はなかったがCI環境でE2Eテストを行う部分で、コンテナ内でChromium headlessを使っており、これが思いの外難産だった。

技術的背景

1. Google Chromeが使えない

本来、googleがバイナリ配布しているChromeが使えれば良いのだが、Linux arm64バイナリは提供されていない。 なのでChromiumを使う必要がある

2. Ubuntu 19.10以降、ChromiumのAPT(deb)パッケージが廃止

バイナリはsnap版に移行し、debパッケージはsnap installに迂回するようになった。

ubuntu.com

3. Dockerコンテナ内でsnapが使えない

普通にDocker buildするとsnapdが立てられないのでsnap install が失敗する。

# snap install chromium
error: cannot communicate with server: Post http://localhost/v2/snaps/chromium: dial unix /run/snapd.socket: connect: no such file or directory

解決法

Dockerコンテナでsnapdを動かす

偉い人が作ったDockerコンテナジェネレータがあったので、これを改造して使ってみた。

github.com

これを紐解くと以下のようなDockerfileになる。

なるほど、、systemd経由でsnapdを起動する構造はそのままで、/sbin/init から上げて行くわけね。

Dockerfile

FROM --platform=linux/arm64 ubuntu:20.04
ENV container docker
ENV PATH "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y fuse snapd snap-confine squashfuse sudo init && apt-get clean && dpkg-divert --local --rename --add /sbin/udevadm && ln -s /bin/true /sbin/udevadm
RUN systemctl enable snapd
VOLUME ["/sys/fs/cgroup"]
STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"]

ハマった点

無事snapdは上がり、snapコマンドは通る

snap list
Name  Version                      Rev    Tracking     Publisher    Notes
core  16-2.56.2+git3949.06393d8a6  13434  latest/edge  canonical**  core

ところが、実際のinstallは失敗する

# snap install chromium
error: cannot perform the following tasks:
- Run configure hook of "chromium" snap if present (run hook "configure": aa_is_enabled() failed unexpectedly (No such file or directory): No such file or directory)

調べていくと、workaroundを提示している人が居た

github.com

# mount -t securityfs securityfs /sys/kernel/security

確かに、/sys/kernel/security が空だったのでmountすることで見事に解決した。

# snap install chromium
chromium 102.0.5005.115 from Canonical✓ installed

しかしDockerfileで解決出来ないのは困ったな。。

MongoDBの統一トポロジー(useUnifiedTopology)について

最近のMongoDBドライバーはエラーが出る

(node:8746) [MONGODB DRIVER] Warning: Current Server Discovery and Monitoring engine is deprecated, and will be removed in a future version. To use the new Server Discover and Monitoring engine, pass option { useUnifiedTopology: true } to the MongoClient constructor.

で、その新トポロジーの説明はこれ。

mongodb.github.io

すごくザックリ内容を紹介すると

  • 接続中(connected)という状態とはなんぞや?
  • 実際のネットワークが接続状態という事に意味はあるのか?
  • 別にになっていなくても処理可能な状態を保っていれば良いよね?
  • だからisConnectedも将来廃止するよ。
  • 直ちに処理出来ない状態でもドライバー内でなんとか辻褄取るよ
  • でも失敗したら30秒位でエラー返すよ

なので、useUnifiedTopologyautoReconnect を両方指定すると怒られる。

(node:8962) [MONGODB DRIVER] DeprecationWarning: The option `autoReconnect` is incompatible with the unified topology, please read more by visiting http://bit.ly/2D8WfT6

新しいトポロジーでは、利用者側は接続状態を気にしなくて良い事になっているからね。

ザックリ図解

現行ReplicaSet

f:id:hiroppon:20211124122931p:plain readPreferenceに合致するコネクションを割り当てる事しかしない。

一度コネクションを取得した後は素通し。

コネクションプール内のコネクションが全部エラーで破棄されるまでエラーが続く。

新統一トポロジー

f:id:hiroppon:20211124122936p:plain 1層咬んでいるのでエラーにならない所を勝手に探してくれる。

全部ダメならタイムアウトserverSelectionTimeoutMS までリトライする。

実際の挙動

createConnection

現行ReplicaSet

require('mongoose').createConnection('mongodb://localhost:27017/foo', {
  autoReconnect: true, 
  readPreference: 'secondaryPreferred'
})

直ちにエラーが帰る

MongoNetworkError: failed to connect to server [localhost:27017] on first connect [Error: connect ECONNREFUSED 127.0.0.1:27017
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1161:16)
    at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
  name: 'MongoNetworkError'
}]

新統一トポロジー

 require('mongoose').createConnection('mongodb://localhost:27017/foo', {
  useUnifiedTopology: true, 
  readPreference: 'secondaryPreferred',
  serverSelectionTimeoutMS: 30000,
})

約30秒後にこうなる

> Uncaught MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017
    at NativeConnection.Connection.openUri (/****/node_modules/mongoose/lib/connection.js:847:32)
    at Mongoose.createConnection (/****/node_modules/mongoose/lib/index.js:291:17) {
  reason: TopologyDescription {
    type: 'Unknown',
    setName: null,
    maxSetVersion: null,
    maxElectionId: null,
    servers: Map(1) { 'localhost:27017' => [ServerDescription] },
    stale: false,
    compatible: true,
    compatibilityError: null,
    logicalSessionTimeoutMinutes: null,
    heartbeatFrequencyMS: 10000,
    localThresholdMS: 15,
    commonWireVersion: null
  }
}

fetch

図解の通りに動くので、動作確認のコード例だけ。

このコードを動かしてレプリカセットのノードを落としたり上げたりすればいい。

新統一トポロジーの方は全ノードを落として、再起動せずにタイムアウトに抵触するまではエラーが出ない。

現行ReplicaSet

let conn = null;
require('mongoose').createConnection('mongodb://localhost:27018/foo', {
  replicaSet: 'RS',
  autoReconnect: true,
  readPreference: 'secondaryPreferred'}).then(r => conn=r).catch(r => console.log('---', r));
(async () => {
  for(let i = 0; i < 100000;i++) {
    try {
      let r = await conn.db.collection('tests').findOne({i: 1});
      console.log(i, r);
    } catch(e) {
      console.log(e);
    }
  }
})();

新統一トポロジー

let conn = null;
require('mongoose').createConnection('mongodb://localhost:27018/foo', {
  replicaSet: 'RS',
  useUnifiedTopology: true,
  readPreference: 'secondaryPreferred'}).then(r => conn=r).catch(r => console.log('---', r));
(async () => {
  for(let i = 0; i < 100000;i++) {
    try {
      let r = await conn.db.collection('tests').findOne({i: 1});
      console.log(i, r);
    } catch(e) {
      console.log(e);
    }
  }
})();

注意

良い改善だとは思うが、ハイロードな環境下では、30秒ものオペレーションを貯めたり再送して重複処理するのは危険極まりない

しっかり設計せずに移行するのは危険だ。

10TBクラスのmongodb ReplicaSet 運用ナレッジ

オススメの構成

[primary]
  - priority = 2
[secondary]
  - priority = 1
   :
 (必要なだけ) 
   :
[backup]
  - priority = 0
  - hidden = true

POINT

1. Primaryが好き勝手変動すると運用上の取り回しが悪いのでなるべく同じノードがPrimaryになるようにする

2. バックアップ中はOplog反映遅延が起こるのでクライアントクエリーをさせないhidden属性にしておく

3. 増強やバックアップや復旧を円滑に行うため、全てのノードでデータディレクトリは別のEBSボリュームにしておく

 

 例えば10TBクラスのEBSでヘビーユースのReplicaSetだと毎日差分スナップショットを取っても4時間ほど掛かりバックアップ後にOpTimeが追いつくまで5~6時間かかる。

スナップショット(バックアップ)のとり方

データ整合性を確保するために、スナップショット中の書き込みを抑止する必要がある。

rs:PRIMARY> db.fsyncLock();

** ここでスナップショットを取る **

rs:PRIMARY> db.fsyncUnlock();

スクリプトにしてcron.dailyにでも放り込んでおくと良い

#!/bin/bash
cd /path/to
bash snapshot.sh --tmp
mongo --host backup-prd1 -u root -p `cat safe_file` <<<'db.fsyncLock()';
bash snapshot.sh
mongo --host backup-prd1 -u root -p `cat safe_file` <<<'db.fsyncUnlock()';

EBSの場合、差分スナップショットなので直前に一回捨てスナップショットを取っておくと差分が小さくなり、lock期間を短くする事が出来る。

snapshot.shは、awsコマンドなりSDKなりで適当に実装すればいい

Oplog設定・設計

config
replication:
  oplogSizeMB: 614400 #600GB
起動中に変更する場合
db.adminCommand({replSetResizeOplog: 1, size: 614400})
サイズの決め方

以下のコマンドで確認しながら最低3日分位は確保する。理由は当記事を読んでいけば解る。出来れば7日分程度欲しいが更新が激しい場合は非効率になってしまう場合もある。

rs:PRIMARY> rs.printReplicationInfo()
configured oplog size:   614400MB
log length start to end: 183206secs (50.89hrs)
oplog first event time:  Mon Jul 05 2021 21:52:36 GMT+0900 (JST)
oplog last event time:   Thu Jul 08 2021 00:46:02 GMT+0900 (JST)
now:                     Thu Jul 08 2021 00:46:02 GMT+0900 (JST)

うわ。。足りてないね・・・

oplog first event time がこのノードが保持している一番古いOplogなのでこれ以上遅れてしまったSecondaryはもう同期出来なくなる。

遅延状態の確認方法
rs:PRIMARY> rs.status().members.map((member)=> [member.name, member.optimeDate.toLocaleString()])
[
        [
                "primary-prd1:27017",
                "Thu Jul  8 00:16:10 2021"
        ],
        [
                "secondary-prd1-prd4:27017",
                "Thu Jul  8 00:16:09 2021"
        ],
        [
                "secondary-prd2:27017",
                "Thu Jul  8 00:16:09 2021"
        ],
        [
                "secondary--prd3:27017",
                "Thu Jul  8 00:16:09 2021"
        ],
        [
                "backup-prd1:27017",
                "Wed Jul  7 16:18:33 2021"
        ]
]

ノードが壊れた場合

パターン

1. データファイル破損

エラーになってmongodが起動しない

 → ノード再構築

2. [rsBackgroundSync] replSet error RS102 too stale to catchup...

反映しなければならないOplog古すぎ、ReplicaSetメンバーの誰も持っていない

状態は1. と同じ。

3. [initandlisten] Taking 999 samples and assuming that each section of oplog contains approximately...

mongodを再起動した際に、エラーにはならないがこのログで止まる

 → ひたすら待つ。1日待っても良い。Secondary復帰後もOplog反映が遅くどんどん遅延して行くが1日位で正常なパフォーマンスに戻り追いつき始める

ノード再構築

1. 単純にデータディレクトリ以下を全て消す

この場合のmongodの挙動は以下

1. mongod起動(STARTUP2)

2. 他のreplicaSetメンバーから接続され同期が始まる(STARTUP2)

3. 同期元のノードからCollection毎にデータ取得 -> Index構築 を繰り返す(STARTUP2)

4. 全てのDB/Collectionの構築が終わったら2.時点のOplogを起点に差分を反映する(STARTUP2)

5. Oplogが追いついたら完了(SECONDARY)

 大きなCollectionでIndexが大量あれば、3.は時間がかかる。

 私の運用しているReplicaSetでは余裕で2週間はかかるのでOplogを巨大にしない限り4.の段階で Stale となる。

 それをやったとしてもOplogが追い付くまでに掛かる時間も膨大で完了まで1ヶ月以上は覚悟しなければならない。

 TBクラス以上の大きなReplicaSetでは現実的ではない。

2. なるべく新しいスナップショットからデータディレクトリを復旧(本命)

この場合のmongodの挙動は以下

1. mongod起動(STARTUP2)

2. データディレクトリ上の最後のcheckpointの状態でデータ復旧。1日近く掛かる事もある

3. 2.終了時に直ちにSECONDARYとなるがSnapshotを取った時点のデータなのでOpTimeが激しく古い状態(SECONDARY)

4. 通常のReplicaSetメンバーと同様の扱いでOplog反映を始める(SECONDARY)

 3. の時点でクライアントクエリーを処理するとセマンティック上の問題が起きるので、プロセス起動前に rs.reconfig() を行いhiddenノードにしておく必要がある

 4.が始まっても原因不明のパフォーマンス劣化状態が続く。1日程度で通常のパフォーマンスになるので3日分程度のOplogは必ず確保する必要がある。

Oplog遅延見積もり
snapshotに掛かる時間(5時間)+(3.)に掛かる時間(24時間)+(4.)開始後24時間で2時間程度しか反映が進まない
= 5 + 24 + 24 - 2
= 最大51時間遅延する

手順

SECONDARYが壊れたら?

1. rs.reconfig() で壊れたノードをhiddenに変更する

2. dailyで取っているバックアップノードのスナップショットからノード再構築

3. rs.reconfig() で再構築したノードのhiddenを解除する

バックアップノードが壊れたら?

1. rs.reconfig() で1つのSECONDARYをhiddenに変更する

2. hiddenセカンダリーの捨てスナップショットを2〜3回取る(1回目はフルスナップショット、2回目は長時間のフルスナップショット分の差分、3回目は2回目のスナップショットも本来最短で取れる差分よりは大きい分の差)

3. fsyncLock() を掛けて本スナップショットを取り完了後にfsyncUnlock()

4.3.のスナップショットからバックアップノード再構築

5.3.で起きた遅延が解消したら、rs.reconfig() で再構築したノードのhiddenを解除する

ARM環境まとめ

最近のARM(aarch64)周り

この1年で本番環境を全てarm64サーバーに切り替えたりDocker環境を作ったりしてarm64周りのナレッジが溜まってきた。

ハマり続けたメモ。思い出したら追記する

ブチ当たった問題の情報がネットに無い場合も多く、もしかしたらarm64周りを弄ってるエンジニアの中でもカッティングエッジに近い所まで来てるんじゃないかな・・・・

ビジネス環境

勢いがある。時間を先行投資する価値を感じる。

株主不安

SoftbankがARMの競合と目されているNVIDIAへ売却

AWS

Graviton2インスタンスが提供されており、intel CPUインスタンスより2割程安く、HWアクセラレータ周りも充実している

ElasticCache(Redis)

5%程割安で、しかし明らかに2倍くらい速度が遅い。

Redisは1CPUしか使わないので影響が出やすいのと、Speculative_executionの差なのか?

Apple

M1チップ搭載のMacが販売開始

Linux

amd64 / arm64

良く目検に失敗する・・・aarch64に統一してほしい・・・

Ubuntuパッケージ

ミラーサーバーがaarch64をサポートしていない場合がある

原因特定までメチャクチャ時間がかかる

Ubuntu 日本語環境

ubuntu-ja.listが使えない

Docker on Mac

ARM版のLinuxも一応動く

しかしカーネルエミュレータ周りの互換性がイマイチでそこかしこでエラーを吐く

遅い、固有の問題多すぎ。

互換性に問題のあるdockerコンテナなぞ価値なし!

Chrome

Chromeが提供されておらず、Chromiumを使うしかない

Ruby webdrivers

何も考えずx86_64のライブラリを引っ張ってきて起動でコケる

Feature specではOSにインストール済みのChromiumをバイナリを使う

JS karma

環境変数CHROME_BINにインストール済みのChromiumバイナリのパスを指定

JS puppeteer

launchのパラメータexecutablePathにインストール済みのChromiumバイナリのパスを指定

screen

化石エンジニア御用達の仮想コンソール

なぜかアタッチに分単位の時間がかかる

ElasticSearch

docker imageは7.8以降のみ対応

完全対応は7.13以降

大体、2020年6月以前のバージョンは非対応

7.13も起動が異常に遅い問題を確認している

rubyjs/libv8

https://github.com/rubyjs/libv8/issues/261

RailsでJS実行する際に必要だがビルド出来ない問題が2018年から放置

docker使って自前ビルドしたlibv8をvender/cacheに放り込ん動かす

1時間超えのCIを10分以下にした話

経緯

app.wercker.com

今までwercker を使っていたのだが、数年前は良いサービスだったのにOracleなんかに買われたせいで最近はインスタンスが目に見えて遅かったり、不安定だったりやってられないので、引っ越しを検討していた。

比較したサービス

wercker

最近明らかに遅いインスタンスが混ざり込む様になった

(超相乗り状態?)

  • 並列実行数=2
  • 1vCPU 2GB RAM
無料プランのみ??

情報が出てこない・・・

CircleCI

circleci.com

言わずと知れた最大手。CI業界の基準

ピュアなコンテナ環境ではなさそう(chrootっぽい何か?)

その影響か、ホストのカーネルの問題か解らないが、稀にCircleCI無いでしか起きないSEGV等が起きたり互換性周りの面倒事が起きる。

実行環境のリソースは細かく選択でき、料金が異なる。

料金はクレジットの単位で設定されており

25000クレジット$15で購入するので

$15 / 25000credit = $0.0006 / credit

resource class
Class vCPUs RAM credit
small 1 2GB 5 / 分
medium (default) 2 4GB 10 / 分
medium+ 3 6GB 15 / 分
large 4 8GB 20 / 分
xlarge 8 16GB 40 / 分
2xlarge(2) 16 32GB 80 / 分
2xlarge+(2) 20 40GB 100 / 分

なぜか、料金表が見当たらず、、赤字は予想。

Free Plan
  • 無料枠 2500 クレジット / week
  • 並列実行数=1
  • 3ユーザまで
  • Mediumのみ

よって無料枠は

 250分 / 週 = 4.2時間 / 週

  • パブリックリポジトリの場合 400000クレジット/月を付与
Performance Plan

クレジットの使用 - CircleCI

  • 並列実行数=80
  • 3ユーザ($15)+ ユーザ毎($15)
  • Docker レイヤー キャッシュへのアクセス(実行毎200クレジット)
  • 1口辺り25000クレジット($15)x 毎月最低2口 = $30〜

仮にsmallを100時間稼働させた場合(他にも、dockerイメージのキャッシュで実行毎200クレジット掛かったりするが、イメージDLと実行時間の相殺の問題なので一旦無視。)

 (ユーザ料金)$15 + 6000分 * 5credit * $0.0006 = $33(支払いは$45)

となる。

スタートアップなら此位で収まるプロダクトも多いだろう。

エンジニアが少ない場合でも、多い場合でも、ユーザ毎$15が重くなりがちな料金体系(少数エンジニアで生産性が高ければお得)

セルフホスティング

どうやら自前のホスティング環境で実行することも出来るようだが、わざわざCircleCIを選ぶ理由が思いつかないので、調べていない。

Github actions

github.co.jp

今まで仲良くしていたCIサードパーティに手の平を返して牙を向いたGithub様の刺客!

バックエンドはMicrosoft Azure。

明らかにインフラリソースの優遇を受けているであろうダンピング価格でCI環境を提供している。

パブリックリポジトリは全部無料

プライベートリポジトリでも巨大な無料枠が用意されている。

rootのHOMEが"/home/github" になっておりイメージの環境調整が面倒

プライベートリポジトリの場合は、JOBの実行時間とストレージ使用量がそれぞれ無料枠+従量の料金体系になっている。CIとして使う場合はストレージ使用量は気にならず、JOB実行時間だけ考えれば良い。

ジョブ実行時間の従量部分は 0.008$ / 分

Free
  • 無料枠2000分 / 月、ストレージ500MB
  • 並列実行数=20
Pro(月額$4)
  • 無料枠3000分 / 月、ストレージ1GB
  • 並列実行数=20

仮に100時間稼働させた場合(細かいことを言えば、どのCIを使おうが掛かる月額4$は除外しても良いが・・・)

 $4 + 3000分 * $0 + 3000分 * $0.008 = $28

一見、CircleCIより少し安い位だけど、プルリが少なくて50時間程度だった場合はほぼ無料になってしまう。

セルフホストランナー

サーバを自前で用意する。無料

CIが走った時にspot instanceでランナーを起動したいが、ランナーをGithubに接続する際に必要なワンタイムトークンを生成するAPIが提供されていないので困難。

github.community

実際の引っ越しの成果

元々の状態

f:id:hiroppon:20210611022858p:plain:w600

71分、これは早いインスタンスを引いた時で、ハズレを引くと3倍遅いしハズレ率が5割以上。

安定しないCIなんぞ糞である

Github actionsに引っ越し

f:id:hiroppon:20210611023513p:plain:w600

65分、あまり変わらないように見えるがハズレが無く安定したのでかなりのストレス軽減

だが、、もっと早くなるのが解っている!

並列化

.github/workflows/main.yml
name: CI
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
  workflow_dispatch:
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 12
      matrix:
        test_script:
          - run_sherpajs.sh
          - run_sherpa_server.sh
          - run_sherpa_admin_advertisement_br.sh
          - run_sherpa_admin_advertisement_br_multi.sh
          - run_sherpa_admin_advertisement_br_movie.sh
          - run_sherpa_admin_advertisement_sn.sh
          - run_sherpa_admin_advertisement_internal.sh
          - run_sherpa_admin_action_widget.sh
          - run_sherpa_admin_campaigns_sherpa.sh
          - run_sherpa_admin_campaigns_company_user.sh
          - run_sherpa_admin_others.sh
          - run_sherpa_admin_report.sh
    container:
      image: crumbjp/sherpa_ci
      credentials:
         username: crumbjp
         password: ${{ secrets.DOCKERHUB_PASSWORD }}
    steps:
      - uses: actions/checkout@v2

      - name: prepare
        run: bash ci/prepare.sh

      - name: services
        run: bash ci/services.sh

      - name: run ${{ matrix.test_script }}
        run: bash ci/run_parallel.sh ${{ matrix.test_script }}

マトリックスを使うと並列テストが作れる。

f:id:hiroppon:20210611023953p:plain:w600

12分!これが無料だから恐ろしい!!

しかし12並列ともなると10分のテストで120分ものジョブ時間を消費するので、我々の開発状況では3000分の無料枠は数日で使い切ってしまった。このペースだと従量課金で$300/月程度になりそうなのでセルフホストランナーも検討。

t4g.medium (vCPUx2 4GB RAM) x 720時間 = $31.1

2ランナー/サーバー x 6 = 12ランナー $186.6

コスト的には検討に乗りそうだ。

ARMのdocker imageは色々苦労があるがそれはまた別の機会に・・・

f:id:hiroppon:20210611023948p:plain:w600

中を見るとInitialize containers(docker イメージDL)に結構時間を掛けている。

Githubが用意しているランナーに我々のdockerイメージがキャッシュされている可能性はほぼ無い。

セルフホストランナーであればここが削れるはずだ。

セルフホストランナー化

f:id:hiroppon:20210611113343p:plain:w600

10分切った!

我々のプロダクトでは固定のStaging環境を維持しており、これをセルフホストランナーに流用して元々余らせていたリソースを活用。

本番サーバーでもリザーブインスタンスの期間の関係でリソースを余らせているサーバーを幾つか見繕って流用。 (一時的な処置)

結果、実質の出費無しで12本のランナーを確保することができた。

f:id:hiroppon:20210611115512p:plain:w600

目論見通りInitialize containersは数秒に収まっている。

またランナーにジョブが飛んでくるまでに30秒〜2分位のラグがあるようだ。

ローカルマシンより早い

今までローカルテストで確認してからプルリを作っていたが、もうカジュアルにプルリ作ってCIでテスト通す方が楽。 しかも料金は気にしなくて良い。

開発スタイルがガラっと変わりました。

もう腿を火傷する必要は無い!!

まとめ

1. CI環境は多くの場合、github actions が良い選択

2. ヘビーユースならばセルフホストランナーの方が良い

3. 並列テスト最高