Bitbucket Pipelines に関する投稿を表示しています

Google App Script を使って Bitbucket Pipelines の動いている状況を見える化した

某所で Bitbucket Cloud を使っていて、そのうえで動く CI 、 Bitbucket Pipelines を利用しています。

Bitbucket Pipelines | Atlassian

CI 時間が月 500 分を超えるまでは無料で利用できるのですが(すごい)、あっちこっちのリポジトリで使い始めたり、プッシュ頻度の高いリポジトリで使ったり
あるいは コード量が多くて CI の時間がかかるようなリポジトリ、そんなところで使い始めると途端に課金額がマッハになってしまいます
(ブランチを絞る、手動だけなどしてもいいけどそういうことじゃない)

いきなり XXXXX ドルの請求です!なんて届くと超ビビってしまうので、
毎月どれくらいの請求額になりそうか、過去はどれくらいだったのかを記録して通知するものを スプレッドシート + GAS で作ってみました。

なぜスプレッドシートと GAS なのか

同某所では GSuite を利用しているので、 Google Drive を組織的に利用できます。
GAS を使うことで、定期的にプログラムを実行することができるうえ、他の外部の何か(例えばサーバなど)について管理することが不要になります。
管理コストが少なく、かつ、スプレッドシートを基軸として、今後の分析にも使えるのでは?と考えると、スプレッドシート最高だなぁという結論になりました。

何も管理する必要なくオンラインにエクセルがいて、マクロの自動実行が出来るよって考えたらやばいと思うんだけど。

Google スプレッドシート - オンラインでスプレッドシートを作成、編集できる無料サービス

Apps Script | Google Developers
Spreadsheet Service | Apps Script | Google Developers

どのように Pipelines の利用状況を集めるか

Pipelines 、というか Bitbucket にはプログラム的に操作やデータ取得を行えるよう API が提供されています。
この中に Pipelines に関するものがあるので、これを利用します。

Bitbucket API

リポジトリ一覧を取得 → 各リポジトリに対して Pipelines の利用状況を確認 というながれで情報を集めます。

リポジトリ一覧 API : Bitbucket API
Pipelines状況 API : Bitbucket API

GAS から API を利用してデータを集める

というわけで出来上がったものがこちらです。

var BitbucketApi = {};

BitbucketApi.getAuth = function () {
  var options = {};
  options.headers = {};
  options.headers.Authorization = "Basic " + "ここに base64(ID:PW) したものを入れる";
  
  return options;
};

BitbucketApi.fetch = function (api) {
  var url = "https://api.bitbucket.org" + api;
  try {
    var rawResponse = UrlFetchApp.fetch(url, BitbucketApi.getAuth());
  } catch (ex) {
    Logger.log(ex);
    return null;
  }
  return JSON.parse(rawResponse.getContentText());
};


var Util = {};

Util.each = function(ary, cb) {
  for (var i=0, m=ary.length; i<m; i++) {
    if(cb(ary[i], i) === false) break;
  }
};

Util.getSheetByName = function (name) {
  if (Util.SpreadSheet == null) {
    Util.SpreadSheet = SpreadsheetApp.getActive();
  }
  
  return Util.SpreadSheet.getSheetByName(name);
};

Util.vlookup = function (searchValue, targetValues, column) {
  var result = null;
  Util.each(targetValues, function(row) {
    if (row[0] === searchValue) {
      result = row[column];
      return false;
    }
  });
  
  return result;
};

Util.yesterDay = function(date) {
  date = date || new Date();
  date.setDate(date.getDate() - 1);
  return date;
};

Util.startDay = function(date) {
  date = date || new Date();
  var d = new Date(date);
  d.setHours(0);
  d.setMinutes(0);
  d.setSeconds(0);
  return d;
};

Util.endDay = function(date) {
  date = date || new Date();
  var d = new Date(date);
  d.setHours(23);
  d.setMinutes(59);
  d.setSeconds(59);
  return d;
};


// reposシート、リポジトリチェック済みフラグのカラム番号
var REPOSITORY_CHECK_COLUMN = 4;


// リポジトリのチェックフラグの初期化
function initPipelinesDuration() {
  var repoSheet = Util.getSheetByName("repos");
  var repoRange = repoSheet.getRange(2, REPOSITORY_CHECK_COLUMN, repoSheet.getMaxRows() - 1, 1);
  repoRange.setValue(0);
}


// pipelinesシートの初期化
function resetPipelinesDuration() {
  Logger.log('WARNING!! Reset record durations!!  exit script.');
  return; // warning, danger.
  
  var sheet = Util.getSheetByName("pipelines");
  if (sheet.getMaxRows() > 1) {
    sheet.deleteRows(2, sheet.getMaxRows() - 1);
  }
  sheet.getRange(1, 1, sheet.getMaxRows(), sheet.getMaxColumns()).setValue("");
  sheet.getRange(1, 1, 1, 4).setValues([["repo", "build_number", "build_seconds", "completed"]]);
}


// リポジトリ一覧の同期
function syncRepositories() {
  // reposシートをまっさらにする
  var sheet = Util.getSheetByName("repos");
  if (sheet.getMaxRows() > 1) {
    sheet.deleteRows(2, sheet.getMaxRows() - 1);
  }
  sheet.getRange(1, 1, 1, 4).setValues([["repo", "url", "project", "status"]]);

  // BitbucketAPIを使ってリポジトリ名一覧をシートに移す
  var page = 1;
  while (true) {
    var repositories = BitbucketApi.fetch("/2.0/repositories/チーム名/?sort=-created_on&pagelen=100&page=" + page);
    if (repositories == null || repositories.values == null || repositories.values.length == 0) {
      break;
    }
    Util.each(repositories.values, function(repo) {
      sheet.appendRow([
        repo.slug,
        repo.links.html.href,
        repo.project.name
      ]);
    });
        
    page++;
  }  
}

// pipelinesシートの更新
function syncPipelinesDuration() {
  var sheet =  Util.getSheetByName("pipelines");
  var pipelineSheetRows = sheet.getLastRow();
  var todayStart = Util.startDay(Util.yesterDay());
  var todayEnd = Util.endDay(Util.yesterDay());
  
  var repoSheet = Util.getSheetByName("repos");
  var repos = repoSheet.getSheetValues(2, 1, repoSheet.getMaxRows(), 3);
  
  // 既にシート上にそのビルドの記録があるかチェック
  var isExistsBuild = function(repo, num) {
    var find = false;
    Util.each(sheet.getSheetValues(2, 1, sheet.getLastRow(), 2), function(row) {
      if (row[0] + row[1] === repo + num) {
        find = true;
        return false;
      }
    });
    
    return find;
  };
  
  Util.each(repos, function(repo, rowIndex) {
    if (repo[0] == "" || repo[2] == "1") {
      Logger.log(repo[0] + " : skip");
      return;
    }
    Logger.log(repo[0] + " : sync");
      
    repoSheet.getRange(rowIndex + 1, REPOSITORY_CHECK_COLUMN).setValue(1);
    
    var page = 1;
    while(true) {
      Logger.log(repo[0] + " : sync : page " + page);
      var response = BitbucketApi.fetch("/2.0/repositories/チーム名/" + repo[0] + "/pipelines/?sort=-created_on&pagelen=100&page=" + page);
      if (response == null || response.values.length == 0) {
        break;
      }
      var breakFlag = false;
      
      Util.each(response.values, function(item) {
        // 完了していないビルドはスキップ
        if (item.completed_on == undefined) {
          return;
        }
        var formattedCompletedOn = item.completed_on.replace(/T.+Z/, "");
        var completedTime = new Date(formattedCompletedOn);
        if (completedTime.getTime() > todayEnd.getTime()) {
          return;
        }
        if (completedTime.getTime() < todayStart.getTime()) {
          breakFlag = true;
          return false;
        }
        
        if (isExistsBuild(item.repository.name, item.build_number)) {
          return;
        }
        
        sheet.appendRow([
          item.repository.name,
          item.build_number,
          item.build_seconds_used,
          completedTime.toLocaleDateString(),
          '=MONTH(D' + pipelineSheetRows + ')=month(now())',
        ]);
        pipelineSheetRows++;;
      });
      
      if (breakFlag) {
        break;
      }
      
      page++;
    }
  });
};

認証周りだけちょっとアレなので、なんとかしたいなあとか思いつつも直打ち。

実装としては次の 2 つの処理に分かれます。

  • リポジトリ一覧の同期

    • 1 日 1 回 午前中 に実行
  • Pipelines 状況の取得

    • 1 日 1 回 お昼ごろ に実行
    • 1 日 1 回 おやつごろ に実行
    • 1 日 1 回 夕方ごろ に実行

リアルタイムに動かすことが難しいので、一日一回動かして、昨日分のデータを取得するようにしています。
Pipelies は 1 度に取りきることが難しいので、何回か動かします。進捗はシートに記録されるので、再度取得することはありません。

あとは、これだけだとシートがなくて動かないので、空っぽのシートを用意したら使えるはず。

  • pipelines
  • repos

収集してからのこと、集計と通知

実行するとこんな感じに Pipelines のデータが収集できるので、ここからスプレッドシートの機能で集計していきます。

ピボットテーブルを使って、当月の、各リポジトリの、実行時間(秒)を合計します。

こんな具合で設定しています。

ここから更に、少々強引な関数を組んで、このデータを参照して日毎の実行時間(分)を算出し、月単位でどれくらいの実行時間になりそうかをざっくり予測できます。

ちなみに使った関数はこんなものです。

データの参照 ( B1 )
=index(aggregate!A1:AA20)


ビルド数の抽出 ( C24 ~ xx26 )
=COUNTIF(pipelines!$D:$D,C1)


総計の抽出行 ( C25 ~ xx26 )
=if(C1="", 0/0, if(C1<>"総計", INDEX(C1:C20, LOOKUP("総計",$B1:$B20,$A$1:$A$20), 1), 0/0))


総計の分 ( C26 ~ xx26 )
=iferror(C25/60, "")


今月予測(ビルド分) ( C27 )
=average(C26:AB26)*22


今月予測(課金額) ( C28 )
=round(((C27-500)/1000)/10*10)*10

で、このときの C28 を UrlFetch で Slack に送るよう、トリガーで日々実行しています。

var SlackApi = {};
SlackApi.config = {};
SlackApi.config.webhook = "";
SlackApi.config.testMode = false;

SlackApi.sendMessage = function (text) {
  var payload = {
    text: text
  };
  
  var options = {
    method: 'post',
    payload: JSON.stringify(payload),
  };
  
  if (SlackApi.config.testMode) {
    Logger.log(SlackApi.config.webhook);
    Logger.log(options);
  } else {
    return UrlFetchApp.fetch(SlackApi.config.webhook, options);
  }
};


function sendSlack() {
  SlackApi.config.webhook = "https://hooks.slack.com/services/....";

  var sheet = Util.getSheetByName("aggregate");
  var usages = sheet.getSheetValues(2, 1, sheet.getLastRow(), sheet.getLastColumn());

  var resultMessage = [];
  resultMessage.push("> *Pipelines利用状況* (" + (new Date()) + ")");
  resultMessage.push("```");
  Util.each(usages, function(usage) {
    if (usage[0] == "") return;
    resultMessage.push(usage[0] + ": " + usage[sheet.getLastColumn() - 1] + "sec");
  });
  resultMessage.push("");
  
  try {
    var paceSheet = Util.getSheetByName("課金ペースをみたい");
    var paces = paceSheet.getSheetValues(27, 2, 2, 2);
    resultMessage.push(paces[0][0] + ": " + paces[0][1]);
    resultMessage.push(paces[1][0] + ": " + paces[1][1]);
    resultMessage.push("");
  } catch (ex) {
    Logger.log(ex);
  }

  resultMessage.push("詳細はここ> https://docs.google.com/spreadsheets/....");
  resultMessage.push("```");
  
  SlackApi.sendMessage(resultMessage.join("\n"));
}

これでいきなり多額の請求が来ることに怯えることが少なくなりました。遅くても 1 日で oops! なことに気づけるはずです。

Bitbucket Pipelines で MySQL コンテナを使う時に早すぎるとエラーになる

この記事は公開されてから1年以上経過しており、情報が古い可能性があります。

Bitbucket Pipelines を使ってて、全く設定を変えていないのに MySQL に繋がったり繋がらなくなったりしていたので、どういうこっちゃ、というメモ。

結論から行くと 3 〜 5 秒くらい待ってあげればよい。

MySQL に繋がらない

エラーとしてはこんな感じ。ちなみにこの問題と直接関係はないが CakePHP 3.5 である。

+ bin/cake migrations migrate
using migration paths 
 - /opt/atlassian/pipelines/agent/build/config/Migrations
using seed paths 
 - /opt/atlassian/pipelines/agent/build/config/Seeds
Exception: There was a problem connecting to the database: SQLSTATE[HY000] [2002] Connection refused in [/opt/atlassian/pipelines/agent/build/vendor/robmorgan/phinx/src/Phinx/Db/Adapter/MysqlAdapter.php, line 115]
2017-10-16 10:57:41 Error: [InvalidArgumentException] There was a problem connecting to the database: SQLSTATE[HY000] [2002] Connection refused in /opt/atlassian/pipelines/agent/build/vendor/robmorgan/phinx/src/Phinx/Db/Adapter/MysqlAdapter.php on line 115
Stack Trace:
#0 /opt/atlassian/pipelines/agent/build/vendor/robmorgan/phinx/src/Phinx/Db/Adapter/PdoAdapter.php(238): Phinx\Db\Adapter\MysqlAdapter->connect()
#1 /opt/atlassian/pipelines/agent/build/vendor/cakephp/migrations/src/CakeAdapter.php(57): Phinx\Db\Adapter\PdoAdapter->getConnection()
#2 /opt/atlassian/pipelines/agent/build/vendor/cakephp/migrations/src/Command/CommandTrait.php(78): Migrations\CakeAdapter->__construct(Object(Phinx\Db\Adapter\MysqlAdapter), Object(Cake\Database\Connection))
#3 /opt/atlassian/pipelines/agent/build/vendor/robmorgan/phinx/src/Phinx/Console/Command/Migrate.php(72): Migrations\Command\Migrate->bootstrap(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#4 /opt/atlassian/pipelines/agent/build/vendor/cakephp/migrations/src/Command/CommandTrait.php(35): Phinx\Console\Command\Migrate->execute(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#5 /opt/atlassian/pipelines/agent/build/vendor/cakephp/migrations/src/Command/Migrate.php(65): Migrations\Command\Migrate->parentExecute(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#6 /opt/atlassian/pipelines/agent/build/vendor/symfony/console/Command/Command.php(262): Migrations\Command\Migrate->execute(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#7 /opt/atlassian/pipelines/agent/build/vendor/symfony/console/Application.php(888): Symfony\Component\Console\Command\Command->run(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#8 /opt/atlassian/pipelines/agent/build/vendor/symfony/console/Application.php(224): Symfony\Component\Console\Application->doRunCommand(Object(Migrations\Command\Migrate), Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#9 /opt/atlassian/pipelines/agent/build/vendor/symfony/console/Application.php(125): Symfony\Component\Console\Application->doRun(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#10 /opt/atlassian/pipelines/agent/build/vendor/cakephp/migrations/src/Shell/MigrationsShell.php(101): Symfony\Component\Console\Application->run(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#11 /opt/atlassian/pipelines/agent/build/vendor/cakephp/cakephp/src/Console/Shell.php(508): Migrations\Shell\MigrationsShell->main('migrations', 'migrate')
#12 /opt/atlassian/pipelines/agent/build/vendor/cakephp/migrations/src/Shell/MigrationsShell.php(156): Cake\Console\Shell->runCommand(Array, true, Array)
#13 /opt/atlassian/pipelines/agent/build/vendor/cakephp/cakephp/src/Console/CommandRunner.php(141): Migrations\Shell\MigrationsShell->runCommand(Array, true)
#14 /opt/atlassian/pipelines/agent/build/bin/cake.php(12): Cake\Console\CommandRunner->run(Array)
#15 {main}
script:
	- composer install --no-interaction
	- chmod +x bin/cake
	- chmod -R 777 tmp/ logs/
	- bin/cake migrations migrate
	- composer test

bitbucket-pipelines.yml に記載した実行スクリプトに関してはこれだけなのだが、マイグレーションのところで、トピックブランチでは通って、マスターブランチでは通らず、なんてことがぼちぼち起きた。

Bitbucket Pipelines 上で MySQL のログが見えるので、これを確認すると、通らなかったときには、通ったときのログが途中できれているようなものだった。

Initializing database
...(中略)...

2017-10-16T01:57:41.831583Z 0 [Note] End of list of non-natively partitioned tables

成功した時はこんな感じの最終行になる。

2017-10-16T01:51:55.506134Z 0 [Note] InnoDB: Starting shutdown...
2017-10-16T01:51:55.606342Z 0 [Note] InnoDB: Dumping buffer pool(s) to /var/lib/mysql/ib_buffer_pool
2017-10-16T01:51:55.606688Z 0 [Note] InnoDB: Buffer pool(s) dump completed at 171016  1:51:55

つまり MySQL コンテナが何かしらの初期化処理をしていて、にも関わらず繋ごうとしたから接続できないエラー?そうして Bitbucket Pipelines がエラーを認識して停止。ふむ、納得出来る気がする。

アプリケーション側で接続時のタイムアウト設定

とりあえず思いつくスマートな方法はこれ。

このプロジェクトは CakePHP3 を使っているが CakePHP3 には接続時のタイムアウト設定な項目はないらしい。かなしい。とはいえ何かあるだろうとソースを読み進めると 最終的に PDO を使っているらしいことがわかる。ので PDO の設定方法を調べてみると PDO::ATTR_TIMEOUT というオプションを設定するとよいらしい。

php - Setting a connect timeout with PDO - Stack Overflow

このオプションを CakePHP3 で設定するには、コンフィグ内の flags にいれると出来そうなソースをしている。

cakephp/PDODriverTrait.php at master · cakephp/cakephp

$connection = new PDO(
	$dsn,
	$config['username'],
	$config['password'],
	$config['flags']
);

しかし入れても状況は改善されなかったので、違うらしい。。これはまたそのうち調べよう。。。

bitbucket-pipelines.yml に sleep を入れる

というわけでこっちの方法。yml (の抜粋)はこんな感じ。

script:
	- composer install --no-interaction
	- chmod +x bin/cake
	- chmod -R 777 tmp/ logs/
	- sleep 5
	- bin/cake migrations migrate
	- composer test

これで安定的に動いてくれているので、このままでいいか。

MySQL コンテナの初期化処理

そもそもとして MySQL コンテナの初期化処理って何をしているんだろうか。Dockerfile はここ。どうやら docker-entrypoint.sh が何かしていそうだ。

mysql/Dockerfile at master · docker-library/mysql
mysql/docker-entrypoint.sh at master · docker-library/mysql

すごいざっくり読むとこんなことをしているっぽい。

  • Docker イメージが提供するのは mysqld の準備~起動、 docker-entrypoint.sh の起動まで
  • docker-entrypoint.sh によって初回起動によるデータファイル構築。設定したデータベース、ユーザの準備。ちなみに途中で mysqld の再起動をしている。

なるほど理解。
やっぱりコンテナが立ち上がったと同時に初期化処理が走って、それが終わるまでは接続が出来ないようだ。

ものすごいバッドプラクティスを感じている sleep 5 なのでなんとかしたいものの、どうこうするのがいいのかイマイチわからないので、知見あるひと教えてほしス