朕喜技术(ZhenxiJS)
2026.09.17
1 分钟阅读

UnicodeEncodeError: 'ascii' codec can't encode character '\xef' —— UTF-8 文件名在 fsenc=ascii 的 legacy 进程里写不进磁盘、读不回来、json.dumps 报 surrogates not allowed 的完整排查

日期:2026-09-17 | 分支:2026-09-17-0000 技术栈:Python 3.6 / Python 3.11、LC_ALL=C、sys.getfilesystemencoding()、surrogateescape、os.listdir、os.walk、O_NOFOLLOW、dir_fd、json.dumps、PEP 538、PEP 540、Docker Compose、named volume 关键词:UnicodeEncodeError ascii codec can't encode character、非 ASCII 文件名、UTF-8 文件名、Linux 中文文件名乱码、C locale、\udcc3\udcaf、surrogates not allowed、os.listdir 返回代理字符、legacy 组件编码、CentOS 7 Python 3.6、PEP538 locale coercion、PEP540 UTF-8 mode、PYTHONUTF8、PYTHONCOERCECLOCALE、docker volume 盖住代码目录


一、问题背景

内网「报表 exporter」流水线,上游下发一份文件清单(MANIFEST), 里面有个名字带 ï(U+00EF,拉丁小写字母 I 分音符):

2026-Q1/naïve_ledger.txt
2026-Q1/naïve_export.txt
2026-Q1/index.json

legacy 组件(老的 exporter,由 supervisor 以 LC_ALL=C 拉起,跑 Python 3.6) 按清单把文件写进共享目录 /srv/raw_data,再算 md5 对账,产物落成 http_report.json。

现场现象(三连击):

[srv] start exporter ... LC_ALL=C LANG=C
Traceback (most recent call last):
  File "/srv/app/report_pipeline.py", line 116, in <module>
    res = run(seed=True)
  File "/srv/app/report_pipeline.py", line 86, in run
    store.write_bytes(name, payload)
  File "/srv/data_tools/nonascii_store.py", line 23, in write_bytes
    with open(path, "wb") as f:
UnicodeEncodeError: 'ascii' codec can't encode character '\xef' in position 24:
                   ordinal not in range(128)
  1. raw_data 里只有 index.json(纯 ASCII 的那个),两个 naïve_* 都没了;
  2. 手工把 naïve_ledger.txt 拷进 raw_data 之后,legacy 还是说「找不到文件」;
  3. /report 接口 503;
  4. 运维用 Python 3.11 跑同样的代码,居然全对了。

已排除:磁盘满 / 权限 / 文件系统不支持 UTF-8 / 代码拼写错误 / Python 3.6 坏了。


二、复现步骤

git fetch origin
git checkout 2026-09-17-0000
cd scenario/2026-09-17-0000

docker compose down -v          # ★ 见第四节「避坑」,必须带 -v
docker compose up -d --build

1)看进程的编码环境(一条命令定生死)

docker compose exec legacy_report bash /srv/ops/demo_new_env.sh
python      : 3.6.15
fsencoding  : ascii                    <-- 一切的起点
preferred   : ANSI_X3.4-1968
utf8_mode   : N/A (python < 3.7)
LANG        : C
LC_ALL      : C

2)第一击:名字写不到磁盘

docker compose exec legacy_report bash /srv/ops/run_legacy.sh
# UnicodeEncodeError: 'ascii' codec can't encode character '\xef' in position 24

3)三方对照诊断

docker compose exec legacy_report bash /srv/ops/explain.sh
=== 2) names in MANIFEST (str in this process) ===
    ledger  repr='2026-Q1/na\xefve_ledger.txt'
            utf8 bytes=323032362d51312f6e61c3af76655f6c65646765722e747874
=== 3) os.fsencode(manifest_name) FAIL UnicodeEncodeError | 'ascii' codec ...
=== 4) real names on disk (raw kernels bytes, hex) ===
    hex  = 2f7372762f7261775f646174612f323032362d51312f6e61c3af76655f6c65646765722e747874
    repr = 'na\udcc3\udcafve_ledger.txt'

4)第二 / 第三击:文件在盘上,还是读不对 + 报文发不出去

docker compose exec legacy_report bash /srv/ops/demo_surrogate_trap.sh
STEP 0: sys.getfilesystemencoding() = ascii
STEP 1: file name bytes = 6e61c3af76655f6c65646765722e747874   <- 磁盘上字节是对的
STEP 2: os.listdir(str)   -> 'na\udcc3\udcafve_ledger.txt'
        lone surrogates   -> 2
        str-name == manifest ? False     <- 同一个文件,两个 str 形态
STEP 2b: os.listdir(bytes) -> [b'na\xc3\xafve_ledger.txt']       <- 不做解码,反而对
STEP 3: open(manifest name) FAIL UnicodeEncodeError ... '\xef'
STEP 4: json.dumps(ensure_ascii=False) FAIL | surrogates not allowed
        json.dumps(ensure_ascii=True)  OK -> {"path": "na\udcc3\udcafve_ledger.txt"}
STEP 6: os.stat(bytes_path) ino=... size=18
        os.stat(str_path)   FAIL UnicodeEncodeError

5)换 Python 版本就「看不见」了

bash ops/demo_python_version_contrast.sh
========== python 3.6 ==========   fsencoding : ascii   utf8_mode : N/A
========== python 3.11 ==========  fsencoding : utf-8   utf8_mode : 1
========== py3.11 + PYTHONUTF8=0 PYTHONCOERCECLOCALE=0 ==========
                                   fsencoding : ascii   utf8_mode : 0

6)故障期验收基线

docker compose exec legacy_report bash /srv/ops/run_legacy.sh     # 先 seed
docker compose exec legacy_report bash /srv/ops/verify.sh
# PASS=2 FAIL=6   exit 1

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:18000/report   # 503
docker compose exec legacy_report python3 /srv/ops/verify_report.py           # PASS=0 FAIL=1

三、根本原因

LC_ALL=C → locale codeset 是 ANSI_X3.4-1968 → Python 3.6 的 sys.getfilesystemencoding() 就是 ascii。 CPython 把 str 路径交给内核前会做 os.fsencode(),用的正是这个编码。

于是同一个文件出现了三个不同的「名字」:

视角名字说明
MANIFEST(写入方)'2026-Q1/na\u00efve_ledger.txt'ï=U+00EF,UTF-8 字节 c3 af
磁盘 / 内核字节 ...6e 61 c3 af 76...字节本来是对的,文件系统没毛病
legacy 进程(读回)'na\udcc3\udcafve_ledger.txt'c3 af 用 ascii 解不了 → surrogateescape 映射成 U+DCC3 U+DCAF

三连击的因果:

  1. 写不进:open(str_path) → os.fsencode() 用 ascii 编 \xef → UnicodeEncodeError;
  2. 读不回 / 账对不上:os.listdir(str) 用 ascii 解码失败 → surrogateescape 把 c3 af 解成两个孤立代理码位 → 得到的 str != MANIFEST 里的 str, 于是「按清单名字找文件」直接失败;
  3. 报文发不出去:含孤立代理码位的 str,json.dumps(ensure_ascii=False) 要按 UTF-8 编码 → surrogates not allowed。 ensure_ascii=True 能「成功」,但那只是把代理码位写成 \udcc3\udcaf, 消费端解出来还是坏字符串 —— 损坏被 JSON 忠实地保存了,问题没解决。

为什么 Python 3.11 上复现不出来?

Python 3.7+ 启动时有两个机制介入:

  • PEP 538(locale coercion):发现 LC_CTYPE 是 C/POSIX 时, 尝试把 codeset 强制改成 UTF-8(找 C.UTF-8 → C.utf8 → en_US.UTF-8);
  • PEP 540(UTF-8 mode):-X utf8 / PYTHONUTF8=1 让 fsenc 恒为 utf-8; 在 C/POSIX locale 下会被自动打开。

这就是「测试环境好好的,上了老机器就炸」的全部机制。 把两个开关显式关掉(PYTHONUTF8=0 PYTHONCOERCECLOCALE=0), Python 3.11 立刻退化成 fsenc=ascii,bug 原样复现。


四、解决办法

核心原则:在数据通路边界做显式 encode("utf-8") / decode("utf-8"), 不要去改别人给的 locale。

4.1 为什么「改 locale」是假修复

把 ops/run_legacy.sh 里的 export LC_ALL=C 改成 LC_ALL=C.UTF-8 确实能让 run_legacy 不报错,但:

  1. entrypoint.sh 里那份 LC_ALL=C 还在(生产环境你改不了别人家的 supervisor 配置), docker compose restart 之后问题原样回来;
  2. 验收脚本自己是显式 LC_ALL=C 跑的,照样 FAIL=6;
  3. 本质是「把炸弹搬到别的地方去炸」——换台机器、换条调用路径还会响。

所以判据必须是与 locale 无关的按字节断言。

4.2 修改 data_tools/nonascii_store.py

ROOT_B = ROOT.encode("utf-8")

def to_bytes(name):
    """用 UTF-8 显式编码,不用 os.fsencode()(fsenc 在 legacy 环境是 ascii)。"""
    return name if isinstance(name, bytes) else name.encode("utf-8")

def _check_inside(rel_bytes):
    norm = os.path.normpath(rel_bytes)
    if norm.startswith(b"..") or norm.startswith(b"/"):
        raise ValueError("name escapes raw_data root: %r" % norm)
    return norm

def _safe_open_b(rel_bytes, flags, mode=0o644):
    rel = _check_inside(rel_bytes)
    parts = [p for p in rel.split(b"/") if p not in (b"", b".")]
    nofollow = getattr(os, "O_NOFOLLOW", 0)
    cur = os.open(ROOT_B, os.O_RDONLY)
    try:
        for part in parts[:-1]:                       # 逐级 dir_fd 递进
            try:
                nxt = os.open(part, os.O_RDONLY | nofollow, dir_fd=cur)
            except OSError as exc:
                if exc.errno != 2:                    # ENOENT -> 创建
                    raise
                os.mkdir(part, 0o755, dir_fd=cur)
                nxt = os.open(part, os.O_RDONLY | nofollow, dir_fd=cur)
            os.close(cur); cur = nxt
        fd = os.open(parts[-1], flags | nofollow, mode, dir_fd=cur)
        return fd, cur
    except Exception:
        os.close(cur); raise

def list_names():
    """os.walk 传 bytes 根 -> 裸字节名 -> 显式 UTF-8 解码,绕开 surrogateescape。"""
    names = []
    for dirpath, _dirnames, filenames in os.walk(ROOT_B):
        for fn in filenames:
            rel = os.path.relpath(os.path.join(dirpath, fn), ROOT_B)
            names.append(rel.decode("utf-8"))
    return sorted(names)

要点:

  • 名字统一按 UTF-8 转换,不用 os.fsencode()(它走 fsenc,ASCII 环境下直接炸);
  • os.walk 传 bytes 根目录,拿到的是裸字节,不做解码就不会被 surrogateescape 破坏;
  • O_NOFOLLOW 只作用于路径的最后一级,所以必须从 ROOT 开始 一级一级用 dir_fd 递进,中间层级的 symlink 才会被内核拦下;
  • 同时保留 normpath 前缀校验,拒绝 .. 和绝对路径。

4.3 修改 app/report_pipeline.py

def _md5_of(name):
    return hashlib.md5(store.read_bytes(name)).hexdigest()   # 走 bytes 通道

def _list_raw_data():
    entries = []
    for rel in store.list_names():        # 已经是干净 UTF-8 str
        try:
            digest = hashlib.md5(store.read_bytes(rel)).hexdigest()
        except Exception:
            digest = None
        entries.append((rel, digest))
    return sorted(entries)

五、验证

docker compose down -v && docker compose up -d --build
docker compose exec legacy_report bash /srv/ops/run_legacy.sh    # seed,EXIT=0
docker compose exec legacy_report bash /srv/ops/verify.sh
==================== env ====================
  fsenc     : ascii                 <-- 仍然是 ascii,没换 locale
  LANG/LC_ALL: C / C
==================== A. ====================
  PASS  A1 manifest name reaches the disk (kernel view)
  PASS  A2 on-disk byte sequence equals manifest UTF-8 bytes
==================== B. ====================
  PASS  B[ledger] md5 matches the pipeline declaration (149e390d7430)
  PASS  B[export] md5 matches the pipeline declaration (a18d96cafa1d)
==================== C. ====================
  PASS  C1 report is json-serialisable (630 bytes)
  PASS  C2 pipeline self-check clean (errors == {})
==================== D. ====================
  PASS  D1 every manifest name exists on disk (byte-exact)
  PASS  D2 no unexpected files on disk
==================================================
PASS=8 FAIL=0

报表接口侧:

docker compose exec legacy_report bash /srv/ops/export_report.sh
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:18000/report  # HTTP 200
docker compose exec legacy_report python3 /srv/ops/verify_report.py
# PASS=3 FAIL=0

报文里 ï 完整:

"ledger": "2026-Q1/naïve_ledger.txt",     <- 不再是 \udcc3\udcaf
"errors": {}

安全边界没被放宽(重要)

docker compose exec legacy_report bash -c '
ln -sf /etc/shadow /srv/raw_data/2026-Q1/evil_l3
python3 -c "
import sys; sys.path.insert(0,\"/srv/data_tools\")
import nonascii_store as store
try: store.read_bytes(\"2026-Q1/evil_l3\")
except Exception as e: print(type(e).__name__, e)"
'
# OSError: [Errno 40] Too many levels of symbolic links: b'evil_l3'

修编码 bug 时顺手放开 symlink 跟随是常见误伤: raw_data 是可写目录,一个 ln -s /etc/shadow raw_data/evil 就能读宿主机文件。


六、经验与避坑

6.1 排查思路(可复用)

  1. 先问一句 sys.getfilesystemencoding() 是什么 —— 别看文件名显示得对不对。
  2. LC_ALL=C + Python ≤ 3.6 = fsenc=ascii 的高危组合(CentOS 7 默认就这样)。
  3. 别信 ls 的输出:终端显示的 naïve_ledger.txt 可能是 'na\udcc3\udcafve_ledger.txt',== 是 False。 判定必须 encode("utf-8") 后比字节。
  4. os.listdir(str) 与 os.listdir(bytes) 会给出两个不同答案, 排查时优先用 bytes 视角当「基准真值」。
  5. json.dumps(ensure_ascii=True) 「成功」不代表对 —— 它可能只是把 非法代理码位转义写进了 JSON,消费端解出来还是坏的。

6.2 线上 5 分钟只读自查法

在已经在跑的服务上判断有没有这个毛病,全部只读:

# ① 这个进程的 fsenc(一条命令定生死)
docker exec <ctr> python3 -c \
  'import sys,locale;print(sys.version.split()[0], sys.getfilesystemencoding(), locale.getpreferredencoding(False), getattr(sys.flags,"utf8_mode","N/A"))'

# ② locale 环境
docker exec <ctr> sh -c 'echo "LANG=$LANG LC_ALL=$LC_ALL LC_CTYPE=$LC_CTYPE"'

# ③ 让进程自己证明「名字编得回去」
docker exec <ctr> python3 -c '
import os
d="/srv/raw_data"
for n in sorted(os.listdir(d))[:5]:
    bad=[hex(ord(c)) for c in n if 0xDC80<=ord(c)<=0xDCFF]
    print(repr(n), "SURROGATE!" if bad else "ok")
'
# 出现 U+DC80..U+DCFF 就是中招了

# ④ 按字节核对「清单 vs 磁盘」(不依赖 locale 的判定)
docker exec <ctr> python3 -c '
import os
want={b"2026-Q1/na\xc3\xafve_ledger.txt"}
have={os.path.relpath(os.path.join(dp,f),b"/srv/raw_data") for dp,_,fs in os.walk(b"/srv/raw_data") for f in fs}
print("missing:", sorted(want-have), "extra:", sorted(have-want))
'

6.3 避坑:Docker named volume 盖住代码目录(本轮最大的干扰项)

改完代码后 docker compose up -d --build 看起来构建成功, 容器里跑的还是老代码,报的还是老 traceback、连行号都对不上 (nonascii_store.py:23,但新文件早就没那行了)。

证据:

docker run --rm 3day-utf8-filename:latest wc -c /srv/data_tools/nonascii_store.py
# 6414        <- 镜像里是新代码
docker compose exec legacy_report wc -c /srv/data_tools/nonascii_store.py
# 747         <- 容器里还是老代码

原因:docker-compose.yml 里把 named volume 挂在 /srv:

volumes:
  - report_data:/srv

第一次 up 时,Docker 把镜像里的 /srv 内容复制进了这个 named volume。 之后每次重建容器,volume 都会重新挂回 /srv, 镜像里新的 /srv/app、/srv/data_tools 永远被这份旧快照遮住。 --build、--force-recreate、--no-cache 全都没用,因为问题不在构建。

解决:

docker compose down -v      # ★ -v 连 named volume 一起删
docker compose up -d

教训:只要代码目录被 named volume 盖住,改代码就永远「不生效」, 且现象是「老报错、老行号」,非常像 build cache 没失效,极易误判。 一句排查口令:docker compose exec <svc> wc -c <文件> 和镜像内的对比。

6.4 避坑:验收脚本本身可能不自带 seed

只读断言脚本不会生成数据。冷启动(空 volume)下直接跑验收, 会得到和故障期一样的数字,容易误判成「没修好」。 先 run_legacy.sh seed,再 verify.sh 验收。

6.5 避坑:docker compose exec 不继承 entrypoint 的 locale

# 错:exec 直接跑 python,会看到 fsenc=utf-8,然后以为「没问题」
docker compose exec legacy_report python3 /srv/ops/demo_surrogate_trap.py
# 对:走 .sh 包装(脚本内部 export LC_ALL=C)
docker compose exec legacy_report bash /srv/ops/demo_surrogate_trap.sh

docker compose exec 直接跑命令时走容器默认环境(可能是 LANG=C.UTF-8), fsenc 是 utf-8,surrogateescape 根本不会触发—— 这会让你白白排查半天。

6.6 长期建议

  • 新代码一律走 bytes 或显式 UTF-8:os.walk(bytes_root)、os.open(..., dir_fd=);
  • 若必须在 legacy 进程里跑,别改别人的 locale,改自己的数据通路;
  • 用「按字节比对」的断言(A2/B/D)而不是「看着一样」的断言, 这样验收结果与 locale 无关、可判定、可复现;
  • 涉及可写共享目录时,O_NOFOLLOW 要逐级 dir_fd 才能覆盖中间路径, 单独给最终路径加 O_NOFOLLOW 是不够的。

七、完整复现命令(照抄即可)

git fetch origin && git checkout 2026-09-17-0000
cd scenario/2026-09-17-0000

docker compose down -v          # ★ 必须带 -v
docker compose up -d --build

# 故障期
docker compose exec legacy_report bash /srv/ops/demo_new_env.sh
docker compose exec legacy_report bash /srv/ops/run_legacy.sh          # UnicodeEncodeError
docker compose exec legacy_report bash /srv/ops/explain.sh
docker compose exec legacy_report bash /srv/ops/demo_surrogate_trap.sh # \udcc3\udcaf
docker compose exec legacy_report bash /srv/ops/run_legacy.sh
docker compose exec legacy_report bash /srv/ops/verify.sh              # PASS=2 FAIL=6
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:18000/report   # 503

# 修复后
docker compose down -v && docker compose up -d --build
docker compose exec legacy_report bash /srv/ops/run_legacy.sh          # EXIT=0
docker compose exec legacy_report bash /srv/ops/verify.sh              # PASS=8 FAIL=0
docker compose exec legacy_report bash /srv/ops/export_report.sh
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:18000/report   # 200
docker compose exec legacy_report python3 /srv/ops/verify_report.py    # PASS=3 FAIL=0

一句话总结:同一个文件,在清单里、在磁盘上、在 legacy 进程眼里, 有三个不同的「名字」。 别再重启 exporter 了, 先问一句:「这个进程的 sys.getfilesystemencoding() 是什么?」


八、质检修复轮次:验收脚本注释与断言数对不上(fix_round 1)

8.1 现象:注释写 0/7 -> 7/0,其余文档写 2/6 -> 8/0

质检(status=fix)只挑出一个文档一致性问题: ops/verify.sh 头部第 4-5 行注释写的是

# 故障期预期:PASS=0 FAIL=7(exit 1)
# 修复后预期:PASS=7 FAIL=0(exit 0)

而 README、ops/hint_verify_expectation.md、SHAKE_DOWN.md、SOLUTION.md 四处统一写的是 2/6 -> 8/0。

8.2 根因:验收脚本自身有 8 条断言,不是 7 条

数断言最容易被忽略的一点:verify.sh 的 PASS/FAIL 不只在脚本正文里, 它还会把子脚本写进 /tmp/_verify_result.txt 的行数捞回来累加:

python3 /srv/ops/_verify_b.py
n=$(grep -c '^PASS' "$RESULT_FILE"); PASS=$((PASS + n))
n=$(grep -c '^FAIL' "$RESULT_FILE"); FAIL=$((FAIL + n))

所以真实断言清单是:

来源断言
verify.sh 内联A1、A2
_verify_b.pyBledger、Bexport
_verify_c.pyC1、C2
_verify_d.pyD1、D2

合计 8 条。那个「7」既不等于 8,也不对应任何真实状态,是纯笔误。

8.3 排查这类「注释/文档与脚本对不上」的通用三步

# ① 把脚本里所有断言点列出来(含被调用的子脚本)
grep -rn 'pass \"\|fail \"\|PASS  \|FAIL  ' ops/verify.sh ops/_verify_*.py

# ② 全仓库搜所有出现过的数字组合,看谁跟谁不一致
grep -rn "PASS=[0-9] FAIL=[0-9]\|2/6\|8/0\|0/7\|7/0" README.md *_DONE.md SOLUTION.md ops/

# ③ 实测两个状态,用真实输出反推「正确数字」应是多少
#    故障期:PASS=2 FAIL=6 / exit 1    修复后:PASS=8 FAIL=0 / exit 0

心法:文档里的期望值,必须能被脚本的实际输出复现。 不要靠数 if/else 猜,跑一遍最省事。

8.4 没有 docker CLI 时,怎么把 fsenc 退化成 ascii 来实测

本题正式环境是 python:3.6-slim + LC_ALL=C。 如果手上只有 Python 3.11(PEP 538/540 默认会把 CJK locale 自动矫正成 UTF-8), 可以用三个开关把它「打回原形」:

env LC_ALL=C LANG=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0 python3 -c \
  'import sys,locale;print(sys.getfilesystemencoding(), locale.getpreferredencoding(False))'
# ascii ANSI_X3.4-1968

PYTHONUTF8=0 关掉 UTF-8 模式,PYTHONCOERCECLOCALE=0 关掉 locale 自动矫正, 两个一起才压得住——只设 LC_ALL=C 在 Python 3.7+ 上是不够的。

实测结果与修正后的注释逐字吻合:

故障期:PASS=2 FAIL=6   exit 1
修复后:PASS=8 FAIL=0   exit 0

8.5 一句话教训

改代码要跑测试,改注释也要跑测试。 注释里的期望值是一种「文档契约」,写错了同样会误导后来人—— 0/7 这种数字会让人以为「脚本一共 7 条断言、故障期一条都不绿」, 反推不出真实的 8 条断言数,排查时会先怀疑脚本本身。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-17-0000-utf-8fsencasciilegacy

分享这篇文章