2025年はそろそろ半分、近況とか

仕事と子育て、家事が結構忙しく、あまり新しいことに手を出せてない日々が続いております。

何かしらはアウトプットしておきたいなと思い、近況やこれからの予定などを少し書いてみます。

Claude Codeを使いはじめた

時間が取れなくて本当にようやく使い始めたところなのですが、 Claude Code を使い始めました。

GitHub Copilotは普段から使っているのですが、Vibe Codingが流行りみたいで何かしらやっておかないとなと思い、Claude Codeは評判が良さそうだったので、社内のメンバーに使ってもらいつつ、私も試していくことにしました。

雑多で後回しになっているコードを書くタスクなどをこなせたらいいなと思っています。

django-jaの活動

django-jaの活動は引き続きやっています。

5月にオンラインミートアップをやりました。

https://django.connpass.com/event/354005/:embed:code

次は 7/21(月) にDjango 20周年のお祝いパーティ(飲み会?)をオフラインでやりたいね、と話しているところです。

Discordのdjango-ja にて話しているので興味ある方はぜひご参加ください。

カンファレンス参加予定

今のところ参加予定のカンファレンスです。

仕事

とても忙しいです。小さいのも含めると常に4~6プロジェクトを見ている状況。

コードを書くことは最近は少ないですが、生成AIエージェントにコードを書いてもらえるならやれることがもう少し増えるかも。

子育てと家事

子守にどうしても時間を取られてしまうので、余暇の時間がほとんどなくなっています。

仕事はリモートワークなので家で作業することが多いのですが、仕事のオンライン会議が最近増えてきて、静かな環境を用意するのが中々難しいこともあり、家の近所に作業部屋を借りることにしました。徐々に環境を整えていきたいと思います。

畑は作業時間を取れないので、今年の7月末の契約終了に合わせて一旦終了しようと思います。 子育てで余裕が出てきたら、また再開を検討したいと思います。

近況は以上です。がんばろう。

PHPでDjangoの暗号署名機能と互換性のあるライブラリを作りました

先月の記事で、DjangoのセッションデータをPHPから読み書きするのを試していました。

tokibito.hatenablog.com

この際にPHPで実装していた django.core.signing.Signer と互換性のある Signer クラスを調整してライブラリ化、再利用できるようにしました。

Packagistへ登録したパッケージ

nullpobug/django-signing という名前のパッケージで、Packagistに公開しています。 Composerでインストールして利用できます。

packagist.org

リポジトリGitHubにあります:

GitHub - tokibito/php-django-signing: Django compatible signing library for PHP

PHPStanを導入するPullRequestをtadsanからもらったりして、いい感じに仕上がりました。

Packagistへの登録をしたのは初めてですが、GitHubと連携してしまえば特に難しいことはないと感じました。便利ですね。

サンプルコード

前回の記事のコードをコピーして、作成したパッケージのほうで動くように調整してみました。

https://github.com/tokibito/sample_nullpobug/tree/main/django/django-shared-session2

セッションデータのデコード・エンコードのところを差し替えています。

<?php
// 中略
// セッションデータをデコード
$session = Api::loads($session_data, APP_SECRET_KEY, APP_SESSION_SALT);

// セッションデータをエンコード
$session_data = Api::dumps($session, APP_SECRET_KEY, APP_SESSION_SALT, true, true);

https://github.com/tokibito/sample_nullpobug/blob/main/django/django-shared-session2/phpapp/webroot/php/index.php#L52

このデモのコードがもっと短くなるように練習がてら色々作ってみるのも良いかも知れない。

参考

DjangoフレームワークのセッションデータをPHPから読み書きする

実際に必要になったわけではないので、遊びみたいなものなのですが、Djangoのセッションの仕組みを理解しておくと、こういうこともできるよ、という例のために、DjangoのセッションデータをPHPから読み書きするのをやってみました。

Djangoのセッションの概要

最初にDjangoのセッション機能についておさらいです。

Djangoのセッションについてのドキュメントは以下の通り:

セッションの使いかた | Django documentation | Django

Djangoのセッション機能は、複数の異なるHTTPリクエストにおいて、同一のブラウザ(閲覧者)からのリクエストの場合に一連の「セッション」として取り扱って、データを保持する機能を提供します。

HTTPとセッションについてはここでは説明しませんが、よくあるウェブアプリケーションフレームワークのセッション機能と同様です。

request.session が辞書ライクなオブジェクトになっており、これを読み書きできます。同一ブラウザからの異なるリクエストでこのセッションデータは保持されます。

class SpamPageView(View):
    def get(self, request):
        my_counter = request.session.get("my_counter", 0)  # セッションに保存した値の取得
        my_counter += 1
        request.session["my_counter"] = my_counter  # セッションに値を保存
        ...

セッションはユーザー認証などでも利用されています。Djangoの認証機能を使った場合は、ユーザーがログインするとこのセッションにユーザーIDなどが保持されます。

Djangoのセッションはバックエンドの実装を切り替えることで、保存先や各種挙動を変更できます。

デフォルトの動作

カスタマイズしないデフォルト設定の場合のDjangoのセッションは以下のような挙動です。

  • request.session に変更があった場合に、Djangoのセッションミドルウェアでデータが保存され、レスポンスに Set-Cookie ヘッダーでセッションIDが追加される
    • セッションIDはCookieに保存される。Cookieのキーは sessionid
  • セッションデータはデータベース上の django_session テーブルに保存される
    • カラムは session_key session_data expire_date の3つ
  • セッションデータ(request.session の辞書ライクなオブジェクト)は django.core.signing により、シリアライズ+暗号署名が付与される
    • Pythonの辞書をJSONエンコードJSONをzlibで圧縮、base64により文字列化、タイムスタンプをbase62で文字列にして付与、HMAC-sha256にてハッシュ値(base64)を生成、これを : で連結
    • つまり、改ざん検知用のハッシュ値付きで保存されている、ただし暗号化されているわけではない

データベースに保存されたセッションデータの確認

Djangoのプロジェクトを作って、管理者ユーザーを作成し、Django管理画面にログインすると、セッションデータがデータベースに追加されます。

この状態で、Djangoシェルから、セッションデータを確認してみると、以下のような文字列になっています。

>>> session = Session.objects.first()
>>> session.session_data
'.eJxVjEEOwiAQRe_C2pAOLQO4dO8ZyJQZpGpKUtqV8e7apAvd_vfef6lI21ri1mSJE6uzAnX63UZKD5l3wHeab1WnOq_LNOpd0Qdt-lpZnpfD_Tso1Mq3loxsAY1kAIdB2HqhhM5yQEY2wbnOZw8dhcweiIy3QwYm7vswIKr3B-nLN8Y:1uCK0q:WMjyqXdLN94dX2CVYdckucQvJari-41kMairMphjvmI'

先頭1文字目のドット(.)は圧縮フラグを表します。ドットがついていればデータはzlibで圧縮されています。

区切り文字はコロン(:)です。 .[データ]:[タイムスタンプ]:[署名] というフォーマットになります。

Djangoのアプリ内でそのまま読み書きするのであれば、APIの実装があるため、 session.get_decoded() のようにすれば、デコードされた辞書オブジェクトを取得できます。

>>> session.get_decoded()
{'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': 'ef6d5162ef11769ed58eac675d96d6d297708f810a9fd81aa2854f1dad339466'}

この処理は django.core.signing モジュールの機能を内部的に使っていますが、一旦改ざん検知は置いといて、Pythonの標準モジュールの機能だけでデコードをしてみます。

>>> import base64
>>> import json
>>> import zlib
>>> json.loads(zlib.decompress(base64.urlsafe_b64decode(session.session_data[1:].split(':', 1)[0] + "=")))
{'_auth_user_id': '1', '_auth_user_backend': 'django.contrib.auth.backends.ModelBackend', '_auth_user_hash': 'ef6d5162ef11769ed58eac675d96d6d297708f810a9fd81aa2854f1dad339466'}

簡単ですね。

base64については、URLセーフに対応する関数を使ってデコードしています。これはエンコード時に django.core.signing がURLセーフに対応するために urlsafe_b64encode を使っているためです。パディングのところはこの例だと雑に = を付与しています。

上記のようにデコードできるので、 Djangoのセッションデータは暗号化はされていない という点に気を付けておく必要があります。

署名による改ざん検知はできますが、暗号化はされていないため、セッションに保存する内容や保存先については気をつける必要があります。

PHP側で読み書きするコード

前置きが長くなりましたが、PHPで先程のセッションデータをデコードするのはさほど難しくはないので、読み取りだけであれば、実装は簡単です。

しかし、PHP側でセッションデータの更新をする場合、Django側は改ざん検知の仕組みがあるため、PHP側でも同じアルゴリズムシグネチャを生成して付与する必要があり、これは結構複雑です。

生成AIに手伝ってもらいながら、 django.core.signing と同等の機能を持つクラスを作ってみました。

signing.php:

<?php

function b62_encode(int $num): string
{
  if (!is_int($num) || $num < 0) {
    throw new InvalidArgumentException("Only non-negative integers allowed");
  }

  $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  $base = 62;

  if ($num === 0) {
    return '0';
  }

  $result = '';
  while ($num > 0) {
    $result = $chars[$num % $base] . $result;
    $num = intdiv($num, $base);
  }

  return $result;
}

function b62_decode(string $str): int
{
  $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  $base = 62;

  $num = 0;
  $len = strlen($str);
  for ($i = 0; $i < $len; $i++) {
    $pos = strpos($chars, $str[$i]);
    if ($pos === false) {
      throw new InvalidArgumentException("Invalid character in input: " . $str[$i]);
    }
    $num = $num * $base + $pos;
  }

  return $num;
}

function b64_encode($data): string
{
  if (!is_string($data)) {
    throw new InvalidArgumentException("Only strings allowed");
  }

  return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function b64_decode($input)
{
  $remainder = strlen($input) % 4;
  if ($remainder) {
    $padlen = 4 - $remainder;
    $input .= str_repeat('=', $padlen);
  }
  return base64_decode(strtr($input, '-_', '+/'));
}

function salted_hmac($key_salt, $value, $secret_key, $algorithm = 'sha1')
{
  if (!is_string($value)) {
    $value = strval($value);
  }
  $key = hash($algorithm, $key_salt . $secret_key, true);
  $hmac = hash_hmac($algorithm, $value, $key, true);
  return $hmac;
}

class Signer
{
  protected string $sep;
  protected string $salt;
  protected string $secret;
  protected string $algorithm;

  public function __construct(string $secret, string $salt = 'django.core.signing.Signer', string $sep = ':', string $algorithm = 'sha256')
  {
    if (preg_match('/[' . preg_quote($sep, '/') . ']/', $salt)) {
      throw new InvalidArgumentException("Salt cannot contain the separator character");
    }
    $this->secret = $secret;
    $this->salt = $salt;
    $this->sep = $sep;
    $this->algorithm = $algorithm;
  }

  protected function get_signature(string $value): string
  {
    return b64_encode(salted_hmac($this->salt . 'signer', $value, $this->secret, $this->algorithm));
  }

  public function sign(string $value): string
  {
    return $value . $this->sep . $this->get_signature($value);
  }

  public function unsign(string $signed_value): string
  {
    $sep_pos = strrpos($signed_value, $this->sep);
    if ($sep_pos === false) {
      throw new RuntimeException("Bad signature");
    }

    $value = substr($signed_value, 0, $sep_pos);
    $sig = substr($signed_value, $sep_pos + strlen($this->sep));

    $expected_sig = $this->get_signature($value);

    if (!hash_equals($expected_sig, $sig)) {
      throw new RuntimeException("Signature does not match");
    }

    return $value;
  }
}

class TimestampSigner extends Signer
{
  protected string $timestamp_salt = 'django.core.signing.TimestampSigner';

  public function make_timestamp(): string
  {
    return b62_encode(time());
  }

  public function sign(string $value): string
  {
    $timestamp = $this->make_timestamp();
    $value_with_ts = $value . $this->sep . $timestamp;
    return parent::sign($value_with_ts);
  }

  public function unsign(string $signed_value, int|null $max_age = null): string
  {
    $result = parent::unsign($signed_value);
    $parts = explode($this->sep, $result);
    if (count($parts) !== 2) {
      throw new RuntimeException("Bad signature format");
    }

    [$value, $ts_b62] = $parts;

    if ($max_age !== null) {
      $timestamp = b62_decode($ts_b62);
      $age = time() - $timestamp;
      if ($age > $max_age) {
        throw new RuntimeException("Signature has expired");
      }
    }

    return $value;
  }

  public function timestamp(string $signed_value): int
  {
    $parts = explode($this->sep, $signed_value);
    if (count($parts) !== 3) {
      throw new RuntimeException("Bad signature format");
    }
    return b62_decode($parts[1]);
  }
}

function django_signer_dumps($value, string $secret, string $salt, bool $compress = false, bool $add_timestamp = false): string
{
  $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

  if ($compress) {
    $data = zlib_encode($json, ZLIB_ENCODING_DEFLATE);
  } else {
    $data = $json;
  }

  $b64 = b64_encode($data);
  // add a dot to the beginning of the string if compress is true
  if ($compress) {
    $b64 = '.' . $b64;
  }

  if ($add_timestamp) {
    $signer = new TimestampSigner($secret, $salt);
  } else {
    $signer = new Signer($secret, $salt);
  }

  return $signer->sign($b64);
}

function django_signer_loads(string $signed_value, string $secret, string $salt, int|null $max_age = null)
{
  // Use appropriate signer
  if (substr_count($signed_value, ':') === 2) {
    $signer = new TimestampSigner($secret, $salt);
    $b64 = $signer->unsign($signed_value, $max_age);
  } else {
    $signer = new Signer($secret, $salt);
    $b64 = $signer->unsign($signed_value);
  }

  // first character is a dot, indicating compression
  $is_compressed = false;
  if (strlen($b64) > 0 && $b64[0] === '.') {
    $is_compressed = true;
    $b64 = substr($b64, 1);
  }

  $raw = b64_decode($b64);
  if ($is_compressed) {
    $json = zlib_decode($raw);
  } else {
    $json = $raw;
  }

  if ($json === false) {
    throw new RuntimeException("Base64 decoding failed");
  }

  $data = json_decode($json, true);

  if (json_last_error() !== JSON_ERROR_NONE) {
    throw new RuntimeException("JSON decoding failed: " . json_last_error_msg());
  }

  return $data;
}

こんな感じです。PHPの組み込みの関数では足りないURLセーフなbase64エンコード、デコードの機能も実装してあります。

signing.phpの利用

作成したモジュールに含まれる django_signer_loads はセッションデータをデコードする関数で、Djangodjango.core.signing.loads とおおむね同等です。

エンコードdjango_signer_dumps を使います。

<?php
require_once('/signing.php');

// 中略

// セッションデータをデコード
$session = django_signer_loads($session_data, APP_SECRET_KEY, APP_SESSION_SALT);

// 中略

// セッションデータをエンコード
$session_data = django_signer_dumps($session, APP_SECRET_KEY, APP_SESSION_SALT, true, true);

APP_SECRET_KEY の部分は、Django側の settings.pySECRET_KEY と同じ文字列を指定します。

APP_SESSION_SALT の部分は、 "django.contrib.sessions.SessionStore" を常に指定します。

サンプルコードの動作

サンプルコード全体は以下に置いています。

https://github.com/tokibito/sample_nullpobug/tree/main/django/django-shared-session

Djangoはデフォルト設定だとsqlite3のデータベースを使い、セッションもそこに保存されるので、PHPからもPDOでsqlite3を読み書きする形にしました。

  • nginxでリバースプロキシしていて、同一ドメイン名のサブディレクトリでDjangoPHPがそれぞれ動作するような形
  • /php/ 以下はPHPのアプリが動作します。
  • それ以外のパスではDjangoのアプリが動作します。

docker-composeで動かせます。あらかじめDjango側の manage.py createsuperuseradmin という名前のユーザーを作っておいて、ブラウザで http://localhost/ にアクセスします。

1. ログイン画面

Djangoの認証フレームワークでログイン画面を作っています。UIは django-bootstrap5 を使っているので、bootstrapの見た目です。

2. Django側の画面

ログインすると、Django側の画面を表示します。ログイン中のユーザー名と、セッションデータを表示しています。

3. PHP側の画面

PHP側の画面へ」のリンク先はPHPのアプリのページになります。Djangoが保存したセッションデータを同じsqlite3のデータベースファイルから取得し、デコードして表示しています。

また、セッションデータの更新の例として、PHP側のページにアクセスする毎に、 counter というキーでセッション内に数値をカウントアップしています。

4. Django側の画面(PHP側で更新したセッションデータの確認)

  • パス: /django/

PHP側のページにアクセスしたあと、Django側のページを再度表示すると、PHP側で counter キーで保存した値をDjango側でも確認できます。

まとめ

  • Djangoのセッションデータは署名付きでエンコードされて保存される
  • PHPから読み取るのは簡単だが、更新をする場合は署名の生成も必要となる
  • Django側の仕様で保存されたセッションデータをPHPで読み取り、更新することができた

こういうコードを書いてみて、Djangoのセッションの仕組みや、signerについて理解が進みました。

このような実装を実際に使うことは基本的にないと思いますが、仕組みとしては面白いですね。

PowerToysを使おう

開発用のPCにWindowsを使っているのであれば、PowerToysを入れておくことを改めておすすめしたい。

learn.microsoft.com

Windows標準機能だけでは足りない部分や、便利な機能を足すツール。Microsoftが公式にリリースしています。

私がよく使っている機能だけ紹介しておきます。

Hosts ファイル エディター

hostsファイル のエディター。コメントアウトのつけ外しもできるし、らくちん。

カラーピッカー

画面上の色コードを取得するツール。

Always On Top

任意のウィンドウをショートカットキーで最前面表示にできる。

ここに書いたもの以外にも色々機能があるので、Windowsユーザーの人で使ったことなければ、ぜひ試してみてほしい。

Dockerでrunserverを起動する際にPIDを1にする

よくありがちなことなのですが、Dockerでアプリを動かす場合は、PID 1 のプロセスにしておいた方が良いという話です。

背景

Dockerコンテナ内でDjangoアプリを開発するときに、 migraterunserver を実行するために、以下のようなDockerfileを作成することがあります。

Dockerfile:

FROM python:3.13

# 環境変数設定
ENV PYTHONUNBUFFERED=1 \
    WORKDIR=/app

# 作業ディレクトリの作成
RUN mkdir -p $WORKDIR

# 作業ディレクトリの設定
WORKDIR $WORKDIR

# 依存関係をインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Djangoのmigrate実行後にrunserverを起動
CMD python manage.py migrate && python manage.py runserver

このDockerfileを使用してコンテナを起動すると、 migrate が実行された後に runserver が起動します。

これを以下のような compose.yaml で実行し、PIDの状態を確認してみます。

compose.yaml:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    volumes:
      - .:/app
docker compose up -d
docker compose exec app ps -ef

結果は以下のようになりました。

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  1 16:50 ?        00:00:00 /bin/sh -c python manage.py migrate && python manage.py runserver
root           8       1 18 16:50 ?        00:00:00 python manage.py runserver
root           9       8 41 16:50 ?        00:00:00 /usr/local/bin/python manage.py runserver
root          11       0 80 16:50 pts/0    00:00:00 ps -ef

さて、ここでPID 1 のプロセスは /bin/sh になっていて、shからrunserverを起動しています。 runserverを実行しているPythonのPIDは 8 です。runserverは内部でDjangoのアプリをスレッドで実行しているので、PID 9Pythonのプロセスです。

この状態で、 docker compose down を実行すると、コンテナが停止して破棄されるのですが、コマンドを実行してから停止するまでに時間がかかります。10秒ぐらい。

なぜか?

この時間がかかっているのは、PID 1にSIGTERMシグナルを送信してもプロセスが停止しないからです。 Docker composeのFAQに記載があります。

docs.docker.com

デフォルトではタイムアウトが10秒になっていて、10秒待ってもPID 1 のプロセスが終了しない場合は、強制終了されます。

少し時間が経った後に終了するのは、この強制終了によって終了されているからですね。

解決策

これをきれいに解決するには、 runserver をPID 1 で実行するとよいです。

Dockerfileを以下のように変更します。

FROM python:3.13

# 環境変数設定
ENV PYTHONUNBUFFERED=1 \
    WORKDIR=/app

# 作業ディレクトリの作成
RUN mkdir -p $WORKDIR

# 作業ディレクトリの設定
WORKDIR $WORKDIR

# 依存関係をインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Djangoのmigrate実行後にrunserverをexecで起動
CMD python manage.py migrate && exec python manage.py runserver

変更したのは最後のCMDの部分です。execコマンドでrunserverを実行するようにしました。 こうすると、PID 1 のプロセスがrunserverになります。

docker compose buildでイメージをビルドしなおし、composeを起動して、PIDを確認してみます。

docker compose build
docker compose up -d
docker compose exec app ps -ef

結果は以下のようになりました。

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0 20 17:10 ?        00:00:00 python manage.py runserver
root           8       1 67 17:10 ?        00:00:00 /usr/local/bin/python manage.py runserver
root          10       0 66 17:10 pts/0    00:00:00 ps -ef

PID 1 のプロセスが python manage.py runserver になっています。

これで、 docker compose down を実行すると、すぐにコンテナが停止、破棄されます。

まとめ

Dockerでアプリを動かすときは、PID 1 のプロセスにしておくと、コンテナの停止が早くなります。

exec コマンドを使うことで、起動したプロセスのPIDを起動元のPIDにできます。

補足

本番環境だとgunicornなどでアプリを起動することが多いですが、gunicornの場合も同様にしてPID 1 で起動しておかないと、graceful shutdownにならなかったりするので、注意が必要です。

参考

tokibito.hatenablog.com

Gunicornのシグナルハンドリングを試す

Python用のWSGIアプリケーションサーバーであるGunicornは、プロセス管理機能を持つマスタープロセスと、アプリケーションを動作させるワーカープロセスで構成されています。 親プロセス側となるマスタープロセスにシグナルを送ることでワーカープロセスを制御できます。

docs.gunicorn.org

シグナルはkillコマンドを使って送信できます。

Graceful Shutdownなどの挙動を見てみます。

試したPythonのバージョンは 3.13, Gunicornは23.0.0

WSGIアプリケーションを準備

main.py:

import time

def slow_response(data: bytes, interval: int) -> iter:
    """
    指定された時間だけスリープして1バイトずつデータを返すジェネレータ
    """
    for byte in data:
        time.sleep(interval)
        yield byte.to_bytes()

def application(environ, start_response):
    """
    WSGIアプリケーション
    """
    status = '200 OK'
    headers = [
        ('Content-Type', 'text/plain'),
    ]
    start_response(status, headers)
    return slow_response(b'Hello\n', 1)

このアプリケーションは、1バイトずつデータを返すジェネレータを使用しています。 「Hello」という文字列を1バイトずつレスポンスし、1バイト返すごとに1秒待機します。

Gunicornを起動

Gunicornを起動します。

gunicorn main

main.pyに application というWSGIアプリケーションが定義されているので、Gunicornはそれを起動します。

起動するとGunicornのログにはプロセスIDがログで表示されます。 kill コマンドでプロセスを終了できることを確認しておきます。

kill <プロセスID>

curlでリクエス

Gunicornが起動したら、curlでリクエストを送ります。

curl -N http://localhost:8000/

-N オプションは、バッファリングを無効にするオプションです。 これにより、サーバーからのレスポンスをリアルタイムで受け取ることができます。

SIGTERMによるGraceful Shutdownの挙動を確認

Gunicornは、SIGTERMシグナルを受け取ると、Graceful Shutdownを行います。

killコマンドでSIGTERMシグナルを送信して、Gunicornの挙動を確認します。

killコマンドは、明示的にシグナルを指定しない場合、デフォルトでSIGTERMシグナルを送信します。

linuxjm.sourceforge.io

curlでリクエストを送信した状態で、別のターミナルでkillコマンドを実行します。

GunicornはSIGTERMを受信したタイミングで、ログに「Handling signal: term」と表示しています。

しかし、curlのリクエストが完了するまで、Gunicornはプロセスを終了しません。

curlのリクエストが完了すると、Gunicornは「Worker exiting: (pid: <プロセスID)」と表示し、プロセスを終了します。

このように、GunicornはSIGTERMシグナルを受信した後、現在処理中のリクエストが完了するまでプロセスを終了しないことがわかります。このような動作をGraceful Shutdownと呼びます。

SIGQUITによる強制終了の挙動を確認

SIGQUITシグナルを送信すると、Gunicornは現在処理中のリクエストがあっても強制終了します。

kill -s SIGQUIT <プロセスID>

同様にcurlでリクエストを送信した状態で、別のターミナルでkillコマンドを実行します。

GunicornはSIGQUITを受信したタイミングで、ログに「Handling signal: quit」と表示しています。

curlのリクエストは強制終了されました。 リクエストを処理中のアプリケーションではSystemExit例外で強制終了されました。

SIGHUPによるワーカーの再起動の挙動を確認

SIGHUPシグナルを送信すると、ワーカーを再起動します。

kill -s SIGHUP <プロセスID>

SIGHUPシグナルでは、Gunicornは古いWorkerをGraceful Shutdownし、新しいWorkerを起動します。

SIGTTINとSIGTTOUによるワーカー数の増減を確認

SIGTTINシグナルを送信すると、Gunicornはワーカー数を増やします。

SIGTTOUシグナルを送信すると、Gunicornはワーカー数を減らします。

kill -s SIGTTIN <プロセスID>
kill -s SIGTTOU <プロセスID>

curlで並列リクエストを送信して、処理中にGunicornのワーカー数を増やしてみます。

curl -N http://localhost:8000/ & curl -N http://localhost:8000/ & wait

1つ目の結果はワーカーが1つの場合です。1つのリクエストが完了するまで、次のリクエストは待機します。

2つ目の結果は途中でSIGTTINシグナルを送信して、ワーカーを2つに増やした場合です。 途中から2つのリクエストが並列で処理されているのがわかります。

その後、SIGTTOUシグナルを送信して、ワーカーを1つに減らすのを試しました。

感想

Gunicornのシグナルハンドリングを試してみました。

シグナル操作でGunicornのワーカーを制御できるのは便利ですね。

適切にシャットダウンする操作は、アプリケーションの安定性のために知っておくと良さそうです。

コード

https://github.com/tokibito/sample_nullpobug/tree/main/python/gunicorn_signal

DjangoCongress JP 2025を開催しました

2025/2/22(土)にDjangoCongress JP 2025を開催しました。 djangocongress.jp

私はスタッフとして参加しました。

オンライン配信の会でした

今回は会場見つからずとか、スタッフのライフイベントとかで、時期をずらしてのオンライン配信のイベントとなりました。経緯などは hirokiky さんの記事にも書かれているので、ぜひ読んでもらえればと思います。

blog.hirokiky.org

DjangoCongressJPでオンライン配信のみの会は今回が初めてでしたが、OBSを使った配信に慣れた人が複数いたのもあり、トラブルなくできて本当によかったです。

配信アーカイブ

YouTubeライブで配信していたので、アーカイブがあります。

ROOM1

ROOM1の司会・進行はhirokikyさん

www.youtube.com

ROOM2

ROOM2の司会・進行は私(tokibito)でした。

www.youtube.com

アンケートのお願い

当日閲覧された方、アーカイブで見た方でも、ぜひイベント後アンケートへの回答をよろしくお願いします。

DjangoCongressJP 2025イベント後アンケート

フィードバックがあるとスタッフのやる気にもつながるので!ぜひ!

海外からの登壇者

今回は国内からのCfP応募が少なめでしたが、DSFの方がフォーラムで登壇者募集をアナウンスしてくれたりして、

  • 日本国内の登壇者7人
  • 海外からの登壇者7人

となりました。

登壇者のタイムゾーンUTC+0UTC+10 でしたので、海外の方はなるべく無理のない時間帯で登壇してもらったり、タイムテーブルを調整しました。

ライブでの字幕と同時通訳(翻訳)

配信を視ていただいた方はわかると思いますが、今回、ライブ配信でリアルタイムの文字起こしと翻訳(日本語→英語、英語→日本語)を配信画面に表示していました。

この文字起こしと翻訳部分は私が所属するObotAIで開発している Minutz というサービスを提供しました。

obot-ai.com

今回、海外からの登壇で英語発表が半分あるということもあり、どうしても日本の参加者が盛り上がるには言語の壁があって、何とかできるとといいなと思っていました。 会社で販売している製品の文字起こし精度が、たまたま最近大幅に改善したのもあり、hirokikyさんに無償提供を提案して試してもらったところ、満足いく精度で大丈夫そう、となったので採用してもらうことになりました。

私個人としても、英語のリスニングはまだまだなので、文字起こしと翻訳があることで、かなりリアルタイムで発表内容を理解することができました。 これはすごい体験だなと思いました。

また、海外の方(日本語がわからない方)が、日本語から翻訳された英語字幕で配信を視てくれていて、字幕・翻訳なしの従来の配信よりも良い体験ができたみたいで、これもすごいことだなと思いました。

オンラインの国際カンファレンス(国際学会)のようなことをできたので、すごくいい事例になったと思います。提案を受け入れていただき感謝しています。

Minutzの開発にもDjangoをたくさん使っている(サーバー側は全部DjangoDjango channels)ので、Djangoを使ったサービスでDjangoコミュニティに貢献できたのもうれしかったです。

おわりに

スタッフの皆さん、登壇者の皆さん、視聴者のみなさん、ありがとうございました。 予定は未定ですが、DjangoCongressJPは次回もあると思うので、その際には、ぜひCfPに応募してみてほしいです。