Docker container で sleep infinity を使う時は init プロセスを有効にする

Docker container で sleep infinity を使う時は init プロセスを有効にしないとSIGINTなどのシグナルの処理がうまくできずにハングするので、これを使う時は有効にする必要がある。docker compose の compose.yaml ではinit: true の指定が init プロセスを有効にするオプションとなる。

compose.yaml:

---
services:
  app_base:
    build:
      context: .
      dockerfile: Dockerfile
    init: true
    command: sleep infinity

axios でリクエストログを出力する

JavaScriptでXHRやFetchなどで通信するとブラウザの開発コンソールのネットワークなどにログが出力されますが、Next.js のSSRなど、Node.js で実行する場合はログはでないです。そのため、通信ログが欲しい場合は、なんらかの方法でログを出力する必要がありますが、axios を使用している場合、以下のようにすると通信ログが出力されるようです。

import axios from 'axios';

axios.interceptors.request.use((request) => {
  console.log('Starting Request: ', request);
  return request;
});

axios.interceptors.response.use((response) => {
  console.log('Response: ', response);
  return response;
});

axios.get("https://example.com");

簡易的な確認の場合、これでもいいのですが少々見にくいので以下のように整形すると見やすいです。

import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';

/**
 * JSONを整形する場合は`true`にする
 */
const pretty = false;

/**
 * `axios`で通信するときにログを出力する
 * @notice 秘匿情報のマスクなどは行っていないので、ローカル環境でのみ有効にするかマスク処理を追加する。
 */
export function injectAxiosLogger(instance: AxiosInstance): AxiosInstance {
  if (process.env.NODE_ENV === 'production') return instance;
  const bar = [...times(120)].map((_) => '-').join('');

  instance.interceptors.request.use((request) => {
    const req = dumpAxiosRequestConfig(request);
    console.log(`${bar}\nStarting HTTP Request\n${bar}\n${req}\n${bar}`);
    return request;
  });

  instance.interceptors.response.use((response) => {
    const req = dumpAxiosRequestConfig(response.config);
    const res = dumpAxiosResponse(response);
    console.log(
      `${bar}\nHTTP Request Completed\n${bar}\n${req}\n${bar}\n${res}\n${bar}`
    );
    return response;
  });

  return instance;
}

/**
 * HTTPリクエストをダンプする
 * @see AxiosRequestConfig https://github.com/axios/axios?tab=readme-ov-file#request-config
 * @see https://sabljakovich.medium.com/axios-interceptors-log-request-and-response-72b01333a760
 */
export function dumpAxiosRequestConfig(req: AxiosRequestConfig) {
  const method = req.method?.toLocaleLowerCase() || '';
  const baseURL = req.baseURL || '';
  const path = req.url || '';
  const params =
    req.params instanceof URLSearchParams
      ? req.params
      : new URLSearchParams(req.params || {});
  let headers = req.headers || {};
  headers = {
    ...(headers.common || {}),
    ...(headers[method] || {}),
    ...headers,
  };
  for (let h of ['common', 'get', 'post', 'head', 'put', 'patch', 'delete']) {
    delete headers[h];
  }
  const data = req.data;

  const rawParams = params.toString();
  const rawHeaders = Object.entries(headers)
    .map(([k, v]) => `${k}: ${v}`)
    .join('\n');
  const rawData = dumpData(data);
  let msg = `${method.toUpperCase()} ${baseURL}${path} HTTP/1.1`;
  msg += rawParams ? `?${rawParams}` : '';
  msg += rawHeaders ? `\n${rawHeaders}` : '';
  msg += rawData ? `\n\n${rawData}` : '';
  return msg;
}

/**
 * HTTPレスポンスをダンプする
 * @see AxiosResponse https://github.com/axios/axios?tab=readme-ov-file#response-schema
 */
export function dumpAxiosResponse<T>(res: AxiosResponse<T>) {
  const status = res.status;
  const statusText = res.statusText;
  const headers = res.headers || {};
  const data = res.data;

  const rawHeaders = Object.entries(headers)
    .map(([k, v]) => `${k}: ${v}`)
    .join('\n');
  const rawData = dumpData(data);

  let msg = `HTTP/1.1 ${status} ${statusText}`;
  msg += rawHeaders ? `\n${rawHeaders}` : '';
  msg += rawData ? `\n\n${rawData}` : '';
  return msg;
}

function *times(n: number) {
  for (let i = 0; i < n; i++) yield i;
}

function dumpData(data: any) {
  return data
    ? typeof data === 'object'
      ? pretty
        ? JSON.stringify(data, null, ' ')
        : JSON.stringify(data)
      : data
    : '';
}

実行結果:

------------------------------------------------------------------------------------------------------------------------
Starting HTTP Request
------------------------------------------------------------------------------------------------------------------------
GET https://example.com HTTP/1.1
Accept: application/json, text/plain, */*
Content-Type: undefined
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
HTTP Request Completed
------------------------------------------------------------------------------------------------------------------------
GET https://example.com HTTP/1.1
Accept: application/json, text/plain, */*
Content-Type: undefined
User-Agent: axios/1.6.0
Accept-Encoding: gzip, compress, deflate, br
------------------------------------------------------------------------------------------------------------------------
HTTP/1.1 200 OK
age: 252936
cache-control: max-age=604800
content-type: text/html; charset=UTF-8
date: Mon, 06 Nov 2023 10:42:07 GMT
etag: "3147526947+gzip"
expires: Mon, 13 Nov 2023 10:42:07 GMT
last-modified: Thu, 17 Oct 2019 07:18:26 GMT
server: ECS (laa/7BA2)
vary: Accept-Encoding
x-cache: HIT
content-length: 648

<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        
    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 2em;
        background-color: #fdfdff;
        border-radius: 0.5em;
        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        div {
            margin: 0 auto;
            width: auto;
        }
    }
    </style>    
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

------------------------------------------------------------------------------------------------------------------------

これでaxiosの通信ログを出力できるようになりましたが、使用するにあたっては注意が必要で、パスワードやアクセストークンなどの秘匿情報もログに出力されてしまうので、ローカル環境以外は無効にするか、マスク処理を実行する必要があります。

また、上記のサンプルコードは sandbox/ts-axios-logger · mrk21/sandbox にあります。

TypeScript の definite assignment assertion operator の話

TypeScript の class では、プロパティは初期化指定子かコンストラクタで初期化していないとエラーになります。そのため、以下のように記述する必要があります。

class Hoge {
  value1: string = 'a';
  value2: number;
  // value3: boolean; // ERROR: Property 'value3' has no initializer and is not definitely assigned in the constructor.

  constructor() {
    this.value2 = 1;
  }
}

しかし、たとえば Vue.js の Component class の props のように Vue.js 側で初期化するプロパティについては少々困ります。この確実に初期化されるが初期化指定子やコンストラクタで初期化はしないという状況の時に、明確な割り当てアサーション演算子(definite assignment assertion operator) ! 1を使用することでこの問題を回避できます。

@Component
export default class HogeComponent extends Vue {
  @Prop({ type: String, required: true }) value1!: string;
  @Prop({ type: Number, required: true }) value2!: number;
  @Prop({ type: Boolean, required: true }) value3!: boolean;
}

bashで複数の子プロセスを並行で実行して終了を待つ方法(Ctrl+cでの終了に対応)

bashで複数の子プロセスを並行で実行して終了を待つには & でバックグラウンド処理しつつ wait で待つことで実現できるのですが、この方法だと Ctrl+c したときに子プロセスが残ってしまいます。

(
  command1 &
  command2 &
  wait
)

ここで、jobs -p を使えば、子プロセスのPID一覧が取得できるので、trap を使って SIGINT をトラップしたときに、jobs -p で得られたPIDを kill すれば子プロセスも kill することができます。

(
  trap 'kill $(jobs -p)' EXIT;
  command1 &
  command2 &
  wait
)

しかし、jobs -pzsh だとうまく動かないので、この場合は bash -c を使って実行する必要があります。

bash -c "
  trap 'kill \$(jobs -p)' EXIT;
  command1 &
  command2 &
  wait
"

参考

GitHub Actions でローカルの composite を使う場合

GitHub Actions でローカルにある composite を使う場合は、あらかじめチェックアウトしていないと、ファイルが見つからないとエラーが出てしまうので注意する。

NG:

name: hoge
on:
  push:
jobs:
  hoge:
    name: Hoge
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/composite/fuga

OK:

name: hoge
on:
  push:
jobs:
  hoge:
    name: Hoge
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - uses: ./.github/composite/fuga

CIでGCPのServiceAccountを使って認証する方法

GCPでServiceAccountで認証するときはGOOGLE_APPLICATION_CREDENTIALS環境変数に鍵ファイルのパスを設定するが、CI実行時にはどうするんだろうと考えた結果、鍵ファイルをbase64エンコードしたものをCIの環境変数に設定して、CI実行時にデコードしてGOOGLE_APPLICATION_CREDENTIALS環境変数に設定されているパスに出力とかしたけど、もっといい方法ないのかな。

# 1. 鍵ファイルをbase64でエンコード
cat /path/to/key.json | base64

# 2. 以下をCIの環境変数に設定
#   GOOGLE_APPLICATION_CREDENTIALS_CONTENT 1.でエンコードした文字列
#   GOOGLE_APPLICATION_CREDENTIALS /path/to/key.json

# 3. base64でエンコードされた鍵ファイルをデコードして、`GOOGLE_APPLICATION_CREDENTIALS` のパスに出力
echo $GOOGLE_APPLICATION_CREDENTIALS_CONTENT | base64 -d > $GOOGLE_APPLICATION_CREDENTIALS

Docker で Compose V2 などをキャッチアップした

docker-compose up ではなく docker compose up を使うように推奨されていることは知っていたが、ちゃんとキャッチアップしてなかったのでした。また、その過程で Compose V2 以外にも新たに知ったことがあったのでここに記す。

docker-compose との違い

大雑把には以下が異なる。

  • docker-compose は docker とは別のスタンドアローンバイナリだったが、docker compose は docker cli plugin として提供される
  • docker compose は Compose Spec に準拠するようになった

Compose Spec

Composeファイルの標準仕様で、プラットフォームやクラウドに依存しない仕様として策定されている模様。

Compose Spec V2 では従来から主に以下が変更/追加されている。

  1. Composeファイルは docker-compose.yml から compose.yaml になった
  2. トップレベルの version は非推奨になった
  3. 設定ファイルのマウントを行う configs が追加された
  4. 秘匿情報のマウントを行う secrets が追加された
  5. 設定の拡張を行う extends が追加された

1. 仕様ファイルは docker-compose.yml から compose.yaml になった

そのまま。後方互換性のため docker-compose.yml も使えるが、今後標準は compose.yaml となる。なお、Compose Spec では compose.yml も使えるが、拡張子は yaml のほうが好ましいとのこと。

2. トップレベルの version は非推奨になった

今までは、トップレベルの version でComposeファイルを検証していたが、これが今後非推奨となるので記述しなくなる模様。

3. 設定ファイルのマウントを行う configs が追加された

今までは、volumes で行っていたが、今後は設定ファイルの類は configs を使用する。docker compose を使っている限りだと volumes と特に大差はないようだが、プラットフォームごとに実装方法を変えられるように独立した模様。

compose.yaml:

services:
  db:
    image: mysql:8
    configs:
      - source: mysql-config
        target: /etc/mysql/conf.d/my.cnf
    ...

configs:
  mysql-config:
    file: ./my.cnf

4. 秘匿情報のマウントを行う secrets が追加された

秘匿情報のマウントが行える secrets が追加された。configs とは実装が異なる可能性があるので、独立している模様。

compose.yaml:

services:
  app:
    image: golang:1.20.1
    secrets:
      - source: app-secrets
        target: /app/.env
    ...

secrets:
  app-secrets:
    file: ./app-secrets.env

5. 設定の拡張を行う extends が追加された

他のserviceの設定をマージ/拡張できるようになる extends が追加された。同一ファイルのみならず、別ファイルも参照できる模様。

compose.common.yaml:

services:
  common:
    init: true
    environment:
      TZ: utc

compose.yaml:

services:
  app:
    image: golang:1.20.1
    extends:
      file: compose.common.yaml
      service: common
    ...

  db:
    image: mysql:8
    extends:
      file: compose.common.yaml
      service: common
    ...

Compose V2 以外のアレコレ

mysqladmin ping を使って、DBコンテナのMySQLに接続できる状態になってから、アプリケーションコンテナを起動する

depends_on を使えば、コンテナ間の依存関係を定義できるので、DBコンテナが起動してからアプリケーションコンテナを起動するのができたし、今までもこれを使ってた。

compose.yaml:

services:
  app:
    image: golang:1.20.1
    depends_on:
      - db
    ...

  db:
    image: mysql:8
    ...

しかし、これだとdbコンテナが起動したあとに対象コンテナを起動するという指定にすぎないので、dbコンテナが起動しているがMySQLにはまだ接続できない状態には対処できない。その場合はdbコンテナの healthcheckmysqladmin ping で疎通確認し、依存先コンテナの depends_oncondition: service_healthy を指定すればできる。

compose.yaml:

services:
  app:
    image: golang:1.20.1
    depends_on:
      db:
        condition: service_healthy
    ...

  db:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "0.0.0.0"]
      interval: 1s
      timeout: 1s
      retries: 30
      start_period: 1s
    ...

docker compose で作成されるコンテナ名などのプレフィックスを変更する

docker compose で作成されるコンテナ名などのプレフィックスは、Composeファイルのトップレベルの name によって指定できる。また、COMPOSE_PROJECT_NAME環境変数でこの指定はオーバーライドできる。

compose.yaml:

name: docker-compose-v2
services:
  ...
$ docker compose up
[+] Running 4/3
 ⠿ Network docker-compose-v2_default  Created                                                                                                                                     0.0s
 ⠿ Volume "docker-compose-v2_db"      Created                                                                                                                                     0.0s
 ⠿ Container docker-compose-v2-db-1   Created                                                                                                                                     0.1s
 ⠿ Container docker-compose-v2-app-1  Created                                                                                                                                     0.1s
...
$ COMPOSE_PROJECT_NAME=other-name docker compose up
[+] Running 4/3
 ⠿ Network other-name_default  Created                                                                                                                                            0.0s
 ⠿ Volume "other-name_db"      Created                                                                                                                                            0.0s
 ⠿ Container other-name-db-1   Created                                                                                                                                            0.1s
 ⠿ Container other-name-app-1  Created                                                                                                                                            0.1s
...

なお、Composeファイルでは環境変数で設定値の指定ができるが、nameでは使用できない模様。

compose.yaml:

name: ${BASE_NAME}-docker-compose-v2
services:
  ...
$ BASE_NAME=hoge docker compose up
[+] Running 4/4
 ⠿ Network base_name-docker-compose-v2_default  Created                                                                                                                           0.1s
 ⠿ Volume "base_name-docker-compose-v2_db"      Created                                                                                                                           0.0s
 ⠿ Container base_name-docker-compose-v2-db-1   Created                                                                                                                           0.1s
 ⠿ Container base_name-docker-compose-v2-app-1  Created                                                                                                                           0.6s
...

環境

  • OS: macOS Ventura 13.2
  • Docker Desktop: 4.16.2
  • Docker Engine: 20.10.22
  • Docker Compose: 2.15.1

参考

Compose V2

その他

サンプルコード

この記事で使用したサンプルコードは以下にある。

github.com/mrk21/sandbox/docker-compose-v2