古いRailsアプリをDockerコンテナに乗せた

もう1年も前になるけどこんな記事を書いた。

ローカルネットワークのサーバ置き換えに際して、古いサーバ(Ubuntu 16.04 LTS)で動かしていた Rails アプリが、新しいサーバ(Ubuntu 20.04 LTS)上で動かせなくてどうしよう……っていう記事だ。

あれから1年も過ぎてしまったけど、今年に入ってから週末ごとにちまちまと作業をして、なんとか新しいサーバの Dockerコンテナで動かせるまでになった。

ベースにした Docker イメージは、Ruby の公式イメージのうち一番古いバージョン、2.6.9。とにかく Ruby の公式 Docker イメージをベースにしたコンテナで動かせることを目標にした。

Rails は 5.0.7.2まで上げた。これだって今となっては古いバージョン(RubyGems.org を見ると2019/3/13リリース)だけど、ローカルネットワークでしか使ってない web アプリなのでひとまず妥協する。

作業は開発用メインマシン(Ubuntu 20.04 LTS)の rbenv で Ruby そのもののバージョンも変えながらやった。作業を始めた時点では Rails 4.1.4 で、最後のコミットはなんと 2016/3/3 だった。6年近くもメンテしてなかったってことだよ。アプリを使う分には不自由してなかったからだけど、だからってほったらかしだとこうやって後になってツケが来るんだよな。Rails にさわるもの6年ぶりってわけで、少しずつバージョンを上げるたびに出るエラー(主に依存関係)で苦労した。これからはもう少し面倒を見て、追いつくようにしよう。

さて、そういうわけで、動かしていたアプリがなくなって、めでたく古いサーバが空いた。春になれば Ubuntu 22.04 LTS もリリースされるだろうから、そのテスト用にしようかな。

Windows11へのGNU Fortranのインストールと算術IFについて

古いFortran(FORTRAN77だ!)のプログラムをコンパイルする必要があって、GNU Fortran をインストールした。

GNU Fortran の公式サイトは↓ここにあるんだけど、どこからダウンロードすればいいんだか、ひどくわかりにくい。

さいわい、丁寧に説明してくれているページを見つけた。日付が古いが内容は古くなってない。

公式サイトのここをクリックして、次の画面のここをクリックして……みたいなことをグダグダと書かれているが――という言い方はよくないな。丁寧に説明してくれてるんだからありがたい。悪いのはわかりにくい公式サイトだ――要するに TDM GCC をインストールすれば GNU Fortran もインストールできる。TDM GCC のサイトは↓ここ。

バージョン10.3.0が最新だった。ダウンロードしたのは64ビット版のtdm64-gcc-10.3.0-2.exe。

このファイルは Windows のインストーラになってるので、普通にインストール作業すれば何も困ることはない。ただ、デフォルトでは Fortran はインストールされないので、途中で Fortran をインストールするようにチェックを入れる必要がある。

さて、インストールできたので早速試してみた。コマンドは gfortran

takatoh@sofa: sample > gfortran --version
GNU Fortran (tdm64-1) 10.3.0
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

コンパイルするには引数にソースファイルの名前を指定すればいい。-o オプションはコンパイルされた実行ファイルの名前の指定。省略すると a.exe になる。

takatoh@sofa: sample > gfortran -o auto auto.for

ソースファイルの拡張子が .for だと FORTRAN77 だと見做すらしい。.f90 なら Fortran90。

こんな調子で20個ほどコンパイルしたんだけど、そのひとつで次のような warning がでた。

takatoh@sofa: sample > gfortran -o ohsp ohsp.for
ohsp.for:70:72:

   70 |       IF(RLOG-R1) 170,180,180
      |                                                                        1
Warning: Fortran 2018 deleted feature: Arithmetic IF statement at (1)

Arithmetic IF statement ってなんだと思って調べたら、算術IFっていうんだと。

↓ここに説明がある。

条件式の値が負かゼロか正かで分岐して指定された番号の文へジャンプする。条件式は論理式じゃなくて算術式、つまり評価すると数値になる式。こんな感じ:

      IF(X-10) 100,200,300
  100 ...
      GO TO 500
  200 ...
      GO TO 500
  300 ...
      GO TO 500
  500 ...

X-10 が負なら100へ、ゼロなら200へ、正なら300へジャンプする。

もう少し調べてみたら、論理IF よりも 算術IF のほうが先にあったらしい。

FORTRAN77 って学生の時にやったんだけど、こんなの覚えてないよ。

それはさておき、こういう古いソースコードでもコンパイルできるってのはすごいことだよな。

Python: Larkが1.0.0になったので試してみたんだけど挙動がおかしい

年が明けたら Lark の 1.0.0 がリリースされていたのに気が付いたので、以前作ったコマンド形式の入力ファイルをパースするのを、再度やってみた。フレームワーク的なパッケージにまとめて再利用できるようにしてみようと思う……んだけど、どうも挙動がおかしい。

要点を先に書くと、書いたパーサも入力ファイルも同じなのに、実行するたびに結果が異なる。原因はわからない。パーサの書き方が悪いのか、Lark のバグなのか。

順を追って説明する。

コマンド形式の入力ファイル

プログラムには何らかの入力が必要で、入力ファイルにも何らかのフォーマットが必要だ。コマンド形式というのは、入力データの内部表現を、ひとつずつコマンドを実行しながら組み立てようというものだ。この記事で扱うのは、そのフレームワーク的なライブラリで、ファイルからの入力である文字列を、コマンド(と引数)の列にパースするもの。

サンプルとして次のような入力ファイルを使う。

TITLE "example"
ADD 1 2
MUL
    3
//    5
    7  8  
    9
// this line is comment.
REPEAT "Hello!" 5  
    // this is comment too.
JOIN "Hello," "world!"  // in-line comment.
ADD-2  5  .7e03
NO-ARG-COMMAND
COMMAND-WITH-KEYWORD
    KW1  1
    KW2  2
    KW3  on
    KW4  off

文法

全体を「スクリプト」と呼ぶ。文法は次の通り。

  • 「スクリプト」は1つ以上の「ステートメント」からなる
  • 「ステートメント」は「コマンド」と0個以上の「引数」からなり、空白文字で区切られ、改行文字で終わる
  • ただし、空白文字で始まる行は前の行の続きとみなす。なので「コマンド」は必ず行頭からはじまる
  • 「引数」には数値、文字列、キーワード、真偽値の4種類がある
  • //から行末まではコメント

「引数」について補足しておく。数値は文字通りの数値だけど、整数/実数の区別はなし。文字列はダブルークォートで囲む。真偽値は true / falseyes / noon / off のいずれか(すべて小文字)。

で、キーワードが今日の主題。「引数」のリストの中で、次の引数が何のデータかを示す目印に使う。要するに Python のメソッドのキーワード引数のような使い方だ。大文字のラテン文字と数字からなり、行頭には来ないことでコマンドと区別できる。実装としては Ruby なら Symbol をつかうところだけど、Python にはそういうのがないので(ないよね?)、Keyword クラスを定義した。

文法ファイルは次のようになった。

?script : statement+

statement : line continued*

line : command _WS_INLINE arglist _WS_INLINE? _NL
     | command _WS_INLINE? _NL

continued : _INDENT arglist _WS_INLINE? _NL

command : CMDNAME

CMDNAME : UCASE_LETTER ("-"|UCASE_LETTER|DIGIT)*

arglist : arg
        | arglist _WS_INLINE arg

arg : number
    | string
    | keyword
    | boolean

number : SIGNED_NUMBER

string : ESCAPED_STRING

keyword : KWORD

KWORD : UCASE_LETTER (UCASE_LETTER|DIGIT)*

boolean : true
        | false

true : "true" | "yes" | "on"

false : "false" | "no" | "off"

_WS_INLINE : WS_INLINE

_NL : NEWLINE

_INDENT : WS_INLINE


%import common.UCASE_LETTER
%import common.DIGIT
%import common.SIGNED_NUMBER
%import common.ESCAPED_STRING
%import common.NEWLINE
%import common.WS_INLINE
%import common.CPP_COMMENT

COMMENT : WS_INLINE? CPP_COMMENT NEWLINE
COMMENT_INLINE : WS_INLINE? CPP_COMMENT

%ignore COMMENT
%ignore COMMENT_INLINE

パーサ

from lark import Lark
from lark.exceptions import UnexpectedInput
from lark.visitors import Interpreter


class Parser():
    def __init__(self):
        with open('grammer.lark', 'r') as f:
            grammer = f.read()
        self.parser = Lark(grammer, start='script')

    def parse(self, input_data):
        try:
            tree = self.parser.parse(input_data)
        except UnexpectedInput as e:
            context = e.get_context(input_data)
            print(f'Syntax error:  line = {e.line}  column = {e.column}\n')
            print(context)
            exit(1)

        script = ScriptInterpreter().visit(tree)
        return script


class ScriptInterpreter(Interpreter):
    def script(self, tree):
        return [self.visit(c) for c  in tree.children]

    def statement(self, tree):
        (cmd, args1) = self.visit(tree.children[0])
        args2 = flatten([ self.visit(a) for a in tree.children[1:] ])
        return (cmd, args1 + args2)

    def line(self, tree):
        cmd = self.visit(tree.children[0])
        if len(tree.children) > 1:
            args = flatten(self.visit(tree.children[1]))
        else:
            args = []
        return (cmd, args)

    def continued(self, tree):
        return self.visit(tree.children[0])

    def command(self, tree):
        return tree.children[0]

    def arglist(self, tree):
        args = [ self.visit(a) for a in tree.children ]
        return args

    def arg(self, tree):
        return self.visit(tree.children[0])

    def number(self, tree):
        return float(tree.children[0])

    def string(self, tree):
        return tree.children[0].strip('"')

    def keyword(self, tree):
        return Keyword(str(tree.children[0]))

    def boolean(self, tree):
        return self.visit(tree.children[0])

    def true(self, tree):
        return True

    def false(self, tree):
        return False


class Keyword():
    def __init__(self, val):
        self.val = val

    def __str__(self):
        return f'Keyword<{self.val}>'

    def __repr__(self):
        return f'Keyword<{self.val}>'


def flatten(lis):
    result = []
    for elem in lis:
        if isinstance(elem, list):
            result += flatten(elem)
        else:
            result.append(elem)
    return result

テスト用のスクリプトと実行結果

テスト用なので、入力データ(の内部表現)を組み立てる代わりにコマンドと引数リストを出力する。

from parsers import Parser
import sys


def main():
    parser = Parser()

    with open(sys.argv[1], 'r') as f:
        input_data = f.read()

    script = parser.parse(input_data)

    print('SCRIPT')
    for (cmd, args) in script:
        print('  COMMAND: ' + cmd)
        print('     ARGS: ' + repr(args))



main()

これを実行すると次のようになる。

takatoh@sofa: inputscriptparser-sample > python main.py example.dat
SCRIPT
  COMMAND: TITLE
     ARGS: ['example']
  COMMAND: ADD
     ARGS: [1.0, 2.0]
  COMMAND: MUL
     ARGS: [3.0, 7.0, 8.0, 9.0]
  COMMAND: REPEAT
     ARGS: ['Hello!', 5.0]
  COMMAND: JOIN
     ARGS: ['Hello,', 'world!']
  COMMAND: ADD-2
     ARGS: [5.0, 700.0]
  COMMAND: NO-ARG-COMMAND
     ARGS: []
  COMMAND: COMMAND-WITH-KEYWORD
     ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]

これは期待通り。ところが、何度か実行を続けると、時々次のような結果になる。

takatoh@sofa: inputscriptparser-sample > python main.py example.dat
SCRIPT
  COMMAND: TITLE
     ARGS: ['example']
  COMMAND: ADD
     ARGS: [1.0, 2.0]
  COMMAND: MUL
     ARGS: [3.0, 7.0, 8.0, 9.0]
  COMMAND: REPEAT
     ARGS: ['Hello!', 5.0, Keyword<JOIN>, 'Hello,', 'world!']
  COMMAND: ADD-2
     ARGS: [5.0, 700.0]
  COMMAND: NO-ARG-COMMAND
     ARGS: []
  COMMAND: COMMAND-WITH-KEYWORD
     ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]

コマンドであるべき JOIN が、REPEAT コマンドの引数リストの中に、JOIN というキーワードとして含まれてしまっている(JOIN に続く引数もろとも)。

最初に書いたとおり、文法ファイルも入力ファイルも、パーサも何も変えてない。なのに実行するたびに、JOIN だけコマンドになったりキーワードになったりする。

今のところ全くの原因不明。

気になるところといえば、入力ファイルの JOIN コマンドの前の行が空白文字とコメントであること。さらにもう一つ前の行の行末(5 のうしろ)に空白文字があること(この記事では見えないけど)だ。だけどこれは文法上は問題ないはずに思える。実際期待通りの結果になることもあるんだし。

というわけで、このままでは安心して使えない。どうしようか。

AWS CLIでAmazon S3のバケットにファイルをアップロードする

AWS CLI は AWS の各サービスをコマンドラインから使えるツールだ。Amazon S3 をデータのバックアップ用に使おうと思って試してみた。

インストールと設定

AWS の Web ページからダウンロードする。↓のページから。

ダウンロードした awscliv2.zip を解凍する。

takatoh@apostrophe:~$ unzip awscliv2.zip

できた aws ディレクトリにあるインストールスクリプトを実行。

takatoh@apostrophe:~$ sudo aws/install
You can now run: /usr/local/bin/aws --version

メッセージにあるとおりコマンド名は aws だ。バージョンを確認してみよう。

takatoh@apostrophe:~$ aws --version
aws-cli/2.4.4 Python/3.8.8 Linux/5.4.0-91-generic exe/x86_64.ubuntu.20 prompt/off

続いて設定。aws configure コマンドを実行。

takatoh@apostrophe:~$ aws configure
AWS Access Key ID [None]: XXXXXXXXXXXXXXXXXXXX
AWS Secret Access Key [None]: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Default region name [None]: us-west-2
Default output format [None]: json

AWS Access Key ID と AWS Secret Access Key はあらかじめ取得しておくこと。

S3にファイルをアップロード

aws s3 cp コマンドを利用する。今回はテスト用の panicblanket-test というバケットにファイルをアップロードする。

takatoh@apostrophe:~$ aws s3 cp sample.zip s3://panicblanket-test/sample.zip

ディレクトリごとアップロードするには aws s3 sync コマンドが便利。バックアップ用途にはこちらがいいだろう。

takatoh@apostrophe:~$ aws s3 sync sample s3://panicblanket-test/sample
upload: sample/sample-1.zip to s3://panicblanket-test/sample/sample-1.zip
upload: sample/sample-2.zip to s3://panicblanket-test/sample/sample-2.zip

sample ディレクトリにあった2つのファイルがアップロードされた。

あとはシェルスクリプトを書いて定期的に事項するようにしておけば良さそうだ。

SQLite3のデータベースファイルからSQLにダンプする

たいしたネタじゃないけど、ときどき必要になる割にいつも調べてる気がするのでメモしておく。

SQLite3 のデータベースは1つのファイルになってる。このデータベースの中身を SQL にダンプするには sqlite3 コマンドをつかって、次の2通りのやり方がある。

対話的インターフェイスでダンプする方法

データベースを開くと、コマンド入力待ち状態になる。

takatoh@wplj:db$ sqlite3 production.sqlite3
SQLite version 3.11.0 2016-02-15 17:29:24
Enter ".help" for usage hints.
sqlite>

ここで .dump コマンドでダンプすることができるけど、このままだと画面に出力されてしまうので、先に .output コマンドで出力先をファイルに変更しておく。

sqlite> .output ./dump.sql

これでカレントディレクトリの dump.sql ファイルに出力されるようになる。そしたら .dump コマンド。

sqlite> .dump

引数なしだとデータベース内のすべてのテーブルをダンプする。特定のテーブルだけをダンプするには、そのテーブル名を引数に与えればいい。

終わったら .exit

sqlite> .exit

コマンドラインから直接ダンプする方法

パイプを使って sqlite3 コマンドにダンプを指示する .dump コマンドを流し込んでやればいい。デフォルトでは標準出力に書き出すので、ファイルにリダイレクトする。

takatoh@wplj:db$ echo '.dump' | sqlite3 production.sqlite3 > dump2.sql

定期的にバックアップするような場合にはこっちのほうが便利。

MediaWikiにYouTubeの動画を埋め込む

ローカルネットワーク上で使ってる MediaWiki に YouTube の動画を埋め込みたいと思って調べたら、そのまんま、YouTube という名前の拡張機能があった。マニュアルに使い方が載ってる。

これを使うことにする。

環境

  • Ubuntu server 20.04 LTS
  • Docker 20.10.8
  • docker-compose 1.25.5
  • MediaWiki 1.35.0

MediaWiki は Docker コンテナ上で動いていて、これは dockerhub から取ってきた公式のイメージを利用してる。

拡張機能のダウンロードとインストール

拡張機能は、上のリンクからたどってダウンロードした。YouTube-REL1_36-c352d8c.tar.gz っていうファイル名だった。

マニュアルによると、これを MediaWiki のディレクトリにある extensions/ ディレクトリに置けばいい。コンテナの中に入って確認すると、(コンテナ内の)/var/www/html/extensions/ に置けばいいことがわかった。

とはいえ、コンテナ内に置く必要はなくて、外(つまりホスト側)に置いてボリュームをマウントすればいい。実際、LocalSettings.php ファイルなんかもそうなっていて、~/docker-environments/wiki/ 以下に置いてある。

というわけで、ここに extensions/ ディレクトリを作って、YouTube 拡張機能を配置する。

$ cd ~/docker-environments/wiki
$ mkdir extensions
$ cd extensions
$ tar xzvf ~/YouTube-REL1_36-c352d8c.tar.gz

これで extensions/ ディレクトリの下に YouTube/ ディレクトリができる。

つぎに、MediaWiki の設定ファイル LocalSettings.php に1行書き加える。

wfLoadExtension('YouTube');

YouTube っていう拡張機能を読み込む設定だ。MediaWiki についてはこれで終わり。

Dockerコンテナの設定と再起動

拡張機能自体はコンテナの外に置いてあるので、ボリュームとしてマウントする。docker-compose.yml ファイルの volumes セクションに書き加える。

      - /home/takatoh/docker-environments/wiki/extensions/YouTube:/var/www/html/extensions/YouTube

最後にコンテナを起動し直して終わり。

$ docker-compose restart wiki

確認

実際に MediaWiki のページに YouTube の動画を埋め込めることを確認した。楽なもんだね。

Dockerコンテナ上のMariaDBにホスト側から接続する

MariaDB には mysql コマンドで接続できるはずだけど、Docker コンテナに乗ってるときには追加のオプションが必要だったので、メモしておく。

docker-compose.yml はこんなの。

version: "3"

services:

  db:
    image: mariadb:10.5.6-focal
    container_name: test-db
    restart: always
    ports:
      - 8802:3306
    volumes:
      - ./mysql:/var/lib/mysql
      - ./initdb.d:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=test
      - MYSQL_USER=test
      - MYSQL_PASSWORD=test
      - BIND-ADDRESS="0.0.0.0"
    tty: true

コンテナを起動する。

takatoh@apostrophe:test$ docker-compose up -d

で、ポート 8802 に繋いであるんだからそれを指定してやればいいんだろうと、次のようにやったらダメだった。ポートを指定してるのにソケットがないと怒られる。

takatoh@apostrophe:test$ mysql -u root -p -P 8802
Enter password: 
ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)

解決策は簡単に見つかった。次のようにすればいい。

takatoh@apostrophe:test$ mysql -u root -p -h localhost -P 8802 --protocol tcp

--protocol オプションで tcp を使うってことを明示する。-h オプションはなくても大丈夫だった。多分ほかの PC から接続するときには必要なんだろう。

Docker上のMediaWikiにファイルをアップロードする

公式の Docker イメージを使って立てた MediaWiki だけど、ファイルをアップロードするにはやっぱりひと手間必要だった。なので、そのメモ。

MediaWiki にはファイルをアップロードする機能があるけど、デフォルトでは無効になっている。有効にするには MediaWiki と PHP 自体の設定ファイルを修正する必要がある。

  • LocalSettings.php – MediaWiki の設定ファイル
  • php.ini – PHP 自体の設定ファイル

どこをどう修正すればいいかはマニュアルに書いてある。

LocalSettings.php

LocalSettings.php ファイルは、MediaWiki をセットアップしたときにホストにダウンロードして、Docker コンテナには volume としてマウントしてあるので、ホスト側のファイルを編集すればいい。

$wgEnableUploads = true;

$wgEnableUploads に true を設定。

php.ini

で、問題はこっち。php.ini ファイルは MediaWiki の公式 Docker イメージに含まれてるものをそのまま使ったので、ホスト側にはない。なので、まずはコンテナの中で編集して、動作が変わるかどうか確認することにした。

takatoh@wplj $ docker exec -it wiki bash
root@bd8268684983:/var/www/html# php -i | grep php.ini
Configuration File (php.ini) Path => /usr/local/etc/php

php.ini ファイルの場所は php -i コマンドで確認できる(と MediaWiki のマニュアルに書いてある)。このコマンドの出力は結構な量を吐くので grep で php.ini にヒットする行だけ抜き出した。ともあれ、/usr/local/etc/php にあることがわかった。

ところが、そこに php.ini ファイルはなかった。

root@bd8268684983:/var/www/html# cd /usr/local/etc/php
root@bd8268684983:/usr/local/etc/php# ls
conf.d  php.ini-development  php.ini-production

そういうものなのかと疑問に思いながらも、ひとまずは php.ini-production ファイルの中身を見てみようとしても less コマンドがない。

root@bd8268684983:/usr/local/etc/php# less php.ini-production
bash: less: command not found

当然のように vim もない。cat はあったので中身は見れたけど編集はできない。

といわけで、方針を変更してファイルをホスト側にコピーして編集し、LocalSettings.php ファイルと同様にコンテナにマウントすることにした。ファイルをホスト側にコピーするのは次の通り:

takatoh@wplj $ docker exec -it wiki cat /usr/local/etc/php/php.ini-production > php.ini

cat コマンドをコンテナ側で実行して、ホスト側の php.ini ファイルへリダイレクトしている。

で、その php.ini ファイルを編集。3行続けて載せたけど、ファイル中では別々のところにある。

file_uploads = On
post_max_size = 16M
upload_max_filesize = 8M

file_uploads は元から On になってた。post_max_size と upload_max_filesize はそれぞれ 8M2M だったのを大きくした。

docker-compose.yml

編集した php.ini ファイルを LocalSettings.php ファイルと同じディレクトリに配置したら、コンテナにマウントすべく docker-compose.yml を修正。

  wiki:
    image: mediawiki:1.35.0
    container_name: wiki
    restart: always
    depends_on:
      - mysql
    volumes:
      - /home/takatoh/var/wiki/images:/var/www/html/images
      - /home/takatoh/var/wiki/LocalSettings.php:/var/www/html/LocalSettings.php
      - /home/takatoh/var/wiki/php.ini:/usr/local/etc/php/php.ini
    ports:
      - 9090:80

これでOK。

最後にコンテナを起動しなおしたら、無事、ファイルをアップロードできるようになった。

aptあるいはDockerの怪

apt コマンドのせいなのか Docker のせいなのかわからないけど、とにかく不可解な現象に遭遇したので記録しておく。

TL;DR

  • 先月から自宅のサーバで動かしている web サービスを少しずつ Docker 上に移行する作業をしている。
  • 開発用のマシン(Ubuntu 20.04 LTS)で期待通り動作する設定(Dockerfile と docker-compose.yml)ができたのでサーバ(Ubuntu 18.04 LTS)に持っていったら Docker イメージのビルドでコケた。
  • Dockerfile の中の apt update を apt-get update に変えたら通った。

開発用のマシンにて

開発用の環境は次の通り:

  • Ubuntu 20.04.1 LTS
  • Docker version 19.03.8
  • docker-compose version 1.25.0

で、期待通り動作するように書き上げた Dockerfile はこう:

FROM ubuntu:20.04

LABEL maintainer="takatoh"

RUN apt update
RUN apt install -y \
    ruby \
    ruby-dev \
    gcc \
    g++ \
    make \
&& rm -rf /var/lib/apt/lists/*

ENV GEM_HOME /usr/local/bundle
ENV PATH $GEM_HOME/bin:$GEM_HOME/gems/bin:$PATH
RUN gem install bundler unicorn

ADD ./files/lcstorage-2.1.0.tar.gz /usr/
WORKDIR /usr/lcstorage
RUN bundle install

CMD [ "unicorn", "-c", "/var/lcstorage/unicorn.conf" ]

Ruby で書いた web アプリを unicorn で動かしている。この Dockerfile でイメージをビルドして、ちゃんと期待通りに動作するのを確認した。

サーバにて

サーバの環境は次の通り:

  • Ubuntu 18.04.5 LTS
  • Docker version 19.03.6
  • docker-compose version 1.17.1

開発用の環境よりもバージョンが旧いといえばそのとおりだけど、OS はともかく Docker や docker-compose はそんなに旧いわけではない。実際、別の web アプリを同じようにサーバで動かしていて、それをビルドしたときには何の問題もなかった。

ところが、今回この Dockerfile をもとにサーバでイメージをビルドすると途中でエラーが発生した。どうも apt コマンドでパッケージをインストールしている途中でコケるようだ。

仕方がないので手順をひとつずつ手動でやってみることにした。ubuntu:20.04 のイメージからコンテナを起動して、apt update し、パッケージを順にひとつずつ apt install した。が、なんの問題もなくすべてのパッケージのインストールができてしまった。

どういうこと?

Dockerfile を使ってビルドしたときにはパッケージのインストールのところでエラーが出てたんだから、手動でひとつずつインストールすればどのパッケージのインストールでエラーが出るのか判断できる、と考えてた。けど、手動でやったらエラーが出ずに終わってしまった。これじゃ手がかりがない。

唯一の手がかりは Dockerfile でビルドしたときのエラーログだ。次のように出ていた。

E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_5.4.0-56.62_amd64.deb  404  Not Found [IP: 91.189.88.152 80]
 E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_5.4.0-56.62_amd64.deb が見つからない、と言ってるけど、ブラウザで見てみると確かにこの URL が示すファイルがない。

でも、じゃあなんで開発用のマシンでは問題なかったんだ?

いろいろググってはみたものの、有力な手掛かりは見つからなかった。

と、もういちどエラーメッセージを見ると、apt-get update を実行しろみたいなことが書いてある。apt じゃなくて apt-get だ。apt コマンドは apt-get コマンドの置き換えなんだからそんなの関係あるか?と思いながら、他に手掛かりがないので Dockerfile を修正してみた。こうだ:

- RUN apt update
+ RUN apt-get update

すると、どういうわけかエラーなくビルドできてしまった。

はぁ?

結論

何が起きたのかよくわからん。いや、さっぱりわかんない。

でも、とにかく動くようになったのでひとまずは良しとする。が、やっぱり釈然としない。原因を追求してみたいけど、手に負えるかなぁ……