コンテナ内で動くGoのWebアプリケーションをリモートデバッグする

コンテナ内のGo製Webアプリケーションをリモートデバッグする方法をまとめています。
ハマった点とその回避策も載せています。解消策などありましたら教えていただけると嬉しいです。

## TL;DR

* [Delve](https://github.com/go-delve/delve)というデバッガーを使うと、リモートデバッグができる
* [Realize](https://github.com/oxequa/realize)というタスクランナーと組み合わせる場合は、いくつか回避策が必要になった
* リモートデバッグのクライアントにはGoLandを使用した(他のエディタも対応状況は[こちら](https://github.com/go-delve/delve/blob/master/Documentation/EditorIntegration.md)を参照)

## サンプルコード

この記事にあげているコードは、こちらにあります。
[bellwood4486/sample-go-containerized-debug](https://github.com/bellwood4486/sample-go-containerized-debug)

ディレクトリは次のように3段階になっています。
* `0_base`: リモートデバッグを組み込む前のコード
* `1_dlv`: Delveを入れてリモートデバッグできるようにしたコード
* `2_dlv_realize`: さらにRealizeも入れてホットリロードにも対応したコード

## リモートデバッグを組み込む前のコード

次のようなシンプルなWebアプリケーションを題材にします。

```go
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		_, _ = fmt.Fprintf(w, "hello world")
	})
	addr := ":8080"
	fmt.Printf("Launching server at %q ...\n", addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}
```

`hello world`を返すだけです。

```sh

❯ curl http://localhost:8080
hello world
```

## Delveを組み込みデバッグする

Delveを組み込みリモートデバッグできるようにしていきます。

おおまかな流れは以下のとおりです。
1. `GOPATH`配下の相対パスを、コンテナとローカルとで合わせる
2. 最適化を無効にして、アプリケーションをビルドする
3. コンテナ起動時にDelveが立ち上がるようにする
4. `docker run`コマンドに渡すオプションを追加する

一つずつ説明します。

### `GOPATH`配下の相対パスを、コンテナとローカルとで合わせる

ブレイクポイントで止まるようにするためには、`GOPATH`配下の相対ファイルパスがコンテナ側と一致している必要があります。
一致していないと、下図のようにブレイクポイントが無効状態になります。
![スクリーンショット 2020-04-12 14.03.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30246/0b3578ef-5809-e987-072e-b38cb5d40a29.png)

Dockerfileを次のように変更して、ローカル側のパスと一致するようにします。

```Dockerfile
FROM golang:1.13.5-alpine AS build

# In order to debug remotely with GoLand, the paths of the source code relative to GOPATH must match.
WORKDIR ${GOPATH}/src/github.com/bellwood4486/sample-go-containerized-debug/1_dlv
# ... snip ...
```

ただ、ググると以下の記事も見つかるため、必須というわけではないかもしれません(未確認)
* GoLand： [GoLandでProject Pathを設定する](https://qiita.com/edelweiss452/items/2013b6f34be8ab6d4abd#gopath%E3%81%AE%E8%A8%AD%E5%AE%9A)
* VSCode： [デバッグの設定に `remotePath` を指定する](https://medium.com/@kaperys/delve-into-docker-d6c92be2f823)

### 最適化を無効にしてアプリケーションをビルドする

ビルドする際は、インライン化などの最適化を無効にしておく必要があります。
`go build` に渡すオプションについては、[Delveの`exec`コマンドのマニュアル](https://github.com/go-delve/delve/blob/master/Documentation/usage/dlv_exec.md)に載っています。
> Please consider compiling debugging binaries with -gcflags="all=-N -l" on Go 1.10 or later, -gcflags="-N -l" on earlier versions of Go.

Goのバージョンによって、オプションは若干変わります。今回の環境は Go 1.13.5 なので、`-gcflags="all=-N -l"` を指定しました。
オプションの値については、[こちら](https://www.thekingsmuseum.info/entry/2017/09/21/113002)が参考になります。

- `-N`：最適化オフ
- `-l`：インライン化オフ

Dockerfileに次のように記述します。

```Dockerfile
# ... snip ...
# To debug with Delve, disable optimization.
# see: https://github.com/derekparker/delve/blob/master/Documentation/usage/dlv_exec.md
RUN CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /bin/sample-go-server-debug ./app
# ... snip ...
```

### コンテナ起動時にDelveが立ち上がるようにする

Delveがアプリケーションにアタッチできるようにするため、Delve経由でアプリケーションを起動させます。
Dockerfileを変更し、Delveが起動するようにします。マルチステージビルドのdebug用ステージを用意しました。

```Dockerfile
FROM golang:1.13.5-alpine AS build
# ... snip ...
RUN CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /bin/sample-go-server-debug ./app
# ... snip ...

FROM build AS debug
RUN set -ex && \
  apk update && \
  apk add --no-cache git && \
  go get -v -u github.com/go-delve/delve/cmd/dlv
# Port 8080 belongs to our application, 2345 belongs to Delve
EXPOSE 8080 2345
# Run Delve with the uninterrupted setting in the main function.
# see: https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker
CMD /go/bin/dlv exec /bin/sample-go-server-debug \
  --listen=:2345 \
  --headless \
  --api-version=2 \
  --continue \
  --accept-multiclient
# ... snip ...
```

debugステージでは、次の3つを行っています。

1. Delveをgo getでインストールする
2. リモートデバッグのコネクションを受け付けるポート(`2345`)を追加で開ける
3. `dlv exec`コマンドにビルドしたバイナリを指定し、Delveを起動する

`dlv exec`コマンドのオプションについては[こちら](https://github.com/go-delve/delve/blob/master/Documentation/usage/dlv_exec.md)を参照ください。
ポイントは、`--continue` オプションです。このオプションの有無でリモートデバッグの挙動が次のように変わります。

- 【指定しない場合】アプリケーションのプロセスは起動しますが、デバッガでアタッチするまでmain関数の実行は**中断されます**。
- 【指定する場合】デバッガをアタッチしなくてもmain関数の実行は**中断されません**。

今回のサンプルで言うと、【指定しない場合】は、リモートデバッガのコネクションを張るまで、curlの実行結果は正しく返りません。main関数の実行が中断されるためです。通常の開発ではすこし不便なので、`--continue` を指定しています。

このことは[DelveのFAQ](https://github.com/derekparker/delve/blob/master/Documentation/faq.md)に書いてあります。
> The program will not start executing until you connect to Delve and send the continue command. If you want the program to start immediately you can do that by passing the --continue and --accept-multiclient options to Delve:

### `docker run`コマンドに渡すオプションを追加する

コンテナ内のアプリケーションをデバッグするときは、`docker run`コマンドに2つのオプションを渡す必要があります。([参考1](https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker)、[参考2](https://blog.jetbrains.com/go/2018/04/30/debugging-containerized-go-applications/))

- `--security-opt=seccomp:unconfined`
- `--cap-add=SYS_PTRACE`

`--security-opt=seccomp:unconfined`は、デフォルトで制限されているコンテナ内での動作を解除するオプションです。Linux Kernelには、Secure computing mode(seccomp)という機能あり、これを使うとコンテナ内の動作を制限させることができます。Dockerではこの機能の[デフォルトのプロファイル](https://github.com/moby/moby/blob/master/profiles/seccomp/default.json)を定義しています。このオプションを指定することで、デフォルトプロファイルを無効にすることができます。詳細は[公式ドキュメント](https://docs.docker.com/engine/security/seccomp/)を参照ください。

Linuxの[capability](https://linux.die.net/man/7/capabilities)の一つに、[ptrace(2)](https://linux.die.net/man/2/ptrace)というシステムコールを使って任意のプロセスをトレースする`CAP_SYS_PTRACE`があります。`--cap-add=SYS_PTRACE`は、このcapabilityを追加するオプションです。GoLandのガイドでは指定されていますが、もしかしたら不要かもしれません。[こちらのブログ](https://please-sleep.cou929.nu/debug-go-project-with-delve-on-docker.html)がとても参考になります。

今回は、docker-composeからコンテナを起動しているので、docker-compose.yaml 上で指定しています。

```yaml
version: '3.7'

services:
  app:
# ... snip ...
    # To debug containerized app with Delve, this option is required.
    # see: https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker
    security_opt:
      - apparmor:unconfined
    # To debug with GoLand, this option is required.
    # see: https://blog.jetbrains.com/go/2018/04/30/debugging-containerized-go-applications/
    cap_add:
      - SYS_PTRACE
```

### コンテナを立ち上げる

以上で準備は完了です。コンテナを立ち上げてみます。

```sh
❯ cd 1_dlv
❯ docker-compose up -d --build
```

ログを確認すると、2つのポートをリッスンしているのがわかります。
`8080`はアプリケーション、`2345`はDelveのデバッグ用ポートです。

```
❯ docker-compose logs -f
Attaching to 1_dlv_app_1
app_1  | API server listening at: [::]:2345
app_1  | Launching server at ":8080" ...
```

アプリケーションも正常にレスポンスを返します。

```sh
❯ curl http://localhost:8080
hello world
```

次に、コンテナ内に入り、プロセスの状況を確認します。
2つのプロセスが動いています。PID:1がDelve、PID:12がアプリケーションのプロセスです。

```sh
❯ docker-compose exec app ash
/go/src/github.com/bellwood4486/sample-go-containerized-debug/1_dlv # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen=:2345 --headless --api-version=2 --continue --accept-multiclient
   12 root      0:00 /bin/sample-go-server-debug
# ... snip ...
```

ツリー形式で表示すると、Delveからアプリケーションが呼び出されていることがわかります。

```sh
/go/src/github.com/bellwood4486/sample-go-containerized-debug/1_dlv # pstree -p
dlv(1)---sample-go-serve(12)
```

### GoLandからデバッグする

GoLandからコンテナ内のDelveに繋いで、アプリケーションをデバッグするまでの流れです。

Run/Debug Configuration から "Go Remote" を新規作成します。
![スクリーンショット 2020-04-18 12 27 27](https://user-images.githubusercontent.com/2452581/79627378-9b4d2300-8172-11ea-93a7-d095e95b086d.png)

ポート番号は、Dockerfileで定義したデバッグ用ポート番号を指定します。(今回の場合は`2345`)
![スクリーンショット 2020-04-18 12 29 14](https://user-images.githubusercontent.com/2452581/79627388-aef88980-8172-11ea-9a4a-7aaf5b72e3ab.png)

自分の環境では、デバッガからDelveに接続すると、次のメッセージがログに表示されました。今のところ支障はなさそうなので一旦棚上げしています。

```
❯ docker-compose logs -f
Attaching to 1_dlv_app_1
... snip ...
app_1  | 2020-04-18T07:03:47Z error layer=rpc writing response:write tcp 127.0.0.1:2345->127.0.0.1:48800: use of closed network connection
```

curlでリクエストを送ると、ハンドラー内のブレイクポイントで無事止まりました。
![スクリーンショット 2020-04-18 12 35 52](https://user-images.githubusercontent.com/2452581/79627399-d64f5680-8172-11ea-8d56-96c01efd0882.png)

**注意：**
前述の「コンテナ起動時にDelveが立ち上がるようにする」でも書きましたが、このサンプルでは、main関数に貼ったブレイクポイントでは止めることができません。main関数をデバッグするには、Delve起動時に指定した`--continue`オプションを外す必要があります。

## さらに Realize と組み合わせる

[Realize](https://github.com/oxequa/realize)は、Go向けのタスクランナーです。
ソースファイルの変更を検知して、自動で再ビルド＆起動させられるので、ホットリロードを実現できます。
これを組み合わせて、ホットリロードとリモートデバッグの両方できる構成にしてみます。

現時点では、`Delve -> app` という2段階でアプリケーションを起動しています。これを `Realize -> Delve -> app` という3段階構成にします。
基本的には、これまで `Dockerfile` や `docker-compose.yaml` に書いた設定を、Realizeの設定ファイルである `.realize.yaml` に移していくだけですが、いくつかハマったのでそのワークアラウンドも記載します。

最終的な `.realize.yaml` ファイルは次のようになります。
ここでは3つの問題が発生し、それぞれ回避策を入れています。

* `-gcflags "all=-N -l"` を `.realize.yaml` に直接書くと go build が失敗する
* Delveとアプリケーションのプロセスが終了しない
* アプリケーションのプロセスがゾンビプロセスとして残る

```yaml
settings:
  legacy:
    # If you want Realize running in a container to detect changes,
    # it must be true (polling).
    force: true
    interval: 1s
schema:
  - name: app-debug
    path: .
    commands:
      build:
        status: true
        # WORKAROUND: The value of the -gcflags option ("all=-N -l") is separated by a space and is interpreted as an error
        # if it is written directly in ".realize.yaml".
        method: ./realize/go-debug-build.sh -o /bin/sample-go-server-debug ./app
      run:
        status: true
        method: /go/bin/dlv
    args:
      - exec
      - /bin/sample-go-server-debug
      - --listen=:2345
      - --headless
      - --api-version=2
      # Run Delve with the uninterrupted setting in the main function.
      # see: https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker
      - --continue
      - --accept-multiclient
    watcher:
      paths:
        - /app
      ignored_paths:
        - .git
        - .realize
        - vendor
      extensions:
        - go
      # WORKAROUND: If you don't explicitly kill a child process, Delve and its child don't terminate.
      scripts:
        - type: before
          command: pkill -INT /bin/sample-go-server-debug
          output: true
```

### 問題1：`-gcflags "all=-N -l"` を `.realize.yaml` に直接書くと go build が失敗する

最初は、次のように `.realize.yaml` にビルドコマンドを直接書いていました。

```yaml
# ... snip ...
schema:
  - name: app-debug
    path: .
    commands:
      build:
        status: true
        method: CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /bin/sample-go-server-debug ./app
```

これを実行すると、`-l"` を一つのオプションとして解釈しようとして失敗します。

```
[13:13:34][APP-DEV] : Watching 1 file/s 1 folder/s
[13:13:34][APP-DEV] : Build started
[13:13:34][APP-DEV] : Build 
 flag provided but not defined: -l"
usage: go build [-o output] [-i] [build flags] [packages]
Run 'go help build' for details.
exit status 2
```

エスケープによる解消方法を見つけられず、今回は別ファイルにビルドコマンドを書き、それを呼び出すかたちで回避しました。

`.realize.yaml`

```yaml
# ... snip ...
        # WORKAROUND: The value of the -gcflags option ("all=-N -l") is separated by a space and is interpreted as an error
        # if it is written directly in ".realize.yaml".
        method: ./realize/go-debug-build.sh -o /bin/sample-go-server-debug ./app
```

`go-debug-build.sh`

```sh
#!/usr/bin/env ash

# To debug with Delve, disable optimization.
# see: https://github.com/derekparker/delve/blob/master/Documentation/usage/dlv_exec.md
CGO_ENABLED=0 go build -gcflags "all=-N -l" "$@"
```

### 問題2：Delveとアプリケーションのプロセスが終了しない

Realizeは、ファイルの更新を検知すると、既存のプロセスを停止させるはずですが、Delveとアプリケーションが終了しないという問題が起きました。
次のIssueにある回避策を参考に、末端のアプリケーションのプロセスを明示的に終了させることで回避はできました。(原因は掴めていません…)
[Realize is not killing the current app · Issue #247 · oxequa/realize](https://github.com/oxequa/realize/issues/247#issuecomment-532152142)

```yaml
# ... snip ...
     # WORKAROUND: If you don't explicitly kill a child process, Delve and its child don't terminate.
      scripts:
        - type: before
          command: pkill -INT /bin/sample-go-server-debug
          output: true
```

### 問題3：アプリケーションのプロセスがゾンビプロセスとして残る

上記2つの回避策を入れることで、再ビルドしたプロセスが起動するようにできました。しかし、なぜかアプリケーションのプロセスが以下のように残ってしまっていました。
COMMAND名の`[sample-go-serv]`がそれです。

```
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /go/bin/realize start
  990 root      0:00 [sample-go-serve]
 1067 root      0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen :2345 -
 1075 root      0:00 /bin/sample-go-server-debug
# ... snip ...
```

ツリー表示したものが以下です。もともとはDelveの子プロセスだったものが、realizeの子プロセスとして残ってしまっています。

```
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # pstre
e -p
realize(1)-+-dlv(1067)---sample-go-serve(1075)
           `-sample-go-serve(990)
```

こちらは、アプリケーションにGraceful Shtdownの実装を入れ、明示的にシグナルをハンドリングすることで回避できました。(こちらも原因は掴めておらず…)

### 動作確認

ようやくホットリロードしつつデバッグできる環境が整ったので確認してみます。

まずコンテナを立ち上げ、正常にレスポンスが返ることを確認します。

```
❯ cd 2_dlv_realize
❯ docker-compose up -d --build
❯ curl http://localhost:8080
hello world
```

コンテナのログを見ると、Realizeがビルドしているのがわかります。(`Build completed in 7.766 s`の部分)

```
❯ docker-compose logs -f
Attaching to 2_dlv_realize_app_1
app_1  | [05:58:33][APP-DEBUG] : Watching 1 file/s 1 folder/s
app_1  | [05:58:33][APP-DEBUG] : Command "pkill -INT /bin/sample-go-server-debug"
app_1  | [05:58:33][APP-DEBUG] : Build started
app_1  | [05:58:41][APP-DEBUG] : Build completed in 7.766 s
app_1  | [05:58:41][APP-DEBUG] : Running..
app_1  | [05:58:41][APP-DEBUG] : API server listening at: [::]:2345
app_1  | [05:58:41][APP-DEBUG] : Launching server at ":8080" ...
```

コンテナの中に入り、プロセスを確認してみます。
3つのプロセスがあり、`Realize -> Delve -> アプリケーション` の順に起動されていることがわかります。

```
❯ docker-compose exec app ash
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /go/bin/realize start
 1000 root      0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen :2345 --headless --api-version 2 --continue --accept-multiclient
 1007 root      0:00 /bin/sample-go-server-debug
# ... snip ...

/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # pstree -p
realize(1)---dlv(1000)---sample-go-serve(1007)
```

ホットリロードが機能しているか確認します。
`main.go` を変更し、レスポンスを'hello world'から'foo bar'に変えて保存します。

```go
//... snip ...
		_, _ = fmt.Fprintf(w, "foo bar")
//... snip ...
```

コンテナのログを見ると、ファイルの変更が検知され、再ビルド＆再起動しているのがわかります。

```
❯ docker-compose logs -f
Attaching to 2_dlv_realize_app_1
... snip ...
app_1  | [06:32:05][APP-DEBUG] : GO changed /go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize/app/main.go
app_1  | [06:32:05][APP-DEBUG] : Command "pkill -INT /bin/sample-go-server-debug"
app_1  | [06:32:05][APP-DEBUG] : Build started
app_1  | [06:32:06][APP-DEBUG] : Build completed in 0.894 s
app_1  | [06:32:06][APP-DEBUG] : Running..
app_1  | [06:32:06][APP-DEBUG] : API server listening at: [::]:2345
app_1  | [06:32:06][APP-DEBUG] : Launching server at ":8080" ...
```

curlの実行結果も期待通り変わっています。

```
❯ curl http://localhost:8080
foo bar
```

再度コンテナに入り、プロセスIDを確認すると、DelveとアプリケーションのPIDが変わっていることがわかります。ゾンビプロセスもありません。

```
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /go/bin/realize start
 1091 root      0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen :2345 --headless --api-version 2 --continue --accept-multiclient
 1100 root      0:00 /bin/sample-go-server-debug
# ... snip ...

/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # pstree -p
realize(1)---dlv(1091)---sample-go-serve(1100)
```

## まとめ

コンテナ内のWebアプリケーションをリモートデバッグするまでの流れを記録がてらまとめてみました。
ただ、原因がつかめていないところも多いので、[Goならわかるシステムプログラミング](https://www.lambdanote.com/products/go) を読んで、Goによるプロセス周りの扱いを勉強したいところです。