Atsushi2022の日記

データエンジニアリングに関連する記事を投稿してます

Docker パーサーディレクティブ

Parser directive

Parser directiveの実体はDockerfileの先頭行に記述する特殊なコメントで、Dockerfileの後続行の処理方法に影響する。

syntaxescapeの2種類のパーサーディレクティブが存在する。

syntax

実はDockerfile 内で使用される構文は交換可能で、syntaxで構文の処理方法を指定することができる。例えば、以下のように先頭行に# syntax=docker/dockerfile:1と記述することで、Dockerfile内でヒアドキュメントを使用できるようになる。

# syntax=docker/dockerfile:1
FROM golang:1.21 as build
WORKDIR /src
COPY <<-EOF /src/main.go
  package main

  import "fmt"

  func main() {
    fmt.Println("hello, world")
  }
EOF
RUN go build -o /bin/hello ./main.go

docker/dockerfileイメージにより、BuildKitを使用してイメージをビルドできるようになる。ちなみにBuildKitとは、コンテナをビルドするためのデーモンらしい。

BuildKit とは、コンテナをビルドするためのツールキットであり、 buildkitdというデーモンとbuildctlコマンドで構成されています。 Dockerの標準のビルドと比べて、BuildKitでビルドした場合には以下のようなメリットがあります。

  • マルチステージDockerfileの各ステージを並列ビルドできる
  • ビルドキャッシュをDockerHubなどに外部保存/再利用ができる
  • SSH接続でリモートのファイルを取得
  • 秘密鍵などのファイルをイメージ内に残さないようにマウント

また、BuildKitの一部機能はDocker 18.06以降のDocker Engineに統合されており、 Docker単体でもBuildKitの一部機能を利用することができます。

syntaxはDockerfile Frontendとも呼ばれる。syntaxで外部のDockerfile frontend(dockerfileというDockerイメージ)を指定することで、構文処理の方法を変更することができる。BuildKitやDocker Engineをアップグレードしなくても、最新のDockerfile frontendを使用できる。

BuildKitは不使用のビルドステージを検知し、スキップする。例えば、次のようなDockerfileがあると、最終イメージはstage2になる。stage2baseステージから作成され、stage1は使われないステージになっている。このときBuildKitはbasestage2のみ処理し、依存関係のないstage1はスキップされる。一方、レガシーのビルダーでは、stage1も含め全てのステージが処理される。

# syntax=docker/dockerfile:1
FROM ubuntu AS base
RUN echo "base"

FROM base AS stage1
RUN echo "stage1"

FROM base AS stage2
RUN echo "stage2"

escape

escapeは、エスケープ文字を指定するために使用するパサーディレクティブ。デフォルトのエスケープ文字はバックスラッシュ\で、変更したい場合に使用するが、あまり用途はない気がする。

# escape=\

参考

BuildKit Dockerfile Frontend

Custom Dockerfile syntax

docker/dockerfile

Docker BuildKitを理解する

BuildKit

BuildKitとは

Differences between legacy builder and BuildKit

Docker マルチステージビルド

Multi-stage builds

要は複数のFROMを使用したビルドのこと。各FROM句により新しいビルドステージが始まる。

COPYにより、前のステージから新しいステージに必要なビルド作成物(アーティファクト、要はファイルのこと)のみコピーすることで、最終イメージサイズを小さくすることができる。

以下の例では2つ目のFROM句でscratchイメージという空のイメージを使用し、COPY句で/bin/helloファイルのみコピーすることで、/bin/helloバイナリのみのイメージができる。

FROM golang:1.21 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

1つ目のFROM句でASにより、ステージにbuildという名前をつけている。そして、2つ目のステージでCOPY --from=buildとすることで、buildというステージからファイルをコピーしている。

次のようにFROM句で以前のステージを指定することもできる。

# syntax=docker/dockerfile:1

FROM alpine:latest AS builder
RUN apk --no-cache add build-base

FROM builder AS build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp

FROM builder AS build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp

参考

Multi-stage builds

Docker ビルドコンテキスト

Build context

Local context

最初に学ぶビルドはdocker build .だろうと思う。これは「Local context」と呼ばれる。Dockerはカレントディレクト.を参照し、取り込む。

local_context

標準入出力からビルドするケースや、tarファイルからビルドするケースも、Local contextに含まれる。

Remote context

GitのHEADを参照してビルドを行う。ビルダーは、デフォルトでリポジトリのデフォルトブランチの最新のコミットをcloneする。

docker build https://github.com/user/myrepo.git

プライベートリポジトリの場合は、SSHトークン認証の認証情報が必要になる。

Empty context

Dockerfileがローカルのファイルに依存しない場合はEmpty contextでビルドできる。

  • Unix pipe

      docker build - < Dockerfile
    
  • PowerShell

      Get-Content Dockerfile | docker build -
    

参考

Build context

Docker ベストプラクティス

Dockerのベストプラクティスについて調べたことをまとめる。

一般的なガイドライン

  • ベースイメージのバージョンを固定する。ダイジェスト参照を使用するとなお良し
  • ビルドキャッシュを活用する
  • RUNでバックスラッシュを使用して、引数を複数行に並べる
  • アプリケーションを分離する。複数のアプリケーションを単一のコンテナに入れない
  • 不要なパッケージはインストールしない。例えばDBイメージにテキストエディタはいらない
  • マルチステージビルドを使用して最終イメージサイズを縮小する
  • .dockerignoreファイルでビルドに関係ないファイルを除外する

イメージ

  • ベースイメージにはオフィシャルイメージを使用すること(自前のイメージをベースイメージにしない)
  • サイズが小さいAlpineを推奨

LABEL

  • ラベルをつけることでイメージやコンテナを検索できる


  • Dockerfile dockerfile LABEL python_version="3.8.12"

  • 検索

    > docker images --filter "label=python_version=3.8.12"
    REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
    mmtest       latest    b2cf0b43f1dc   About a minute ago   2.39GB
    > docker container ls --filter "label=python_version=3.8.12"
    CONTAINER ID   IMAGE     COMMAND       CREATED          STATUS          PORTS     NAMES
    b15779dec25a   mmtest    "/bin/bash"   13 seconds ago   Up 12 seconds             blissful_chebyshev
    

RUN

  • RUNに記述するステートメントが長くなった場合は、可読性のためバックスラッシュで複数行にする
  • RUN apt-getにはいくつか直観に反する挙動がある
    • apt-get updateを実行するときは常にapt-get installを同じRUNステートメントに入れる(キャッシュ無効化"cache busting"と呼ばれるテクニック)。apt-get updateだけ実行するとキャッシュ問題を引き起こし、後続のapt-get installで問題が起こる
      • OK

          RUN apt-get update && RUN apt-get install -y curl
        
        • バージョン固定化「version pinning」でもキャッシュ無効化できる

          RUN apt-get update && RUN apt-get install -y curl=7.74.0
          
      • NG
        • buildすると、Dockerでは全レイヤーをDockerキャッシュに保存する。buildを複数回行った場合、Dockerは、最初の命令と2回目以降の命令を同一とみなし、前のステップのキャッシュを再利用する。その結果、apt-get updateは実行されず、古いバージョンのcurlおよびnginxパッケージを取得する可能性がある。

          RUN apt-get update
          RUN apt-get install -y curl
          
    • aptキャッシュの削除
      • /var/lib/apt/lists配下のファイルを削除し、イメージサイズを減らす

        RUN apt-get update && RUN apt-get install -y curl
          && rm -rf /var/lib/apt/lists/*
        
    • パイプのエラー処理
      • RUNでは/bin/sh -cで実行する。パイプの最後のオペレーションのみでexitコードを評価するため、パイプの途中の処理でエラーになっていてもエラーにならないケースがある
      • エラーで失敗させたい場合はset -o pipefail &&を使用する

        RUN set -o pipefail && wget -O - https://some.site | wc -l > /number
        

ENV

  • バージョン番号をENVでセットすると保守が楽になる
  ENV PG_MAJOR=9.3
  ENV PG_VERSION=9.3.4
  RUN curl -SL https://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgres && â¦
  ENV PATH=/usr/local/postgres-$PG_MAJOR/bin:$PATH
  • ENVRUN同様レイヤーを作成することになる。ENV移行のレイヤーで環境変数unsetしてもレイヤーに維持され、値がダンプされる。これを防いで、環境変数unsetするには、1つのRUNsetunsetを実行し、環境変数setunsetする必要がある。
# syntax=docker/dockerfile:1
FROM alpine
RUN export ADMIN_USER="mark" \
    && echo $ADMIN_USER > ./mark \
    && unset ADMIN_USER
CMD sh

ADD or COPY

COPYbuild context、またはmulti-staged buildのステージからファイルをコンテナにコピーする。

一方、ADDはリモートのHTTPSまたはGitリポジトリからファイルをフェッチして追加する。

COPYの代わりにbind mountを使うこともできる。bind mount はbuild contextからファイルをincludeできるため COPYよりも効率的である。

bind mountされたファイルは単一のRUN実行のために一時的にマウントされ、最終イメージには維持されない。最終イメージにファイルを維持したい場合はCOPYを使用すること。

RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --requirement /tmp/requirements.txt

より確実にビルドキャッシュするには、wgettarによる手動でのリモートファイル追加よりも、ADDの方が望ましい。

ENTRYPOINT

ENTRYPOINTでコマンドを指定し、コマンドのパラメータをCMDに設定するのがベストプラクティス。docker runでパラメータを渡してCMDの引数を上書きすることができる。

FROM buildpack-deps:bullseye
ENTRYPOINT ["/bin/bash", "-c"]
CMD ["ls"]

USER

  • 非特権ユーザでサービスを実行する場合にはUSERで非ルートユーザーに変更する。USERで非ルートユーザーになる場合は、事前にグループとユーザーを作成しておく
  RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres
  • 但し、複雑さ回避のため、USERの頻繁な使用は避ける
  • sudoのインストールと使用は避ける。予期しないTTYと信号転送動作問題を引き起こす可能性があるらしい。どうしてもsudoを使用する必要がある場合、例えばデーモンをルートとして初期化して非ルートとして実行する場合などは、gosuの使用を検討すること

WORKDIR

  • 明確さと可読性のために、WORKDIRには常に絶対パスを使用すること
  • 可読性と保守性の観点からRUN cd … && do-somethingと記述することは避けるべき。そういった場合はWORKDIRを使用すること

参考

Best practices for Dockerfile instructions

Dockerfile reference

BigQueryのFORループでデータセットの全テーブルに対してクエリを投げる

複数のテーブルのレコード件数を表にまとめたいことがたまにある。

そういう時はFORループを使用して、count(*)した結果を一時テーブルに入れて確認したりする。

DECLARE dataset STRING DEFAULT "test_dataset";

CREATE OR REPLACE TEMP TABLE record_counts (
  table_name STRING
  ,record_count INT64
); 

FOR table IN (SELECT table_name FROM `region-us.INFORMATION_SCHEMA.TABLES` AS name WHERE table_schema = dataset)
DO
  EXECUTE IMMEDIATE FORMAT(
  """
  INSERT INTO record_counts
  SELECT '%s', count(*)
  FROM `%s.%s`
  """
  ,table.table_name
  ,dataset
  ,table.table_name
  );
END FOR;

SELECT * FROM record_counts;

参考

https://www.yasuhisay.info/entry/2022/03/14/093500

BigQuery INFORMATION_SCHEMAのアクセス権限

INFORMATION_SCHEMAのビューを使用して、テーブルやカラムの情報を取得して、テーブル作成などに使用することが多いけれど、INFORMATION_SCHEMAビューにアクセスするのに権限が必要なことは知らなかった。

権限を持っていないと、SELECTしても次のようなエラーになる。

Access Denied: Table xxxxxxx:region-us.INFORMATION_SCHEMA.TABLES: User does not have permission to query table xxxxxxx:region-us.INFORMATION_SCHEMA.TABLES, or perhaps it does not exist in location US.

TABLESビューにクエリするには、下記のいずれかのロールを割り当てれば良い。

  • roles/bigquery.admin
  • roles/bigquery.dataViewer
  • roles/bigquery.metadataViewer

参考

TABLESビュー 必要な権限

PythonでのEnum

標準ライブラリにあるEnumクラスにより、Enumを使用することができる。

列挙型は定数を表すために使われるため、Enumのメンバの名前は大文字にすることが推奨される。

次のコードで挙動を確認することができる。詳細については列挙型 HOWTOにわかりやすくまとめられている。

from enum import Enum, auto


class Colors(Enum):
    """
    >>> print(Colors.GREEN)
    Colors.GREEN
    >>> Colors.GREEN
    <Colors.GREEN: 2>
    >>> Colors(2)
    <Colors.GREEN: 2>
    >>> Colors["GREEN"]
    <Colors.GREEN: 2>
    >>> Colors(2).name
    'GREEN'
    >>> Colors(2).value
    2
    >>> Colors["MIDORI"] # MIDORIはGREENの別名
    <Colors.GREEN: 2>
    >>> Colors.MIDORI # MIDORIはGREENの別名
    <Colors.GREEN: 2>
    >>> [color for color in Colors]
    [<Colors.RED: 1>, <Colors.GREEN: 2>, <Colors.BLUE: 3>]
    >>> {color.name: color.value for color in Colors}
    {'RED': 1, 'GREEN': 2, 'BLUE': 3}
    """
    RED = 1
    GREEN = 2
    BLUE = 3
    MIDORI = 2


class Weekday(Enum):
    """
    >>> Weekday.FIRST_DAY_OF_WEEK
    <Weekday.MONDAY: 1>
    >>> from datetime import datetime
    >>> day = datetime.strptime("2024-03-23", '%Y-%m-%d')
    >>> Weekday.from_date(day)
    <Weekday.SATURDAY: 6>
    """
    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
    THURSDAY = 4
    FRIDAY = 5
    SATURDAY = 6
    SUNDAY = 7
    FIRST_DAY_OF_WEEK = 1

    @classmethod
    def from_date(cls, date):
        return cls(date.isoweekday())


class Vehicles(Enum):
    """
    >>> [member.value for member in Vehicles]
    [1, 2, 3]
    """
    Car = auto()
    Airplane = auto()
    Bicycle = auto()


class AutoName(Enum):
    @staticmethod
    def _generate_next_value_(name, start, count, last_values):
        return name


class Ordinal(AutoName):
    """
    >>> [member.value for member in Ordinal] # _generate_next_value_()でauto()で返す値をオーバーライド
    ['NORTH', 'SOUTH', 'EAST', 'WEST']
    """
    NORTH = auto()
    SOUTH = auto()
    EAST = auto()
    WEST = auto()


if __name__ == "__main__":
    import doctest

    doctest.testmod()

参考

列挙型 HOWTO

列挙型のサポート