Github Actionsでbranch作成/削除にフックしてFeature環境を構築する

最近Github Actionsを触る機会があったのですが、まだ自分のgithub accountはbetaのwait list待ちで業務で使ってるrepostioryでしか使えないので、使い方とかポイントを忘れないようにメモ。ついでにいくつか公式のactionにPR送ったり、KubernetesIngress Rulesを編集するためのActionを公開していたりもするので、こちらも紹介します。

Feature環境の自動作成

業務では開発中の機能を手軽にDev環境で確認するために、特定の命名規則に従ったブランチ名でGithubにpushすると、自動でdev環境のKubernetesクラスターにリリースしIngressでエンドポイントを用意して閲覧できるようにしています。これ自体はそんなに珍しくなくて検索するといくつか同じような記事が見つかります。

これまでもSpeeeさんの事例のようにwebhook eventを監視してoperationを行うサーバーを用意して解決することはできました。弊社ではCircle CIなどでfeature環境を作成したりもしています。 ただSpeeeさんの事例では自分たちでサーバーを用意して運用しないといけません。また弊社がこれまでやっていたようにCIサービスでfeature環境を作成する場合にはbranchの削除にトリガーできません。社内の別のチームではbotを立ててbranchの削除を監視してたりもしたみたいですが、これだけのためにbotたてるのも少し手間になります。

GithubのあらゆるイベントにトリガーできるGithub Actionsを使えば、branchがpushされたときにfeature環境を作成し、branchが削除されたときにfeature環境を削除するといったオペレーションを、自分たちでサーバーを管理することなく実現できます。業務ではKubernetesを使っているので、全体像としては次のような感じになります。

f:id:nwpct1:20190222231923j:plain
ざっくりした全体像。実際にはmicroserviceが30以上あり、それらのtraffic routingまではコントロールできる環境を用意できていないので、webのツールや動画配信サーバー(HLS playlist, MPEG-DASH manifest, MPEG-2 TSやFragmented MP4といったメディアセグメントを提供)など一部のコンポーネントだけがこの機能を有効にしています。

  1. feature-abc のように feature-*命名規則に従ってbranchを作成しGithubにPush
  2. Kubernetes Deploymentをbranch用に作成
  3. Kubernetes Serviceをfeatureブランチ用に新規で作成 1
  4. Ingress (ingress-gce) でエンドポイント作成 2
  5. Google Cloud DNSのRecordsetsの作成
  6. https://feature-abc-webapp.foo.com でアクセスして動作確認

Github Actions

基本的な使い方は公式ドキュメントをみてください。

https://developer.github.com/actions/

いくつか悩んだり調べた中でメモしておきたいポイントを中心に残します。

credential情報の管理

外部に漏れては困る情報は Secret によりGithub RepositoryのSettingsで指定できます (参照 https://developer.t.com/actions/creating-workflows/storing-secrets/)。 Actionsを追加する際にも「Secret」というフィールドがありますが、そこから指定してもやってることは同じです。

実は1月頃に一度Github Actionsの利用を検討したことがあったのですが、当時はまだLimited Public Beta期間中でProduction Secretsを保存してはいけませんでした。 今回はLimited Public Betaがとれたため、改めてGithub Actionsを調査することにしました。

f:id:nwpct1:20190222164218p:plain
github-action-secrets

ブランチ名のフィルター

pushイベントに対してすべてトリガーしてほしいわけではなく、特定の命名規則に従ったbranchでのみ実行してほしいものです。GITHUB_REFS という環境変数の中に refs/head/feature-A のような形式でブランチ名やタグ名が入っています。 refs/head/のprefixを削除して利用すればOKです。公式で用意されている↓のactionがこの操作をしてくれているのでこちらを利用しましょう。

bin/filter at master · actions/bin · GitHub

f:id:nwpct1:20190222164042p:plain
github-actions-filter

ただbranch削除時のfilterにはこの方法が使えません。 delete triggerは GITHUB_REF にdefault branchつまりmasterを指定が指定されています。環境変数からbranch名を取り出すことはできません。そのかわり GITHUB_EVENT_PATH 環境変数が示す場所にWebhookのevent情報がそのままjson形式で入っています。

delete でtriggerしたときは DeleteEvent の形式なので、 ref フィールドよりブランチ名が取り出せます。公式で用意してほしい機能なので↓にPRをだしました。

github.com

まだマージされていないので c-bata/bin/filter@master を指定して使っています。 deleted_branch feature-* のようにargsを指定すれば使えます。

マージされたので公式の actions/bin/filter@master を使用してください。そちらには deleted_tag フィルターも追加しています。

f:id:nwpct1:20190227010255p:plain
c-bata/actions/filter deleted_branch feature-*

GCPのService Accountからgcloudの認証を行う

f:id:nwpct1:20190227010437p:plain
Activate gcloud command using service-account

公式で用意されている↓のactionを用いることで実現できます。Service AccountはSecret GCLOUD_AUTHbase64 encodeしたservice accountのjsonファイルを与えればOKです (ex: base64 ./service-account.json )。

gcloud/auth at master · actions/gcloud · GitHub

少し驚いたのですがgcloudコマンドの実行は別のactionとして定義し、↓を利用して実行します。

gcloud/cli at master · actions/gcloud · GitHub

gcloudコマンドのcredential情報は、Homeディレクトリ以下に作成されます。Github Actionsは裏側で /github/workspace を常にマウントしそこをHomeディレクトリに設定しているようです。このディレクトリは次のactionでもそのままの状態で引き継がれます。gcloudの認証とgcloudコマンドの実行は別のactionでやるのがGithub Actionらしいやり方なようです。

kubectlの実行

gcloud authができるようになったので、kubernetes clusterのcredentials情報を取得してkubectlを実行します。既存でよさそうなものがなかったのですが、https://github.com/actions/gcloud で管理されるのがみんな幸せかと思うので PRを出しました。

Add github action for kubectl by c-bata · Pull Request #9 · actions/gcloud · GitHub

gcloudコマンドにならって、PROJECT_IDやZONE、K8S clusterをセットするactionとkubectlの実行用actionを分割しました。まだマージはされていないので c-bata/gcloud/kubectl-config@master および c-bata/gcloud/kubectl@master を指定して使っています。

f:id:nwpct1:20190227010829p:plain

ingress rules書き換えツールの実行

deploymentsやserviceをfeature環境ごとに個別に作っていたようにingressもfeature環境ごとにつくることもできるのですが、大きいチームだったので大量にFeature環境が立ち上がりLoadbalancerの作成上限に引っかかったことがありました。そのため全てのfeature環境で1つのingressを使いまわし、Spec.Rulesに振り分け設定を追加して webapp.foo.comfeature-a-webapp.foo.com を振り分けています。管理の都合上もその方がいいかなと思います。IngressのSpec.Rulesの編集にはもともとnodeで書かれたscriptが社内で使われていたのですが、kubectlのwrapperになっていてclient-goが使えるGoで書いたほうが色々楽だったので今回書き直しました。↓で公開しています。

GitHub - abema/github-actions-ingress-rules-editor: Edit ingress rules to build feature environments automatically on Github Actions.

c-bata/go-actions

github actionsの調査もかねてutilityライブラリ作りました。 正直使うほどでもないシーンが多いと思いますが、よければ使ってみてください。

GitHub - c-bata/go-actions: go-actions provides the utilities for Github Actions.

面倒だったこと

branchを削除したときにはmasterブランチのmain.workflowが参照され、実行されます。そのためbranchの削除にtriggerして何らかの処理を行いたいとき、一度そのbranchをmasterにマージして削除しないと動作確認ができません。

Add deleted_branch and deleted_tag filters by c-bata · Pull Request #42 · actions/bin · GitHub みたいな機能はとりあえず書いてmasterにマージしてbranchを削除して、問題があればまたbranchを作ってmasterにマージしてbranchを削除しないと確認できずmasterのcommit logが結構汚れます。仕事のrepositoryでそれをやることになったので申し訳ないなと思いながら開発してました。

おわりに

はやく自分のrepositoryでも使ってみたい


  1. 執筆時点では ingress-gce がClusterIPへのヒモ付に対応してないのでServiceTypeはNodePortを使用しています。NodePortの番号は特に指定していないのでKubernetes側にrandomに割り振ってもらっています。

  2. 次の手順でGoogle Cloud DNS Recordsetsを作りますが、もしそちらをterraformで管理して消し忘れとかをなくしたいのであれば、Google compute address(静的IPアドレス)の払い出しもTerraformで行って、Ingress側の metadata.annotations.kubernetes.io/ingress.global-static-ip-name で指定して使うのが管理の都合上いいかと思います。

RAW Socket / BPF(Berkeley Packet Filter)を用いたパケットキャプチャーツールの実装

パケットキャプチャーツールは、ネットワークを流れるすべてのパケットを受け取り解析します。 NIC(Network Interface Card)のほとんどはプロミスキャスモードとよばれるモードをサポートしており、これを有効にすることでアドレスにかかわらずNICはすべてのパケットをホストに渡します。 ソフトウェアとハードウェアが連携して動作するため、扱っているレイヤーが低く環境によってInterfaceに差異があります。

tcpdumpの開発者によってつくられたlibpcapというライブラリはUNIXのシステムの差異を吸収します。またWindowsにもWinPcapという名前で移植されています。 もしパケットキャプチャーを作る際にはlibpcapを利用することが一般的かと思いますが、今回は勉強も兼ねて LinuxmacOS で動作するパケットキャプチャーをlibpcapを使わずに1からC言語で実装してみました。

※ BPF VM(Berkeley Packet Filter Virtual Machine)によるFilteringの仕組みには今回は触れません。

f:id:nwpct1:20181028135417g:plain

目次

xpcap のソースコード(Github)

github.com

最近作りたいなと思っているパケットキャプチャー関連のソフトわがありそちらはGo言語で実装しているのですが、せっかくなら移植性を考えてPure Goで実装したいと思っています。こういったレイヤーのプログラムをいきなりCのAPIがベースにあってそれをGoで書くとドキュメントを追うのも大変なので、C言語でまずは書いてみたものがこちらです。

プロトコルは今のところARPIPv4IPv6TCPUDP、ICMPに対応していて、Ethernet Frameからパースした結果を標準出力に書き出します。

RAW SOCKETを用いたキャプチャー (Linux)

LinuxMACアドレスEthernet Frameのヘッダー情報までプログラムで扱うには、RAW Socketが必要です。 ソケットディスクリプタを取得する際には、アドレスファミリーとして AF_PACKET 、ソケットタイプとして SOCK_RAW そして第3引数のprotocolには htons(EATH_P_ALL) を指定します。全部を説明すると長くなるので手順と呼び出さないといけない関数を次に示します。 xpcapのソースコードと合わせてご覧ください。

  1. socket() ディスクリプタの取得
    • int soc = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)))
  2. en0 などのインターフェイス名を指定してインターフェイスの情報を取得
    • ioctl(soc, SIOCGIFINDEX, &if_req)
  3. ソケットディスクリプターをインターフェイスにバインド
    • bind(soc, (struct sockaddr *) &sa, sizeof(sa))
  4. インターフェイスのフラグを取得
    • ioctl(soc, SIOCGIFFLAGS, &if_req)
  5. プロミスキャスモードを有効にし、インターフェイスをUP(動作中)にする
    • ioctl(soc, SIOCSIFFLAGS, &if_req)

これで準備が完了です。あとは selectepoll でソケットディスクリプターへの書き込みを監視しready担った状態で recv(2) で読み出せばOKです。

struct timeval timeout;
fd_set mask;
int width, len, ready;
while (g_gotsig == 0) {
    FD_ZERO(&mask);
    FD_SET(soc, &mask);
    width = doc + 1;

    timeout.tv_sec = 8;
    timeout.tv_usec = 0;
    ready = select(width, &mask, NULL, NULL, &timeout);
    if (ready == -1) {
        perror("select");
        break;
    } else if (ready == 0) {
        fprintf(stderr, "select timeout");
        break;
    }

    if (FD_ISSET(sniffer->fd, &mask)){
        if ((len = recv(soc, buffer, >buf_len, 0)) == -1){
            perror("recv:");
            return -1;
        }
    }
}

自分は Linuxネットワークプログラミングバイブル で勉強しましたが、この書籍以外にもLinuxで動くRAW SOCKETを使ったシンプルなパケットキャプチャーの作り方を解説している資料は多くあります。一方でBSD系のOSではアドレスファミリーとして AF_PACKET を指定できません。BSD系のOSでEthernet frameを読み出す方法を確認しましょう。

BPF(Berkeley Packet Filter)によるキャプチャー (macOS, BSD系)

これらはBPF(Berkeley Packet Filter)という仕組みを使う必要があります。 BPFにはBPF Virtual Machineという仕組みを使ってパケットをKernel側でフィルタリングすることで必要ないものまでユーザー空間に移さずオーバーヘッドを減らす仕組みのようです。読み出しには BPFデバイスというのを用います。ひとまずすべてキャプチャーするならBPF VMについては気にする必要はありません。

BPFデバイスは、 /dev/bpf* に存在します。これらを順にopenしながら、使用可能なBPFデバイスを探さなくてはいけません。

$ ls /dev/bpf?
/dev/bpf0 /dev/bpf1 /dev/bpf2 /dev/bpf3 /dev/bpf4 /dev/bpf5 /dev/bpf6 /dev/bpf7 /dev/bpf8 /dev/bpf9

手元では bpf255 ぐらいまで存在しますが、google/gopacketなどの実装では99までチェックしているようです。 NICの数以上に必要になるケースはほとんどなさそうなので99個は十分に余裕を持った値なんだと思います。

gopacket/bsd_bpf_sniffer.go at a35e09f9f224786863ce609de910bc82fc4d4faf · google/gopacket · GitHub

BPFデバイスが決まったらOpenします。その後次のような手順が準備に必要になります。

  1. bpfデバイスのopen
    • fd = open(params.device, O_RDWR)
  2. バッファ長の設定 or 取得。BIOCSBLEN の変更は、BPFデバイスNICアサインする BIOCSETIF より先に呼び出される必要があるので注意してください。これになかなか気づかず結構はまってしまいました。
    • ioctl(fd, BIOCSBLEN, &params.buf_len) : 設定
    • ioctl(fd, BIOCGBLEN, &params.buf_len) : 取得
  3. BPFデバイスとネットワークインターフェイスをバインド
    • ioctl(fd, BIOCSETIF, &if_req)
  4. プロミスキャスモードの有効化
    • ioctl(fd, BIOCPROMISC, NULL)

こちらはデバイスファイルなので recv(2) ではなく read(2) で読み出します。 読み出すとイーサネットのフレームではなくBPFパケットというものにくるまれています。 ヘッダーをパースするとデータ長が乗っているため、それをもとに次のBPFパケットの位置を求めてパースを繰り返していきます。

typedef struct {
    int fd;
    char device[11];
    unsigned int buf_len;
    char *buffer;
    unsigned int last_read_len;
    unsigned int read_bytes_consumed;
} Sniffer;

int
parse_bpf_packets(Sniffer *sniffer, CapturedInfo *info)
{
    if (sniffer->read_bytes_consumed + sizeof(sniffer->buffer) >= sniffer->last_read_len) {
        return 0;
    }

    info->bpf_hdr = (struct bpf_hdr*)((long)sniffer->buffer + (long)sniffer->read_bytes_consumed);
    info->data = sniffer->buffer + (long)sniffer->read_bytes_consumed + info->bpf_hdr->bh_hdrlen;
    sniffer->read_bytes_consumed += BPF_WORDALIGN(info->bpf_hdr->bh_hdrlen + info->bpf_hdr->bh_caplen);
    return info->bpf_hdr->bh_datalen;
}

あとはごりごりパースしていくのですが、そこはプラットフォームに変わらず同じです。 ゴリゴリ実装していくだけで解説してもしかたないのでマスタリングTCP/IPなどを頼りにソースコードを読んでみてください。

実行方法

build.sh でビルドできます。Vagrantfileも用意しているのでLinuxで試したいmacOSユーザーの方はご利用ください。実行結果は次のような感じです。

$ ./build.sh
$ ./xpcap en0 -v
device = en0, verbose = 1, port = 0

================================================================================
[TCP6]
ether_header--------------------------------------------------------------------
ether_dhost = XX:XX:XX:XX:XX:XX
ether_shost = XX:XX:XX:XX:XX:XX
ether_type = 86DD(IPv6)
ip6-----------------------------------------------------------------------------
ip6_vfc = 96
ip6_flow = 2363892320
ip6_plen = 15104
(TCP), ip6_hlim = 56
ip6_src = xxxx:xxxx:xxxx:x::xxxx:xxxx
ip6_dst = yyyy:yy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy
tcphdr--------------------------------------------------------------------------
source: 47873
destination: 59083
sequence number: 1148644729
ack number = 2897299570
data offset = 5, control flag = 24, window = 49152, checksum = 54057, urgent pointer = 0
data----------------------------------------------------------------------------
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ..something..data..
================================================================================

================================================================================
[ARP]
ether_header--------------------------------------------------------------------
ether_dhost = XX:XX:XX:XX:XX:XX
ether_shost = XX:XX:XX:XX:XX:XX
ether_type = 806(Address resolution)
ether_arp-----------------------------------------------------------------------
arp_hrd = 1(Ethernet 10/100Mbps.), arp_pro = 2048(IP)
arp_hln = 6, arp_pln = 4, arp_op = 1(ARP request.)
arp_sha = 34:76:C5:77:5D:4C
arp_spa = 192.168.0.1
arp_tha = 00:00:00:00:00:00
arp_tpa = 192.168.0.8
================================================================================

================================================================================
[UDP]
ether_header--------------------------------------------------------------------
ether_dhost = XX:XX:XX:XX:XX:XX
ether_shost = XX:XX:XX:XX:XX:XX
ether_type = 800(IP)
ip------------------------------------------------------------------------------
ip_v = 4, ip_hl = 5, ip_tos = 0, ip_len = 149
ip_id = 29282, ip_off = 0, 0
ip_ttl = 255, ip_p = 17(UDP), ip_sum = 42831
ip_src = yyy.yyy.yyy.yyy
ip_dst = xxx.xxx.xxx.xxx
udphdr--------------------------------------------------------------------------
source = 5353, dest = 5353
len = 129, check = 38825
data----------------------------------------------------------------------------
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ..something..data..
================================================================================

参考ソースコード

困ったときは次のコードが参考になりました。

またLinuxネットワークプログラミングバイブルはかなりおすすめの書籍です。

Linuxネットワークプログラミングバイブル

Linuxネットワークプログラミングバイブル

実践 パケット解析 第3版 ―Wiresharkを使ったトラブルシューティング

実践 パケット解析 第3版 ―Wiresharkを使ったトラブルシューティング

Google Translate APIを使ったSphinxドキュメントの自動翻訳

多言語への翻訳は大変な作業ですが、近年は機械翻訳の精度も上がってきました。 ふと思いついて .po 形式の翻訳ファイルをGoogle翻訳を通して自動で入力するスクリプト を作ったのですが、サクッと書いた割に予想以上に便利で料金も思ったより安かったので記事にしました。また実際に自分が公開している日本語で書かれたSphinxの資料を、このスクリプトを使って英語に翻訳してみます。

f:id:nwpct1:20181029112031p:plain

追記: ライセンスについて

id:beatdjam さんのコメントが気になったので共有です。 自分もGoogle Translate APIのドキュメントを読み返してみますが、利用される方も確認してからご利用ください。

以前こういった事例もあったので、OSSで利用することを推奨して良いのか心配。ドキュメントだけなら平気なのかな https://anond.hatelabo.jp/20170225195916

作ったもの

.po 形式の翻訳ファイルをパースし好きな言語にGoogle Translate APIを用いて翻訳するスクリプトを用意しました。 実行には google-cloud-translate とGCP service accountが必要です。

$ pip install --upgrade google-cloud-translate
$ export GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account-credential.json
$ python translate_po.py --help
usage: translate_po.py [-h] [--lang LANG] [--currency CURRENCY] filepath

positional arguments:
  filepath

optional arguments:
  -h, --help           show this help message and exit
  --lang LANG          target language (default: "ja")
  --currency CURRENCY  dollar per your currency. (default currency is yen: 111.90)

現状はとりあえずファイルの上書きオプションなどは用意せず、stdoutに書き出すようにしています。 Google Translate APIは、100万文字あたり20ドルかかります。 本一冊とかになると数百円かそれ以上かかりそうですが、手元の文章を翻訳したいなどの用途なら数十円に収まることがほとんどです。 ちなみにマルチバイト文字でも1文字は1文字としてカウントしてくれるようなので、日本語から英語の翻訳などは比較的お得です。 Google Translate APIに投げたテキストの文字数からかかった金額も算出し表示するようにしています。

$ python translate_po.py ./po/index.po 1>./po/index_ja.po
Cost: 2.1417659999999996 yen

また翻訳結果はキャッシュしていて、実行したディレクトリの直下に json ファイルを書き出します。 なので2回目の実行は、キャッシュが効きお金を節約できます。

$ python translate_po.py ./po/index.po 1>./po/index_ja.po
Cost: 0 yen

ソースコードGithubで公開しています。

github.com

実際に翻訳してみる

Webアプリケーションフレームワークの作り方 in Python — c-bata.link (Githubはこちら) はSphinxで書かれた日本語の資料です。 今回はこちらを英語に翻訳していきます。Sphinxのドキュメントの国際化の方法は次のページに非常によくまとまっています。

まず sphinx-intl をインストールします。

$ pip install sphinx-intl
$ vim source/conf.py
# add following settings
# locale_dirs = ['locale/']
# gettext_compact = False
$ make gettext
$ ls build/locale/
index.pot      kobin.pot      middleware.pot request.pot    response.pot   routing.pot    server.pot     sphinx.pot     template.pot   wsgi.pot

potファイルができました。今回は日本語から英語に翻訳するので、次のようにします。

$ sphinx-intl update -p build/locale -l ja
Create: source/locale/ja/LC_MESSAGES/kobin.po
Create: source/locale/ja/LC_MESSAGES/template.po
Create: source/locale/ja/LC_MESSAGES/middleware.po
Create: source/locale/ja/LC_MESSAGES/sphinx.po
Create: source/locale/ja/LC_MESSAGES/request.po
Create: source/locale/ja/LC_MESSAGES/routing.po
Create: source/locale/ja/LC_MESSAGES/wsgi.po
Create: source/locale/ja/LC_MESSAGES/response.po
Create: source/locale/ja/LC_MESSAGES/index.po
Create: source/locale/ja/LC_MESSAGES/server.po

poファイルが出来上がったら変換をかけます。 このスクリプトは今のところ上書き用のオプションを用意していないので、一度stdoutをファイルに書き出して置き換える必要があります。 いくつかファイルがあるので変換用のスクリプトを用意しました。

$ cat > translate.sh <<EOF
#!/bin/bash
 function translate {
  for f in ./ja/LC_MESSAGES/*.po; do
    python translate_po.py --lang en $f 1>${f%.po}_en.po
    mv ${f%.po}_en.po $f;
  done; 
}
 translate
EOF
$ chmod +x ./translate.sh
$ ./translate.sh
Cost: 3.6904620000000006 yen
Cost: 0.024617999999999998 yen
Cost: 1.60017 yen
Cost: 4.728894 yen
Cost: 3.8784539999999996 yen
Cost: 5.8188 yen
Cost: 1.087668 yen
Cost: 1.4009880000000001 yen
Cost: 7.00494 yen

トータル30円くらいかかりました。翻訳精度を考えるとすごくお得に感じます。 最後はこれをbuildしてみましょう。

$ make -e SPHINXOPTS="-D language='ja'" html
$ open build/html/index.html

結果は次のような感じです。

f:id:nwpct1:20181028231948p:plain

f:id:nwpct1:20181028232035p:plain

f:id:nwpct1:20181028232044p:plain

reSTのリンクが壊れたり、いくつか変な文字が混ざっていたりはしますが予想以上にそれっぽくなりました。

エキスパートPythonプログラミング 改訂2版 (アスキードワンゴ)

エキスパートPythonプログラミング 改訂2版 (アスキードワンゴ)