MongoDB in Docker: Fix Authentication Failed and Init Scripts
You set MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD in Compose, start the official mongo image, and your app logs MongoServerError: Authentication failed. The password is right. You can see it in the container's environment. Or you changed the password in .env, recreated the container, and now nothing can log in. Or the user your docker-entrypoint-initdb.d script should create never shows up.
This guide reproduces each of those failures with mongo:9.0.4 in Docker Compose and fixes them. Every error and log line below comes from that lab run on November 1, 2026, when mongo:latest pointed to the same 9.0.4 image digest. The questions behind these errors get a lot of traffic. On Stack Overflow, How can I create a database for a MongoDB container on startup? had 325,066 views that day and How to enable authentication on MongoDB through Docker? had 316,972. The image repo issue Initialization of a user defined database, username, and password using environment variables has 29 comments.
The short version:
- From another container, localhost is that container. Connect to the Compose service name, for example mongodb://mongo:27017.
- The root user from MONGO_INITDB_ROOT_USERNAME lives in the admin database. If your connection string names another database, add ?authSource=admin.
- The MONGO_INITDB_* variables and the /docker-entrypoint-initdb.d scripts only run when /data/db is empty. Changing them later does nothing. Change the password with changeUserPassword() or run the script by hand.
- Percent-encode @ : / ? # [ ] $ in the password part of the connection string.
- docker logs on the MongoDB container tells you why a login failed. Look for Failed to authenticate.
Fix Authentication failed with MONGO_INITDB_ROOT_USERNAME
Start with the map. Find the message you see in the left column:
<img src="deploylark.com" alt="Table diagram mapping five MongoDB Docker errors to their cause and fix: ECONNREFUSED on localhost, Authentication failed because of the wrong authSource, Authentication failed after a password change, initdb.d scripts that did not run, and unescaped characters in the password" width="1200" height="680" loading="lazy" decoding="async" />
The lab started with the smallest Compose file that turns on authentication:
services:
mongo:
image: mongo:9.0.4
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
MONGO_ROOT_PASSWORD=Lab-Pass-4821
On the first start, the entrypoint ran a temporary mongod bound to 127.0.0.1, created the root user, shut it down and then started the real server as mongod --auth --bind_ip_all. A one-off mongo:9.0.4 container on the same Compose network played the app and ran mongosh 2.14.0. mongosh is built on the Node.js driver (the server log named driver 7.5.0), so Node.js apps get the same error names.
Connect to the service name, not localhost
client$ mongosh 'mongodb://localhost:27017' --eval 'db.runCommand({ping:1})'
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017
client$ mongosh 'mongodb://mongo:27017' --eval 'db.runCommand({ping:1})'
{ ok: 1 }
Inside the app container, localhost points back at the app itself. Nothing listens on port 27017 there, so the connection is refused. Use the service name from your Compose file. The same rule applies to MySQL and Redis, as the MySQL guide and the Redis guide show.
Note that ping worked without a password. MongoDB lets unauthenticated clients run ping, but a real query failed with MongoServerError: Command find requires authentication. That message means you connected without credentials. Authentication failed. means you sent credentials and the server rejected them.
Add authSource=admin for the root user
When the password is right and the login still fails, check the database in the connection string first. Here it named the app database, and the right password failed:
client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/appdb' --eval 'db.items.insertOne({a:1})'
MongoServerError: Authentication failed.
client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/appdb?authSource=admin' --eval 'db.items.insertOne({a:1}).acknowledged'
true
A MongoDB user belongs to the database where it was created. The Docker Hub page for the image says MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD are for "creating a simple user with the role root in the admin authentication database". The MongoDB connection string docs describe authSource as the "Database name associated with the user's credentials. Defaults to defaultauthdb if specified, otherwise defaults to admin." So /appdb in the path made the driver look for root in appdb. Without a database in the path, as in mongodb://root:Lab-Pass-4821@mongo:27017/, the login worked because the default was admin.
The server log shows the difference between a wrong database and a wrong password. The client gets the same message for both:
<img src="deploylark.com" alt="Terminal capture: mongosh to localhost gets ECONNREFUSED, a query without credentials gets Command find requires authentication, root with database appdb gets Authentication failed, adding authSource=admin works, and the server log shows UserNotFound for db appdb and storedKey mismatch for a wrong password" width="1200" height="334" loading="lazy" decoding="async" />
$ docker logs mongolab-mongo-1 2>&1 | grep '"Failed to authenticate"'
Failed to authenticate user=root db=appdb error=UserNotFound: Could not find user "root" for db "appdb"
Failed to authenticate user=root db=admin error=AuthenticationFailed: SCRAM authentication failed, storedKey mismatch
The raw lines are JSON. The lab filtered them with jq to show attr.user, attr.db and attr.error. UserNotFound means fix the authSource or the user name. storedKey mismatch means the user exists and the password is wrong.
Fix a password that changed in .env but not in MongoDB
Why MONGO_INITDB_ROOT_PASSWORD is ignored on restart
The lab changed MONGO_ROOT_PASSWORD in .env from Lab-Pass-4821 to New-Pass-7310 and ran docker compose up -d --force-recreate. The new container had the new value in its environment, but the new password failed:
<img src="deploylark.com" alt="Terminal capture: after changing the password in .env the container environment shows New-Pass-7310 but the init process did not run and the new password fails, changeUserPassword with the old password fixes it, and a volume first started without auth runs mongod with auth on but no root user" width="1200" height="418" loading="lazy" decoding="async" />
The init step never ran. The entrypoint checks /data/db for files such as WiredTiger, journal and storage.bson, and skips initialization when any of them exist. The Docker Hub page says it directly: "Do note that none of the variables below will have any effect if you start the container with a data directory that already contains a database: any pre-existing database will always be left untouched on container startup."
So the old password stays valid until you change it inside MongoDB.
Change the password with mongosh
Log in with the old password and change it in place. Then the .env value and the database agree again:
client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/?authSource=admin' \
--eval 'db.getSiblingDB("admin").changeUserPassword("root", "New-Pass-7310")'
{ ok: 1 }
client$ mongosh 'mongodb://root:New-Pass-7310@mongo:27017/?authSource=admin' \
--eval 'db.runCommand({connectionStatus:1}).authInfo.authenticatedUsers'
[ { user: 'root', db: 'admin' } ]
If you lost the old password, or the volume only holds test data, docker compose down -v deletes the volume. In the lab, the next start ran the init step again and the new .env password worked. That also deletes every database in the volume.
A related trap: the lab started a volume with no auth variables, wrote data, then added MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD. The entrypoint still added --auth (the process ran as mongod --auth --bind_ip_all), but it did not create the user, because the volume was not empty. Every login failed with UserNotFound: Could not find user "root" for db "admin", and queries without credentials failed with Command aggregate requires authentication. MongoDB's localhost exception "allows you to create the first user or role in the system after enabling access control." It only works from inside the container, so the fix ran there:
docker exec mongolate-mongo-1 sh -c 'mongosh --quiet admin --eval "db.createUser({user: process.env.MONGO_INITDB_ROOT_USERNAME, pwd: process.env.MONGO_INITDB_ROOT_PASSWORD, roles: [\"root\"]}).ok"'
It printed 1, and the root login worked. The old data was still there.
Fix docker-entrypoint-initdb.d scripts that don't run
Create the app user in the right database
Most apps should not log in as root. The usual pattern is a script in /docker-entrypoint-initdb.d that creates an app user. The lab mounted this file:
// Runs once, against MONGO_INITDB_DATABASE, only when /data/db is empty
db.createUser({
user: "app",
pwd: process.env.APP_DB_PASSWORD,
roles: [{ role: "readWrite", db: "appdb" }],
});
db.items.insertOne({ seeded: true });
With MONGO_INITDB_DATABASE: appdb and APP_DB_PASSWORD passed into the mongo service, the log showed running /docker-entrypoint-initdb.d/01-create-app-user.js and MongoDB init process complete; ready for start up. The .js file read the password with process.env, so it never sat in the script.
Now the authSource rule flips. The script ran in appdb, so the app user lives in appdb, not in admin:
client$ mongosh 'mongodb://app:App-Pass-5532@mongo:27017/appdb?authSource=admin' --eval 'db.items.countDocuments()'
MongoServerError: Authentication failed.
client$ mongosh 'mongodb://app:App-Pass-5532@mongo:27017/appdb' --eval 'db.items.countDocuments()'
1
Copying ?authSource=admin from a root example into an app user's connection string breaks it. Set authSource to the database the script ran in, or leave it out when that database is already in the path. The readWrite role on appdb also limits the user. A write to another database failed with MongoServerError: not authorized on other to execute command.
MONGO_INITDB_DATABASE only picks the database the .js scripts run against. The Docker Hub page notes that "MongoDB is fundamentally designed for "create on first use", so if you do not insert data with your JavaScript files, then no database is created." The insertOne line in the script creates appdb.
Run a new script on an existing volume
The lab then added 02-create-report-user.js and recreated the container on the same volume. The script did not run, and the user did not exist:
<img src="deploylark.com" alt="Terminal capture: on a fresh volume the init script runs and the app user works only without authSource=admin, then a second init script added later does not run on the existing volume until it is run by hand with mongosh as root" width="1200" height="418" loading="lazy" decoding="async" />
Init scripts follow the same rule as the root variables. They run once, on the first start of an empty /data/db. You don't need to delete the volume to apply a new one. Run it with mongosh inside the container, logged in as root, against the same database the entrypoint would use:
docker exec mongoinit-mongo-1 sh -c 'mongosh --quiet -u root -p "$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase admin appdb /docker-entrypoint-initdb.d/02-create-report-user.js'
After that, the report user logged in to appdb. The entrypoint also runs .sh files from the same folder, and ignores any other extension. On a fresh volume with no scripts, the log shows ignoring /docker-entrypoint-initdb.d/*.
Fix MongoParseError for passwords with special characters
Percent-encode the password in the connection string
The lab set the root password to p@ss:w/rd#1. The Compose value worked, because the entrypoint passes it to createUser and never builds a URL. The connection string was the problem:
<img src="deploylark.com" alt="Terminal capture: passwords with @, colon and slash in a MongoDB connection string fail with MongoRuntimeError Unable to parse and MongoParseError Password contains unescaped characters, and the percent-encoded password p%40ss%3Aw%2Frd%231 works from another container and from the host through a published port" width="1200" height="355" loading="lazy" decoding="async" />
client$ mongosh 'mongodb://root:p@ss:w/rd#1@mongo:27017/?authSource=admin' --eval 1
MongoRuntimeError: Unable to parse ss:w with URL
client$ mongosh 'mongodb://root:pa:ss@mongo:27017/?authSource=admin' --eval 1
MongoParseError: Password contains unescaped characters
client$ mongosh 'mongodb://root:pa@ss@mongo:27017/?authSource=admin' --eval 1
MongoParseError: Invalid connection string
The MongoDB docs say that if the username or password includes $ : / ? # [ ] @, "those characters must be converted using percent encoding". Python's urllib.parse.quote("p@ss:w/rd#1", safe="") printed p%40ss%3Aw%2Frd%231, and that version logged in. Put the encoded form only in the connection string. The MONGO_INITDB_ROOT_PASSWORD value stays as typed. If the password contains $, Compose also treats it as a variable, which the MySQL guide covers.
Connect from the host through a published port
Without a ports entry, nothing on the host can reach MongoDB. The lab published 127.0.0.1:27018:27017, and mongosh with host networking connected to 127.0.0.1:27018 and printed 9.0.4. Port 27017 on the host was refused, because only 27018 was published. Binding to 127.0.0.1 keeps the port off the network, so only the host itself can connect.
A MongoDB Compose file that avoids these errors
compose.yaml, .env and the secret file
This file ran verbatim in the lab:
services:
mongo:
image: mongo:9.0.4
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD_FILE: /run/secrets/mongo_root_password
MONGO_INITDB_DATABASE: appdb
APP_DB_PASSWORD: ${APP_DB_PASSWORD}
secrets:
- mongo_root_password
volumes:
- mongo-data:/data/db
- ./init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
app:
image: mongo:9.0.4
depends_on:
mongo:
condition: service_healthy
environment:
MONGODB_URI: mongodb://app:${APP_DB_PASSWORD}@mongo:27017/appdb?authSource=appdb
command: ["sh", "-c", "mongosh --quiet \"$$MONGODB_URI\" --eval 'db.items.countDocuments()'"]
secrets:
mongo_root_password:
file: ./secrets/mongo_root_password
volumes:
mongo-data:
APP_DB_PASSWORD=App-Pass-5532
./secrets/mongo_root_password holds the root password, and ./init/01-create-app-user.js is the script from above. The _FILE suffix is documented on Docker Hub for both root variables. With it, docker inspect on the container did not show the root password. Replace app with your real service and give it the MONGODB_URI. Keep APP_DB_PASSWORD URL-safe or percent-encode it in the URI.
The healthcheck only proves that mongod answers, since ping needs no login. It doesn't test the app's password. That is enough for depends_on. A wrong app password still shows up in the app's own log as Authentication failed. The Umami guide uses the same service_healthy pattern with PostgreSQL.
Check it
``<code>text
$ docker compose up -d
Container mongofinal-mongo-1 Started
Container mongofinal-mongo-1 Healthy
Container mongofinal-app-1 Started
$ docker compose ps -a --format '{{.Service}} {{.Status}}'
app Exited (0) 7 seconds ago
mongo Up 18 seconds (healthy)
$ docker compose logs --no-log-prefix app
1
$ docker compose restart mongo
$ docker compose run --rm app
1
</code>``
The log showed running /docker-entrypoint-initdb.d/01-create-app-user.js once, on the first start. Root logged in with the password from the secret file and ?authSource=admin. After the restart, the seeded document was still there. Keep the image pinned to an exact version such as mongo:9.0.4, so a restart never pulls a new major version onto your data volume. The PostgreSQL 17 to 18 guide shows what a planned major upgrade of a database volume looks like.
FAQ
Login errors
Why does MongoDB in Docker say Authentication failed when the password is right?
Check which database the user lives in. The root user from MONGO_INITDB_ROOT_USERNAME is in admin, so a URI like mongodb://root:pass@mongo:27017/appdb needs ?authSource=admin. Run docker logs on the MongoDB container. UserNotFound points to the database, storedKey mismatch to the password.
What is the default username and password for the MongoDB Docker image?
There is none. Without MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD, the lab's server ran without --auth, logged "Access control is not enabled for the database" and accepted writes without a login. If you set only one of the two, the container exits with error: missing 'MONGO_INITDB_ROOT_USERNAME' or 'MONGO_INITDB_ROOT_PASSWORD'.
How do I connect to MongoDB in Docker from the host?
Publish the port, for example ports: ["127.0.0.1:27017:27017"], and connect to 127.0.0.1 with ?authSource=admin for the root user. Without a published port, the host can't reach the container.
Setup and init scripts
Why are my docker-entrypoint-initdb.d scripts not running?
The volume already holds a database. The entrypoint runs the scripts only when /data/db has no data files. Run the script with mongosh inside the container, or delete the volume if the data doesn't matter. Only .js and .sh files run.
How do I create a database and user for a MongoDB container on startup?
Set MONGO_INITDB_DATABASE and mount a .js file into /docker-entrypoint-initdb.d that calls db.createUser() and inserts one document. MongoDB creates a database on the first write. The app then connects to that database without authSource=admin.
How do I change the MongoDB root password in Docker?
Changing MONGO_INITDB_ROOT_PASSWORD has no effect on an existing volume. Log in with the old password and run db.getSiblingDB("admin").changeUserPassword("root", "new-password"), then update .env to match.
PostgreSQL in Docker: Fix Password Authentication Failed
Your Postgres container runs, but every login fails with password authentication failed for user "postgres". Or psql inside the container says role "root" does not exist. Or the container exits right away with Database is uninitialized and superuser password is not specified. The password in your .env file looks right, and that is the trap.
This guide reproduces each of these errors with the official postgres:18.6 image in Docker Compose and fixes them. Every error message below comes from that lab run on November 3, 2026, with a small Node.js client (pg 8.23.1) standing in for your app. Most of them have one root cause: the image reads its POSTGRES_* variables only once, the first time it creates the database.
These questions get a lot of traffic. On Stack Overflow, Connecting to Postgresql in a docker container from outside had 986,699 views on November 3, 2026, and postgres with docker compose gives FATAL: role "root" does not exist error had 188,086.
The short version:
- Changing POSTGRES_PASSWORD after the first start does nothing. The log says Skipping initialization. Change the password with ALTER ROLE ... PASSWORD through docker compose exec, or delete the volume if the data is disposable.
- role "root" does not exist means psql or pg_isready ran without -U. Pass -U with the user from POSTGRES_USER. If you set POSTGRES_USER, there is no postgres role at all.
- superuser password is not specified means the password reached the container empty. Check for an unset .env variable or a $ in the password, and use ${DB_PASSWORD:?} so Compose stops before it starts anything.
- From another container, connect to the service name, not localhost, and wait for a healthcheck with condition: service_healthy. URL-encode special characters in connection strings.
Fix password authentication failed after changing POSTGRES_PASSWORD
You started the stack once, then changed the password in .env or compose.yaml, recreated the container, and now nothing can log in with the new password.
Why the new password never applied
The official image runs initdb only when the data directory is empty. The image documentation says it plainly: "the Docker specific variables will only have an effect if you start the container with a data directory that is empty." An existing database is left as it is.
<img src="deploylark.com" alt="Diagram: when the postgres container starts, the entrypoint checks if the data directory is empty. If yes, initdb creates POSTGRES_USER with its password, creates POSTGRES_DB and runs the initdb.d scripts. If no, it prints Skipping initialization and ignores the POSTGRES variables and scripts" width="1200" height="620" loading="lazy" decoding="async" />
In the lab, the volume was first created with DB_PASSWORD=first-secret. After changing .env to second-secret and running docker compose up -d --force-recreate, the log showed:
PostgreSQL Database directory appears to contain a database; Skipping initialization
The container's environment had the new value, POSTGRES_PASSWORD=second-secret. The database still had the old one. A login with the new password failed with password authentication failed for user "postgres" (SQLSTATE 28P01), and the server log named the pg_hba.conf rule the connection matched:
FATAL: password authentication failed for user "postgres"
DETAIL: Connection matched file "/var/lib/postgresql/18/docker/pg_hba.conf" line 128: "host all all all scram-sha-256"
The same rule explains why init scripts seem to be ignored. A file mounted into /docker-entrypoint-initdb.d after the first start never ran, and select count(*) from notes returned relation "notes" does not exist.
Change the password inside the database
The image's pg_hba.conf trusts connections over the local Unix socket, so docker compose exec gets you in without the password. Set the new password with SQL:
docker compose exec db psql -U postgres -c "ALTER ROLE postgres WITH PASSWORD 'second-secret'"
It printed ALTER ROLE. The app logged in with second-secret right away, without a restart, and first-secret stopped working. Keep .env and the database in sync from now on, because the app reads the new value from .env while the database only changes through SQL.
<img src="deploylark.com" alt="Terminal capture: after changing DB_PASSWORD in .env, the postgres log says Skipping initialization, the container environment shows the new password, the login fails with password authentication failed for user postgres and a pg_hba.conf scram-sha-256 detail line, and ALTER ROLE fixes it" width="1200" height="439" loading="lazy" decoding="async" />
If the data is disposable, delete the volume instead. docker compose down -v removes the project's volumes, and with them all data. On the next up, the log showed running /docker-entrypoint-initdb.d/01-notes.sql, the notes table existed, and the new password worked. The MySQL and MongoDB guides cover the same first-start rule for those images.
Fix role "root" does not exist and role "postgres" does not exist
The second family of errors comes from the user name, not the password.
psql and pg_isready inside the container
docker compose exec db psql runs as root inside the container. Without -U, psql uses that operating system user as the database user:
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: role "root" does not exist
The top answer on the Stack Overflow question, by richyen, puts it this way: "If you don't specify the PGUSER environment variable, then psql will assume you want to use the current OS user as your database user name." Pass the user, as in psql -U postgres.
The same thing happens in healthchecks. With test: ["CMD", "pg_isready"], the container still showed (healthy), because pg_isready reported the server as up even though the role was rejected. But the server log got a new FATAL: role "root" does not exist line every 5 seconds, one per check. With ["CMD", "pg_isready", "-U", "app", "-d", "app"], the log had 0 FATAL lines over the next 20 seconds.
POSTGRES_USER replaces the postgres role
When you set POSTGRES_USER, the image creates that user as the superuser and does not create postgres. With POSTGRES_USER: app and no POSTGRES_DB, the lab cluster had one role, app (Superuser, Create role, Create DB, Replication, Bypass RLS), and a database also named app, matching the docs: the variable creates "the specified user with superuser power and a database with the same name."
So these failed:
$ docker compose exec db psql -U postgres
... FATAL: role "postgres" does not exist
$ docker compose exec db psql -U app -d appdb
... FATAL: database "appdb" does not exist
Over TCP, the error is less helpful. Logging in as postgres from the app with the app password returned password authentication failed for user "postgres", which sends you after the wrong problem. Only the server log told the truth: DETAIL: Role "postgres" does not exist. Check the server log before you reset any password.
<img src="deploylark.com" alt="Terminal capture: with POSTGRES_USER set to app, psql without -U fails with role root does not exist, psql -U postgres fails with role postgres does not exist, a TCP login as postgres reports password authentication failed with a server detail that the role does not exist, and a pg_isready healthcheck without -U logs FATAL lines until -U app -d app is added" width="1200" height="460" loading="lazy" decoding="async" />
Use the user and database you set, in psql, in the healthcheck and in the app's connection string. If an app insists on a postgres role or another database, create them with SQL: CREATE ROLE postgres WITH LOGIN SUPERUSER PASSWORD '...' and createdb -U app appdb both worked in the lab, and the app then logged in to appdb as postgres.
Fix Database is uninitialized and superuser password is not specified
This one stops the container on its first start. It exited with code 1 and printed:
Error: Database is uninitialized and superuser password is not specified.
You must specify POSTGRES_PASSWORD to a non-empty value for the
superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".
The message goes on to offer POSTGRES_HOST_AUTH_METHOD=trust, which lets anyone connect without a password. Don't use that to get past this error. If you did set a password, check how it gets into the container.
Unset .env variables and dollar signs
In the lab, the compose file had POSTGRES_PASSWORD: ${DB_PASSWORD} and there was no .env file next to it. Compose printed a warning and started the container anyway, with an empty password:
level=warning msg="The \"DB_PASSWORD\" variable is not set. Defaulting to a blank string."
A $ inside a password does something similar. Compose treats it as the start of a variable. With POSTGRES_PASSWORD: pa$word42 written straight into compose.yaml, Compose warned that word42 was not set, and the container got POSTGRES_PASSWORD=pa. The cluster was created with the password pa, so pa$word42 failed with password authentication failed and pa worked. The Compose interpolation docs give the fix: "You can use a $$ (double-dollar sign) when your configuration needs a literal dollar sign." With pa$$word42, the container got pa$word42.
<img src="deploylark.com" alt="Terminal capture: with DB_PASSWORD unset Compose warns and the postgres container exits with Database is uninitialized and superuser password is not specified, a dollar sign in the password is cut to pa until it is written as two dollar signs, and the colon question mark syntax makes Compose stop with an error before starting anything" width="1200" height="481" loading="lazy" decoding="async" />
Make Compose fail before it starts anything
Replace ${DB_PASSWORD} with ${DB_PASSWORD:?set DB_PASSWORD in .env}. With no .env file, docker compose up -d stopped right away and started nothing:
error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable DB_PASSWORD is missing a value: set DB_PASSWORD in .env
This matters more than it looks. An empty password stops Postgres on the first start, but the pa case above did not stop anything. It created a database with the wrong password, and the variable rule from the first section then kept that password for good.
Fix connection refused and pg_hba errors from other containers
If the password and user are right and the app still can't connect, look at the address, the timing and the connection string.
localhost, published ports and startup order
Inside a container, localhost is that container. An app with @localhost:5432 in its URL got connect ECONNREFUSED ::1:5432, connect ECONNREFUSED 127.0.0.1:5432. Use the Compose service name, such as db, as the host. node-postgres defaults to PGHOST=localhost when you set no host, so always set it.
From the host machine, the container is only reachable if you publish the port. Without ports, psql -h 127.0.0.1 -p 5432 on the host got Connection refused. With ports: - "127.0.0.1:5433:5432", the host connected on port 5433 and read the data. Binding to 127.0.0.1 keeps the port off the host's other network interfaces.
Timing is the other trap. With depends_on: - db and no condition, the app started as soon as the database container did. On a fresh volume with an init script, it failed with connect ECONNREFUSED 172.28.0.2:5432 and exited. During the first start, the entrypoint runs a temporary server with listen_addresses='', so nothing listens on TCP until the init scripts are done. Starting the app again a few seconds later worked. The fix is a healthcheck plus condition: service_healthy, shown in the Compose file below. The Grafana and Prometheus guide and the Redis guide run into the same localhost mistake.
Passwords in URLs and pg_hba.conf entries
Connection strings are URLs, so @, #, / and : in a password break them. With the password p@ss#word/1 pasted into postgres://app:p@ss#word/1@db:5432/app, the parser read the password as p, the host as ss and no database at all. Encode the password first. node -p 'encodeURIComponent("p@ss#word/1")' printed p%40ss%23word%2F1, and the URL with that value logged in. Or skip the URL and pass PGHOST, PGUSER, PGPASSWORD and PGDATABASE as separate variables, as the Compose file below does.
You only see no pg_hba.conf entry if you replaced the image's default pg_hba.conf, which ends with host all all all scram-sha-256 and allows every address with a password. A custom file that only allowed 127.0.0.1/32 over TCP rejected the app container:
no pg_hba.conf entry for host "172.28.0.4", user "postgres", database "postgres", no encryption
Add a line for the Docker network. The pg_hba.conf docs describe samenet as "any address in any subnet that the server is directly connected to", which covers the Compose network. After adding host all all samenet scram-sha-256 and running SELECT pg_reload_conf(), the same login worked without a restart. If you carried a custom file over from an upgrade, the PostgreSQL 17 to 18 upgrade guide covers the other places it can break.
<img src="deploylark.com" alt="Terminal capture: the app gets ECONNREFUSED while the database runs its init script, localhost inside the app container is refused, an unencoded password is parsed as host ss, the encodeURIComponent value logs in, and a custom pg_hba.conf returns no pg_hba.conf entry for host until a samenet line is added" width="1200" height="439" loading="lazy" decoding="async" />
A PostgreSQL Compose file that avoids these errors
This file puts the fixes together. It ran verbatim in the lab. The app service is the small Node.js test client from the lab. Replace its image, command and volume with your own app and keep the environment and depends_on parts.
The compose file
Create .env next to it with a random password. Hex characters need no escaping in Compose or in URLs:
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
services:
db:
image: postgres:18.6
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql
- ./initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "pg_isready", "-U", "app", "-d", "app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
app:
image: node:24.22.2-alpine
working_dir: /app
command: node pgtest.mjs
environment:
PGHOST: db
PGUSER: app
PGDATABASE: app
PGPASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
volumes:
- ./app:/app:ro
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
The volume is mounted at /var/lib/postgresql, the path that the 18 images expect. Both services read the same DB_PASSWORD, so there is one place to change it. If you change it later, also run the ALTER ROLE command from the first section, or the app gets 28P01 again.
Check that it works
Run docker compose up -d. Compose waited for the database before it started the app:
Container pgfinal-db-1 Waiting
Container pgfinal-db-1 Healthy
Container pgfinal-app-1 Starting
Container pgfinal-app-1 Started
The app printed OK app app PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2), and the log showed running /docker-entrypoint-initdb.d/01-schema.sql. After docker compose restart db and docker compose run --rm app, the app connected again, and the database log had 0 FATAL lines for the whole run. For a full app stack on this pattern, see the Umami with PostgreSQL guide.
FAQ
Passwords and users
Why does Postgres in Docker say password authentication failed when the password is right?
Either the database still has the password from its first start, or the user doesn't exist. Look at the server log with docker compose logs db. Skipping initialization at startup means your new POSTGRES_PASSWORD was ignored. DETAIL: Role "..." does not exist. means the user name is wrong.
How do I use a new password from .env without removing the volume?
Run docker compose exec db psql -U <user> -c "ALTER ROLE <user> WITH PASSWORD '<new>'". It works over the local socket without the old password and takes effect right away. Then keep the same value in .env for the app.
Why do I get role "root" does not exist?
Something ran psql or pg_isready inside the container without -U, so it used the operating system user root. Add -U with your POSTGRES_USER (and -d with your database) to the command or the healthcheck.
Setup and connections
How do I connect to Postgres in Docker from the host?
Publish the port, for example ports: - "127.0.0.1:5433:5432", then connect to 127.0.0.1 on port 5433 with the user and password from your Compose file. Other containers on the same network use the service name and port 5432 and need no published port.
Why didn't my docker-entrypoint-initdb.d script run?
Init scripts run only when the data directory is empty, the same as the POSTGRES_* variables. If the volume already has a database, the scripts are skipped. Run the SQL by hand with docker compose exec, or delete the volume if the data is disposable.
How do I fix Database is uninitialized and superuser password is not specified?
Give POSTGRES_PASSWORD a non-empty value. Check that your .env file sits next to compose.yaml and defines the variable, and run docker compose config to see the value Compose actually passes. Use ${DB_PASSWORD:?} so a missing value stops Compose with an error.
postgres:18 が起動直後に終了する原因:データディレクトリの場所が変わった(16 → 18 移行手順付き)
ネットにある PostgreSQL + Docker の記事の Compose ファイルをそのまま使い、タグだけ 18 にすると、コンテナが数秒で止まります。
$ docker run -d --name pgold -e POSTGRES_PASSWORD=secret -v pgold:/var/lib/postgresql/data postgres:18
b480eb9c72b29fa99f1ed8df17972e2bfc2d6da4b86c058a36cf21fd63579bfa
$ docker ps -a --filter name=pgold --format "{{.Status}}"
Exited (1) 5 seconds ago
パスワードや Volume の設定ミスではありません。公式イメージがデータを置く場所を変えたためです。以下は postgres:18(PostgreSQL 18.6)、Docker Engine 29.9 での実行結果です。
ログに書かれていること
$ docker logs pgold 2>&1 | head -n 12
Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" (specifically, using
major-version-specific directory names). This better reflects how
PostgreSQL itself works, and how upgrades are to be performed.
See also https://github.com/docker-library/postgres/pull/1259
Counter to that, there appears to be PostgreSQL data in:
/var/lib/postgresql/data (unused mount/volume)
This is usually the result of upgrading the Docker image without
upgrading the underlying database using "pg_upgrade" (which requires both
17 までのイメージは PGDATA=/var/lib/postgresql/data だったので、みんなそこに Volume をマウントしていました。18 からはメジャーバージョンごとのディレクトリがデフォルトです。
| イメージ | デフォルトのデータディレクトリ | Volume のマウント先 |
| --- | --- | --- |
| postgres:17 以前 | /var/lib/postgresql/data | /var/lib/postgresql/data |
| postgres:18 | /var/lib/postgresql/18/docker | /var/lib/postgresql |
古いマウント先のままだと、実際のデータディレクトリは Volume の外、つまりコンテナの書き込みレイヤーに作られ、コンテナを作り直した時点で消えてしまいます。エントリポイントは使われていないマウントを検出して起動を拒否します。面倒ですが、気づかないうちにデータを失うのを防いでくれる仕組みです。
対処法
親ディレクトリにマウントします。
$ docker run -d --name pg -e POSTGRES_PASSWORD=secret -p 127.0.0.1:5432:5432 -v pgdata:/var/lib/postgresql postgres:18
$ docker exec pg psql -U postgres -c "SHOW data_directory;"
data_directory
-------------------------------
/var/lib/postgresql/18/docker
(1 row)
Compose の場合:
services:
db:
image: postgres:18
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql
volumes:
pgdata:
ディレクトリ名にメジャーバージョンが入るので、将来のバージョンのデータを同じ Volume に並べて置けます。これがこの変更の狙いです。
既存の 16 / 17 のデータベースを移行する
既存のデータベースでタグだけ変えても、別の理由で動きません。データファイルはメジャーバージョン間で互換性がないからです。16 のデータディレクトリを 18 で開くと(PGDATA で古いパスを指定):
$ docker run --name pg18 -e POSTGRES_PASSWORD=secret -e PGDATA=/var/lib/postgresql/data -v pg16data:/var/lib/postgresql/data postgres:18 2>&1 | tail -n 3
2026-10-13 00:54:09.740 UTC [1] FATAL: database files are incompatible with server
2026-10-13 00:54:09.740 UTC [1] DETAIL: The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 18.6 (Debian 18.6-1.pgdg13+2).
いちばん簡単で安全なのは、ダンプして新しい Volume にリストアする方法です。古い Volume は結果を確認するまで残しておきます。
$ docker exec pg16 pg_dumpall -U postgres > upgrade.sql
$ docker run -d --name pg18 -e POSTGRES_PASSWORD=secret -v pg18data:/var/lib/postgresql postgres:18
$ docker exec -i pg18 psql -U postgres -q < upgrade.sql
ERROR: role "postgres" already exists
このエラーは想定どおりです。新しいコンテナがすでに postgres ロールを作っていて、ダンプがもう一度作ろうとしているだけで、それ以外はリストアされます。バージョンとデータを確認してから、アプリの接続先を新しいコンテナに切り替えます。
Windows では注意点が 2 つあります。PowerShell は < による入力をサポートしておらず、Windows PowerShell 5.1 の > は UTF-16 で書き出すのでダンプが壊れます。その場合は pg_dumpall -f でコンテナ内にファイルを作り、docker cp で移動します。
postgres:18 チェックリスト
- Volume は /var/lib/postgresql にマウントする(/var/lib/postgresql/data ではない)
- PGDATA を自分で指定する場合は、マウントした Volume の中にする
- マイナーアップデート(18.5 → 18.6)は pull して作り直すだけ。メジャーアップグレードはダンプ&リストア(または pg_upgrade)で、新しい Volume へ
- POSTGRES_PASSWORD などの変数はデータディレクトリが空のときだけ有効。後から変えても既存のパスワードは変わらない
Compose 構成、早すぎる healthy を返さないヘルスチェック、バックアップ、16 → 18 移行の全出力は、日本語の無料 Docker チュートリアルにまとめています:Docker で PostgreSQL を動かす
1 like
PHP2Shell で、対応をオープンにしている会社
はじめに
2026 年 10 月 21 日に修正版が出た PHP の深刻な脆弱性 PHP2Shell では、修正版が出る前からサービスを止める会社も出るなど、緊急の対応が相次ぎました。
前回は、レンタルサーバー各社で、脆弱性のある状態で利用者のサイトに外部からアクセスできた時間を並べ、自前でサービスを動かしている所も 5 つ図に入れました。
前回の終わりに書いたとおり、自社でサービスとインフラの両方を持っていれば、止めるか、配布元を待たずに自分でビルドするか、いつ再開するかを、すべて自社で決められます。
今回は、自前で動かしている会社のうち、対応の経緯をオープンにしていた 3 社、GREE・pixiv・コドモンを紹介します。
自分たちで判断して手を動かし、その時刻や経緯を記録しているから、ここまでオープンにできるのだと考えています。
3 社がオープンにしていること
3 社とも、公式のお知らせやブログに次の 3 つを書いています。
- サービスを止めた時刻
- サービスを戻した時刻(一部だけ戻したのか、全部戻したのかも分けて)
- 止めた理由が PHP の脆弱性であること
攻撃を避けるためにソフトウェアの名前を伏せる所や、始めた時刻だけをお知らせする所も多い中で、3 社はここまで公開していました。
3 社の止めた時間
gantt
title 3 社がサービスを止めていた時間(日本時間)
dateFormat YYYY-MM-DD HH:mm
axisFormat %m/%d %H時
section 予告と公開
予告(oss-security) :milestone, 2026-10-21 05:22, 0m
修正版の公開 :milestone, 2026-10-21 22:34, 0m
section GREE
サービス全体を停止 :active, 2026-10-21 23:30, 2026-10-22 06:55
SNS と決済は復旧、一部のゲームは復旧中 : 2026-10-22 06:55, 2026-10-22 15:00
section pixiv
メンテナンスモード :active, 2026-10-21 19:00, 2026-10-21 23:16
section コドモン
主要サービスを停止 :active, 2026-10-21 14:49, 2026-10-22 03:22
一部の機能は停止を続ける : 2026-10-22 03:22, 2026-10-23 09:00
PHP 公式は日本時間の 10/21 早朝、修正版を 23:00 に出すと予告しました(oss-security への投稿は 5:22)。実際に出たのは 30 分ほど早い 22:34 です(GitHub の security advisory の公開時刻)。
GREE
GREE はサービス全体を止めました。
- 10/21 23:30 ごろ: サービス全体を止めて緊急メンテナンスを開始
- 10/22 6:55 ごろ: メンテナンスを終了し、SNS や決済などを復旧(一部のゲームは復旧作業を続ける)
- 10/22 15:30: 全体復旧の見込み(15 時ごろ)のあと、タイトルに「【復旧】」と付けてお知らせを更新
修正版が出てから 1 時間ほどでサービス全体を止め、7 時間半ほどで SNS や決済などのプラットフォームの機能から先に戻しています。
お知らせでは、PHP の脆弱性が公表されたことを受けてシステムの安全性を確保するための対応だと、理由を説明しています。使っているソフトウェアの名前を伏せずに示しています。
復旧作業が続いていた一部のゲームについては、各ゲームのお知らせを見るよう案内しています。全体の状況はこのお知らせ、個別のゲームは各ゲームのお知らせ、と案内先を分けています。
全体が復旧する見込みは 10/22 15 時ごろと示し、15:30 にはお知らせのタイトルに「【復旧】」と付けて更新しました。被害やデータの流出は確認されていないことも明記し、問い合わせフォームも案内しています。停止から一部の復旧、全体の復旧まで、段階ごとに時刻を追える書き方になっています。
pixiv
pixiv は修正版が出る前にサービスを止めて待機し、予定(23:00)より早く 22:34 に出た修正版を 22:41 に確認して、そこから 35 分で再開しました。
利用者向けのお知らせとは別に、公式ブログ(pixiv inside)に作業の時刻が分単位で残っています。
- 10/21 19:00: 影響を受けうるサービスをメンテナンスモードにする
- 22:41: 予定(23:00)より早く出た修正版(22:34 公開)を確認
- 22:42〜22:56: コンテナイメージのビルド
- 22:56〜23:16: Kubernetes へのデプロイ(ステージングでの確認を含む)
- 23:16: メンテナンスモードを解除
35 分で戻せたのは、修正版が出る前に準備を済ませていたからです。
Docker 公式イメージのビルドスクリプトを使って、PHP のバージョンだけを差し替えられるようにしていました。公式イメージの公開を待たずに、ソースから自前でビルドする経路も用意していました。
Docker 公式イメージの更新がマージされたのは 10/22 0:16 で、pixiv が再開した 23:16 の 1 時間後です。配布元を待たずに自前でビルドしたことで、この 1 時間の待ち時間を減らせています。
またブログでは、メンテナンスモードが入っていないエンドポイントがあったことも課題として挙げています。今回は条件がそろっていただけで、毎回この手順で済むとは限らない、とも振り返っています。
コドモン
コドモンは、保育施設と保護者が使うサービスです。
修正版が出る 8 時間ほど前の 14:49 までに、対象となる全サービスを止めました。
- 10/21 9:01: 社内で把握
- 11:37: 23:00 の公開に合わせて、夜に止める方針で合意
- 12:37: 情報が広まってきたのを受けて、前倒しで止めると決定
- 13:00 ごろ〜14:49: 順に止め、全対象サービスの停止を完了
- 22:38: 修正版の公開を検知
- 10/22 3:22: 職員用の管理画面、保護者向けの連絡帳アプリなどを再開
- 10/23 9:00: 最後まで止めていた所在確認アラートを再開
止めると決めた理由も書いています。お預かりしているお子さまの情報を守ることを最優先にした、という判断です。
一部のサービスだけ止める案、お迎えの時間まで動かす案、監視を強めて検知したら止める案も検討し、採らなかった理由を一つずつ書いています。
反省点も並べています。修正版が届く時刻が PHP の配布元ごとに違うことを事前に把握していなかったこと、作業と判断が深夜に集中したこと、予防のための停止だと利用者に伝わりにくかったこと、などです。
何を重視するかで、止め方は変わる
3 社とも自社で決められる立場でしたが、止めた時刻と戻し方はそれぞれ違っていました。コドモンは修正版が出る 8 時間ほど前の 14:49 までに、pixiv は出る前の 19:00 に、GREE は出てから 1 時間ほどあとの 23:30 ごろに止めています。
自分のサービスで同じ判断をするなら、何を重視するかで、次のように選び方が変わります。
| 何を重視するか | 止め方 | 近い対応をした会社 |
|---|---|---|
| 利用者の情報を守ることを最優先 | 修正版が出る前に、日中でも前倒しで止める | コドモン |
| 止める時間を短くしたい | 準備を済ませてから止めて待ち、出たらすぐ当てて戻す | pixiv |
| 公開まではふだんどおり動かし、戻すときは確実に | 出てからサービス全体を止め、段階的に戻す | GREE |
- コドモン は、お迎えの時間まで動かす案も比べたうえで、お子さまの情報を守ることを最優先にして日中に止めました。主要なサービスを 10/22 3:22 に戻し、残りの機能も翌日に再開しています
- pixiv は、修正版をすぐ当てられるように準備してから 19:00 に止めました。修正版が出たあと自前でビルドして当て、23:16 に再開しています
- GREE は、修正版が出たあとの 23:30 ごろにサービス全体を止めました。翌朝 6:55 ごろに SNS や決済などのプラットフォームを先に戻し、各ゲームの復旧はそれぞれのお知らせで案内しています
修正版が出る前から止めて安全を優先するやり方も、準備を整えて止める時間を短くするやり方も、公開後に全体を止めて段階的に戻すやり方もあります。唯一の正解はなく、サービスの性質と何を守りたいかで、選び方は変わります。
自分たちで決めて記録しているから、オープンにできる
3 社に共通するのは、サービスもインフラも自社で持っていることです。止めるかどうか、どこまで止めるか、いつ戻すかを、配布元やほかの会社の判断を待たずに自分たちで決められます。
自分たちで決めて自分たちで作業しているので、いつ何をしたか、なぜそうしたかを、自社の記録だけで正確に書けます。
- GREE は、全体を止めるタイミングも、SNS や決済から先に戻す順番も自社で決めています。そのため、停止から一部の復旧、全体の復旧まで、段階ごとの時刻をそのままお知らせに出せています
- pixiv は、Docker 公式イメージの更新を待たずに、コンテナイメージのビルドから Kubernetes へのデプロイまでを自分たちで動かしています。作業を自社で完結させているから、分単位の記録をブログに残せています
- コドモン は、社内で把握した 9:01 から方針を変えた 12:37 まで、判断した時刻と理由を記録していました。自分たちで比較して決めているから、採らなかった案とその理由まで公開できています
判断から作業までが自社の中で完結していて、その記録も残っているから、ここまで具体的な時刻や経緯をオープンにできます。
前回のレンタルサーバーは、この点で条件が違います。インフラは業者のものでも、その上で動くサイトは利用者のものです。業者が利用者のサイトを止めれば、利用者の業務も止まるため、業者だけの判断では止めにくい事情があります。また、自前でビルドするのではなく配布元のパッケージを待つ構成なら、対応を始める時刻も配布元しだいになります。利用者との関係や作業工程の制約があるので、すべてを自社だけで決めて動ける 3 社と同じようにはいかない所があるのは仕方ありません。
おわりに
今回紹介した 3 社は、対応を自社で決めるだけでなく、サービスを止めた時刻と再開した時刻、そして PHP の脆弱性への対応であることを明記していました。さらに pixiv はビルドからデプロイまでの分単位の記録を、コドモンは方針変更の時刻や採らなかった案の理由まで公開しています。
自分たちで判断して対応し、その記録を残しているから、いつ止めていつ戻したかを正確に出せるだけでなく、判断や作業の経緯までオープンにできる。そこがこの 3 社の良さです。
本記事の情報は 10/29 時点のものです。
参考文献
- GREE「【復旧】緊急メンテナンスについて」 games.gree.net
- pixiv お知らせ pixiv.net
- pixiv inside「PHP2Shell」対応の記録 inside.pixiv.blog
- コドモン Product Team Blog「PHP2Shellへの対応を振り返って」 tech.codmon.com
- oss-security(予告) openwall.com
- Docker official-images #22541 official-images#22541
- GitHub の security advisory(修正版の公開の時刻 13:34 UTC)github.com/php/php-src/security/advisories/GHSA-w… ・ github.com/php/php-src/security/advisories/GHSA-6…
1 like
PHP2Shell で、サイトが無防備だった時間
はじめに
PHP2Shell について、Qiita にもいろいろな記事が出ています。
みなさんの記事を読んでいてふと思いました。
利用者のサイトが脆弱性のある状態で外部からアクセスできるままになっていた時間を、各社のお知らせから測ってみようか、と。
ちなみに、私が昔いた現場はパッチの適用が数日遅れていました。さもありなん、です。
PHP の脆弱性の話になると、PHP を使う開発者だけでなく、レンタルサーバーの業者までいい加減なのでは、と言われがちです。私も最初はそう思っていました。
脆弱性のある状態で利用者のサイトを外部にさらしていたこと自体は、インフラを預かる業者の責任です。ただ、それが「いい加減だったから」なのか、作業の工程上しかたない部分があったのかを、公式のお知らせの時刻で確かめます。
予告から公開まで
PHP 公式は日本時間の 10/21 早朝、深刻度 9.2 の脆弱性 2 件の修正版を 23:00 に出すと予告しました(oss-security への投稿は 5:22)。実際に出たのは 22:34 です(GitHub の security advisory の公開時刻)。
gantt
title 予告から修正版の公開まで(日本時間)
dateFormat YYYY-MM-DD HH:mm
axisFormat %m/%d %H時
section 予告と公開
予告(oss-security) :milestone, 2026-10-21 05:22, 0m
修正版がない時間(17 時間あまり) : 2026-10-21 05:22, 2026-10-21 22:34
修正版の公開 :milestone, 2026-10-21 22:34, 0m
一方で、悪用の試みは公表前の 10/17 から見つかっていたことが判明しています。
予告より早く出た理由は公表されていません。
各社の対応時刻と停止期間
安全になるための条件は、パッチを当て終えることです。
ただ、当て終えた時刻だけでは、危険だった時間は分かりません。先にサイトを止めていれば、そのあいだは外部からアクセスできないからです。
そこで、止めた時間と当て終えた時刻の両方を図にしました。
図の最上段は予告と公開の時刻です。その下がレンタルサーバー、いちばん下が自前でサービスを動かしている所で、バーの色は次のとおりです。
- 赤いバー(露出): 予告のあと、サイトの停止や緩和策、パッチの適用がまだで、脆弱性のある状態で外部からアクセスできた時間
- 薄い青のバー(停止): サイトやサービスを止めていた時間
- 青紫のバー: 緩和策を入れていた時間、またはパッチを順番に当てていた時間
- 灰色のバー: パッチを当て終えた後、または表示を再開した後
「自前」は、レンタルサーバーを借りずに、自社のサービスを自社で用意したサーバー(クラウドを含む)で動かしている所のことです。外部から見ても PHP を使っているかどうか分からないので、止めた時間だけを描いています。
gantt
title 予告から公開後までの状態(日本時間)
dateFormat YYYY-MM-DD HH:mm
axisFormat %m/%d %H時
section 予告と公開
予告(oss-security) :milestone, 2026-10-21 05:22, 0m
修正版の公開 :milestone, 2026-10-21 22:34, 0m
section レンタルサーバー
エックスサーバー 露出 :crit, 2026-10-21 05:22, 2026-10-21 12:00
エックスサーバー 緩和策のみ・順次適用(全部の完了は 10/26) : 2026-10-21 12:00, 2026-10-23 12:30
コアサーバー V1 ほか 露出 :crit, 2026-10-21 05:22, 2026-10-22 00:50
コアサーバー V1 ほか 適用済み :done, 2026-10-22 00:50, 2026-10-23 12:30
mixhost 露出 :crit, 2026-10-21 05:22, 2026-10-22 10:18
mixhost 適用済み :done, 2026-10-22 10:18, 2026-10-23 12:30
ヘテムル 露出 :crit, 2026-10-21 05:22, 2026-10-22 03:05
ヘテムル サイトを停止 :active, 2026-10-22 03:05, 2026-10-22 11:24
ヘテムル 表示を再開 :done, 2026-10-22 11:24, 2026-10-23 12:30
お名前.com 露出 :crit, 2026-10-21 05:22, 2026-10-22 20:45
お名前.com 適用済み :done, 2026-10-22 20:45, 2026-10-23 12:30
KAGOYA 露出 :crit, 2026-10-21 05:22, 2026-10-22 14:40
KAGOYA 順次適用 : 2026-10-22 14:40, 2026-10-23 00:00
KAGOYA 適用済み :done, 2026-10-23 00:00, 2026-10-23 12:30
section 自前で動かすサービス
BASE 停止 :active, 2026-10-21 15:50, 2026-10-22 08:00
ジンジャー 停止(終わりは予定の時刻) :active, 2026-10-21 17:00, 2026-10-22 05:00
freee の 4 サービス 停止 :active, 2026-10-21 19:04, 2026-10-22 12:56
Garoon クラウド版 停止 :active, 2026-10-21 21:00, 2026-10-22 03:01
LoGo フォームの PDF 印刷 停止 :active, 2026-10-21 21:00, 2026-10-22 02:09
図に入れた自前の 5 つは songchong さんの記事から取ったもので、どれも修正版が出る前に止めています(同じ記事にあるほかの 6 つは、公開のあとに止めています)。
レンタルサーバー各社の動きは次のとおりです。
- ヘテムル・ロリポップ!: パッチが当たるまで利用者のサイトごと止めた 2 社です(ロリポップ!は止めた時刻の記載がないので図にはありません)
- エックスサーバー: サイトは止めませんでしたが、公開前の 12:00 までに緩和策を入れ、公開後は順次パッチを当てています
- コアサーバー V1 など: 止めずに、公開から 2 時間ほどで当て終えています
図に入れていない所は次のとおりです。
| 事業者 | 止めた・緩和策 | パッチの適用 |
|---|---|---|
| ロリポップ! | 10/21 からサイトの表示を停止 | 10/22 15:33 の更新で適用済み |
| さくらのレンタルサーバ | 10/21 22:30 から会員関連の機能を緊急メンテナンス | 10/23 に適用済み |
| WebARENA | 10/21 に緩和策と、海外からのアクセスの停止 | 10/22 17:30 から緊急メンテナンス |
| ConoHa WING | ファイルマネージャーと Web メールを停止 | 10/22 時点で順次適用中 |
| CPI | — | 8.2〜8.4 は 10/22、7.4・8.0 は 10/27〜10/29 |
レンタルサーバーの時刻は、各社の公式のお知らせから拾いました(10/27 に確認)。並べたのはお知らせに書かれた時刻(またはお知らせの更新時刻)で、必ずしも全部のサーバーに当て終えた時刻ではありません。
ただ、こうして並べても「各社が何時間、脆弱性のある状態で外部にさらしていたか」は一つの数字に決まりません。
10/17(悪用の試みが見つかった日)と 10/21(公式の予告)のどちらから数えるかで 4 日ずれますし、サイトを止めたのか、お知らせに書いていないだけなのか、区別のつかない所があるからです。
!どこから数えるか(10/17〜10/29)
当て終えるまでに時間がかかった理由(推定)
- 公式の修正版は 8.2〜8.5 だけで、古いバージョンはバックポートか独自の修正待ち
- 一度に当てず順番に当てたので、全体が終わるまで時間がかかった(KAGOYA は 14:40〜24:00 の 9 時間あまり。順番にした理由はお知らせに書かれていません)
- パッケージで PHP を入れている場合は、配布元(CloudLinux や remi など)のビルドを待つ必要があった(下の表)
- 公開直後は配布元にアクセスが集中し、つながりにくくなった所があった(Ubuntu 向けパッケージの配布に使われる Launchpad が 503 エラー、php.net が 504 エラー)
!修正版がサーバーに当たるまでの 3 つの経路
配布元が修正版のパッケージを出した時刻は次のとおりです(比較のため、先頭に PHP 公式の公開時刻を入れています)。
| 配布元 | 出た日時(日本時間) | 修正版の公開から |
|---|---|---|
| PHP 公式(ソースコード) | 10/21 22:34 | — |
| Debian(DSA 6574-1) | 10/21 22:45 | 11 分 |
| Ubuntu(USN-8948-1) | 10/21 22:56 | 22 分 |
| Debian LTS(DLA 4848-1) | 10/21 23:11 | 37 分 |
| Ondřej Surý の PPA(Launchpad) | 10/21 23:45 | 1 時間 11 分 |
| Docker 公式イメージ(official-images へのマージ) | 10/22 0:16 | 1 時間 42 分 |
| Red Hat Enterprise Linux 8〜10(AlmaLinux 9 も同じ日) | 10/21(UTC・時刻なし) | — |
| remi(RHEL 系で広く使われるリポジトリ) | 10/22 21:58 | 23 時間 24 分 |
| Red Hat の延長サポート向け(EUS など) | 10/23(UTC・時刻なし) | — |
多くの配布元は、公開から 2 時間以内にパッケージを出しています。待ちが長かったのは remi と、Red Hat の延長サポート向けです。
レンタルサーバー各社が修正をどこから得たかは、お知らせに次のように書かれています。
| 事業者 | 修正の出どころ(お知らせの記載) |
|---|---|
| エックスサーバー | 8.2〜8.5 は修正版に更新、5.1〜8.1 はバージョンを変えずに修正パッチを適用。出どころの記載なし |
| コアサーバー V1 | 記載なし |
| mixhost | 採用している CloudLinux の修正パッケージ。サポートが終わったバージョンも CloudLinux が独自に修正 |
| ヘテムル | 公式の修正版がない古いバージョンは、自社で修正を作成 |
| ロリポップ! | 公式の修正版が出たバージョンはそれを適用し、ほかは自社で独自に修正 |
| お名前.com | バージョンを変えずにパッチを適用。出どころの記載なし |
| KAGOYA | 修正版に更新。出どころの記載なし |
| さくらのレンタルサーバ | 記載なし |
| WebARENA | 記載なし |
| ConoHa WING | バージョンを変えずにパッチを適用。出どころの記載なし |
| CPI | 8.2〜8.4 は修正パッチを適用、7.4・8.0 はパッチ適用版を準備。出どころの記載なし |
配布元のパッケージを待っていたと書いている所はありませんでした。当て終えるまでにかかった時間のうち、どこまでが配布元の待ちによるものかは、お知らせからは分かりません。
主な配布元の時刻を、さきほどの図の最上段に足すと次のとおりです(自前の段は省きました)。レンタルサーバー各社のバーと見比べられます。
gantt
title 配布元の公開時刻とレンタルサーバーの対応(日本時間)
dateFormat YYYY-MM-DD HH:mm
axisFormat %m/%d %H時
section 予告・公開・配布元
予告(oss-security) :milestone, 2026-10-21 05:22, 0m
修正版の公開 :milestone, 2026-10-21 22:34, 0m
Debian(22時45分)・Ubuntu(22時56分) :milestone, 2026-10-21 22:45, 0m
Ondřej Surý の PPA :milestone, 2026-10-21 23:45, 0m
Docker 公式イメージ :milestone, 2026-10-22 00:16, 0m
remi :milestone, 2026-10-22 21:58, 0m
section レンタルサーバー
エックスサーバー 露出 :crit, 2026-10-21 05:22, 2026-10-21 12:00
エックスサーバー 緩和策のみ・順次適用(全部の完了は 10/26) : 2026-10-21 12:00, 2026-10-23 12:30
コアサーバー V1 ほか 露出 :crit, 2026-10-21 05:22, 2026-10-22 00:50
コアサーバー V1 ほか 適用済み :done, 2026-10-22 00:50, 2026-10-23 12:30
mixhost 露出 :crit, 2026-10-21 05:22, 2026-10-22 10:18
mixhost 適用済み :done, 2026-10-22 10:18, 2026-10-23 12:30
ヘテムル 露出 :crit, 2026-10-21 05:22, 2026-10-22 03:05
ヘテムル サイトを停止 :active, 2026-10-22 03:05, 2026-10-22 11:24
ヘテムル 表示を再開 :done, 2026-10-22 11:24, 2026-10-23 12:30
お名前.com 露出 :crit, 2026-10-21 05:22, 2026-10-22 20:45
お名前.com 適用済み :done, 2026-10-22 20:45, 2026-10-23 12:30
KAGOYA 露出 :crit, 2026-10-21 05:22, 2026-10-22 14:40
KAGOYA 順次適用 : 2026-10-22 14:40, 2026-10-23 00:00
KAGOYA 適用済み :done, 2026-10-23 00:00, 2026-10-23 12:30
危険な時間を減らすには
ここからは私の見方です。
いい加減だったのか
測ってみた結果、業者がいい加減だったとは言えません。時間がかかった所も、作業の工程の限界によるものだと私は見ています。
- 主な配布元は修正版の公開から 2 時間以内にパッケージを出し、レンタルサーバーでも公開から 2 時間ほどで当て終えた所(コアサーバー V1 など)があります
- 予告から公開までのあいだに、手を打った所があります。エックスサーバーは 12:00 までに緩和策を入れ、ヘテムルは 13:30 ごろから phpMyAdmin などの機能を止め始め(サイトの表示を止めたのは 10/22 3:05)、さくらは 22:30 から会員関連の機能の緊急メンテナンスに入りました
- 公式の修正版がない古いバージョンにも手当てをしています。ヘテムルとロリポップ!は自社で修正を作り、mixhost は CloudLinux の修正を使い、エックスサーバーは 5.1 まで対応を広げました
もちろん、パッチが当たるまでのあいだ、脆弱性のある状態で利用者のサイトを外部にさらしていた責任は業者にあります。
ただ、完了が翌日以降になった所も、手を抜いていたとは言えません。古いバージョンを抱えていたり、配布元のパッケージを待つ構成だったり、順番に当てる必要があったりすれば、作業の工程上、すぐには当て終えられないからです。
CERT-EU・英国の NCSC・米国の NSA・日本の IPA は、すぐ当てられないなら止めるよう呼びかけていました。サイトを止めてからパッチを当てるやり方は、再開が遅くなっても安全です。呼びかけどおりサイトごと止めたヘテムルとロリポップ!がこれにあたります。
とはいえ、レンタルサーバーが利用者のサイトを止めるのは簡単ではありません。
レンタルサーバーの業者はインフラを持っていますが、その上で動くサイト(サービス)は利用者のものです。止めれば利用者の業務が止まり、動かし続ければ脆弱性にさらされるので、業者だけでは決めにくい立場にあります。
これに対して、自社でサービスとインフラの両方を持っている会社は、サービスを止めるか、配布元を待たずに自分でビルドするか、いつ再開するかを、すべて自社で決められます。さきほどの図で、自前の 5 つが修正版の公開前に止めていたのも、自社で決められる立場にあったから取れた手です。
時刻を分単位で出している所は、測られてもいいと分かって出している所です。書いていない所が悪い、という話ではありません。
どこで露出していたか
ここまでの図と表から、露出していた時間は 2 つに分かれます。
!露出していた時間は 2 つに分かれる(レンタルサーバー)
1 つ目の「予告から公開まで」は、この図のレンタルサーバーではエックスサーバーが 12:00 までに緩和策を入れ、さきほどのガントチャートにあった自前の 5 つが公開前にサービスを止めていました。
2 つ目の「公開から当て終える・止めるまで」は、サポート切れの古いバージョンほど長引いています。公式の修正版がないため自社で作るか CloudLinux などから得る必要があり、図に入れていない CPI の PHP 7.4・8.0 は適用が 10/27〜10/29 でした。
減らすためにできること
1 で「予告から公開まで」、2・3 で「公開から当て終えるまで」の 2 つの露出時間を減らし、4・5 で利用者ごとの影響と確認の手間を減らせます。
!2 つの時間を、どこで減らすか
1. 予告の段階(止めるか遮断するか): 止めるか動かし続けるかを利用者が前もって選べる設定(Google Compute Engine のメンテナンス設定のようなもの)を用意し、動かし続ける利用者は WAF で攻撃のリクエストを遮断します。Cloudflare は今回、修正版の公開の 10 時間半ほど前に、PHP2Shell を遮断するルールを既定でオンにしています
2. 配布元を待たない経路: ソースからビルドできるようにしておけば、パッケージを待たずに済みます。pixiv は公式イメージの公開を待たずにビルドする経路を用意していて、修正版に気づいてから 35 分で再開しました。Docker 公式イメージの更新がマージされたのは、その 1 時間後の 10/22 0:16 です
3. サポート切れのバージョンへの備え: 古いバージョンほど修正に時間がかかりました。移行の期限を決めておくか、TuxCare のようなバックポートを使えるようにしておきます
4. アカウントごとの実行環境の分離: 順番に当てると全体が終わるまで時間がかかります。mixhost が採用している CloudLinux には、アカウントごとに PHP を選べる PHP Selector があります
5. 露出していた時間の利用者ごとの通知: 順番に当てた場合、全体の完了時刻だけでは自分のサイトがいつまで露出していたか分かりません。その利用者のサイトに外部からアクセスできた時間を知らせます
処理の流れにすると、次のとおりです。
flowchart TD
A[予告が出る] --> B{利用者ごとの設定}
B -->|止めて待つ・既定| C[サイトを止める]
B -->|動かしたまま| D[WAF で攻撃のリクエストを遮断する]
E[上流を待たずにソースからビルド] --> F[修正版を当てる]
G[サポート切れのバージョン] -->|期限を決めて移行か、バックポート| F
C --> F
D --> F
F --> H[再開して、外部からアクセスできた時間を利用者ごとに通知]
subgraph アカウントごとに分けた PHP の実行環境
B
C
D
F
end
!危険な時間を減らす処理の流れ
必要な仕組みは、どれも既存の製品や機能にあります。これらをあらかじめ組み合わせておけば、作業の工程でどうしてもかかる時間があっても、外部にさらす時間は減らせます。
もっとも、いちばん強いのは、外部からアクセスできる公開側に PHP の実行環境(ランタイム)を置かないことです。前回の話のとおり、置かなければ、さきほどの対応の図に並ぶこともありません。
おわりに
脆弱性のある状態でサイトを外部にさらしていた責任はレンタルサーバー業者にあります。ただ、時間がかかったのは業者がいい加減だったからではなく、作業の工程や、利用者のサイトを業者だけでは止めにくい立場によるものです。そのうえで、前もって仕組みを組み合わせておけば、危険な時間はまだ減らせます。
一方で、自社でサービスとインフラの両方を持っていれば、止める判断もビルドの手順も自分で決められます。次の記事では、自前でサービスを動かしている会社のお知らせから、止めた時刻と戻した時刻、そこに至る経緯まで詳しく公開していた 3 社(GREE・pixiv・コドモン)を取り上げています。
10/27 時点の情報です。サイトの側から見た Tivo0921 さんの記事もあわせてどうぞ。
興味のある方の参考になれば幸いです。ご指摘は歓迎します。
参考文献
- PHP News Archive 2026 php.net
- oss-security(予告) openwall.com
- 配布元: Debian DSA 6574-1・Debian DLA 4848-1・Ubuntu USN-8948-1・Ondřej Surý の PPA・Docker official-images #22541・Red Hat・remi
- GitHub の security advisory(修正版の公開の時刻 13:34 UTC)github.com/php/php-src/security/advisories/GHSA-w… ・ github.com/php/php-src/security/advisories/GHSA-6…
- Tenable(10/17 の悪用の試み) tenable.com
- 各社のお知らせ: エックスサーバー・同 停止・コアサーバー V1・mixhost・ヘテムル・ロリポップ!・お名前.com・KAGOYA・さくら・WebARENA・ConoHa WING・CPI
- UptimeRobot(配布元の混雑) uptimerobot.com
- IPA 注意喚起 ipa.go.jp
- Cloudflare の WAF(ppc.land) ppc.land
- CloudLinux PHP Selector support.cpanel.net
- TuxCare(Plesk) plesk.com
- Google Compute Engine cloud.google.com
1 like
The Compose Race Your Coding Agent Inherits From You
!Hero artwork: a robotic arm stamping green checkmarks on a conveyor belt of identical generated compose files, the finished stack toppling into a wall labeled connection refused
Ask a coding agent for a Docker Compose file with Postgres, then cold-boot the result on a machine that has never run it. There is a good chance the app container starts, immediately asks for a database that does not exist yet, and fails its first connections. Wait a few seconds, try again, and everything works. The agent will tell you it tested the file. Both things are true, and the bug is older than the agent: it inherited the race from the tutorials it was trained on, and it also inherited the half-fix that looks like the fix.
Docker Compose is a tool for defining and running multi-container applications from one YAML file. I tested the generated shape, the half-fixed shape, and the corrected shape on my own host this week: three cold starts on fresh volumes (Docker Engine 29.1.3, Compose v5.1.4), the inherited race, the inherited half-fix that still races, and the three-line correction that actually holds. Everything below is from that run, with the run methodology and the timestamps as captured.
> Key Takeaways
> - A bare depends_on: list only orders container startup. Compose explicitly does not wait for "ready", only "running" (Docker docs).
> - The most-copied Postgres healthcheck, pg_isready -U postgres, probes the Unix socket, and during first-boot initialization a temporary Postgres server answers on that socket before the real one is up (docker-library/postgres issue #146).
> - In my run, that socket-probe healthcheck reported the database healthy 4 seconds before Postgres actually accepted a TCP connection. The gate was wired correctly. The probe lied.
> - The fix is three edits: condition: service_healthy, restart: true, and -h 127.0.0.1 on the probe. My corrected file connected on attempt one, on a cold volume.
In this post:
- [What does the agent actually write?](#what-does-the-agent-actually-write)
- [Why does the race survive a working demo?](#why-does-the-race-survive-a-working-demo)
- [What did I actually run?](#what-did-i-actually-run)
- [Why doesn't the copied healthcheck fix it?](#why-doesnt-the-copied-healthcheck-fix-it)
- [What does the fixed file look like?](#what-does-the-fixed-file-look-like)
- [How do you review agent YAML in 60 seconds?](#how-do-you-review-agent-yaml-in-60-seconds)
- [FAQ](#faq)
What does the agent actually write?
Two lines date a compose file, and agents write both of them together.
The first is the bare dependency list. In the long form of depends_on, per the compose-file services reference, this same key takes a condition instead, which is the part the 2021 corpus never learned:
services:
app:
depends_on:
- db
The second is the socket-probe healthcheck on the database. healthcheck is a container configuration that makes the runtime probe the service at a fixed interval and report a health status:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
Neither line is broken on its own. The first one does exactly what it says: start db before app. The second one does exactly what it says: exit 0 when something answers on the Postgres Unix socket. The problem is what they don't say, and they cluster in generated files because they cluster in the training corpus. Both come from the same generation of quick-start guides, the ones that shipped years before the long-form depends_on with conditions was common knowledge.
The evidence that this is a corpus problem, not a reasoning problem, is now measured. Work on updating model knowledge after API changes found that models continue generating outdated API calls "even with access to current documentation" (arXiv:2506.20495). A study of deprecated-API recommendations reports that 37.4 percent of GPT-3.5's API predictions were deprecated, and that fixing this by retraining is impractical because library APIs change too fast (arXiv:2511.21022). Compose YAML is not a Python library call, but the mechanism transfers exactly: the spec moved, the tutorials didn't, and the average of the corpus is the 2021 shape.
!Diagram of the inheritance pipeline: dated tutorial books flow into a glowing model core that outputs a compose file card, which feeds a container stack where a race condition plays out
Why does the race survive a working demo?
Because a warm volume hides it.
Postgres only does its slow first-boot work when the data directory is empty. The entrypoint script runs initdb, starts a temporary server for the setup phase, runs anything mounted in /docker-entrypoint-initdb.d/, and only then hands over to the real server as PID 1. On a volume that has already been initialized, the container skips all of that, and the gap between "container running" and "accepting connections" shrinks to near zero. Whoever verifies the compose file, human or agent, usually verifies it on a warm volume. The demo passes. The agent's confidence is not fake; the test genuinely passed on the machine it had.
The first cold start is the one that pays for it: CI runner, teammate's laptop, fresh VM. That is the boot where the app container starts a beat after the database container and asks a half-initialized Postgres for a connection.
Here is the output of that exact cold start from my run, an alpine app probing db:5432 every half second in a loop. Consider it the shape of every "it works on the second try" bug report you have ever closed:
app-1 | ATTEMPT 1: CONNECTION-REFUSED
app-1 | ATTEMPT 2: CONNECTION-REFUSED
app-1 | ATTEMPT 3: CONNECTION-REFUSED
app-1 | ATTEMPT 4: CONNECTION-REFUSED
app-1 | ATTEMPT 5: CONNECTED
Two seconds of refused connections on a fast local disk. The database log tells you why: the "ready to accept connections" line lands at 08:10:28, after the app has already been asking. On a slower disk, or with init scripts in /docker-entrypoint-initdb.d/, the window stretches to seconds more. If your app has restart: unless-stopped, Compose quietly restarts it until it sticks, the stack comes up, and the race is invisible again until the morning it crash-loops.
I documented the anatomy of this race in the healthcheck deep-dive earlier in this series. The part that is new here is where the YAML came from, and the fact that the obvious fix, asking the agent to add a healthcheck, often doesn't fix it.
<!-- UNIQUE INSIGHT: the inherited race and the inherited half-fix come from the same corpus generation, so the agent reproduces both together. -->
What did I actually run?
<!-- ORIGINAL DATA: reproduced locally, Docker Engine 29.1.3, Docker Compose v5.1.4, alpine:3.20 + postgres:16-alpine, 2026-10-29, three runs on fresh volumes. -->
Three cold starts on my own host: Docker Engine 29.1.3, Docker Compose v5.1.4, with alpine:3.20 as the app and postgres:16-alpine as the database. Every run started from a deleted volume, so Postgres went through its full first-boot initialization each time. The app is deliberately dumb, a shell loop that tries db:5432 every half second and prints the result, so the timing is visible and nothing else can mask it:
services:
app:
image: alpine:3.20
depends_on:
- db
command: >
sh -c 'i=0;
while [ $$i -lt 30 ]; do
i=$$((i+1));
if nc -z -w 1 db 5432 2>/dev/null; then
echo "ATTEMPT $$i: CONNECTED";
exit 0;
else
echo "ATTEMPT $$i: CONNECTION-REFUSED";
fi;
sleep 0.5;
done;
echo "GAVE UP"'
For the third run, to make the initialization window wide enough to observe, I mounted a one-line init script into /docker-entrypoint-initdb.d/ that sleeps four seconds. The sleep does not create the gap, it only stretches the same initialization phase that already existed in the first run, and every timestamp below comes from docker inspect and the Postgres log, not from my watch. The three runs, all from the same host:
| Run | File shape | Outcome |
|---|---|---|
| 1 | bare depends_on list | 4 refused connections, connected on attempt 5 |
| 2 | service_healthy + TCP probe | connected on attempt 1, app held until Healthy |
| 3 | service_healthy + socket probe | "healthy" 4s early, 7 refused connections after the verdict |
Why doesn't the copied healthcheck fix it?
Here is where the inherited fix becomes worse than the inherited race. Ask an agent to fix the startup race and it will, correctly, reach for the long-form dependency. service_healthy is the depends_on condition that holds the dependent container back until the dependency's healthcheck reports healthy:
app:
depends_on:
db:
condition: service_healthy
That is the right gate. Compose will not start the app until the database's healthcheck reports healthy. But the healthcheck it copies, the same one from the same tutorials, is pg_isready -U postgres, and pg_isready without a host flag connects to the Unix socket. pg_isready is the Postgres client's readiness probe; it exits 0 when a server accepts connections. On the official Postgres image, first boot runs a temporary server for initialization that listens on the Unix socket only. The probe passes while the real server, the one your app connects to over TCP, is not up yet. The maintainers have been explaining this in issue #146 for years: "Simply adding -h 127.0.0.1 to the pg_isready command line should be enough to detect readiness correctly."
I ran that combination, the correct gate with the copied probe, and watched docker inspect while it happened. The healthcheck went starting at +0.78 seconds and healthy at +3.02 seconds. The real server accepted its first TCP connection at +7.06 seconds. Four seconds of lie. Compose trusted the verdict, started the app, and the app did this:
app-1 | ATTEMPT 1: CONNECTION-REFUSED
app-1 | ATTEMPT 2: CONNECTION-REFUSED
app-1 | ATTEMPT 3: CONNECTION-REFUSED
app-1 | ATTEMPT 4: CONNECTION-REFUSED
app-1 | ATTEMPT 5: CONNECTION-REFUSED
app-1 | ATTEMPT 6: CONNECTION-REFUSED
app-1 | ATTEMPT 7: CONNECTION-REFUSED
app-1 | ATTEMPT 8: CONNECTED
Seven refused connections after the container was declared healthy. The probe log shows the mechanics, and it is worth reading slowly because it is the whole post in three lines:
08:13:52.4 exit 0 /var/run/postgresql:5432 - accepting connections
08:13:54.5 exit 0 /var/run/postgresql:5432 - accepting connections
08:13:56.5 exit 2 /var/run/postgresql:5432 - no response
The first two probes passed against the temporary initialization server, on the socket. Then the entrypoint shut that server down to finish init work, and the third probe failed, no response, while the real server was still seconds away. The status even flipped to unhealthy for a moment during the handoff. If the failure had landed one probe later, after the container was already marked healthy, the streak would have needed three consecutive failures to matter, and by then the app had long since started and crashed.
This is the failure mode that makes people conclude the healthcheck pattern "doesn't work" and delete it. The gate worked. The probe answered a different question than the one the app asks.
What does the fixed file look like?
Three edits to the generated file, and my run 2 output after them:
Container racefix-db-1 Started
Container racefix-db-1 Waiting
Container racefix-db-1 Healthy
Container racefix-app-1 Starting
Container racefix-app-1 Started
app-1 | ATTEMPT 1: CONNECTED
The app is not created until db reports Healthy, and its very first connection lands. The full file:
services:
app:
image: alpine:3.20
depends_on:
db:
condition: service_healthy
restart: true
command: >
sh -c 'i=0;
while [ $$i -lt 30 ]; do
i=$$((i+1));
if nc -z -w 1 db 5432 2>/dev/null; then
echo "ATTEMPT $$i: CONNECTED";
exit 0;
else
echo "ATTEMPT $$i: CONNECTION-REFUSED";
fi;
sleep 0.5;
done;
echo "GAVE UP"'
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
interval: 2s
timeout: 3s
retries: 10
start_period: 10s
volumes:
pgdata:
The three edits, one line each. condition: service_healthy makes Compose wait for the healthcheck verdict instead of container start, the gate. restart: true re-runs that gate when you explicitly restart the database, so the app is not left pointing at stale connections after a docker compose restart db. And -h 127.0.0.1 forces the probe onto TCP, where the temporary initialization server cannot answer it, the honest probe. The timing knobs around it matter too, start_period especially: they give a slow first boot room to finish before failures start counting. On a cold volume my database needed about seven seconds, so 10 seconds of grace is the floor I would ship, and CI on shared runners gets more.
Keep the application-side retry anyway. The gate protects the startup sequence only. If Postgres restarts two hours later, nothing re-runs the gate for you, a limit I pushed on in the exit-0 OOM post from the other angle: a green healthcheck reports its own probe, not your app's reality.
How do you review agent YAML in 60 seconds?
You do not need to run the stack to catch this class of bug. Read the diff against the current spec, the same way you would review a human's PR.
Check the dependency wiring first. A bare depends_on: - db list means no waiting; it needs the long form with condition: service_healthy to gate startup. Check the probe next, because this is the part everyone trusts. pg_isready with no -h flag is a socket probe; on the Postgres image that can pass during initialization. It needs -h 127.0.0.1 (or the service name, if you want the probe to mirror app traffic) to test the path your app actually uses. Check the timing budget after that: no start_period means a slow first boot spends its failure budget before the database had a chance. CI runners are slower than your laptop, so give the budget headroom there. And check what happens after startup: no restart: true on the dependency means a deliberate database restart leaves the app on stale connections.
Then fix the input, not just the output. The prompt that produces current YAML is a prompt with current references in it. Paste the startup-order doc and the services reference links into the request, and say "gate the app on service_healthy with a TCP probe" instead of "write me a compose file with Postgres". The model still carries the old corpus, and it will still try the 2021 shape when you let it guess. But it does not argue with a pasted spec. The measured version of this instinct is the ReCode result: outdated API knowledge persists "even with access to current documentation" (arXiv:2506.20495). The documentation you paste is doing real work, and the review pass you run after is doing the rest.
One more habit that pays for itself: cold-boot the result before you merge it. On a scratch project, docker compose down -v && docker compose up --wait is a 30-second test. It reproduces exactly the run that fails in CI, and --wait turns the healthcheck into the pass/fail signal instead of a grepped log line.
FAQ
Doesn't restart: unless-stopped already handle this?
It hides it. The app crash-loops until the database happens to be ready, then looks healthy. The race is still there; you just stopped being able to see it.
Does the long depends_on form without condition wait for readiness?
No. Without condition: service_healthy it is equivalent to the short list: ordering only.
Is docker compose up --wait enough for CI?
Yes, and it is the right tool: it blocks until healthchecked services report healthy. It does not remove the need for the gate on the app service itself, or for an honest probe.
Do I still need app-level retries if the gate works?
Yes. The gate runs at startup only. A connection that drops an hour later is the application's problem, and the same retry-with-backoff loop you already write for production networks covers it.
Where does this leave the agent?
The agent is not wrong that the file works. It worked on the machine it had, with the volume it had, and the corpus it was trained on says that file is what a compose file looks like. The spec moved, the tutorials didn't, and the average of all that 2021 YAML is what comes out when you ask for 2026 infrastructure. The three-line diff between the inherited shape and the current one is small, it is mechanical, and it is exactly the kind of review you were going to do anyway on a file nobody typed by hand.
What is the oldest line of config an agent has handed you that you only caught because it failed somewhere strange? I am collecting these for a follow-up, and the comments are where I will read them.
Verified against Docker Engine 29.1.3 and Docker Compose v5.1.4 on 2026-10-29, with three cold-start runs on fresh volumes; every timestamp and probe result above is from docker inspect or the Postgres log of those runs. Claims about model behavior are cited to the linked papers, and anything I could not reproduce myself is not in this post.
Sources
Every claim above traces to these primary sources. Where I could not verify something hands-on, the post says so.
- Hero artwork: a robotic arm stamping green checkmarks on a conveyor belt of identical gene
- Diagram artwork: dated tutorial books flow into a glowing model core that outputs a compose file card
- Docker docs
- docker-library/postgres issue #146
- arXiv:2506.20495
- arXiv:2511.21022
- compose-file services reference
- docker-library postgres entrypoint
- GitHub
About the author
Kunal Das writes operations engineering notes as he learns them, always against primary sources. Contact: GitHub and dev.to. Follow the Docker tag for the next posts in this series.
1 reaction
Seems like this situation is about to change. Thank you Ruby core team for your hard work!!!
official-images#22616
31 likes · 2,035 views
【応急処置】PHP 7.4 のまま CVE-2026-109618 / 109619(RCE)を塞ぐ Docker イメージを公開しました
はじめに
2026-10-21、PHP プロジェクトが 認証なしでリモートコード実行(RCE)が可能な脆弱性を 2 件 公表しました(php.net のアナウンス)。
- CVE-2026-109618(GHSA-w8hw-w9fx-j79w): メモリ不足(OOM)の処理が、内部のリクエストデータの初期化途中で PHP を中断させ、壊れたデータがクリーンアップ時に正しいデータとして扱われる
- CVE-2026-109619(GHSA-6wmf-4rv5-p3rp): 解放済みのメモリを指すリクエスト用の内部状態が残り、同じワーカーが処理する次のリクエストでそれが使われる
影響を受けるのは、Web リクエストを処理するすべての PHP(PHP-FPM、Apache mod_php、php-cgi など)です。advisory は mod_php のリスクが最も高い としており、アプリケーションのコードが動く前に攻撃が成立する実証コードもあるとしています。
修正版が出たのは PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 だけです。PHP 7.4 は EOL なので修正版は出ません。ただ、深刻度を考慮して、PHP セキュリティチームが 7.4 / 8.0 / 8.1 向けに 最小限のソース backport を公開しています。
一方で、Docker の公式イメージ php:7.4-* が再ビルドされることはありません。FROM php:7.4-apache のまま運用しているシステムは、何もしなければ脆弱なまま です。
そこで、この backport を当てて再ビルドした php:7.4-* 互換のイメージを OSS として公開しました。
GitHub: softjapan/php74-cve-patched
softjapan/php74-cve-patched
:::note alert
このイメージは「応急処置」です。PHP 7.4 を安全なバージョンにするものではありません。
- 直るのは CVE-2026-109618 と CVE-2026-109619 の 2 件だけ です。7.4.33 より後に見つかったほかの問題は直っていません。
- ベース OS の Debian 11 (bullseye) は 2026-08 に LTS が終わっています。OpenSSL や Apache などの OS パッケージは今後更新されません。
- PHP プロジェクトや Docker 社の公式イメージではありません。
恒久的な対策は PHP 8.2 以降への移行 です。このイメージは、移行までの時間を稼ぐために使ってください。
:::
TL;DR
- FROM php:7.4-apache
+ FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache
1. FROM を 1 行差し替える(apache / fpm / cli、amd64 / arm64)
2. イメージを再ビルド・再デプロイし、PHP のワーカーをすべて再起動する
3. check-patched.sh で修正が入ったことを確認する
4. PHP 8.2 以降への移行計画を立てる(ここまでやって対応完了です)
まず、自分の環境が脆弱か確認する
困ったことに、backport を当てても php -v は 7.4.33 のまま です。バージョン番号を見ても、対策済みかどうかは分かりません。
そこで、PHP のバイナリの中身を調べて判定するスクリプトを用意しました。
curl -fsSLO https://raw.githubusercontent.com/softjapan/php74-cve-patched/main/scripts/check-patched.sh
sh check-patched.sh php:7.4-apache # イメージ
sh check-patched.sh --container my-web-1 # 起動中のコンテナ
sh check-patched.sh --local # このマシン(VM や物理サーバー)
公式の php:7.4-apache を調べた結果です。
Image: php:7.4-apache
PHP version: 7.4.33
VULNERABLE /usr/lib/apache2/modules/libphp7.so
VULNERABLE /usr/local/bin/php
RESULT: VULNERABLE to CVE-2026-109618 / CVE-2026-109619
パッチを当てたイメージではこうなります。
Image: ghcr.io/softjapan/php74-cve-patched:7.4-apache
PHP version: 7.4.33
patched /usr/lib/apache2/modules/libphp7.so
patched /usr/local/bin/php
RESULT: PATCHED (backport found in every PHP binary)
- 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 以降は修正済みと判定します。それより古いバージョンは、backport で追加される関数 zend_new_array_singleton がバイナリにあるかどうかで判定します。
- CLI だけでなく、実際にリクエストを処理する libphp7.so(mod_php)や php-fpm も個別に調べます。CLI だけ直っていて Apache が古いまま、という状態も見つけられます。
- 終了コードは 0 が修正済み、1 が脆弱です。CI に組み込んで、脆弱なイメージをデプロイしないようにもできます。
:::note info
この判定は簡易的なものです。ディストリビューションや LTS ベンダーが独自の方法で backport したパッケージは、「脆弱」と判定されることがあります。その場合はベンダーの情報を確認してください。
:::
使い方
同じイメージを GHCR と Docker Hub の両方で公開しています。タグもダイジェストも同じです。
| 公式イメージ | GHCR | Docker Hub |
|---|---|---|
| php:7.4-apache | ghcr.io/softjapan/php74-cve-patched:7.4-apache | softjpn/php74-cve-patched:7.4-apache |
| php:7.4-fpm | ghcr.io/softjapan/php74-cve-patched:7.4-fpm | softjpn/php74-cve-patched:7.4-fpm |
| php:7.4-cli | ghcr.io/softjapan/php74-cve-patched:7.4-cli | softjpn/php74-cve-patched:7.4-cli |
公式イメージと同じ使い方ができることを重視しています。
- docker-php-ext-install / docker-php-ext-configure / pecl / php.ini-production がそのまま使えます
- php -m の拡張モジュール一覧が公式イメージと同じであることを CI で確認しています
- apt-get install も使えます(apt の参照先を archive.debian.org に変えてあります)
Laravel 向けの例です。
FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" gd pdo_mysql zip bcmath opcache \
&& a2enmod rewrite
:::note alert
イメージを差し替えただけでは終わりません。 実行中のコンテナやワーカーは古いバイナリのまま動いています。再ビルド・再デプロイして、PHP のワーカーをすべて再起動してください。最後に check-patched.sh --container <名前> で、動いているコンテナ が修正済みかを確認してください。
:::
何を変えたのか
変更は最小限にしています。docker-library/php に残っている 最後の PHP 7.4 用 Dockerfile(commit 1361ba95) を元に、変えたのは次の 3 点だけです。
1. apt の参照先を archive.debian.org に変更: bullseye は通常のミラーから消えたため、apt-get update が 404 になります
2. PHP 7.4.33 のソースに backport を適用: patch -p1 --fuzz=0 で当てるので、少しでもずれていればビルドが失敗します
3. 修正が入ったことをビルド中に確認: php、php-fpm、libphp7.so のどれか 1 つでも修正が入っていなければ、ビルドを失敗させます
当てているパッチは、上流の php/php-src@712e60f と バイト単位で同一 です。CI が push のたびと毎週、次の点を確認しています。
- 同梱のパッチが上流の commit と同一であること
- 上流の backport ブランチに新しい commit が入っていないこと(入っていれば CI が失敗して知らせます)
当初は社内で php:7.4-apache の上から ./configure をやり直す方法をとっていました。しかしこの方法には、configure のオプションが x86_64 向けに固定される、fpm / cli 版でオプションがずれる、といった問題がありました。公開版では公式の Dockerfile そのものを元にして、マルチアーキテクチャへの対応と公式との互換性を両立しています。
信頼して使うために
セキュリティ目的のイメージを、知らない人が配っているものから取ってくるのは不安だと思います。そのため、検証できる材料をそろえました。
- イメージはリポジトリの GitHub Actions だけでビルドし、SBOM と provenance を付けています
- cosign(keyless)で署名しています
cosign verify ghcr.io/softjapan/php74-cve-patched:7.4-apache \
--certificate-identity-regexp '^https://github.com/softjapan/php74-cve-patched/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
- 本番環境ではダイジェスト(@sha256:...)で固定してください
- このイメージを使わず、自分でビルドする手順 も公開しています。Docker を使わずソースからビルドしている環境向けに、patch -p1 で当てる手順も載せています
イメージをすぐ差し替えられない場合の緩和策
advisory には、すぐに更新できない場合の緩和策も載っています。どれも攻撃の経路を一部ふさぐだけで、安全になるわけではない と明記されていますが、差し替えまでのつなぎや多層防御として使えます。
CVE-2026-109618(OOM による状態破損)
- memory_limit=-1 を、アプリケーションが上書きできない方法(FPM プールや Apache の php_admin_value)で設定する。どこかの設定で有限の値に戻されていないかも確認する
- その代わり、コンテナや cgroup のメモリ制限で、影響を受けたワーカーだけが止まるようにしておく
- RLIMIT_AS / RLIMIT_DATA での代用はしない(脆弱な経路を逆に通る原因になる)
- post_max_size を小さくする。アップロードがないなら 8M、あるなら 32M。upload_max_filesize は post_max_size より小さくする
- Web サーバーやプロキシでも、リクエストサイズ、フィールド数、マルチパートの数、ヘッダー数などを制限する。Content-Length がないリクエストも対象に含める
CVE-2026-109619(古いグローバル状態)
- session.upload_progress.enabled=0 を設定し、ワーカーをすべて再起動する
- php.ini、FPM プール、サーバー設定など、リクエストの解析前に効く場所で設定する。ini_set() や .user.ini では効果がない
- 初期化前に警告を出すような、無効な設定や非推奨の設定をリクエストごとに適用しているなら取り除く(@ や display_errors で表示を隠しても意味はない)
- 攻撃者が起こせる fatal error やタイムアウトを修正する
このイメージには、上記の一部(session.upload_progress.enabled = 0 と、小さめの post_max_size / upload_max_filesize)をまとめた設定ファイルを同梱しています。デフォルトでは無効 です。PHP の挙動は公式イメージと変えていないので、使う場合は次の 1 行を追加してください。
RUN cp /usr/local/share/php74-cve-patched/zz-cve-2026-mitigations.ini "$PHP_INI_DIR/conf.d/"
応急処置のあとにやること
最後にもう一度書いておきます。このイメージは止血であって、治療ではありません。
- [ ] すべての PHP 7.4 環境を洗い出し、check-patched.sh で確認した
- [ ] イメージを差し替え、再デプロイし、ワーカーを再起動した
- [ ] 動いているコンテナ で check-patched.sh --container が PATCHED になった
- [ ] 必要に応じて緩和策の設定も入れた
- [ ] PHP 8.2 以降への移行計画と期限を決めた(8.2 も 2026-12-31 に EOL になるので、8.3 以降も検討する)
- [ ] OS(Debian 11)も更新されないことを、関係者と共有した
まとめ
- CVE-2026-109618 / 109619 は、認証なしの RCE です。特に mod_php は最優先で対応してください
- PHP 7.4 の公式 Docker イメージは今後も直りません。php:7.4-* を使っていれば脆弱なままです
- php74-cve-patched は、FROM を 1 行変えるだけで公式の backport を取り込める 応急処置 です
- 恒久的な対策は PHP 8.2 以降への移行です
PHP 8.0 / 8.1 版(こちらも公式の backport があります)や Alpine 版も検討しています。必要な方は Issue で教えてください。この記事が役に立ったら、Qiita の LGTM や GitHub の ⭐ をもらえるとうれしいです。同じように困っている人に見つけてもらいやすくなります。
softjapan/php74-cve-patched
参考
- PHP: News Archive 2026(2026-10-21 Security advisory)
- GHSA-w8hw-w9fx-j79w(CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp(CVE-2026-109619)
- php-src: PHP-7.4-CVE-2026-109618-109619 ブランチ
【応急処置】PHP 7.4 のまま CVE-2026-109618 / 109619(RCE)を塞ぐ Docker イメージを公開しました
はじめに
2026-10-21、PHP プロジェクトが 認証なしでリモートコード実行(RCE)が可能な脆弱性を 2 件 公表しました(php.net のアナウンス)。
CVE-2026-109618(GHSA-w8hw-w9fx-j79w): メモリ不足(OOM)の処理が、内部のリクエストデータの初期化途中で PHP を中断させ、壊れたデータがクリーンアップ時に正しいデータとして扱われる
CVE-2026-109619(GHSA-6wmf-4rv5-p3rp): 解放済みのメモリを指すリクエスト用の内部状態が残り、同じワーカーが処理する次のリクエストでそれが使われる
影響を受けるのは、Web リクエストを処理するすべての PHP(PHP-FPM、Apache mod_php、php-cgi など)です。advisory は mod_php のリスクが最も高い としており、アプリケーションのコードが動く前に攻撃が成立する実証コードもあるとしています。
修正版が出たのは PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 だけです。PHP 7.4 は EOL なので修正版は出ません。ただ、深刻度を考慮して、PHP セキュリティチームが 7.4 / 8.0 / 8.1 向けに 最小限のソース backport を公開しています。
一方で、Docker の公式イメージ php:7.4-* が再ビルドされることはありません。FROM php:7.4-apache のまま運用しているシステムは、何もしなければ脆弱なまま です。
そこで、この backport を当てて再ビルドした php:7.4-* 互換のイメージを OSS として公開しました。
softjapan/php74-cve-patched
!
このイメージは「応急処置」です。PHP 7.4 を安全なバージョンにするものではありません。
直るのは CVE-2026-109618 と CVE-2026-109619 の 2 件だけ です。7.4.33 より後に見つかったほかの問題は直っていません。
ベース OS の Debian 11 (bullseye) は 2026-08 に LTS が終わっています。OpenSSL や Apache などの OS パッケージは今後更新されません。
PHP プロジェクトや Docker 社の公式イメージではありません。
恒久的な対策は PHP 8.2 以降への移行 です。このイメージは、移行までの時間を稼ぐために使ってください。
TL;DR
- FROM php:7.4-apache
- FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache
FROM を 1 行差し替える(apache / fpm / cli、amd64 / arm64)
イメージを再ビルド・再デプロイし、PHP のワーカーをすべて再起動する
check-patched.sh で修正が入ったことを確認する
PHP 8.2 以降への移行計画を立てる(ここまでやって対応完了です)
まず、自分の環境が脆弱か確認する
困ったことに、backport を当てても php -v は 7.4.33 のまま です。バージョン番号を見ても、対策済みかどうかは分かりません。
そこで、PHP のバイナリの中身を調べて判定するスクリプトを用意しました。
curl -fsSLO raw.githubusercontent.com
sh check-patched.sh php:7.4-apache # イメージ
sh check-patched.sh --container my-web-1 # 起動中のコンテナ
sh check-patched.sh --local # このマシン(VM や物理サーバー)
公式の php:7.4-apache を調べた結果です。
Image: php:7.4-apache
PHP version: 7.4.33
VULNERABLE /usr/lib/apache2/modules/libphp7.so
VULNERABLE /usr/local/bin/php
RESULT: VULNERABLE to CVE-2026-109618 / CVE-2026-109619
パッチを当てたイメージではこうなります。
Image: ghcr.io/softjapan/php74-cve-patched:7.4-apache
PHP version: 7.4.33
patched /usr/lib/apache2/modules/libphp7.so
patched /usr/local/bin/php
RESULT: PATCHED (backport found in every PHP binary)
8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 以降は修正済みと判定します。それより古いバージョンは、backport で追加される関数 zend_new_array_singleton がバイナリにあるかどうかで判定します。
CLI だけでなく、実際にリクエストを処理する libphp7.so(mod_php)や php-fpm も個別に調べます。CLI だけ直っていて Apache が古いまま、という状態も見つけられます。
終了コードは 0 が修正済み、1 が脆弱です。CI に組み込んで、脆弱なイメージをデプロイしないようにもできます。
!
この判定は簡易的なものです。ディストリビューションや LTS ベンダーが独自の方法で backport したパッケージは、「脆弱」と判定されることがあります。その場合はベンダーの情報を確認してください。
使い方
同じイメージを GHCR と Docker Hub の両方で公開しています。タグもダイジェストも同じです。
公式イメージ
GHCR
Docker Hub
php:7.4-apache
ghcr.io/softjapan/php74-cve-patched:7.4-apache
softjpn/php74-cve-patched:7.4-apache
php:7.4-fpm
ghcr.io/softjapan/php74-cve-patched:7.4-fpm
softjpn/php74-cve-patched:7.4-fpm
php:7.4-cli
ghcr.io/softjapan/php74-cve-patched:7.4-cli
softjpn/php74-cve-patched:7.4-cli
公式イメージと同じ使い方ができることを重視しています。
docker-php-ext-install / docker-php-ext-configure / pecl / php.ini-production がそのまま使えます
php -m の拡張モジュール一覧が公式イメージと同じであることを CI で確認しています
apt-get install も使えます(apt の参照先を archive.debian.org に変えてあります)
Laravel 向けの例です。
FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" gd pdo_mysql zip bcmath opcache \
&& a2enmod rewrite
!
イメージを差し替えただけでは終わりません。 実行中のコンテナやワーカーは古いバイナリのまま動いています。再ビルド・再デプロイして、PHP のワーカーをすべて再起動してください。最後に check-patched.sh --container <名前> で、動いているコンテナ が修正済みかを確認してください。
何を変えたのか
変更は最小限にしています。docker-library/php に残っている 最後の PHP 7.4 用 Dockerfile(commit 1361ba95) を元に、変えたのは次の 3 点だけです。
apt の参照先を archive.debian.org に変更: bullseye は通常のミラーから消えたため、apt-get update が 404 になります
PHP 7.4.33 のソースに backport を適用: patch -p1 --fuzz=0 で当てるので、少しでもずれていればビルドが失敗します
修正が入ったことをビルド中に確認: php、php-fpm、libphp7.so のどれか 1 つでも修正が入っていなければ、ビルドを失敗させます
当てているパッチは、上流の php/php-src@712e60f と バイト単位で同一 です。CI が push のたびと毎週、次の点を確認しています。
同梱のパッチが上流の commit と同一であること
上流の backport ブランチに新しい commit が入っていないこと(入っていれば CI が失敗して知らせます)
当初は社内で php:7.4-apache の上から ./configure をやり直す方法をとっていました。しかしこの方法には、configure のオプションが x86_64 向けに固定される、fpm / cli 版でオプションがずれる、といった問題がありました。公開版では公式の Dockerfile そのものを元にして、マルチアーキテクチャへの対応と公式との互換性を両立しています。
信頼して使うために
セキュリティ目的のイメージを、知らない人が配っているものから取ってくるのは不安だと思います。そのため、検証できる材料をそろえました。
イメージはリポジトリの GitHub Actions だけでビルドし、SBOM と provenance を付けています
cosign(keyless)で署名しています
cosign verify ghcr.io/softjapan/php74-cve-patched:7.4-apache \
--certificate-identity-regexp '^softjapan/php74-cve-patched' \
--certificate-oidc-issuer token.actions.githubusercontent.com
本番環境ではダイジェスト(@sha256:...)で固定してください
このイメージを使わず、自分でビルドする手順 も公開しています。Docker を使わずソースからビルドしている環境向けに、patch -p1 で当てる手順も載せています
イメージをすぐ差し替えられない場合の緩和策
advisory には、すぐに更新できない場合の緩和策も載っています。どれも攻撃の経路を一部ふさぐだけで、安全になるわけではない と明記されていますが、差し替えまでのつなぎや多層防御として使えます。
CVE-2026-109618(OOM による状態破損)
memory_limit=-1 を、アプリケーションが上書きできない方法(FPM プールや Apache の php_admin_value)で設定する。どこかの設定で有限の値に戻されていないかも確認する
その代わり、コンテナや cgroup のメモリ制限で、影響を受けたワーカーだけが止まるようにしておく
RLIMIT_AS / RLIMIT_DATA での代用はしない(脆弱な経路を逆に通る原因になる)
post_max_size を小さくする。アップロードがないなら 8M、あるなら 32M。upload_max_filesize は post_max_size より小さくする
Web サーバーやプロキシでも、リクエストサイズ、フィールド数、マルチパートの数、ヘッダー数などを制限する。Content-Length がないリクエストも対象に含める
CVE-2026-109619(古いグローバル状態)
session.upload_progress.enabled=0 を設定し、ワーカーをすべて再起動する
php.ini、FPM プール、サーバー設定など、リクエストの解析前に効く場所で設定する。ini_set() や .user.ini では効果がない
初期化前に警告を出すような、無効な設定や非推奨の設定をリクエストごとに適用しているなら取り除く(@ や display_errors で表示を隠しても意味はない)
攻撃者が起こせる fatal error やタイムアウトを修正する
このイメージには、上記の一部(session.upload_progress.enabled = 0 と、小さめの post_max_size / upload_max_filesize)をまとめた設定ファイルを同梱しています。デフォルトでは無効 です。PHP の挙動は公式イメージと変えていないので、使う場合は次の 1 行を追加してください。
RUN cp /usr/local/share/php74-cve-patched/zz-cve-2026-mitigations.ini "$PHP_INI_DIR/conf.d/"
応急処置のあとにやること
最後にもう一度書いておきます。このイメージは止血であって、治療ではありません。
すべての PHP 7.4 環境を洗い出し、check-patched.sh で確認した
イメージを差し替え、再デプロイし、ワーカーを再起動した
動いているコンテナ で check-patched.sh --container が PATCHED になった
必要に応じて緩和策の設定も入れた
PHP 8.2 以降への移行計画と期限を決めた(8.2 も 2026-12-31 に EOL になるので、8.3 以降も検討する)
OS(Debian 11)も更新されないことを、関係者と共有した
まとめ
CVE-2026-109618 / 109619 は、認証なしの RCE です。特に mod_php は最優先で対応してください
PHP 7.4 の公式 Docker イメージは今後も直りません。php:7.4-* を使っていれば脆弱なままです
php74-cve-patched は、FROM を 1 行変えるだけで公式の backport を取り込める 応急処置 です
恒久的な対策は PHP 8.2 以降への移行です
PHP 8.0 / 8.1 版(こちらも公式の backport があります)や Alpine 版も検討しています。必要な方は Issue で教えてください。役に立ったら GitHub で ⭐ をもらえるとうれしいです。同じように困っている人に見つけてもらいやすくなります。
softjapan/php74-cve-patched
参考
PHP: News Archive 2026(2026-10-21 Security advisory)
GHSA-w8hw-w9fx-j79w(CVE-2026-109618)
GHSA-6wmf-4rv5-p3rp(CVE-2026-109619)
php-src: PHP-7.4-CVE-2026-109618-109619 ブランチ
:obama_phew:
```
# php -v
PHP 8.5.12 (cli) (built: Oct 21 2026 15:49:40) (NTS)
Copyright (c) The PHP Group
Built by docker-library/php
Zend Engine v4.5.12, Copyright (c) Zend Technologies
with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies
```
1 like
Mentions
MongoDB in Docker: Fix Authentication Failed and Init Scripts You set MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD in Compose, start the official mongo image, and your app logs MongoServerError: Authentication failed. The password is right. You can see it in the container's environment. Or you changed the password in .env, recreated the container, and now nothing can log in. Or the user your docker-entrypoint-initdb.d script should create never shows up. This guide reproduces each of those failures with mongo:9.0.4 in Docker Compose and fixes them. Every error and log line below comes from that lab run on November 1, 2026, when mongo:latest pointed to the same 9.0.4 image digest. The questions behind these errors get a lot of traffic. On Stack Overflow, How can I create a database for a MongoDB container on startup? had 325,066 views that day and How to enable authentication on MongoDB through Docker? had 316,972. The image repo issue Initialization of a user defined database, username, and password using environment variables has 29 comments. The short version: - From another container, localhost is that container. Connect to the Compose service name, for example mongodb://mongo:27017. - The root user from MONGO_INITDB_ROOT_USERNAME lives in the admin database. If your connection string names another database, add ?authSource=admin. - The MONGO_INITDB_* variables and the /docker-entrypoint-initdb.d scripts only run when /data/db is empty. Changing them later does nothing. Change the password with changeUserPassword() or run the script by hand. - Percent-encode @ : / ? # [ ] $ in the password part of the connection string. - docker logs on the MongoDB container tells you why a login failed. Look for Failed to authenticate. Fix Authentication failed with MONGO_INITDB_ROOT_USERNAME Start with the map. Find the message you see in the left column: <img src="deploylark.com" alt="Table diagram mapping five MongoDB Docker errors to their cause and fix: ECONNREFUSED on localhost, Authentication failed because of the wrong authSource, Authentication failed after a password change, initdb.d scripts that did not run, and unescaped characters in the password" width="1200" height="680" loading="lazy" decoding="async" /> The lab started with the smallest Compose file that turns on authentication: services: mongo: image: mongo:9.0.4 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD} volumes: - mongo-data:/data/db volumes: mongo-data: MONGO_ROOT_PASSWORD=Lab-Pass-4821 On the first start, the entrypoint ran a temporary mongod bound to 127.0.0.1, created the root user, shut it down and then started the real server as mongod --auth --bind_ip_all. A one-off mongo:9.0.4 container on the same Compose network played the app and ran mongosh 2.14.0. mongosh is built on the Node.js driver (the server log named driver 7.5.0), so Node.js apps get the same error names. Connect to the service name, not localhost client$ mongosh 'mongodb://localhost:27017' --eval 'db.runCommand({ping:1})' MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017 client$ mongosh 'mongodb://mongo:27017' --eval 'db.runCommand({ping:1})' { ok: 1 } Inside the app container, localhost points back at the app itself. Nothing listens on port 27017 there, so the connection is refused. Use the service name from your Compose file. The same rule applies to MySQL and Redis, as the MySQL guide and the Redis guide show. Note that ping worked without a password. MongoDB lets unauthenticated clients run ping, but a real query failed with MongoServerError: Command find requires authentication. That message means you connected without credentials. Authentication failed. means you sent credentials and the server rejected them. Add authSource=admin for the root user When the password is right and the login still fails, check the database in the connection string first. Here it named the app database, and the right password failed: client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/appdb' --eval 'db.items.insertOne({a:1})' MongoServerError: Authentication failed. client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/appdb?authSource=admin' --eval 'db.items.insertOne({a:1}).acknowledged' true A MongoDB user belongs to the database where it was created. The Docker Hub page for the image says MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD are for "creating a simple user with the role root in the admin authentication database". The MongoDB connection string docs describe authSource as the "Database name associated with the user's credentials. Defaults to defaultauthdb if specified, otherwise defaults to admin." So /appdb in the path made the driver look for root in appdb. Without a database in the path, as in mongodb://root:Lab-Pass-4821@mongo:27017/, the login worked because the default was admin. The server log shows the difference between a wrong database and a wrong password. The client gets the same message for both: <img src="deploylark.com" alt="Terminal capture: mongosh to localhost gets ECONNREFUSED, a query without credentials gets Command find requires authentication, root with database appdb gets Authentication failed, adding authSource=admin works, and the server log shows UserNotFound for db appdb and storedKey mismatch for a wrong password" width="1200" height="334" loading="lazy" decoding="async" /> $ docker logs mongolab-mongo-1 2>&1 | grep '"Failed to authenticate"' Failed to authenticate user=root db=appdb error=UserNotFound: Could not find user "root" for db "appdb" Failed to authenticate user=root db=admin error=AuthenticationFailed: SCRAM authentication failed, storedKey mismatch The raw lines are JSON. The lab filtered them with jq to show attr.user, attr.db and attr.error. UserNotFound means fix the authSource or the user name. storedKey mismatch means the user exists and the password is wrong. Fix a password that changed in .env but not in MongoDB Why MONGO_INITDB_ROOT_PASSWORD is ignored on restart The lab changed MONGO_ROOT_PASSWORD in .env from Lab-Pass-4821 to New-Pass-7310 and ran docker compose up -d --force-recreate. The new container had the new value in its environment, but the new password failed: <img src="deploylark.com" alt="Terminal capture: after changing the password in .env the container environment shows New-Pass-7310 but the init process did not run and the new password fails, changeUserPassword with the old password fixes it, and a volume first started without auth runs mongod with auth on but no root user" width="1200" height="418" loading="lazy" decoding="async" /> The init step never ran. The entrypoint checks /data/db for files such as WiredTiger, journal and storage.bson, and skips initialization when any of them exist. The Docker Hub page says it directly: "Do note that none of the variables below will have any effect if you start the container with a data directory that already contains a database: any pre-existing database will always be left untouched on container startup." So the old password stays valid until you change it inside MongoDB. Change the password with mongosh Log in with the old password and change it in place. Then the .env value and the database agree again: client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/?authSource=admin' \ --eval 'db.getSiblingDB("admin").changeUserPassword("root", "New-Pass-7310")' { ok: 1 } client$ mongosh 'mongodb://root:New-Pass-7310@mongo:27017/?authSource=admin' \ --eval 'db.runCommand({connectionStatus:1}).authInfo.authenticatedUsers' [ { user: 'root', db: 'admin' } ] If you lost the old password, or the volume only holds test data, docker compose down -v deletes the volume. In the lab, the next start ran the init step again and the new .env password worked. That also deletes every database in the volume. A related trap: the lab started a volume with no auth variables, wrote data, then added MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD. The entrypoint still added --auth (the process ran as mongod --auth --bind_ip_all), but it did not create the user, because the volume was not empty. Every login failed with UserNotFound: Could not find user "root" for db "admin", and queries without credentials failed with Command aggregate requires authentication. MongoDB's localhost exception "allows you to create the first user or role in the system after enabling access control." It only works from inside the container, so the fix ran there: docker exec mongolate-mongo-1 sh -c 'mongosh --quiet admin --eval "db.createUser({user: process.env.MONGO_INITDB_ROOT_USERNAME, pwd: process.env.MONGO_INITDB_ROOT_PASSWORD, roles: [\"root\"]}).ok"' It printed 1, and the root login worked. The old data was still there. Fix docker-entrypoint-initdb.d scripts that don't run Create the app user in the right database Most apps should not log in as root. The usual pattern is a script in /docker-entrypoint-initdb.d that creates an app user. The lab mounted this file: // Runs once, against MONGO_INITDB_DATABASE, only when /data/db is empty db.createUser({ user: "app", pwd: process.env.APP_DB_PASSWORD, roles: [{ role: "readWrite", db: "appdb" }], }); db.items.insertOne({ seeded: true }); With MONGO_INITDB_DATABASE: appdb and APP_DB_PASSWORD passed into the mongo service, the log showed running /docker-entrypoint-initdb.d/01-create-app-user.js and MongoDB init process complete; ready for start up. The .js file read the password with process.env, so it never sat in the script. Now the authSource rule flips. The script ran in appdb, so the app user lives in appdb, not in admin: client$ mongosh 'mongodb://app:App-Pass-5532@mongo:27017/appdb?authSource=admin' --eval 'db.items.countDocuments()' MongoServerError: Authentication failed. client$ mongosh 'mongodb://app:App-Pass-5532@mongo:27017/appdb' --eval 'db.items.countDocuments()' 1 Copying ?authSource=admin from a root example into an app user's connection string breaks it. Set authSource to the database the script ran in, or leave it out when that database is already in the path. The readWrite role on appdb also limits the user. A write to another database failed with MongoServerError: not authorized on other to execute command. MONGO_INITDB_DATABASE only picks the database the .js scripts run against. The Docker Hub page notes that "MongoDB is fundamentally designed for "create on first use", so if you do not insert data with your JavaScript files, then no database is created." The insertOne line in the script creates appdb. Run a new script on an existing volume The lab then added 02-create-report-user.js and recreated the container on the same volume. The script did not run, and the user did not exist: <img src="deploylark.com" alt="Terminal capture: on a fresh volume the init script runs and the app user works only without authSource=admin, then a second init script added later does not run on the existing volume until it is run by hand with mongosh as root" width="1200" height="418" loading="lazy" decoding="async" /> Init scripts follow the same rule as the root variables. They run once, on the first start of an empty /data/db. You don't need to delete the volume to apply a new one. Run it with mongosh inside the container, logged in as root, against the same database the entrypoint would use: docker exec mongoinit-mongo-1 sh -c 'mongosh --quiet -u root -p "$MONGO_INITDB_ROOT_PASSWORD" \ --authenticationDatabase admin appdb /docker-entrypoint-initdb.d/02-create-report-user.js' After that, the report user logged in to appdb. The entrypoint also runs .sh files from the same folder, and ignores any other extension. On a fresh volume with no scripts, the log shows ignoring /docker-entrypoint-initdb.d/*. Fix MongoParseError for passwords with special characters Percent-encode the password in the connection string The lab set the root password to p@ss:w/rd#1. The Compose value worked, because the entrypoint passes it to createUser and never builds a URL. The connection string was the problem: <img src="deploylark.com" alt="Terminal capture: passwords with @, colon and slash in a MongoDB connection string fail with MongoRuntimeError Unable to parse and MongoParseError Password contains unescaped characters, and the percent-encoded password p%40ss%3Aw%2Frd%231 works from another container and from the host through a published port" width="1200" height="355" loading="lazy" decoding="async" /> client$ mongosh 'mongodb://root:p@ss:w/rd#1@mongo:27017/?authSource=admin' --eval 1 MongoRuntimeError: Unable to parse ss:w with URL client$ mongosh 'mongodb://root:pa:ss@mongo:27017/?authSource=admin' --eval 1 MongoParseError: Password contains unescaped characters client$ mongosh 'mongodb://root:pa@ss@mongo:27017/?authSource=admin' --eval 1 MongoParseError: Invalid connection string The MongoDB docs say that if the username or password includes $ : / ? # [ ] @, "those characters must be converted using percent encoding". Python's urllib.parse.quote("p@ss:w/rd#1", safe="") printed p%40ss%3Aw%2Frd%231, and that version logged in. Put the encoded form only in the connection string. The MONGO_INITDB_ROOT_PASSWORD value stays as typed. If the password contains $, Compose also treats it as a variable, which the MySQL guide covers. Connect from the host through a published port Without a ports entry, nothing on the host can reach MongoDB. The lab published 127.0.0.1:27018:27017, and mongosh with host networking connected to 127.0.0.1:27018 and printed 9.0.4. Port 27017 on the host was refused, because only 27018 was published. Binding to 127.0.0.1 keeps the port off the network, so only the host itself can connect. A MongoDB Compose file that avoids these errors compose.yaml, .env and the secret file This file ran verbatim in the lab: services: mongo: image: mongo:9.0.4 restart: unless-stopped environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD_FILE: /run/secrets/mongo_root_password MONGO_INITDB_DATABASE: appdb APP_DB_PASSWORD: ${APP_DB_PASSWORD} secrets: - mongo_root_password volumes: - mongo-data:/data/db - ./init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] interval: 10s timeout: 5s retries: 5 start_period: 20s app: image: mongo:9.0.4 depends_on: mongo: condition: service_healthy environment: MONGODB_URI: mongodb://app:${APP_DB_PASSWORD}@mongo:27017/appdb?authSource=appdb command: ["sh", "-c", "mongosh --quiet \"$$MONGODB_URI\" --eval 'db.items.countDocuments()'"] secrets: mongo_root_password: file: ./secrets/mongo_root_password volumes: mongo-data: APP_DB_PASSWORD=App-Pass-5532 ./secrets/mongo_root_password holds the root password, and ./init/01-create-app-user.js is the script from above. The _FILE suffix is documented on Docker Hub for both root variables. With it, docker inspect on the container did not show the root password. Replace app with your real service and give it the MONGODB_URI. Keep APP_DB_PASSWORD URL-safe or percent-encode it in the URI. The healthcheck only proves that mongod answers, since ping needs no login. It doesn't test the app's password. That is enough for depends_on. A wrong app password still shows up in the app's own log as Authentication failed. The Umami guide uses the same service_healthy pattern with PostgreSQL. Check it ``<code>text $ docker compose up -d Container mongofinal-mongo-1 Started Container mongofinal-mongo-1 Healthy Container mongofinal-app-1 Started $ docker compose ps -a --format '{{.Service}} {{.Status}}' app Exited (0) 7 seconds ago mongo Up 18 seconds (healthy) $ docker compose logs --no-log-prefix app 1 $ docker compose restart mongo $ docker compose run --rm app 1 </code>`` The log showed running /docker-entrypoint-initdb.d/01-create-app-user.js once, on the first start. Root logged in with the password from the secret file and ?authSource=admin. After the restart, the seeded document was still there. Keep the image pinned to an exact version such as mongo:9.0.4, so a restart never pulls a new major version onto your data volume. The PostgreSQL 17 to 18 guide shows what a planned major upgrade of a database volume looks like. FAQ Login errors Why does MongoDB in Docker say Authentication failed when the password is right? Check which database the user lives in. The root user from MONGO_INITDB_ROOT_USERNAME is in admin, so a URI like mongodb://root:pass@mongo:27017/appdb needs ?authSource=admin. Run docker logs on the MongoDB container. UserNotFound points to the database, storedKey mismatch to the password. What is the default username and password for the MongoDB Docker image? There is none. Without MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD, the lab's server ran without --auth, logged "Access control is not enabled for the database" and accepted writes without a login. If you set only one of the two, the container exits with error: missing 'MONGO_INITDB_ROOT_USERNAME' or 'MONGO_INITDB_ROOT_PASSWORD'. How do I connect to MongoDB in Docker from the host? Publish the port, for example ports: ["127.0.0.1:27017:27017"], and connect to 127.0.0.1 with ?authSource=admin for the root user. Without a published port, the host can't reach the container. Setup and init scripts Why are my docker-entrypoint-initdb.d scripts not running? The volume already holds a database. The entrypoint runs the scripts only when /data/db has no data files. Run the script with mongosh inside the container, or delete the volume if the data doesn't matter. Only .js and .sh files run. How do I create a database and user for a MongoDB container on startup? Set MONGO_INITDB_DATABASE and mount a .js file into /docker-entrypoint-initdb.d that calls db.createUser() and inserts one document. MongoDB creates a database on the first write. The app then connects to that database without authSource=admin. How do I change the MongoDB root password in Docker? Changing MONGO_INITDB_ROOT_PASSWORD has no effect on an existing volume. Log in with the old password and run db.getSiblingDB("admin").changeUserPassword("root", "new-password"), then update .env to match.
PostgreSQL in Docker: Fix Password Authentication Failed Your Postgres container runs, but every login fails with password authentication failed for user "postgres". Or psql inside the container says role "root" does not exist. Or the container exits right away with Database is uninitialized and superuser password is not specified. The password in your .env file looks right, and that is the trap. This guide reproduces each of these errors with the official postgres:18.6 image in Docker Compose and fixes them. Every error message below comes from that lab run on November 3, 2026, with a small Node.js client (pg 8.23.1) standing in for your app. Most of them have one root cause: the image reads its POSTGRES_* variables only once, the first time it creates the database. These questions get a lot of traffic. On Stack Overflow, Connecting to Postgresql in a docker container from outside had 986,699 views on November 3, 2026, and postgres with docker compose gives FATAL: role "root" does not exist error had 188,086. The short version: - Changing POSTGRES_PASSWORD after the first start does nothing. The log says Skipping initialization. Change the password with ALTER ROLE ... PASSWORD through docker compose exec, or delete the volume if the data is disposable. - role "root" does not exist means psql or pg_isready ran without -U. Pass -U with the user from POSTGRES_USER. If you set POSTGRES_USER, there is no postgres role at all. - superuser password is not specified means the password reached the container empty. Check for an unset .env variable or a $ in the password, and use ${DB_PASSWORD:?} so Compose stops before it starts anything. - From another container, connect to the service name, not localhost, and wait for a healthcheck with condition: service_healthy. URL-encode special characters in connection strings. Fix password authentication failed after changing POSTGRES_PASSWORD You started the stack once, then changed the password in .env or compose.yaml, recreated the container, and now nothing can log in with the new password. Why the new password never applied The official image runs initdb only when the data directory is empty. The image documentation says it plainly: "the Docker specific variables will only have an effect if you start the container with a data directory that is empty." An existing database is left as it is. <img src="deploylark.com" alt="Diagram: when the postgres container starts, the entrypoint checks if the data directory is empty. If yes, initdb creates POSTGRES_USER with its password, creates POSTGRES_DB and runs the initdb.d scripts. If no, it prints Skipping initialization and ignores the POSTGRES variables and scripts" width="1200" height="620" loading="lazy" decoding="async" /> In the lab, the volume was first created with DB_PASSWORD=first-secret. After changing .env to second-secret and running docker compose up -d --force-recreate, the log showed: PostgreSQL Database directory appears to contain a database; Skipping initialization The container's environment had the new value, POSTGRES_PASSWORD=second-secret. The database still had the old one. A login with the new password failed with password authentication failed for user "postgres" (SQLSTATE 28P01), and the server log named the pg_hba.conf rule the connection matched: FATAL: password authentication failed for user "postgres" DETAIL: Connection matched file "/var/lib/postgresql/18/docker/pg_hba.conf" line 128: "host all all all scram-sha-256" The same rule explains why init scripts seem to be ignored. A file mounted into /docker-entrypoint-initdb.d after the first start never ran, and select count(*) from notes returned relation "notes" does not exist. Change the password inside the database The image's pg_hba.conf trusts connections over the local Unix socket, so docker compose exec gets you in without the password. Set the new password with SQL: docker compose exec db psql -U postgres -c "ALTER ROLE postgres WITH PASSWORD 'second-secret'" It printed ALTER ROLE. The app logged in with second-secret right away, without a restart, and first-secret stopped working. Keep .env and the database in sync from now on, because the app reads the new value from .env while the database only changes through SQL. <img src="deploylark.com" alt="Terminal capture: after changing DB_PASSWORD in .env, the postgres log says Skipping initialization, the container environment shows the new password, the login fails with password authentication failed for user postgres and a pg_hba.conf scram-sha-256 detail line, and ALTER ROLE fixes it" width="1200" height="439" loading="lazy" decoding="async" /> If the data is disposable, delete the volume instead. docker compose down -v removes the project's volumes, and with them all data. On the next up, the log showed running /docker-entrypoint-initdb.d/01-notes.sql, the notes table existed, and the new password worked. The MySQL and MongoDB guides cover the same first-start rule for those images. Fix role "root" does not exist and role "postgres" does not exist The second family of errors comes from the user name, not the password. psql and pg_isready inside the container docker compose exec db psql runs as root inside the container. Without -U, psql uses that operating system user as the database user: psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: role "root" does not exist The top answer on the Stack Overflow question, by richyen, puts it this way: "If you don't specify the PGUSER environment variable, then psql will assume you want to use the current OS user as your database user name." Pass the user, as in psql -U postgres. The same thing happens in healthchecks. With test: ["CMD", "pg_isready"], the container still showed (healthy), because pg_isready reported the server as up even though the role was rejected. But the server log got a new FATAL: role "root" does not exist line every 5 seconds, one per check. With ["CMD", "pg_isready", "-U", "app", "-d", "app"], the log had 0 FATAL lines over the next 20 seconds. POSTGRES_USER replaces the postgres role When you set POSTGRES_USER, the image creates that user as the superuser and does not create postgres. With POSTGRES_USER: app and no POSTGRES_DB, the lab cluster had one role, app (Superuser, Create role, Create DB, Replication, Bypass RLS), and a database also named app, matching the docs: the variable creates "the specified user with superuser power and a database with the same name." So these failed: $ docker compose exec db psql -U postgres ... FATAL: role "postgres" does not exist $ docker compose exec db psql -U app -d appdb ... FATAL: database "appdb" does not exist Over TCP, the error is less helpful. Logging in as postgres from the app with the app password returned password authentication failed for user "postgres", which sends you after the wrong problem. Only the server log told the truth: DETAIL: Role "postgres" does not exist. Check the server log before you reset any password. <img src="deploylark.com" alt="Terminal capture: with POSTGRES_USER set to app, psql without -U fails with role root does not exist, psql -U postgres fails with role postgres does not exist, a TCP login as postgres reports password authentication failed with a server detail that the role does not exist, and a pg_isready healthcheck without -U logs FATAL lines until -U app -d app is added" width="1200" height="460" loading="lazy" decoding="async" /> Use the user and database you set, in psql, in the healthcheck and in the app's connection string. If an app insists on a postgres role or another database, create them with SQL: CREATE ROLE postgres WITH LOGIN SUPERUSER PASSWORD '...' and createdb -U app appdb both worked in the lab, and the app then logged in to appdb as postgres. Fix Database is uninitialized and superuser password is not specified This one stops the container on its first start. It exited with code 1 and printed: Error: Database is uninitialized and superuser password is not specified. You must specify POSTGRES_PASSWORD to a non-empty value for the superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run". The message goes on to offer POSTGRES_HOST_AUTH_METHOD=trust, which lets anyone connect without a password. Don't use that to get past this error. If you did set a password, check how it gets into the container. Unset .env variables and dollar signs In the lab, the compose file had POSTGRES_PASSWORD: ${DB_PASSWORD} and there was no .env file next to it. Compose printed a warning and started the container anyway, with an empty password: level=warning msg="The \"DB_PASSWORD\" variable is not set. Defaulting to a blank string." A $ inside a password does something similar. Compose treats it as the start of a variable. With POSTGRES_PASSWORD: pa$word42 written straight into compose.yaml, Compose warned that word42 was not set, and the container got POSTGRES_PASSWORD=pa. The cluster was created with the password pa, so pa$word42 failed with password authentication failed and pa worked. The Compose interpolation docs give the fix: "You can use a $$ (double-dollar sign) when your configuration needs a literal dollar sign." With pa$$word42, the container got pa$word42. <img src="deploylark.com" alt="Terminal capture: with DB_PASSWORD unset Compose warns and the postgres container exits with Database is uninitialized and superuser password is not specified, a dollar sign in the password is cut to pa until it is written as two dollar signs, and the colon question mark syntax makes Compose stop with an error before starting anything" width="1200" height="481" loading="lazy" decoding="async" /> Make Compose fail before it starts anything Replace ${DB_PASSWORD} with ${DB_PASSWORD:?set DB_PASSWORD in .env}. With no .env file, docker compose up -d stopped right away and started nothing: error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable DB_PASSWORD is missing a value: set DB_PASSWORD in .env This matters more than it looks. An empty password stops Postgres on the first start, but the pa case above did not stop anything. It created a database with the wrong password, and the variable rule from the first section then kept that password for good. Fix connection refused and pg_hba errors from other containers If the password and user are right and the app still can't connect, look at the address, the timing and the connection string. localhost, published ports and startup order Inside a container, localhost is that container. An app with @localhost:5432 in its URL got connect ECONNREFUSED ::1:5432, connect ECONNREFUSED 127.0.0.1:5432. Use the Compose service name, such as db, as the host. node-postgres defaults to PGHOST=localhost when you set no host, so always set it. From the host machine, the container is only reachable if you publish the port. Without ports, psql -h 127.0.0.1 -p 5432 on the host got Connection refused. With ports: - "127.0.0.1:5433:5432", the host connected on port 5433 and read the data. Binding to 127.0.0.1 keeps the port off the host's other network interfaces. Timing is the other trap. With depends_on: - db and no condition, the app started as soon as the database container did. On a fresh volume with an init script, it failed with connect ECONNREFUSED 172.28.0.2:5432 and exited. During the first start, the entrypoint runs a temporary server with listen_addresses='', so nothing listens on TCP until the init scripts are done. Starting the app again a few seconds later worked. The fix is a healthcheck plus condition: service_healthy, shown in the Compose file below. The Grafana and Prometheus guide and the Redis guide run into the same localhost mistake. Passwords in URLs and pg_hba.conf entries Connection strings are URLs, so @, #, / and : in a password break them. With the password p@ss#word/1 pasted into postgres://app:p@ss#word/1@db:5432/app, the parser read the password as p, the host as ss and no database at all. Encode the password first. node -p 'encodeURIComponent("p@ss#word/1")' printed p%40ss%23word%2F1, and the URL with that value logged in. Or skip the URL and pass PGHOST, PGUSER, PGPASSWORD and PGDATABASE as separate variables, as the Compose file below does. You only see no pg_hba.conf entry if you replaced the image's default pg_hba.conf, which ends with host all all all scram-sha-256 and allows every address with a password. A custom file that only allowed 127.0.0.1/32 over TCP rejected the app container: no pg_hba.conf entry for host "172.28.0.4", user "postgres", database "postgres", no encryption Add a line for the Docker network. The pg_hba.conf docs describe samenet as "any address in any subnet that the server is directly connected to", which covers the Compose network. After adding host all all samenet scram-sha-256 and running SELECT pg_reload_conf(), the same login worked without a restart. If you carried a custom file over from an upgrade, the PostgreSQL 17 to 18 upgrade guide covers the other places it can break. <img src="deploylark.com" alt="Terminal capture: the app gets ECONNREFUSED while the database runs its init script, localhost inside the app container is refused, an unencoded password is parsed as host ss, the encodeURIComponent value logs in, and a custom pg_hba.conf returns no pg_hba.conf entry for host until a samenet line is added" width="1200" height="439" loading="lazy" decoding="async" /> A PostgreSQL Compose file that avoids these errors This file puts the fixes together. It ran verbatim in the lab. The app service is the small Node.js test client from the lab. Replace its image, command and volume with your own app and keep the environment and depends_on parts. The compose file Create .env next to it with a random password. Hex characters need no escaping in Compose or in URLs: printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env services: db: image: postgres:18.6 restart: unless-stopped environment: POSTGRES_USER: app POSTGRES_DB: app POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} volumes: - pgdata:/var/lib/postgresql - ./initdb:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD", "pg_isready", "-U", "app", "-d", "app"] interval: 5s timeout: 3s retries: 10 start_period: 30s app: image: node:24.22.2-alpine working_dir: /app command: node pgtest.mjs environment: PGHOST: db PGUSER: app PGDATABASE: app PGPASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} volumes: - ./app:/app:ro depends_on: db: condition: service_healthy volumes: pgdata: The volume is mounted at /var/lib/postgresql, the path that the 18 images expect. Both services read the same DB_PASSWORD, so there is one place to change it. If you change it later, also run the ALTER ROLE command from the first section, or the app gets 28P01 again. Check that it works Run docker compose up -d. Compose waited for the database before it started the app: Container pgfinal-db-1 Waiting Container pgfinal-db-1 Healthy Container pgfinal-app-1 Starting Container pgfinal-app-1 Started The app printed OK app app PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2), and the log showed running /docker-entrypoint-initdb.d/01-schema.sql. After docker compose restart db and docker compose run --rm app, the app connected again, and the database log had 0 FATAL lines for the whole run. For a full app stack on this pattern, see the Umami with PostgreSQL guide. FAQ Passwords and users Why does Postgres in Docker say password authentication failed when the password is right? Either the database still has the password from its first start, or the user doesn't exist. Look at the server log with docker compose logs db. Skipping initialization at startup means your new POSTGRES_PASSWORD was ignored. DETAIL: Role "..." does not exist. means the user name is wrong. How do I use a new password from .env without removing the volume? Run docker compose exec db psql -U <user> -c "ALTER ROLE <user> WITH PASSWORD '<new>'". It works over the local socket without the old password and takes effect right away. Then keep the same value in .env for the app. Why do I get role "root" does not exist? Something ran psql or pg_isready inside the container without -U, so it used the operating system user root. Add -U with your POSTGRES_USER (and -d with your database) to the command or the healthcheck. Setup and connections How do I connect to Postgres in Docker from the host? Publish the port, for example ports: - "127.0.0.1:5433:5432", then connect to 127.0.0.1 on port 5433 with the user and password from your Compose file. Other containers on the same network use the service name and port 5432 and need no published port. Why didn't my docker-entrypoint-initdb.d script run? Init scripts run only when the data directory is empty, the same as the POSTGRES_* variables. If the volume already has a database, the scripts are skipped. Run the SQL by hand with docker compose exec, or delete the volume if the data is disposable. How do I fix Database is uninitialized and superuser password is not specified? Give POSTGRES_PASSWORD a non-empty value. Check that your .env file sits next to compose.yaml and defines the variable, and run docker compose config to see the value Compose actually passes. Use ${DB_PASSWORD:?} so a missing value stops Compose with an error.
postgres:18 が起動直後に終了する原因:データディレクトリの場所が変わった(16 → 18 移行手順付き) ネットにある PostgreSQL + Docker の記事の Compose ファイルをそのまま使い、タグだけ 18 にすると、コンテナが数秒で止まります。 $ docker run -d --name pgold -e POSTGRES_PASSWORD=secret -v pgold:/var/lib/postgresql/data postgres:18 b480eb9c72b29fa99f1ed8df17972e2bfc2d6da4b86c058a36cf21fd63579bfa $ docker ps -a --filter name=pgold --format "{{.Status}}" Exited (1) 5 seconds ago パスワードや Volume の設定ミスではありません。公式イメージがデータを置く場所を変えたためです。以下は postgres:18(PostgreSQL 18.6)、Docker Engine 29.9 での実行結果です。 ログに書かれていること $ docker logs pgold 2>&1 | head -n 12 Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster" (specifically, using major-version-specific directory names). This better reflects how PostgreSQL itself works, and how upgrades are to be performed. See also https://github.com/docker-library/postgres/pull/1259 Counter to that, there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume) This is usually the result of upgrading the Docker image without upgrading the underlying database using "pg_upgrade" (which requires both 17 までのイメージは PGDATA=/var/lib/postgresql/data だったので、みんなそこに Volume をマウントしていました。18 からはメジャーバージョンごとのディレクトリがデフォルトです。 | イメージ | デフォルトのデータディレクトリ | Volume のマウント先 | | --- | --- | --- | | postgres:17 以前 | /var/lib/postgresql/data | /var/lib/postgresql/data | | postgres:18 | /var/lib/postgresql/18/docker | /var/lib/postgresql | 古いマウント先のままだと、実際のデータディレクトリは Volume の外、つまりコンテナの書き込みレイヤーに作られ、コンテナを作り直した時点で消えてしまいます。エントリポイントは使われていないマウントを検出して起動を拒否します。面倒ですが、気づかないうちにデータを失うのを防いでくれる仕組みです。 対処法 親ディレクトリにマウントします。 $ docker run -d --name pg -e POSTGRES_PASSWORD=secret -p 127.0.0.1:5432:5432 -v pgdata:/var/lib/postgresql postgres:18 $ docker exec pg psql -U postgres -c "SHOW data_directory;" data_directory ------------------------------- /var/lib/postgresql/18/docker (1 row) Compose の場合: services: db: image: postgres:18 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql volumes: pgdata: ディレクトリ名にメジャーバージョンが入るので、将来のバージョンのデータを同じ Volume に並べて置けます。これがこの変更の狙いです。 既存の 16 / 17 のデータベースを移行する 既存のデータベースでタグだけ変えても、別の理由で動きません。データファイルはメジャーバージョン間で互換性がないからです。16 のデータディレクトリを 18 で開くと(PGDATA で古いパスを指定): $ docker run --name pg18 -e POSTGRES_PASSWORD=secret -e PGDATA=/var/lib/postgresql/data -v pg16data:/var/lib/postgresql/data postgres:18 2>&1 | tail -n 3 2026-10-13 00:54:09.740 UTC [1] FATAL: database files are incompatible with server 2026-10-13 00:54:09.740 UTC [1] DETAIL: The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 18.6 (Debian 18.6-1.pgdg13+2). いちばん簡単で安全なのは、ダンプして新しい Volume にリストアする方法です。古い Volume は結果を確認するまで残しておきます。 $ docker exec pg16 pg_dumpall -U postgres > upgrade.sql $ docker run -d --name pg18 -e POSTGRES_PASSWORD=secret -v pg18data:/var/lib/postgresql postgres:18 $ docker exec -i pg18 psql -U postgres -q < upgrade.sql ERROR: role "postgres" already exists このエラーは想定どおりです。新しいコンテナがすでに postgres ロールを作っていて、ダンプがもう一度作ろうとしているだけで、それ以外はリストアされます。バージョンとデータを確認してから、アプリの接続先を新しいコンテナに切り替えます。 Windows では注意点が 2 つあります。PowerShell は < による入力をサポートしておらず、Windows PowerShell 5.1 の > は UTF-16 で書き出すのでダンプが壊れます。その場合は pg_dumpall -f でコンテナ内にファイルを作り、docker cp で移動します。 postgres:18 チェックリスト - Volume は /var/lib/postgresql にマウントする(/var/lib/postgresql/data ではない) - PGDATA を自分で指定する場合は、マウントした Volume の中にする - マイナーアップデート(18.5 → 18.6)は pull して作り直すだけ。メジャーアップグレードはダンプ&リストア(または pg_upgrade)で、新しい Volume へ - POSTGRES_PASSWORD などの変数はデータディレクトリが空のときだけ有効。後から変えても既存のパスワードは変わらない Compose 構成、早すぎる healthy を返さないヘルスチェック、バックアップ、16 → 18 移行の全出力は、日本語の無料 Docker チュートリアルにまとめています:Docker で PostgreSQL を動かす
PHP2Shell で、対応をオープンにしている会社 はじめに 2026 年 10 月 21 日に修正版が出た PHP の深刻な脆弱性 PHP2Shell では、修正版が出る前からサービスを止める会社も出るなど、緊急の対応が相次ぎました。 前回は、レンタルサーバー各社で、脆弱性のある状態で利用者のサイトに外部からアクセスできた時間を並べ、自前でサービスを動かしている所も 5 つ図に入れました。 前回の終わりに書いたとおり、自社でサービスとインフラの両方を持っていれば、止めるか、配布元を待たずに自分でビルドするか、いつ再開するかを、すべて自社で決められます。 今回は、自前で動かしている会社のうち、対応の経緯をオープンにしていた 3 社、GREE・pixiv・コドモンを紹介します。 自分たちで判断して手を動かし、その時刻や経緯を記録しているから、ここまでオープンにできるのだと考えています。 3 社がオープンにしていること 3 社とも、公式のお知らせやブログに次の 3 つを書いています。 - サービスを止めた時刻 - サービスを戻した時刻(一部だけ戻したのか、全部戻したのかも分けて) - 止めた理由が PHP の脆弱性であること 攻撃を避けるためにソフトウェアの名前を伏せる所や、始めた時刻だけをお知らせする所も多い中で、3 社はここまで公開していました。 3 社の止めた時間 gantt title 3 社がサービスを止めていた時間(日本時間) dateFormat YYYY-MM-DD HH:mm axisFormat %m/%d %H時 section 予告と公開 予告(oss-security) :milestone, 2026-10-21 05:22, 0m 修正版の公開 :milestone, 2026-10-21 22:34, 0m section GREE サービス全体を停止 :active, 2026-10-21 23:30, 2026-10-22 06:55 SNS と決済は復旧、一部のゲームは復旧中 : 2026-10-22 06:55, 2026-10-22 15:00 section pixiv メンテナンスモード :active, 2026-10-21 19:00, 2026-10-21 23:16 section コドモン 主要サービスを停止 :active, 2026-10-21 14:49, 2026-10-22 03:22 一部の機能は停止を続ける : 2026-10-22 03:22, 2026-10-23 09:00 PHP 公式は日本時間の 10/21 早朝、修正版を 23:00 に出すと予告しました(oss-security への投稿は 5:22)。実際に出たのは 30 分ほど早い 22:34 です(GitHub の security advisory の公開時刻)。 GREE GREE はサービス全体を止めました。 - 10/21 23:30 ごろ: サービス全体を止めて緊急メンテナンスを開始 - 10/22 6:55 ごろ: メンテナンスを終了し、SNS や決済などを復旧(一部のゲームは復旧作業を続ける) - 10/22 15:30: 全体復旧の見込み(15 時ごろ)のあと、タイトルに「【復旧】」と付けてお知らせを更新 修正版が出てから 1 時間ほどでサービス全体を止め、7 時間半ほどで SNS や決済などのプラットフォームの機能から先に戻しています。 お知らせでは、PHP の脆弱性が公表されたことを受けてシステムの安全性を確保するための対応だと、理由を説明しています。使っているソフトウェアの名前を伏せずに示しています。 復旧作業が続いていた一部のゲームについては、各ゲームのお知らせを見るよう案内しています。全体の状況はこのお知らせ、個別のゲームは各ゲームのお知らせ、と案内先を分けています。 全体が復旧する見込みは 10/22 15 時ごろと示し、15:30 にはお知らせのタイトルに「【復旧】」と付けて更新しました。被害やデータの流出は確認されていないことも明記し、問い合わせフォームも案内しています。停止から一部の復旧、全体の復旧まで、段階ごとに時刻を追える書き方になっています。 pixiv pixiv は修正版が出る前にサービスを止めて待機し、予定(23:00)より早く 22:34 に出た修正版を 22:41 に確認して、そこから 35 分で再開しました。 利用者向けのお知らせとは別に、公式ブログ(pixiv inside)に作業の時刻が分単位で残っています。 - 10/21 19:00: 影響を受けうるサービスをメンテナンスモードにする - 22:41: 予定(23:00)より早く出た修正版(22:34 公開)を確認 - 22:42〜22:56: コンテナイメージのビルド - 22:56〜23:16: Kubernetes へのデプロイ(ステージングでの確認を含む) - 23:16: メンテナンスモードを解除 35 分で戻せたのは、修正版が出る前に準備を済ませていたからです。 Docker 公式イメージのビルドスクリプトを使って、PHP のバージョンだけを差し替えられるようにしていました。公式イメージの公開を待たずに、ソースから自前でビルドする経路も用意していました。 Docker 公式イメージの更新がマージされたのは 10/22 0:16 で、pixiv が再開した 23:16 の 1 時間後です。配布元を待たずに自前でビルドしたことで、この 1 時間の待ち時間を減らせています。 またブログでは、メンテナンスモードが入っていないエンドポイントがあったことも課題として挙げています。今回は条件がそろっていただけで、毎回この手順で済むとは限らない、とも振り返っています。 コドモン コドモンは、保育施設と保護者が使うサービスです。 修正版が出る 8 時間ほど前の 14:49 までに、対象となる全サービスを止めました。 - 10/21 9:01: 社内で把握 - 11:37: 23:00 の公開に合わせて、夜に止める方針で合意 - 12:37: 情報が広まってきたのを受けて、前倒しで止めると決定 - 13:00 ごろ〜14:49: 順に止め、全対象サービスの停止を完了 - 22:38: 修正版の公開を検知 - 10/22 3:22: 職員用の管理画面、保護者向けの連絡帳アプリなどを再開 - 10/23 9:00: 最後まで止めていた所在確認アラートを再開 止めると決めた理由も書いています。お預かりしているお子さまの情報を守ることを最優先にした、という判断です。 一部のサービスだけ止める案、お迎えの時間まで動かす案、監視を強めて検知したら止める案も検討し、採らなかった理由を一つずつ書いています。 反省点も並べています。修正版が届く時刻が PHP の配布元ごとに違うことを事前に把握していなかったこと、作業と判断が深夜に集中したこと、予防のための停止だと利用者に伝わりにくかったこと、などです。 何を重視するかで、止め方は変わる 3 社とも自社で決められる立場でしたが、止めた時刻と戻し方はそれぞれ違っていました。コドモンは修正版が出る 8 時間ほど前の 14:49 までに、pixiv は出る前の 19:00 に、GREE は出てから 1 時間ほどあとの 23:30 ごろに止めています。 自分のサービスで同じ判断をするなら、何を重視するかで、次のように選び方が変わります。 | 何を重視するか | 止め方 | 近い対応をした会社 | |---|---|---| | 利用者の情報を守ることを最優先 | 修正版が出る前に、日中でも前倒しで止める | コドモン | | 止める時間を短くしたい | 準備を済ませてから止めて待ち、出たらすぐ当てて戻す | pixiv | | 公開まではふだんどおり動かし、戻すときは確実に | 出てからサービス全体を止め、段階的に戻す | GREE | - コドモン は、お迎えの時間まで動かす案も比べたうえで、お子さまの情報を守ることを最優先にして日中に止めました。主要なサービスを 10/22 3:22 に戻し、残りの機能も翌日に再開しています - pixiv は、修正版をすぐ当てられるように準備してから 19:00 に止めました。修正版が出たあと自前でビルドして当て、23:16 に再開しています - GREE は、修正版が出たあとの 23:30 ごろにサービス全体を止めました。翌朝 6:55 ごろに SNS や決済などのプラットフォームを先に戻し、各ゲームの復旧はそれぞれのお知らせで案内しています 修正版が出る前から止めて安全を優先するやり方も、準備を整えて止める時間を短くするやり方も、公開後に全体を止めて段階的に戻すやり方もあります。唯一の正解はなく、サービスの性質と何を守りたいかで、選び方は変わります。 自分たちで決めて記録しているから、オープンにできる 3 社に共通するのは、サービスもインフラも自社で持っていることです。止めるかどうか、どこまで止めるか、いつ戻すかを、配布元やほかの会社の判断を待たずに自分たちで決められます。 自分たちで決めて自分たちで作業しているので、いつ何をしたか、なぜそうしたかを、自社の記録だけで正確に書けます。 - GREE は、全体を止めるタイミングも、SNS や決済から先に戻す順番も自社で決めています。そのため、停止から一部の復旧、全体の復旧まで、段階ごとの時刻をそのままお知らせに出せています - pixiv は、Docker 公式イメージの更新を待たずに、コンテナイメージのビルドから Kubernetes へのデプロイまでを自分たちで動かしています。作業を自社で完結させているから、分単位の記録をブログに残せています - コドモン は、社内で把握した 9:01 から方針を変えた 12:37 まで、判断した時刻と理由を記録していました。自分たちで比較して決めているから、採らなかった案とその理由まで公開できています 判断から作業までが自社の中で完結していて、その記録も残っているから、ここまで具体的な時刻や経緯をオープンにできます。 前回のレンタルサーバーは、この点で条件が違います。インフラは業者のものでも、その上で動くサイトは利用者のものです。業者が利用者のサイトを止めれば、利用者の業務も止まるため、業者だけの判断では止めにくい事情があります。また、自前でビルドするのではなく配布元のパッケージを待つ構成なら、対応を始める時刻も配布元しだいになります。利用者との関係や作業工程の制約があるので、すべてを自社だけで決めて動ける 3 社と同じようにはいかない所があるのは仕方ありません。 おわりに 今回紹介した 3 社は、対応を自社で決めるだけでなく、サービスを止めた時刻と再開した時刻、そして PHP の脆弱性への対応であることを明記していました。さらに pixiv はビルドからデプロイまでの分単位の記録を、コドモンは方針変更の時刻や採らなかった案の理由まで公開しています。 自分たちで判断して対応し、その記録を残しているから、いつ止めていつ戻したかを正確に出せるだけでなく、判断や作業の経緯までオープンにできる。そこがこの 3 社の良さです。 本記事の情報は 10/29 時点のものです。 参考文献 - GREE「【復旧】緊急メンテナンスについて」 games.gree.net - pixiv お知らせ pixiv.net - pixiv inside「PHP2Shell」対応の記録 inside.pixiv.blog - コドモン Product Team Blog「PHP2Shellへの対応を振り返って」 tech.codmon.com - oss-security(予告) openwall.com - Docker official-images #22541 official-images#22541 - GitHub の security advisory(修正版の公開の時刻 13:34 UTC)github.com/php/php-src/security/advisories/GHSA-w… ・ github.com/php/php-src/security/advisories/GHSA-6…
PHP2Shell で、サイトが無防備だった時間 はじめに PHP2Shell について、Qiita にもいろいろな記事が出ています。 みなさんの記事を読んでいてふと思いました。 利用者のサイトが脆弱性のある状態で外部からアクセスできるままになっていた時間を、各社のお知らせから測ってみようか、と。 ちなみに、私が昔いた現場はパッチの適用が数日遅れていました。さもありなん、です。 PHP の脆弱性の話になると、PHP を使う開発者だけでなく、レンタルサーバーの業者までいい加減なのでは、と言われがちです。私も最初はそう思っていました。 脆弱性のある状態で利用者のサイトを外部にさらしていたこと自体は、インフラを預かる業者の責任です。ただ、それが「いい加減だったから」なのか、作業の工程上しかたない部分があったのかを、公式のお知らせの時刻で確かめます。 予告から公開まで PHP 公式は日本時間の 10/21 早朝、深刻度 9.2 の脆弱性 2 件の修正版を 23:00 に出すと予告しました(oss-security への投稿は 5:22)。実際に出たのは 22:34 です(GitHub の security advisory の公開時刻)。 gantt title 予告から修正版の公開まで(日本時間) dateFormat YYYY-MM-DD HH:mm axisFormat %m/%d %H時 section 予告と公開 予告(oss-security) :milestone, 2026-10-21 05:22, 0m 修正版がない時間(17 時間あまり) : 2026-10-21 05:22, 2026-10-21 22:34 修正版の公開 :milestone, 2026-10-21 22:34, 0m 一方で、悪用の試みは公表前の 10/17 から見つかっていたことが判明しています。 予告より早く出た理由は公表されていません。 各社の対応時刻と停止期間 安全になるための条件は、パッチを当て終えることです。 ただ、当て終えた時刻だけでは、危険だった時間は分かりません。先にサイトを止めていれば、そのあいだは外部からアクセスできないからです。 そこで、止めた時間と当て終えた時刻の両方を図にしました。 図の最上段は予告と公開の時刻です。その下がレンタルサーバー、いちばん下が自前でサービスを動かしている所で、バーの色は次のとおりです。 - 赤いバー(露出): 予告のあと、サイトの停止や緩和策、パッチの適用がまだで、脆弱性のある状態で外部からアクセスできた時間 - 薄い青のバー(停止): サイトやサービスを止めていた時間 - 青紫のバー: 緩和策を入れていた時間、またはパッチを順番に当てていた時間 - 灰色のバー: パッチを当て終えた後、または表示を再開した後 「自前」は、レンタルサーバーを借りずに、自社のサービスを自社で用意したサーバー(クラウドを含む)で動かしている所のことです。外部から見ても PHP を使っているかどうか分からないので、止めた時間だけを描いています。 gantt title 予告から公開後までの状態(日本時間) dateFormat YYYY-MM-DD HH:mm axisFormat %m/%d %H時 section 予告と公開 予告(oss-security) :milestone, 2026-10-21 05:22, 0m 修正版の公開 :milestone, 2026-10-21 22:34, 0m section レンタルサーバー エックスサーバー 露出 :crit, 2026-10-21 05:22, 2026-10-21 12:00 エックスサーバー 緩和策のみ・順次適用(全部の完了は 10/26) : 2026-10-21 12:00, 2026-10-23 12:30 コアサーバー V1 ほか 露出 :crit, 2026-10-21 05:22, 2026-10-22 00:50 コアサーバー V1 ほか 適用済み :done, 2026-10-22 00:50, 2026-10-23 12:30 mixhost 露出 :crit, 2026-10-21 05:22, 2026-10-22 10:18 mixhost 適用済み :done, 2026-10-22 10:18, 2026-10-23 12:30 ヘテムル 露出 :crit, 2026-10-21 05:22, 2026-10-22 03:05 ヘテムル サイトを停止 :active, 2026-10-22 03:05, 2026-10-22 11:24 ヘテムル 表示を再開 :done, 2026-10-22 11:24, 2026-10-23 12:30 お名前.com 露出 :crit, 2026-10-21 05:22, 2026-10-22 20:45 お名前.com 適用済み :done, 2026-10-22 20:45, 2026-10-23 12:30 KAGOYA 露出 :crit, 2026-10-21 05:22, 2026-10-22 14:40 KAGOYA 順次適用 : 2026-10-22 14:40, 2026-10-23 00:00 KAGOYA 適用済み :done, 2026-10-23 00:00, 2026-10-23 12:30 section 自前で動かすサービス BASE 停止 :active, 2026-10-21 15:50, 2026-10-22 08:00 ジンジャー 停止(終わりは予定の時刻) :active, 2026-10-21 17:00, 2026-10-22 05:00 freee の 4 サービス 停止 :active, 2026-10-21 19:04, 2026-10-22 12:56 Garoon クラウド版 停止 :active, 2026-10-21 21:00, 2026-10-22 03:01 LoGo フォームの PDF 印刷 停止 :active, 2026-10-21 21:00, 2026-10-22 02:09 図に入れた自前の 5 つは songchong さんの記事から取ったもので、どれも修正版が出る前に止めています(同じ記事にあるほかの 6 つは、公開のあとに止めています)。 レンタルサーバー各社の動きは次のとおりです。 - ヘテムル・ロリポップ!: パッチが当たるまで利用者のサイトごと止めた 2 社です(ロリポップ!は止めた時刻の記載がないので図にはありません) - エックスサーバー: サイトは止めませんでしたが、公開前の 12:00 までに緩和策を入れ、公開後は順次パッチを当てています - コアサーバー V1 など: 止めずに、公開から 2 時間ほどで当て終えています 図に入れていない所は次のとおりです。 | 事業者 | 止めた・緩和策 | パッチの適用 | |---|---|---| | ロリポップ! | 10/21 からサイトの表示を停止 | 10/22 15:33 の更新で適用済み | | さくらのレンタルサーバ | 10/21 22:30 から会員関連の機能を緊急メンテナンス | 10/23 に適用済み | | WebARENA | 10/21 に緩和策と、海外からのアクセスの停止 | 10/22 17:30 から緊急メンテナンス | | ConoHa WING | ファイルマネージャーと Web メールを停止 | 10/22 時点で順次適用中 | | CPI | — | 8.2〜8.4 は 10/22、7.4・8.0 は 10/27〜10/29 | レンタルサーバーの時刻は、各社の公式のお知らせから拾いました(10/27 に確認)。並べたのはお知らせに書かれた時刻(またはお知らせの更新時刻)で、必ずしも全部のサーバーに当て終えた時刻ではありません。 ただ、こうして並べても「各社が何時間、脆弱性のある状態で外部にさらしていたか」は一つの数字に決まりません。 10/17(悪用の試みが見つかった日)と 10/21(公式の予告)のどちらから数えるかで 4 日ずれますし、サイトを止めたのか、お知らせに書いていないだけなのか、区別のつかない所があるからです。 !どこから数えるか(10/17〜10/29) 当て終えるまでに時間がかかった理由(推定) - 公式の修正版は 8.2〜8.5 だけで、古いバージョンはバックポートか独自の修正待ち - 一度に当てず順番に当てたので、全体が終わるまで時間がかかった(KAGOYA は 14:40〜24:00 の 9 時間あまり。順番にした理由はお知らせに書かれていません) - パッケージで PHP を入れている場合は、配布元(CloudLinux や remi など)のビルドを待つ必要があった(下の表) - 公開直後は配布元にアクセスが集中し、つながりにくくなった所があった(Ubuntu 向けパッケージの配布に使われる Launchpad が 503 エラー、php.net が 504 エラー) !修正版がサーバーに当たるまでの 3 つの経路 配布元が修正版のパッケージを出した時刻は次のとおりです(比較のため、先頭に PHP 公式の公開時刻を入れています)。 | 配布元 | 出た日時(日本時間) | 修正版の公開から | |---|---|---| | PHP 公式(ソースコード) | 10/21 22:34 | — | | Debian(DSA 6574-1) | 10/21 22:45 | 11 分 | | Ubuntu(USN-8948-1) | 10/21 22:56 | 22 分 | | Debian LTS(DLA 4848-1) | 10/21 23:11 | 37 分 | | Ondřej Surý の PPA(Launchpad) | 10/21 23:45 | 1 時間 11 分 | | Docker 公式イメージ(official-images へのマージ) | 10/22 0:16 | 1 時間 42 分 | | Red Hat Enterprise Linux 8〜10(AlmaLinux 9 も同じ日) | 10/21(UTC・時刻なし) | — | | remi(RHEL 系で広く使われるリポジトリ) | 10/22 21:58 | 23 時間 24 分 | | Red Hat の延長サポート向け(EUS など) | 10/23(UTC・時刻なし) | — | 多くの配布元は、公開から 2 時間以内にパッケージを出しています。待ちが長かったのは remi と、Red Hat の延長サポート向けです。 レンタルサーバー各社が修正をどこから得たかは、お知らせに次のように書かれています。 | 事業者 | 修正の出どころ(お知らせの記載) | |---|---| | エックスサーバー | 8.2〜8.5 は修正版に更新、5.1〜8.1 はバージョンを変えずに修正パッチを適用。出どころの記載なし | | コアサーバー V1 | 記載なし | | mixhost | 採用している CloudLinux の修正パッケージ。サポートが終わったバージョンも CloudLinux が独自に修正 | | ヘテムル | 公式の修正版がない古いバージョンは、自社で修正を作成 | | ロリポップ! | 公式の修正版が出たバージョンはそれを適用し、ほかは自社で独自に修正 | | お名前.com | バージョンを変えずにパッチを適用。出どころの記載なし | | KAGOYA | 修正版に更新。出どころの記載なし | | さくらのレンタルサーバ | 記載なし | | WebARENA | 記載なし | | ConoHa WING | バージョンを変えずにパッチを適用。出どころの記載なし | | CPI | 8.2〜8.4 は修正パッチを適用、7.4・8.0 はパッチ適用版を準備。出どころの記載なし | 配布元のパッケージを待っていたと書いている所はありませんでした。当て終えるまでにかかった時間のうち、どこまでが配布元の待ちによるものかは、お知らせからは分かりません。 主な配布元の時刻を、さきほどの図の最上段に足すと次のとおりです(自前の段は省きました)。レンタルサーバー各社のバーと見比べられます。 gantt title 配布元の公開時刻とレンタルサーバーの対応(日本時間) dateFormat YYYY-MM-DD HH:mm axisFormat %m/%d %H時 section 予告・公開・配布元 予告(oss-security) :milestone, 2026-10-21 05:22, 0m 修正版の公開 :milestone, 2026-10-21 22:34, 0m Debian(22時45分)・Ubuntu(22時56分) :milestone, 2026-10-21 22:45, 0m Ondřej Surý の PPA :milestone, 2026-10-21 23:45, 0m Docker 公式イメージ :milestone, 2026-10-22 00:16, 0m remi :milestone, 2026-10-22 21:58, 0m section レンタルサーバー エックスサーバー 露出 :crit, 2026-10-21 05:22, 2026-10-21 12:00 エックスサーバー 緩和策のみ・順次適用(全部の完了は 10/26) : 2026-10-21 12:00, 2026-10-23 12:30 コアサーバー V1 ほか 露出 :crit, 2026-10-21 05:22, 2026-10-22 00:50 コアサーバー V1 ほか 適用済み :done, 2026-10-22 00:50, 2026-10-23 12:30 mixhost 露出 :crit, 2026-10-21 05:22, 2026-10-22 10:18 mixhost 適用済み :done, 2026-10-22 10:18, 2026-10-23 12:30 ヘテムル 露出 :crit, 2026-10-21 05:22, 2026-10-22 03:05 ヘテムル サイトを停止 :active, 2026-10-22 03:05, 2026-10-22 11:24 ヘテムル 表示を再開 :done, 2026-10-22 11:24, 2026-10-23 12:30 お名前.com 露出 :crit, 2026-10-21 05:22, 2026-10-22 20:45 お名前.com 適用済み :done, 2026-10-22 20:45, 2026-10-23 12:30 KAGOYA 露出 :crit, 2026-10-21 05:22, 2026-10-22 14:40 KAGOYA 順次適用 : 2026-10-22 14:40, 2026-10-23 00:00 KAGOYA 適用済み :done, 2026-10-23 00:00, 2026-10-23 12:30 危険な時間を減らすには ここからは私の見方です。 いい加減だったのか 測ってみた結果、業者がいい加減だったとは言えません。時間がかかった所も、作業の工程の限界によるものだと私は見ています。 - 主な配布元は修正版の公開から 2 時間以内にパッケージを出し、レンタルサーバーでも公開から 2 時間ほどで当て終えた所(コアサーバー V1 など)があります - 予告から公開までのあいだに、手を打った所があります。エックスサーバーは 12:00 までに緩和策を入れ、ヘテムルは 13:30 ごろから phpMyAdmin などの機能を止め始め(サイトの表示を止めたのは 10/22 3:05)、さくらは 22:30 から会員関連の機能の緊急メンテナンスに入りました - 公式の修正版がない古いバージョンにも手当てをしています。ヘテムルとロリポップ!は自社で修正を作り、mixhost は CloudLinux の修正を使い、エックスサーバーは 5.1 まで対応を広げました もちろん、パッチが当たるまでのあいだ、脆弱性のある状態で利用者のサイトを外部にさらしていた責任は業者にあります。 ただ、完了が翌日以降になった所も、手を抜いていたとは言えません。古いバージョンを抱えていたり、配布元のパッケージを待つ構成だったり、順番に当てる必要があったりすれば、作業の工程上、すぐには当て終えられないからです。 CERT-EU・英国の NCSC・米国の NSA・日本の IPA は、すぐ当てられないなら止めるよう呼びかけていました。サイトを止めてからパッチを当てるやり方は、再開が遅くなっても安全です。呼びかけどおりサイトごと止めたヘテムルとロリポップ!がこれにあたります。 とはいえ、レンタルサーバーが利用者のサイトを止めるのは簡単ではありません。 レンタルサーバーの業者はインフラを持っていますが、その上で動くサイト(サービス)は利用者のものです。止めれば利用者の業務が止まり、動かし続ければ脆弱性にさらされるので、業者だけでは決めにくい立場にあります。 これに対して、自社でサービスとインフラの両方を持っている会社は、サービスを止めるか、配布元を待たずに自分でビルドするか、いつ再開するかを、すべて自社で決められます。さきほどの図で、自前の 5 つが修正版の公開前に止めていたのも、自社で決められる立場にあったから取れた手です。 時刻を分単位で出している所は、測られてもいいと分かって出している所です。書いていない所が悪い、という話ではありません。 どこで露出していたか ここまでの図と表から、露出していた時間は 2 つに分かれます。 !露出していた時間は 2 つに分かれる(レンタルサーバー) 1 つ目の「予告から公開まで」は、この図のレンタルサーバーではエックスサーバーが 12:00 までに緩和策を入れ、さきほどのガントチャートにあった自前の 5 つが公開前にサービスを止めていました。 2 つ目の「公開から当て終える・止めるまで」は、サポート切れの古いバージョンほど長引いています。公式の修正版がないため自社で作るか CloudLinux などから得る必要があり、図に入れていない CPI の PHP 7.4・8.0 は適用が 10/27〜10/29 でした。 減らすためにできること 1 で「予告から公開まで」、2・3 で「公開から当て終えるまで」の 2 つの露出時間を減らし、4・5 で利用者ごとの影響と確認の手間を減らせます。 !2 つの時間を、どこで減らすか 1. 予告の段階(止めるか遮断するか): 止めるか動かし続けるかを利用者が前もって選べる設定(Google Compute Engine のメンテナンス設定のようなもの)を用意し、動かし続ける利用者は WAF で攻撃のリクエストを遮断します。Cloudflare は今回、修正版の公開の 10 時間半ほど前に、PHP2Shell を遮断するルールを既定でオンにしています 2. 配布元を待たない経路: ソースからビルドできるようにしておけば、パッケージを待たずに済みます。pixiv は公式イメージの公開を待たずにビルドする経路を用意していて、修正版に気づいてから 35 分で再開しました。Docker 公式イメージの更新がマージされたのは、その 1 時間後の 10/22 0:16 です 3. サポート切れのバージョンへの備え: 古いバージョンほど修正に時間がかかりました。移行の期限を決めておくか、TuxCare のようなバックポートを使えるようにしておきます 4. アカウントごとの実行環境の分離: 順番に当てると全体が終わるまで時間がかかります。mixhost が採用している CloudLinux には、アカウントごとに PHP を選べる PHP Selector があります 5. 露出していた時間の利用者ごとの通知: 順番に当てた場合、全体の完了時刻だけでは自分のサイトがいつまで露出していたか分かりません。その利用者のサイトに外部からアクセスできた時間を知らせます 処理の流れにすると、次のとおりです。 flowchart TD A[予告が出る] --> B{利用者ごとの設定} B -->|止めて待つ・既定| C[サイトを止める] B -->|動かしたまま| D[WAF で攻撃のリクエストを遮断する] E[上流を待たずにソースからビルド] --> F[修正版を当てる] G[サポート切れのバージョン] -->|期限を決めて移行か、バックポート| F C --> F D --> F F --> H[再開して、外部からアクセスできた時間を利用者ごとに通知] subgraph アカウントごとに分けた PHP の実行環境 B C D F end !危険な時間を減らす処理の流れ 必要な仕組みは、どれも既存の製品や機能にあります。これらをあらかじめ組み合わせておけば、作業の工程でどうしてもかかる時間があっても、外部にさらす時間は減らせます。 もっとも、いちばん強いのは、外部からアクセスできる公開側に PHP の実行環境(ランタイム)を置かないことです。前回の話のとおり、置かなければ、さきほどの対応の図に並ぶこともありません。 おわりに 脆弱性のある状態でサイトを外部にさらしていた責任はレンタルサーバー業者にあります。ただ、時間がかかったのは業者がいい加減だったからではなく、作業の工程や、利用者のサイトを業者だけでは止めにくい立場によるものです。そのうえで、前もって仕組みを組み合わせておけば、危険な時間はまだ減らせます。 一方で、自社でサービスとインフラの両方を持っていれば、止める判断もビルドの手順も自分で決められます。次の記事では、自前でサービスを動かしている会社のお知らせから、止めた時刻と戻した時刻、そこに至る経緯まで詳しく公開していた 3 社(GREE・pixiv・コドモン)を取り上げています。 10/27 時点の情報です。サイトの側から見た Tivo0921 さんの記事もあわせてどうぞ。 興味のある方の参考になれば幸いです。ご指摘は歓迎します。 参考文献 - PHP News Archive 2026 php.net - oss-security(予告) openwall.com - 配布元: Debian DSA 6574-1・Debian DLA 4848-1・Ubuntu USN-8948-1・Ondřej Surý の PPA・Docker official-images #22541・Red Hat・remi - GitHub の security advisory(修正版の公開の時刻 13:34 UTC)github.com/php/php-src/security/advisories/GHSA-w… ・ github.com/php/php-src/security/advisories/GHSA-6… - Tenable(10/17 の悪用の試み) tenable.com - 各社のお知らせ: エックスサーバー・同 停止・コアサーバー V1・mixhost・ヘテムル・ロリポップ!・お名前.com・KAGOYA・さくら・WebARENA・ConoHa WING・CPI - UptimeRobot(配布元の混雑) uptimerobot.com - IPA 注意喚起 ipa.go.jp - Cloudflare の WAF(ppc.land) ppc.land - CloudLinux PHP Selector support.cpanel.net - TuxCare(Plesk) plesk.com - Google Compute Engine cloud.google.com
The Compose Race Your Coding Agent Inherits From You !Hero artwork: a robotic arm stamping green checkmarks on a conveyor belt of identical generated compose files, the finished stack toppling into a wall labeled connection refused Ask a coding agent for a Docker Compose file with Postgres, then cold-boot the result on a machine that has never run it. There is a good chance the app container starts, immediately asks for a database that does not exist yet, and fails its first connections. Wait a few seconds, try again, and everything works. The agent will tell you it tested the file. Both things are true, and the bug is older than the agent: it inherited the race from the tutorials it was trained on, and it also inherited the half-fix that looks like the fix. Docker Compose is a tool for defining and running multi-container applications from one YAML file. I tested the generated shape, the half-fixed shape, and the corrected shape on my own host this week: three cold starts on fresh volumes (Docker Engine 29.1.3, Compose v5.1.4), the inherited race, the inherited half-fix that still races, and the three-line correction that actually holds. Everything below is from that run, with the run methodology and the timestamps as captured. > Key Takeaways > - A bare depends_on: list only orders container startup. Compose explicitly does not wait for "ready", only "running" (Docker docs). > - The most-copied Postgres healthcheck, pg_isready -U postgres, probes the Unix socket, and during first-boot initialization a temporary Postgres server answers on that socket before the real one is up (docker-library/postgres issue #146). > - In my run, that socket-probe healthcheck reported the database healthy 4 seconds before Postgres actually accepted a TCP connection. The gate was wired correctly. The probe lied. > - The fix is three edits: condition: service_healthy, restart: true, and -h 127.0.0.1 on the probe. My corrected file connected on attempt one, on a cold volume. In this post: - [What does the agent actually write?](#what-does-the-agent-actually-write) - [Why does the race survive a working demo?](#why-does-the-race-survive-a-working-demo) - [What did I actually run?](#what-did-i-actually-run) - [Why doesn't the copied healthcheck fix it?](#why-doesnt-the-copied-healthcheck-fix-it) - [What does the fixed file look like?](#what-does-the-fixed-file-look-like) - [How do you review agent YAML in 60 seconds?](#how-do-you-review-agent-yaml-in-60-seconds) - [FAQ](#faq) What does the agent actually write? Two lines date a compose file, and agents write both of them together. The first is the bare dependency list. In the long form of depends_on, per the compose-file services reference, this same key takes a condition instead, which is the part the 2021 corpus never learned: services: app: depends_on: - db The second is the socket-probe healthcheck on the database. healthcheck is a container configuration that makes the runtime probe the service at a fixed interval and report a health status: db: image: postgres:16-alpine healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] Neither line is broken on its own. The first one does exactly what it says: start db before app. The second one does exactly what it says: exit 0 when something answers on the Postgres Unix socket. The problem is what they don't say, and they cluster in generated files because they cluster in the training corpus. Both come from the same generation of quick-start guides, the ones that shipped years before the long-form depends_on with conditions was common knowledge. The evidence that this is a corpus problem, not a reasoning problem, is now measured. Work on updating model knowledge after API changes found that models continue generating outdated API calls "even with access to current documentation" (arXiv:2506.20495). A study of deprecated-API recommendations reports that 37.4 percent of GPT-3.5's API predictions were deprecated, and that fixing this by retraining is impractical because library APIs change too fast (arXiv:2511.21022). Compose YAML is not a Python library call, but the mechanism transfers exactly: the spec moved, the tutorials didn't, and the average of the corpus is the 2021 shape. !Diagram of the inheritance pipeline: dated tutorial books flow into a glowing model core that outputs a compose file card, which feeds a container stack where a race condition plays out Why does the race survive a working demo? Because a warm volume hides it. Postgres only does its slow first-boot work when the data directory is empty. The entrypoint script runs initdb, starts a temporary server for the setup phase, runs anything mounted in /docker-entrypoint-initdb.d/, and only then hands over to the real server as PID 1. On a volume that has already been initialized, the container skips all of that, and the gap between "container running" and "accepting connections" shrinks to near zero. Whoever verifies the compose file, human or agent, usually verifies it on a warm volume. The demo passes. The agent's confidence is not fake; the test genuinely passed on the machine it had. The first cold start is the one that pays for it: CI runner, teammate's laptop, fresh VM. That is the boot where the app container starts a beat after the database container and asks a half-initialized Postgres for a connection. Here is the output of that exact cold start from my run, an alpine app probing db:5432 every half second in a loop. Consider it the shape of every "it works on the second try" bug report you have ever closed: app-1 | ATTEMPT 1: CONNECTION-REFUSED app-1 | ATTEMPT 2: CONNECTION-REFUSED app-1 | ATTEMPT 3: CONNECTION-REFUSED app-1 | ATTEMPT 4: CONNECTION-REFUSED app-1 | ATTEMPT 5: CONNECTED Two seconds of refused connections on a fast local disk. The database log tells you why: the "ready to accept connections" line lands at 08:10:28, after the app has already been asking. On a slower disk, or with init scripts in /docker-entrypoint-initdb.d/, the window stretches to seconds more. If your app has restart: unless-stopped, Compose quietly restarts it until it sticks, the stack comes up, and the race is invisible again until the morning it crash-loops. I documented the anatomy of this race in the healthcheck deep-dive earlier in this series. The part that is new here is where the YAML came from, and the fact that the obvious fix, asking the agent to add a healthcheck, often doesn't fix it. <!-- UNIQUE INSIGHT: the inherited race and the inherited half-fix come from the same corpus generation, so the agent reproduces both together. --> What did I actually run? <!-- ORIGINAL DATA: reproduced locally, Docker Engine 29.1.3, Docker Compose v5.1.4, alpine:3.20 + postgres:16-alpine, 2026-10-29, three runs on fresh volumes. --> Three cold starts on my own host: Docker Engine 29.1.3, Docker Compose v5.1.4, with alpine:3.20 as the app and postgres:16-alpine as the database. Every run started from a deleted volume, so Postgres went through its full first-boot initialization each time. The app is deliberately dumb, a shell loop that tries db:5432 every half second and prints the result, so the timing is visible and nothing else can mask it: services: app: image: alpine:3.20 depends_on: - db command: > sh -c 'i=0; while [ $$i -lt 30 ]; do i=$$((i+1)); if nc -z -w 1 db 5432 2>/dev/null; then echo "ATTEMPT $$i: CONNECTED"; exit 0; else echo "ATTEMPT $$i: CONNECTION-REFUSED"; fi; sleep 0.5; done; echo "GAVE UP"' For the third run, to make the initialization window wide enough to observe, I mounted a one-line init script into /docker-entrypoint-initdb.d/ that sleeps four seconds. The sleep does not create the gap, it only stretches the same initialization phase that already existed in the first run, and every timestamp below comes from docker inspect and the Postgres log, not from my watch. The three runs, all from the same host: | Run | File shape | Outcome | |---|---|---| | 1 | bare depends_on list | 4 refused connections, connected on attempt 5 | | 2 | service_healthy + TCP probe | connected on attempt 1, app held until Healthy | | 3 | service_healthy + socket probe | "healthy" 4s early, 7 refused connections after the verdict | Why doesn't the copied healthcheck fix it? Here is where the inherited fix becomes worse than the inherited race. Ask an agent to fix the startup race and it will, correctly, reach for the long-form dependency. service_healthy is the depends_on condition that holds the dependent container back until the dependency's healthcheck reports healthy: app: depends_on: db: condition: service_healthy That is the right gate. Compose will not start the app until the database's healthcheck reports healthy. But the healthcheck it copies, the same one from the same tutorials, is pg_isready -U postgres, and pg_isready without a host flag connects to the Unix socket. pg_isready is the Postgres client's readiness probe; it exits 0 when a server accepts connections. On the official Postgres image, first boot runs a temporary server for initialization that listens on the Unix socket only. The probe passes while the real server, the one your app connects to over TCP, is not up yet. The maintainers have been explaining this in issue #146 for years: "Simply adding -h 127.0.0.1 to the pg_isready command line should be enough to detect readiness correctly." I ran that combination, the correct gate with the copied probe, and watched docker inspect while it happened. The healthcheck went starting at +0.78 seconds and healthy at +3.02 seconds. The real server accepted its first TCP connection at +7.06 seconds. Four seconds of lie. Compose trusted the verdict, started the app, and the app did this: app-1 | ATTEMPT 1: CONNECTION-REFUSED app-1 | ATTEMPT 2: CONNECTION-REFUSED app-1 | ATTEMPT 3: CONNECTION-REFUSED app-1 | ATTEMPT 4: CONNECTION-REFUSED app-1 | ATTEMPT 5: CONNECTION-REFUSED app-1 | ATTEMPT 6: CONNECTION-REFUSED app-1 | ATTEMPT 7: CONNECTION-REFUSED app-1 | ATTEMPT 8: CONNECTED Seven refused connections after the container was declared healthy. The probe log shows the mechanics, and it is worth reading slowly because it is the whole post in three lines: 08:13:52.4 exit 0 /var/run/postgresql:5432 - accepting connections 08:13:54.5 exit 0 /var/run/postgresql:5432 - accepting connections 08:13:56.5 exit 2 /var/run/postgresql:5432 - no response The first two probes passed against the temporary initialization server, on the socket. Then the entrypoint shut that server down to finish init work, and the third probe failed, no response, while the real server was still seconds away. The status even flipped to unhealthy for a moment during the handoff. If the failure had landed one probe later, after the container was already marked healthy, the streak would have needed three consecutive failures to matter, and by then the app had long since started and crashed. This is the failure mode that makes people conclude the healthcheck pattern "doesn't work" and delete it. The gate worked. The probe answered a different question than the one the app asks. What does the fixed file look like? Three edits to the generated file, and my run 2 output after them: Container racefix-db-1 Started Container racefix-db-1 Waiting Container racefix-db-1 Healthy Container racefix-app-1 Starting Container racefix-app-1 Started app-1 | ATTEMPT 1: CONNECTED The app is not created until db reports Healthy, and its very first connection lands. The full file: services: app: image: alpine:3.20 depends_on: db: condition: service_healthy restart: true command: > sh -c 'i=0; while [ $$i -lt 30 ]; do i=$$((i+1)); if nc -z -w 1 db 5432 2>/dev/null; then echo "ATTEMPT $$i: CONNECTED"; exit 0; else echo "ATTEMPT $$i: CONNECTION-REFUSED"; fi; sleep 0.5; done; echo "GAVE UP"' db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: example volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"] interval: 2s timeout: 3s retries: 10 start_period: 10s volumes: pgdata: The three edits, one line each. condition: service_healthy makes Compose wait for the healthcheck verdict instead of container start, the gate. restart: true re-runs that gate when you explicitly restart the database, so the app is not left pointing at stale connections after a docker compose restart db. And -h 127.0.0.1 forces the probe onto TCP, where the temporary initialization server cannot answer it, the honest probe. The timing knobs around it matter too, start_period especially: they give a slow first boot room to finish before failures start counting. On a cold volume my database needed about seven seconds, so 10 seconds of grace is the floor I would ship, and CI on shared runners gets more. Keep the application-side retry anyway. The gate protects the startup sequence only. If Postgres restarts two hours later, nothing re-runs the gate for you, a limit I pushed on in the exit-0 OOM post from the other angle: a green healthcheck reports its own probe, not your app's reality. How do you review agent YAML in 60 seconds? You do not need to run the stack to catch this class of bug. Read the diff against the current spec, the same way you would review a human's PR. Check the dependency wiring first. A bare depends_on: - db list means no waiting; it needs the long form with condition: service_healthy to gate startup. Check the probe next, because this is the part everyone trusts. pg_isready with no -h flag is a socket probe; on the Postgres image that can pass during initialization. It needs -h 127.0.0.1 (or the service name, if you want the probe to mirror app traffic) to test the path your app actually uses. Check the timing budget after that: no start_period means a slow first boot spends its failure budget before the database had a chance. CI runners are slower than your laptop, so give the budget headroom there. And check what happens after startup: no restart: true on the dependency means a deliberate database restart leaves the app on stale connections. Then fix the input, not just the output. The prompt that produces current YAML is a prompt with current references in it. Paste the startup-order doc and the services reference links into the request, and say "gate the app on service_healthy with a TCP probe" instead of "write me a compose file with Postgres". The model still carries the old corpus, and it will still try the 2021 shape when you let it guess. But it does not argue with a pasted spec. The measured version of this instinct is the ReCode result: outdated API knowledge persists "even with access to current documentation" (arXiv:2506.20495). The documentation you paste is doing real work, and the review pass you run after is doing the rest. One more habit that pays for itself: cold-boot the result before you merge it. On a scratch project, docker compose down -v && docker compose up --wait is a 30-second test. It reproduces exactly the run that fails in CI, and --wait turns the healthcheck into the pass/fail signal instead of a grepped log line. FAQ Doesn't restart: unless-stopped already handle this? It hides it. The app crash-loops until the database happens to be ready, then looks healthy. The race is still there; you just stopped being able to see it. Does the long depends_on form without condition wait for readiness? No. Without condition: service_healthy it is equivalent to the short list: ordering only. Is docker compose up --wait enough for CI? Yes, and it is the right tool: it blocks until healthchecked services report healthy. It does not remove the need for the gate on the app service itself, or for an honest probe. Do I still need app-level retries if the gate works? Yes. The gate runs at startup only. A connection that drops an hour later is the application's problem, and the same retry-with-backoff loop you already write for production networks covers it. Where does this leave the agent? The agent is not wrong that the file works. It worked on the machine it had, with the volume it had, and the corpus it was trained on says that file is what a compose file looks like. The spec moved, the tutorials didn't, and the average of all that 2021 YAML is what comes out when you ask for 2026 infrastructure. The three-line diff between the inherited shape and the current one is small, it is mechanical, and it is exactly the kind of review you were going to do anyway on a file nobody typed by hand. What is the oldest line of config an agent has handed you that you only caught because it failed somewhere strange? I am collecting these for a follow-up, and the comments are where I will read them. Verified against Docker Engine 29.1.3 and Docker Compose v5.1.4 on 2026-10-29, with three cold-start runs on fresh volumes; every timestamp and probe result above is from docker inspect or the Postgres log of those runs. Claims about model behavior are cited to the linked papers, and anything I could not reproduce myself is not in this post. Sources Every claim above traces to these primary sources. Where I could not verify something hands-on, the post says so. - Hero artwork: a robotic arm stamping green checkmarks on a conveyor belt of identical gene - Diagram artwork: dated tutorial books flow into a glowing model core that outputs a compose file card - Docker docs - docker-library/postgres issue #146 - arXiv:2506.20495 - arXiv:2511.21022 - compose-file services reference - docker-library postgres entrypoint - GitHub About the author Kunal Das writes operations engineering notes as he learns them, always against primary sources. Contact: GitHub and dev.to. Follow the Docker tag for the next posts in this series.
Seems like this situation is about to change. Thank you Ruby core team for your hard work!!! official-images#22616
【応急処置】PHP 7.4 のまま CVE-2026-109618 / 109619(RCE)を塞ぐ Docker イメージを公開しました はじめに 2026-10-21、PHP プロジェクトが 認証なしでリモートコード実行(RCE)が可能な脆弱性を 2 件 公表しました(php.net のアナウンス)。 - CVE-2026-109618(GHSA-w8hw-w9fx-j79w): メモリ不足(OOM)の処理が、内部のリクエストデータの初期化途中で PHP を中断させ、壊れたデータがクリーンアップ時に正しいデータとして扱われる - CVE-2026-109619(GHSA-6wmf-4rv5-p3rp): 解放済みのメモリを指すリクエスト用の内部状態が残り、同じワーカーが処理する次のリクエストでそれが使われる 影響を受けるのは、Web リクエストを処理するすべての PHP(PHP-FPM、Apache mod_php、php-cgi など)です。advisory は mod_php のリスクが最も高い としており、アプリケーションのコードが動く前に攻撃が成立する実証コードもあるとしています。 修正版が出たのは PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 だけです。PHP 7.4 は EOL なので修正版は出ません。ただ、深刻度を考慮して、PHP セキュリティチームが 7.4 / 8.0 / 8.1 向けに 最小限のソース backport を公開しています。 一方で、Docker の公式イメージ php:7.4-* が再ビルドされることはありません。FROM php:7.4-apache のまま運用しているシステムは、何もしなければ脆弱なまま です。 そこで、この backport を当てて再ビルドした php:7.4-* 互換のイメージを OSS として公開しました。 GitHub: softjapan/php74-cve-patched softjapan/php74-cve-patched :::note alert このイメージは「応急処置」です。PHP 7.4 を安全なバージョンにするものではありません。 - 直るのは CVE-2026-109618 と CVE-2026-109619 の 2 件だけ です。7.4.33 より後に見つかったほかの問題は直っていません。 - ベース OS の Debian 11 (bullseye) は 2026-08 に LTS が終わっています。OpenSSL や Apache などの OS パッケージは今後更新されません。 - PHP プロジェクトや Docker 社の公式イメージではありません。 恒久的な対策は PHP 8.2 以降への移行 です。このイメージは、移行までの時間を稼ぐために使ってください。 ::: TL;DR - FROM php:7.4-apache + FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache 1. FROM を 1 行差し替える(apache / fpm / cli、amd64 / arm64) 2. イメージを再ビルド・再デプロイし、PHP のワーカーをすべて再起動する 3. check-patched.sh で修正が入ったことを確認する 4. PHP 8.2 以降への移行計画を立てる(ここまでやって対応完了です) まず、自分の環境が脆弱か確認する 困ったことに、backport を当てても php -v は 7.4.33 のまま です。バージョン番号を見ても、対策済みかどうかは分かりません。 そこで、PHP のバイナリの中身を調べて判定するスクリプトを用意しました。 curl -fsSLO https://raw.githubusercontent.com/softjapan/php74-cve-patched/main/scripts/check-patched.sh sh check-patched.sh php:7.4-apache # イメージ sh check-patched.sh --container my-web-1 # 起動中のコンテナ sh check-patched.sh --local # このマシン(VM や物理サーバー) 公式の php:7.4-apache を調べた結果です。 Image: php:7.4-apache PHP version: 7.4.33 VULNERABLE /usr/lib/apache2/modules/libphp7.so VULNERABLE /usr/local/bin/php RESULT: VULNERABLE to CVE-2026-109618 / CVE-2026-109619 パッチを当てたイメージではこうなります。 Image: ghcr.io/softjapan/php74-cve-patched:7.4-apache PHP version: 7.4.33 patched /usr/lib/apache2/modules/libphp7.so patched /usr/local/bin/php RESULT: PATCHED (backport found in every PHP binary) - 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 以降は修正済みと判定します。それより古いバージョンは、backport で追加される関数 zend_new_array_singleton がバイナリにあるかどうかで判定します。 - CLI だけでなく、実際にリクエストを処理する libphp7.so(mod_php)や php-fpm も個別に調べます。CLI だけ直っていて Apache が古いまま、という状態も見つけられます。 - 終了コードは 0 が修正済み、1 が脆弱です。CI に組み込んで、脆弱なイメージをデプロイしないようにもできます。 :::note info この判定は簡易的なものです。ディストリビューションや LTS ベンダーが独自の方法で backport したパッケージは、「脆弱」と判定されることがあります。その場合はベンダーの情報を確認してください。 ::: 使い方 同じイメージを GHCR と Docker Hub の両方で公開しています。タグもダイジェストも同じです。 | 公式イメージ | GHCR | Docker Hub | |---|---|---| | php:7.4-apache | ghcr.io/softjapan/php74-cve-patched:7.4-apache | softjpn/php74-cve-patched:7.4-apache | | php:7.4-fpm | ghcr.io/softjapan/php74-cve-patched:7.4-fpm | softjpn/php74-cve-patched:7.4-fpm | | php:7.4-cli | ghcr.io/softjapan/php74-cve-patched:7.4-cli | softjpn/php74-cve-patched:7.4-cli | 公式イメージと同じ使い方ができることを重視しています。 - docker-php-ext-install / docker-php-ext-configure / pecl / php.ini-production がそのまま使えます - php -m の拡張モジュール一覧が公式イメージと同じであることを CI で確認しています - apt-get install も使えます(apt の参照先を archive.debian.org に変えてあります) Laravel 向けの例です。 FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache RUN apt-get update && apt-get install -y --no-install-recommends \ libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \ && rm -rf /var/lib/apt/lists/* \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j"$(nproc)" gd pdo_mysql zip bcmath opcache \ && a2enmod rewrite :::note alert イメージを差し替えただけでは終わりません。 実行中のコンテナやワーカーは古いバイナリのまま動いています。再ビルド・再デプロイして、PHP のワーカーをすべて再起動してください。最後に check-patched.sh --container <名前> で、動いているコンテナ が修正済みかを確認してください。 ::: 何を変えたのか 変更は最小限にしています。docker-library/php に残っている 最後の PHP 7.4 用 Dockerfile(commit 1361ba95) を元に、変えたのは次の 3 点だけです。 1. apt の参照先を archive.debian.org に変更: bullseye は通常のミラーから消えたため、apt-get update が 404 になります 2. PHP 7.4.33 のソースに backport を適用: patch -p1 --fuzz=0 で当てるので、少しでもずれていればビルドが失敗します 3. 修正が入ったことをビルド中に確認: php、php-fpm、libphp7.so のどれか 1 つでも修正が入っていなければ、ビルドを失敗させます 当てているパッチは、上流の php/php-src@712e60f と バイト単位で同一 です。CI が push のたびと毎週、次の点を確認しています。 - 同梱のパッチが上流の commit と同一であること - 上流の backport ブランチに新しい commit が入っていないこと(入っていれば CI が失敗して知らせます) 当初は社内で php:7.4-apache の上から ./configure をやり直す方法をとっていました。しかしこの方法には、configure のオプションが x86_64 向けに固定される、fpm / cli 版でオプションがずれる、といった問題がありました。公開版では公式の Dockerfile そのものを元にして、マルチアーキテクチャへの対応と公式との互換性を両立しています。 信頼して使うために セキュリティ目的のイメージを、知らない人が配っているものから取ってくるのは不安だと思います。そのため、検証できる材料をそろえました。 - イメージはリポジトリの GitHub Actions だけでビルドし、SBOM と provenance を付けています - cosign(keyless)で署名しています cosign verify ghcr.io/softjapan/php74-cve-patched:7.4-apache \ --certificate-identity-regexp '^https://github.com/softjapan/php74-cve-patched/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com - 本番環境ではダイジェスト(@sha256:...)で固定してください - このイメージを使わず、自分でビルドする手順 も公開しています。Docker を使わずソースからビルドしている環境向けに、patch -p1 で当てる手順も載せています イメージをすぐ差し替えられない場合の緩和策 advisory には、すぐに更新できない場合の緩和策も載っています。どれも攻撃の経路を一部ふさぐだけで、安全になるわけではない と明記されていますが、差し替えまでのつなぎや多層防御として使えます。 CVE-2026-109618(OOM による状態破損) - memory_limit=-1 を、アプリケーションが上書きできない方法(FPM プールや Apache の php_admin_value)で設定する。どこかの設定で有限の値に戻されていないかも確認する - その代わり、コンテナや cgroup のメモリ制限で、影響を受けたワーカーだけが止まるようにしておく - RLIMIT_AS / RLIMIT_DATA での代用はしない(脆弱な経路を逆に通る原因になる) - post_max_size を小さくする。アップロードがないなら 8M、あるなら 32M。upload_max_filesize は post_max_size より小さくする - Web サーバーやプロキシでも、リクエストサイズ、フィールド数、マルチパートの数、ヘッダー数などを制限する。Content-Length がないリクエストも対象に含める CVE-2026-109619(古いグローバル状態) - session.upload_progress.enabled=0 を設定し、ワーカーをすべて再起動する - php.ini、FPM プール、サーバー設定など、リクエストの解析前に効く場所で設定する。ini_set() や .user.ini では効果がない - 初期化前に警告を出すような、無効な設定や非推奨の設定をリクエストごとに適用しているなら取り除く(@ や display_errors で表示を隠しても意味はない) - 攻撃者が起こせる fatal error やタイムアウトを修正する このイメージには、上記の一部(session.upload_progress.enabled = 0 と、小さめの post_max_size / upload_max_filesize)をまとめた設定ファイルを同梱しています。デフォルトでは無効 です。PHP の挙動は公式イメージと変えていないので、使う場合は次の 1 行を追加してください。 RUN cp /usr/local/share/php74-cve-patched/zz-cve-2026-mitigations.ini "$PHP_INI_DIR/conf.d/" 応急処置のあとにやること 最後にもう一度書いておきます。このイメージは止血であって、治療ではありません。 - [ ] すべての PHP 7.4 環境を洗い出し、check-patched.sh で確認した - [ ] イメージを差し替え、再デプロイし、ワーカーを再起動した - [ ] 動いているコンテナ で check-patched.sh --container が PATCHED になった - [ ] 必要に応じて緩和策の設定も入れた - [ ] PHP 8.2 以降への移行計画と期限を決めた(8.2 も 2026-12-31 に EOL になるので、8.3 以降も検討する) - [ ] OS(Debian 11)も更新されないことを、関係者と共有した まとめ - CVE-2026-109618 / 109619 は、認証なしの RCE です。特に mod_php は最優先で対応してください - PHP 7.4 の公式 Docker イメージは今後も直りません。php:7.4-* を使っていれば脆弱なままです - php74-cve-patched は、FROM を 1 行変えるだけで公式の backport を取り込める 応急処置 です - 恒久的な対策は PHP 8.2 以降への移行です PHP 8.0 / 8.1 版(こちらも公式の backport があります)や Alpine 版も検討しています。必要な方は Issue で教えてください。この記事が役に立ったら、Qiita の LGTM や GitHub の ⭐ をもらえるとうれしいです。同じように困っている人に見つけてもらいやすくなります。 softjapan/php74-cve-patched 参考 - PHP: News Archive 2026(2026-10-21 Security advisory) - GHSA-w8hw-w9fx-j79w(CVE-2026-109618) - GHSA-6wmf-4rv5-p3rp(CVE-2026-109619) - php-src: PHP-7.4-CVE-2026-109618-109619 ブランチ
【応急処置】PHP 7.4 のまま CVE-2026-109618 / 109619(RCE)を塞ぐ Docker イメージを公開しました はじめに 2026-10-21、PHP プロジェクトが 認証なしでリモートコード実行(RCE)が可能な脆弱性を 2 件 公表しました(php.net のアナウンス)。 CVE-2026-109618(GHSA-w8hw-w9fx-j79w): メモリ不足(OOM)の処理が、内部のリクエストデータの初期化途中で PHP を中断させ、壊れたデータがクリーンアップ時に正しいデータとして扱われる CVE-2026-109619(GHSA-6wmf-4rv5-p3rp): 解放済みのメモリを指すリクエスト用の内部状態が残り、同じワーカーが処理する次のリクエストでそれが使われる 影響を受けるのは、Web リクエストを処理するすべての PHP(PHP-FPM、Apache mod_php、php-cgi など)です。advisory は mod_php のリスクが最も高い としており、アプリケーションのコードが動く前に攻撃が成立する実証コードもあるとしています。 修正版が出たのは PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 だけです。PHP 7.4 は EOL なので修正版は出ません。ただ、深刻度を考慮して、PHP セキュリティチームが 7.4 / 8.0 / 8.1 向けに 最小限のソース backport を公開しています。 一方で、Docker の公式イメージ php:7.4-* が再ビルドされることはありません。FROM php:7.4-apache のまま運用しているシステムは、何もしなければ脆弱なまま です。 そこで、この backport を当てて再ビルドした php:7.4-* 互換のイメージを OSS として公開しました。 softjapan/php74-cve-patched ! このイメージは「応急処置」です。PHP 7.4 を安全なバージョンにするものではありません。 直るのは CVE-2026-109618 と CVE-2026-109619 の 2 件だけ です。7.4.33 より後に見つかったほかの問題は直っていません。 ベース OS の Debian 11 (bullseye) は 2026-08 に LTS が終わっています。OpenSSL や Apache などの OS パッケージは今後更新されません。 PHP プロジェクトや Docker 社の公式イメージではありません。 恒久的な対策は PHP 8.2 以降への移行 です。このイメージは、移行までの時間を稼ぐために使ってください。 TL;DR - FROM php:7.4-apache - FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache FROM を 1 行差し替える(apache / fpm / cli、amd64 / arm64) イメージを再ビルド・再デプロイし、PHP のワーカーをすべて再起動する check-patched.sh で修正が入ったことを確認する PHP 8.2 以降への移行計画を立てる(ここまでやって対応完了です) まず、自分の環境が脆弱か確認する 困ったことに、backport を当てても php -v は 7.4.33 のまま です。バージョン番号を見ても、対策済みかどうかは分かりません。 そこで、PHP のバイナリの中身を調べて判定するスクリプトを用意しました。 curl -fsSLO raw.githubusercontent.com sh check-patched.sh php:7.4-apache # イメージ sh check-patched.sh --container my-web-1 # 起動中のコンテナ sh check-patched.sh --local # このマシン(VM や物理サーバー) 公式の php:7.4-apache を調べた結果です。 Image: php:7.4-apache PHP version: 7.4.33 VULNERABLE /usr/lib/apache2/modules/libphp7.so VULNERABLE /usr/local/bin/php RESULT: VULNERABLE to CVE-2026-109618 / CVE-2026-109619 パッチを当てたイメージではこうなります。 Image: ghcr.io/softjapan/php74-cve-patched:7.4-apache PHP version: 7.4.33 patched /usr/lib/apache2/modules/libphp7.so patched /usr/local/bin/php RESULT: PATCHED (backport found in every PHP binary) 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 以降は修正済みと判定します。それより古いバージョンは、backport で追加される関数 zend_new_array_singleton がバイナリにあるかどうかで判定します。 CLI だけでなく、実際にリクエストを処理する libphp7.so(mod_php)や php-fpm も個別に調べます。CLI だけ直っていて Apache が古いまま、という状態も見つけられます。 終了コードは 0 が修正済み、1 が脆弱です。CI に組み込んで、脆弱なイメージをデプロイしないようにもできます。 ! この判定は簡易的なものです。ディストリビューションや LTS ベンダーが独自の方法で backport したパッケージは、「脆弱」と判定されることがあります。その場合はベンダーの情報を確認してください。 使い方 同じイメージを GHCR と Docker Hub の両方で公開しています。タグもダイジェストも同じです。 公式イメージ GHCR Docker Hub php:7.4-apache ghcr.io/softjapan/php74-cve-patched:7.4-apache softjpn/php74-cve-patched:7.4-apache php:7.4-fpm ghcr.io/softjapan/php74-cve-patched:7.4-fpm softjpn/php74-cve-patched:7.4-fpm php:7.4-cli ghcr.io/softjapan/php74-cve-patched:7.4-cli softjpn/php74-cve-patched:7.4-cli 公式イメージと同じ使い方ができることを重視しています。 docker-php-ext-install / docker-php-ext-configure / pecl / php.ini-production がそのまま使えます php -m の拡張モジュール一覧が公式イメージと同じであることを CI で確認しています apt-get install も使えます(apt の参照先を archive.debian.org に変えてあります) Laravel 向けの例です。 FROM ghcr.io/softjapan/php74-cve-patched:7.4-apache RUN apt-get update && apt-get install -y --no-install-recommends \ libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \ && rm -rf /var/lib/apt/lists/* \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j"$(nproc)" gd pdo_mysql zip bcmath opcache \ && a2enmod rewrite ! イメージを差し替えただけでは終わりません。 実行中のコンテナやワーカーは古いバイナリのまま動いています。再ビルド・再デプロイして、PHP のワーカーをすべて再起動してください。最後に check-patched.sh --container <名前> で、動いているコンテナ が修正済みかを確認してください。 何を変えたのか 変更は最小限にしています。docker-library/php に残っている 最後の PHP 7.4 用 Dockerfile(commit 1361ba95) を元に、変えたのは次の 3 点だけです。 apt の参照先を archive.debian.org に変更: bullseye は通常のミラーから消えたため、apt-get update が 404 になります PHP 7.4.33 のソースに backport を適用: patch -p1 --fuzz=0 で当てるので、少しでもずれていればビルドが失敗します 修正が入ったことをビルド中に確認: php、php-fpm、libphp7.so のどれか 1 つでも修正が入っていなければ、ビルドを失敗させます 当てているパッチは、上流の php/php-src@712e60f と バイト単位で同一 です。CI が push のたびと毎週、次の点を確認しています。 同梱のパッチが上流の commit と同一であること 上流の backport ブランチに新しい commit が入っていないこと(入っていれば CI が失敗して知らせます) 当初は社内で php:7.4-apache の上から ./configure をやり直す方法をとっていました。しかしこの方法には、configure のオプションが x86_64 向けに固定される、fpm / cli 版でオプションがずれる、といった問題がありました。公開版では公式の Dockerfile そのものを元にして、マルチアーキテクチャへの対応と公式との互換性を両立しています。 信頼して使うために セキュリティ目的のイメージを、知らない人が配っているものから取ってくるのは不安だと思います。そのため、検証できる材料をそろえました。 イメージはリポジトリの GitHub Actions だけでビルドし、SBOM と provenance を付けています cosign(keyless)で署名しています cosign verify ghcr.io/softjapan/php74-cve-patched:7.4-apache \ --certificate-identity-regexp '^softjapan/php74-cve-patched' \ --certificate-oidc-issuer token.actions.githubusercontent.com 本番環境ではダイジェスト(@sha256:...)で固定してください このイメージを使わず、自分でビルドする手順 も公開しています。Docker を使わずソースからビルドしている環境向けに、patch -p1 で当てる手順も載せています イメージをすぐ差し替えられない場合の緩和策 advisory には、すぐに更新できない場合の緩和策も載っています。どれも攻撃の経路を一部ふさぐだけで、安全になるわけではない と明記されていますが、差し替えまでのつなぎや多層防御として使えます。 CVE-2026-109618(OOM による状態破損) memory_limit=-1 を、アプリケーションが上書きできない方法(FPM プールや Apache の php_admin_value)で設定する。どこかの設定で有限の値に戻されていないかも確認する その代わり、コンテナや cgroup のメモリ制限で、影響を受けたワーカーだけが止まるようにしておく RLIMIT_AS / RLIMIT_DATA での代用はしない(脆弱な経路を逆に通る原因になる) post_max_size を小さくする。アップロードがないなら 8M、あるなら 32M。upload_max_filesize は post_max_size より小さくする Web サーバーやプロキシでも、リクエストサイズ、フィールド数、マルチパートの数、ヘッダー数などを制限する。Content-Length がないリクエストも対象に含める CVE-2026-109619(古いグローバル状態) session.upload_progress.enabled=0 を設定し、ワーカーをすべて再起動する php.ini、FPM プール、サーバー設定など、リクエストの解析前に効く場所で設定する。ini_set() や .user.ini では効果がない 初期化前に警告を出すような、無効な設定や非推奨の設定をリクエストごとに適用しているなら取り除く(@ や display_errors で表示を隠しても意味はない) 攻撃者が起こせる fatal error やタイムアウトを修正する このイメージには、上記の一部(session.upload_progress.enabled = 0 と、小さめの post_max_size / upload_max_filesize)をまとめた設定ファイルを同梱しています。デフォルトでは無効 です。PHP の挙動は公式イメージと変えていないので、使う場合は次の 1 行を追加してください。 RUN cp /usr/local/share/php74-cve-patched/zz-cve-2026-mitigations.ini "$PHP_INI_DIR/conf.d/" 応急処置のあとにやること 最後にもう一度書いておきます。このイメージは止血であって、治療ではありません。 すべての PHP 7.4 環境を洗い出し、check-patched.sh で確認した イメージを差し替え、再デプロイし、ワーカーを再起動した 動いているコンテナ で check-patched.sh --container が PATCHED になった 必要に応じて緩和策の設定も入れた PHP 8.2 以降への移行計画と期限を決めた(8.2 も 2026-12-31 に EOL になるので、8.3 以降も検討する) OS(Debian 11)も更新されないことを、関係者と共有した まとめ CVE-2026-109618 / 109619 は、認証なしの RCE です。特に mod_php は最優先で対応してください PHP 7.4 の公式 Docker イメージは今後も直りません。php:7.4-* を使っていれば脆弱なままです php74-cve-patched は、FROM を 1 行変えるだけで公式の backport を取り込める 応急処置 です 恒久的な対策は PHP 8.2 以降への移行です PHP 8.0 / 8.1 版(こちらも公式の backport があります)や Alpine 版も検討しています。必要な方は Issue で教えてください。役に立ったら GitHub で ⭐ をもらえるとうれしいです。同じように困っている人に見つけてもらいやすくなります。 softjapan/php74-cve-patched 参考 PHP: News Archive 2026(2026-10-21 Security advisory) GHSA-w8hw-w9fx-j79w(CVE-2026-109618) GHSA-6wmf-4rv5-p3rp(CVE-2026-109619) php-src: PHP-7.4-CVE-2026-109618-109619 ブランチ
:obama_phew: ``` # php -v PHP 8.5.12 (cli) (built: Oct 21 2026 15:49:40) (NTS) Copyright (c) The PHP Group Built by docker-library/php Zend Engine v4.5.12, Copyright (c) Zend Technologies with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies ```