GoのバイナリをデプロイするためにGitHub ReleaseからDLするツールをGoで書いた

GoのバイナリをデプロイするためにGitHub Enterprise(以下、GHE)のReleaseにアップロードしておいたバイナリをダウンロードするツールをGoで書いた話をする。

背景

複数の開発が並行していてGitHubのブランチと本番・ステージ環境が対応付けできなかったり、一方はオンプレで一方はAWSでといった環境の違いによるネットワーク的な問題から、CIでテスト・ビルドまでは自動化できた一方でデプロイまでは自動化できなかった。このため、CIでのビルドで作成したGoのバイナリをGHEのReleaseにアップロードした後に、手動でデプロイ(本番環境からGHEのReleaseに登録されたバイナリをダウンロード)することにした。

ここで、GHEのReleaseからバイナリをダウンロードするツールが必要になったので最初はシェルスクリプトで書こうとしたが、GitHub APIを叩く必要があるためJSONの取り扱いをシェルスクリプトで行うのが面倒だと感じた。jqコマンドを使用すればいいことはわかっていたが、全てのサーバにjqコマンドをインストールしているわけではなかった。jqコマンドをインストールすればいいのだが、この時点でサーバのConfigurationが自動化されていなかったため、jqコマンドがインストールされていることを保証できなかった。

このため、単にGHEのReleaseからバイナリをダウンロードするだけなのだが、このためのツールをGoで書くことにした。一つのバイナリだけで動作するのが使い勝手がいいと思ったので。

比較

自分で作らずとも既にあるツールを使えばいいとも思ったが、いろいろと探してはみたがタグを目印にGitHub Releaseからバイナリをダウンロードするツールがなかった(見つけられなかった)。そもそもこのようなデプロイをする人はあまりいないだろうから誰も作っていないのかな。このため、自分でツールを作ることにした。

話は逸れるが、逆に、バイナリを作成してGitHubのReleaseにアップロードするためのツールはいくつか見つけた。

github.com

github.com

ghrd

作成したツールはこれ。実際には仕事で書いたものを一般的に使えるように一から書き直したので、背景で書いたツールと中身としては別物であるが。

github.com

使い方は以下の通り、タグを目印にGitHub Releaseから対象をダウンロードする。

ghrd -u [OWNER] -r [REPOSITORY] [TAG]

また、環境変数を指定することでGitHub Enterpriseでも使用できる。

作った後に考えたこと

自分はサーバサイドの開発をメインにしている。だが、今回デプロイ用のツールを書いたようにリリース・インフラにも手を出すことで、普段この辺りを運用しているインフラチームとの仕事が進めやすくなったように感じた。

以前読んだ記事( Full-stack developers ) で、フルスタックエンジニアは現実的ではないがフロントからインフラまでの各チームを横断的に動いて全体の生産性をあげる人が必要とされている(かなり意訳)、と書かれてあった。

まだまだスタート地点に過ぎないが、この記事に書かれてあるようなことを自分としてはやっていきたいと思うようになった。 今後は今回と反対側であるフロントサイド(モバイルを含む)にもチャレンジしていきたいとぼんやりだが考えている。

AWSでアラームの閾値を設定する

GoでLambdaを含むいわゆる一般的なサーバレスな環境(API Gateway <=> Lambda <=> DynamoDB + CloudWatch)を構築した。監視のためCloudWatchでアラームを設定する必要があったのだが、そのアラームでの閾値の考え方がよくわからなかったのでメモする。アラームの設定画面は以下の通りである。

f:id:takayukinakata:20180330194828p:plain

こちらの記事が理解する上で大変参考になった。

AWS CloudWatch Alarmの判定方法

上記の記事でのPeriodが画面の間隔にあたる。また、Evaluation Periodsが画面の期間にあたる。

CloudWatch アラームは、1分毎にその時点から Period に指定された期間を遡り、範囲内のデータポイントから Maximum 等の値を算出するとのこと。各データポイントの最大値が画面の次の時" "がのところで評価された値に少なくとも一つ当てはまればアラームが実行される。

UIの更新はmain threadから行う

iOSからAPIにリクエストを送って返ってきたjsonを画面に表示させたい、というシンプルな実装をしようとしたら詰まったのでメモする。まず、以下のように実装したがエラーが発生した。

 @IBOutlet weak var myTextView: UITextView!
    func makeData() {
        let url = URL(string: "https://api.openweathermap.org/data/2.5/forecast?q=Tokyo,jpn&APPID=xxxxx")!
        let req = URLRequest(url: url)
        let task = URLSession.shared.dataTask(with: req) { (data, res, error) in
            guard let data = data else {
                return
            }
            do {
                let obj = try JSONSerialization.jsonObject(with: data, options: [])
                self.myTextView.text = "\(obj)"
            } catch let e {
                print(e)
            }
        }
        task.resume()
    }

デバッガに表示されたエラーは以下の通り。

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Only run on the main thread!'

main thread

iOSではmain threadという考え方があり、UIの描画や更新などはこのmain threadで行なっている。また、UIの更新はmain threadで行う必要がある(ここではmainではないthreadをbackground threadと呼ぶことにする)。 この辺りに関しては、GCDも含めてIntro to iOS threading.での説明がわかりやすかった。日本語だとmixiの研修資料がよかった。

上記のコードでいうと、URLSessionを使用した通信はbackground threadで行われており、background threadでself.myTextView.text = "\(obj)"のようにUIを更新しようとしたためエラーが発生していた。

では上記のコードでどのようにmain threadでUIを更新するかというと、DispatchQueue.main.syncを利用する。DispatchQueue.main.syncのブロック内に記述されたコードはmain threadで実行される。修正後のコードは以下の通り。

 @IBOutlet weak var myTextView: UITextView!
    func makeData() {
        let url = URL(string: "https://api.openweathermap.org/data/2.5/forecast?q=Tokyo,jpn&APPID=xxxxx")!
        let req = URLRequest(url: url)
        let task = URLSession.shared.dataTask(with: req) { (data, res, error) in
            guard let data = data else {
                return
            }
            do {
                let obj = try JSONSerialization.jsonObject(with: data, options: [])
                DispatchQueue.main.sync {
                    str = "\(obj)"
                }
            } catch let e {
                print(e)
            }
        }
        task.resume()
    }

GitHub Enterpriseからreviewdogを使用できないときに見るメモ

GitHub Enterpriseのバージョンが古い場合、新しいreviewdog( > 0.9.7)が動作しないので0.9.6のreviewdogを試してみるとよい。

GitHub & GitHub Enterprise Support: As of version 0.9.7, reviewdog completely switched to use new Pull Reqeust Review Comments API including GitHub Enterprise. If your GitHub Enterprise version is too low to use this new API, please use reviewdog version < 0.9.7

https://github.com/haya14busa/reviewdog/releases より引用

参考

github.com

RFC2822のフォーマットでテキストを作成するパッケージを書いた

前にgmail APIを叩いた時にRFC2822について調べたが、絶対にフォーマットを忘れる自信があったので簡単なパッケージとしてまとめた。 特にメールのヘッダーに日本語を使いたい時のフォーマットなんて覚えて置ける自信がまるでなかったので。

ブログで記事として残すのもいいが、こんな感じでパッケージとして残しておいた方が後々使いたくなった時に便利かなと思った。今回くらい小さなパッケージであれば、コード書くのも文章書くのもそんなに労力は変わらないし。

github.com

参考

takayukinakata.hatenablog.com

Serverless Frameworkで複数AWSアカウントを切り替えられるようにする

serverless.com

AWSのアクセスキーを登録すると~/.aws/credentialsに情報が登録される。登録方法は以下を参照。

https://serverless.com/framework/docs/providers/aws/guide/credentials#setup-with-serverless-config-credentials-command

通常だと上記リンク先のコマンドで登録した一つのアクセスキーのみ管理することになるが、複数のAWSアカウントに対するアカウントを管理したい場合は以下のように~/.aws/credentialsを書き換える。

https://serverless.com/framework/docs/providers/aws/guide/credentials#use-an-existing-aws-profile

[default]
aws_access_key_id=***************
aws_secret_access_key=***************

[production]
aws_access_key_id=***************
aws_secret_access_key=***************

デプロイ時にproductionのアクセスキーを使用したい場合は以下のように--aws-profileオプションを設定する。

https://serverless.com/framework/docs/providers/aws/guide/credentials#using-the-aws-profile-option

$ serverless deploy --aws-profile production

--aws-profileオプションを指定しなければdefaultのアクセスキーが使用される。

Goでgmail APIを叩いて下書きを作成する

Go Quickstart  |  Gmail API  |  Google Developers

上記を参考にした。サンプルコードは最後に記載する。実装してる途中でメールの題名に日本語を設定できず、RFC2822について調べる必要があったのでメモしておく。

RFC2822

https://www.ietf.org/rfc/rfc2822.txt

電子メールのフォーマットを定めている。Goのgmailパッケージを使用する際にRFC2822のフォーマットでメッセージを記述する必要があった。基本的なフォーマットは以下の通り。

Header1 CRLF
Header2 CRLF
CRLF
Body

ヘッダーでは:で区切られたヘッダーフィールドとヘッダーボディが記載される。一つのヘッダーの終わりにはCRLFがくる。ヘッダーとボディの間にはCRLFだけの行が入る。この行以降は全てボディである。文字数制限などもあるがここでは省略。具体例は以下の通り。

From: xxxatxxx\r\n
To: yyyatyyy\r\n
Subject: test\r\n
\r\n
test

gmailではボディをHTMLで表現できる。この時、ヘッダにはContent-Type: text/html; charset=UTF-8とコンテンツタイプを設定するといい。

また、RFC2822ではUS-ASCIIを前提としている。ボディ部分に関してはMIMEの導入によりContent-Typeを指定することでUS-ASCII以外を指定できるようになったが、通常だとヘッダ部分は依然としてUS-ASCII以外使用できない。ここに関してだが、RFC2047でヘッダのエンコード仕様が定められている。具体的には、

=?charset?encoding?encoded-text?=

の形式でヘッダをエンコードすることができる(ヘッダでUTF-8の文字が使える!)。概要はこちらを参照。

例えば、題名を「テスト」としたい場合、

=?UTF-8?B?44OG44K544OI?=

となる。文字コード(charset)はUTF-8エンコードの形式(encoding)はB(base64)、「テスト」をbase64エンコードした値が44OG44K544OIとなる

これでメールの題名に日本語を設定できるようになる。

サンプルコード

package main

import (
    "encoding/base64"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "net/url"
    "os"
    "os/user"
    "path/filepath"
    "time"

    "golang.org/x/net/context"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
    "google.golang.org/api/gmail/v1"
)

// getClient uses a Context and Config to retrieve a Token
// then generate a Client. It returns the generated Client.
func getClient(ctx context.Context, config *oauth2.Config) *http.Client {
    cacheFile, err := tokenCacheFile()
    if err != nil {
        log.Fatalf("Unable to get path to cached credential file. %v", err)
    }
    tok, err := tokenFromFile(cacheFile)
    if err != nil {
        tok = getTokenFromWeb(config)
        saveToken(cacheFile, tok)
    }
    return config.Client(ctx, tok)
}

// getTokenFromWeb uses Config to request a Token.
// It returns the retrieved Token.
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
    authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
    fmt.Printf("Go to the following link in your browser then type the "+
        "authorization code: \n%v\n", authURL)

    var code string
    if _, err := fmt.Scan(&code); err != nil {
        log.Fatalf("Unable to read authorization code %v", err)
    }

    tok, err := config.Exchange(oauth2.NoContext, code)
    if err != nil {
        log.Fatalf("Unable to retrieve token from web %v", err)
    }
    return tok
}

// tokenCacheFile generates credential file path/filename.
// It returns the generated credential path/filename.
func tokenCacheFile() (string, error) {
    usr, err := user.Current()
    if err != nil {
        return "", err
    }
    tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials")
    os.MkdirAll(tokenCacheDir, 0700)
    return filepath.Join(tokenCacheDir,
        url.QueryEscape("gmail-go-quickstart.json")), err
}

// tokenFromFile retrieves a Token from a given file path.
// It returns the retrieved Token and any read error encountered.
func tokenFromFile(file string) (*oauth2.Token, error) {
    f, err := os.Open(file)
    if err != nil {
        return nil, err
    }
    t := &oauth2.Token{}
    err = json.NewDecoder(f).Decode(t)
    defer f.Close()
    return t, err
}

// saveToken uses a file path to create a file and store the
// token in it.
func saveToken(file string, token *oauth2.Token) {
    fmt.Printf("Saving credential file to: %s\n", file)
    f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
    if err != nil {
        log.Fatalf("Unable to cache oauth token: %v", err)
    }
    defer f.Close()
    json.NewEncoder(f).Encode(token)
}

func main() {
    log.SetFlags(log.LstdFlags | log.Lshortfile)
    ctx := context.Background()

    b, err := ioutil.ReadFile("client_secret.json")
    if err != nil {
        log.Fatalf("Unable to read client secret file: %v", err)
    }

    // If modifying these scopes, delete your previously saved credentials
    // at ~/.credentials/gmail-go-quickstart.json
    config, err := google.ConfigFromJSON(b, gmail.GmailComposeScope, gmail.GmailModifyScope, gmail.MailGoogleComScope)
    if err != nil {
        log.Fatalf("Unable to parse client secret file to config: %v", err)
    }
    client := getClient(ctx, config)

    srv, err := gmail.New(client)
    if err != nil {
        log.Fatalf("Unable to retrieve gmail Client %v", err)
    }

    now := time.Now()

    const rfc2822 = "Mon Jan 02 15:04:05 -0700 2006"
    emailDate := now.Format(rfc2822)

    subjectStr := fmt.Sprintln("テスト")
    subject := base64.StdEncoding.EncodeToString([]byte(subjectStr))

    message := gmail.Message{
        Raw: base64.URLEncoding.EncodeToString([]byte("Date: " + emailDate + "\r\n" +
            "From: xxxatxxx.xxx\r\n" +
            "To: xxxatxxx.xxx\r\n" +
            "Subject: =?UTF-8?B?" + subject + "?=\r\n" +
            "Content-Type: text/html; charset=UTF-8\r\n" +
            "\r\n" +
            "<html><body>test</body></html>")),
    }

    udSrv := gmail.NewUsersDraftsService(srv)
    if _, err = udSrv.Create("me", &gmail.Draft{Message: &message}).Do(); err != nil {
        log.Fatalln(err)
    }
}

まとめ

RFC2822に関しては実装に必要なとこだけ拾って読んだ程度なのでちゃんと読みたい。

Goでbase64エンコードを実装する

base64を自分の理解のために実装してみる。

手順はこちらの記事がわかりやすいです。RFCで読むならこちら

実装したコードは以下の通りです。ほとんど標準パッケージ

package main

import (
    "fmt"
    "os"
)

const encodeStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
const padding = '='

func main() {
    if len(os.Args) != 2 {
        panic("Invalid arguments")
    }
    input := os.Args[1]

    result := decode(input)
    fmt.Println("Result: ", result)
}

func decode(input string) string {
    src := []byte(input)
    dst := make([]byte, (len(src)+2)/3*4)

    si, di := 0, 0
    n := (len(src) / 3) * 3
    for si < n {
        v := uint(src[si+0])<<16 | uint(src[si+1])<<8 | uint(src[si+2])

        dst[di+0] = encodeStr[v>>18&0x3F]
        dst[di+1] = encodeStr[v>>12&0x3F]
        dst[di+2] = encodeStr[v>>6&0x3F]
        dst[di+3] = encodeStr[v&0x3F]

        si += 3
        di += 4
    }

    remain := len(src) - si
    if remain == 0 {
        return string(dst)
    }

    v := uint(src[si+0]) << 16
    if remain == 2 {
        v |= uint(src[si+1]) << 8
    }

    dst[di+0] = encodeStr[v>>18&0x3F]
    dst[di+1] = encodeStr[v>>12&0x3F]

    switch remain {
    case 1:
        dst[di+2] = byte(padding)
        dst[di+3] = byte(padding)
    case 2:
        dst[di+2] = encodeStr[v>>6&0x3F]
        dst[di+3] = byte(padding)
    }

    return string(dst)
}

まとめ

  • コードを書いてみると、base64エンコードされたときに末尾に追加される=は最大で2つ、ということもわかる。
  • ビット演算とか普段使わないから勉強になる。

gitで開くエディタをemacsにする

gitでgit rebase -iするときにデフォルトでvimが起動するがemacsに起動してほしい。emacsで書きたいんだ。

というわけでその設定を行った。~/.gitconfigファイルに以下の設定を追加するだけでよかった。

[core]
        editor="emacs"

nginxでレスポンスをgzipで圧縮する

nginxでレスポンスをgzipで圧縮するための設定を調べた。

nginxでgzipモジュールを提供しているのでこれを利用する。設定例は以下の通り。

http {
    gzip  on;
    gzip_comp_level   2;
    gzip_types text/plain text/css text/xml application/xml text/javascript application/javascript application/json;
    gzip_buffers      4 8k;
}

クライアント側はリクエストヘッダーにAccept-Encodingを含める必要がある。 なお、curl--compressedオプションを指定すると、-Hで以下のヘッダーを付与してくれる。

Accept-Encoding: deflate, gzip

レスポンスヘッダーにはContent-Encoding: gzipが含まれる。

参考

www.oreilly.co.jp

www.oreilly.co.jp