読者です 読者をやめる 読者になる 読者になる

S.F. Page

Programming,Music,etc...

GitHubのwebhookを使ってnginxでホストしているWebサイトの自動更新デプロイコードをnodeで書く。

静的ブログ・ジェネレータで生成したコンテンツのWebサーバへの更新方法を考えていた。今まではscpを使って行っていた。それで運用上は問題ないのだけれども、ソースコードの管理はgitで行っているから、更新自体もgitで行えないかなと考えた。何かあってもコンテンツを戻すのも簡単そうだしね。

どうやるか

いろいろ調べたが、post-receive使って、リモート・レポジトリのpushに連動してコンテンツ・ディレクトリにpullすればやりたいことができるということがわかった。

qiita.com

さらにGitHubにはwebhookという、GitHubのレポジトリにpushすると、指定したWebサーバーに更新情報をpostするという機能がある。

blog.wadackel.me

これを使えば、WWWサーバーでgit pullするだけでよくなり、gitサーバーは不要である。この方式で行くことにした。図にすると以下となる。

私の環境での実現方法

私のWebサーバーの環境にあてはめて、実現方法を考えた結果を図にすると以下のとおりである。

GitHubからのwebhookをnginxで受けて、unix domain socketで待ち受けているnode.jsのサーバーに転送する。node.jsはその情報を受けてGitHubからgit pullでコンテンツを更新する。更新したら、前回のコミットからどのファイルが更新されたかをgit diffで調べ、更新されたファイルをgzipで圧縮する。gzipで圧縮するのはnginxのgzip_staticを使っているからである。 node.jsで直接GitHubからのリクエストを受けた方がよい気もするが、あえてnginxを介すようにしている。unix domain socketが使えるかどうかも試してみたかったので。。

1.push

これはGitHubレポジトリに普通にpushするだけ。

2.webhook

webhookしたいレポジトリで設定をする。

https://bp7ruq-bn1305.files.1drv.com/y3m_AU5GN7WO1w6LEsx1NFTE54bZh-tjwq3gACdjkw2FwWu-HWKNQBR6NM9oitpRIswDbTkYtjDFH1fR8ZlJqUWtCY9avcAgHSJtqmMUYL_tw23vu4XsYYyoSoQT6-NH0k6fTmpF-RRnWLQt4RUF3as0w?width=824&height=658&cropmode=none

Payload URLはデータ送信先のWebサーバーのURL、Content Typeはデータの形式、Secretはパスワード、Which events would you like to trigger this webhook ?はどのようなイベントを送るかを設定して保存する。

どのようなイベントが送信されるのかは以下のリンクに載っている。

Webhooks | GitHub Developer Guide

Secretは受信側で指定した値を検査して、異なれば処理しないようにすることでセキュリティを担保するものだ。私はhttpsで送信するようにして、Secretが盗聴されないようにしておいた。

3.Proxy転送

GitHubからのwebhookはまずnginxでhttpsで受け、webhookを処理するnodeへhttpでproxy転送する。その設定は以下のとおりである。

# nginx.confのhttpサーバーセクションに以下を追加
upstream webhook {
  server unix:/tmp/webhook.sock;
}

# nginx.conf serverセクションに以下を追加
location /webhook/ {
  proxy_pass http://webhook/;
}

4.pull

5.git diff

6.コンテンツの圧縮版の作成

4.,5.,6.の処理はnodeで書いた。

unix domain socketでリクエストを待ち受け、webhookを受け取ったら必要な処理を行う。 webhookからイベントを受け取る部分はライブラリを使用した。

github.com

コードは以下のとおりである。上記ライブラリのサンプルをベースにしている。

"use strict";

var http = require('http');
var fs = require('fs');
var zlib = require('zlib');
var exec_ = require('child_process').exec;
var createHandler = require('github-webhook-handler');
// シークレットの読み込み
var secret = fs.readFileSync('./secret','utf-8').trim();
var handler = createHandler({ path: '/', secret: secret});
var sockPath = '/tmp/webhook.sock';

// unixドメインソケットの削除。削除しておかないとエラーになる。
try {
  fs.unlinkSync(sockPath);
} catch(e) {

}

// nodeの非同期関数をpromiseを返すものに変更する
function denodeify(nodeFunc){
    var baseArgs = Array.prototype.slice.call(arguments, 1);
    return function() {
        var nodeArgs = baseArgs.concat(Array.prototype.slice.call(arguments));
        return new Promise((resolve, reject) => {
            nodeArgs.push((error, data) => {
                if (error) {
                    reject(error);
                } else if (arguments.length > 2) {
                    resolve(Array.prototype.slice.call(arguments, 1));
                } else {
                    resolve(data);
                }
            });
            nodeFunc.apply(null, nodeArgs);
        });
    }
}

// gzip圧縮
function compressGzip(path) {
    // gzipファイルを作成する
    return new Promise((resolve,reject)=>{
      let out = fs.createWriteStream(path + '.gz');
      out.on('finish', resolve.bind(null));

      fs.createReadStream(path)
        .pipe(zlib.createGzip({ level: zlib.Z_BEST_COMPRESSION }))
        .pipe(out);
    });
}

var exec = denodeify(exec_);

// イベントの待ち受け
http.createServer(function (req, res) {
  handler(req, res, function (err) {
    res.statusCode = 404;
    res.end('no such location');
  })
}).listen(sockPath);

// nginxからunix domain socketにアクセスできるようにパーミッションを変更する
exec_('/bin/chown (ユーザー):(nginxとユーザーが含まれるグループ) ' + sockPath);

// エラー処理
handler.on('error', function (err) {
  console.error('Error:', err.message);
});

// push eventの処理
handler.on('push', function (event) {
  // githubからの更新を受け取る
  let homeDir = '/var/www/html/';
  let opt = {cwd:'/var/www/html'};
  // コンテンツの更新
  if(event.payload.repository.name === 'www'){
    exec('/usr/bin/git pull origin master',opt)
    .then((stdout,stderr)=>{
      // git diffをとって変更のあったファイル一覧を取得する
      // event.payload.afterに最新のコミットID,event.payload.beforeに前回push時のコミットIDが入っている
      // ので差分をとれば変更したファイルがわかる
      return exec(`/usr/bin/git diff --name-only ${event.payload.after} ${event.payload.before}`,opt);
    })
    .then((stdout,stderr)=>{
      // 変更のあったファイルをgzip圧縮する
      let files = stdout.split(/\n/);
      let pr = Promise.resolve(0);
      files.forEach((d,i)=>{
        let path  = d.trim();
        if(path.length > 0){
          pr = pr
            .then(compressGzip.bind(null,homeDir + path))
            .then(exec.bind(null,'/bin/chown (ユーザー):(nginxとユーザーが含まれるグループ)' + homeDir + path))
            .then(exec.bind(null,'/bin/chown (ユーザー):(nginxとユーザーが含まれるグループ)' + homeDir + path + '.gz'));

        }
      });
      return pr;
    })
    .then(()=>{
      // gc起動
      if(global.gc) {
        global.gc();
      }
    })
    .catch((e)=>{console.log(`Error:${e}`);});
  }
});

// gc起動
if(global.gc) {
  global.gc();
}

工夫した点は、unix domain socketの部分と、変更したファイルのみgzip圧縮するようにしたところだろうか。

上記スクリプトをpm2で起動し、常駐させる。

pm2 start webhook.js --node-args="--expose_gc"

メモリ肥大を防ぐためにgcをいじれるようにオプションをつけ起動し、コード内で適当なタイミングでgcを起動するようにしておいた。

今のところ意図したとおりに動いている。