<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Docker on 超越网</title><link>https://www.chaoyuewang.cn/tags/docker/</link><description>Recent content in Docker on 超越网</description><generator>Hugo</generator><language>zh-cn</language><lastBuildDate>Thu, 28 May 2026 10:20:00 +0800</lastBuildDate><atom:link href="https://www.chaoyuewang.cn/tags/docker/index.xml" rel="self" type="application/rss+xml"/><item><title>Docker Compose 多环境管理：从开发到生产的优雅方案</title><link>https://www.chaoyuewang.cn/posts/ops/docker-compose-multi-environment/</link><pubDate>Thu, 28 May 2026 10:20:00 +0800</pubDate><guid>https://www.chaoyuewang.cn/posts/ops/docker-compose-multi-environment/</guid><description>&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;2025年，我经历过三次因为环境不一致导致的线上故障。每次排查都花费数小时，最终发现是开发环境和生产环境的配置差异造成的。&lt;/p&gt;
&lt;p&gt;从那时起，我开始系统性地重构多环境管理方案。这篇文章记录完整的实践过程，包括目录结构、配置管理和部署流程。&lt;/p&gt;
&lt;h2 id="一问题根源"&gt;一、问题根源&lt;/h2&gt;
&lt;h3 id="11-常见痛点"&gt;1.1 常见痛点&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;th&gt;现象&lt;/th&gt;
&lt;th&gt;影响&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;配置硬编码&lt;/td&gt;
&lt;td&gt;环境变量写死在 docker-compose.yml&lt;/td&gt;
&lt;td&gt;切换环境需修改文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;镜像版本混乱&lt;/td&gt;
&lt;td&gt;开发用 latest，生产用具体版本&lt;/td&gt;
&lt;td&gt;行为不一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;依赖管理缺失&lt;/td&gt;
&lt;td&gt;数据库迁移脚本未版本化&lt;/td&gt;
&lt;td&gt;数据不一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;密钥管理不当&lt;/td&gt;
&lt;td&gt;敏感信息明文存储&lt;/td&gt;
&lt;td&gt;安全风险&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="12-根本原因"&gt;1.2 根本原因&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;环境隔离不彻底&lt;/strong&gt;：开发、测试、生产共用同一份配置模板，仅靠注释区分。&lt;/p&gt;
&lt;h2 id="二目录结构设计"&gt;二、目录结构设计&lt;/h2&gt;
&lt;h3 id="21-推荐结构"&gt;2.1 推荐结构&lt;/h3&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;project/
├── docker-compose.yml # 基础配置（公共部分）
├── docker-compose.override.yml # 本地开发覆盖
├── environments/
│ ├── dev/
│ │ ├── docker-compose.dev.yml
│ │ └── .env.dev
│ ├── staging/
│ │ ├── docker-compose.staging.yml
│ │ └── .env.staging
│ └── prod/
│ ├── docker-compose.prod.yml
│ └── .env.prod
├── scripts/
│ ├── deploy.sh
│ └── rollback.sh
└── .gitignore
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="22-基础配置docker-composeyml"&gt;2.2 基础配置（docker-compose.yml）&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3.8&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${APP_IMAGE:-myapp:latest}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;unless-stopped&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;NODE_ENV=${NODE_ENV:-development}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;LOG_LEVEL=${LOG_LEVEL:-info}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;depends_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;db&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;redis&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;postgres:${POSTGRES_VERSION:-16}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;db_data:/var/lib/postgresql/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;POSTGRES_DB=${POSTGRES_DB:-app}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;POSTGRES_USER=${POSTGRES_USER:-app}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;POSTGRES_PASSWORD_FILE=/run/secrets/db_password&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;redis:${REDIS_VERSION:-7-alpine}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;redis-server --maxmemory 256mb&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;db_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="23-生产环境覆盖environmentsproddocker-composeprodyml"&gt;2.3 生产环境覆盖（environments/prod/docker-compose.prod.yml）&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3.8&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;myapp:${APP_VERSION:-1.0.0}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cpus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;2&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;2G&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;512M&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;healthcheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;CMD&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;curl&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;-f&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;http://localhost:3000/health&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;30s&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;10s&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;db_password&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;frontend&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;backend&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;4G&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;db_data:/var/lib/postgresql/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;./backups:/backups&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;db_password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;bridge&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="三环境变量管理"&gt;三、环境变量管理&lt;/h2&gt;
&lt;h3 id="31-env-文件规范"&gt;3.1 .env 文件规范&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# .env.prod&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 应用配置&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;APP_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.0.0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;warn
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 数据库&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;POSTGRES_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app_prod
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Redis&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;REDIS_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;7-alpine
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 镜像仓库&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;REGISTRY_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.example.com
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="32-密钥管理"&gt;3.2 密钥管理&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;不要将密钥存入 .env 文件&lt;/strong&gt;！&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="前言">前言</h2>
<p>2025年，我经历过三次因为环境不一致导致的线上故障。每次排查都花费数小时，最终发现是开发环境和生产环境的配置差异造成的。</p>
<p>从那时起，我开始系统性地重构多环境管理方案。这篇文章记录完整的实践过程，包括目录结构、配置管理和部署流程。</p>
<h2 id="一问题根源">一、问题根源</h2>
<h3 id="11-常见痛点">1.1 常见痛点</h3>
<table>
	<thead>
			<tr>
					<th>问题</th>
					<th>现象</th>
					<th>影响</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>配置硬编码</td>
					<td>环境变量写死在 docker-compose.yml</td>
					<td>切换环境需修改文件</td>
			</tr>
			<tr>
					<td>镜像版本混乱</td>
					<td>开发用 latest，生产用具体版本</td>
					<td>行为不一致</td>
			</tr>
			<tr>
					<td>依赖管理缺失</td>
					<td>数据库迁移脚本未版本化</td>
					<td>数据不一致</td>
			</tr>
			<tr>
					<td>密钥管理不当</td>
					<td>敏感信息明文存储</td>
					<td>安全风险</td>
			</tr>
	</tbody>
</table>
<h3 id="12-根本原因">1.2 根本原因</h3>
<p><strong>环境隔离不彻底</strong>：开发、测试、生产共用同一份配置模板，仅靠注释区分。</p>
<h2 id="二目录结构设计">二、目录结构设计</h2>
<h3 id="21-推荐结构">2.1 推荐结构</h3>
<pre tabindex="0"><code>project/
├── docker-compose.yml          # 基础配置（公共部分）
├── docker-compose.override.yml # 本地开发覆盖
├── environments/
│   ├── dev/
│   │   ├── docker-compose.dev.yml
│   │   └── .env.dev
│   ├── staging/
│   │   ├── docker-compose.staging.yml
│   │   └── .env.staging
│   └── prod/
│       ├── docker-compose.prod.yml
│       └── .env.prod
├── scripts/
│   ├── deploy.sh
│   └── rollback.sh
└── .gitignore
</code></pre><h3 id="22-基础配置docker-composeyml">2.2 基础配置（docker-compose.yml）</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;3.8&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">${APP_IMAGE:-myapp:latest}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">NODE_ENV=${NODE_ENV:-development}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">LOG_LEVEL=${LOG_LEVEL:-info}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:${POSTGRES_VERSION:-16}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">db_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=${POSTGRES_DB:-app}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=${POSTGRES_USER:-app}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD_FILE=/run/secrets/db_password</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">redis:${REDIS_VERSION:-7-alpine}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">redis-server --maxmemory 256mb</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db_data</span><span class="p">:</span><span class="w">
</span></span></span></code></pre></div><h3 id="23-生产环境覆盖environmentsproddocker-composeprodyml">2.3 生产环境覆盖（environments/prod/docker-compose.prod.yml）</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;3.8&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">myapp:${APP_VERSION:-1.0.0}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cpus</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">2G</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">reservations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">512M</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:3000/health&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">secrets</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">db_password</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">backend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">4G</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">db_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./backups:/backups</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">secrets</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db_password</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">frontend</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">bridge</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">backend</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">internal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><h2 id="三环境变量管理">三、环境变量管理</h2>
<h3 id="31-env-文件规范">3.1 .env 文件规范</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># .env.prod</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 应用配置</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP_VERSION</span><span class="o">=</span>1.0.0
</span></span><span class="line"><span class="cl"><span class="nv">NODE_ENV</span><span class="o">=</span>production
</span></span><span class="line"><span class="cl"><span class="nv">LOG_LEVEL</span><span class="o">=</span>warn
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 数据库</span>
</span></span><span class="line"><span class="cl"><span class="nv">POSTGRES_VERSION</span><span class="o">=</span><span class="m">16</span>
</span></span><span class="line"><span class="cl"><span class="nv">POSTGRES_DB</span><span class="o">=</span>app_prod
</span></span><span class="line"><span class="cl"><span class="nv">POSTGRES_USER</span><span class="o">=</span>app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Redis</span>
</span></span><span class="line"><span class="cl"><span class="nv">REDIS_VERSION</span><span class="o">=</span>7-alpine
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 镜像仓库</span>
</span></span><span class="line"><span class="cl"><span class="nv">REGISTRY_URL</span><span class="o">=</span>registry.example.com
</span></span></code></pre></div><h3 id="32-密钥管理">3.2 密钥管理</h3>
<p><strong>不要将密钥存入 .env 文件</strong>！</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 使用 Docker secrets</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;your-secure-password&#34;</span> <span class="p">|</span> docker secret create db_password -
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 或在 Kubernetes 中使用 Secret</span>
</span></span><span class="line"><span class="cl">kubectl create secret generic db-credentials --from-literal<span class="o">=</span><span class="nv">password</span><span class="o">=</span>your-secure-password
</span></span></code></pre></div><h2 id="四部署脚本">四、部署脚本</h2>
<h3 id="41-部署脚本scriptsdeploysh">4.1 部署脚本（scripts/deploy.sh）</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="nv">dev</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROJECT_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BASH_SOURCE</span><span class="p">[0]</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;🚀 部署到环境: </span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. 加载环境变量</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -a
</span></span><span class="line"><span class="cl"><span class="nb">source</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PROJECT_DIR</span><span class="si">}</span><span class="s2">/environments/</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">/.env.</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> +a
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. 拉取最新镜像</span>
</span></span><span class="line"><span class="cl">docker compose -f docker-compose.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               -f environments/<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>/docker-compose.<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               pull
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. 执行数据库迁移</span>
</span></span><span class="line"><span class="cl">docker compose -f docker-compose.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               -f environments/<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>/docker-compose.<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               run --rm app npm run migrate
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. 启动服务</span>
</span></span><span class="line"><span class="cl">docker compose -f docker-compose.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               -f environments/<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>/docker-compose.<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               up -d --remove-orphans
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. 健康检查</span>
</span></span><span class="line"><span class="cl">sleep <span class="m">10</span>
</span></span><span class="line"><span class="cl">docker compose -f docker-compose.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               -f environments/<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>/docker-compose.<span class="si">${</span><span class="nv">ENV</span><span class="si">}</span>.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">               ps
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;✅ 部署完成&#34;</span>
</span></span></code></pre></div><h3 id="42-回滚脚本scriptsrollbacksh">4.2 回滚脚本（scripts/rollback.sh）</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="nv">dev</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROJECT_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BASH_SOURCE</span><span class="p">[0]</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;⏪ 回滚环境: </span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 获取上一个版本</span>
</span></span><span class="line"><span class="cl"><span class="nv">PREV_VERSION</span><span class="o">=</span><span class="k">$(</span>docker images --format <span class="s2">&#34;{{.Tag}}&#34;</span> myapp <span class="p">|</span> head -2 <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 更新环境变量</span>
</span></span><span class="line"><span class="cl">sed -i <span class="s2">&#34;s/APP_VERSION=.*/APP_VERSION=</span><span class="si">${</span><span class="nv">PREV_VERSION</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PROJECT_DIR</span><span class="si">}</span><span class="s2">/environments/</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">/.env.</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 重新部署</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;</span><span class="si">${</span><span class="nv">PROJECT_DIR</span><span class="si">}</span><span class="s2">/scripts/deploy.sh&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;✅ 回滚完成至版本: </span><span class="si">${</span><span class="nv">PREV_VERSION</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span></code></pre></div><h2 id="五cicd-集成">五、CI/CD 集成</h2>
<h3 id="51-github-actions-示例">5.1 GitHub Actions 示例</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/deploy.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy-staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./scripts/deploy.sh staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">DOCKER_REGISTRY_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.DOCKER_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy-prod</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./scripts/deploy.sh prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">DOCKER_REGISTRY_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.DOCKER_TOKEN }}</span><span class="w">
</span></span></span></code></pre></div><h2 id="六最佳实践总结">六、最佳实践总结</h2>
<table>
	<thead>
			<tr>
					<th>实践</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>✅ 基础配置与覆盖分离</td>
					<td>docker-compose.yml 放公共配置，环境文件放差异</td>
			</tr>
			<tr>
					<td>✅ 使用 .env 文件</td>
					<td>不要硬编码环境变量</td>
			</tr>
			<tr>
					<td>✅ 密钥使用 secrets</td>
					<td>不要将密钥存入版本控制</td>
			</tr>
			<tr>
					<td>✅ 固定镜像版本</td>
					<td>避免 latest 标签导致的不一致</td>
			</tr>
			<tr>
					<td>✅ 健康检查</td>
					<td>确保服务真正可用后再认为部署成功</td>
			</tr>
			<tr>
					<td>✅ 回滚方案</td>
					<td>每次部署前确认可以快速回滚</td>
			</tr>
			<tr>
					<td>❌ 不要手动修改线上配置</td>
					<td>所有变更通过代码审查</td>
			</tr>
			<tr>
					<td>❌ 不要共享 .env 文件</td>
					<td>每个环境独立文件</td>
			</tr>
	</tbody>
</table>
<h2 id="七总结">七、总结</h2>
<p>多环境管理的核心原则：</p>
<ol>
<li><strong>配置即代码</strong>：所有环境配置版本化</li>
<li><strong>最小差异</strong>：基础配置最大化，环境差异最小化</li>
<li><strong>自动化部署</strong>：减少人为操作，提高一致性</li>
<li><strong>可回滚</strong>：每次部署都有明确的回滚路径</li>
</ol>
<p>如果你也在为环境不一致头疼，我的建议是：<strong>尽早建立规范</strong>，不要等到问题频发时才重构。</p>
<hr>
<blockquote>
<p><strong>更新日志</strong>：本文基于2026年5月实践编写，具体命令和配置可能因项目而异，请以实际需求为准。</p>
</blockquote>
]]></content:encoded></item><item><title>SSH密钥持久化：为什么容器内生成的密钥在重启后丢失</title><link>https://www.chaoyuewang.cn/posts/ops/ssh-key-persistence/</link><pubDate>Wed, 27 May 2026 13:00:00 +0800</pubDate><guid>https://www.chaoyuewang.cn/posts/ops/ssh-key-persistence/</guid><description>&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;2026年5月，我遇到一个反复出现的问题：容器内生成的SSH密钥在容器重启后丢失，导致无法通过SSH连接到宿主机。&lt;/p&gt;
&lt;p&gt;这个问题看似简单，但背后涉及Docker容器的文件系统隔离机制。这篇文章记录完整的排查过程和最终解决方案。&lt;/p&gt;
&lt;h2 id="一问题现象"&gt;一、问题现象&lt;/h2&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;现象：SSH密钥在容器内生成，容器重启后密钥消失，无法连接宿主机
时间：2026-05-18
环境：fnOS虚拟化平台 + Ubuntu 24.04 VM + Docker
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;初始错误&lt;/strong&gt;：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Warning: Permanently added &amp;#39;192.168.0.200&amp;#39; (ED25519) to the list of known hosts.
Permission denied (publickey).
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="二根因分析"&gt;二、根因分析&lt;/h2&gt;
&lt;h3 id="21-docker容器的文件系统隔离"&gt;2.1 Docker容器的文件系统隔离&lt;/h3&gt;
&lt;p&gt;Docker容器使用&lt;strong&gt;联合文件系统（UnionFS）&lt;/strong&gt;，容器内的文件系统是独立的。当容器重启时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;容器内生成的文件&lt;/strong&gt; → 存储在容器的可写层&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;容器重启&lt;/strong&gt; → 可写层被销毁，所有未持久化的文件丢失&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH密钥丢失&lt;/strong&gt; → 无法通过密钥认证连接宿主机&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="22-为什么宿主机ssh拒绝使用容器内密钥"&gt;2.2 为什么宿主机SSH拒绝使用容器内密钥&lt;/h3&gt;
&lt;p&gt;即使密钥被挂载到容器，宿主机SSH服务也会拒绝使用：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;# /var/log/auth.log
sshd[12345]: Authentication refused: bad ownership or modes for key file
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：SSH要求私钥文件所有者必须是 &lt;code&gt;root:root&lt;/code&gt;，且权限为 &lt;code&gt;600&lt;/code&gt;。容器内生成的密钥，挂载后文件所有者可能不匹配。&lt;/p&gt;
&lt;h2 id="三解决方案"&gt;三、解决方案&lt;/h2&gt;
&lt;h3 id="31-核心原则"&gt;3.1 核心原则&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;密钥必须在宿主机生成，不能容器内生成。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="32-完整步骤"&gt;3.2 完整步骤&lt;/h3&gt;
&lt;h4 id="步骤1在宿主机生成ssh密钥"&gt;步骤1：在宿主机生成SSH密钥&lt;/h4&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 宿主机执行（192.168.0.200）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh-keygen -t ed25519 -C &lt;span class="s2"&gt;&amp;#34;hermes-agent&amp;#34;&lt;/span&gt; -f /home/ksboy/.ssh/hermes_key
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 设置权限&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chmod &lt;span class="m"&gt;600&lt;/span&gt; /home/ksboy/.ssh/hermes_key
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chmod &lt;span class="m"&gt;644&lt;/span&gt; /home/ksboy/.ssh/hermes_key.pub
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="步骤2将公钥添加到宿主机授权文件"&gt;步骤2：将公钥添加到宿主机授权文件&lt;/h4&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 宿主机执行&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /home/ksboy/.ssh/hermes_key.pub &amp;gt;&amp;gt; /home/ksboy/.ssh/authorized_keys
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chmod &lt;span class="m"&gt;600&lt;/span&gt; /home/ksboy/.ssh/authorized_keys
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="步骤3在docker-composeyml中挂载密钥"&gt;步骤3：在docker-compose.yml中挂载密钥&lt;/h4&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hermes-agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;hermes-agent:latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# 密钥挂载（只读模式）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/home/ksboy/.ssh/hermes_key:/root/.ssh/id_ed25519:ro&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/home/ksboy/.ssh/hermes_key.pub:/root/.ssh/id_ed25519.pub:ro&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# SSH配置&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/home/ksboy/.ssh/config:/root/.ssh/config:ro&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;SSH_HOST=192.168.0.200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;SSH_USER=ksboy&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="步骤4宿主机ssh配置调整"&gt;步骤4：宿主机SSH配置调整&lt;/h4&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# /etc/ssh/sshd_config&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 允许Docker网段访问&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ListenAddress 0.0.0.0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 重启SSH服务&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl restart sshd
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="33-验证"&gt;3.3 验证&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 容器内测试&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh -i /root/.ssh/id_ed25519 ksboy@192.168.0.200 &lt;span class="s2"&gt;&amp;#34;echo &amp;#39;连接成功&amp;#39;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 重启容器后再次测试&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker-compose restart hermes-agent
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh -i /root/.ssh/id_ed25519 ksboy@192.168.0.200 &lt;span class="s2"&gt;&amp;#34;echo &amp;#39;重启后连接成功&amp;#39;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="四关键要点"&gt;四、关键要点&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;要点&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;密钥生成位置&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;必须在宿主机&lt;/strong&gt;，容器内生成的密钥重启后丢失&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文件所有者&lt;/td&gt;
&lt;td&gt;宿主机密钥必须为 &lt;code&gt;root:root&lt;/code&gt;（容器以root运行）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;挂载模式&lt;/td&gt;
&lt;td&gt;使用 &lt;code&gt;:ro&lt;/code&gt; 只读模式，防止容器内意外修改&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH监听地址&lt;/td&gt;
&lt;td&gt;宿主机需监听 &lt;code&gt;0.0.0.0&lt;/code&gt;，允许Docker网段访问&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网络隔离&lt;/td&gt;
&lt;td&gt;容器在Docker网段，宿主机在LAN网段，需正确配置路由&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="五常见错误"&gt;五、常见错误&lt;/h2&gt;
&lt;h3 id="错误1容器内生成密钥"&gt;错误1：容器内生成密钥&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ❌ 错误做法&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; -it hermes-agent ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 容器重启后密钥丢失&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="错误2密钥权限不正确"&gt;错误2：密钥权限不正确&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ❌ 错误做法&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chmod &lt;span class="m"&gt;644&lt;/span&gt; /home/ksboy/.ssh/hermes_key &lt;span class="c1"&gt;# SSH拒绝使用&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ✅ 正确做法&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chmod &lt;span class="m"&gt;600&lt;/span&gt; /home/ksboy/.ssh/hermes_key
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="错误3宿主机ssh只监听localhost"&gt;错误3：宿主机SSH只监听localhost&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ❌ 错误做法&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ListenAddress 127.0.0.1 &lt;span class="c1"&gt;# Docker容器无法连接&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ✅ 正确做法&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ListenAddress 0.0.0.0
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="六总结"&gt;六、总结&lt;/h2&gt;
&lt;p&gt;SSH密钥持久化的核心是理解Docker的文件系统隔离机制：&lt;/p&gt;</description><content:encoded><![CDATA[<h2 id="前言">前言</h2>
<p>2026年5月，我遇到一个反复出现的问题：容器内生成的SSH密钥在容器重启后丢失，导致无法通过SSH连接到宿主机。</p>
<p>这个问题看似简单，但背后涉及Docker容器的文件系统隔离机制。这篇文章记录完整的排查过程和最终解决方案。</p>
<h2 id="一问题现象">一、问题现象</h2>
<pre tabindex="0"><code>现象：SSH密钥在容器内生成，容器重启后密钥消失，无法连接宿主机
时间：2026-05-18
环境：fnOS虚拟化平台 + Ubuntu 24.04 VM + Docker
</code></pre><p><strong>初始错误</strong>：</p>
<pre tabindex="0"><code>Warning: Permanently added &#39;192.168.0.200&#39; (ED25519) to the list of known hosts.
Permission denied (publickey).
</code></pre><h2 id="二根因分析">二、根因分析</h2>
<h3 id="21-docker容器的文件系统隔离">2.1 Docker容器的文件系统隔离</h3>
<p>Docker容器使用<strong>联合文件系统（UnionFS）</strong>，容器内的文件系统是独立的。当容器重启时：</p>
<ol>
<li><strong>容器内生成的文件</strong> → 存储在容器的可写层</li>
<li><strong>容器重启</strong> → 可写层被销毁，所有未持久化的文件丢失</li>
<li><strong>SSH密钥丢失</strong> → 无法通过密钥认证连接宿主机</li>
</ol>
<h3 id="22-为什么宿主机ssh拒绝使用容器内密钥">2.2 为什么宿主机SSH拒绝使用容器内密钥</h3>
<p>即使密钥被挂载到容器，宿主机SSH服务也会拒绝使用：</p>
<pre tabindex="0"><code># /var/log/auth.log
sshd[12345]: Authentication refused: bad ownership or modes for key file
</code></pre><p><strong>原因</strong>：SSH要求私钥文件所有者必须是 <code>root:root</code>，且权限为 <code>600</code>。容器内生成的密钥，挂载后文件所有者可能不匹配。</p>
<h2 id="三解决方案">三、解决方案</h2>
<h3 id="31-核心原则">3.1 核心原则</h3>
<p><strong>密钥必须在宿主机生成，不能容器内生成。</strong></p>
<h3 id="32-完整步骤">3.2 完整步骤</h3>
<h4 id="步骤1在宿主机生成ssh密钥">步骤1：在宿主机生成SSH密钥</h4>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 宿主机执行（192.168.0.200）</span>
</span></span><span class="line"><span class="cl">ssh-keygen -t ed25519 -C <span class="s2">&#34;hermes-agent&#34;</span> -f /home/ksboy/.ssh/hermes_key
</span></span><span class="line"><span class="cl"><span class="c1"># 设置权限</span>
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> /home/ksboy/.ssh/hermes_key
</span></span><span class="line"><span class="cl">chmod <span class="m">644</span> /home/ksboy/.ssh/hermes_key.pub
</span></span></code></pre></div><h4 id="步骤2将公钥添加到宿主机授权文件">步骤2：将公钥添加到宿主机授权文件</h4>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 宿主机执行</span>
</span></span><span class="line"><span class="cl">cat /home/ksboy/.ssh/hermes_key.pub &gt;&gt; /home/ksboy/.ssh/authorized_keys
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> /home/ksboy/.ssh/authorized_keys
</span></span></code></pre></div><h4 id="步骤3在docker-composeyml中挂载密钥">步骤3：在docker-compose.yml中挂载密钥</h4>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">hermes-agent</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">hermes-agent:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># 密钥挂载（只读模式）</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/ksboy/.ssh/hermes_key:/root/.ssh/id_ed25519:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/ksboy/.ssh/hermes_key.pub:/root/.ssh/id_ed25519.pub:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># SSH配置</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/ksboy/.ssh/config:/root/.ssh/config:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">SSH_HOST=192.168.0.200</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">SSH_USER=ksboy</span><span class="w">
</span></span></span></code></pre></div><h4 id="步骤4宿主机ssh配置调整">步骤4：宿主机SSH配置调整</h4>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># /etc/ssh/sshd_config</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 允许Docker网段访问</span>
</span></span><span class="line"><span class="cl">ListenAddress 0.0.0.0
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 重启SSH服务</span>
</span></span><span class="line"><span class="cl">systemctl restart sshd
</span></span></code></pre></div><h3 id="33-验证">3.3 验证</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 容器内测试</span>
</span></span><span class="line"><span class="cl">ssh -i /root/.ssh/id_ed25519 ksboy@192.168.0.200 <span class="s2">&#34;echo &#39;连接成功&#39;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 重启容器后再次测试</span>
</span></span><span class="line"><span class="cl">docker-compose restart hermes-agent
</span></span><span class="line"><span class="cl">ssh -i /root/.ssh/id_ed25519 ksboy@192.168.0.200 <span class="s2">&#34;echo &#39;重启后连接成功&#39;&#34;</span>
</span></span></code></pre></div><h2 id="四关键要点">四、关键要点</h2>
<table>
	<thead>
			<tr>
					<th>要点</th>
					<th>说明</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>密钥生成位置</td>
					<td><strong>必须在宿主机</strong>，容器内生成的密钥重启后丢失</td>
			</tr>
			<tr>
					<td>文件所有者</td>
					<td>宿主机密钥必须为 <code>root:root</code>（容器以root运行）</td>
			</tr>
			<tr>
					<td>挂载模式</td>
					<td>使用 <code>:ro</code> 只读模式，防止容器内意外修改</td>
			</tr>
			<tr>
					<td>SSH监听地址</td>
					<td>宿主机需监听 <code>0.0.0.0</code>，允许Docker网段访问</td>
			</tr>
			<tr>
					<td>网络隔离</td>
					<td>容器在Docker网段，宿主机在LAN网段，需正确配置路由</td>
			</tr>
	</tbody>
</table>
<h2 id="五常见错误">五、常见错误</h2>
<h3 id="错误1容器内生成密钥">错误1：容器内生成密钥</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># ❌ 错误做法</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -it hermes-agent ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519
</span></span><span class="line"><span class="cl"><span class="c1"># 容器重启后密钥丢失</span>
</span></span></code></pre></div><h3 id="错误2密钥权限不正确">错误2：密钥权限不正确</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># ❌ 错误做法</span>
</span></span><span class="line"><span class="cl">chmod <span class="m">644</span> /home/ksboy/.ssh/hermes_key  <span class="c1"># SSH拒绝使用</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ✅ 正确做法</span>
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> /home/ksboy/.ssh/hermes_key
</span></span></code></pre></div><h3 id="错误3宿主机ssh只监听localhost">错误3：宿主机SSH只监听localhost</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># ❌ 错误做法</span>
</span></span><span class="line"><span class="cl">ListenAddress 127.0.0.1  <span class="c1"># Docker容器无法连接</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ✅ 正确做法</span>
</span></span><span class="line"><span class="cl">ListenAddress 0.0.0.0
</span></span></code></pre></div><h2 id="六总结">六、总结</h2>
<p>SSH密钥持久化的核心是理解Docker的文件系统隔离机制：</p>
<ol>
<li><strong>容器内文件不是持久的</strong> → 密钥必须在宿主机生成</li>
<li><strong>权限必须匹配</strong> → 宿主机密钥所有者需与容器运行用户一致</li>
<li><strong>网络必须可达</strong> → 宿主机SSH需监听所有地址</li>
</ol>
<p>这个解决方案已经稳定运行超过2周，容器重启后SSH连接正常。</p>
<hr>
<blockquote>
<p><strong>相关文档</strong>：<a href="~/.hermes/skills/git-credential-persistence/SKILL.md">SSH密钥持久化技能</a></p>
</blockquote>
]]></content:encoded></item></channel></rss>