<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
<title><![CDATA[活到老学到老]]></title> 
<atom:link href="https://binliyou.com/rss.php" rel="self" type="application/rss+xml" />
<description><![CDATA[]]></description>
<link>https://binliyou.com/</link>
<language>zh-cn</language>

<item>
    <title>Docker 部署人大金仓 KingbaseES：license、数据目录和启动排障</title>
    <link>https://binliyou.com/?post=13</link>
    <description><![CDATA[<p>人大金仓（KingbaseES）的容器部署和 MySQL、Redis 那类镜像不太一样。镜像由厂商提供，里面带着授权文件，启动校验严格，出问题时日志又经常误导人。这篇把部署、license 挂载和&quot;库起不来&quot;的排查顺序讲清楚，路径以厂商镜像的实际布局为准。</p>
<h2>一、镜像</h2>
<p>金仓的镜像不挂在公共仓库，来源一般是两个：</p>
<ul>
<li>厂商给的镜像 tar 包，<code>docker load -i kingbase-v8r6.tar</code> 导入</li>
<li>内部私有仓库，<code>docker pull &lt;仓库地址&gt;/kingbase:v8r6</code></li>
</ul>
<p>导入后确认：</p>
<pre><code class="language-bash">docker images | grep -i kingbase</code></pre>
<p>镜像名和 tag 按实际导入的写，后面的 compose 里统一用 <code>kingbase:v8r6</code> 举例。</p>
<h2>二、目录规划</h2>
<pre><code class="language-bash">sudo mkdir -p /data/kingbase/{data,license}</code></pre>
<table>
<thead>
<tr>
<th>宿主机路径</th>
<th>容器内路径</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/data/kingbase/data</code></td>
<td>数据目录</td>
<td>必须挂出来，容器重建不丢库</td>
</tr>
<tr>
<td><code>/data/kingbase/license/license.dat</code></td>
<td><code>.../bin/license.dat</code></td>
<td>授权文件，建议单独挂载</td>
</tr>
</tbody>
</table>
<p>容器内的安装目录不是统一的，先看一眼实际布局再写挂载路径：</p>
<pre><code class="language-bash">docker run --rm --entrypoint ls kingbase:v8r6 /home/kingbase/install/kingbase</code></pre>
<p>常见的几处：</p>
<ul>
<li>安装目录：<code>/home/kingbase/install/kingbase</code></li>
<li>授权文件：<code>/home/kingbase/install/kingbase/bin/license.dat</code></li>
<li>服务端程序：同目录下的 <code>bin/kingbase</code>、<code>bin/sys_ctl</code>、<code>bin/ksql</code></li>
<li>数据目录：由镜像或 entrypoint 决定，通常在 <code>kingbase</code> 用户主目录下</li>
</ul>
<p>数据目录的属主要和容器里的 kingbase 用户对上：</p>
<pre><code class="language-bash">docker run --rm --entrypoint id kingbase:v8r6 kingbase
sudo chown -R &lt;输出的uid&gt;:&lt;输出的gid&gt; /data/kingbase/data</code></pre>
<h2>三、启动</h2>
<pre><code class="language-yaml">services:
  kingbase:
    image: kingbase:v8r6
    container_name: kingbase8
    restart: unless-stopped
    privileged: true
    environment:
      TZ: Asia/Shanghai
      DB_MODE: mysql
    ports:
      - "54321:54321"
    volumes:
      - /data/kingbase/data:/home/kingbase/data
      - /data/kingbase/license/license.dat:/home/kingbase/install/kingbase/bin/license.dat
    shm_size: 1gb</code></pre>
<p>几点说明：</p>
<ol>
<li><code>DB_MODE</code> 决定兼容模式（Oracle / MySQL / PG），建库前定好，后面改不了。用 MySQL 兼容模式就写 <code>mysql</code>。</li>
<li><code>privileged</code> 和 <code>command: /usr/sbin/init</code>：有的厂商镜像把完整系统打包进来，用 systemd 启动，这种镜像必须开特权模式。如果镜像是单进程启动的，这两项可以去掉，以 <code>docker inspect</code> 看到的 entrypoint 为准。</li>
<li><code>shm_size</code> 默认只有 64 MB，跑并发查询时容易报共享内存不足，给到 1 GB 比较稳。</li>
<li>端口映射写成 <code>127.0.0.1:54321:54321</code> 更安全，只给本机或反向代理访问。</li>
</ol>
<h2>四、license 的三个坑</h2>
<p>金仓只在启动时校验授权，运行中的实例不会因为到期被踢出去，但下一次重启就起不来。这是它和普通数据库最不一样的地方。</p>
<p><strong>坑一：license 在容器可写层里</strong></p>
<p>不挂卷的时候，license 的实体在容器内的安装目录下。<code>docker restart</code> 能保住，<code>docker rm</code> 之后重建，就退回镜像里那份旧证。镜像里那份往往是打包时的临时授权，可能早就过期了。</p>
<p>所以 license 要单独挂出来：</p>
<pre><code class="language-yaml">    volumes:
      - /data/kingbase/license/license.dat:/home/kingbase/install/kingbase/bin/license.dat</code></pre>
<p><strong>坑二：挂载不能加 <code>:ro</code></strong></p>
<p>这个镜像的 entrypoint 启动时会对安装目录做 <code>chown</code>。挂载点设成只读，chown 直接失败：</p>
<pre><code>chown: changing ownership of '/home/kingbase/install/kingbase/bin/license.dat': Read-only file system</code></pre>
<p>随后服务起不来，报 <code>sys_ctl: could not start server</code>。所以 license 的挂载必须是可写的。</p>
<p><strong>坑三：宿主机文件要先存在</strong></p>
<p>如果宿主机上 <code>license.dat</code> 不存在，Docker 会把挂载点创建成一个<strong>目录</strong>，容器里的金仓读到目录，报错形态和&quot;授权无效&quot;完全不一样，很容易查错方向。</p>
<p>顺序必须是：</p>
<pre><code class="language-bash"># 1. 先放文件
sudo cp license_xxx.dat /data/kingbase/license/license.dat
# 2. 改属主，和容器内 kingbase 用户一致
sudo chown &lt;uid&gt;:&lt;gid&gt; /data/kingbase/license/license.dat
chmod 640 /data/kingbase/license/license.dat
# 3. 再改 compose 挂载</code></pre>
<p>改完挂载启动，容器内 entrypoint 那次 chown 会真的改掉宿主机文件的属主，这是 bind mount 共享 inode 的正常结果，不是事故。</p>
<h2>五、初始化</h2>
<p>多数厂商镜像的 entrypoint 会在数据目录为空时自动 <code>initdb</code>，已经有数据就跳过。手工初始化也可以，用 kingbase 用户执行：</p>
<pre><code class="language-bash">docker exec -u kingbase kingbase8 \
  /home/kingbase/install/kingbase/bin/initdb \
  -D /home/kingbase/data -U SYSTEM --pwfile=/tmp/pw</code></pre>
<p><code>-U</code> 指定超级用户，后面所有连接都用这个用户名。<code>--pwfile</code> 里放初始密码，不要用 <code>-W</code> 交互输入，容器里没有终端。</p>
<p>注意 <code>initdb</code> 的字符集和兼容模式要和 <code>DB_MODE</code> 一致，建库之后再改，等于重建。</p>
<h2>六、验证库到底起没起</h2>
<p>这一步最容易被误导。<code>sys_ctl</code> 在 license 校验之前就把 pid 文件写出来了，所以日志里出现 <code>server started</code>，不代表库活着。判定存活只看这三条：</p>
<pre><code class="language-bash"># 1. 进程状态，退出码为 0 才算
docker exec -u kingbase kingbase8 \
  /home/kingbase/install/kingbase/bin/sys_ctl status -D /home/kingbase/data

# 2. 端口在监听。这个镜像里可能没有 ss/netstat，直接看 /proc
docker exec kingbase8 grep -i ':D431' /proc/net/tcp /proc/net/tcp6

# 3. 日志里出现 ready 字样
docker exec kingbase8 tail -50 /home/kingbase/data/sys_log/*.log</code></pre>
<p><code>54321</code> 的十六进制是 <code>0xD431</code>，这也是为什么要在 <code>/proc/net/tcp</code> 里搜这个字符串。容器里没有 <code>ss</code> 的时候，<code>docker exec kingbase8 ss -lntp</code> 会静默无输出，不能当成&quot;端口没监听&quot;。</p>
<p>三条都过了，再连库：</p>
<pre><code class="language-bash">docker exec -u kingbase kingbase8 \
  ksql -U SYSTEM -p 54321 -d test -c "select version();"</code></pre>
<p>镜像的 entrypoint 在 initdb 时会把本地连接认证改成 <code>trust</code>，容器内连库不需要密码。外网连接受 <code>kingbase.conf</code> 里的 <code>listen_addresses</code> 和 <code>sys_hba.conf</code> 控制，默认只允许本地。</p>
<h2>七、换 license 的标准动作</h2>
<p>授权到期后，启动日志是这样的：</p>
<pre><code>FATAL: XX000: License file expired.
LOCATION: KesMasterMain, master.c:961</code></pre>
<p>换证按顺序做，少一步就会遇到&quot;换了还是起不来&quot;：</p>
<pre><code class="language-bash"># 1. 备份旧证和当前配置
sudo cp -a /data/kingbase/license/license.dat \
  /data/kingbase/license/license.dat.bak-$(date +%F)

# 2. 覆盖新证，注意保住 inode 和属主
sudo cat new_license.dat &gt; /data/kingbase/license/license.dat
sudo chown &lt;uid&gt;:&lt;gid&gt; /data/kingbase/license/license.dat

# 3. 清掉上次启动失败留下的 socket 和 pid 残留
docker exec kingbase8 sh -c '
  ts=$(date +%s)
  mkdir -p /tmp/kingbase-stale-$ts
  mv /tmp/.s.KINGBASE.54321* /tmp/kingbase-stale-$ts/ 2&gt;/dev/null || true'

# 4. 用 kingbase 用户启动，root 会被拒绝
docker exec -u kingbase kingbase8 \
  /home/kingbase/install/kingbase/bin/sys_ctl start -D /home/kingbase/data</code></pre>
<p>用 <code>cat &gt;</code> 就地覆盖而不是删除重写，是为了保住文件 inode，已经挂载的路径不用重建容器。<code>/tmp/.s.KINGBASE.54321</code> 和对应的 <code>.lock</code> 文件是上次启动失败留下的，不清掉会报：</p>
<pre><code>another server might be running; trying to start server anyway
lock file "/tmp/.s.KINGBASE.54321.lock" already exists</code></pre>
<p>残留文件用 <code>mv</code> 挪走而不是 <code>rm</code>，出问题时还能看一眼时间戳，判断上一次启动尝试发生在什么时候。</p>
<p>还有一条：镜像里通常挂着一个 cron 看门狗，每分钟尝试拉起数据库。手工启动时如果撞上它，会看到 <code>another server is running</code>，等一分钟再试，或者先停掉看门狗。</p>
<h2>八、备份</h2>
<p>逻辑备份用 <code>sys_dump</code>，参数和 PostgreSQL 的 <code>pg_dump</code> 基本一致：</p>
<pre><code class="language-bash">docker exec -u kingbase kingbase8 \
  sys_dump -U SYSTEM -p 54321 -d app -F c -f /tmp/app.dump
docker cp kingbase8:/tmp/app.dump /backup/app-$(date +%F).dump</code></pre>
<p>恢复：</p>
<pre><code class="language-bash">docker cp /backup/app-2026-10-29.dump kingbase8:/tmp/app.dump
docker exec -u kingbase kingbase8 \
  sys_restore -U SYSTEM -p 54321 -d app /tmp/app.dump</code></pre>
<p>数据库本身可以直接停库后复制数据目录，但和 MySQL 一样，热复制没有意义。</p>
<h2>九、常见报错</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FATAL: XX000: License file expired</code></td>
<td>授权到期</td>
<td>按第七节换证</td>
</tr>
<tr>
<td><code>sys_ctl: could not start server</code></td>
<td>原因很多，先看 <code>sys_log</code> 最后 50 行</td>
<td>优先查 license 和 socket 残留</td>
</tr>
<tr>
<td><code>another server might be running</code> / <code>lock file ... already exists</code></td>
<td>上次启动失败留下的 socket 和 pid</td>
<td>清 <code>/tmp/.s.KINGBASE.54321*</code></td>
</tr>
<tr>
<td><code>WARNING: max_connections should be less than or equal than N (restricted by license)</code></td>
<td>授权带连接数上限，配置被压到上限值</td>
<td>核对应用连接池，别超上限</td>
</tr>
<tr>
<td><code>chown: ... Read-only file system</code></td>
<td>license 挂载加了 <code>:ro</code></td>
<td>去掉只读</td>
</tr>
<tr>
<td><code>Is a directory</code> 指向 license 路径</td>
<td>宿主机文件不存在，Docker 建了目录</td>
<td>先放文件再改 compose</td>
</tr>
<tr>
<td><code>Permission denied</code> 写数据目录</td>
<td>目录属主和容器内 kingbase 用户不一致</td>
<td><code>chown</code> 成对应 uid</td>
</tr>
<tr>
<td><code>could not create shared memory segment</code></td>
<td>容器 <code>/dev/shm</code> 太小</td>
<td>加大 <code>shm_size</code></td>
</tr>
<tr>
<td>进程在，端口连不上</td>
<td>监听地址、防火墙、端口映射</td>
<td>容器内看 <code>/proc/net/tcp</code>，宿主机看 <code>ss -lntp</code></td>
</tr>
<tr>
<td><code>docker logs</code> 里 <code>server started</code> 但库连不上</td>
<td><code>sys_ctl</code> 在 license 校验前就报成功</td>
<td>用第六节的三条标准重新判断</td>
</tr>
</tbody>
</table>
<h2>十、限制</h2>
<ul>
<li>授权是硬中断：到期不影响运行中的实例，但任何一次重启、宿主机重启、容器重建都会当场起不来。到期日要单独记，别等出事再查。</li>
<li>授权可能带并发连接数上限，配置里写再大也会被压下来，这个值只能问发证方。</li>
<li>容器重建会退回镜像内的旧授权，所以 license 必须挂卷，且换证后要检查挂载是否还在。</li>
<li>单实例部署没有高可用，做主备是另一套配置（<code>sys_hba.conf</code>、复制槽、<code>recovery.conf</code> 这一套），不在容器化本身的范围里。</li>
</ul>]]></description>
    <pubDate>Fri, 30 Oct 2026 14:16:09 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=13</guid>
</item>
<item>
    <title>Docker 部署 MySQL 8.0：初始化参数、字符集和几个必踩的坑</title>
    <link>https://binliyou.com/?post=12</link>
    <description><![CDATA[<p>MySQL 容器化的坑集中在&quot;初始化只做一次&quot;这件事上：密码、库、字符集、大小写敏感，全是在数据目录为空的那一次初始化里定下来的，后面改环境变量、改配置文件都不会回头补。搞清这一点，剩下的就是挂卷、权限和备份。</p>
<h2>一、镜像和目录</h2>
<pre><code class="language-bash">docker pull mysql:8.0
sudo mkdir -p /data/mysql/{data,conf,init}</code></pre>
<table>
<thead>
<tr>
<th>宿主机目录</th>
<th>容器内路径</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/data/mysql/data</code></td>
<td><code>/var/lib/mysql</code></td>
<td>数据文件</td>
</tr>
<tr>
<td><code>/data/mysql/conf</code></td>
<td><code>/etc/mysql/conf.d</code></td>
<td>自定义配置</td>
</tr>
<tr>
<td><code>/data/mysql/init</code></td>
<td><code>/docker-entrypoint-initdb.d</code></td>
<td>首次初始化执行的 SQL</td>
</tr>
</tbody>
</table>
<p><code>/data/mysql/data</code> 要给容器里的 mysql 用户（uid 999）权限：</p>
<pre><code class="language-bash">sudo chown -R 999:999 /data/mysql/data</code></pre>
<p>如果数据目录挂的是新格式化的独立分区，注意分区根目录里会有 <code>lost+found</code>。MySQL 初始化时要求目录为空，看到 <code>lost+found</code> 就认为&quot;目录里有文件&quot;，直接放弃初始化。这种情况用分区下的子目录，比如 <code>/data/mysql/data</code>，而不是直接把分区挂到 <code>/var/lib/mysql</code>。</p>
<h2>二、my.cnf</h2>
<p><code>/data/mysql/conf/my.cnf</code>：</p>
<pre><code class="language-ini">[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_0900_ai_ci
lower_case_table_names = 1
default-time-zone = '+08:00'

max_connections = 500
innodb_buffer_pool_size = 1G

slow_query_log = 1
long_query_time = 1</code></pre>
<p>几个参数说明：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>character-set-server</code></td>
<td>默认字符集，8.0 默认就是 utf8mb4，写出来是为了明确</td>
</tr>
<tr>
<td><code>collation-server</code></td>
<td>8.0 的默认排序规则，从 5.7 迁过来的库要留意差异</td>
</tr>
<tr>
<td><code>lower_case_table_names</code></td>
<td>表名大小写是否敏感，只在初始化时生效</td>
</tr>
<tr>
<td><code>default-time-zone</code></td>
<td>容器时区是 UTC，业务按东八区存时间必须设</td>
</tr>
<tr>
<td><code>innodb_buffer_pool_size</code></td>
<td>通常给物理内存的 50% 到 70%，容器内存限制下别超过上限</td>
</tr>
</tbody>
</table>
<p>配置文件权限有要求：<strong>MySQL 会忽略全局可写的配置文件</strong>。挂进去之后再确认一次：</p>
<pre><code class="language-bash">sudo chmod 644 /data/mysql/conf/my.cnf</code></pre>
<p>日志里出现 <code>World-writable config file '/etc/mysql/conf.d/my.cnf' is ignored</code>，就是权限太松，配置压根没被读。</p>
<h2>三、compose</h2>
<pre><code class="language-yaml">services:
  mysql:
    image: mysql:8.0
    container_name: mysql
    restart: always
    ports:
      - "3306:3306"
    environment:
      TZ: Asia/Shanghai
      MYSQL_ROOT_PASSWORD: 换成强密码
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD: 换成强密码
    volumes:
      - /data/mysql/data:/var/lib/mysql
      - /data/mysql/conf:/etc/mysql/conf.d:ro
      - /data/mysql/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "mysql -uroot -p\"$$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1' &gt;/dev/null 2&gt;&amp;1"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s</code></pre>
<p>四个环境变量的实际行为：</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>生效条件</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MYSQL_ROOT_PASSWORD</code></td>
<td>只能二选一：不设它就得设 <code>MYSQL_RANDOM_ROOT_PASSWORD</code>，否则容器拒绝启动</td>
</tr>
<tr>
<td><code>MYSQL_DATABASE</code></td>
<td>仅在数据目录为空时建库</td>
</tr>
<tr>
<td><code>MYSQL_USER</code> / <code>MYSQL_PASSWORD</code></td>
<td>仅在初始化时建用户并授权到 <code>MYSQL_DATABASE</code></td>
</tr>
<tr>
<td>四个变量整体</td>
<td>数据目录非空时全部忽略，改了不起任何作用</td>
</tr>
</tbody>
</table>
<p>healthcheck 这里用的是 <code>mysql -e 'SELECT 1'</code>，不是 <code>mysqladmin ping</code>。<code>mysqladmin ping</code> 只要服务端能响应就返回成功，密码错了也照样&quot;通&quot;，检测不出认证问题。</p>
<p><code>start_period</code> 给 60 秒是有必要的，首次初始化要建系统表，磁盘慢的机器上跑一两分钟很正常，这段时间里的失败不该算进 <code>retries</code>。</p>
<h2>四、初始化脚本</h2>
<p><code>/data/mysql/init/</code> 下的 <code>.sql</code>、<code>.sh</code> 文件只在<strong>数据目录为空</strong>时按字母顺序执行一次。常见的用法是建库、建只读账号：</p>
<pre><code class="language-sql">CREATE DATABASE IF NOT EXISTS app DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER IF NOT EXISTS 'readonly'@'%' IDENTIFIED BY '只读密码';
GRANT SELECT ON app.* TO 'readonly'@'%';
FLUSH PRIVILEGES;</code></pre>
<p>放进去之后想让它再跑一遍，只能删数据目录重建，没有别的办法。所以初始化脚本要么一次写全，要么后面手工执行 SQL，不要指望&quot;补一个文件进去重启就好&quot;。</p>
<h2>五、验证</h2>
<pre><code class="language-bash">docker ps --filter name=mysql
docker exec mysql mysql -uroot -p'密码' -e "SELECT VERSION(), @@character_set_server, @@lower_case_table_names, @@time_zone;"
docker exec mysql mysql -uroot -p'密码' -e "SHOW DATABASES;"</code></pre>
<p>四个值对上了，说明配置文件和初始化参数都生效：</p>
<pre><code>8.0.x   utf8mb4   1   +08:00</code></pre>
<p>再从外部连一次，确认端口映射和账号授权：</p>
<pre><code class="language-bash">mysql -h 127.0.0.1 -P 3306 -uapp -p'密码' app</code></pre>
<h2>六、备份和恢复</h2>
<p>逻辑备份，适合中小库和跨版本迁移：</p>
<pre><code class="language-bash">docker exec mysql sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" \
  --single-transaction --routines --triggers --events --set-gtid-purged=OFF app' \
  &gt; /backup/app-$(date +%F).sql</code></pre>
<p><code>--single-transaction</code> 让 InnoDB 在一致性快照里导出，不锁表；<code>--set-gtid-purged=OFF</code> 避免恢复时报 GTID 冲突。</p>
<p>恢复：</p>
<pre><code class="language-bash">docker exec -i mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" app' &lt; /backup/app-2026-10-29.sql</code></pre>
<p>物理备份可以直接复制 <code>/data/mysql/data</code>，但必须在停库之后：</p>
<pre><code class="language-bash">docker compose stop mysql
sudo tar -czf /backup/mysql-data-$(date +%F).tar.gz -C /data/mysql data
docker compose start mysql</code></pre>
<p>热复制数据目录得到的文件不可用，InnoDB 的 redo 和双写缓冲在运行中不一致，恢复时会报错。</p>
<h2>七、常见报错</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Access denied for user 'root'@'localhost'</code></td>
<td>数据目录已存在，初始化变量被忽略，密码还是旧的</td>
<td>用旧密码进，或 <code>--skip-grant-tables</code> 重置</td>
</tr>
<tr>
<td><code>[ERROR] --initialize specified but the data directory has files in it</code></td>
<td>挂载目录非空，常见是 <code>lost+found</code></td>
<td>换个空子目录</td>
</tr>
<tr>
<td>容器反复重启，日志 <code>Different lower_case_table_names settings for server ('1') and data dictionary ('0')</code></td>
<td>初始化后改了 <code>lower_case_table_names</code></td>
<td>这个值初始化后不能改，只能重建库</td>
</tr>
<tr>
<td><code>Authentication plugin 'caching_sha2_password' cannot be loaded</code></td>
<td>老客户端不认 8.0 的默认认证插件</td>
<td>升级驱动，或 <code>ALTER USER ... IDENTIFIED WITH mysql_native_password BY ...</code></td>
</tr>
<tr>
<td><code>World-writable config file ... is ignored</code></td>
<td>配置文件权限太松</td>
<td><code>chmod 644</code></td>
</tr>
<tr>
<td><code>mysqld: Can't read dir of '/etc/mysql/conf.d/' (Errcode: 13)</code></td>
<td>目录权限或 SELinux</td>
<td>修权限，CentOS 上挂载加 <code>:z</code></td>
</tr>
<tr>
<td>容器被 OOMKilled</td>
<td><code>innodb_buffer_pool_size</code> 超过容器内存上限</td>
<td>调小 buffer pool，或提高内存限制</td>
</tr>
<tr>
<td>启动日志 <code>Permission denied</code> 指向 <code>/var/lib/mysql</code></td>
<td>数据目录属主不是 999</td>
<td><code>chown -R 999:999</code></td>
</tr>
<tr>
<td><code>Can't connect to local MySQL server through socket</code></td>
<td>服务没起来，还是初始化中</td>
<td><code>docker logs mysql</code> 看最后 50 行</td>
</tr>
</tbody>
</table>
<h2>八、8.4 和迁移</h2>
<p>如果准备用 8.4，先确认客户端兼容性：<code>mysql_native_password</code> 在 8.4 里默认不加载，老版本驱动和工具连上来会失败。新项目直接用 8.4 没问题，存量系统升级前要在测试环境把驱动、中间件、备份脚本都过一遍。</p>
<p>跨大版本迁移（5.7 → 8.0）不要靠复制数据目录，走 <code>mysqldump</code> 导出再导入，或者用官方的 <code>mysqlsh</code> 升级检查工具，先把不兼容项列出来。</p>
<p>最后一条限制：容器里的 MySQL 性能和裸机没有本质区别，瓶颈仍然是磁盘 IO 和内存。把数据目录放在网络存储（NFS、CIFS）上跑 MySQL 属于高风险操作，遇到 <code>fsync</code> 语义不一致会直接损坏数据，这一点和容器无关，但容器化之后更容易被顺手挂上去。</p>]]></description>
    <pubDate>Fri, 30 Oct 2026 14:15:41 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=12</guid>
</item>
<item>
    <title>Docker 部署 MinIO：单机对象存储和常用运维命令</title>
    <link>https://binliyou.com/?post=11</link>
    <description><![CDATA[<p>MinIO 单机部署很轻，一个容器、两个端口、一个数据目录就够。麻烦的地方在于控制台和 API 是两套端口、改账号要重建容器、以及反向代理下的上传和 WebSocket。下面按单机单盘模式讲，最后说清它的边界。</p>
<h2>一、镜像、端口和目录</h2>
<pre><code class="language-bash">docker pull minio/minio:latest
sudo mkdir -p /data/minio/data</code></pre>
<table>
<thead>
<tr>
<th>端口</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>9000</td>
<td>S3 API，所有 SDK、<code>mc</code>、后端程序连这个</td>
</tr>
<tr>
<td>9001</td>
<td>管理控制台，浏览器访问</td>
</tr>
</tbody>
</table>
<p>正式环境不要用 <code>latest</code>，把 tag 固定到具体的 RELEASE 版本，升级时才有据可查。</p>
<h2>二、启动</h2>
<pre><code class="language-bash">docker run -d --name minio \
  -p 9000:9000 -p 9001:9001 \
  -v /data/minio/data:/data \
  -e MINIO_ROOT_USER=admin \
  -e MINIO_ROOT_PASSWORD='至少八位的密码' \
  -e TZ=Asia/Shanghai \
  --restart=always \
  minio/minio:latest server /data --console-address ":9001"</code></pre>
<p>三个参数不能少：</p>
<ol>
<li><code>server /data</code>：告诉 MinIO 数据目录在哪，容器里就是 <code>/data</code>，必须挂出来。</li>
<li><code>--console-address ":9001"</code>：不给的话控制台端口是随机的，<code>docker ps</code> 里还要再查一次。</li>
<li><code>MINIO_ROOT_USER</code> / <code>MINIO_ROOT_PASSWORD</code>：不设的话会用默认的 <code>minioadmin</code> / <code>minioadmin</code>，这是公认的默认口令，必须改。用户名至少 3 位，密码至少 8 位，不满足时容器启动即退出。</li>
</ol>
<p>老资料里的 <code>MINIO_ACCESS_KEY</code> / <code>MINIO_SECRET_KEY</code> 已经废弃，写了也不生效，直接用 <code>MINIO_ROOT_*</code>。</p>
<h2>三、compose 版本</h2>
<pre><code class="language-yaml">services:
  minio:
    image: minio/minio:latest
    container_name: minio
    restart: always
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      TZ: Asia/Shanghai
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: 至少八位的密码
    volumes:
      - /data/minio/data:/data
    command: server /data --console-address ":9001"
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 30s
      timeout: 10s
      retries: 3</code></pre>
<p>密码写在 compose 里等于写在文件里，权限收到 <code>600</code>，或者用 <code>env_file</code> 单独放：</p>
<pre><code class="language-bash">sudo chmod 600 /data/minio/docker-compose.yml</code></pre>
<h2>四、验证</h2>
<p>容器起来后，先看健康检查接口，这个不需要认证：</p>
<pre><code class="language-bash">curl -i http://127.0.0.1:9000/minio/health/live</code></pre>
<p>返回 <code>200 OK</code> 说明服务在跑。然后进控制台 <code>http://服务器IP:9001</code>，用上面设置的用户名密码登录，能建桶就说明读写正常。</p>
<p>再看一眼数据目录，首次启动后 <code>/data/minio/data</code> 下会出现 <code>.minio.sys</code>：</p>
<pre><code class="language-bash">ls -la /data/minio/data</code></pre>
<p>如果这里报权限问题，先确认容器内进程的 uid：</p>
<pre><code class="language-bash">docker exec minio id
sudo chown -R &lt;uid&gt;:&lt;gid&gt; /data/minio/data</code></pre>
<h2>五、用 mc 管理</h2>
<p><code>mc</code> 是 MinIO 的客户端，单文件二进制，和 Docker 无关，装在宿主机或运维机上：</p>
<pre><code class="language-bash">mc alias set local http://127.0.0.1:9000 admin '密码'
mc admin info local</code></pre>
<p>常用的几条：</p>
<pre><code class="language-bash">mc mb local/backup                 # 建桶
mc ls local                        # 列桶
mc ls local/backup                 # 列对象
mc cp app.tar.gz local/backup/     # 上传
mc cp local/backup/app.tar.gz ./   # 下载
mc rm local/backup/old.tar.gz      # 删除
mc mirror ./dist local/static      # 目录同步，增量
mc du local/backup                 # 统计用量</code></pre>
<p><code>mc mirror</code> 是最实用的一个，可以把它当成对象存储版的 rsync，做目录发布和备份都行。加 <code>--remove</code> 会删掉目标端多余的文件，用之前确认方向别写反。</p>
<h2>六、反向代理</h2>
<p>API 和控制在同一个域名下，用路径区分：</p>
<pre><code class="language-nginx">server {
    listen 443 ssl;
    server_name s3.example.com;

    client_max_body_size 0;
    proxy_request_buffering off;
    proxy_buffering off;

    location / {
        proxy_pass http://127.0.0.1:9000;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /minio/ {
        proxy_pass http://127.0.0.1:9001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
    }
}</code></pre>
<p><code>client_max_body_size 0</code> 是必须的：MinIO 上传大对象时走的是分片，但单个分片也可能超过 Nginx 默认的 1 MB，不放开就会遇到 413。<code>proxy_buffering off</code> 是为了避免大文件先落到 Nginx 的临时目录。</p>
<p>控制台那一段必须带 <code>Upgrade</code> 和 <code>Connection</code> 头，否则页面能打开但登录后一直转圈，因为控制台的 WebSocket 连不上。</p>
<p>反代之后还要告诉 MinIO 外部访问地址，否则登录后跳转和预签名 URL 会指向内网地址：</p>
<pre><code class="language-yaml">    environment:
      MINIO_SERVER_URL: https://s3.example.com
      MINIO_BROWSER_REDIRECT_URL: https://s3.example.com/minio/</code></pre>
<h2>七、备份</h2>
<p>MinIO 的数据是对象文件，直接 <code>cp</code> 数据目录并不可靠，尤其在有写入的时候。用客户端做：</p>
<pre><code class="language-bash">mc mirror --overwrite local/important /backup/minio/important</code></pre>
<p>或者用 <code>mc admin</code> 的导出功能搬全量，数据量大的场景更推荐在存储层做快照，或者上分布式多副本。</p>
<h2>八、常见报错</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>启动即退出，日志 <code>Unable to initialize backend: Drive ... is not a directory</code></td>
<td>挂载路径不对，<code>/data</code> 挂成了文件</td>
<td>检查 <code>-v</code> 前后路径顺序</td>
</tr>
<tr>
<td><code>MinIO root password length should be at least 8</code></td>
<td>密码太短</td>
<td>换 8 位以上</td>
</tr>
<tr>
<td>控制台提示用户名或密码错误，但配置看着没问题</td>
<td>改环境变量后只 <code>restart</code>，容器还是旧配置</td>
<td><code>docker compose up -d --force-recreate</code></td>
</tr>
<tr>
<td>上传大文件报 413</td>
<td>Nginx 的 <code>client_max_body_size</code> 限制</td>
<td>设为 <code>0</code></td>
</tr>
<tr>
<td>控制台能打开，登录后空白或一直加载</td>
<td>反代少了 WebSocket 的 <code>Upgrade</code> 头</td>
<td>补 <code>Upgrade</code> / <code>Connection</code> 头</td>
</tr>
<tr>
<td>后端报 <code>RequestTimeTooSkewed</code></td>
<td>客户端和服务器时间差太大</td>
<td>两边都开 NTP 同步</td>
</tr>
<tr>
<td>SDK 连 9000 报 <code>Access Denied</code></td>
<td>桶策略没开，或密钥不对</td>
<td>控制台配 policy，核对 access key</td>
</tr>
<tr>
<td>预签名 URL 打不开，地址是内网 IP</td>
<td>没设 <code>MINIO_SERVER_URL</code></td>
<td>配成对外域名</td>
</tr>
<tr>
<td>重启后桶没了</td>
<td><code>/data</code> 没挂出来，数据写进容器层</td>
<td>检查卷，容器层数据随删除丢失</td>
</tr>
</tbody>
</table>
<h2>九、边界</h2>
<p>单机单盘模式没有冗余，磁盘坏了数据就没了，也没有纠删码。MinIO 的分布式模式至少需要 4 块盘（4 个目录也行），部署形态和多副本策略是另一套东西，单机跑起来之后再往上加节点并不平滑。</p>
<p>另外，MinIO 不是文件服务器，它存的是对象。需要像 Windows 共享目录那样挂载到本地盘的场景，它不合适，得配 S3 网关或者换别的方案。</p>
<p>如果只是给应用做附件、备份包、镜像层这类存储，单机模式配好备份就足够用了。</p>]]></description>
    <pubDate>Fri, 30 Oct 2026 14:15:34 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=11</guid>
</item>
<item>
    <title>Docker 部署 Redis：密码、持久化和启动失败排查</title>
    <link>https://binliyou.com/?post=10</link>
    <description><![CDATA[<p>Redis 容器化最容易出的问题不是装不上，而是装上了没密码、重启丢数据、或者配置根本没被加载。这篇按顺序把这几件事定下来：密码、持久化、内存上限，以及出错时先看哪里。</p>
<h2>一、镜像和目录</h2>
<pre><code class="language-bash">docker pull redis:7.2
sudo mkdir -p /data/redis/{conf,data}</code></pre>
<p>官方镜像默认以 <code>redis</code> 用户（uid 999）运行，<code>/data</code> 要能让它写：</p>
<pre><code class="language-bash">sudo chown -R 999:999 /data/redis/data</code></pre>
<p>宿主机目录属主不改成 999，后面要么存 RDB 失败，要么直接起不来。</p>
<h2>二、先起一个空壳验证</h2>
<p>先在没有任何配置的情况下跑一遍，确认镜像和端口没问题：</p>
<pre><code class="language-bash">docker run -d --name redis-test -p 6379:6379 redis:7.2
docker exec redis-test redis-cli ping</code></pre>
<p>返回 <code>PONG</code> 就说明镜像正常。验证完删掉，正式部署要带配置文件和密码：</p>
<pre><code class="language-bash">docker rm -f redis-test</code></pre>
<h2>三、redis.conf</h2>
<p><code>/data/redis/conf/redis.conf</code>：</p>
<pre><code class="language-conf">bind 0.0.0.0
protected-mode yes
port 6379
timeout 0
tcp-keepalive 300

requirepass 换成你的强密码

daemonize no
loglevel notice
logfile ""

dir /data

# RDB
save 900 1
save 300 10
save 60 10000
rdbcompression yes
dbfilename dump.rdb

# AOF
appendonly yes
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 内存
maxmemory 512mb
maxmemory-policy allkeys-lru</code></pre>
<p>几个参数的作用：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>bind 0.0.0.0</code></td>
<td>容器内监听所有地址，宿主机能否访问由 <code>-p</code> 决定</td>
</tr>
<tr>
<td><code>protected-mode yes</code></td>
<td>没密码时拒绝外部连接，有密码时保留也没问题</td>
</tr>
<tr>
<td><code>daemonize no</code></td>
<td>容器里必须前台运行，写 <code>yes</code> 会启动即退出</td>
</tr>
<tr>
<td><code>dir /data</code></td>
<td>RDB 和 AOF 都写这里，必须挂卷</td>
</tr>
<tr>
<td><code>appendonly yes</code></td>
<td>开 AOF，重启不丢最近写入</td>
</tr>
<tr>
<td><code>maxmemory</code> + <code>policy</code></td>
<td>防止把宿主机内存吃光</td>
</tr>
</tbody>
</table>
<p><code>requirepass</code> 写在配置文件里，不要写在 <code>docker run</code> 的 <code>--requirepass</code> 参数上，否则 <code>docker inspect</code> 和 <code>ps</code> 里都能直接看到。</p>
<h2>四、docker run 或 compose</h2>
<p><code>docker run</code> 版本：</p>
<pre><code class="language-bash">docker run -d --name redis \
  -p 6379:6379 \
  -v /data/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf:ro \
  -v /data/redis/data:/data \
  -e TZ=Asia/Shanghai \
  --restart=always \
  redis:7.2 redis-server /usr/local/etc/redis/redis.conf</code></pre>
<p>compose 版本：</p>
<pre><code class="language-yaml">services:
  redis:
    image: redis:7.2
    container_name: redis
    restart: always
    ports:
      - "6379:6379"
    environment:
      TZ: Asia/Shanghai
      REDIS_PASSWORD: 换成你的强密码
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
    volumes:
      - /data/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
      - /data/redis/data:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 30s
      timeout: 5s
      retries: 3</code></pre>
<p>两处说明：</p>
<ol>
<li>healthcheck 里的 <code>$$</code> 是 compose 的转义，最终传给容器的是 <code>$REDIS_PASSWORD</code>，由 shell 展开。写成单个 <code>$</code> 会被 compose 在解析阶段替换掉，密码就丢了。</li>
<li>配置文件里已经写了 <code>requirepass</code>，环境变量 <code>REDIS_PASSWORD</code> 只是为了给健康检查用，两边的值要一致，改密码时一起改。</li>
</ol>
<p><code>command</code> 一定要带配置文件路径。不带的话镜像会用自己的默认配置启动，<code>requirepass</code>、<code>appendonly</code> 全部不生效，看起来容器起得好好的，实际是个没有密码的空库。</p>
<h2>五、验证</h2>
<pre><code class="language-bash"># 不带密码访问，应该被拒绝
docker exec redis redis-cli ping
# NOAUTH Authentication required.

# 带密码
docker exec redis redis-cli --no-auth-warning -a "$(grep requirepass /data/redis/conf/redis.conf | awk '{print $2}')" ping

# 确认配置真的加载了
docker exec redis redis-cli --no-auth-warning -a '密码' CONFIG GET appendonly
docker exec redis redis-cli --no-auth-warning -a '密码' CONFIG GET maxmemory

# 确认持久化文件
ls -lh /data/redis/data</code></pre>
<p><code>CONFIG GET appendonly</code> 返回 <code>yes</code>，<code>/data/redis/data</code> 下能看到 <code>dump.rdb</code> 或 <code>appendonlydir</code>，才说明配置生效、持久化在工作。</p>
<h2>六、持久化和备份</h2>
<p>Redis 有两种持久化：</p>
<table>
<thead>
<tr>
<th>方式</th>
<th>原理</th>
<th>丢数据窗口</th>
<th>开销</th>
</tr>
</thead>
<tbody>
<tr>
<td>RDB</td>
<td>定时 fork 子进程写快照</td>
<td>两次快照之间</td>
<td>fork 时内存翻倍风险</td>
</tr>
<tr>
<td>AOF</td>
<td>追加写命令，<code>everysec</code> 每秒刷盘</td>
<td>最多 1 秒</td>
<td>磁盘 IO 和文件体积</td>
</tr>
</tbody>
</table>
<p>生产环境一般两个都开：AOF 保证少丢，RDB 方便搬走做备份。</p>
<p>备份：</p>
<pre><code class="language-bash"># 触发一次快照，然后复制文件
docker exec redis redis-cli --no-auth-warning -a '密码' BGSAVE
sleep 2
docker exec redis redis-cli --no-auth-warning -a '密码' LASTSAVE
sudo cp /data/redis/data/dump.rdb /backup/redis-$(date +%F).rdb</code></pre>
<p><code>BGSAVE</code> 是异步的，复制前用 <code>LASTSAVE</code> 确认时间戳已经更新。</p>
<p>不落盘直接拉一份也可以，适合数据量不大的场景：</p>
<pre><code class="language-bash">docker exec redis redis-cli --no-auth-warning -a '密码' --rdb /tmp/backup.rdb
docker cp redis:/tmp/backup.rdb ./backup.rdb</code></pre>
<p>恢复就是把 <code>dump.rdb</code> 放回 <code>/data</code>，重启容器。注意恢复前先停 Redis，否则可能被正在运行的实例覆盖。</p>
<h2>七、常见报错</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>NOAUTH Authentication required</code></td>
<td>客户端没带密码</td>
<td>客户端配置密码，或 <code>AUTH 密码</code></td>
</tr>
<tr>
<td><code>ERR Client sent AUTH, but no password is set</code></td>
<td>配置没加载，服务端还是无密码状态</td>
<td>检查 <code>command</code> 是否带了配置文件路径，重启容器</td>
</tr>
<tr>
<td>容器启动后立刻退出，日志只有一两行</td>
<td>conf 里写了 <code>daemonize yes</code></td>
<td>改成 <code>no</code></td>
</tr>
<tr>
<td><code>MISCONF Redis is configured to save RDB snapshots, but it is currently not able to persist on disk</code></td>
<td>磁盘满、目录没权限、或 overcommit 限制</td>
<td>看磁盘和 <code>/data</code> 属主，处理完执行 <code>CONFIG SET stop-writes-on-bgsave-error no</code> 只是临时放行</td>
</tr>
<tr>
<td>日志 <code>WARNING overcommit_memory is set to 0</code></td>
<td>内核内存策略，fork 快照可能失败</td>
<td>宿主机 <code>sysctl -w vm.overcommit_memory=1</code>，写进 <code>/etc/sysctl.d/</code> 持久化</td>
</tr>
<tr>
<td><code>WARNING The TCP backlog setting of 511 cannot be enforced</code></td>
<td><code>somaxconn</code> 太小</td>
<td><code>sysctl -w net.core.somaxconn=1024</code></td>
</tr>
<tr>
<td><code>Permission denied</code> 写 <code>dump.rdb</code></td>
<td><code>/data</code> 属主不是 999</td>
<td><code>chown -R 999:999 /data/redis/data</code></td>
</tr>
<tr>
<td><code>Bad directive or wrong number of arguments</code></td>
<td>conf 语法错，常见于行尾多余空格或复制时带了中文引号</td>
<td>按报错行号改</td>
</tr>
<tr>
<td>重启后数据全没了</td>
<td>没挂 <code>/data</code>，或者没开 AOF/RDB</td>
<td>挂卷并确认 <code>dir /data</code></td>
</tr>
<tr>
<td><code>READONLY You can't write against a read only replica</code></td>
<td>配置里残留 <code>replicaof</code></td>
<td>删掉该配置重启</td>
</tr>
</tbody>
</table>
<h2>八、几条限制</h2>
<ul>
<li>单机 Redis 没有高可用，主库挂了就是不可用。要主从或哨兵，是另一套部署，别指望 <code>restart: always</code> 顶上。</li>
<li><code>maxmemory-policy allkeys-lru</code> 会淘汰任何键，包括没设过期时间的。做缓存可以，当存储用要换成 <code>noeviction</code> 并密切盯内存。</li>
<li><code>appendfsync everysec</code> 是性能和数据安全之间的折中，要求零丢失得用 <code>always</code>，代价是每次写都等磁盘。</li>
</ul>
<p>访问控制上还有一条：<code>-p 6379:6379</code> 会把 Redis 直接暴露到宿主机所有网卡。只给内网用的服务，端口映射写成 <code>-p 127.0.0.1:6379:6379</code>，让外面连不进来。</p>]]></description>
    <pubDate>Fri, 30 Oct 2026 14:15:34 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=10</guid>
</item>
<item>
    <title>Docker 部署 Nginx：配置挂载、反向代理和日志</title>
    <link>https://binliyou.com/?post=9</link>
    <description><![CDATA[<p>Nginx 用 Docker 跑，镜像本身不用动，真正要定下来的是三件事：配置怎么挂、日志落到哪、容器和上游怎么互相找到。这三点定好了，后面加站点就是复制一个 conf 文件的事。</p>
<h2>一、镜像和目录</h2>
<p>用官方镜像，版本写死到小版本：</p>
<pre><code class="language-bash">docker pull nginx:1.27</code></pre>
<p>目录先建出来：</p>
<pre><code class="language-bash">sudo mkdir -p /data/nginx/{conf.d,html,certs,logs}</code></pre>
<table>
<thead>
<tr>
<th>宿主机目录</th>
<th>容器内路径</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/data/nginx/conf.d</code></td>
<td><code>/etc/nginx/conf.d</code></td>
<td>站点配置，官方镜像默认会加载这里的 <code>*.conf</code></td>
</tr>
<tr>
<td><code>/data/nginx/html</code></td>
<td><code>/usr/share/nginx/html</code></td>
<td>静态文件</td>
</tr>
<tr>
<td><code>/data/nginx/certs</code></td>
<td><code>/etc/nginx/certs</code></td>
<td>证书，自己约定的路径</td>
</tr>
<tr>
<td><code>/data/nginx/logs</code></td>
<td><code>/var/log/nginx</code></td>
<td>访问日志和错误日志</td>
</tr>
</tbody>
</table>
<h2>二、先用 docker run 起一个</h2>
<pre><code class="language-bash">docker run -d --name nginx \
  -p 80:80 -p 443:443 \
  -v /data/nginx/conf.d:/etc/nginx/conf.d:ro \
  -v /data/nginx/html:/usr/share/nginx/html:ro \
  -v /data/nginx/certs:/etc/nginx/certs:ro \
  -v /data/nginx/logs:/var/log/nginx \
  -e TZ=Asia/Shanghai \
  --restart=always \
  nginx:1.27</code></pre>
<p><code>conf.d</code>、<code>html</code>、<code>certs</code> 都是只读挂载，容器不需要往里写东西。<code>logs</code> 必须可写。</p>
<p>跑起来先看默认页：</p>
<pre><code class="language-bash">curl -I http://127.0.0.1/
docker exec nginx nginx -t</code></pre>
<p><code>nginx -t</code> 返回 <code>syntax is ok</code> 和 <code>test is successful</code> 两行才算配置能过，这个检查在改完配置后每次都要跑。</p>
<h2>三、反向代理</h2>
<p>在 <code>/data/nginx/conf.d/app.conf</code> 里写一个最简的反代：</p>
<pre><code class="language-nginx">upstream app_backend {
    server app:8080;
}

server {
    listen 80;
    server_name app.example.com;

    client_max_body_size 50m;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}</code></pre>
<p>两个地方容易忽略：</p>
<ol>
<li><code>upstream</code> 里写的 <code>app</code> 是容器名。Docker 内置 DNS 只在同一个自定义网络里解析容器名，Nginx 容器和 <code>app</code> 容器必须挂在同一个网络上，否则启动时报 <code>host not found in upstream "app"</code>，整个 Nginx 起不来。</li>
<li><code>proxy_set_header Host $host</code> 不能省。默认透传的是 <code>$proxy_host</code>，也就是 upstream 名字，后端按 Host 做的域名路由、重定向、Cookie 域都会错。</li>
</ol>
<p>改完配置，不用重启容器：</p>
<pre><code class="language-bash">docker exec nginx nginx -t
docker exec nginx nginx -s reload</code></pre>
<p><code>nginx -s reload</code> 是给 master 进程发 HUP，平滑加载新配置，旧连接不断。先 <code>-t</code> 再 reload，配置写错时 <code>-t</code> 会拦下来，reload 不会让服务挂掉。</p>
<p>HTTPS 站点在同一个 server 块里加：</p>
<pre><code class="language-nginx">    listen 443 ssl;
    http2 on;
    ssl_certificate     /etc/nginx/certs/app.example.com.pem;
    ssl_certificate_key /etc/nginx/certs/app.example.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;</code></pre>
<p>证书文件放在 <code>/data/nginx/certs</code> 下，容器里按挂载路径引用。</p>
<h3>静态文件单独放一个 server</h3>
<pre><code class="language-nginx">server {
    listen 80;
    server_name static.example.com;
    root /usr/share/nginx/html;
    index index.html;

    location /assets/ {
        expires 7d;
        add_header Cache-Control "public";
    }
}</code></pre>
<h2>四、用 compose 管起来</h2>
<p><code>/data/nginx/docker-compose.yml</code>：</p>
<pre><code class="language-yaml">services:
  nginx:
    image: nginx:1.27
    container_name: nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    environment:
      TZ: Asia/Shanghai
    volumes:
      - /data/nginx/conf.d:/etc/nginx/conf.d:ro
      - /data/nginx/html:/usr/share/nginx/html:ro
      - /data/nginx/certs:/etc/nginx/certs:ro
      - /data/nginx/logs:/var/log/nginx

networks:
  default:
    name: web
    external: true</code></pre>
<p>业务容器加入这个网络，就能用容器名互相访问：</p>
<pre><code class="language-bash">docker network create web            # 只需建一次
docker network connect web app</code></pre>
<p>用 compose 的话，在业务侧 compose 文件的网络段里写：</p>
<pre><code class="language-yaml">networks:
  default:
    name: web
    external: true</code></pre>
<p>这样两边在同一个网络里，<code>proxy_pass http://app:8080</code> 才能解析。</p>
<h2>五、日志</h2>
<p>官方镜像里 <code>/var/log/nginx/access.log</code> 和 <code>error.log</code> 是指向 <code>/dev/stdout</code>、<code>/dev/stderr</code> 的软链，所以默认 <code>docker logs nginx</code> 能看到访问日志。一旦把宿主机目录挂到 <code>/var/log/nginx</code>，这两个软链被盖住，日志变成宿主机上的真实文件，<code>docker logs</code> 就空了。这不是故障，是挂载导致的必然结果。</p>
<p>用哪种方式，提前定：</p>
<table>
<thead>
<tr>
<th>方式</th>
<th>查看命令</th>
<th>轮转</th>
</tr>
</thead>
<tbody>
<tr>
<td>不挂 logs 目录</td>
<td><code>docker logs -f nginx</code></td>
<td>由 Docker 的 <code>max-size</code> 控制</td>
</tr>
<tr>
<td>挂 logs 目录</td>
<td><code>tail -f /data/nginx/logs/access.log</code></td>
<td>自己做 logrotate</td>
</tr>
</tbody>
</table>
<p>挂目录的情况下，加一份 logrotate：</p>
<pre><code>/data/nginx/logs/*.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    copytruncate
}</code></pre>
<p><code>copytruncate</code> 是因为容器里的 Nginx 不会收到 logrotate 的 <code>USR1</code> 信号，只能先复制再清空原文件。</p>
<p>要 JSON 格式日志，在 <code>nginx.conf</code> 里定义 <code>log_format</code>；<code>conf.d</code> 里的站点文件只能引用，不能定义全局 format，所以需要挂整个 <code>nginx.conf</code>，或者用 <code>include</code> 拆出来。</p>
<h2>六、常见报错</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>502 Bad Gateway</code>，错误日志 <code>connect() failed (111: Connection refused)</code></td>
<td>上游容器没起，或端口写错</td>
<td><code>docker ps</code> 看上游，<code>docker exec nginx getent hosts app</code> 验 DNS</td>
</tr>
<tr>
<td><code>504 Gateway Time-out</code></td>
<td>后端处理慢，超过 <code>proxy_read_timeout</code></td>
<td>调大超时，先确认后端本身耗时</td>
</tr>
<tr>
<td><code>413 Request Entity Too Large</code></td>
<td>上传超过 <code>client_max_body_size</code>，默认只有 1m</td>
<td>在 server 或 location 里调大</td>
</tr>
<tr>
<td>启动失败，<code>host not found in upstream "app"</code></td>
<td>两个容器不在同一网络</td>
<td>建共用网络，或改用宿主机 IP</td>
</tr>
<tr>
<td>改了 conf 文件，容器里还是旧内容</td>
<td>单文件 bind mount 的 inode 问题</td>
<td>挂目录不挂单文件，重启容器</td>
</tr>
<tr>
<td><code>403 Forbidden</code>，<code>directory index of "/usr/share/nginx/html/" is forbidden</code></td>
<td>目录里没有 index 文件</td>
<td>放 <code>index.html</code>，或开 <code>autoindex</code></td>
</tr>
<tr>
<td><code>docker logs</code> 没输出</td>
<td>挂了 logs 目录，日志写到宿主机文件了</td>
<td>看 <code>/data/nginx/logs</code></td>
</tr>
<tr>
<td>CentOS 上挂载后 403 或 Nginx 读不到文件</td>
<td>SELinux 拦了目录</td>
<td>挂载加 <code>:z</code>，或 <code>setenforce 0</code> 临时验证</td>
</tr>
</tbody>
</table>
<h2>七、备份和升级</h2>
<p>Nginx 本身没有&quot;数据&quot;，要备份的只有配置和证书：</p>
<pre><code class="language-bash">sudo tar -czf /backup/nginx-$(date +%F).tar.gz -C /data nginx</code></pre>
<p>升级镜像就是改 <code>image: nginx:1.27</code> 里的小版本，然后：</p>
<pre><code class="language-bash">docker compose pull nginx
docker compose up -d nginx
docker exec nginx nginx -t</code></pre>
<p>容器重建不影响挂载的配置和日志。升级前先备份配置，<code>nginx -t</code> 过了再放流量。</p>
<p>最后一条限制：<code>client_max_body_size</code>、<code>proxy_read_timeout</code> 这类参数没有全局最优值，得按实际业务的上传大小和后端耗时定，默认值在文件上传和大报表导出场景下基本都要调。</p>]]></description>
    <pubDate>Fri, 30 Oct 2026 14:15:34 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=9</guid>
</item>
<item>
    <title>Docker 镜像代理最新可用地址</title>
    <link>https://binliyou.com/?post=8</link>
    <description><![CDATA[]]></description>
    <pubDate>Fri, 30 Oct 2026 14:02:30 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=8</guid>
</item>
<item>
    <title>Docker 部署：在线装、离线装和镜像源配置</title>
    <link>https://binliyou.com/?post=7</link>
    <description><![CDATA[<p>装 Docker 本身不复杂，麻烦的是环境不一样：有的机器能直接上外网，有的完全断网，还有的能上网但拉镜像慢得受不了。下面把在线安装、离线安装和镜像源配置放在一起讲一遍。脚本和配置都是平时在用的那套，openEuler 20/22/24、CentOS 7、RHEL、Ubuntu 18.04、Debian 10 都跑过。</p>
<h2>一、先把三个&quot;源&quot;分开</h2>
<p>装 Docker 遇到的慢和失败，大部分是把三个不同的东西混成一件事了。</p>
<table>
<thead>
<tr>
<th>名称</th>
<th>配置位置</th>
<th>作用</th>
<th>生效时机</th>
</tr>
</thead>
<tbody>
<tr>
<td>软件源（repo）</td>
<td><code>/etc/yum.repos.d/docker-ce.repo</code>、<code>/etc/apt/sources.list.d/docker.list</code></td>
<td>下载 <code>docker-ce</code>、<code>containerd.io</code> 等安装包</td>
<td>安装与升级</td>
</tr>
<tr>
<td>镜像加速器（registry mirror）</td>
<td><code>/etc/docker/daemon.json</code> 的 <code>registry-mirrors</code></td>
<td>加速 <code>docker pull</code> 拉取 Docker Hub 镜像</td>
<td>拉取镜像</td>
</tr>
<tr>
<td>私有仓库 / 内网仓库</td>
<td><code>insecure-registries</code> 或 <code>/etc/docker/certs.d/</code></td>
<td>访问自建 Harbor、Registry</td>
<td>拉取私有镜像</td>
</tr>
</tbody>
</table>
<p>这三个不能互相替代。把软件源换成清华，<code>docker pull</code> 该慢还是慢；配了镜像加速器，<code>yum install docker-ce</code> 也不会快一点。离线环境里三个概念同时存在，更容易搞混。</p>
<h2>二、在线安装</h2>
<p>在线安装就一个变量：包从哪下载。Docker 官方源在国内时好时坏，几个国内镜像站都有完整镜像，把 repo 地址和 GPG 公钥一起换掉就行。</p>
<h3>2.1 Ubuntu / Debian</h3>
<pre><code class="language-bash"># 1. 依赖
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release

# 2. GPG 公钥（注意 keyring 目录和 signed-by 写法）
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# 3. 软件源
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] \
https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu \
$(. /etc/os-release &amp;&amp; echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null

# 4. 安装
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin</code></pre>
<p>有三处容易错：</p>
<ol>
<li>公钥和 repo 要来自同一个镜像站。混着用一般也能装上，但镜像站换签名的时候，先坏的往往是这种组合。</li>
<li><code>signed-by</code> 是现在推荐的写法，<code>apt-key add</code> 已经废弃了。还在用旧写法的话会看到 <code>Key is stored in legacy trusted.gpg keyring</code> 的警告。</li>
<li><code>VERSION_CODENAME</code> 不能是空的。有些国产衍生系统在 <code>/etc/os-release</code> 里没这个字段，照抄会生成一行缺代号的源，<code>apt-get update</code> 直接报 <code>Malformed entry</code>。这种情况手动填 <code>jammy</code>、<code>focal</code>、<code>bookworm</code> 这种代号。</li>
</ol>
<h3>2.2 RHEL / CentOS / openEuler</h3>
<pre><code class="language-bash"># 1. 依赖与仓库管理插件
sudo yum install -y ca-certificates curl gnupg2 yum-utils

# 2. 拉取 repo 文件
sudo curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo \
  -o /etc/yum.repos.d/docker-ce.repo

# 3. 镜像站的 repo 文件可能还指向官方地址，统一替换一次（已经是镜像地址就是空操作）
sudo sed -i 's|https://download.docker.com|https://mirrors.aliyun.com/docker-ce|g' \
  /etc/yum.repos.d/docker-ce.repo

# 4. 安装
sudo yum install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin</code></pre>
<p><code>docker-ce.repo</code> 里是用 <code>$releasever</code> 拼路径的，比如 <code>.../linux/centos/7/x86_64/stable</code>。这个变量 yum 会按系统版本算，CentOS 7/8 算出来正好是 7/8，对得上仓库目录。系统版本比仓库覆盖范围新的时候，问题就来了。</p>
<h3>2.3 openEuler 上的 <code>$releasever</code></h3>
<p>openEuler 22.03 算出来是 <code>22</code>，24.03 是 <code>24</code>。而 Docker 仓库只有 <code>centos7</code>、<code>centos8</code>、<code>centos9</code> 三个目录，没有 <code>centos22</code>、<code>centos24</code>，<code>yum makecache</code> 直接 404。</p>
<p>脚本里是按主版本号映射的：</p>
<pre><code class="language-bash">map_docker_releasever() {
    local major
    major="$(printf '%s' "$OS_VERSION" | tr -d '\r')"
    major="${major%%.*}"
    major="${major%%-*}"
    if [[ "$major" =~ ^[0-9]+$ ]] &amp;&amp; (( major &gt;= 24 )); then
        DOCKER_YUM_RELEASEVER=9
    else
        DOCKER_YUM_RELEASEVER=8
    fi
}

# 之后在 repo 文件里做定点替换
sed -i "s|\$releasever|${DOCKER_YUM_RELEASEVER}|g" "$repo_file"</code></pre>
<p>24 及以上走 <code>centos9</code>，其余走 <code>centos8</code>。这么做的理由不是版本号刚好对得上，而是 ABI 能兼容：<code>centos8</code>、<code>centos9</code> 的包分别按 RHEL 8、RHEL 9 的 glibc 编，openEuler 22.03 / 24.03 自带的 glibc 比这两个基线新，低版本编出来的二进制在新环境里能跑。映射只是让仓库路径对得上，装完还是 openEuler 系统。麒麟、UOS、OpenCloudOS、Anolis 这些 RHEL 系衍生版同理，脚本里列了名单：</p>
<pre><code class="language-bash">case "$OS" in
    opencloudos|anolis|openanolis|alinux|tencentos|kylin|uos|uniontech)
        PKG_FAMILY="rhel"
        map_docker_releasever
        ;;
    centos|rhel|fedora|rocky|almalinux|ol|amzn)
        PKG_FAMILY="rhel"
        ;;
esac</code></pre>
<p>第一个分支做映射，第二个分支保持 <code>$releasever</code> 原样，因为 CentOS/RHEL/Rocky 的版本号本来就对得上仓库目录。</p>
<h3>2.4 脚本怎么选源</h3>
<p>脚本内置了三个源，GPG 地址和包地址成对存：</p>
<pre><code class="language-bash">declare -A MIRRORS=(
    [official]="https://download.docker.com|https://download.docker.com"
    [tsinghua]="https://mirrors.tuna.tsinghua.edu.cn/docker-ce|https://mirrors.tuna.tsinghua.edu.cn/docker-ce"
    [aliyun]="https://mirrors.aliyun.com/docker-ce|https://mirrors.aliyun.com/docker-ce"
)</code></pre>
<p><code>auto</code> 模式只判断官方域通不通，不测速：</p>
<pre><code class="language-bash">if curl -fsSL --connect-timeout 5 --max-time 10 \
    "https://download.docker.com/linux/ubuntu/gpg" -o /dev/null 2&gt;/dev/null; then
    MIRROR_NAME="official"
else
    MIRROR_NAME="aliyun"
fi</code></pre>
<p>两点说明：</p>
<ol>
<li>探测地址写死成 <code>/linux/ubuntu/gpg</code> 了，跟实际发行版无关，就是看看 <code>download.docker.com</code> 这个域能不能连上，后面不会拿它当下载地址。</li>
<li>通不等于快。5 秒连接超时、10 秒总超时只能筛掉完全连不上的。官方域返回 200 但只有几十 KB/s 的时候，脚本还是选 official。对速度有要求的直接 <code>--mirror aliyun</code> 或 <code>--mirror tsinghua</code>，跳过探测。</li>
</ol>
<p>用法：</p>
<pre><code class="language-bash">sudo bash install-docker.sh                      # 自动选源
sudo bash install-docker.sh --mirror tsinghua    # 指定源
sudo bash install-docker.sh --mirror official    # 走官方源</code></pre>
<p>脚本跑下来是这么个流程：查 root，看有没有装过 Docker 并提示覆盖，读 <code>/etc/os-release</code> 认发行版和包管理器，选源，卸旧包，装依赖，写 repo 和 GPG，装五个包，<code>systemctl enable --now docker</code>，最后校验一遍。</p>
<h3>2.5 装完检查</h3>
<pre><code class="language-bash">docker version
docker compose version
systemctl status docker
docker run --rm hello-world</code></pre>
<p><code>docker compose version</code> 挂了但 <code>docker version</code> 正常，就是 compose 插件没装上。Compose V2 是 CLI 插件，<code>docker compose</code> 这个子命令只认两个目录：</p>
<ul>
<li><code>/usr/libexec/docker/cli-plugins/</code>（系统级）</li>
<li><code>~/.docker/cli-plugins/</code>（用户级）</li>
</ul>
<p>把二进制放到 <code>/usr/bin/docker-compose</code>，只是多了一个 <code>docker-compose</code> 命令，<code>docker compose</code> 照样报 <code>docker: 'compose' is not a docker command</code>。脚本装的是 <code>docker-compose-plugin</code> 包，插件会进系统插件目录。</p>
<p>非 root 用户要用的话，加进 docker 组再重新登录：</p>
<pre><code class="language-bash">sudo usermod -aG docker &lt;用户名&gt;
# 组变更只对新的登录会话生效，当前 shell 不会变
groups</code></pre>
<h2>三、镜像源配置</h2>
<h3>3.1 registry-mirrors 只管 Docker Hub</h3>
<p>镜像加速器的原理不复杂：dockerd 收到 <code>docker pull</code>，如果镜像是 <code>docker.io</code> 的，就按顺序去 mirrors 里试，全失败了才回源。所以要同时满足两个条件：</p>
<ol>
<li>请求得指向 <code>docker.io</code>。不写域名的短名（<code>nginx</code>、<code>redis:7</code>）会补全成 <code>docker.io/library/nginx:latest</code>，走 mirror；写了别的仓库地址的（<code>harbor.example.com/prod/app:v1</code>）不走 mirror，直接连那个仓库。</li>
<li>mirror 本身得能通。2024 年年中开始，一批公共镜像加速服务陆续停了，有的云厂商改成账号绑定专属地址。网上那些&quot;可用加速器列表&quot;时效性都很差，用之前自己测一下。</li>
</ol>
<p>怎么测：<code>/v2/</code> 是 Registry V2 的探测端点，返回 200 或 401 都说明服务在线。</p>
<pre><code class="language-bash">for u in https://mirror-a.example.com https://mirror-b.example.com; do
  printf '%-40s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code} %{time_total}s' --max-time 5 "$u/v2/")"
done</code></pre>
<h3>3.2 写 daemon.json</h3>
<p>配置文件在 <code>/etc/docker/daemon.json</code>，没有就新建。只配加速器的最小版本：</p>
<pre><code class="language-json">{
  "registry-mirrors": ["https://&lt;加速器地址&gt;"]
}</code></pre>
<p>实际环境一般一次把日志、数据目录、cgroup 驱动一起定下来，省得以后反复重启：</p>
<pre><code class="language-json">{
  "registry-mirrors": ["https://&lt;加速器地址&gt;"],
  "insecure-registries": ["harbor.example.com:80"],
  "data-root": "/data/docker",
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  },
  "live-restore": true
}</code></pre>
<table>
<thead>
<tr>
<th>字段</th>
<th>作用</th>
<th>常见取值</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>registry-mirrors</code></td>
<td>Docker Hub 拉取加速，可多个，按顺序尝试</td>
<td>镜像站地址，必须带 <code>https://</code></td>
</tr>
<tr>
<td><code>insecure-registries</code></td>
<td>允许以 HTTP 或自签证书访问的仓库</td>
<td><code>IP:端口</code> 或 <code>域名:端口</code></td>
</tr>
<tr>
<td><code>data-root</code></td>
<td>镜像、容器、卷的存放根目录</td>
<td>独立数据盘，如 <code>/data/docker</code></td>
</tr>
<tr>
<td><code>exec-opts</code></td>
<td>运行时参数，cgroup 驱动要和 k8s 保持一致</td>
<td><code>native.cgroupdriver=systemd</code></td>
</tr>
<tr>
<td><code>log-driver</code> / <code>log-opts</code></td>
<td>容器日志驱动与轮转</td>
<td><code>json-file</code> + <code>max-size</code> + <code>max-file</code></td>
</tr>
<tr>
<td><code>live-restore</code></td>
<td>dockerd 重启时保持容器运行</td>
<td><code>true</code></td>
</tr>
</tbody>
</table>
<p><code>log-opts</code> 的值必须是字符串。写成数字 <code>100</code>，dockerd 直接起不来，报错是 JSON 反序列化：</p>
<pre><code>json: cannot unmarshal number into Go struct field ...log-opts of type string</code></pre>
<p><code>journalctl -u docker -n 50</code> 能看到完整字段名，按提示改成 <code>"100m"</code> 就行。</p>
<p>默认的 <code>json-file</code> 不限制大小，服务容器跑久了能把分区写满。离线安装脚本在 <code>daemon.json</code> 不存在时，写的就是带轮转的一份：</p>
<pre><code class="language-json">{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  }
}</code></pre>
<p>单容器最多 300 MB（100m × 3），一般 Web 服务够用了；日志量大的在 <code>docker run</code> 或 compose 里单独覆盖 <code>logging</code> 段。</p>
<h3>3.3 生效和检查</h3>
<pre><code class="language-bash">sudo systemctl daemon-reload     # 只改单元文件时才需要；只改 daemon.json 可以跳过
sudo systemctl restart docker

docker info | grep -A5 "Registry Mirrors"
docker info | grep -E "Docker Root Dir|Cgroup Driver|Storage Driver"</code></pre>
<p><code>docker info</code> 里看不到 <code>Registry Mirrors</code>，要么 <code>daemon.json</code> 没读到，要么文件位置不对。语法错的话 dockerd 会直接起不来，不会悄悄忽略。<code>systemctl cat docker.service</code> 能看出来 <code>ExecStart</code> 有没有带 <code>--config-file</code> 这种非默认路径。</p>
<h3>3.4 私有仓库：HTTP 和自签证书</h3>
<p><code>insecure-registries</code> 管两种情况：仓库是 HTTP 的，仓库用自签证书的。配了之后拉取走明文或者不校验证书，只能在内网用。</p>
<p>HTTPS + 私有 CA 的话，规范做法是把 CA 放到按域名命名的目录：</p>
<pre><code class="language-bash">sudo mkdir -p /etc/docker/certs.d/harbor.example.com
sudo cp internal-ca.crt /etc/docker/certs.d/harbor.example.com/ca.crt
sudo systemctl restart docker</code></pre>
<p>目录名要和 <code>docker pull</code> 里的主机名一字不差，带端口就一起带上，否则证书加载不上，报 <code>x509: certificate signed by unknown authority</code>。</p>
<h3>3.5 镜像源相关报错</h3>
<table>
<thead>
<tr>
<th>报错</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>TLS handshake timeout</code></td>
<td>mirror 不可达，dockerd 逐个尝试后回源</td>
<td>从列表里移除失效地址，或直接指定可用地址</td>
</tr>
<tr>
<td><code>Get https://...: dial tcp: i/o timeout</code></td>
<td>没外网，也没配内网仓库</td>
<td>按第四节离线方案搬镜像</td>
</tr>
<tr>
<td><code>http: server gave HTTP response to HTTPS client</code></td>
<td>仓库是 HTTP，没进 <code>insecure-registries</code></td>
<td>加进该仓库地址后重启</td>
</tr>
<tr>
<td><code>x509: certificate signed by unknown authority</code></td>
<td>自签证书没放进 <code>certs.d</code></td>
<td>按域名建目录放 <code>ca.crt</code></td>
</tr>
<tr>
<td><code>pull access denied</code> / <code>unauthorized</code></td>
<td>私有仓库没登录</td>
<td><code>docker login harbor.example.com</code></td>
</tr>
<tr>
<td><code>manifest unknown</code></td>
<td>标签不存在，或者架构不匹配</td>
<td>确认 tag；跨架构要加 <code>--platform</code></td>
</tr>
</tbody>
</table>
<h2>四、离线安装</h2>
<h3>4.1 两条路</h3>
<table>
<thead>
<tr>
<th>路线</th>
<th>产物</th>
<th>适用</th>
<th>限制</th>
</tr>
</thead>
<tbody>
<tr>
<td>包管理器包（rpm/deb）</td>
<td><code>docker-ce</code> 等一组本地包</td>
<td>和打包机同发行版、同大版本、同架构</td>
<td>跨大版本或跨发行版容易缺依赖</td>
</tr>
<tr>
<td>静态二进制</td>
<td>解压后的 <code>dockerd</code>、<code>docker</code>、<code>containerd</code> 等</td>
<td>任意 x86_64/aarch64 Linux，不依赖发行版包管理</td>
<td>要手工提供 systemd 单元；仍受 glibc 最低版本约束</td>
</tr>
</tbody>
</table>
<p>静态包用得最多，因为目标机经常和打包机不是一个系统，静态包只依赖内核、glibc 和少量系统库。脚本两种都支持，默认走静态。</p>
<h3>4.2 打包机：做离线包</h3>
<p>找一台有外网、有 DNS 的 Linux 执行：</p>
<pre><code class="language-bash">bash pack-docker-offline.sh --arch x86_64 --mirror aliyun</code></pre>
<p>可选参数：</p>
<pre><code class="language-bash">bash pack-docker-offline.sh --mode rpm      # 在同类 RHEL 系机器上打本地包
bash pack-docker-offline.sh --mode deb      # 在同类 Debian 系机器上打本地包
bash pack-docker-offline.sh --with-images nginx:1.27,redis:7   # 顺带导出镜像
bash pack-docker-offline.sh --docker-ver 27.3.1 --out /data/pkg</code></pre>
<p>打包脚本干了这几件事：</p>
<ol>
<li>看镜像站上最新的静态包版本。解析目录页 HTML，把 <code>docker-x.y.z.tgz</code> 挑出来按版本排序取最大；探测失败就用脚本里的固定版本。</li>
</ol>
<pre><code class="language-bash">html="$(mktemp)"
if curl -fsSL --connect-timeout 10 --max-time 30 "$STATIC_BASE/" -o "$html" 2&gt;/dev/null; then
    latest="$(grep -oE 'docker-[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?\.tgz' "$html" \
      | grep -v rootless | sort -V | tail -1 || true)"
fi</code></pre>
<ol start="2">
<li>下载静态包，在打包机上就解开。这一步是故意的：目标机可能没有 <code>tar</code>，解压放打包机做，目标机只管拷文件。</li>
</ol>
<pre><code class="language-bash">tar -xzf "$tgz_path" -C "$unpack"
cp -a "$unpack/docker/." "$PAYLOAD_DIR/bin/"</code></pre>
<ol start="3">
<li>
<p>下载 <code>docker compose</code> 和 <code>docker buildx</code> 两个插件。这俩只在 GitHub Releases 提供，脚本按官方、<code>mirror.ghproxy.com</code>、<code>ghfast.top</code> 的顺序试，都失败就跳过，安装时会告警，不影响 Docker 本体。</p>
</li>
<li>
<p>生成三种形态的产物：</p>
</li>
</ol>
<pre><code>docker-offline-x86_64-20261029/
├── install-docker-offline.sh    # 目标机执行
├── VERSION.txt                  # 架构、模式、源、版本、打包时间
├── payload/
│   ├── bin/                     # 已解压的 docker/containerd/runc 等
│   ├── docker-compose
│   ├── docker-buildx
│   └── docker.service
├── packages/                    # rpm/deb 模式下的本地包，静态模式为空
└── images/                      # --with-images 导出的镜像 tar</code></pre>
<p>同时产出同名 <code>.tar.gz</code> 和 <code>.zip</code>。打包机没有 <code>tar</code> 就只出 zip；zip 优先用系统 <code>zip</code> 命令，没有就用 Python 的 <code>zipfile</code>，保证产物总能出来。</p>
<h3>4.3 目标机：安装</h3>
<p>整目录拷，别只拷脚本：</p>
<pre><code class="language-bash">scp -r docker-offline-x86_64-20261029 root@目标机:/root/</code></pre>
<p>在目标机上执行：</p>
<pre><code class="language-bash">cd /root/docker-offline-x86_64-20261029
sudo bash install-docker-offline.sh</code></pre>
<p>脚本必须和 <code>payload</code> 在同一层，它按固定顺序找 <code>dockerd</code>，命中第一个就返回：</p>
<pre><code class="language-bash">for d in "$PAYLOAD_DIR/bin" "$BUNDLE_DIR/bin" "$BUNDLE_DIR/docker" "$PAYLOAD_DIR/docker" "$BUNDLE_DIR"; do
    if [[ -x "$d/dockerd" ]]; then
        echo "$d"
        return 0
    fi
done</code></pre>
<p>都没找到，才去搜 <code>docker-*.tgz</code>（只看两层深，排除 rootless 包），找到就在目标机现场解。</p>
<p>把脚本单独拷到 <code>/root</code> 再执行的话，<code>$BUNDLE_DIR</code> 就成了 <code>/root</code>，找不到 payload，直接退出，提示&quot;请把完整离线目录拷到服务器后再执行&quot;。</p>
<p>安装过程：</p>
<ol>
<li>先看 <code>iptables</code> 或 <code>nft</code> 在不在。都没有只告警，静态包里不带这两个工具，容器网络会用不了，得另外离线装。</li>
<li>加载 <code>overlay</code>、<code>br_netfilter</code> 模块，打开 <code>net.ipv4.ip_forward</code>。<code>overlay</code> 是 overlay2 存储驱动的前提，<code>br_netfilter</code> 让桥接流量走 netfilter，缺哪个都不行，轻则容器上不了网，重则 dockerd 起不来。</li>
<li>按包类型装：有 rpm 用 <code>yum localinstall</code>，有 deb 用 <code>dpkg -i</code>，都没有就装静态二进制到 <code>/usr/bin</code>。</li>
<li>装 CLI 插件到 <code>/usr/libexec/docker/cli-plugins/</code>。</li>
<li>写 systemd 单元。离线包里自带一份（<code>payload/docker.service</code>），没有就用脚本内置的：</li>
</ol>
<pre><code class="language-ini">[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network-online.target firewalld.service
Wants=network-online.target

[Service]
Type=notify
ExecStart=/usr/bin/dockerd --host=unix:///var/run/docker.sock
ExecReload=/bin/kill -s HUP $MAINPID
TimeoutStartSec=0
RestartSec=2
Restart=always
StartLimitBurst=3
StartLimitInterval=60s
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
Delegate=yes
KillMode=process
OOMScoreAdjust=-500

[Install]
WantedBy=multi-user.target</code></pre>
<p><code>Type=notify</code> 是让 dockerd 通过 sd_notify 报就绪，静态包里的 dockerd 支持这套协议；<code>LimitNOFILE=infinity</code> 和 <code>TasksMax=infinity</code> 在容器多的时候是必需的，默认 1024 的文件描述符上限，表现出来就是容器随机启动失败。<code>ExecStart</code> 里已经有 <code>--host</code>，所以 <code>daemon.json</code> 里不能再写 <code>hosts</code>，写了就报重复配置：</p>
<pre><code>unable to configure the Docker daemon with file /etc/docker/daemon.json:
the following directives are specified both as a flag and in the configuration file: hosts</code></pre>
<ol start="6">
<li><code>daemon-reload</code>、<code>enable</code>、<code>restart</code>，然后校验状态，输出 <code>docker version</code>、<code>docker compose version</code>、<code>docker buildx version</code>。</li>
<li><code>images/</code> 下有 tar 就逐个 <code>docker load</code>。</li>
</ol>
<h3>4.4 目标机没有 tar</h3>
<p>三种办法，按可靠程度排：</p>
<p><strong>A. 拷已解压目录</strong>（最稳）。打包机上目录本来就是展开状态，<code>scp -r</code> 整个目录过去，目标机不需要任何解压工具。</p>
<p><strong>B. 拷 zip，用 Python 解压</strong>。Python 通常随系统预装：</p>
<pre><code class="language-bash">python3 -c "import zipfile; zipfile.ZipFile('docker-offline-x86_64-20261029.zip').extractall()"
cd docker-offline-x86_64-20261029
sudo bash install-docker-offline.sh</code></pre>
<p><strong>C. 拷了 tar.gz，目标机也有 tar</strong>：</p>
<pre><code class="language-bash">tar -xzf docker-offline-x86_64-20261029.tar.gz</code></pre>
<p>脚本内部还有一层解压兜底：<code>tar</code> → <code>busybox tar</code> → <code>python3</code>/<code>python</code> 的 <code>tarfile</code>，给包里还带 <code>.tgz</code> 的情况用。三个都没有就停下来，提示去打包机解压后整目录拷，别在目标机上硬解。</p>
<h3>4.5 镜像一起搬</h3>
<p>装好 Docker 只是解决了运行时，镜像是另一回事。打包这边：</p>
<pre><code class="language-bash">bash pack-docker-offline.sh --with-images nginx:1.27,redis:7</code></pre>
<p>脚本对每个镜像 <code>docker pull</code> 完再 <code>docker save</code>，文件名把 <code>/</code> 和 <code>:</code> 换成 <code>_</code>：</p>
<pre><code class="language-bash">docker save -o "$IMAGES_DIR/${safe}.tar" "$name"</code></pre>
<p>安装那边自动导入：</p>
<pre><code class="language-bash">for img in "$IMAGES_DIR"/*.tar; do
    docker load -i "$img"
done</code></pre>
<p><code>docker load</code> 会把标签一起恢复，不用重新打 tag。镜像大的话注意 <code>images/</code> 的体积，<code>docker save</code> 出来是不压缩的层数据，几个 GB 很常见。</p>
<p>离线环境还有条硬规矩：别执行 <code>docker run hello-world</code>。本地没有这个镜像就会去 Docker Hub 拉，没 DNS 或者禁了外网必然失败，报 <code>lookup registry-1.docker.io: no such host</code>，这跟装没装成没关系，很容易看错。验证就用：</p>
<pre><code class="language-bash">docker version
docker compose version
systemctl status docker
docker images</code></pre>
<h3>4.6 离线环境的&quot;镜像源&quot;</h3>
<p>完全断网又没有内网仓库的时候，镜像只能靠 <code>docker save</code> / <code>docker load</code> 搬，<code>registry-mirrors</code> 没意义，配了只会让失败来得更慢：dockerd 还是会先试镜像站，超时才报错。</p>
<p>接了内网 Harbor 之后，Harbor 就顶了&quot;镜像源&quot;的位置：</p>
<ul>
<li>Harbor 是 HTTP：加 <code>insecure-registries</code>。</li>
<li>Harbor 是 HTTPS + 自签证书：按 3.4 放 <code>ca.crt</code>。</li>
<li>要把上游镜像同步进 Harbor：建远程仓库（proxy cache）或者复制规则，客户端改成 <code>docker pull harbor.example.com/library/nginx:1.27</code>，不再碰 Docker Hub。</li>
</ul>
<p><code>registry-mirrors</code> 和 Harbor 是两套机制，前者只能加速 <code>docker.io</code>，没法把 <code>harbor.example.com/...</code> 的拉取转到别处。</p>
<h2>五、装完后的统一基线</h2>
<p>在线离线都一样，装完建议把下面几项一次配好，后面少折腾。</p>
<table>
<thead>
<tr>
<th>项</th>
<th>做法</th>
<th>判断标准</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据目录</td>
<td><code>data-root</code> 指向独立数据盘</td>
<td><code>docker info</code> 的 <code>Docker Root Dir</code></td>
</tr>
<tr>
<td>cgroup 驱动</td>
<td><code>native.cgroupdriver=systemd</code></td>
<td><code>docker info</code> 的 <code>Cgroup Driver</code></td>
</tr>
<tr>
<td>日志轮转</td>
<td><code>json-file</code> + <code>100m</code> × <code>3</code></td>
<td>单容器日志不无限增长</td>
</tr>
<tr>
<td>镜像加速</td>
<td><code>registry-mirrors</code></td>
<td><code>docker info</code> 的 <code>Registry Mirrors</code> 有输出</td>
</tr>
<tr>
<td>容器自启</td>
<td><code>docker run --restart=always</code> 或 compose 的 <code>restart</code></td>
<td>主机重启后容器自动恢复</td>
</tr>
<tr>
<td>dockerd 重启不杀容器</td>
<td><code>live-restore: true</code></td>
<td>重启 Docker 服务时容器不中断</td>
</tr>
</tbody>
</table>
<p>迁 <code>data-root</code> 的时候，新目录必须是空的，先停服务再搬：</p>
<pre><code class="language-bash">sudo systemctl stop docker
sudo mkdir -p /data/docker
sudo rsync -aHAX --numeric-ids /var/lib/docker/ /data/docker/
# 确认 daemon.json 里已有 "data-root": "/data/docker"
sudo systemctl start docker
docker images        # 原有镜像和容器应该都还在</code></pre>
<p><code>rsync</code> 的 <code>-aHAX</code> 保留权限、ACL 和扩展属性，overlay2 对这些比较敏感，参数少了可能镜像层校验不过。</p>
<h2>六、排障对照表</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Cannot connect to the Docker daemon at unix:///var/run/docker.sock</code></td>
<td>服务没起，或者当前 shell 的 <code>DOCKER_HOST</code> 指向别处</td>
<td><code>systemctl status docker</code>；<code>echo $DOCKER_HOST</code></td>
</tr>
<tr>
<td><code>permission denied while trying to connect to the Docker daemon socket</code></td>
<td>用户不在 <code>docker</code> 组，或者组变更没生效</td>
<td><code>usermod -aG docker &lt;用户&gt;</code> 后重新登录，<code>groups</code> 确认</td>
</tr>
<tr>
<td><code>Job for docker.service failed because the control process exited with error code</code></td>
<td><code>daemon.json</code> 语法错、字段类型错、<code>hosts</code> 冲突</td>
<td><code>journalctl -u docker -n 50 --no-pager</code></td>
</tr>
<tr>
<td><code>failed to start daemon: Error initializing network controller ... iptables failed</code></td>
<td>缺 <code>iptables</code>/<code>nft</code>，或者规则链被防火墙清了</td>
<td>装 <code>iptables</code>；检查 firewalld 和 <code>DOCKER-USER</code> 链</td>
</tr>
<tr>
<td><code>write /var/lib/docker/...: no space left on device</code></td>
<td>数据目录所在分区满了</td>
<td><code>docker system prune</code> 清理，或者迁 <code>data-root</code></td>
</tr>
<tr>
<td><code>yum makecache</code> 报 404，路径里带 <code>centos22</code></td>
<td><code>$releasever</code> 和仓库目录对不上</td>
<td>用 <code>sed</code> 固定成 8 或 9（见 2.3）</td>
</tr>
<tr>
<td><code>apt-get update</code> 报 <code>Malformed entry</code></td>
<td>源行缺发行版代号</td>
<td><code>VERSION_CODENAME</code> 为空，手动填代号</td>
</tr>
<tr>
<td><code>docker: 'compose' is not a docker command</code></td>
<td>插件没放到 CLI 插件目录</td>
<td>装 <code>docker-compose-plugin</code>，或者手工放进 <code>/usr/libexec/docker/cli-plugins/</code></td>
</tr>
<tr>
<td><code>docker pull</code> 卡一会儿然后超时，<code>docker info</code> 里有失效 mirror</td>
<td>加速器已经停服</td>
<td>从 <code>registry-mirrors</code> 里删掉</td>
</tr>
<tr>
<td><code>lookup registry-1.docker.io: no such host</code></td>
<td>离线机执行了需要联网拉镜像的命令</td>
<td>用 <code>docker load</code> 导入本地 tar</td>
</tr>
<tr>
<td><code>unable to configure the Docker daemon ... hosts</code> 重复</td>
<td><code>ExecStart</code> 和 <code>daemon.json</code> 同时指定了 <code>hosts</code></td>
<td>删掉 <code>daemon.json</code> 里的 <code>hosts</code></td>
</tr>
</tbody>
</table>
<h2>七、几个限制</h2>
<ul>
<li>公共镜像加速器能不能用是变化的，本文不写具体地址，用 3.1 的 <code>/v2/</code> 脚本自己测。</li>
<li>静态二进制绕过了包管理，Docker 升级要重新打一遍包；rpm/deb 那条路才能用包管理器升级。</li>
<li><code>docker save</code> 不压缩，搬运前可以自己 <code>gzip</code>，导入时先解压再 <code>docker load -i</code>。</li>
<li>离线包里的 <code>docker compose</code>、<code>docker buildx</code> 来自 GitHub Releases，网络受限时可能只拿到 Docker 本体，compose 得单独准备。</li>
</ul>]]></description>
    <pubDate>Thu, 29 Oct 2026 16:26:53 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=7</guid>
</item>
<item>
    <title>把活交给 Qoder 这阵子，攒下来的几条使用心得</title>
    <link>https://binliyou.com/?post=6</link>
    <description><![CDATA[<p>这阵子把不少工作挪到了 Qoder 上，从查问题、写代码到发这篇博客本身。写点实在的感受，顺便把踩出来的用法记下来，给别人参考，也给自己留个底。</p>
<p>先说最有用的一条：把边界写清楚，比把提示词写长有用得多。</p>
<p>我在项目根放了一份规则文件，里面就几条：哪个目录可写、哪些目录只读、哪些文件不许碰。账号表、密钥文件、license 这类，写明了不读、不传、不复述，连列目录时看到这种名字都只报告存在、不把内容带出来。有了这份东西，我不用每次开头都叮嘱一遍&quot;注意别把密码贴出来&quot;，它自己会守住。规则文件里还写了读写边界优先于系统权限这件事，意思是我不去改文件系统 ACL，而是用文档声明约束，改起来轻，也不会把权限搞乱。</p>
<p>第二条，让它自己验证，但要求它把没验证的部分说清楚。</p>
<p>前几天写博客发文那条链路，官方文档站是前端渲染的，抓下来只有个壳。它没照着猜，转身去翻本地的 EMlog 源码，把三件事找到了：接口只认表单编码，JSON 发过去字段全空、报错却只有一句 parameter error；更新文章是整行覆盖，没传的字段会被写成空值；正文存的是原文，渲染发生在读取的时候，所以不该自己先转一遍 HTML。这三个都是文档里不会写、只能读代码知道的。后来写百度推送那个插件也一样，本机没有 PHP 也没有 Docker，几个文件只能人工读一遍加括号配平检查，它就明写着&quot;语法没有机器验证过，装完第一件事是 php -l&quot;。这种坦白比什么都重要——它说测过了，我就真以为测过了。</p>
<p>第三条，纠错一次就固化下来，不要重复纠正。</p>
<p>我让它写过一篇技术文章，第一版腔调太像 AI，我说&quot;偏技术一点&quot;。它把设问句、比喻、第二人称、抒情收尾全去掉，按现象、原因、写法重排，结论后面都挂上源码位置。这条偏好被记进长期记忆里了，之后再写对外文档就是默认这个风格。类似地，运维类对话里我习惯贴终端原文、要整块能直接跑的命令、改配置必须带备份和回滚，这些也都记着，不用重说。用 AI 用得顺不顺，很大一部分差别在这里：它有没有地方存这些约定。</p>
<p>第四条，敏感信息不进对话，截图也算。</p>
<p>这条是我自己犯的。查百度推送接口时我截了后台的图，图里带着准入密钥，它当场提醒我密钥已经进对话了、建议去重置。这事提醒我：以前觉得&quot;我没把密码打字发出去&quot;就算安全，其实一张截图就破了。现在贴报错之前会先扫一眼有没有 token、有没有内网地址。同理，涉及公司环境的运维笔记我不会让它直接发到公网博客上，内网 IP、主机名、项目名这些得先剥掉。</p>
<p>第五条，默认停在可回退的那一步。</p>
<p>发文它默认只落到草稿，前台看不见，我确认过再发布；改线上文章之前先把 ID 和标题报出来对一眼；更新前先从线上把原值读回来再整体提交，防止漏传字段把内容清空。这套习惯不是它自己想的，是我一开始要求&quot;先给我看&quot;，然后固化成了流程。工具愿意多问一句、多留一步，比它跑得快更有价值。</p>
<p>第六条，别让它写它没查到的东西。</p>
<p>这次写这篇稿，我让它顺带提一下 Qoder 最近的活动，附上我的邀请链接。它先去抓活动页，发现页面是前端渲染的，抓到的只有几个内部开关（邀请功能开着、限时折扣没开之类），没有任何活动文案，于是明确说不写具体活动名和奖励，免得配一段编出来的说明。我觉得这个反应是对的——缺信息就直说。后来我把活动页截图贴过去，信息补齐了，它才把内容写进来，同时还是留了一句&quot;以页面当时显示为准&quot;。补信息就照实写，没信息就空着，这个分寸比多写几行重要。</p>
<p>再补一条工具层面的：它能在同一个会话里调数据库、浏览器、知识库这些东西，所以&quot;查一条慢 SQL&quot;&quot;验一个接口通不通&quot;&quot;把这段结论找出处&quot;不用换窗口。上下文不中断这件事，对复杂任务的影响比模型能力本身还大。</p>
<h2>Qoder CN 当期活动</h2>
<p>顺手把活动页上那几条抄在这儿，省得别人再去找。都是限时的，具体条件和截止时间以页面显示为准：</p>
<ul>
<li><strong>续费加赠</strong>：CN 个人版月付续费、月付升级，或过期后再开通，最高加赠 3000 Qwen Credits。</li>
<li><strong>新注册福利</strong>：2026 年 9 月 30 日起，首次注册 Qoder CN 的个人用户，登录桌面端完成新手任务可领奶茶免单卡，数量有限先到先得。</li>
<li><strong>每日 100 Credits</strong>：个人用户每天可以在桌面端内主动领取 100 通用 Credits。</li>
<li><strong>Qwen3.8-Flash 限免</strong>：面向新老个人用户开放，活动期间调用该模型不消耗 Credits，计费系数从 0.1× 降到 0.0×。</li>
<li><strong>大使计划</strong>：不限背景与地区，申请入选后享全年旗舰版会员、新功能优先内测、官方社群与限定周边。</li>
<li><strong>高校计划</strong>：大学生 1 个月专业版免费抵扣。</li>
</ul>
<p>每日领 Credits 和模型限免这两条是实打实能省额度的，长任务多的时候差别挺明显。</p>
<p>最后说下邀请。如果你也想试试，可以从下面这个链接注册，是我的邀请入口。具体能给到什么以页面当时显示为准，活动政策会变，我不替官方承诺：</p>
<p><a href="https://qoder.cn/activities?referral_code=owXz6Y8HsI1q6okjlY5Ttr26l5n7dZCE">https://qoder.cn/activities?referral_code=owXz6Y8HsI1q6okjlY5Ttr26l5n7dZCE</a></p>
<p>上面这些心得，包括这篇稿子本身，都是在这条链路上写完再发到博客的。工具带来的最大变化不是省了几分钟打字，是我可以把&quot;记得提醒我检查&quot;这类事情外包出去，让它替我守着流程。</p>]]></description>
    <pubDate>Mon, 26 Oct 2026 16:54:59 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=6</guid>
</item>
<item>
    <title>给博客写百度推送插件，记点过程中的事</title>
    <link>https://binliyou.com/?post=5</link>
    <description><![CDATA[<p>起因是问了一句 EMlog 的插件会不会写，然后就成了这个把文章链接定时推给百度的东西。这篇记详细一点，把关键代码贴出来，免得以后自己还得翻仓库。</p>
<h2>还没动手就撞上一个拦路的</h2>
<p>百度给的接口调用地址长这样：<code>http://data.zz.baidu.com/urls?site=https://www.binliyou.com&amp;token=xxx</code>，里面的 site 是带 www 的。而站点实际用的是裸域，www 根本没做解析，curl 一试就露了：</p>
<pre><code>https://binliyou.com/       200
https://www.binliyou.com/   连不上</code></pre>
<p>麻烦的地方在于，域名不一致时百度<strong>不会报错</strong>。接口照样返回 200、照样给你 <code>success</code> 字段，只是提交的链接全部落在 <code>not_same_site</code> 数组里，一条都不入队。要是先装上跑一轮，当日配额花了、日志看着还挺正常，回头再查域名就晚了。</p>
<p>所以插件里第一个功能不是推送，是比对：</p>
<pre><code class="language-php">$want = parse_url($query['site'], PHP_URL_HOST);
if (!$want) {
    $want = $query['site'];
}
$mine = parse_url(BLOG_URL, PHP_URL_HOST);
if ($mine &amp;&amp; strcasecmp($want, $mine) !== 0) {
    return sprintf(
        '接口地址里的 site 域名是 %s，站点当前域名是 %s。两者不一致时百度会把链接全部退回 not_same_site，一条都不会入队；请先在资源平台改用站点真实域名的接口地址。',
        $want,
        $mine
    );
}</code></pre>
<p>不一致就在设置页顶上挂黄条，推送按钮点了也不执行。整件事里花的时间最少、价值最大的一处是这里。</p>
<h2>接口调用本身没什么花样</h2>
<p>POST 纯文本，一行一个 URL，<code>Content-Type: text/plain</code>：</p>
<pre><code class="language-php">curl_setopt_array($ch, [
    CURLOPT_POST =&gt; true,
    CURLOPT_POSTFIELDS =&gt; $body,          // implode("\n", $urls)
    CURLOPT_HTTPHEADER =&gt; ['Content-Type: text/plain', 'Expect:'],
    CURLOPT_RETURNTRANSFER =&gt; true,
    CURLOPT_CONNECTTIMEOUT =&gt; 10,
    CURLOPT_TIMEOUT =&gt; 30,
]);</code></pre>
<p>成功返回 <code>remain</code>（当天剩余配额）、<code>success</code>（成功条数）、<code>not_same_site</code>、<code>not_valid</code>；失败是 4xx 带 <code>error</code> 和 <code>message</code>。两个处理原则：<code>remain &lt;= 0</code> 立刻停止，配额当日有效不累计，继续打只是浪费；接口报错也停止，不要在下一次重试时把同一批反复推。</p>
<p>链接用 EMlog 自己的 <code>Url::log($gid)</code> 生成，这样站点哪天开了伪静态或改了 URL 模式，推出去的地址跟着变，不用插件操心。</p>
<h2>EMlog 插件的机制是翻源码确认的</h2>
<p>几条硬约定，名字写错一个就加载不上：</p>
<ul>
<li>入口 <code>baidu_push/baidu_push.php</code>，头部注释块是插件元信息（<code>Plugin Name</code> / <code>Version</code> / <code>Description</code>）。</li>
<li>配置读写走 <code>Storage::getInstance('baidu_push')</code>，数据落在 <code>storage</code> 表。插件名和键名都限 <strong>16 个字符、只能 <code>[\w_]</code></strong>，超了会被静默替换成 <code>DefaultPlugin</code> / <code>DefaultName</code>，这个坑够阴的。</li>
<li>设置页文件名必须是 <code>baidu_push_setting.php</code>，里面要有 <code>plugin_setting_view()</code> 和 <code>plugin_setting()</code> 两个函数，路由是 <code>admin/plugin.php?plugin=baidu_push</code>。</li>
<li>启用/升级/删除的回调在 <code>baidu_push_callback.php</code>，可选。我用了删除回调，卸载时把配置清掉，因为里面存着百度准入密钥：</li>
</ul>
<pre><code class="language-php">function callback_rm()
{
    $storage = Storage::getInstance(BaiduPush::PLUGIN);
    $storage-&gt;deleteAllName('YES');
}</code></pre>
<p>后台那条路由过了 <code>loginAuth::checkLogin()</code> 和角色校验，确认只有管理员能进，才敢把密钥存这儿。</p>
<h2>定时怎么触发</h2>
<p>EMlog 没有常驻进程，&quot;定时&quot;得借外力。三条路：crontab 直接调 php、crontab curl 一个带密钥的 URL、前台访问时顺带触发。</p>
<p>选了第一条为主、第二条为辅。理由是密钥进 URL 就会进 nginx 访问日志，等于天天在日志里写一遍。第三条没做，因为没人访问就永远不跑，跑起来还拖慢那一次请求。</p>
<p>一个文件里分两种入口：</p>
<pre><code class="language-php">if (PHP_SAPI === 'cli') {
    $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
    $_SERVER['REQUEST_METHOD'] = 'GET';
    $_SERVER['HTTP_USER_AGENT'] = 'emlog-baidu-push-cron';
}

require_once dirname(__DIR__, 2) . '/init.php';
require_once __DIR__ . '/baidu_push_core.php';

// init.php 已经把 Content-Type 设成 text/html，这里必须在其之后覆盖
if (PHP_SAPI !== 'cli') {
    header('Content-Type: text/plain; charset=UTF-8');
}</code></pre>
<p>那个注释是踩出来的：EMlog 的 <code>init.php</code> 第二行就 <code>header('Content-Type: text/html')</code>，你要是引导之前就设自己的头，会被它盖掉。CLI 那几行赋值是给 <code>getIp()</code> / <code>getUA()</code> 用的，虽然它们内部有 <code>isset</code> 兜底不会炸，但补上更干净。</p>
<p>后台那几个按钮限了最多五批（<code>BaiduPush::run($mode, 5)</code>），那是同步请求，让一次点击把全站扫完会把请求挂死。完整推送交给 crontab：</p>
<pre><code class="language-cron">30 8 * * * php /app/emlog/content/plugins/baidu_push/run.php increment &gt;&gt; /var/log/emlog-baidu-push.log 2&gt;&amp;1
0 4 * * 0  php /app/emlog/content/plugins/baidu_push/run.php full      &gt;&gt; /var/log/emlog-baidu-push.log 2&gt;&amp;1</code></pre>
<p>站点在 Docker 里就得走 <code>docker exec &lt;php容器&gt; php ...</code>。</p>
<h2>增量为什么扫 date，不挂钩子</h2>
<p>本来想在 <code>save_log</code> 钩子上记一笔&quot;这篇待推&quot;，后来改成直接扫数据库：</p>
<pre><code class="language-sql">-- 增量：水位之后发布或更新过的可见文章
SELECT gid, date FROM blog
 WHERE type='blog' AND hide='n' AND checked='y' AND date &gt; &lt;水位&gt;
 ORDER BY date ASC, gid ASC;

-- 全量：按主键游标分批，可断点续推
SELECT gid FROM blog
 WHERE type='blog' AND hide='n' AND checked='y' AND gid &gt; &lt;游标&gt;
 ORDER BY gid ASC LIMIT &lt;批大小&gt;;</code></pre>
<p>关键前提是 EMlog 更新文章时会刷新 <code>blog.date</code>，所以&quot;改过的&quot;和&quot;新增的&quot;都会被这条查询带出来，不需要额外记账。好处是插件在正常请求链路上一点开销都不占——入口文件除了 <code>require_once</code> 什么都不做，不挂任何前台钩子。</p>
<p>草稿和待审核的推不出去，靠 <code>hide='n' AND checked='y'</code> 挡。</p>
<h2>人工读代码抓出的一个真 bug</h2>
<p>游标推进原来是这么写的，在拼 URL 的循环里顺手往前推：</p>
<pre><code class="language-php">foreach ($rows as $row) {
    $cursor = $row['gid'];          // 错：还没推就先走了
    $urls[] = Url::log($row['gid']);
}
self::pushBatch($api, $urls, $stat);</code></pre>
<p>这样一批推送失败、百度没收下，游标照样走过了那批 gid，那些链接就永久漏推了，除非手动把游标归零重扫。改成先记在局部变量里，确认这批真送出去了才推进：</p>
<pre><code class="language-php">$lastGid = $cursor;
foreach ($rows as $row) {
    $urls[] = Url::log($row['gid']);
    $lastGid = $row['gid'];
}
$before = $stat['batches'];
self::pushBatch($api, $urls, $stat);
// 只有这一批真正送出去了才推进游标，失败的那批下次仍会重试
if ($stat['batches'] &gt; $before) {
    $cursor = $lastGid;
}</code></pre>
<p>增量水位同理。这类问题不在任何报错里，跑起来一切正常，只有读代码能看见——写完通读一遍这个动作，值。</p>
<h2>并发就一个 flock</h2>
<pre><code class="language-php">$file = sys_get_temp_dir() . '/emlog_baidu_push.lock';
$fp = @fopen($file, 'c');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
    $age = time() - (int)@filemtime($file);
    fclose($fp);
    return $age &gt; self::LOCK_TTL ? self::forceLock($file) : false;
}
@touch($file);</code></pre>
<p>拿不到锁就这次跳过并记录原因，不排队不重试。锁文件超过三十分钟视为异常残留会尝试强制接管，但接管用的还是非阻塞 flock，原来那个进程活着的时候抢不到，所以最坏情况只是多跳过一次，不会出现两个任务同时往百度推。</p>
<h2>打包传后台也有坑</h2>
<p>后台「安装插件」要求上传 zip，解压前的校验逻辑是这样的：</p>
<pre><code class="language-php">$r = explode('/', $zip-&gt;getNameIndex(0), 2);
$dir = isset($r[0]) ? $r[0] . '/' : '';
// type == 'plugin' 时
$plugin_name = substr($dir, 0, -1);
$re = $zip-&gt;getFromName($dir . $plugin_name . '.php');
if (false === $re) {
    return -1;   // 前端提示"插件包格式不正确"
}</code></pre>
<p>它拿 <strong>zip 第一个条目的顶层目录名</strong>当插件名，再要求那个目录下有同名 php。所以文件平铺在压缩包根目录会直接失败，必须是一层目录包着。</p>
<p>另一个坑在打包工具：Windows PowerShell 5.1 的 <code>Compress-Archive</code> 会把条目名写成反斜杠（<code>baidu_push\baidu_push.php</code>），上面那句 <code>explode('/')</code> 就切不出目录了，报错还只告诉你&quot;格式不正确&quot;。改用 bsdtar：</p>
<pre><code>C:\Windows\System32\tar.exe -a -cf baidu_push.zip baidu_push</code></pre>
<p>打完用 <code>tar -tf</code> 回读确认条目是正斜杠：</p>
<pre><code>baidu_push/
baidu_push/baidu_push.php
baidu_push/baidu_push_core.php
baidu_push/baidu_push_setting.php
baidu_push/baidu_push_callback.php
baidu_push/run.php
baidu_push/README.md</code></pre>]]></description>
    <pubDate>Mon, 26 Oct 2026 16:43:33 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=5</guid>
</item>
<item>
    <title>给 EMlog REST API 写一个零依赖的发文脚本</title>
    <link>https://binliyou.com/?post=4</link>
    <description><![CDATA[<p>EMlog 的 REST 接口可以完成发文、改文、查分类、传图：入口统一是 <code>https://站点/?rest-api=&lt;方法名&gt;</code>。官方文档只给了参数列表，实际行为有几处和直觉不一致。下面三条是写发布脚本时踩到的，结论均来自源码核对（<code>include/controller/api_controller.php</code>、<code>include/lib/input.php</code>、<code>include/model/log_model.php</code>）。</p>
<h2>1. 请求体只接受表单编码</h2>
<p><strong>现象</strong>：<code>POST</code> 一个 JSON 到 <code>?rest-api=article_post</code>，返回 <code>{"code":1,"msg":"parameter error"}</code>，HTTP 400。</p>
<p><strong>原因</strong>：所有入参经 <code>Input::postStrVar()</code> 取值：</p>
<pre><code class="language-php">public static function postStrVar($var_name, $var_default = '')
{
    $str = filter_input(INPUT_POST, $var_name);
    return $str ? addslashes(trim($str)) : $var_default;
}</code></pre>
<p><code>filter_input(INPUT_POST, ...)</code> 只解析 <code>application/x-www-form-urlencoded</code> 和 <code>multipart/form-data</code>。JSON 请求体不会进入 <code>$_POST</code>，于是 <code>title</code>、<code>content</code> 全部取到默认空值，触发了发布方法里的这个分支：</p>
<pre><code class="language-php">if (empty($title) || empty($content)) {
    Output::error('parameter error');
}</code></pre>
<p>错误信息不区分&quot;参数没传&quot;和&quot;参数没被解析&quot;，排查时容易往鉴权方向想。</p>
<p><strong>写法</strong>：<code>Content-Type: application/x-www-form-urlencoded</code>。Node 侧 <code>new URLSearchParams()</code> 即可，上传接口例外，用 <code>FormData</code>。</p>
<h2>2. <code>article_update</code> 是整行覆盖</h2>
<p><strong>现象</strong>：只想改标题，结果正文变空；只想换标签，封面丢失。</p>
<p><strong>原因</strong>：更新方法组装的 <code>$logData</code> 无条件包含全部字段，<code>updateLog()</code> 把它们逐个拼进 <code>SET</code> 子句：</p>
<pre><code class="language-php">$logData = [
    'title'   =&gt; $title,
    'content' =&gt; $content,
    'excerpt' =&gt; $excerpt,
    'sortid'  =&gt; $sort_id,
    'cover'   =&gt; $cover,
    'author'  =&gt; $author_uid,
    'date'    =&gt; strtotime($post_date ?: date('Y-m-d H:i:s')),
    'hide'    =&gt; $draft === 'y' ? 'y' : 'n',
];
$this-&gt;Log_Model-&gt;updateLog($logData, $id, $author_uid);</code></pre>
<p>没有&quot;字段未传则跳过&quot;的判断。缺省值直接落库：</p>
<table>
<thead>
<tr>
<th>未传参数</th>
<th>落库结果</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>content</code></td>
<td>正文清空</td>
</tr>
<tr>
<td><code>cover</code></td>
<td>封面清空</td>
</tr>
<tr>
<td><code>sort_id</code></td>
<td><code>-1</code>（未分类）</td>
</tr>
<tr>
<td><code>post_date</code></td>
<td>发布时间改为当前时间</td>
</tr>
<tr>
<td><code>draft</code></td>
<td><code>hide='n'</code>，草稿被发布</td>
</tr>
</tbody>
</table>
<p><strong>写法</strong>：更新前先读回原记录，与本次要改的字段合并后整体提交。读取接口有两个：</p>
<ul>
<li><code>article_detail</code>：返回 <code>content</code>（Parsedown 渲染后的 HTML）和 <code>content_raw</code>（库中原文）。回填必须用 <code>content_raw</code>，否则一轮编辑之后正文里全是标签。</li>
<li><code>draft_detail</code>：只返回 <code>hide='y'</code> 的记录，<code>content</code> 即原文，无需二次判断。</li>
</ul>
<p>两者的差异还能用来判断文章状态：先查 <code>draft_detail</code>，命中即草稿；未命中再查 <code>article_detail</code>，即为已发布。</p>
<pre><code class="language-js">let existing, isDraft;
try {
  const d = await apiGet('draft_detail', { id }, { auth: true, soft: true });
  existing = normalizeDraft(d.draft);
  isDraft = true;
} catch {
  const d = await apiGet('article_detail', { id });
  existing = normalizeArticle(d.article);   // 取 content_raw
}
const params = { id, ...existing, ...explicitOverrides };</code></pre>
<h2>3. 正文存原文，渲染发生在读取时</h2>
<p>保存链路不做任何格式转换，读取时才过 Parsedown：</p>
<pre><code class="language-php">$this-&gt;Parsedown = new Parsedown();
$this-&gt;Parsedown-&gt;setBreaksEnabled(true);   // 自动换行
...
'log_content' =&gt; $this-&gt;Parsedown-&gt;text($row['content']),
'content_raw' =&gt; $row['content'],</code></pre>
<p>由此得出三条约束：</p>
<ol>
<li>正文直接提交 Markdown。预先转成 HTML 会被再处理一次。</li>
<li><code>setBreaksEnabled(true)</code> 使单个换行输出 <code>&lt;br&gt;</code>。段落分隔必须用空行，不能依赖行尾空格。</li>
<li>后台编辑页由 <code>editormd</code> 渲染源码面板，看到 <code>##</code>、<code>|</code>、``` 属正常显示，不代表转换失败。</li>
</ol>
<p><code>log_model</code> 未调用 <code>setSafeMode</code>，因此原生 HTML 块（<code>&lt;img&gt;</code>、<code>&lt;table&gt;</code>、<code>&lt;details&gt;</code>）原样透传给浏览器。但自带的 <code>include/lib/parsedown.php</code> 没有 <code>hideLines</code> 实现，GitHub 风格的 <code>|||</code> 折叠语法不会渲染，需要折叠用 <code>&lt;details&gt;&lt;summary&gt;</code>。</p>
<h2>默认值与开关</h2>
<table>
<thead>
<tr>
<th>项</th>
<th>行为</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>allow_remark</code></td>
<td>默认 <code>n</code>，即评论关闭；需显式传 <code>y</code>，且受全站评论总开关约束</td>
</tr>
<tr>
<td><code>sort_id</code></td>
<td>默认 <code>-1</code>（未分类）；分类 ID 由 <code>sort_list</code> 获取，该接口无需鉴权</td>
</tr>
<tr>
<td><code>draft</code></td>
<td><code>y</code> 映射为 <code>hide='y'</code>，前台不可见，<code>article_detail</code> 返回 <code>Article not found</code></td>
</tr>
<tr>
<td><code>post_date</code></td>
<td>经 <code>strtotime()</code> 解析；晚于当前时间不报错，但首页查询带 <code>date&lt;=now</code>，届时才可见</td>
</tr>
<tr>
<td>鉴权</td>
<td><code>api_key=&lt;key&gt;</code>，或 <code>req_time=&lt;unix&gt;</code> + <code>req_sign=md5(req_time . api_key)</code>，时间窗 ±300 秒</td>
</tr>
<tr>
<td>API 开关</td>
<td>后台未开启时除 <code>article_list</code> 外一律返回 <code>api is closed</code></td>
</tr>
</tbody>
</table>
<p>签名方式的价值在于密钥不出现在 URL 与访问日志里。前提是本机与服务器时钟差小于 300 秒，否则返回 <code>request expired</code>。</p>
<h2>脚本</h2>
<p>上述规则封装成单文件 CLI，无 npm 依赖（Node 18+ 自带 <code>fetch</code>/<code>FormData</code>/<code>Blob</code>）：</p>
<pre><code>node emlog.mjs post   --file 文章.md --draft --tags "EMlog,Node.js" --comments
node emlog.mjs show   --id 12          # 回读原文与元信息，校验字段是否丢失
node emlog.mjs update --id 12 --publish
node emlog.mjs upload --file 配图.png  # 返回绝对 URL，直接写入正文</code></pre>
<p><code>update</code> 不带 <code>--file</code> 时沿用线上正文，只提交显式指定的字段；带 <code>--file</code> 时整体替换。密钥从 <code>~/secrets/emlog.env</code> 读取，不作为命令行参数传入，且所有输出（含异常堆栈）统一把密钥替换为 <code>***</code>。</p>
<h2>一个限制</h2>
<p>REST 接口没有删除文章的方法。验证链路产生的测试稿只能登录后台手动删除，因此测试一律用 <code>--draft</code>（前台不可见），正式内容也先落草稿、确认后再 <code>update --publish</code>。</p>]]></description>
    <pubDate>Thu, 22 Oct 2026 17:33:00 +0800</pubDate>
    <dc:creator>blogger</dc:creator>
    <guid>https://binliyou.com/?post=4</guid>
</item>
</channel>
</rss>