skaffold のメモ

EKS/Kubernetes での開発のために skaffold を使ってみたメモ。 自分用メモなので How To 的なものはありません。

例のための最小限の skaffold.yaml の構成。

apiVersion: skaffold/v3
kind: Config
build:
  artifacts:
    - image: my-app
      docker:
        dockerfile: Dockerfile
      sync:
        infer:
          - app/**
        hooks:
          after:
            - container:
                command: ["sh", "-c", "kill -USR2 1 || true"]
manifests:
  rawYaml:
    - manifests/*.yaml
deploy:
  kubectl: {}
portForward:
  - resourceType: service
    resourceName: my-app
    port: 80
    localPort: 9876

これで skaffold dev すると次のことが行われます。

  • Dockerfile からイメージをビルド
    • my-app:<hash> のようにタグ付けされる
  • 元のマニュフェスト manifests/*.yaml から一部が置換された新しいマニュフェストを生成
    • image がビルドでタグ付けされたイメージに置換されたり
    • metadata.labels が追記されたり
  • 生成したマニュフェストを kubectl apply でクラスタにデプロイ
  • ローカルの 9876 ポートを kubernetes の service/my-app/80 にポートフォワード

また、ファイルシステムが監視され、ファイルの編集に応じて次のように処理されます。

  • Dockerfile、または、Dockerfile で COPY しているファイル(app/** は除く)
    • ビルド~マニュフェスト生成~デプロイ、のやり直し
  • app/**
    • 変更されたファイルをコンテナにコピー(kubectl cp
    • コンテナの pid:1 に USR2 シグナルを送る
  • manifests/*.yaml
    • マニュフェストを再生成してクラスタにデプロイ

マニュフェストのイメージの書き換え

ビルドされたイメージは skaffold によってタグ付けされ、元のマニュフェストファイルの image の箇所に置換したうえでデプロイされます。

例えば次の skaffold.yaml の場合、

# skaffold.yaml
apiVersion: skaffold/v3
kind: Config
build:
  artifacts:
    - image: my-nginx
      docker:
        dockerfile: docker/nginx/Dockerfile
    - image: my-php-fpm
      docker:
        dockerfile: docker/php-fpm/Dockerfile
manifests:
  rawYaml:
    - manifests/*.yaml

build.artifacts.*.image に指定した my-nginxmy-php-fpm が Pod や Deployment の image と一致していると、該当箇所が skaffold によってビルドされたタグ付きのイメージに置換されます。

例えば次の Pod のマニュフェストだと image: my-nginximage: my-php-fpm は一致するビルドイメージがあるので置換されます。image: my-nodejs は一致するものが無いので置換されません。

# manifests/pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
    - name: nginx
      image: my-nginx
    - name: php-fpm
      image: my-php-fpm
    - name: nodejs
      image: my-nodejs

次のコマンドで置換されたマニュフェストの内容が表示できます。

skaffold build -q | skaffold render -a -

skaffold がタグをどのように生成するかは skaffold.yaml の build.tagPolicy で指定できる、とありますが紛らわしいことにここで指定したものは実際にマニュフェストが書き換えられるときのタグ値とは異なります。

参考: https://skaffold.dev/docs/taggers/

build.tagPolicy はイメージのビルド時に付けられるタグを決めるもので、イメージのビルド前に計算されます。(ローカルクラスタの場合)ビルド後、ビルドされたイメージの ID そのものでタグがさらに追加され、マニュフェストファイルはその ID そのものであるタグ値で書き換えられます。

例)build.tagPolicy に基づくタグ(デフォルトは git のコミットハッシュ)と IMAGE ID そのもののタグの両方が保存される

REPOSITORY TAG            IMAGE ID
my-nginx   3c77f04        649cff01c83d
my-nginx   649cff01c83d   649cff01c83d

なお、skaffold はマニュフェスト内から image というキーを探してきて置換します。ので、次のように image というラベルがあるとそれも置換されます。

# manifests/pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-app
  labels:
    image: my-nginx # ここも置換される(label として不正な文字が含まれることになるためデプロイでエラーとなる)
spec:
  containers:
    - name: nginx
      image: my-nginx

これが問題になるようなことはまずないと思うけれども、次のように置換する位置は制限できます。

# skaffold.yaml
resourceSelector:
  allow:
    - groupKind: "Pod"
      image: [".spec.containers.*.image"]

参考: https://skaffold.dev/docs/tutorials/skaffold-resource-selector/

Helm

skaffold の Helm のサポートは Helm Renderer(manifest.helm.*)と Helm Deployer(deploy.helm.*)の2種類存在します。

Helm Renderer

# skaffold.yaml
apiVersion: skaffold/v3
kind: Config
build:
  # ...snip...
manifests:
  helm:
    releases:
      - name: my-app
        chartPath: helm/
        setValueTemplates:
          image.fqn: '{{ .IMAGE_FULLY_QUALIFIED_my_app}}'
        upgradeOnChange: true

Helm Deployer

apiVersion: skaffold/v3
kind: Config
build:
  # ...snip...
deploy:
  helm:
    releases:
      - name: my-app
        chartPath: helm/
        setValueTemplates:
          image.fqn: '{{ .IMAGE_FULLY_QUALIFIED_my_app}}'
        upgradeOnChange: true

これは次のような違いがあります。

  • Helm Renderer
    • helm template でマニュフェストを生成したうえで kubectl apply でデプロイする
  • Helm Deployer
    • Helm チャートから直接 helm install でデプロイする

バグなのかどうかわからないけれども、Helm Renderer だと Helm チャートのファイル監視が効かないようで、チャートやテンプレートを修正しても再デプロイされません(後者だとチャートを編集すると自動的に再デプロイされる)。

イメージの削除

普通に skaffold dev していると Docker にビルドされたイメージがごみのようにたまります。skaffold を次のように実行しておくと Ctrl+C で止めたときに skaffold が作成していたイメージが削除されるようになります。

skaffold dev --no-prune=false --cache-artifacts=false

参考: https://skaffold.dev/docs/cleanup/

--no-prune=false は skaffold によってビルドされたイメージを終了時に削除するかどうかのオプションで、デフォルトだと prune されません。

--cache-artifacts=false はアーティファクトのキャッシュを有効にするかどうかのフラグです。デフォルトでは ~/.skaffold/cacke にアーティファクトのハッシュ(Dockerfile や COPY のソースなどのファイルのハッシュ)とイメージのダイジェスト(sha256)の対応が追記されています。イメージのビルド時、計算されたアーティファクトのハッシュに対応するイメージが存在するならビルドをスキップしてそのイメージが使われます。

参考: https://github.com/GoogleContainerTools/skaffold/issues/4842

skaffold の終了時に --no-prune=false でイメージを削除する場合はアーティファクトキャッシュを有効にする意味が無いので --cache-artifacts=false も一緒に指定するのが良いもよう。

都度コマンドラインオプションで指定するのも面倒なので環境変数でも指定できます。

export SKAFFOLD_NO_PRUNE=false
export SKAFFOLD_CACHE_ARTIFACTS=false

参考: https://skaffold.dev/docs/references/cli/#skaffold-dev

ただ、これを設定すると当たり前だけれども skaffold dev の開始のたびにフルビルドが走るため一長一短です。一日中 skaffold dev しっぱなしならともかく、あげてはさげてを繰り返す場合、毎回フルビルドが走るのはつらい。

build.artifacts.*.docker.cacheFrom などを活用してフルビルドを避けることも考えられそう。

Helm チャートを envsubst で生成

values.template.yaml から envsubst で values.yaml を生成していたので、skaffold でも values.template.yaml の編集で自動的に values.yaml を生成→デプロイしたかったのだけど、簡単にはできなさそう。

before-deploy フックで values.yaml を生成することを考えてみたけど、ダメ、無限ループに陥る。

  • before-deploy フック → ファイル生成 → 変更を検出 → before-deploy フック → ファイル生成 → 変更を検出 → ...

参考: https://github.com/GoogleContainerTools/skaffold/issues/7183 \ 参考: https://github.com/GoogleContainerTools/skaffold/issues/7216

カスタムビルドで無理やりできなくもない?

apiVersion: skaffold/v3
kind: Config
build:
  artifacts:
    - image: my-app-values
      custom:
        dependencies:
          paths:
            - helm/values.template.yaml
        buildCommand: |
          envsubst < helm/values.template.yaml > helm/values.yaml
          docker pull alpine
          docker tag alpine $IMAGE

    # ..snip..

values.template.yaml に変更があればこのビルドが走るので values.yaml が生成される。カスタムビルドは環境変数 $IMAGE で指定されたイメージをビルドしなければならないので(イメージをビルドするためのカスタムビルドなので)、適当なイメージを pull してタグ付けする。

これで出来なくもなさそうだけど・・適当な別ツールを併用するほうが素直で良さそう。

skaffold dev &
watchexec -w helm/values.template.yaml -- 'envsubst < helm/values.template.yaml > helm/values.yaml'
# values.template.yaml が編集されると values.yaml も更新される
# values.yaml は skaffold が監視しているので自動デプロイが走る

あるいは自動デプロイを無効にしたうえで skaffold api でデプロイをトリガできる。

skaffold dev --auto-deploy=false --rpc-http-port=50052 &
watchexec -w helm/values.template.yaml -- '
    envsubst < helm/values.template.yaml > helm/values.yaml &&\
    curl -X POST http://localhost:50052/v1/execute -d "{\"deploy\": true}"
'
# values.template.yaml が編集されて values.yaml が更新されたあと、
# skaffold の API を叩いてデプロイを実行する

参考: https://skaffold.dev/docs/design/api/#control-api

マルチステージビルドの FileSync

ドキュメントにさらっと書いていますが、マルチステージビルドではどのステージを target にしているかにかかわらず、最後のステージのみが検査されます。ので、最後のステージ以外で COPY している場合は次のような manual の同期ルールが必要です。

参考: https://skaffold.dev/docs/filesync/#manual-sync-mode

Dockerfile をマルチステージビルドするように書き換えたところ skaffold の FileSync が動かなくなり、原因調査に小一時間ぐらい溶かしました。ドキュメントはしっかり読もう。

Deployment の minReadySeconds

k8s の Deployment で minReadySeconds に適当な値(60 秒とか)を指定したところ、skaffold dev で環境を立ち上げた後、ポートフォワードが有効になるまでに 60 秒待たされるようになりました。Service でポートを公開しているポッドの Deployment とは別の、バックグラウンドジョブのための Deployment の minReadySeconds を指定しただけにも関わらずです。

どうも skaffold はマニュフェストで定義されているすべての Deployment が準備完了になるまでポートフォワードを開始しないようです。ので、実環境で minReadySeconds を設定する場合でも 、ローカル用のマニュフェストでは minReadySeconds が 0 になるようにしておくと良いです。

さいごに

実環境が Kubernetes でもローカルでの開発は docker compose を使っておく方が嵌りどころが少なくて良いと思います。ただ、なんらかの事情で Kubernetes を開発に使う必要があるならビルドからデプロイまでひとつひとつやるのはかなり煩雑なので skaffold でサクッと自動化すると便利です。

ただ skaffold dev だけでいろいろされすぎて、裏で何がどうなっているのか理解しにくいようにも思いました。いっそのころ、どのファイルが編集されたときにどの処理が行われるかを明示的にすべて指定する、とかでも良いような気もしました。うーん、でもそれ、skaffold に対する grunt/gulp のようなもので、むしろ逆行してしまっているかも・・やっぱり skaffold で良いかな。

Kubernetes の HPA(HorizontalPodAutoscaler) の Utilization

kubernetes の HPA(HorizontalPodAutoscaler)の Utilization で指定する cpu 使用率、いわゆる論理1コアを 100 とするパーセンテージなのかと思っていたら違ったのでメモ。

例えば次のように設定されているとき、

# HorizontalPodAutoscaler
  ...snip...
  minReplicas: 1
  maxReplicas: 40
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 80
  ...snip...
# Pod
  ...snip...
        resources:
          limits:
            cpu: 100m
          requests:
            cpu: 50m
  ...snip...

この状態でポッドで yes > /dev/null するとポッドのリソース制限値(resources.limits)が 100m = 0.1 = 10% なのでCPU使用率は 10% まであがります。

HorizontalPodAutoscaler の Utilization はポッドのリソース要求値(resources.requests)に対するパーセンテージのようなので、この設定だと 50m * 80% = 40m = 0.04 = 4% となり CPU 使用率(のポッドの平均)が 4% を超えただけでスケールアウトし、この例だとポッド数は 3 まで増えます。

以下計算式。

50 * 0.8 = 40 < 100 / 1 = 100
50 * 0.8 = 40 < 100 / 2 =  50
50 * 0.8 = 40 > 100 / 3 =  33.3333....

次のようにポッドの cpu の limits を削除するとCPU使用率は 100% まであがります。

# Pod
        resources:
          requests:
            cpu: 50m

このときポッド数は 22 まで増えました。単純計算で 25 まで増えると思いましたが、1000/22 は 45.454545... なのでだいたいそれぐらいなのでしょうか(ちょっと不可解)。

次に Pod の cpu の requests と 500m にしてみると、

# Pod
        resources:
          requests:
            cpu: 500m

スケールアウトの閾値が 400m = 0.4 = 40% となるのでポッド数は 3 までしか増えません。


ポッドではなくノードのオートスケールも要求量に対するキャパシティで行われるので(オートスケーラーの設定などによるもしれない・・そもそも Fargate だとどうなるのかとかは判らない)、2番目の例は1番目や3番目の例と比べて同じノードの多数のポッドが作られやすくなりそうです。

なお次のようにすれば絶対量でも指定できるようです(この例だとコアの50%)。

# HorizontalPodAutoscaler
  minReplicas: 1
  maxReplicas: 40
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: AverageValue
          averageUtilization: 500m

コアファイルを gdb with VSCode で解析

とあるサーバで1年ぐらい前に発生していた Apache が異常終了したときに abrt によって収集されていたコアファイルを解析したメモ。

abrt について

abrt というのはプロセスでコアダンプが発生したときにそれを所定のディレクトリに保管したり(デフォルトだとカレントディレクトリなのでシステム内にコアファイルがまき散らされてしまう)、発生時の状況を収集して保存したり、さらにメール等で通知してくれるやつです。

RHEL7 系ディストリなら @base をインストールすれば有効になるので大抵の環境で有効になってると思います。

RHEL8 系ディストリだと別途インストールのうえ有効にする必要があります。RHEL8 ならコアダンプ時は systemd-coredump によって abrt と似たような情報が収集されるので必要無い、ということかもしれません。

むしろ RHEL8 だと abrt は非奨励らしいので有効にしないほうがよいのかも。

abrt の保管場所

下記のような場所に保管されています。

/var/spool/abrt/ccpp-2022-02-11-03:21:02-7654

この中にはコアダンプ発生時の様々な情報が入っています。

例えば core_backtrace には発生時のスタックトレースが入っています。この内容は ELF ファイル(Windows でいうところの exe とか dll とかのいわゆる実行可能ファイル)に含まれている情報を元にしているのでソースファイルや行番号などといった詳細な情報は含まれていません。辛うじて .so ファイルがエクスポートしているシンボル名だけは解決されています。

{
  "signal": 6,
  "executable": "/usr/sbin/httpd",
  "stacktrace": [
    {
      "crash_thread": true,
      "frames": [
        {
          "address": 139743219704439,
          "build_id": "cb4b7554d1adbef2f001142dd6f0a5139fc9aa69",
          "build_id_offset": 221815,
          "function_name": "raise",
          "file_name": "/usr/lib64/libc-2.17.so"
        },
        {
          "address": 139743219710312,
          "build_id": "cb4b7554d1adbef2f001142dd6f0a5139fc9aa69",
          "build_id_offset": 227688,
          "function_name": "abort",
          "file_name": "/usr/lib64/libc-2.17.so"
        },
        :
        :
      ]
    }
  ]
}

var_log_messages には発生時の /var/log/messages が入っています。

[System Logs]:
:
:
Feb 11 03:21:02 ore-no-server abrt-hook-ccpp[13577]: Process 7654 (httpd) of user 0 killed by SIGABRT - dumping core
Feb 11 03:21:02 ore-no-server systemd[1]: httpd.service: main process exited, code=dumped, status=6/ABRT
Feb 11 03:21:02 ore-no-server systemd[1]: httpd.service: control process exited, code=exited status=1
Feb 11 03:21:02 ore-no-server systemd[1]: Unit httpd.service entered failed state.
Feb 11 03:21:02 ore-no-server systemd[1]: httpd.service failed.

coredump がいわゆるコアファイルです。

コアファイルを開くための Docker 環境

コアファイルを gdb で開くために同じディストリの環境を Docker で作ります。元環境は CentOS 7.5.1804 でした。

docker run -it -v "$PWD:$PWD" -w "$PWD" --name debug centos:7.5.1804

コアファイルのスタックトレースに対応する debuginfo が必要です。これはバージョンも完全に一致する必要があります。前述の core_backtrace に含まれていた build_id は ELF バイナリを一意に識別するなにかのハッシュ値になっていて、このハッシュ値をもとにした /usr/lib/debug/.build-id/80/619621be79fdb8007b60fbf5c634dfbe12a318 のようなシンボリックリンクが debuginfo パッケージには含まれています。

要するにこういうことです。

rpm -qf /usr/lib/debug/.build-id/80/619621be79fdb8007b60fbf5c634dfbe12a318
#=> curl-debuginfo-7.29.0-46.el7.x86_64

readlink -f /usr/lib/debug/.build-id/80/619621be79fdb8007b60fbf5c634dfbe12a318
#=> /usr/lib64/libcurl.so.4.3.0

ので build_id を手がかりにバージョンも完全に一致する debuginfo をインストールできます。まずは core_backtrace に含まれる build_id をリストアップします。

cat core_backtrace | jq .stacktrace[].frames[].build_id -r | sort | uniq
#=> 468cdcf74ee3ea5d4b33541534c67444dd8619a3
#=> 584240581e74bb6f7a0b99111286cd8fc2a43695
#=> 80619621be79fdb8007b60fbf5c634dfbe12a318
#=> cb4b7554d1adbef2f001142dd6f0a5139fc9aa69
#=> d260f476eb23fd11cbc0a825b7e36b0f0c66f190
#=> de762a28174110911b273e175d54f222b313cfe0

シンボリックリンク名に読み替えて debuginfo をインストールします。

yum --enablerepo='*debug*' install \
  /usr/lib/debug/.build-id/46/8cdcf74ee3ea5d4b33541534c67444dd8619a3 \
  /usr/lib/debug/.build-id/58/4240581e74bb6f7a0b99111286cd8fc2a43695 \
  /usr/lib/debug/.build-id/80/619621be79fdb8007b60fbf5c634dfbe12a318 \
  /usr/lib/debug/.build-id/cb/4b7554d1adbef2f001142dd6f0a5139fc9aa69 \
  /usr/lib/debug/.build-id/d2/60f476eb23fd11cbc0a825b7e36b0f0c66f190 \
  /usr/lib/debug/.build-id/de/762a28174110911b273e175d54f222b313cfe0

次の警告が表示されました。

No package /usr/lib/debug/.build-id/58/4240581e74bb6f7a0b99111286cd8fc2a43695 available.
No package /usr/lib/debug/.build-id/d2/60f476eb23fd11cbc0a825b7e36b0f0c66f190 available.

これは PHP の mod_php と curl 拡張です。remi リポジトリのものなので remi-release をインストールしてリポジトリを追加する必要があります・・と言いたいところですが remi リポジトリは古いパッケージが維持されていないので remi リポジトリを追加しても対応する debuginfo はインストールできません。

とりあえず PHP は諦めるとして、他は次の通りに debuginfo がインストールされました。

curl-debuginfo   7.29.0-46.el7          x86_64
glibc-debuginfo  2.17-222.el7           x86_64
httpd-debuginfo  2.4.6-80.el7.centos.1  x86_64
nspr-debuginfo   4.19.0-1.el7_5         x86_64

これで gdb でコアダンプを読み込める・・と思ったのですが、どうやら debuginfo だけでなく元の ELF バイナリそのものが必要なようです・・?

curl/glibc/nspr は CentOS 7.5.1804 の base に最初から入っているものなので対応不要ですが、httpd だけは updates にあったものなので CentOS Vault から入れる必要があります。直接 RPM をダウンロードしたり yum リポジトリを追加するなどしても良いですが、次のようにすると手っ取り早いです。

# CentOS の yum リポジトリを最新にする
yum update centos-release

# Vault の C7.5.1804 リポジトリを有効にしてインストール
yum --enablerepo='C7.5.1804-*' install httpd-2.4.6-80.el7.centos.1.x86_64

これで gdb でコアファイルを開けるようになります。試しにスタックトレースを表示してみると次のようにソースファイルや行番号まで表示されるようになりました。

gdb -c coredump --ex bt
 :
Core was generated by `/usr/sbin/httpd -DWEB -DFOREGROUND'.
Program terminated with signal 6, Aborted.
#0  0x00007f1880f86277 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:56
56        return INLINE_SYSCALL (tgkill, 3, pid, selftid, sig);
#0  0x00007f1880f86277 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:56
#1  0x00007f1880f87968 in __GI_abort () at abort.c:90
#2  0x00007f187888a7bc in PR_Assert (s=s@entry=0x7f18788aa62a "0 == rv", file=file@entry=0x7f18788aa3f0 "../../.././nspr/pr/src/pthreads/ptthread.c", ln=ln@entry=973)
    at ../../../nspr/pr/src/io/prlog.c:553
#3  0x00007f18788a368d in _PR_InitThreads (type=type@entry=PR_USER_THREAD, priority=priority@entry=PR_PRIORITY_NORMAL, maxPTDs=maxPTDs@entry=0) at ../../../nspr/pr/src/pthreads/ptthread.c:973
#4  0x00007f1878894334 in _PR_InitStuff () at ../../../nspr/pr/src/misc/prinit.c:180
#5  0x00007f18788944e5 in _PR_ImplicitInitialization () at ../../../nspr/pr/src/misc/prinit.c:265
#6  PR_Init (type=type@entry=PR_USER_THREAD, priority=priority@entry=PR_PRIORITY_NORMAL, maxPTDs=maxPTDs@entry=256) at ../../../nspr/pr/src/misc/prinit.c:266
#7  0x00007f1879d36935 in Curl_nss_init () at nss.c:1059
#8  0x00007f1879d2c8dc in Curl_ssl_init () at sslgen.c:191
#9  0x00007f1879d1e1b5 in curl_global_init (flags=3) at easy.c:225
#10 0x00007f1879f634b8 in ?? ()
 :

ここまでの内容で一旦 Docker イメージを作っておきます。

docker commit debug debug

VSCode でコアファイルを開く

VSCode で Dev Container を↑で作った Docker イメージで実行します。

// .devcontainer/devcontainer.json
{
    "name": "debug",
    "image": "debug",
    "extensions": [
        "ms-vscode.cpptools"
    ]
}

デバッグを始めるための設定をします。下記の URL を参考にしました。

// .vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "cppdbg",
            "request": "launch",
            "name": "Open a core dump(c/c++)",
            "program": "/usr/sbin/httpd",
            "coreDumpPath": "${file}",
            "cwd": "${workspaceFolder}",
            "MIMode": "gdb"
        }
    ]
}

VSCode でコアファイルを選択して F5 です。

すごいべんり。

さいごに

解析結果 → ぜんぜんわからない。

AWS SDK for PHP の SQS::ReceiveMessage でシグナルを受けたときに long pooling を中断して終了したい

<?php
pcntl_async_signals(true);

$sig_handler = function ($signo) use (&$term){
    echo "signal:$signo\n";
    $term = true;
};

pcntl_signal(SIGHUP, $sig_handler);
pcntl_signal(SIGINT, $sig_handler);
pcntl_signal(SIGQUIT, $sig_handler);
pcntl_signal(SIGTERM, $sig_handler);

$sqs = new SqsClient([
    'region'  => 'ap-northeast-1',
    'version' => 'latest',
]);

while (!$term) {
    $messages = $sqs->receiveMessage([
        'QueueUrl'        => $queue,
        'WaitTimeSeconds' => 20,
    ]);

    // 何か処理する、途中でシグナルを受けても死なない
}

SQS からメッセージを受信し、何かの処理が完了するまではシグナルを受けてもプロセスを終了しないようにするためにシグナルハンドラを仕込んでいます。しかしこのコードでは WaitTimeSeconds で指定した long polling 中にもシグナルで終了しなくなるため、終了させたいときにサッと終了しません。

非同期シグナルが有効になっていればシグナルハンドラでの割り込みは利くし AWS SDK for PHP(の中の Guzzle) の中で呼ばれている curl_multi_select もシグナルで中断されるものの AWS SDK for PHP(の中の Guzzle)が自動的に curl_multi_select を再試行するため sqs::receiveMessage から抜けてこなくて、きっちり WaitTimeSeconds まで待たされます。

sqs::receiveMessage で待っているときは実質何もしていないのでシグナルを受けたらすぐにサッと終了してほしいです。終了が最大で20秒も遅延するのはちょっと長すぎます。

いくつか改善案を考えてみました。

シグナルを処理しない

PHP でシグナルを処理しようとするから終了が遅れてしまうのであってシグナルを処理しなければ即死するので、シグナルを処理しないことにします。

<?php
$sqs = new SqsClient([
    'region' => 'ap-northeast-1',
    'version' => 'latest',
]);

for (;;) {
    $messages = $sqs->receiveMessage([
        'QueueUrl'        => $queue,
        'WaitTimeSeconds' => 20,
    ]);

    // 何か処理する、途中でシグナルを受けて死んでもいいじゃない
}

いやそれで終わりならこんな記事書かんわ。

メッセージを受信した後、何か処理しているときにはできればシグナルでは即死せず、何かの処理が終わってから死んでほしいです。

処理中はシグナルをブロックする

シグナルハンドラは仕込まず、かつ、何か処理中はシグナルをブロックします。

<?php
$sqs = new SqsClient([
    'region' => 'ap-northeast-1',
    'version' => 'latest',
]);

for (;;) {
    $messages = $sqs->receiveMessage([
        'QueueUrl'        => $queue,
        'WaitTimeSeconds' => 20,
    ]);

    pcntl_sigprocmask(SIG_BLOCK, [SIGHUP, SIGINT, SIGQUIT, SIGTERM]);
    try {

        // 何か処理する、途中でシグナルを受けても死なない

    } finally {
        pcntl_sigprocmask(SIG_UNBLOCK, [SIGHUP, SIGINT, SIGQUIT, SIGTERM]);
    }
}

シグナルハンドラで exit する

シグナルハンドラで exit すれば普通に終了します。 何か処理中は死んでほしくないのでフラグ変数とかで分岐させます。

<?php
pcntl_async_signals(true);

$processing = false;

$sig_handler = function ($signo) use (&$term, &$processing){
    echo "signal:$signo\n";
    if (!$processing) {
        exit;
    }
    $term = true;
};

pcntl_signal(SIGHUP, $sig_handler);
pcntl_signal(SIGINT, $sig_handler);
pcntl_signal(SIGQUIT, $sig_handler);
pcntl_signal(SIGTERM, $sig_handler);

$sqs = new SqsClient([
    'region' => 'ap-northeast-1',
    'version' => 'latest',
]);

while (!$term) {
    $messages = $sqs->receiveMessage([
        'QueueUrl'        => $queue,
        'WaitTimeSeconds' => 20,
    ]);
    try {
        $processing = true;

        // 何か処理する、途中でシグナルを受けても死なない

    } finally {
        $processing = false;
    }
}

シグナルハンドラから例外を投げる

先ほどの例とあまり変わりませんが、シグナルハンドラから例外を投げれば無理やり SQS::ReceiveMessage を抜けることができます。 なお、飛んでくる例外は AWS SDK の例外クラスでラップされているので getPrevious で剥がす必要があります。

<?php
class SignalException extends RuntimeException {}

pcntl_async_signals(true);

$processing = false;

$sig_handler = function ($signo) use (&$term, &$processing){
    echo "signal:$signo\n";
    if (!$processing) {
        throw new SignalException("signal:$signo", $signo);
    }
    $term = true;
};

pcntl_signal(SIGHUP, $sig_handler);
pcntl_signal(SIGINT, $sig_handler);
pcntl_signal(SIGQUIT, $sig_handler);
pcntl_signal(SIGTERM, $sig_handler);

$sqs = new SqsClient([
    'region' => 'ap-northeast-1',
    'version' => 'latest',
]);

while (!$term) {
    try {
        $messages = $sqs->receiveMessage([
            'QueueUrl'        => $queue,
            'WaitTimeSeconds' => 20,
        ]);
    } catch (Throwable $ex) {
        if ($ex->getPrevious() instanceof SignalException) {
            break;
        }
        throw $ex;
    }
    try {
        $processing = true;

        // 何か処理する、途中でシグナルを受けても死なない

    } finally {
        $processing = false;
    }
}

シグナルハンドラからリクエストをキャンセルする → ダメ

ダメでした。WaitTimeSeconds の時間を待ったうえで wait から CancellationException が飛んでくるという動きになりました。

<?php
pcntl_async_signals(true);

$sig_handler = function ($signo) use (&$term, &$async){
    echo "signal:$signo\n";
    if ($async) {
        assert($async instanceof Promise);
        $async->cancel();
        $async = null;
    }
    $term = true;
};

pcntl_signal(SIGHUP, $sig_handler);
pcntl_signal(SIGINT, $sig_handler);
pcntl_signal(SIGQUIT, $sig_handler);
pcntl_signal(SIGTERM, $sig_handler);

$sqs = new SqsClient([
    'region' => 'ap-northeast-1',
    'version' => 'latest',
]);

while (!$term) {
    $async = $sqs->receiveMessageAsync([
        'QueueUrl'        => $queue,
        'WaitTimeSeconds' => 20,
    ]);
    try {
        $messages = $async->wait();
    } catch (CancellationException) {
        break;
    }
    $async = null;

    // 何か処理する、途中でシグナルを受けても死なない
}

素の Guzzle で requestAsync などを使うと大丈夫だったのですが・・・謎。

指定されたコマンドを子プロセスで実行し、特定のシグナルを受けたときは子プロセス終了時にリスタートするシェルスクリプト

はじめに

  • php で作ったキューワーカーのようなサービスをコンテナで実行させる
  • サービスはシグナルをハンドリングしていわゆるに Graceful に終了したい
  • サービスが終了したときは普通にコンテナも終了する
  • ただしローカルの開発環境では特定のシグナルを受けたときはコンテナを終了させずにサービスだけリスタートする
  • そのためにシェルスクリプトを書いてサービスはそのシェルスクリプトから実行する

実装例その1

コンテナの init プロセスとして dumb-init を使って「dumb-init -> sh -> サービス」のようなプロセスツリーになるなら、次のようにシンプルに実装できそうです。

#!/bin/sh

trap restart=1 HUP
trap restart=  INT
trap restart=  QUIT
trap restart=  TERM

while :; do
  restart=
  "$@"
  status=$?
  if [ -z "$restart" ]; then
    exit "$status"
  fi
done

dumb-init がシグナルを受けると(-c オプションが指定されていなければ)直接の子プロセスだけでなく孫プロセスにもまとめてシグナルが転送されます。ので、シェルスクリプトからサービスへシグナルを送る必要はなく、単にサービスが終了したときにリスタートするかどうかの条件として最後に受けたシグナルを使えば良いだけです。

この方法の問題点は↑で列挙しているシグナル(HUP/INT/QUIT/TERM)以外のシグナルを受けると、例えサービスでそれをハンドリングしていても sh のプロセスが落ちてしまい、直下のプロセスが落ちたことで dumb-init も終了してコンテナ自体が終了するため、サービスのプロセスも突然死します。ただ、サービスでハンドリングしているシグナルを列挙しておけば良いだけなので、問題ではありません。

他の問題点をあげるとすれば・・dumb-init からシグナルが複数のプロセスに送られた後、各プロセスでシグナルハンドラが実行されるタイミングは決まっていないだろうので、HUP シグナルの後に sh の trap で restart=1 が実行される前に、サービスのプロセスが終了して sh の while ループを抜けてしまう可能性がゼロでは無いかもしれません(未確認)。ただリスタートしたいのはローカル環境だけのことでプロダクション環境で使いたいわけではないので、この程度の問題は目を瞑ります。

実装例その2

別の実装案。dumb-init が無くても動きます。また、シェルスクリプトが予期しないシグナルなどで終了するときもサービスのプロセスの終了を一定時間待ちます。ただ予期しないシグナルでも trap EXIT を発動させるために bash が必要です()。

#!/bin/bash

on_exit() {
  if [ -n "$pid" ]; then
    kill -TERM "$pid"
    for x in 0 1 2 3 4 5 6 7 8 9; do
      if ! kill -0 "$pid" 2>/dev/null; then
        exit
      fi
      sleep 1
    done
    kill -KILL "$pid"
  fi
}

trap 'restart=1 ; [ -n "$pid" ] && kill -HUP  "$pid"' HUP
trap 'restart=  ; [ -n "$pid" ] && kill -INT  "$pid"' INT
trap 'restart=  ; [ -n "$pid" ] && kill -QUIT "$pid"' QUIT
trap 'restart=  ; [ -n "$pid" ] && kill -TERM "$pid"' TERM
trap 'on_exit' EXIT

while :; do
  restart=
  "$@" &
  pid=$!
  while :; do
    wait
    if kill -0 "$pid" 2>/dev/null; then
      continue
    fi
    pid=
    if [ -z "$restart" ]; then
      exit
    fi
    break
  done
done

この実装だとサービスの終了コードが何であってもシェルスクリプトの終了コードは 0 になってしまいます。

さいごに

特定のシグナルでリスタートしたいことがあるのはローカル環境だけで、プロダクション環境では素朴に dumb-init の直下で実行すればよかったので、それならローカル環境でのみ runit とか supervised とかでリスタートさせればよいか・・と思ったのですがそれだけのために runit やら supervised やらを出してくるのは仰々しい気がしたので、シェルスクリプトを書いてみました。

S3 に保存された CloudFront のログを見やすく整形するツーライナー

要するに TSV や CSV は column コマンドで見やすくなる&# の行はちょっと特殊なので加工が必要。

aws s3 cp s3://XXX/YYY/ZZZ/ ./log/ --recursive --exclude='*' --include '*.2022-10-27-*'
zcat log/*.gz |
  sed -r '/^#Version/d;/^#Fields:/{s/\s/\t/g;s/^#Fields:\s+/#/}' |
  LANG=C sort |
  uniq |
  column -s $'\t' -t |
  less -S

ログに含まれる UNIX タイムスタンプを書式化するワンライナー

ログに日時が [1659316846] のように UNIX タイムスタンプで記録されているときに人にわかりやすく書式化するワンライナー。

cat <<'EOS' >log.log
[1659316843] りんご
[1659316844] ごりら
[1659316845] らっぱ
[1659316846] perl
EOS

cat log.log | perl -MPOSIX -ple 's/^\[(\d+)\]/strftime("[%Y-%m-%dT%H:%M:%S]",localtime($1))/e'
# [2022-08-01T10:20:43] りんご
# [2022-08-01T10:20:44] ごりら
# [2022-08-01T10:20:45] らっぱ
# [2022-08-01T10:20:46] perl

ワンライナーっつっても perl だけど・・これをワンライナーと呼んでよいのかどうか(謎)