PodmanではじめるRed Hatのミドルウェア製品:OpenJDK

Red Hatでソリューションアーキテクトをしている田中司恩(@tnk4on)です。

Red Hatの製品にはミドルウェア製品が多数あるのですが、数も種類も多くて細かい機能までは把握しきれていません。 個人の観点ではこれまでIT業界の仕事はインフラ畑でやってきたのでミドルウェアについて深く触れることもありませんでした。 そこで、Podmanとコンテナの力を借りて、Red Hatのミドルウェアと仲良くなろうというのがこの企画の趣旨です。 今回はOpenJDKについて深堀していきたいと思います。

(追記:続きの記事を書きました。合わせてお読みください) rheb.hatenablog.com

-目次-


OpenJDKとは

OpenJDKについてはRed HatのOpenJDKの製品*1ドキュメントに下記のように記載されています。

  • Open Java Development Kit (OpenJDK) は、Java Platform Standard Edition (Java SE) のオープンソース実装です。Red Hat build of OpenJDK は、8u、11u、17u、21u の 4 つのバージョンで利用できます。
  • Red Hat build of OpenJDK 向けパッケージは、Red Hat Enterprise Linux および Microsoft Windows で利用でき、Red Hat Ecosystem Catalog の JDK および JRE として同梱されています。

Red Hatが製品として提供しているOpenJDKは「Red Hat build of OpenJDK」が正式名称です。本記事執筆時点では「21.0.3」が最新です。

Red Hatが提供するOpenJDKのコンテナイメージ

Red Hatが提供するOpenJDKのコンテナイメージにはいくつか種類があります。

Ecosystem Catalogを検索していくつか出てきますが、関連度で上位に上がってくるのはUBIベースのものです。

Red Hat Ecosystem Catalogの検索結果
Red Hat Ecosystem Catalogの検索結果

https://catalog.redhat.com/search?gs&q=openjdk&searchType=containers

このUBIベースのOpenJDKイメージは、ベースイメージのUBIと同じくUBI EULAに基づいて無償で使えます。ただし、このOpenJDKのイメージをベースに作成した新たなイメージを再配布する場合は、UBIと同じライセンス上の制限がある点はご注意ください。

詳細はUBIのFAQを参照

Universal Base Images FAQ | Red Hat Developer

PodmanやSkopeoを使ってコンテナイメージ中に設定されているライセンス条項を確認できます。 (Podmanについては後述)

$ skopeo inspect docker://registry.access.redhat.com/ubi9/openjdk-21 --format '{{ index .Labels "com.redhat.license_terms" }}'
https://www.redhat.com/en/about/red-hat-end-user-license-agreements#UBI

エコシステムカタログを検索するとOpenJDKのコンテナイメージにはいろいろバリエーションがありますが、完全に無償で使えるのはregistry.access.redhat.comでホストされているものです。(例:registry.access.redhat.com/ubi9/openjdk-21) registry.redhat.io/openjdkの使用には適格なサブスクリプションが必要です(コンテナレジストリへのログインが必要)

OpenJDKのコンテナイメージの入手

まずはOpenJDKのコンテナイメージを入手します。registry.access.redhat.comはログイン不要でコンテナイメージをpullできます。ここでは最新のopenjdk-21を利用します。

$ podman pull registry.access.redhat.com/ubi9/openjdk-21
$ podman images openjdk-21:latest
REPOSITORY                                  TAG         IMAGE ID      CREATED      SIZE
registry.access.redhat.com/ubi9/openjdk-21  latest      985daf3b31fb  10 days ago  422 MB

イメージのレイヤーを確認します。

$ podman image tree openjdk-21:latest
Image ID: 985daf3b31fb
Tags:     [registry.access.redhat.com/ubi9/openjdk-21:latest]
Size:     422.4MB
Image Layers
├── ID: 256cf40975c5 Size: 104.3MB
└── ID: 25d8d0ec0c6b Size: 318.1MB Top Layer of: [registry.access.redhat.com/ubi9/openjdk-21:latest]

podman inspectpodman image inspect)でイメージの詳細を確認します。ポイントだけピックアップします。

$ podman inspect openjdk-21:latest
...
          "Config": {
               "User": "185",
...
               "Cmd": [
                    "/usr/local/s2i/run"
               ],
               "WorkingDir": "/home/default",
...
  • User:UID=185でユーザーが作成されている
  • Cmd:コンテナのデフォルトコマンドは/usr/local/s2i/runが実行される
  • WorkingDir:/home/defaultがデフォルトの作業ディレクトリ

ライセンス条項を確認するには下記のようにフィルタすることで確認できます。

$ podman inspect registry.access.redhat.com/ubi9/openjdk-21 --format '{{ index .Config.Labels "com.redhat.license_terms" }}'
https://www.redhat.com/en/about/red-hat-end-user-license-agreements#UBI

コンテナイメージの履歴を確認します(出力が多いので一部のみ表示)。

$ podman history openjdk-21:latest
ID            CREATED      CREATED BY                                     SIZE        COMMENT
985daf3b31fb  10 days ago  /bin/sh -c #(nop) USER 185                     318MB       FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:ef6fb6b3b38ef6c85daebeabebc7ff3151b9dd1500056e6abc9c3295e4b78a51
<missing>     10 days ago  /bin/sh -c mv -fZ /tmp/ubi.repo /etc/yum.r...  0B
<missing>     10 days ago  /bin/sh -c #(nop) USER root                    0B
<missing>     10 days ago  /bin/sh -c #(nop) USER 185                     0B
<missing>     10 days ago  /bin/sh -c rm -f /tmp/tls-ca-bundle.pem        0B
<missing>     10 days ago  /bin/sh -c rm -f '/etc/yum.repos.d/odcs-31...  0B
...

コメントにFROM registry.access.redhat.com/ubi9/ubi-minimalとあり、このOpenJDKのコンテナイメージはUBI-minimalをベースとして構築されていることがわかります。

OpenJDKのコンテナイメージでjarファイルが実行される仕組み

OpenJDKの製品ドキュメント*2には下記のような記載があります。

Red Hat build of OpenJDK イメージには、アプリケーション JAR ファイルを自動的に検出し、Java を起動するデフォルトの起動スクリプトがあります。 OpenJDK イメージの /deployments ディレクトリーの Java アプリケーションは、イメージの読み込み時に実行されます。

これがどのように実行されるのか調べてみます。まず、コンテナの実行コマンドファイル/usr/local/s2i/runを調べます。 podman image mountコマンドを使いコンテナイメージをファイルシステムにマウントしてファイルの中身を確認します。 ルートレスで実行している場合はユーザーネームスペースの中でpodman image mountを実行する必要があるので、podman unshareを実行します。 (macOSやWindowsで実行している場合はPodman machineにログインしてpodman unshareを実行する必要があります)

$ podman unshare
# mnt=$(podman image mount openjdk-21)
# cat $mnt/usr/local/s2i/run
#!/bin/bash

# Command line arguments given to this script
args="$*"

source "${JBOSS_CONTAINER_UTIL_LOGGING_MODULE}/logging.sh"
source "${JBOSS_CONTAINER_S2I_CORE_MODULE}/s2i-core"
# include our s2i_core_*() overrides/extensions
source "${JBOSS_CONTAINER_JAVA_S2I_MODULE}/s2i-core-hooks"

# Global S2I variable setup
s2i_core_env_init

export JAVA_OPTS

if [ -f "${S2I_TARGET_DEPLOYMENTS_DIR}/bin/run.sh" ]; then
    echo "Starting the application using the bundled ${S2I_TARGET_DEPLOYMENTS_DIR}/bin/run.sh ..."
    exec ${DEPLOYMENTS_DIR}/bin/run.sh $args ${JAVA_ARGS}
else
    echo "Starting the Java application using ${JBOSS_CONTAINER_JAVA_RUN_MODULE}/run-java.sh $args..."
    exec "${JBOSS_CONTAINER_JAVA_RUN_MODULE}/run-java.sh" $args ${JAVA_ARGS}
fi

/usr/local/s2i/runファイルをざっくり眺めると、${S2I_TARGET_DEPLOYMENTS_DIR}/bin/run.shファイルの有無で実行モードがS2IモードになるかJavaアプリの実行モードになるかの選択があるようです。つまり、このOpenJDKのコンテナイメージはS2Iのビルド用途にもJavaアプリの実行用途にもどちらでも使える共通イメージとして提供されているようです。 今回はS2Iについては深入りしないのでJavaアプリの実行用途についてもう少し探っていきます。

run-java.shがどこにあるか探します。

# cd $mnt
# find . -name run-java.sh
./opt/jboss/container/java/run/run-java.sh

/opt/jboss/container/java/run/run-java.shにファイルがあるようです。run-java.shファイルの中身を確認します。

# cd ~
# cat $mnt/opt/jboss/container/java/run/run-java.sh

ファイルの行数が多いので全文は本記事の最後に載せておきます。

まず、確認する点がこちら。JAVA_APP_DIR環境変数に/deploymentsがセットされています。

# Default the application dir to the S2I deployment dir
if [ -z "$JAVA_APP_DIR" ]
  then JAVA_APP_DIR=/deployments
fi

製品ドキュメントに書いてあった/deploymentsのJavaアプリケーションが読み込まれるというのはここの設定のようです。 /opt/jboss/container/java/run/run-java.shの中でjarファイルの検出が行われ、最終的には${args}にファイルがセットされて実行される仕組みです。

# Start JVM
startup() {
...
  exec -a "${procname}" java $(get_java_options) -cp "$(get_classpath)" ${args} $*
}

OpenJDKのコンテナイメージを引数なしで実行するとエラーメッセージの内容でここまでに確認したことがなんとなくわかります。

$ podman run --rm openjdk-21:latest
Starting the Java application using /opt/jboss/container/java/run/run-java.sh ...
ERROR Neither $JAVA_MAIN_CLASS nor $JAVA_APP_JAR is set and 0 JARs found in /deployments (1 expected)
INFO exec -a "java" java -XX:MaxRAMPercentage=80.0 -XX:+UseParallelGC -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -XX:+ExitOnOutOfMemoryError -cp "." -jar
INFO running in /deployments
Error: -jar requires jar file specification
...

作業が終わったらpodman image unmountコマンドでマウントを解除します。-aオプションで全てのマウントを解除します。 最後にexitでユーザーネームスペースを抜けます。

# podman image unmount -a
985daf3b31fb3a88c12a0f7e374bc2e29c5a6718c836f876eca99c0d91ff2ea8
# exit
exit
$

OpenJDKのコンテナイメージでjarファイルを実行する

jarファイルの実行の仕組みが分かったのでサンプルのjarファイルを作成してコンテナを実行してみます。

まず、Hello Worldを実行するJavaソースファイルを作成します。適当な作業ディレクトリを作ってその中で作業してください。後ほどコンテナにバインドマウントを行うため、HOME以外のカレントディレクトリに余計なファイルがない状態にするためです。

$ mkdir work
$ cd work
$ cat > hello.java <<EOF
public class hello {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}
EOF

次に、OpenJDKのコンテナイメージを使ってコンパイルします。コンテナを使うことでローカルホストに直接OpenJDKをインストールする必要はありません。 OpenJDKのコンテナ内でJavaのコンパイルに必要なコマンドは揃っているようです。

$ podman run --rm -it openjdk-21 bash
[default@2281f04e1b49 ~]$ ja
jar        jarsigner  java       javac      javadoc    javap

カレントディレクトリをバインドマウントしてコンパイルを実行します。

$ podman run --rm -it -v $PWD:$PWD:Z -w $PWD --user root openjdk-21 javac hello.java
  • --user root:OpenJDKのコンテナイメージはUID=185のdefaultというユーザーで実行されます。そのままバインドマウントを行うとローカルホスト上のUIDと異なりファイルの書き込みができません。また、実行ユーザーが固定されているので--userns=keep-idも使えません。そのため実行ユーザーをrootで上書きしてシンプルなコマンドで実行できるようにしています。

続けてjarファイルにパッケージ化します

$ podman run --rm -it -v $PWD:$PWD:Z -w $PWD --user root openjdk-21 jar cfe hello.jar hello hello.class

でき上がったファイルを確認します。

$ ls -l
total 12
-rw-r--r--. 1 core core 417 Jun 11 02:22 hello.class
-rw-r--r--. 1 core core 738 Jun 11 02:22 hello.jar
-rw-r--r--. 1 core core 119 Jun 11 02:14 hello.java

jarファイルを指定して実行します。

$ podman run --rm -it -v $PWD:$PWD:Z -w $PWD openjdk-21 java -jar hello.jar
Hello, World!

jarファイルができたのでOpenJDKのコンテナイメージに組み込んで新しいコンテナイメージを作成します。 jarファイルの実行の流れはこのようになります。

OpenJDKコンテナのJavaアプリ実行の流れ
OpenJDKコンテナのJavaアプリ実行の流れ

まず、下記のContainerfileを作成してビルドします。ベースイメージにOpenJDKコンテナを指定し、hello.jarを/deploymentsにコピーします。

$ cat > Containerfile <<EOF
FROM registry.access.redhat.com/ubi9/openjdk-21
COPY hello.jar /deployments/hello.jar
EOF
$ podman build -t java-test .
STEP 1/2: FROM registry.access.redhat.com/ubi9/openjdk-21
STEP 2/2: COPY hello.jar /deployments/hello.jar
COMMIT java-test
--> 3b11c122787a
Successfully tagged localhost/java-test:latest
3b11c122787a0cd14d50ff2ef595a59e239c5731818a56b5f2f2328319a069c1

ビルドしたjava-testコンテナイメージを実行します。

$ podman run --rm java-test
Starting the Java application using /opt/jboss/container/java/run/run-java.sh ...
INFO exec -a "java" java -XX:MaxRAMPercentage=80.0 -XX:+UseParallelGC -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -XX:+ExitOnOutOfMemoryError -cp "." -jar /deployments/hello.jar
INFO running in /deployments
Hello, World!

期待通りにHello Worldが実行されました。

まとめ

OpenJDKコンテナイメージを使うことで、コンテナ上でJavaのコンパイルを行ったりjarファイルの実行ができます。 また、OpenJDKコンテナイメージをベースイメージとして指定し、コンテナ内の/deploymentsにjarファイルを配置するだけでコンテナの実行時に自動で実行してくれます。 ベースのUBIイメージはRed Hatが定期的に更新しており、RHELの信頼性とセキュリティを担保された安心して使用できるコンテなイメージです。 UBIをベースイメージとしたOpenJDKのコンテナイメージは誰でも無償で使用できますので、Javaを使う方にはどなたにでもオススメのコンテナイメージです。

Podmanとコンテナを使うことでミドルウェアの理解がさらに深まりそうです。

参考:/opt/jboss/container/java/run/run-java.sh

run-java.shの出力結果を開く

root@localhost:~# cat $mnt/opt/jboss/container/java/run/run-java.sh
#!/bin/bash

# Fail on a single failed command
set -eo pipefail

export JBOSS_CONTAINER_UTIL_LOGGING_MODULE="${JBOSS_CONTAINER_UTIL_LOGGING_MODULE-/opt/jboss/container/util/logging}"
export JBOSS_CONTAINER_JAVA_RUN_MODULE="${JBOSS_CONTAINER_JAVA_RUN_MODULE-/opt/jboss/container/java/run}"

# Default the application dir to the S2I deployment dir
if [ -z "$JAVA_APP_DIR" ]
  then JAVA_APP_DIR=/deployments
fi

source "$JBOSS_CONTAINER_UTIL_LOGGING_MODULE/logging.sh"

# ==========================================================
# Generic run script for running arbitrary Java applications
#
# This has forked (and diverged) from:
# at https://github.com/fabric8io/run-java-sh
#
# ==========================================================

# Error is indicated with a prefix in the return value
check_error() {
  local msg=$1
  if echo ${msg} | grep -q "^ERROR:"; then
    log_error ${msg}
    exit 1
  fi
}

# detect Quarkus fast-jar package type (OPENJDK-631)
is_quarkus_fast_jar() {
  if test -f quarkus-app/quarkus-run.jar; then
    log_info "quarkus fast-jar package type detected"
    echo quarkus-app/quarkus-run.jar
    return 0
  else
    return 1
  fi
}

# Try hard to find a sane default jar-file
auto_detect_jar_file() {
  local dir=$1

  # Filter out temporary jars from the shade plugin which start with 'original-'
  local old_dir=$(pwd)
  cd ${dir}
  if [ $? = 0 ]; then

    if quarkus="$(is_quarkus_fast_jar)"; then
      echo "$quarkus"
      return
    fi

    local nr_jars=`ls *.jar 2>/dev/null | grep -v '^original-' | wc -l | tr -d '[[:space:]]'`
    if [ ${nr_jars} = 1 ]; then
      ls *.jar | grep -v '^original-'
      exit 0
    fi

    log_error "Neither \$JAVA_MAIN_CLASS nor \$JAVA_APP_JAR is set and ${nr_jars} JARs found in ${dir} (1 expected)"
    cd ${old_dir}
  else
    log_error "No directory ${dir} found for auto detection"
  fi
}

# Check directories (arg 2...n) for a jar file (arg 1)
get_jar_file() {
  local jar=$1
  shift;

  if [ "${jar:0:1}" = "/" ]; then
    if [ -f "${jar}" ]; then
      echo "${jar}"
    else
      log_error "No such file ${jar}"
    fi
  else
    for dir in $*; do
      if [ -f "${dir}/$jar" ]; then
        echo "${dir}/$jar"
        return
      fi
    done
    log_error "No ${jar} found in $*"
  fi
}

load_env() {
  # Configuration stuff is read from this file
  local run_env_sh="run-env.sh"

  # Load default default config
  if [ -f "${JBOSS_CONTAINER_JAVA_RUN_MODULE}/${run_env_sh}" ]; then
    source "${JBOSS_CONTAINER_JAVA_RUN_MODULE}/${run_env_sh}"
  fi

  # Check also $JAVA_APP_DIR. Overrides other defaults
  # It's valid to set the app dir in the default script
  if [ -f "${JAVA_APP_DIR}/${run_env_sh}" ]; then
      source "${JAVA_APP_DIR}/${run_env_sh}"
  fi

  export JAVA_APP_DIR

  # JAVA_LIB_DIR defaults to JAVA_APP_DIR
  export JAVA_LIB_DIR="${JAVA_LIB_DIR:-${JAVA_APP_DIR}}"
  if [ -z "${JAVA_MAIN_CLASS}" ] && [ -z "${JAVA_APP_JAR}" ]; then
    JAVA_APP_JAR="$(auto_detect_jar_file ${JAVA_APP_DIR})"
    check_error "${JAVA_APP_JAR}"
  fi

  if [ "x${JAVA_APP_JAR}" != x ]; then
    local jar="$(get_jar_file ${JAVA_APP_JAR} ${JAVA_APP_DIR} ${JAVA_LIB_DIR})"
    check_error "${jar}"
    export JAVA_APP_JAR=${jar}
  else
    export JAVA_MAIN_CLASS
  fi
}

# Combine all java options
get_java_options() {
  local jvm_opts
  local debug_opts
  local proxy_opts
  local opts
  if [ -f "${JBOSS_CONTAINER_JAVA_JVM_MODULE}/java-default-options" ]; then
    jvm_opts=$(${JBOSS_CONTAINER_JAVA_JVM_MODULE}/java-default-options)
  fi
  if [ -f "${JBOSS_CONTAINER_JAVA_JVM_MODULE}/debug-options" ]; then
    debug_opts=$(${JBOSS_CONTAINER_JAVA_JVM_MODULE}/debug-options)
  fi
  if [ -f "${JBOSS_CONTAINER_JAVA_PROXY_MODULE}/proxy-options" ]; then
    source "${JBOSS_CONTAINER_JAVA_PROXY_MODULE}/proxy-options"
    proxy_opts="$(proxy_options)"
  fi

  opts=${JAVA_OPTS-${debug_opts} ${proxy_opts} ${jvm_opts} ${JAVA_OPTS_APPEND}}
  # Normalize spaces with awk (i.e. trim and eliminate double spaces)
  echo "${opts}" | awk '$1=$1'
}

# Read in a classpath either from a file with a single line, colon separated
# or given line-by-line in separate lines
# Arg 1: path to claspath (must exist), optional arg2: application jar, which is stripped from the classpath in
# multi line arrangements
format_classpath() {
  local cp_file="$1"
  local app_jar="$2"

  local wc_out=`wc -l $1 2>&1`
  if [ $? -ne 0 ]; then
    log_error "Cannot read lines in ${cp_file}: $wc_out"
    exit 1
  fi

  local nr_lines=`echo $wc_out | awk '{ print $1 }'`
  if [ ${nr_lines} -gt 1 ]; then
    local sep=""
    local classpath=""
    while read file; do
      local full_path="${JAVA_LIB_DIR}/${file}"
      # Don't include app jar if include in list
      if [ x"${app_jar}" != x"${full_path}" ]; then
        classpath="${classpath}${sep}${full_path}"
      fi
      sep=":"
    done < "${cp_file}"
    echo "${classpath}"
  else
    # Supposed to be a single line, colon separated classpath file
    cat "${cp_file}"
  fi
}

# Fetch classpath from env or from a local "run-classpath" file
get_classpath() {
  local cp_path="."
  if [ "x${JAVA_LIB_DIR}" != "x${JAVA_APP_DIR}" ]; then
    cp_path="${cp_path}:${JAVA_LIB_DIR}"
  fi
  if [ -z "${JAVA_CLASSPATH}" ] && [ "x${JAVA_MAIN_CLASS}" != x ]; then
    if [ "x${JAVA_APP_JAR}" != x ]; then
      cp_path="${cp_path}:${JAVA_APP_JAR}"
    fi
    if [ -f "${JAVA_LIB_DIR}/classpath" ]; then
      # Classpath is pre-created and stored in a 'run-classpath' file
      cp_path="${cp_path}:`format_classpath ${JAVA_LIB_DIR}/classpath ${JAVA_APP_JAR}`"
    else
      # No order implied
      cp_path="${cp_path}:${JAVA_APP_DIR}/*"
    fi
  elif [ "x${JAVA_CLASSPATH}" != x ]; then
    # Given from the outside
    cp_path="${JAVA_CLASSPATH}"
  fi
  echo "${cp_path}"
}

# Mask secrets before printing
mask_passwords() {
    local content="$1"
    local result=""

    IFS=' ' read -r -a key_value_pairs <<< "$content"

    for pair in "${key_value_pairs[@]}"; do
        key=$(echo "$pair" | cut -d '=' -f 1)
        value=$(echo "$pair" | cut -d '=' -f 2-)

        if [[ $key =~ [Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd] ]]; then
            result+="$key=***** "
        else
            result+="$pair "
        fi
    done

    echo "${result% }"
}

# Start JVM
startup() {
  # Initialize environment
  load_env

  local args
  cd ${JAVA_APP_DIR}
  if [ "x${JAVA_MAIN_CLASS}" != x ] ; then
     args="${JAVA_MAIN_CLASS}"
  else
     args="-jar ${JAVA_APP_JAR}"
  fi

  local procname="${JAVA_APP_NAME-java}"

  local masked_opts=$(mask_passwords "$(get_java_options)")

  log_info "exec -a \"${procname}\" java ${masked_opts} -cp \"$(get_classpath)\" ${args} $*"
  log_info "running in $PWD"
  exec -a "${procname}" java $(get_java_options) -cp "$(get_classpath)" ${args} $*
}

# =============================================================================
# Fire up
startup $*

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。