LoginSignup
18
12

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-04-25

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

TL;DR

  • Delveというデバッガーを使うと、リモートデバッグができる
  • Realizeというタスクランナーと組み合わせる場合は、いくつか回避策が必要になった
  • リモートデバッグのクライアントにはGoLandを使用した(他のエディタも対応状況はこちらを参照)

サンプルコード

この記事にあげているコードは、こちらにあります。
bellwood4486/sample-go-containerized-debug

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

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

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

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を返すだけです。


❯ 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

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を設定する
* VSCode: デバッグの設定に remotePath を指定する

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

ビルドする際は、インライン化などの最適化を無効にしておく必要があります。
go build に渡すオプションについては、Delveのexecコマンドのマニュアルに載っています。

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" を指定しました。
オプションの値については、こちらが参考になります。

  • -N:最適化オフ
  • -l:インライン化オフ

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用ステージを用意しました。

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コマンドのオプションについてはこちらを参照ください。
ポイントは、--continue オプションです。このオプションの有無でリモートデバッグの挙動が次のように変わります。

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

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

このことはDelveのFAQに書いてあります。

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参考2)

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

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

Linuxのcapabilityの一つに、ptrace(2)というシステムコールを使って任意のプロセスをトレースするCAP_SYS_PTRACEがあります。--cap-add=SYS_PTRACEは、このcapabilityを追加するオプションです。GoLandのガイドでは指定されていますが、もしかしたら不要かもしれません。こちらのブログがとても参考になります。

今回は、docker-composeからコンテナを起動しているので、docker-compose.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

コンテナを立ち上げる

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

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" ...

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

❯ curl http://localhost:8080
hello world

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

❯ 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からアプリケーションが呼び出されていることがわかります。

/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

ポート番号は、Dockerfileで定義したデバッグ用ポート番号を指定します。(今回の場合は2345)
スクリーンショット 2020-04-18 12 29 14

自分の環境では、デバッガから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

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

さらに Realize と組み合わせる

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

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

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

  • -gcflags "all=-N -l".realize.yaml に直接書くと go build が失敗する
  • Delveとアプリケーションのプロセスが終了しない
  • アプリケーションのプロセスがゾンビプロセスとして残る
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 にビルドコマンドを直接書いていました。

# ... 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

# ... 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

#!/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

# ... 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'に変えて保存します。

//... 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ならわかるシステムプログラミング を読んで、Goによるプロセス周りの扱いを勉強したいところです。

18
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
12