随机密码生成器这种东西说实话实现起来并没什么高深算法但真正到了要部署、要稳定跑起来、要让团队成员顺手用的时候坑还挺多。最近我把一套随机密码生成器做了Docker化部署作为内部小工具挂在服务器上整个过程走下来有一些值得记录的经验。如果你也想自己搭一个不依赖外部服务、完全可控的密码生成器这篇实践笔记应该能帮你省不少时间。这篇东西适合几类人看一是正在学习Docker部署的开发者想找一个麻雀虽小但五脏俱全的练手项目二是运维或全栈工程师需要快速给团队提供一个内部密码生成服务三是单纯对自己手上密码管理不放心、想自己写个工具又不想装一堆环境依赖的人。无论你是哪种我都会从需求设计讲到容器部署再讲到常见的坑尽量把每一步为什么这么做讲清楚。1. 需求分析与整体设计思路1.1 为什么非要自建一个密码生成器很多人第一反应是在线搜一个随机密码生成器不就行了我最初也是这么想的但仔细想有几个问题。第一隐私不可控。你生成的密码会经过别人的服务器虽然大多数正规工具声称不记录但作为内部安全工具把密码材料交给第三方本身就是个隐患。第二质量参差不齐。不少在线工具用的是前端Math.random()这个函数并不是密码学安全的随机源用它生成的密码理论上是可以被预测的这和“随机密码”的初衷背道而驰。第三网络依赖。内网环境、断网情况下就用不了。所以自建一个密码生成器核心诉求是三个安全、可控、好用。安全指的是随机源必须密码学安全可控指的是字符集、长度、规则都能自己定义好用指的是有简洁的界面或者API团队成员能一条命令或者打开网页就拿到结果。1.2 为什么选择Docker化部署我一开始是在宿主机上直接跑后来发现一个问题迁移麻烦。换服务器、给同事本地部署都需要重新配Python环境、装依赖、处理端口冲突烦得很。Docker化之后构建一次镜像任何装了Docker的机器都能跑环境完全一致。这个场景下Docker的好处很典型环境隔离不污染宿主机版本一致我在本地跑什么样服务器上就跑什么样部署简单一条docker run或者docker compose up -d搞定资源开销低一个轻量Python服务撑起来也就几十兆内存完全够用。对于这种内部小工具Docker化的收益非常直接这和我后来部署其他工具的思路一致凡是独立服务先Docker化再说。1.3 技术方案选型Python Flask还是其他技术选型上我纠结过一阵。Python Flask足够简单生态成熟而且Python标准库里的secrets模块天生就是做密码学安全随机数用的用起来非常顺。Node.js Express也能做但写起来比Python啰嗦一点且随机数方面还得找库。Go性能好、部署出来是单二进制但开发速度慢一些对于这种小工具没必要。最终选了Python 3.11 Flask原因有三开发效率高几百行代码就能搞定前后端secrets模块直接提供密码学安全的随机选择Web框架轻量构建镜像时依赖也不大。生产环境的WSGI服务器用的是gunicorn而不是Flask自带的开发服务器。这一点后面讲Dockerfile的时候会细说。2. 密码生成核心实现与细节拆解2.1 随机数安全random和secrets的区别这是整个项目里最不该省的一步。Python里最简单的random.choice()用的是梅森旋转算法这是伪随机数生成器PRNG它的随机性主要面向Monte Carlo模拟、非安全采样这类场景并不保证对抗恶意预测。如果有人拿到足够多的输出样本理论上就有能力推断后续序列。生成密码这种场景恰恰面对的就是这种攻击模型。正确的做法是用secrets模块。secrets内部基于os.urandom()它从操作系统的熵源在Linux上是/dev/urandom获取随机字节属于密码学安全伪随机数生成器CSPRNG。简单理解random的随机是“看起来乱”secrets的随机是“不可预测地乱”。对密码生成器来说“不可预测”是底线。这个区别值得放到任何密码工具的代码评审里反复强调。我之前见过有人用random.shuffle打乱字符顺序来“增强随机性”其实是要害因为打乱本身的随机源就不是安全的。2.2 字符集设计与密码规则字符集的选择直接影响密码可用性和强度。我默认的字符集是string.ascii_letters string.digits string.punctuation覆盖大小写字母、数字和常见特殊符号。但这里有个细节特殊字符里有像、、、$、\这种在Shell或者JSON里需要转义的字符如果生成的密码要用于命令行密码重置、脚本传参这些字符会让拷贝和使用变得非常痛苦。所以我增加了排除字符功能。默认排除\\;这几个易出问题的符号同时提供exclude参数让用户自定义排除。另外为了降低人工传递密码时看错的可能还可以加一个易混淆字符排除选项把小写l、大写I、数字1、大写O、数字0 都去掉代价是字符集缩小密码熵会略降但换来的是可读性适合需要人工抄写的场景。规则层面默认要求至少包含大写、小写、数字、符号四类中的每一类避免出现纯小写字母这种弱口令。这个约束的实现方式是先从每个启用的字符池里各取一个字符再用安全随机数填充剩余位数最后用Fisher-Yates洗牌算法打乱顺序。注意洗牌时也要用secrets.randbelow不能走random.shuffle否则安全随机源就在最后一步被降级了。2.3 密码强度估算与熵计算怎么判断一个密码“够强”一个可量化的指标是熵单位是比特。计算公式很简单熵 长度 × log2(字符集大小)比如字符集大小为26 26 10 32 94时16位密码的熵是16 × log2(94) ≈ 16 × 6.55 ≈ 104.8 bits。这个水平在当下算挺充裕的作为参考普通的在线服务密码建议至少达到60到70比特用于加密密钥则建议更高。所以我在API里返回entropy_bits字段调用方可以直观看到生成密码的强度。这里面有个有意思的点2位长度的密码如果字符集只有数字熵大约是2 × 3.32 ≈ 6.6比特区分一下就知道弱在哪了。这个计算也方便之后做策略比如要求密码强度必须达到80比特不满足的请求直接拒绝。2.4 API接口设计为了让Web界面、命令行、脚本都能调用我设计了一个简单的REST接口GET/generate参数如下参数含义默认值length密码长度16lower包含小写字母trueupper包含大写字母truedigits包含数字truesymbols包含特殊符号trueexclude排除字符列表\\;count一次性生成数量1返回JSON格式{ password: k83Bd9$vRm2#zsQ, length: 16, entropy_bits: 104.8 }count参数支持一次生成多个密码方便批量创建账号时使用。接口统一返回JSON错误场景返回{error: ...}和合适的HTTP状态码比如length非法就返回400。2.5 关键代码实现生成密码的核心逻辑我写成独立模块方便单测和复用。这里贴出关键部分import secrets import string DEFAULT_CHAR_POOLS { lower: string.ascii_lowercase, upper: string.ascii_uppercase, digits: string.digits, symbols: string.punctuation, } def build_alphabet(use_lowerTrue, use_upperTrue, use_digitsTrue, use_symbolsTrue, exclude_chars): pools [] if use_lower: pools.append(DEFAULT_CHAR_POOLS[lower]) if use_upper: pools.append(DEFAULT_CHAR_POOLS[upper]) if use_digits: pools.append(DEFAULT_CHAR_POOLS[digits]) if use_symbols: pools.append(DEFAULT_CHAR_POOLS[symbols]) alphabet .join(pools) if exclude_chars: alphabet .join(c for c in alphabet if c not in exclude_chars) return alphabet def secure_shuffle(items): Fisher-Yates洗牌全程使用密码学安全随机数 for i in range(len(items) - 1, 0, -1): j secrets.randbelow(i 1) items[i], items[j] items[j], items[i] def generate_password(length16, use_lowerTrue, use_upperTrue, use_digitsTrue, use_symbolsTrue, exclude_chars\\\;, ensure_each_typeTrue): if length 1: raise ValueError(length 必须大于等于 1) alphabet build_alphabet(use_lower, use_upper, use_digits, use_symbols, exclude_chars) if not alphabet: raise ValueError(字符集不能为空) if ensure_each_type: chars [] pools [ DEFAULT_CHAR_POOLS[lower] if use_lower else , DEFAULT_CHAR_POOLS[upper] if use_upper else , DEFAULT_CHAR_POOLS[digits] if use_digits else , DEFAULT_CHAR_POOLS[symbols] if use_symbols else , ] pools [p for p in pools if p] # 从每个启用的字符池里各取一个然后过滤掉排除字符 for p in pools: candidate secrets.choice(p) while candidate in exclude_chars: candidate secrets.choice(p) chars.append(candidate) else: chars [] fill_count length - len(chars) for _ in range(fill_count): chars.append(secrets.choice(alphabet)) secure_shuffle(chars) return .join(chars)这段代码里有一个容易忽略细节while candidate in exclude_chars这个循环。如果不做这一步当排除了某些特殊字符时secrets.choice(p)仍可能选中被排除字符最终结果里还是会出现。这个过滤虽然简单但实际操作中非常容易漏我第一版就没加结果exclude参数在“至少包含每类字符”模式下完全失效。2.6 HTTP层的实现Flask的接口层非常薄from flask import Flask, request, jsonify from password_gen import generate_password app Flask(__name__) def entropy_bits(length, alphabet_size): import math if alphabet_size 0: return 0.0 return round(length * math.log2(alphabet_size), 2) app.route(/generate, methods[GET]) def api_generate(): try: length int(request.args.get(length, 16)) count int(request.args.get(count, 1)) if count 1 or count 20: raise ValueError(count 必须在 1 到 20 之间) exclude_chars request.args.get(exclude, \\\;) use_lower request.args.get(lower, true).lower() ! false use_upper request.args.get(upper, true).lower() ! false use_digits request.args.get(digits, true).lower() ! false use_symbols request.args.get(symbols, true).lower() ! false passwords [] for _ in range(count): pwd generate_password( lengthlength, use_loweruse_lower, use_upperuse_upper, use_digitsuse_digits, use_symbolsuse_symbols, exclude_charsexclude_chars, ) passwords.append(pwd) # 计算熵时使用当前请求实际生效的字符集大小 from password_gen import build_alphabet alphabet build_alphabet(use_lower, use_upper, use_digits, use_symbols, exclude_chars) return jsonify({ passwords: passwords, length: length, entropy_bits: entropy_bits(length, len(alphabet)) }) except ValueError as e: return jsonify({error: str(e)}), 400 except Exception: return jsonify({error: 服务内部错误}), 500这里有个细节entropy_bits需要按实际排除后剩余的字符集算而不是直接按94个字符算。因为exclude参数可能去掉了部分特殊字符。这个看起来是小事但如果调用方太依赖返回值做安全审计熵算错会导致强度误判。3. Docker化部署完整实操3.1 Dockerfile的编写思路Dockerfile是整个部署过程的核心。我见过很多部署教程把Dockerfile写得只剩三步装依赖、拷贝代码、启动。但对于要长期运行的服务这样远远不够至少要考虑镜像体积、容器用户权限、健康检查、缓存策略。我的Dockerfile长这样FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 \ PIP_NO_CACHE_DIR1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . COPY password_gen.py . COPY templates/ ./templates/ RUN useradd --create-home --shell /usr/sbin/nologin appuser USER appuser EXPOSE 8000 HEALTHCHECK --interval30s --timeout3s --start-period10s --retries3 \ CMD python -c import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/health, timeout2) || exit 1 CMD [gunicorn, --workers, 2, --bind, 0.0.0.0:8000, app:app]有几个地方值得解释一下。基础镜像选python:3.11-slim而不是alpine。Alpine镜像确实更小但有个我踩过的坑某些Python依赖在Alpine上需要编译因为Alpine用musl而不是glibc很多wheel包不兼容安装时间会暴涨甚至直接失败。slim是基于Debian的精简版保留了glibc兼容性好体积虽然比Alpine大一点但也就一百多兆换来的是省心。PYTHONDONTWRITEBYTECODE这个环境变量很重要。Python默认会把.pyc字节码文件写到__pycache__目录。如果不关掉后面如果设置read_only: true文件系统容器运行时会尝试写入而报错。用户权限方面USER appuser强制使用非root账号运行。这属于容器安全的基本操作。攻击者就算通过应用漏洞拿到了shell权限也只是普通用户而不是root拆弹难度完全不同。这个习惯要养成别图省事直接用默认的root跑。3.2 健康检查的设计很多内部小工具的容器部署容易忽略健康检查我这次专门做了。HEALTHCHECK指令让Docker定期执行一个命令判断容器是否存活。这里有个细节slim镜像里没有curl装curl又会增加体积和依赖所以我直接用Python标准库urllib.request发起HTTP请求到/health端点。反正容器里本来就有Python完全不引入额外依赖。健康检查命令里还设了--start-period10s。这个参数的意思是给应用一个启动宽限期在10秒内即使检查失败也不算容器不健康。Python启动Flask或者gunicorn通常很快但首次冷启动时加载镜像层、初始化工作目录还需要一点时间不设置这个参数容易造成误判。对应的健康检查端点要在应用里实现app.route(/health, methods[GET]) def health(): return jsonify({status: ok})这个接口不参与任何业务逻辑只返回固定内容确保整个服务生命周期内能被可靠探测。3.3 docker-compose编排虽然docker run也能跑但为了配置可维护性我还是写了docker-compose.ymlservices: password-gen: build: . image: password-gen:latest container_name: password-gen ports: - 8080:8000 restart: unless-stopped read_only: true tmpfs: - /tmp environment: - DEFAULT_LENGTH16 - MAX_COUNT20 healthcheck: test: [CMD, python, -c, import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/health, timeout2)] interval: 30s timeout: 3s retries: 3 start_period: 10sread_only: true配合tmpfs: [/tmp]是一个比较硬的配置。rootfs只读之后容器内任何进程都不能在文件系统上写文件攻击面变小了。但Python运行时可能需要临时目录比如gunicorn的临时socket、Python的临时文件所以把/tmp挂成临时内存文件系统既保留了可写空间又保证重启后清空。这是容器安全加固里一种相当实用的组合。restart: unless-stopped保证服务器重启或者容器异常退出后能自动拉起。内部工具没人盯着这个策略非常重要不然半夜服务挂了第二天才发现就失去了服务化部署的意义。3.4 构建与启动执行部署非常简单docker compose build docker compose up -d然后验证一下curl http://127.0.0.1:8080/generate?length20count3正常情况下返回三组密码每组20位。这里要提一个实际部署时容易被忽略的细节端口映射时宿主机端口和容器内端口是两个概念。我在容器内用8000gunicorn监听8000宿主机映射到8080。如果宿主机8080被占了改ports里左侧就行容器的代码和配置不用动。这种解耦正是容器化的一个优势。4. Web界面与体验优化4.1 极简前端不引入框架API服务跑起来后团队里有人觉得用curl还是不够直观所以我顺手写了一个极简的Web界面。设计原则是不引入前端框架、零构建步骤、单HTML文件。这样容器里不需要额外的静态文件服务gunicorn直接返回HTML内容即可。页面包含长度滑块、四个字符集勾选框、排除字符输入框、生成按钮、结果展示区和一键复制按钮。实现上用原生的fetch调用自己的APIasync function generatePassword() { const length document.getElementById(length).value; const lower document.getElementById(lower).checked; const upper document.getElementById(upper).checked; const digits document.getElementById(digits).checked; const symbols document.getElementById(symbols).checked; const exclude document.getElementById(exclude).value; const params new URLSearchParams({ length: length, lower: lower, upper: upper, digits: digits, symbols: symbols, exclude: exclude }); const resp await fetch(/generate? params.toString()); const data await resp.json(); if (data.error) { document.getElementById(result).textContent 错误 data.error; } else { document.getElementById(result).textContent data.passwords.join(\n); document.getElementById(entropy).textContent 估算强度 data.entropy_bits bits; } } document.getElementById(copyBtn).addEventListener(click, async () { const text document.getElementById(result).textContent; await navigator.clipboard.writeText(text); });复制功能用了navigator.clipboard注意这个API要求页面在HTTPS或localhost环境下才能使用。如果部署在HTTP的内网IP上剪贴板权限可能会受限这时候就要降级到document.execCommand(copy)方案。我在实测中确实遇到过内网HTTP下复制失效的问题最后的解决办法是加了一个fallback。这个坑很典型属于“本地开发没问题一部署到内网就翻车”的类型。4.2 页面路由与Flask集成前端页面没做成单独静态文件服务而是由Flask直接渲染保证整个服务只有一个入口。在app.py里加一段from flask import render_template app.route(/, methods[GET]) def index(): return render_template(index.html)这样访问根路径就直接看到页面不需要额外安排Nginx之类的静态文件服务器。对于内部小工具来说保持架构简单比堆组件更重要。4.3 移动端适配团队里有人会在手机上临时用这个工具比如被要求“马上给我改个密码”。所以我顺手做了基本的响应式用flex布局配合max-width限制页面宽度输入框和按钮在小屏幕上也能完整展示。这一步很简单CSS里加几行媒体查询的事但能极大提升工具的实际使用率。不然手机上打开页面按钮超出屏宽基本就等于劝退。5. 实操中的常见问题与排查经验5.1 镜像构建失败Alpine的坑第一版Dockerfile我用的python:3.10-alpine结果构建时Flask依赖安装报错卡在某个C扩展的编译上。查了半天发现是musl libc导致某些wheel不起作用源码编译又缺gcc。最终换成python:3.11-slim安装秒过。经验总结对Python项目来说优先选slim镜像体积虽然比alpine大一点但省下的构建时间和心智负担是值得的。除非团队对镜像体积有极苛刻的要求否则别为了省几十兆去踩musl兼容性的坑。5.2 容器非root运行导致权限不足设置read_only: true之后第一次启动时gunicorn直接报错提示无法在工作目录写入临时文件。排查过程是这样的先看容器日志确认是[Errno 30] Read-only file system然后检查Dockerfile里的ENV PYTHONDONTWRITEBYTECODE1已经设了说明不是pycache写入问题。继续跟踪gunicorn的行为发现它会往当前目录创建__pycache__的几率不大问题是它默认会尝试写入socket文件到临时目录而我的临时目录没有事先声明。加上tmpfs: [/tmp]之后问题解决。这里的关键教训设置只读rootfs后凡是进程需要写文件的地方要么提前挂tmpfs要么把目录显式声明为可写。盲猜是浪费时间先看日志再动手才是正确的排障顺序。5.3 密码中特殊字符在Shell中难用第一版把全部string.punctuation都放进了字符集结果生成了一批包含和的密码同事在终端里复制粘贴被转义问题来回折腾体验极差。这个我之前在设计字符集时提过真正用起来才发现这不是小问题而是影响工具口碑的大问题。解决办法是修改默认值exclude_chars\\;把Shell、JSON、MySQL命令行里最容易出问题的字符全部排除。同时在前端页面加了一个“排除字符”输入框让使用者自己决定是否还要额外排除$ 这种在Shell里会被解析的字符。实测下来默认配置生成的密码在各种场景下“无脑复制”都没问题使用满意度大幅提升。5.4 gunicorn worker数量怎么定内部的密码生成服务属于I/O密集型的轻量应用worker数量不需要太多。我设置了两个worker进程实测并发请求处理没有瓶颈。需要注意的是gunicorn配--workers 2意味着会有两个Python进程同时跑如果后续在这个服务上扩展了需要共享内存的功能比如黏性会话就需要重新考虑架构。但对于无状态API多worker是天然合适的。5.5 常见问题速查表症状可能原因解决方案容器启动后立即退出端口被占用、环境变量配置错误查看docker logs检查端口冲突健康检查一直不通过应用启动慢、监听地址错误确保gunicorn绑定0.0.0.0提高start_period生成的密码包含不想要的字符排除字符参数没传或传入方式不对接口层默认排除特殊字符前端补充自定义排除复制按钮无响应内网HTTP环境不支持Clipboard API降级到execCommand(copy)镜像构建拉取依赖极慢网络原因在Dockerfile中配置国内镜像源或使用代理的构建环境修改代码后容器跑的还是旧逻辑未重新构建镜像必须执行docker compose build只restart不生效5.6 安全加固日志别碰密码最后一个安全细节。密码生成服务本身不存储密码这是设计上的一个原则。所以在实现日志时我只记录请求的IP、长度、字符集选项和耗时绝对不记录返回的密码明文。有人可能觉得记录一下方便排查问题但一旦日志泄露所有生成的密码就都暴露了。这个工具的核心价值是安全那就让安全在每一层都成立。部署公网访问时还有一个强烈建议不要裸奔HTTP。如果必须暴露到公网前置一层反向代理配置HTTPS证书。内网使用则无所谓只要做好访问控制即可。我自己使用的时候就只是把它放在内网服务器上通过Nginx做了一层简单的IP白名单过滤进一步限制了访问面。6. 扩展方向与个人体会这个项目目前已经稳定跑了挺长时间。实际体感是团队里需要临时密码、测试环境占位密码、演示环境的初始账号密码时大家会自然打开这个页面生成完直接复制走不再互相问“你上次密码是啥来着”。这比从技术角度讲更有成就感工具被用起来才叫工具。根据我的经验这个项目后续还可以做几个方向的扩展。加一个Kubernetes部署版本方便迁移到集群环境增加一个简单的认证令牌防止有人蹭服务再配上可自定义的密码策略模板比如“公司内部系统要求至少16位含特殊字符”这样团队成员选一个模板就能生成合规密码。另外也可以做一个/batch批量接口和账号生命周期管理流程集成这一点对需要成批创建测试账号的场景尤其有用。不过扩展之前先把手上的基础功能用稳、用透这比加一堆花哨功能更实在。一个只有几KB代码的密码生成器通过Docker化部署后变成一个随时可用的内部服务整个过程里学到的容器安全、镜像优化和接口设计经验比工具本身值钱多了。