VimにおけるLSPやデバッガの使い方

この記事はLayerX Tech Advent Calendar 2022 の16日目の記事です。LayerXのバクラク事業部で機械学習エンジニアをしている @yoppiblog です。 現在は、OCRチームで帳票を読み取る機械学習モデルの開発をしています。 今回は、主にOCRチームで使われている、バックエンド側のプログラミング言語における、Vimでの開発を効率化する

  • Language Server Protocol
  • デバッガ

周りの話を簡単に紹介できればと思います。

Language Server Protocol

LSP が策定されてからというものの、Vimでもその恩恵に預かり定義元へのジャンプやコード補完など、IDEと遜色ない機能を使えるようになりました。 VimでLSPを喋る場合、 vim-lsp がありますが、今回は、 coc-nvimについて紹介します。

coc.nvim

cov.nvimvim-lsp と同様にLSPをサポートするプラグインです。 Vim Scriptではなく、Node.jsで実装されており、vim-lspより高速だと感じており個人的に一番のメリットだと思います。また、jsonファイルでプロジェクト毎に柔軟にカスタマイズ可能な点も使いやすいです。 加えて、オムニ補完をとくに設定を細かくすることもなく、いい感じに補完してくれるのも嬉しい点です。 .nvim とsuffixがついていますが、Vimでも利用できます(nvim特有の機能に依存する機能は使えないですが...)。

環境構築

インストールは各々使っているプラグインマネージャで完結します。

" using vim-plug
Plug 'neoclide/coc.nvim', {'branch': 'release'}

また、各言語のLanguage Serverはこちらを参考にインストールします。 https://github.com/neoclide/coc.nvim/wiki/Language-servers Pythonでは coc-jedi(裏ではjediが動く) を、Goだと coc-go(裏ではgoplsが動く) が使いやすいプラグインになります。

:CocInstall coc-jedi coc-go

環境によってはうまく jedi-language-server が動かない場合もあるため、その場合は手動でインストールして使うようにすると良いでしょう。

:CocConfig  " coc.nvimの設定ファイルを開く
{
  "jedi": {
    "enable": true,
    "startupMessage": false,
    "executable.command": "~/.pyenv/shims/jedi-language-server"
  }
}

使い方

READMEに書いてある通り.vimrc に記述すればすぐに使えるようになりますが、重要なポイントはKey mappingです。 プログラミング中、常にコード定義を見に行ったり、renameしたり、参照元を探したり、、、という作業を頻繁にするため自分の使いやすいKey mappingを定義すると良いでしょう。

" e.g. Key mappings
nmap <silent> <C-]> <Plug>(coc-definition)
nmap <silent> <C-[> <C-o>
nmap <silent> gy <Plug>(coc-type-definition)
nmap <silent> gi <Plug>(coc-implementation)
nmap <silent> gr <Plug>(coc-references)
nmap <silent> gn <Plug>(coc-rename)
inoremap <silent><expr> <C-m> coc#pum#visible() ? coc#_select_confirm() : "\<C-m>"

デバッガ

ソフトウェアを開発する上で一番必要になるのは、個人的にデバッガだと思っています。 単純なprintデバッグで解決するシーンもあると思いますが、深く原因究明するためにはデバッガで状態を確認することで早期に解決できると考えています。

Pythonにおけるデバッガ

Pythonでは、 IPython が提供している機能を使うと一番楽です。 標準添付ライブラリに pdb があるのですが、より直感的で使いやすいものがIPythonになります。

ブレークポイントを設定したい場所に、

from datetime import datetime
from dateutil import parser

from IPython import embed


def parse(arg: str) -> datetime:
    dt = parser.parse(arg)
    embed()
    return dt

parse("2022-12-16")

embed 関数を埋め込み、通常実行するだけで任意の場所でデバッグできるようになります。

# IPythonのreplが動く
In [1]: dt
Out[1]: datetime.datetime(2022, 12, 16, 0, 0)

Goにおけるデバッガ

Goでのデバッガは delve がよく使われています。 他のIDEでのデバッガもdelveを元に実装されています。 Vimから使うためには vim-delve が使いやすくておすすめです。

delveのインストール後、

$ go install github.com/go-delve/delve/cmd/dlv@latest

vim-delveをインストールします。

" using vim-plug
Plug 'sebdah/vim-delve'

よく使うコマンドとして、

があります。

下記の例は、10行目に DlvAddBreakpointブレークポイント設定したのち、 DlvDebug で実行した場面です。 デバッガが起動し、変数の確認など可能になります。

> main.main() ./time.go:10 (hits goroutine(1):1 total:1) (PC: 0x1028bf9b0)
     5:         "time"
     6: )
     7:
     8: func main() {
     9:         date, err := time.Parse("2006-01-02", "2022-12-16")
=>  10:         if err != nil {
    11:                 panic(err)
    12:         }
    13:         fmt.Printf("date:%v\n", date)
    14: }
(dlv)

終わりに

Vimでの開発環境を少し紹介しました。IDEのほうが機能が盛りだくさんで使いやすいシーン(特にデバッグ周り)は多いと思いますが、 必要な機能を自分で取捨選択してカスタマイズできるエディタは、それはそれで使いやすいと感じています。

Amazon ECS上のタスクをGolangで操作する

この記事は Speeeアドベントカレンダー 16日目 の記事になります。 昨日は、 kosuke_nishayaさん による、「作ればわかる、FM音源」でした。

この記事では、バッチ環境としてAmazon ECS上でのタスク実行をGolangで管理するためのAPIの使い方を https://github.com/yoppi/ecs-launcher-go をもとに紹介します。

Amazon ECS(Elastic Container Service)とは

EC2上にDockerコンテナクラスタを構築し、コンテナ上で「タスク」という単位で任意の計算の実行環境を提供してくれるサービスです。

ECSは細かい単位でタスクを制御できることから、大量のタスクを同時に実行するのにAWS Batchより適しているアーキテクチャだと考えています。 デメリットとしてはECSクラスタの構築運用が煩雑的なところでしょう。

環境構築などはこの記事では言及しません。ドキュメント及び多くの方の記事を参考にしてください。

GoからECSのタスクを扱う

AWSコンソールから手動でタスクを実行可能ですが、多くの場合、事業やサービスにおいてさまざまな状態を扱う必要があるので、タスク実行をプログラムで制御したくなることでしょう。 ただ、AWS SDKAPIはほぼ自動生成されているため、全般的に読みにくいところが多いですが、基本的に使うAPIさえ俯瞰すれば難しくはありません。 ここではECSを扱う際に必要十分なAPIを紹介します。

ECSはタスク単位でDockerコンテナ上で実行されます。そこで、

  • タスク作成
  • タスク実行
  • タスク監視

これら、3つのAPIをもとに操作することになります。

タスク作成

// launcher.go

type ECSTask struct {
    Input  *ecs.RunTaskInput
    Output *ecs.RunTaskOutput
    doneCh chan struct{}
}

// example/hello.go

func createTasks() []*launcher.ECSTask {
    var tasks []*launcher.ECSTask

    def := "task-name"
    cluster := "cluster-name"
    container := "container-name"

    for i := 0; i < 10; i++ {
        tasks = append(tasks, launcher.NewECSTask(&ecs.RunTaskInput{
            TaskDefinition: aws.String(def),
            Cluster:        aws.String(cluster),
            Count:          aws.Int64(1),
            Overrides: &ecs.TaskOverride{
                ContainerOverrides: []*ecs.ContainerOverride{
                    {
                        Name: aws.String(container),
                        Environment: []*ecs.KeyValuePair{
                            {
                                Name:  aws.String("i"),
                                Value: aws.String(fmt.Sprint(i)),
                            },
                        },
                    },
                },
            },
        }))
    }

    return tasks
}

ECSのタスクの実行は、 RunTaskInput を入力として、 RunTaskOutput を生成します。

RunTaskInputは実行するクラスタ、タスク定義、そして環境変数を登録します。 タスクはECSはDockerコンテナ上で実行されるので、実行時に可変となる値をタスク内のプログラムで使う場合は、必ず環境変数を渡すことになります。 また、タスク実行は1度に一つとしています。そのためcountを1にしています。

RunTaskOutputは、ECSのDockerコンテナ上でタスクが登録されたときに返されます。 タスクのステータスや、タスクを一意に決定するARNもこの構造体に登録されています。

ECSLauncherはgoroutineで並行してタスクを実行するので、doneChを通じてそのタスクが完了したか(エラー含めて)を検知します。

タスク実行

func (t *ECSTask) Run(client *ecs.ECS) error {
    for {
        resp, err := client.RunTask(t.Input)
        if err != nil {
            if strings.Contains(err.Error(), "No Container Instances") {
                fmt.Printf("wait for launching instances:%v\n", t.StringEnvs())
                time.Sleep(30 * time.Second)
                continue
            } else if strings.Contains(err.Error(), "ThrottlingException") {
                fmt.Printf("wait for becoming empty throttles:%v\n", t.StringEnvs())
                time.Sleep(30 * time.Second)
                continue
            } else {
                return err
            }
        }

        if len(resp.Failures) > 0 {
            var isRetry bool

            for _, failure := range resp.Failures {
                reason := aws.StringValue(failure.Reason)
                if strings.HasPrefix(reason, "RESOURCE:") || reason == "AGENT" {
                    isRetry = true
                }
            }

            if isRetry {
                fmt.Printf("wait for releasing machine resource:%v\n", t.StringEnvs())
                time.Sleep(30 * time.Second)
                continue
            }
        }

        if len(resp.Tasks) > 0 {
            t.Output = resp
            fmt.Printf("task started:%s\n", t.StringEnvs())
            break
        }

        // errがnilでFailures, Tasksが両方共空の場合もある
        fmt.Printf("fail RunTask():%s\n", t.StringEnvs())
        time.Sleep(30 * time.Second)
    }

    return nil
}

RunTask() がタスクを実行するAPIになります。 戻り値は、先程紹介したRunTaskOutput及びerrorになります。 このAPIを呼び出し後にerrorが返却されると、

の2点を、errorのメッセージから判別してリトライする必要があります。 このあたりがかなり煩雑で、せめてerrorの型を定義してほしいですね。

ECSクラスタインスタンスを起動するのにはある程度時間を要します。

バッチ目的で クラスタ内のインスタンス起動 → タスク実行

というJobFlowを組んでいたとするとこのタスク実行時には確実に間に合わずリトライする必要があります。

errorが返されず Failures が存在する場合もRunTask()に失敗しています。 クラスタ内のインスタンスにリソースが足りていない場合(いくつかタスクが実行されている状態)はリトライするようにします。

errorもなく、Failuresも0の場合で、Tasks が1つ以上ある場合は、RunTaskが成功したとみなせます。 しかし、errorもなくFailuresも0、Tasksも0といった状態もあり、これも考慮する必要があります。

タスク監視

func (t *ECSTask) Describe(client *ecs.ECS) {
    for {
        input := &ecs.DescribeTasksInput{
            Cluster: t.Input.Cluster,
            Tasks:   []*string{t.GetArn()},
        }

        resp, err := client.DescribeTasks(input)
        if err != nil {
            fmt.Printf("%v\n", err)
            time.Sleep(10 * time.Second)
            continue
        }

        if len(resp.Failures) > 0 {
            for _, failure := range resp.Failures {
                fmt.Printf("%v\n", failure)
            }
            time.Sleep(10 * time.Second)
            continue
        }

        if len(resp.Tasks) > 0 {
            task := resp.Tasks[0]
            if aws.StringValue(task.LastStatus) == "STOPPED" {
                var failed bool

                if len(task.Containers) > 0 {
                    for _, c := range task.Containers {
                        if c.ExitCode != nil && *c.ExitCode > 0 {
                            failed = true
                            fmt.Printf("fail task exitCode:%v reason:%v containerArn:%v taskArn:%v\n", *c.ExitCode, *c.Reason, *c.ContainerArn, *c.TaskArn)
                        }
                    }
                }

                if !failed {
                    fmt.Printf("finish task arn:%v elappsed:%v envs:%v\n", aws.StringValue(t.GetArn()), task.StartedAt, task.StoppedAt, t.StringEnvs())
                }

                t.doneCh <- struct{}{}

                break
            } else {
                fmt.Printf("task status:%v arn:%v envs:%v\n", aws.StringValue(task.LastStatus), aws.StringValue(t.GetArn()), t.StringEnvs())
            }
        }

        time.Sleep(10 * time.Second)
    }
}

実行中のタスクがどのような状況かを判別するためのAPIが、DescribeTasks()です。 引数としてDescribeTasksInput構造体を受取り、対象のタスクのARNとクラスタ名を指定して実行します。

重要なのは、レスポンスのTasksがある場合のハンドリングです。 ここからタスクのステータスを判別することができ、実行中もしくは停止している、かを判断します。 ステータスの値が STOPPED の場合タスクが停止しているとみなせます。 ただ、ただしく終了したかどうかは判別できないので、タスクの終了コードも見て判別します。 0より大きい場合、何らかの原因で終了したことがわかります。

まとめ

この記事ではAamazon ECS上でのタスクをGolangで扱うときのtips及をお届けしました。 明日は、bino98さんによる「Botのお話」です!楽しみですね!

伸長するsliceの取扱い

Goで何かしら値を計算した結果をsliceに格納するときにcapを事前に設定出来ない場合もある。

// capがわかってない場合
var slice []T
// cap(N)がわかっている場合
slice := make([]T, 0, n)

append時にcapが足りない場合、slice内部の値をコピーして伸長する。 そのため、capを事前に設定できない場合、sliceに格納するstructは実態ではなく参照にしておくことが望ましい。

package slice

import (
    "testing"
)

type T struct {
    A int
}

type T2 struct {
    A int
    B string
}

type T3 struct {
    A int
    B string
    C []string
}

func BenchmarkReal(b *testing.B) {
    var slices []T
    for i := 0; i < b.N; i++ {
        t := T{A: 1}
        slices = append(slices, t)
    }

}

func BenchmarkReal2(b *testing.B) {
    var slices []T2
    for i := 0; i < b.N; i++ {
        t := T2{A: 1, B: "B"}
        slices = append(slices, t)
    }
}

func BenchmarkReal3(b *testing.B) {
    var slices []T3
    for i := 0; i < b.N; i++ {
        t := T3{A: 1, B: "B", C: []string{"C"}}
        slices = append(slices, t)
    }

}

func BenchmarkPointer(b *testing.B) {
    var slices []*T
    for i := 0; i < b.N; i++ {
        t := &T{A: 1}
        slices = append(slices, t)
    }
}

func BenchmarkPointer2(b *testing.B) {
    var slices []*T2
    for i := 0; i < b.N; i++ {
        t := &T2{A: 1, B: "B"}
        slices = append(slices, t)
    }
}

func BenchmarkPointer3(b *testing.B) {
    var slices []*T3
    for i := 0; i < b.N; i++ {
        t := &T3{A: 1, B: "B", C: []string{"C"}}
        slices = append(slices, t)
    }
}
$ go test -bench=. -benchmem                                                                                    [~/src/github.com/yoppi/go]
goos: darwin
goarch: amd64
pkg: github.com/yoppi/go
BenchmarkReal-4         100000000               14.2 ns/op            49 B/op          0 allocs/op
BenchmarkReal2-4         5000000               220 ns/op             132 B/op          0 allocs/op
BenchmarkReal3-4         3000000               551 ns/op             272 B/op          1 allocs/op
BenchmarkPointer-4       5000000               235 ns/op              51 B/op          1 allocs/op
BenchmarkPointer2-4     10000000               220 ns/op              74 B/op          1 allocs/op
BenchmarkPointer3-4      5000000               240 ns/op             107 B/op          2 allocs/op
PASS
ok      github.com/yoppi/go     10.506s

興味深いのは、フィールドがint一つだけの場合参照ではなく実態の方が高速に扱えていること。このあたりもうちょっと調べる。

[WIP]文書間類似度の参考文献

最近、仕事で記事レコメンドアルゴリズムをコンテンツベース(文書間類似度)を用いて実装して、記事CTRを大幅(高いものだと700%くらい)に上げられた。 そのとき調べたことを雑にまとめておく。

論文

文書間類似度とはなんぞやを過不足なくまとまっていて良い。

OM-basedのためのtext tilingについて。

ISUCON6

毎年恒例のISUCONに参加(オシャレ怪盗スワロウテイル)してきました。今年で4回目の挑戦になります。

ISUCON6予選

いわゆる、はてなキーワードクローン実装です。

加えて、各機能が別アプリ(別プロセス)で動作しており相互間はAPIでやり取りするという構成でした。 厳密な実装をするならキャッシュ戦略があまり効かないアプリケーションです。ただ、今回ベンチマーカーでPOST後適切にリンクを生成していなくても一発failとならず減点だけとなるのでキャッシュで押し切ってもなんとかなったという感じでした。

時間内にやったことは、

  • まずは、マイクロサービス化されている実装(isutar、isuda)を統合する
  • データ永続化部分をMySQLからRedisに置き換え
  • さらに初期データをすべてhtmlfyしたものをRedisに乗せておく

といったところまで実装し、POSTが来たらキーワード毎のflagを見てキャッシュをパージ、というところまで実装したかったのですが時間が及ばず終了。 なんとか予選を突破できました。

ざわざわしていたGoですが、予選後、試してみてたしかにGoのregexpは遅く、正規表現ではなく単なる置換ですむので strings.Replacer 大躍進というのは良い知見でした。

ISUCON6本選

pixivさん作の問題だけあってpixiv sketchのミニマム実装で、使われている技術要素としてSSE、React、サーバサイドレンダリング、そしてDockerというモダンな構成でした。

最初難儀したのは、各ミドルウェア、アプリケーションがDocker Composeで動作しているので各アプリのログ出力がまったくない!という状態で、スタートしたので、ベンチマーカーの挙動を素早く追えなかったことでした。

Dockerだと、短時間に何回も再起動を繰り返すことになりかえって足かせになるので、

  • MySQLをDockerからhost側に移行
  • nginxをnodeの前に建てる(http2化)

ということを進めました。

ここで、すでに15:00くらいと大きく時間を使ってしまい、慌てることに。 加えてnginx化した段階で初期スコアより大きく下がりさらにさらにあわてることに。 nginx化したことにより、クライアントからのリクエスト要求を受け付けられるようになり、その結果、/img/:id で詰まり(大量のTIME_WAIT)、ベンチマーカーがワークロードを下げたことが原因でした。

ここをまず突破しないと先に進めないぞということで、

  • nginxのproxy_cache
  • nodeにおける /img:id をやめる

ということを戦略としました。 proxy_cacheは 画像の変更 = 画数の変更 ということから、リクエストパラメータにstroke_countを持たせる(/img/:id?s={stroke_count})戦略が良さそうとなりました。 そして、nodeでどうやらつまるのでRuby(thin)でリクエストを捌かせようようという試みでした。

この2つの戦略を進めようとしていましたが、完成させることができずタイムアップとなりました。 懇親会で他チームの話をきいているともうちょっとうまくやれた感じがして悔やまれる…

ちなみに、上記とは別にいろいろやっていたのですが、どれもまずはトップページのからの /img/:id を解決しないことには意味のないものばかりでした。

  • N+1を解決する
  • 特定のキャッシュのパージを実現するためにproxy_cacheではなく、nginxからmemcachedを参照する方法。結果としておそすぎてだめ(1リクエストに100msくらいかかっている)

予選と本選通じて

毎年ハードルが上がっているのを感じていますが、ISUCON6でまた一段難しさが増したという感じでした。 運営、問題作成者みなさん、ありがとうございました。

やはり3人というチーム構成はいいバランスになって良いなと思います(フォローしあいつつも効率的に動ける)。チームメンバに感謝を。

また来年もリベンジです。

fontdがCPUリソースを喰う

ここ1、2年(遅くなったり普通だったりしたりして原因がわからず放置していた...)、Macのターミナル(iTerm)で作業すると、 fontd プロセスがとても重くなりもっさりする現象が続いていた。

原因は、zshのプロンプトに機種依存文字を使っていたからだった。

たとえば、みんな大好きなゆのっちプロンプト。

✘╹◡╹✘

そして、gopherプロンプト。

( ◔ ౪◔)

こういったものを PROMPT に設定していると、ターミナルでのフォントがレンダリングされるタイミングでfontd serverへの問い合わせが発生し、CPUリソースを大幅に消費することでもっさりするのだった。

fontd プロセスがCPUリソースを喰う事例はググるとちらほら出てくるが、根本的な原因がわからず対処できていなかった。

https://github.com/yoppi/config/commit/e9494760fcf67f4e2eb7ba994be012c7c38c542c

平穏が戻ってきた。

goroutineのテストを同期的に行う

とある関数の評価値 ― 例えばファイルに文字列を書き込む ― をテストしたい場合、 その評価がgoroutine内だと、テスト側から実行しても、タイミングによって取得できないことがあります。 そこで、テスト側から評価するときにはchanelを渡す用にして、そのchanelに対して書き込むようにすることで同期処理できるようになります。

type SUTType struct {
  ...
  Out io.WriteCloser
  ...
}

func (o *SUTType) SUT() {
  ...
  go func() {
    o.Out.Write("aqours")
  }()
  ...
}

テスト対象がこんな感じのコードになってたりする。 のでこの、 Out をすげ替えてあげればいい。

func TestSUT(t *testing.T) {
  s := &SUTType{Out: &DummyOut{}}
  
  s.SUT()

  donewait := make(chan struct{}{})
  var result string
  go func() {
    result := <-out.Buf
    donewait <- struct{}{}
  }()

  <-donewait

  if result != "aqours" {
    t.Error("should be aqours")
  }
}

type DummyOut struct {
  Buf chan []byte
}

func (o *DummyOut) Write(p []byte) (int, error) {
  o.Buf <- p
  return 0, nil
}

func (o *DummyOut) Close() error {
  return nil
}

chanelを使うことで同期処理が可能になる、ということと、テスト可能なコードにする場合は、 インタフェースを持つことでテスト側で柔軟なコード ― 上記の例だと、 SUTType のOutの型が os.File だと途端にテストが面倒になる ― にできる典型例でした。